Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions docs/doperpowers/specs/2026-08-19-task-grain-experiment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
# Task-Grain Experiment — controlled track, paired double-run

**Purpose.** The controlled track defines a task as the *smallest* unit worth a
fresh reviewer's gate (doperpowers:writing-plans, "Task Right-Sizing"). A
mid-size feature therefore cuts into 8–12 tasks, and the pipeline pays its
dominant fixed cost — worker re-orientation (executor reads brief and orients
in the codebase; reviewer orients in the diff; fixers re-orient again) — 20–30
times per feature. If a mid-tier executor can reliably own larger units, the
same feature ships with materially fewer dispatches and less wall-clock at
equal quality. Changing Task Right-Sizing is a behavior-shaping skill change;
this repo's own bar ("Skill Changes Require Evaluation") demands eval evidence
first. This experiment produces that evidence.

After this experiment one can observe: a metrics table comparing two complete
implementations of the same real feature — one under current doctrine, one
under a coarse-grain variant — a blinded comparative review verdict, and a
recorded adopt / reject / iterate decision in this spec's Decision Log.

## Hypotheses

- **H1 (grain).** Tasks sized as "the largest unit a mid-tier executor
reliably owns from one self-contained brief" preserve quality — no increase
in escaped defects, no executor-reliability red flags — while cutting total
dispatches and wall-clock substantially (expectation: ≥25%).
- **H2 (cadence, contingent).** If H1 fails specifically on *executor*
reliability, batching reviews into dependency-frontier waves at the current
fine grain captures most of the review-count savings without enlarging the
executor's unit. H2 is not run in this experiment; it becomes the follow-up
treatment only if H1 fails that way.

## Arms

**Arm B (baseline).** Current doctrine verbatim: doperpowers:writing-plans →
doperpowers:subagent-driven-execution, untouched.

**Arm V1 (coarse grain).** Identical doctrine with exactly one substitution:
the treatment text below replaces writing-plans' "Task Right-Sizing" section.
Everything else applies unchanged — No Placeholders (complete code in every
step), Interfaces blocks, bite-sized steps *inside* a task, codex adversarial
plan review, SDE's per-task two-stage review, and the final whole-branch codex
review. Worker model is held identical in both arms at current practice —
opus for executors/reviewers/fixers, per the standing human directive that
overrides the skill's sonnet default. This keeps the manipulation single-variable: only
the partitioning of the same plan content changes, so an outcome difference is
attributable to task grain, not to plan resolution or review machinery.

### V1 treatment text (copied verbatim into the variant session's instructions)

> **Task Right-Sizing (coarse-grain variant).** A task is the **largest** unit
> a mid-tier executor can reliably own from one self-contained brief — not the
> smallest reviewable one. Draw boundaries by:
>
> - **Interface frontiers.** Any interface consumed by a *different* task must
> be produced — and therefore reviewed — before the consuming task
> dispatches: place produced-then-consumed contracts at task edges, or keep
> producer and consumer inside the same task. Within-task interfaces are
> free.
> - **Reviewable diff.** Expected diff stays under ~500 changed lines per
> task; past that, single-pass review reliability drops — split.
> - **Ownability.** Everything in one task fits one executor's working
> context: closely related state owners, one coherent verification strategy,
> a brief needing nothing from neighbors beyond its declared Interfaces.
> - **Target.** Where the fine-grained doctrine would produce 8–12 tasks, aim
> for 3–5. Inside a task, organize the work as sequential deliverables, each
> keeping its full TDD step sequence (failing test → verify fail → implement
> → verify pass → commit).

Note the first-task calibration gate is automatic in V1: per-task review is
retained, so the first (larger) task is still fully reviewed before the second
dispatches — if the executor systematically misreads the coarser brief, it
surfaces at task 1.

## Feature under test

**Recommended: arkho#9** — paged envelope for `/queue/decisions` and
`/tickets` (currently read whole; contract pin filed during A2). Why it fits:

- Mid-size: estimated 8–10 tasks under current doctrine (envelope schema,
cursor semantics, two endpoints, ordering/limit guarantees, tests, spec
update, final verification) — squarely in the regime where SDE overhead
bites.
- Real backlog value: natural follow-on to the just-shipped read-surface epic;
the winning branch merges and ships.
- Pure server-side code with a real test suite (281 green) — behavior-testable,
no live side effects until merge (only the winner merges; merge = Render
deploy).
- Delegable: contract work whose ambiguity a grill can exhaust up front.

Alternates considered and rejected: arkho A3 mirror writers (too big for a
first paired run; external GitHub side effects), dp#63 board-transition guard
(too small; shell-heavy, weak fit for TDD-pipeline metrics).

## Protocol

**Phase 0 — setup (after human go).** Confirm the feature. Run the standard
grill + brainstorming once and write the feature spec (in the arkho repo, per
its conventions); freeze it at a commit. Create two arkho worktrees from the
same base commit, branches `pe-x` and `pe-y` (arm-neutral names). Extract the
V1 treatment text into an instruction file for the variant session.

**Phase 1 — plan + execute, in parallel.** One fresh session per arm; each
session authors its plan from the frozen spec and runs SDE to completion in
its own worktree, including the codex adversarial plan review and the final
whole-branch codex review (same model and effort settings in both arms).
Neither session is told another arm exists; neither reads the other's worktree
or branch. Record each arm's session ID(s) for telemetry.

**Phase 2 — measure.** Run `scripts/sde-telemetry` over both arms' session
transcripts. Tally review findings, fix loops, ⚠️ items, and executor statuses
from each arm's SDE workspace (ledger, reports). Collect both final-review
finding lists.

**Phase 3 — blinded comparative judgment.** A fresh frontier judge receives
the frozen feature spec plus two *squashed* diffs labeled X and Y (no commit
history — commit granularity leaks arm identity; no mention of the experiment
or of task counts). It returns, per branch: defects found (with severity), and
which branch better satisfies the spec, with reasoning.

**Phase 4 — decide and merge.** Apply the decision rule below; record the
outcome in this spec's Decision Log. The human merges the winning branch;
the losing branch is tagged and preserved for audit, not deleted.

## Metrics (pre-registered)

| Metric | Source |
|---|---|
| Active time (primary): sum of dispatch durations + controller turn time; span (secondary — includes idle gaps) | transcript timestamps (phase boundary = first executor dispatch) |
| Dispatches by role: executor / task-reviewer / fixer | `subagents/*.meta.json` descriptions |
| Tokens by model, controller vs workers | `usage` sums over main + subagent transcripts |
| Review findings by severity, per review | reviewer reports in the SDE workspace |
| Fix-loop iterations per task | ledger + fix reports |
| ⚠️ (cannot-verify-from-diff) items and their resolution cost | reviewer reports |
| Executor statuses (DONE_WITH_CONCERNS / BLOCKED / NEEDS_CONTEXT), model escalations | executor reports |
| Escaped defects: final whole-branch codex findings, severity-weighted | final review output |
| Comparative verdict | blinded judge (Phase 3) |
| Acceptance: feature spec's acceptance section passes | both branches; a failing branch = arm failure |

## Decision rule (pre-registered, before any run)

Adopt V1 — rewrite writing-plans' Task Right-Sizing, citing this spec as the
eval evidence — only if **all** hold:

1. **Quality non-inferior.** V1's escaped defects (Critical + Important, final
review ∪ blinded judge) ≤ Arm B's, and the judge attributes no V1 defect
class to under-review of oversized diffs.
2. **Cost materially better.** ≥25% reduction in total wall-clock **or** total
tokens (expectation: both), with dispatch count reduced roughly in
proportion to task count.
3. **Executor reliability holds.** No BLOCKED-on-reasoning-capacity
escalation; no task exceeding 2 fix-loop iterations.

Failure routing:

- Rule 3 fails → H2 follow-up: wave-cadence variant (V2) at current grain, on
the next comparable feature.
- Rule 1 fails via review-side misses (task reviewer missing what the final
review catches on large diffs) → retry V1 with the diff cap tightened to
~300 lines, or fall back to V2.
- Ambiguous margins → one more paired feature before touching doctrine.

**n=1 honesty.** The paired design controls the dominant confound (feature
difficulty) but not run-to-run variance; a single pair is directional
evidence, not proof. Adoption is therefore adopt-with-monitoring: the next
2–3 controlled-track features run the collector as standing telemetry, and a
quality regression reopens this decision.

## Confounds and mitigations

- **Feature difficulty** — controlled by the paired same-feature design.
- **Contamination** — fresh sessions per arm, parallel execution, no
cross-reads, isolated worktrees, shared frozen spec.
- **Judge bias** — blinding via squashed arm-neutral diffs; the judge never
learns the hypothesis.
- **Reviewer variance at the exit gate** — same codex model/effort for both
final reviews.
- **Plan-author variance** — same model, same spec; irreducible at n=1, noted.

## Acceptance

- `scripts/sde-telemetry <arm-B-session-jsonl> <arm-V1-session-jsonl>` prints,
for each session: wall-clock span, dispatch counts by role, and token totals
by model for controller and subagents. (Runnable today against any past
session transcript; verified against a real session during design.)
- Both arkho branches pass the feature spec's acceptance section as written.
- This spec's living tail contains the filled metrics table, the judge's
verdict, and a Decision Log entry recording adopt / reject / iterate.

## Decision Log

- Decision: paired same-feature double-run, over historical-baseline
comparison and synthetic eval-harness runs.
Rationale: historical baselines are confounded by feature difficulty — the
dominant variance source; the evals/ harness measures skill compliance, not
pipeline economics. Double-implementation cost accepted and bounded by
choosing a mid-size feature whose winner ships.
Date/Author: 2026-08-19 / fable session

- Decision: single-variable treatment — only task partitioning changes; plan
resolution (No Placeholders) and the SDE review loop stay untouched.
Rationale: bundling coarse grain with wave review or lower plan resolution
would make failure unattributable. Plan-resolution relaxation is a separate,
deeper question deferred until grain results exist.
Date/Author: 2026-08-19 / fable session

- Decision: V2 (wave cadence at fine grain) held as a contingent follow-up,
not a third arm.
Rationale: a 3-arm run costs 3× one feature; V1 already subsumes the
review-count economics (fewer tasks ⇒ fewer reviews), so V2 is informative
only if V1 fails on the executor side.
Date/Author: 2026-08-19 / fable session

- Decision: blinded comparative judge reads squashed diffs only.
Rationale: commit granularity reveals task structure and hence arm identity;
an unblinded judge can pattern-match "more tasks = more rigor."
Date/Author: 2026-08-19 / fable session

- Decision: feature = arkho#9 (paged envelope), pending human confirmation.
Rationale: mid-size, real backlog, pure server code with a real suite, no
side effects until merge. A3 (too big, external side effects) and dp#63
(too small, shell-heavy) rejected.
Date/Author: 2026-08-19 / fable session

- Decision: decision rule pre-registered before any run.
Rationale: prevents post-hoc rationalization of whichever arm feels better.
Date/Author: 2026-08-19 / fable session

## Surprises & Discoveries

- Observation: per-dispatch telemetry is fully recoverable post-hoc — each
subagent leaves `subagents/agent-*.jsonl` (per-message `usage` + `model` +
`timestamp`) beside a `.meta.json` carrying the dispatch description, so
dispatches classify into executor/reviewer/fixer by description string with
exact token attribution.
Evidence: probe on session `0c5f5b2b` (2026-08-19): one dispatch, 10 usage
turns, model claude-opus-5, 9,249 output tokens attributed.

- Observation: in a real 8-task SDE run (session `b380b6da`, 2026-08-01..05),
fixer dispatches (11) outnumbered executor dispatches (8) — the review-fix
loop, not execution, was the larger dispatch-count center, supporting the
re-orientation-overhead premise. Workers ran on opus, confirming practice
diverges from the skill's sonnet default.
Evidence: `scripts/sde-telemetry` output, 2026-08-19: 37 dispatches =
8 executor / 8 task-reviewer / 11 fixer / 10 other (a parallel panel wave).

## Outcomes & Retrospective

Pending — written at finish.

## Revision Notes

- 2026-08-19: Initial version — arms, treatment text, protocol, pre-registered
metrics and decision rule.
131 changes: 131 additions & 0 deletions scripts/sde-telemetry
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""sde-telemetry — post-hoc metrics for a controlled-track (SDE) run.

For each session transcript given, prints: wall-clock span, dispatch counts
by role, and token totals by model for the controller and its subagents.
Subagent transcripts are read from the sibling directory
<transcript-dir>/<session-id>/subagents/.

Usage: sde-telemetry SESSION_JSONL [SESSION_JSONL ...]
"""
import collections
import datetime
import glob
import json
import os
import re
import sys

ROLE_PATTERNS = [
(re.compile(r"implement task", re.I), "executor"),
(re.compile(r"review task", re.I), "task-reviewer"),
(re.compile(r"\bfix", re.I), "fixer"),
]
Comment on lines +19 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid classifying fixture work as fixer dispatches

The fixer pattern matches the prefix fix rather than a complete role word, so unrelated descriptions such as Inspect test fixtures or Investigate fixture data are counted as fixer dispatches. Test-oriented implementation sessions are likely to use such descriptions, and the experiment explicitly treats fixer count as evidence about review-loop overhead. Restrict this classifier to actual fixer wording instead of any word beginning with fix.

Useful? React with 👍 / 👎.



def role_of(description):
for pattern, role in ROLE_PATTERNS:
if pattern.search(description or ""):
return role
return "other"


def scan_transcript(path):
"""Return (token totals by model, first timestamp, last timestamp)."""
totals = collections.defaultdict(collections.Counter)
first = last = None
with open(path) as fh:
for line in fh:
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
ts = entry.get("timestamp")
if ts:
t = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
first = t if first is None else min(first, t)
last = t if last is None else max(last, t)
message = entry.get("message")
if isinstance(message, dict) and isinstance(message.get("usage"), dict):
usage = message["usage"]
Comment on lines +48 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include external Codex usage in total-token comparisons

This counts only Claude transcript message.usage, while the prescribed arm runs also include Codex adversarial plan review and the final Codex whole-branch review, which execute through the companion rather than as native subagents and thus do not appear in these message or sidecar totals. Since the decision rule permits adoption based on a 25% reduction in total tokens alone, omitting those potentially arm-dependent review tokens can produce a decision from partial costs; capture that external usage or explicitly exclude token totals from the decision rule.

Useful? React with 👍 / 👎.

model = message.get("model") or "unknown"
for key in (
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
):
totals[model][key] += usage.get(key) or 0
return totals, first, last


def merge(into, totals):
for model, counter in totals.items():
into[model].update(counter)


def fmt_totals(totals, indent=" "):
lines = []
for model in sorted(totals):
c = totals[model]
lines.append(
f"{indent}{model}: in={c['input_tokens']:,} out={c['output_tokens']:,} "
f"cache_read={c['cache_read_input_tokens']:,} "
f"cache_write={c['cache_creation_input_tokens']:,}"
)
return "\n".join(lines) or f"{indent}(none)"


def report(session_jsonl):
session_jsonl = os.path.abspath(session_jsonl)
session_id = os.path.splitext(os.path.basename(session_jsonl))[0]
print(f"=== session {session_id} ===")

controller_totals, first, last = scan_transcript(session_jsonl)
if first and last:
span = last - first
print(f"wall-clock: {first.isoformat()} -> {last.isoformat()} ({span})")
Comment on lines +84 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound wall-clock measurement to the execution phase

Each arm first authors its plan and only then starts SDE, but this computes the span from the first to last event in the entire controller transcript. Planning time and any idle time before the first executor dispatch therefore enter a metric whose pre-registered boundary is the first executor dispatch; because the adoption threshold is only 25%, those unrelated periods can determine the result. Locate the first executor dispatch and measure from that phase boundary instead.

Useful? React with 👍 / 👎.


subagent_dir = os.path.join(os.path.dirname(session_jsonl), session_id, "subagents")
role_counts = collections.Counter()
role_totals = collections.defaultdict(lambda: collections.defaultdict(collections.Counter))
dispatches = []
for meta_path in sorted(glob.glob(os.path.join(subagent_dir, "*.meta.json"))):
with open(meta_path) as fh:
meta = json.load(fh)
description = meta.get("description", "")
role = role_of(description)
role_counts[role] += 1
agent_jsonl = meta_path.replace(".meta.json", ".jsonl")
if os.path.exists(agent_jsonl):
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject incomplete subagent transcript sets

When a .meta.json survives but its corresponding agent transcript is missing—for example after copying only the session JSONL and metadata or after partial transcript retention—the dispatch is counted while its tokens and duration are silently omitted. A wholly missing sibling subagents directory similarly produces a plausible-looking zero-dispatch report. Because these incomplete totals feed the experiment's adoption threshold, fail or emit a prominent incomplete-data status instead of silently skipping the missing transcript.

Useful? React with 👍 / 👎.

totals, a_first, a_last = scan_transcript(agent_jsonl)
merge(role_totals[role], totals)
duration = (a_last - a_first) if a_first and a_last else None
dispatches.append((role, description, duration))
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Emit the pre-registered active-time total

The collector computes each subagent duration only for the dispatch list and never sums those durations or derives controller turn time. Consequently it cannot produce the experiment's primary metric, defined in the added spec as dispatch durations plus controller turn time; the only aggregate it prints is the explicitly secondary transcript span. Aggregate these durations with controller active time before using the run to make the adoption decision.

Useful? React with 👍 / 👎.


print(f"dispatches: {sum(role_counts.values())} total "
f"({', '.join(f'{r}={n}' for r, n in sorted(role_counts.items())) or 'none'})")
print("controller tokens:")
print(fmt_totals(controller_totals))
for role in sorted(role_totals):
print(f"{role} tokens ({role_counts[role]} dispatches):")
print(fmt_totals(role_totals[role]))
if dispatches:
print("dispatch list:")
for role, description, duration in dispatches:
dur = f"{duration}" if duration is not None else "?"
print(f" [{role}] {description} ({dur})")
print()


def main(argv):
if len(argv) < 2:
print(__doc__.strip(), file=sys.stderr)
return 2
for path in argv[1:]:
report(path)
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))