Recursive decomposition and the depth experiment¶
One agent in a loop cannot hold a whole application. That is the constraint the mechanism on this page answers: the binding limit on building software with agents is decomposition quality rather than agent supply, so work is split until each unit is one agent's worth, the leaves are built concurrently, each in its own workspace, and each level is assembled from the level below it. The fan-out is not there to make a run finish sooner.
A decomposition is therefore a tree rather than a list. DecompositionService
assesses every subtask it plans and decomposes again any that is more than one
agent's work, against a child context one level deeper. This page covers three
things: the recursion mechanism, the durable form the tree takes so an operator
can review it and a dispatcher can run it, and the harness that measures what
happens to the work as the tree deepens.
Scope
The Build Loop is authoritative on how work is planned and assembled. This page owns the recursion mechanism, the durable tree, and the depth experiment's evidence.
The build loop replaces whole-tree planning with slice planning: the next set of concurrently-runnable units is planned against the current trunk, with everything the previous slice learned. Depth is emergent there rather than configured, so the depth cap this page treats as the experiment's independent variable is a property of the harness, not a knob the designed loop exposes. It also replaces bottom-up assembly with continuous integration, which removes the fan-in cost the curves below are measuring.
The evidence on this page therefore remains valid as a measurement of the loop as built, and is not a measurement of the loop as designed.
Recursion ships ON, and the sweep below is weaker evidence than it looks: its depth-1 cells scored 0 of 42 in both arms against 36 of 42 at depth 2, but that recording judged a merge on whether a path the PLANNER named before the tree existed had changed, and most merges at every cap failed that test. The pilot therefore cannot separate what depth did from what the verdict did, and the replication is what will. What still stands from it is narrower and does not depend on the ranking: the harness planned and assembled a tree of 58 units with no replanning at all.
That last point is the thing to keep in view. A static hundred-item tree planned in one pass, at the moment of maximum ignorance, is waterfall applied recursively. The bounds below are runaway backstops rather than controllers: the size signal is the controller, and its feedback loop closes without an operator. Just-in-time replanning would be the execution-side half of that, and it is not built.
The question¶
Two bodies of published work bear on deep decomposition and they do not meet. ARIES measured aggregation deterioration as work is decomposed and recombined, with no verification at the joins. The Six Sigma multi-agent verification model argues that gating each join arrests exactly that decay, and measures nothing. No paper connects them.
What hangs on the answer is whether decomposition capacity is a property of one LEVEL or of the whole tree: whether verifying at every merge holds the survival rate flat as recursion deepens, so depth buys scale, or whether it does not, so the limit binds however deep the tree goes. That is what the sweep below exists to measure, and it is not settled. No figure for that limit is stated anywhere on this page in either direction, because none has been established.
Depth is counted in levels, on both sides¶
max_depth is a count of PLANNING LEVELS, not a count of edges.
RecursionBudget.has_room asks whether current_depth + 1 < max_depth, so a
node plans at current_depth 0 through max_depth - 1: a cap of three means
three levels of planning, the root plus two below it.
The harness reports the same unit, and the conversion is worth naming because
the raw signal disagrees with it: max_depth_reached is the deepest node's
zero-based INDEX, so a run that used its whole cap of three carries a 2, which
beside depth_cap=3 reads as a tree that stopped a level short.
evals/recursion_depth/tree.py::achieved_levels owns the conversion and is the
only place it happens. A leaf's own depth stays an index and is never
converted: nothing bins on it, since the curve buckets each run on the depth its
TREE reached.
For an experiment whose independent variable is depth this decides how the
x-axis is labelled, so it is stated once here: a cap of N fully used reports
N. The S2 review's "depth four" is max_depth=4.
The recursion point¶
DecompositionResult is a tree node. It carries its own depth and the
children of whichever of its subtasks were split again; both default to the
flat shape, so a tree that never recursed reads exactly like a flat plan.
Three derived views read the tree, and which one a caller wants is decided by
what it is asking. all_tasks and all_subtasks answer what the plan CONTAINS
(routing, the rollup, the park that needs a unit's declared role).
dispatch_subtasks answers what waits on what, and is the DAG a dispatcher
runs; a container appears in it, because a container that split is dispatched
as its own assembly task rather than
as the work below it, which would do that work twice. leaf_tasks names the
tasks nothing below them replaced, which is the leaf count the tree reports.
_do_decompose is the recursion point. After the DAG is validated and the
tasks exist, each subtask is assessed, and one that is oversized with budget
left is decomposed again against a child context carrying current_depth + 1.
That copy in engine/decomposition/_recursion.py is the only write of
current_depth in the tree.
Recursion is decomposed after the tasks are built rather than before, because a child level decomposes the task, not the definition: it inherits the project, the type and the delegation chain the same way any other dispatch would.
Levels are decomposed sequentially rather than fanned out. Each child level is itself a planning session against a provider, and a level of eight subtasks fanning out at every level turns one decomposition into a burst nothing here rate-limits.
The size signal¶
SubtaskAtomicityPolicy.assess decides, from the subtask's own declaration and
with no extra LLM call, whether a unit is one agent's worth of work. Three
conditions, any of which makes it oversized:
| Condition | Setting | Ships at | Role |
|---|---|---|---|
len(expected_artifacts) |
coordination.subtask_max_artifacts |
10 | Loose guard |
len(acceptance_criteria) |
coordination.subtask_max_criteria |
10 | Loose guard |
len(satisfies) |
fixed at 1 | 1 | The controller |
The third is the operative one and it is not configurable: a unit advancing several of the objective's own success criteria is several units, whatever its artifact count says. It terminates on its own, which is what makes depth emergent rather than a number somebody picked: a unit claiming one criterion hands its children a vocabulary of exactly that one, so they can claim at most it, and the tree stops once every unit advances exactly one thing. Depth therefore tracks the objective's own criterion count: less if it needs less, more if it needs more.
The induction rests on the criteria DESCENDING with the recursion. Re-read per
level instead, each level mints a fresh vocabulary out of what the level above
happened to write: a child task is built from its subtask's own
acceptance_criteria, which is the planner's per-item prose. A claim made below
the root then names nothing the objective ever stated, the claim count is
bounded by subtask_max_criteria rather than by what the parent held, and the
recursion-depth sweep dropped 143 claims it could attribute to nothing.
DecompositionContext.objective_criteria is what descends. It is stamped once
at the root from the objective's own criteria (stamp_objective_criteria,
beside the bounds resolve, so one tree is never planned against two
vocabularies) and narrowed by child_context to exactly the criteria the
parent unit claimed. Once stamped, child_context is its only writer, the same
way it is current_depth's. The narrowed tuple carries the OBJECTIVE's spelling
rather than the claim's: matching is forgiving about case and spacing, and
handing the claim's text down instead would move the vocabulary one
normalisation step per level until a deep claim matched nothing at all.
The child task keeps its own acceptance_criteria as the local definition of
done, so below the root the planning message carries two lists and says which is
which: Acceptance Criteria is when THIS unit is finished, and
Objective criteria to cover is what the objective the whole tree serves is
still waiting for. An item's satisfies is copied out of the second. At the
root the two coincide and one list is rendered, under the heading the submit
tool's schema names. BOTH planners render it, because either can run: the
single-shot decomposer builds it in build_task_message, the agent-session one
in planning_brief, and a strategy whose schema names a heading its own prompt
omits is judged on a list it was never shown.
A level narrowed to NOTHING admits no claim at all rather than admitting every
claim. That case is reachable and not rare: a pure-support unit is judged
oversized on its artifact count with satisfies never entering the decision, so
it is recursed into, and letting its descendants claim freely put text into a
plan that the operator's own edit boundary then refused. Refusing there is what
keeps every level's vocabulary a subset of the root's.
The backstops below are still not redundant. A planner can under-tag, which does not break the induction but flattens the tree (a unit claiming nothing is atomic by this rule and is never split), and a level can still be refused for reasons of its own. The depth and session backstops bound the walk, and the last-level correction asks the planner to spend breadth first.
The first two are loose "obviously too large" guards rather than the discriminator. At one artifact, an item that writes a module plus its test read as oversized, which made artifact count (a proxy for how verbosely a particular planner writes) the thing deciding the shape of every tree.
subtask_max_artifacts exists because coordination.leaf_subtask_threshold
was read by two consumers that want opposite values. It keeps the
objective-level LeafThresholdRoutingPolicy, where an objective declaring two
deliverables is a team's work; atomicity got its own key, where a subtask
declaring two is still one agent's.
The failure mode this creates is stated rather than hidden: an objective with
no acceptance criteria leaves satisfies empty everywhere and nothing splits.
That is why the two guards stay real rather than being opened to their maxima
the way the sweep arms them. In the product's own path an objective always
carries criteria (the charter interview produces them, and evaluate.py parks
a plan whose objective_criteria is empty), so this guards a hand-filed
objective rather than the normal case.
The signal is only asked about WORK items. A DECISION item is a choice among
its declared options rather than work to divide, and the policy reads only the
artifact, criterion and claim counts, so one declaring several acceptance
criteria would read as oversized and open a child planning session that plans
work nobody asked for.
When a split is refused rather than made¶
While a level below is still available, an oversized unit is simply decomposed again. That is the measured behaviour.
At the LAST permitted level there is nowhere to delegate to. Dispatching the oversized unit whole behind a log line is what that costs when nobody asks: a live run left twenty-one units carrying five to twelve objective criteria each against a limit of one, and nothing told the plan it had guessed wrong.
The level is handed back instead. DecompositionService is the single owner of
"is there anywhere left to split into" and stamps the size signal onto the
context it plans the last level under (_held_to_size);
_atomicity_gate.describe_unsplittable then refuses the submitted plan at
parse time, on the same correction channel a graph violation already takes.
Both strategies answer it the way they already answer a malformed plan: the
single-shot loop re-prompts through with_retry_context, and the agent-session
submit tool hands the reason back as its tool result. The correction asks for
BREADTH: split these further at this level, since there is no level below.
Breadth spent where depth ran out, without an operator being asked.
manual.py is exempt by construction: _split_oversized early-outs on
strategy.plans_any_task(), and an operator-supplied plan has no session to
correct.
The correction carries the width cap, and is withheld at exactly it. A
correction that asks for breadth without naming how much room is left is asking
for the one thing the level can then be refused for: a level complying with it
produces one unit too many and DecompositionSubtaskLimitError fails the whole
tree. A live run did exactly that, widening to eleven against a limit of ten
precisely as instructed, where compliance was fatal, non-compliance was
rejected, and no amount of retrying could resolve it. So
describe_unsplittable takes width_limit from the same budget the strategy
enforces after the session, states it in the correction, and asks a level that
is already OVER the cap to merge or drop rather than to widen. A level sitting
exactly AT the cap is the one silent case: there is no depth below it and no
width beside it, so it draws no correction at all and its units dispatch under
the depth backstop instead. Equality rather than "at or above", because a level
over the cap can still be saved by merging and is exactly the one that needs to
hear the cap named.
Only when the retries are spent does the condition reach the plan, as
PlanItem.unsplit_reason, and it gets there through a typed error rather than
through the generic one. A child planning session that exhausts its retries on
the size correction raises DecompositionUnsplittableError; the level that
ASKED for that session catches exactly that class, files
PLANNER_DECLINED on the unit and dispatches it whole. Its own plan is valid
and its sibling units are dispatchable, so discarding the tree above it would
throw away every level already paid for to report one unit's size.
The type is what keeps that from becoming a swallow. It is one member of a declared set of child failures a level may absorb, and everything outside that set propagates; the set and its reasoning are in the backstops below.
The backstops, and what a bind reports¶
Five bounds, none of them a target. Depth and width are runaway guards: what decides a split is the size signal above, so a small objective stops on its own well short of either.
| Setting | Bounds | Ships at |
|---|---|---|
coordination.decomposition_max_depth |
levels of planning | 5 |
coordination.decomposition_max_subtasks |
units one level may produce | 10 |
coordination.decomposition_tree_max_sessions |
planning sessions per tree | 40 |
coordination.decomposition_timeout_seconds |
one planning session | 600s |
coordination.decomposition_tree_timeout_seconds |
one whole decompose_task call |
14400s |
DecompositionContext.max_depth and max_subtasks are int | None, where
None means "not declared, read the operator's setting". They are resolved
ONCE at the root by decompose_task and stamped into the context it recurses
with, so one tree is never planned under two budgets. That is an ordered
precedence ladder with one resolver (caller declaration, then the operator's
setting, then the definition default), not two authorities: the recursion-depth
sweep keeps passing explicit values and keeps control, while a production
caller that passes nothing gets the operator's setting rather than a hardcoded
number.
The session budget is the one that stops GRACEFULLY, which is why it exists beside the wall-clock ceilings rather than instead of one. Past it no further child session is opened, the tree returns what it has, and the units it could not split say so.
A bound on ONE NODE ends one node. A per-session ceiling exists so a level
waiting on a provider that never answers cannot hold the tree. Letting its
breach propagate does the opposite: it hands every node in a deep tree an
independent chance to destroy every other node's work. A live run spent 39
planning sessions and 1h 48m, reached sessions_remaining=2 of 40, and
discarded every level because session 39 ran 599.7s against the 600s ceiling
while it was in the last-level correction loop.
So a child whose session ends on its own terms is ABSORBED by the level that
asked for it, exactly as a child the planner could not split is: that level
already holds a valid plan, the unit dispatches carrying the reason, and the
tree survives. _child_failure.py is where the set is declared rather than
counted, keyed on the error type because that is what carries the remedy: the
wall-clock ceiling (DecompositionTimeoutError), the turn budget
(DecompositionTurnBudgetError), the token budget
(DecompositionSessionBudgetError), a session that stopped making progress
(DecompositionStagnationError), and a planner that declined to divide the
unit (DecompositionUnsplittableError). Everything else propagates: a
transport that keeps mangling replies is fixed at the provider, and filing it
as a note on one plan item hides an outage.
Absorbing those buys no unbounded time, because the two TREE-scoped bounds are
untouched: the session budget still caps how many such nodes one tree can pay
for, and the whole-tree ceiling still fires as a bare TimeoutError that no
handler absorbs, because that bound covers every level and no level holds a
plan that outlived it. At the ROOT there is no plan above to carry the unit, so
the same breach fails the decomposition and says so.
The whole-tree ceiling is therefore the one that raises and discards every level already paid for, which is why it is set well above a real tree rather than at it. It is not a multiple of the per-session one and cannot be derived from the depth cap: sessions scale with the NODE COUNT, which is the branching factor to the power of the depth, so any multiple of the per-session number is a guess that kills a legitimate deep tree. It is a catastrophic backstop rather than an operating bound, because the session budget is what bounds a tree's cost. Two of the four callers are request handlers.
The reported condition¶
A unit that reached the plan still oversized carries PlanItem.unsplit_reason,
naming the rule that fired, both numbers, and which bound stopped the split.
The phrasings are kept apart because the remedies differ, and each is a line in
atomicity.py rather than a count stated here: the depth backstop and the tree
session budget each want their own bound raised; a child session that ran out
on its own terms names WHICH of its three bounds it hit
(SESSION_CEILING_BACKSTOP wall clock, TURN_BUDGET_BACKSTOP turns,
SESSION_BUDGET_BACKSTOP tokens); STAGNATION_BACKSTOP says the session
stopped making progress, which is a defect to fix rather than a bound to
raise; and PLANNER_DECLINED wants a narrower objective because no bound was
reached at all. It is written by the projection from what the service recorded, and
it is deliberately ABSENT from PlanItemPayload: an operator editing the item
has just revised it, and a note about the version they replaced describes
nothing.
It is read where the two remedies (raise the bound, narrow the objective) are
the reader's: as a flag in the plan's attention panel and on the item's own
card, and in the stakeholder panel's brief beside the item, where a reviewer
raising OVERSIZED_SCOPE has the machine's own evidence in front of it.
A third remedy needs no reviewer to detect, once a leaf carrying this field has already been dispatched and completed: Workstream Extension reads exactly this field, post hoc, to decide whether the workstream that dispatched it is actually finished or only finished the tree it was given, and grafts a further decomposition onto the leaf rather than raising a bound or narrowing an objective nobody has looked at yet. Whether that graft needs a reviewer to approve is a separate question the deterministic autonomy ladder answers per case, which can still park the decision for a human.
Those units still dispatch. Refusing the plan would throw away every level already paid for and block work whose leaves may well execute; the honest answer is to run it and say so.
How they are read¶
All five are read live, per decomposition, so an operator raising one applies to the next call rather than the next restart. A read that fails falls back to the definition's own default only for the two things the setting itself can be wrong about: the key is unregistered, or its stored value is not the right type. Anything else, a settings store that is down above all, propagates for the wall-clock ceilings, because those are re-read once per node and swallowing a transient failure would run an arbitrary share of a tree under a bound nobody chose.
A bound nobody can read is never a licence to spend: every fallback lands on a
real number, and the depth, width, and session backstops mirror their
definitions in code (context.py, _recursion.py, _ceilings.py) so a
harness running with no settings backend at all is still bounded.
What the sweep arms, and why it differs from the product defaults¶
The recursion-depth sweep writes its settings through the real service, so what
it measured is only interpretable against what it armed
(evals/recursion_depth/tree.py::arm_recursion, logged as
evals.recursion_depth.settings_armed at the start of every run):
| Setting | Product default | The sweep arms | Why |
|---|---|---|---|
recursive_decomposition_enabled |
on | on, or off for the control arm | The variable under test |
subtask_max_artifacts |
10 | its declared maximum | Opened all the way so the requirement floor is the one rule that decides a split |
subtask_max_criteria |
10 | its declared maximum | The same manipulation, on the other threshold |
decomposition_timeout_seconds |
600s | 2400s | Sized for a model that answers directly; every model worth sweeping reasons first, and losing an arm to a timing margin destroys the comparison rather than slowing it |
decomposition_tree_timeout_seconds |
14400s | its declared maximum | A sweep is not a request handler, and the default is sized for the ones that are |
decomposition_tree_max_sessions |
40 | its declared maximum | Unlike every other bound here, this one does not kill a cell: it stops the split and returns a PARTIAL tree, which the sweep would then record as the depth it asked for rather than the depth it got. A depth-4 cell is above a hundred planning nodes on this page's own branching model, so leaving it at the default silently flattens the independent variable at exactly the depths worth paying for |
decomposition_max_retries |
5 | 6 | A cell that never plans destroys its pairing rather than costing a data point |
providers.retry_max_attempts |
3 | its declared maximum | Widens the ladder between the hosted gateway and the real upstream provider, where a momentary blip otherwise terminates a session thirty turns in and nothing re-enters that conversation. It does NOT widen the harness driver's own ladder, which takes its budget from the company config so a recorded artefact stays reproducible from the config it names |
The sweep declares its own max_depth per cell, which is the variable it
sweeps, so decomposition_max_depth is never armed: the caller's declaration
wins over the setting by construction.
The five armed at a declared maximum read it off the definition rather than copying the number, so a product bound that changes carries the sweep with it instead of surfacing as a write the settings service refuses partway through a paid run.
Sampling is the one part of the treatment that is not armed through
settings, because it is not a setting: temperature, top_p, reasoning depth
and the per-response ceiling belong to the bound model, so each is declared on
the manifest's own ModelPair and reaches the roster as the agent's
ModelConfig (evals/recursion_depth/staffing.py). Per pair rather than once
for the matrix, because the two models a cross-family matrix binds are
published with different values on different dials, and which dial a model even
honours differs by family: one may expose graded effort and ignore sampling
while thinking, another expose no effort parameter at all. A single figure for
both is therefore guaranteed wrong for one of them.
Declaring it there rather than on the command line is what puts it inside
manifest_sha256, which matrix_identity pins in the journal header, so a
resume against a changed treatment is refused rather than mixing two into one
curve. The recorded Provenance carries both pairs read back off the
dispatched identity (ModelPair.of), not off the manifest, so the report
publishes the binding that actually ran; a dial reading unset there is one
the binding did not state, which per-call resolution may still have answered
for, rather than proof that no request carried a value.
The decomposition_max_retries row is a margin rather than a manipulation, and
what makes it worth reading twice is how narrow the margin turns out to be. The
setting counts RETRIES, and the first ask is not one, so a value of N allows N+1
attempts. A subtree in a live run was refused four times and converged on the
fifth: at any default below four it would have failed a couple of attempts short
of the plan it went on to produce, and a live round records exactly that outcome
as "the replan then exhausted its decomposition retries". At the shipped five,
the sweep's six is a cushion rather than the difference between planning and
not.
Arming the per-session ceiling ALONE is worse than arming neither, and is the mistake this table exists to prevent: it raises what one session may spend while the whole-tree ceiling keeps a default sized for a request handler, leaving an outer bound that cannot admit even two of the sessions the inner one allows. Every tree killed that way has already paid for the levels it planned, and the sweep files it as an unavailable cell, which reads as "the planner could not decompose this" rather than "the harness could not finish a tree it was paying for".
A timeout is also the one planning failure the sweep does not retry
(DecompositionTimeoutError): the ceiling is unchanged on the next attempt, so
a retry reaches the same place having paid it twice, and at these ceilings the
second attempt is measured in hours.
No published system has a signal like this at all, so it is deliberately a small measurable rule rather than an elaborate one. Its limitation is stated rather than hidden: the assessment is made from the planner's declaration, so a planner that under-declares produces a unit nothing splits. That is why the experiment's results carry a caveat saying so on their face.
The tree, made durable¶
PlanItem.parent_id is the whole of it: the item this one was split out of, or
None when nothing contains it. plans.items is a JSON column, so the field
needed no DDL and no migration; the claim is asserted against both real
backends in tests/conformance/persistence/test_plan_repository.py rather than
reasoned about.
Containment is not order. parent_id says what an item belongs to and
never when it runs, which PlanItem.dependencies alone decides. The one place
the two meet is a rule about what a dependency may SAY: it must name a unit at
the same level, which DecompositionPlan already required before the tree
existed. A cross-subtree need is stated between the containers, which the tree
already expresses.
core/plan_tree.py::PlanTree is the single owner of every derived question
(which items are workstreams, what hangs off an item, how deep it sits, what
order assembles bottom-up), so "this item was split" cannot drift from "this
item has children" the way a declared flag would.
core/plan_tree_validation.py owns the invariants only the whole set can
answer: a parent resolves, the parent graph reaches a workstream, and a
DECISION is never a parent (it is chosen rather than decomposed, and dispatch
strips it, so its children would be orphaned).
Telling a working tree from a hung one¶
The tree is persisted ONCE, at the end, which is correct and leaves the plan
reading PLANNING with zero items for the whole run. A live run sat at zero
for 54 minutes under a page that promised "items appear as they are written",
and the only way to tell a working decomposition from a hung one was the
backend log. Everything needed to answer it was in the session ledger already,
and the ledger knew it only in memory.
plans.decomposition_progress carries the answer to the page. It is a
snapshot, not a log: overwritten each time the tree reaches a new node,
because the question is "where is this now" and the run's own history is the
event stream's job. It names the sessions spent against the sessions allowed
(so the number reads as progress rather than a bare count), the deepest level
reached, the units written so far, and when the snapshot was taken. That last
field is what distinguishes working from stalled, which is the whole question
an operator has while the item count reads zero. NULL means nothing has been
reported yet, which is a different claim from a zero snapshot.
The write is deliberately unlike every other plan write. It is ONE conditional
statement, UPDATE ... WHERE parent_task_id = ? AND status = 'planning',
because the status is a WRITE condition rather than a read one: a plan can be
failed under a live decomposition, and a whole-row write from a snapshot taken
before that would revive it and discard the reason. It asserts no version and
bumps none, because the decomposition ends by claiming its shell at the version
it started from; a progress line that moved the version would fail the very
write it exists to describe. It leaves updated_at alone for the same reason:
this describes a run, it does not revise a plan.
The engine reaches it through DecompositionProgressReporter, a one-method
seam wired at the worker assembly. The service holds no repository, so a
harness plans without persistence at zero cost, and publishing is allowed to
fail by contract (_progress_publish.py): a reporter that raises is logged and
dropped, because losing the progress line costs an operator a refresh while
losing the tree costs the run.
Both directions of the projection¶
items_from_decomposition walks children, so every level reaches the plan. A
child node's plan.parent_task_id IS the id of the subtask it was split out
of, so the parent link is read off the tree rather than derived a second way.
decomposition_from_plan rebuilds the nested DecompositionResult from the
persisted links, which is what makes an operator-edited tree the one that
actually builds, and gives a nested item's task its CONTAINER's task as
parent_task_id rather than the objective's.
How containment reaches the wave gate¶
DecompositionResult.dispatch_subtasks is a derived view in which a container's
dependencies are augmented with its children's ids. The wave builder
reconstructs its DependencyGraph from that view, so a container lands in a
strictly later topological level than the subtree it assembles while
independent subtrees stay in the same wave. gate_wave then already asks
exactly the right question, and a container parked dependency_failed names
the child that died, which reads honestly.
The augmentation exists ONLY inside that view. The persisted
PlanItem.dependencies, the persisted Task.dependencies and the planner's own
SubtaskDefinition are untouched, asserted by test, so dependencies keeps
sole ownership of declared order.
A container is its own assembly task¶
An item with children is not dispatched as work; that would do the work twice.
engine/assembly.build_assembly is the single owner of that: it gives a
container an assembly brief over its own children, its declared artifacts plus
that subtree's own namespaced evidence paths, and stakes one rung above the
highest of what it assembles. Its plan_item_id is set, so tasks_by_item,
collect_item_progress, item_is_done and the rollup work unchanged, and it
runs through routing, waves and the review gate like any other item.
BOTH paths reach that owner, because there are two and they are not the same
call. decomposition_from_plan rebuilds a tree from the durable plan (the
approve-a-reviewed-plan route), and DecompositionService builds one in memory
(coordinate() with no precomputed plan). A container is an assembly on one
and its own oversized work description on the other is not a smaller version of
the same bug: it is the double execution, on whichever route a given deployment
takes.
It reaches BOTH ends of one decision too. Routing admits candidates against
SubtaskDefinition.stakes and dispatch judges Task.stakes, so the escalation
is applied to each from the one verdict; escalating only the task routes a
container to an agent the dispatch then refuses.
The evidence namespace is keyed on the container's whole ADDRESS in the tree
(.synthorg/integration/<slug>/<slug>/, one segment per level), not on its
position among its own siblings. A sibling position repeats across the tree, so
two cousins both sitting first under different parents, both titled "Setup",
resolve to one directory and overwrite each other's report: the collision the
namespacing exists to prevent, rather than a corner of it. The chain of
positions is unique by construction, and each segment still carries the
sanitised title so an operator can read the path.
That is what turns one wide fan-in at the top into a sequence of narrow ones.
Whether the width was ever the binding constraint is open: the pilot's own
table has the deeper, wider-fanning trees scoring better, so narrow assembly is
argued for here on the grounds that a merge brief stays readable, not on a
measured collapse. The root keeps today's INTEGRATING tail stage, and its
brief now names the plan's WORKSTREAMS rather than every leaf in the tree.
The experiment¶
evals/recursion_depth/ sweeps the depth cap and emits one chart with three
panels on one depth axis: the fraction of the specification a merged tree
satisfies, the fraction of the delivered leaves' own claims the merge kept,
and the headline, tokens per solved requirement with a 95% bootstrap
interval over each bucket's runs. The headline is what ranks the arms: a
published comparison of three harnesses (arXiv 2607.22585) measured a
forty-fold cost separation while every pairwise pass-rate interval but the
largest included zero, so a loop can be cheaper by an order of magnitude at a
pass rate no interval separates, and a report ranking on satisfaction alone
ranks two things it cannot tell apart. The interval is a seeded percentile bootstrap
(evals/recursion_depth/efficiency.py), seeded from the runs themselves so a
re-score reproduces it; a bucket under three runs reports the point with no
interval rather than a fabricated one, and two arms whose intervals overlap
at a depth are named in the caveats as indistinguishable there. The depth axis is not
the one the question asks for, and The metric below says why it
stands in.
Beside the score, and never folded into it, every measured cell carries a
liveness verdict on the deliverable the specification names
(evals/recursion_depth/liveness.py). The oracle says which requirements a
tree satisfies; it does not say whether python -m sqlcsv runs, and the two
can come apart: an agent can satisfy a hidden oracle while the requested
artefact is dead (arXiv 2606.28430), and the gap between visible and held-out
verdicts grows about 28 points per tenfold increase in code size (arXiv
2605.21384), which is the direction a depth sweep pushes. The spec's
requirements.yaml declares what must run (liveness: modules and
entry_points); each module is imported and each entry point executed with
-I in a throwaway container holding the tree ALONE, so nothing the probe
runs can read an expectation, and the verdict is live, dead (with what
died) or not_probeable (the spec declares nothing to run). A cell scoring
well while its deliverable is dead is named in the caveats, because that is
the published failure mode the probe exists to catch.
The committed matrix records caps 1 to 4 at five repetitions each, one arm,
because MINIMUM_REPETITIONS refuses to load a matrix asking for fewer: the
same comparison found that below five trials a pass-rate difference is
indistinguishable from the run-to-run spread. The harness still supports two
arms, and the first recording used both; what changed and why is in
The gate.
Run make recursion-depth to print the matrix and the bill without spending
anything, and make recursion-depth-record to measure for real.
Before a matrix is paid for¶
A recording refuses to start without a passing wire-level smoke for its
own manifest digest (scripts/record_recursion_depth.py --smoke, read by
--record from <out-dir>/smoke/wiring.json). A 200 response, a valid
manifest and a green unit test are all compatible with a treatment being
absent from the engine: the corpus this harness replaced measured an engine
wired at 8 of the 51 points a deployment supplies, for eight recordings, and
nothing at any layer could tell. The smoke runs one cell at the shallowest
cap and reads each treatment off EVIDENCE rather than configuration
(evals/recursion_depth/wire_check.py): the engine's own wiring summary
(AgentEngine.wiring, the same facts its creation event logs, plus the tool
surface the invoker was built with), the live settings the manifest was armed
into, the cell ledger (cached-prefix tokens), the recorded request bodies
(the reasoning depth that actually reached the provider), the review gate the
host built (the completion-oracle peer review is attached only once a
coordination pair is published, and a runtime built without one reviews
nothing), and the product's own verdict on a finished leaf, read back off the
host rather than off the harness. Each becomes a finding with what was
expected and what was seen; a treatment whose evidence cannot be read is
unverified, which is neither a pass nor a failure and is said in those
words. A leaf that ends at its turn cap is never offered for review, so a
cell in which no leaf finished leaves the leaf-review finding unverified
rather than failed. The findings travel in the report under wiring, so a
published artefact states its own wiring rather than asserting it, and a
recording made before the smoke existed says "not measured" there.
What one run does¶
- Plan. The shipped owner-run planning session decomposes the
specification down to this run's cap, through the real
DecompositionServicewith the settings written through the real settings service. One session per node that plans. - Fix the contract. One forced-leaf session per cell, between the plan and
the first unit, writing module layout, cross-module signatures and one
FAILING test per requirement. Its tree, not the committed seed, is what
every later unit and merge is recreated from, so the agreement is already in
each checkout and nothing has to be handed to anybody. Governed by
contract_stagein the manifest and by--contract-stage/--no-contract-stage; with it off, step 3 seeds from the committed README as it always did.
It exists because the seed alone fixes nothing: measured across the three
corpus cells recorded without it, 11 of 14, 11 of 12, and 12 of 13 of the
modules more than one child wrote disagreed on their exports, against 0 of
21 in a cell recorded with it. Re-derivable from the kept trees with
scripts/report_interface_divergence.py.
3. Build every leaf. One agent owns a unit end to end, its own tests
included, in a workspace recreated from the contract's tree (or from the
committed seed where no contract ran). A leaf delivered when it changed
something it declared and its own tests pass in its own tree (below).
Under a contract, "its own tests" narrows to the tests naming the
requirements that unit CLAIMS, because the seeded checkout carries a failing
test for every requirement and each unit is told to leave the others
failing. Grading a unit on the whole suite there does not misjudge
occasionally: it marks every unit undelivered whatever it built.
4. Assemble every node, deepest first. The children are copied under
.children/<slug>/ and the deliverable is the tree at the workspace root.
The merging agent is told it may change a child's interface and is asked to
record each time it does.
5. Judge, or spend the same budget without judging. See below.
6. Grade. The held-out oracle runs against the root's assembled tree.
Preflight settles what it can before any of this: provider coverage, a
reachable daemon, a one-token completion on both pairs, and, once the host has
resolved it, that the sandbox image the run will need actually exists. That
last one runs after the boot because unless --sandbox-image names one the
reference comes from the running instance's own settings resolver; it still
beats the first session, which is what matters, since planning runs entirely
through the gateway and a cell that can never be graded would otherwise buy a
whole plan first.
The metric¶
Two curves, on one axis, from one grading. They answer different questions and
the pair coming apart is the finding; evals/recursion_depth/score.py owns
both.
SPECIFICATION. After the root merge the held-out oracle runs over the whole specification, and:
| requirements the merged tree satisfies |
y = ----------------------------------------
| requirements the specification defines |
The denominator is fixed at 42 for every cell, so every run produces a point,
including where a cell's leaves all failed. DepthPoint.fraction sums both
operands per (depth, arm) bucket. What it does not say is where the work came
from.
SURVIVAL. The question the programme was built around:
| requirements DELIVERED leaves claimed that the merged tree satisfies |
y = ----------------------------------------------------------------------
| requirements DELIVERED leaves claimed |
SurvivalPoint.fraction, binned on the same axis so the two read off one
chart. Delivery rather than standalone correctness gates the denominator: at
depth a unit is one function and nothing above it exists yet, so a leaf's own
tree usually cannot run the spec oracle at all and requiring a standalone pass
would empty the denominator exactly where the curve is most interesting. This
denominator IS leaf work, so it can empty, and an empty one is an ABSENT point
rather than a zero: nothing was measured there, and a zero says everything was
lost.
How a claim reaches a requirement. Leaf work is claimed through
SubtaskDefinition.satisfies, which carries criterion TEXT rather than
requirement ids, because text is what the planner is given and copies back. The
root objective is filed with one criterion per requirement, "R03: A decimal
column reads as a float", and the criteria descend with the recursion (see
The size signal), so a claim at any depth still names one of
them and recursion_depth/claims.py resolves it on the id token it carries.
The criterion carries the requirement's title as well as its id because at
depth the specification prose is gone: the child task describes the unit, not
the spec, and an id alone is not something a planner can allocate against.
The map is checked once, on the tree the planner produced and BEFORE the first
leaf session opens (tree.claimed_requirements, called from
_build_tree_units). A claim naming no requirement raises there, so the cell is
recorded unavailable having spent its planning sessions rather than its whole
leaf budget. That is the backstop; the product refuses such a claim at the
boundary the planner writes it, where the session can still correct it.
What the pair buys. A tree scoring well because the merging agent rebuilt
the work and one scoring well because its leaves' work survived are the same
number on the specification curve and different numbers on the survival one.
Every emitted artefact carries METRIC_CAVEAT stating both, because the chart
and the JSON travel without this page.
What the repetitions buy. Both curves POOL a bucket's runs into one
fraction. That is the right shape for a rate over work and it cannot say
whether a low point is one bad draw or a real drop, which is the entire reason
a cap is recorded five times. So DepthSpread reports each bucket's range and
its middle run per metric, and depth_curve.md also lists every cell on its
own row. The middle is the LOW median, so it is always a figure some run
actually recorded rather than one describing neither of two. The absent-point
rule applies per RUN there: a run whose delivered leaves claimed nothing has no
survival rate and is left out of the range rather than folded in as a zero,
which would report a collapse nobody measured.
A third column, which is not a curve. shared_modules and
diverged_modules on each cell record how many module paths more than one unit
wrote, and how many of those the units disagreed on. It is reported beside the
score rather than folded into it, because it measures the CAUSE the contract
stage addresses rather than the outcome the oracle grades, and the two move for
different reasons. Counted per module path and only where more than one unit
wrote it: a module one unit owns cannot disagree with anybody, and folding those
in buries the reading under the many files each cell writes once. Agreement is
the module's public surface, never its bytes, since two units are SUPPOSED to
write different bodies for a module they share.
evals/recursion_depth/divergence.py owns it and
scripts/report_interface_divergence.py re-derives it from any kept recording
without a provider call.
What delivery is decided by, and what it is not¶
Delivery is a question about the AGENT's work, so it is decided by what the run
produced: the session took a turn, it changed something the unit declared, and
the unit's own tests pass in its own tree. The middle condition goes through the
product's own ArtifactPresence.delivered_nothing_since, against a probe taken
before the session, so a path the recreated seed already provided is not
credited to the run that received it.
It is deliberately NOT decided by the planner's declared list being complete,
and that is a correction rather than a preference. The harness asked whether ANY
declared path was missing, which is the inverse of the rule stated in the module
that owns it ("the 'none, not some' rule is this module's to state once"), and
it made delivery turn on a plan-time guess written per node at whatever
granularity the planner chose: the same output was a delivery under a parent's
two-entry list and a non-delivery under the leaf's four-entry one. One live unit
wrote its module, a 31-test suite and ran it, and was booked at 598,585 tokens
as no delivery because an empty tests/__init__.py was absent, which resolves
without one and which its own passing suite proved it did not need. At a
delivered rate near a half that term dominated the curve rather than sitting in
its tail.
The declared list is still recorded, as UnitRecord.missing_declared_paths,
because a planner over-declaring is worth seeing. It holds the paths the planner
named that the finished tree does not contain, which is a mismatch between the
two rather than a fact about either on its own. It is a diagnostic and nothing
more: nothing reads it to decide produced, and delivery is settled by the
tree.
The direction is stated because the field's previous name, undeclared_paths,
read the other way and was cited backwards on this very page. One recorded unit
holds ('sqlcsv/csv_loader.py', 'tests/test_csv_loader.py') there. Under the
old name that is a unit that wrote its module and its test suite, which is the
most convincing evidence of success available; it is the list of what that unit
FAILED to write. No type, test or docstring caught it, because each was
correct. What catches a name pointing the wrong way is reading it against a
concrete value, which is why the value is here rather than a description of it.
Achieved depth, not the cap¶
The cap is what a run was allowed; the planner decides what it uses, and a planner that stops splitting at three produces identical trees at caps four, five, and six. Binning on the cap would make those look like three measured points and a flat right half would read as "gating holds at depth" when it means "nothing went there".
So the primary curve bins each leaf on its own level, the cap curve is reported beside it, and the achieved-depth histogram is split per arm, because an arm plans its own trees and two arms compared at a depth only one of them reached is two experiments on one axis.
The gate¶
Every merge in the committed matrix calls CompletionOracleGateService.evaluate
unchanged. The harness supplies the engine the reviewer runs on and nothing
else: selection, the exclusion of the executor, the narrowed review session, the
fail-closed escalation and the verdict's attribution all stay the product's. A
rejection feeds its findings into a repair attempt.
What the harness does change is which tree the reviewer is pointed at: it gets a detached copy, and the graded tree is the original. The gate prompt requires a disconfirming command, so the reviewer holds the terminal tool whatever its file tools allow, and a reviewer able to touch the tree it judges could repair the work under review, which would credit gating for work the gate itself did.
An escalation is recorded, never resolved. There is no human in a sweep, so the merge stands and the parked count travels with the chart: a line resting on unresolved escalations is a different claim from one resting on verdicts.
Why one arm. The harness still ships the ungated arm, which spends the
identical attempt budget with nobody independent in the loop: a self-review by
the agent that just did the merge, whose output no verdict is taken from. It is
the honest control for crediting the gate with independence rather than
effort, and the first recording ran both.
It is not in the committed matrix, and the reason is what the first recording could and could not support. The arm difference there was 36 against 35 and 36 against 33 at one repetition each, with the arms on different trees (58 leaves against 43, then 38 against 30): inside the noise, indistinguishable from zero and equally impossible to rule out as substantial. Fixing that needs either a shared tree, which leaves the two arms' spend impossible to compare while spend is the check on the whole result, or enough repetitions to average the tree draw out, which doubles a bill that already runs to hundreds of sessions per cell.
Gating is not the binding question. Replication is, and depth 4 is where ARIES puts the blow-up, so halving the arms is what pays for both. It also dissolves the paired-tree confound rather than requiring a fix for it: with one arm there is no arm comparison left to confound. The gated arm survives because the shipped product gates every merge, so the curve describes the product's own behaviour rather than a configuration nobody runs, and because it was not the dearer arm in this pilot (3.05 merge attempts against 6.00, since an ungated merge has no verdict to stop on and always burns its whole budget). That gap was measured under a harness bug (see below: a park used to end the repair loop early, the same way an approval does), so a gated merge that parks now also burns its whole budget rather than stopping after one round -- the cost comparison is not assumed to hold under the fixed harness and needs re-measuring, not carried forward as a settled fact.
Leaf-level verification is untouched either way, so what the recorded curve measures is depth under the product's own aggregation.
The judge is checked, not assumed¶
A judge sharing the executor's (provider, model) pair biases straight toward
the null, which matters whether or not an ungated arm is being compared against:
the recorded curve is what a gated merge produced, and a gate whose reviewer is
the executor is not a gate. The manifest declares an
independence class and the loader refuses a manifest whose pairs do not match
what it claims; identical pairs are refused outright. Under same_family
every artifact carries the caveat on its face.
That claim is checked against a declared family, never against the provider.
Self-preference attaches to the organisation that trained a model, and the
connection reaching it is a separate fact: an aggregating provider serves many
families through one endpoint, so two models behind it are as decorrelated as
their families are, while two connections to one vendor are not decorrelated at
all. Deriving family from the provider would refuse the first case and wave the
second through, which is backwards in both directions. A cross_family claim
whose pairs name one family, or name none, is refused.
Each pair also declares its capability rung, and family is declared beside it for the same reason. The capability registry grades a pair from a catalogue that knows nothing about a placeholder id, and selection refuses an ungraded pair outright, so a roster built from a manifest that did not say would leave every review unstaffed and the sweep would record escalations rather than verdicts. A placeholder id has no discoverable family either.
Because the manifest ships placeholders, a company config decides which real models answer them, which makes family a fact with two owners: the manifest's copy is what the claim is checked against, and the config's copy names what runs. A config aliasing both placeholders onto one organisation therefore satisfies every check in the manifest and still produces a correlated judge, so the recorder compares the two before the host boots. What it compares is the RELATION and never the names: a vendor-agnostic placeholder cannot equal a real organisation's name, so testing for that would refuse every real recording, while the claim those placeholders make (that the two pairs differ) survives aliasing intact. A config declaring no family is not a disagreement; it is the config not saying, which leaves the manifest the only claim.
The oracle is held out¶
evals/recursion_depth/spec/sqlcsv/ defines a SQL-over-CSV query CLI in 42
numbered requirements. requirements.yaml maps each to the behavioural pytest
that decides it, and those node ids never leave evals/recursion_depth/oracle.py:
they reach no brief, no workspace, and no prompt. An agent told which test
decides a requirement builds to the test, and every leaf and merge brief says so
in as many words, which is the largest single measured countermeasure against
reward hacking and costs one prompt change.
The specification is proved satisfiable by a reference implementation under
tests/evals_spine/recursion_depth/reference_tree/, which passes all 42, and
the oracle is proved discriminating by an empty tree, which fails all 42.
What the sweep costs, and what bounds it¶
A depth sweep's session count is a product of branching factors the manifest
cannot predict, and the cost of being wrong about it is spend rather than a
wrong answer. So plan mode prints what a full tree costs at a declared
branching, the manifest carries a hard max_sessions ceiling, and hitting it
stops the sweep
and reports what was measured with a caveat saying so. --depths stages the
bill: record the shallow end, read the curve forming, then pay for the deep end.
Those stages are CUMULATIVE, and this is the one thing about them worth stating
twice: the report holds exactly the caps the invocation planned, because
run_sweep replays a journalled cell only when the narrowed matrix still asks
for it. A final stage naming only the deepest cap therefore emits a chart
missing every cap already paid for. A replayed cell costs nothing, so each stage
names every cap recorded so far and adds --resume.
--max-sessions lowers the ceiling, and it is folded into the manifest rather
than applied to the run, so the figure the plan prints is the one the run
enforces: a ceiling applied downstream of the plan shows the manifest's own
number beside the flag that was meant to lower it, at the one moment the number
is being relied on.
--repetitions is the third lever and the one that reaches the bill, because a
cap costs its branching to the POWER of its depth: one repetition fewer at the
deepest cap buys back more time than any other single change, and the shallow
end is nearly free either way. It takes CAP:COUNT pairs and changes only the
caps named. Per run rather than by editing the file, because the committed
counts are the experimental DESIGN (five at every cap, the floor
MINIMUM_REPETITIONS refuses to load a matrix below, so per-depth spread is
reportable everywhere rather than only where the transition is expected), and an
operator trading one of them for a schedule should not leave the next reader
inheriting a quota window as if it were an intended design. The floor binds the
override as well as the file: the NARROWED matrix is re-checked against it
after the counts are folded in, so --repetitions can raise a cap or hold it
at five and is refused below that, because a cap dropped to one repetition
reports a range of one draw, which is the thing the design exists to avoid.
All three levers are folded into the
manifest OBJECT and none touches the manifest FILE, which is what the journal's
identity pins, so none of them turns a resumable matrix into a foreign one. A
COMMIT does, because the identity pins that too, and that is the constraint that
actually governs a staged recording: fix everything before the first stage, and
carry the tree unchanged until the last one.
That reasoning holds only for a SCHEDULE lever, and the distinction is load
-bearing. --max-sessions and --repetitions decide how much of the matrix
runs; --contract-stage and --leaf-reasoning-effort decide what running it
MEANS. A treatment that changed the loop while leaving the file alone would
leave two arms with byte-identical headers, and the identity check would then
accept a resume of one inside the other's directory and splice two loops into
one curve. So Provenance.loop carries the treatments a run actually resolved
(LoopTreatments) alongside the file digest, and Provenance.sandbox_image
carries the image it resolved, both inside matrix_identity.
--leaf-concurrency is the only lever that changes wall clock rather than the
bill: sibling leaves at one level have no dependency on each other, so they
build together. Merges never overlap, and the cell loop stays sequential, which
is what keeps one cost ledger per cell readable.
Re-emitting a report without paying again¶
--rescore rebuilds the whole report from the journal alone. It reads no
manifest, contacts no provider and spends nothing: the cells, the provenance and
both curves are all recoverable from what the recording already wrote. It exists
because a scoring or rendering defect found after a multi-hour run is otherwise
only fixable by paying for the run again, and because a report produced by a
scratch script is reproducible by nobody, which is most of what the provenance
block is for.
A re-score REBUILDS every caveat it can derive and CARRIES only the declared
RUN_STATE_CAVEATS, the three that record how one run went (a session ceiling,
a quota refusal, a same-family judge) and that the journal does not hold.
Deriving the rest is what gives an old recording the current release's wording;
carrying them instead left a re-scored report holding two wordings of the same
caveat side by side.
--repair-spend-from takes the recorder's own log and rebuilds the token
column from it, for a recording whose sessions shared one process-wide cost
sink. That sink is swapped per session, so concurrent leaves could journal zero
while a neighbour absorbed their records; the log is written one line per CALL,
which no swap can scramble. Attribution is by interval rather than by task id,
because the root task id is derived from the specification and repeats across
every cell: a unit's spend is what banked against its id since that id was last
journalled. The plan row is cut off explicitly, since planning dispatches under
the root id that the root merge later reuses. A repair that places no call at
all is refused rather than reported, because the caveat it would add is a
provenance claim.
The figure is derived from the TREE each cap admits, and it has to be, because
the arithmetic is unforgiving in exactly one direction. A projection that counts
one session per cell plus its merge attempts, and then adds "one per leaf and
per node on top of that", leaves the entire tree out: for the recorded matrix
that shape prints 42 against a real cost of roughly 158 sessions PER CELL, so a
ceiling chosen from it is about four times too small. A run launched at a
ceiling of 30 planned an 85-leaf tree, built six units, and stopped with zero
cells measured, which is the whole failure mode max_sessions exists to make
survivable and was instead the thing that fired.
RecursionDepthManifest.projected_sessions states the model instead: at a
declared projected_branching of b, a cap of d holds b ** d leaves and
(b ** d - 1) / (b - 1) nodes that planned, each of which also assembles, and
an assembly is two sessions (the merge and its review, in every arm). The
branching is declared in the manifest and PRINTED beside the figure it produces,
because a model whose input is hidden reads as a measurement, and it is rounded
DOWN from what a real tree showed (85 leaves over 25 planning nodes at cap 3
implies about 4.4).
That figure is a scenario, not a bound in either direction, and reading it as
one is how an operator gets surprised. depth_cap is a MAXIMUM the planner need
not spend, so a tree that stops shallow costs less than this says; and the
declared branching does not constrain the planner either, so a tree that
branches wider costs more. What it answers is the question worth sizing a
ceiling against, since the run that uses its whole cap is the expensive one.
max_sessions is what makes being wrong in either direction survivable: set it
too low and the sweep stops early with a caveat, which is the outcome this
whole section exists to keep survivable.
Sizing a ceiling and starting a cell are different questions¶
The ceiling books sessions AFTER they run, so on its own it can only stop a
sweep that has already overrun. A cell entered without the budget to finish it
spends everything left, records no achieved_depth and enters no curve: the
measurement is lost either way and the spend goes with it. _refused_on_budget
is what makes that recoverable, declining to START such a cell so the sweep can
be resumed against a raised ceiling with every finished cell replayed free.
It cannot use the projection to decide that, and the reason is the tree's shape.
b ** d assumes UNIFORM branching, and these trees branch wide at the top and
narrow below: the recorded cap-3 tree split 7 ways at the root, 4.6 ways at
level 1 and 3.5 ways at level 2. So the widest factor any recorded cell shows is
the ROOT's, and a forecast that substituted it (7, from a cap-1 cell holding 7
leaves over one level) answered 3,601 sessions for a cap-4 cell whose real
cost is near 300. Used as a refusal threshold that number refuses the deepest
cell of every sweep, which is the one the whole matrix is paid for.
So the manifest declares both, and each answers one question:
| Field | Answers | Read by |
|---|---|---|
projected_branching |
what a FULL tree costs at each cap, the expensive scenario | the plan print, and through it whoever sizes max_sessions |
expected_sessions_per_cell |
what ONE cell at each cap is expected to actually cost | estimate_sessions, which refuses a cell the remaining budget cannot finish |
estimate_sessions prefers a MEASUREMENT of the same cap over the declaration,
because the matrix repeats caps: once one cap-3 cell has run, what a cap-3 cell
costs is known rather than modelled, and the costliest such run is taken. The
declared figures still err HIGH, since refusing a cell that would have fit costs
one measurement while entering one that does not fit costs that measurement and
the spend; what changed is that the margin is a figure an operator sized from
measurement rather than an artefact of the wrong tree shape. Both are printed by
make recursion-depth, beside the ceiling, and beside each other.
Once a journal exists the manifest is frozen, so this figure is chosen once. The
journal header pins the manifest digest along with the commit, the spec, both
pairs, and whether the tree was dirty, and a resume against any of those changed
is refused rather than mixing two matrices into one curve. That dirty flag
deliberately excludes the recorder's OWN output directory
(evals/harness/provenance.py::_dirty_argv): the default one is tracked because
the artifact is committed, so without the exclusion a stage that finished would
dirty the tree with its own report and refuse the next resume, forfeiting every
cell it had just paid for. A change to the code still flips it and still refuses
the resume, which is the point: a fix changes the system under measurement. --max-sessions is the only lever a resume has, and it
works precisely because it is folded into the manifest object without touching
the file the digest is taken over. Editing manifest.yaml to run a cheaper
matrix forfeits the planning already paid for and means starting a new
--out-dir; --resume --help says so.
A unit is bounded twice, by unit_cost_ceiling and by unit_token_ceiling, and
the second is not redundancy. A flat-rate connection attributes 0.0 to every
call, so its cost ceiling can never fire and a runaway unit would be held by
nothing but its turn cap. Tokens are counted on every provider, so the plan
states the token bound as the one that holds without the reader first knowing
how they are billed.
Those bounds are per ROLE and scaled, not one flat number for every session.
session_limits_for sizes each role from a base the manifest declares plus
what that particular session is being asked to do, under a cap:
| role | base | scaled by | cap |
|---|---|---|---|
| leaf | unit_token_ceiling |
requirements claimed, at unit_token_per_claim |
unit_token_cap |
| merge | merge_token_base |
pieces to assemble, at merge_token_per_piece |
merge_token_cap |
| review | review_token_base |
the same fan-in | review_token_cap |
| contract | contract_token_ceiling |
nothing: one session per cell | itself |
Flat was the earlier shape and it is what starved the corpus: a leaf claiming
eighteen requirements got what a leaf claiming two got, and 58% of leaves
stopped on their ceiling rather than finishing. Every base and cap pair is
checked at manifest load, because a cap below its own base makes min(base,
cap) size every session of that role to the smaller number, which is
indistinguishable at runtime from the matrix legitimately needing less.
Every cap is repeated, which the first recording did not do: it ran one cell per point and so could report no spread anywhere. Spread is only reportable where there is a population, and a matrix that repeats only the caps it expects to be interesting cannot say whether the flat ones are flat or merely too thinly sampled to tell.
Repetitions are five at every cap, and the loader refuses fewer. The deep cells
are where the sessions are: a cap-1 cell is 14 sessions against a cap-3 cell's
135, and a cap-4 cell is projected near 300, so the earlier design tapered to
two at the deep end to fit the ceiling. Five is the sweep's own floor, adopted
from the protocol the harness comparison above was measured against, which
runs five trials per task; an independent bootstrap over that benchmark's
leaderboard found 24 of 25 adjacent rank pairs indistinguishable even at that
count (evals/recursion_depth/results/harness-audit/README.md, gap 5). The
comparison itself ran one trial per task and says nothing about repetition
counts; what it says is that pass rate alone separates almost nothing, which
is why a taper leaves exactly the cells the curve is about too thinly sampled
to read. What the taper was hiding is still worth stating: two draws bound a
range but do not give a median distinct from it, so caps 3 and 4 report a
spread that says how far apart two trees fell and not much more.
Preflight¶
Provider coverage, a reachable Docker daemon, and a one-token completion against each declared pair are all settled before the host boots. Each is a property of the configuration or the machine, so none becomes truer once a scratch database, a gateway and a container are standing, and each is otherwise found by a unit failing mid-decomposition.
The misdiagnosis is what makes this load-bearing rather than merely faster. An
invalid credential surfaces as decomposition.failed and records the cell
unavailable with a DecompositionError reason, which names the wrong subsystem
entirely: the operator goes and reads the planner. Measured with a deliberately
invalid key, it took 56 seconds to get there, because the credential error was
retried by the driver, returned across the recorder's own gateway hop as a 502,
and retried again on the far side. A bad key fails identically every time, so
all of that is latency. The probe is also the warm-up, which matters because a
cold model load would otherwise land entirely on whichever cell is recorded
first, and that is depth 1: the flattest, cheapest point on the curve.
The sandbox image is checked separately and one step later, because unless
--sandbox-image names one the reference comes from the running instance's
own settings resolver and there is nothing to check until the host is up. It
still lands before the first session, which is the boundary that costs money:
a cell plans entirely through the gateway, so an image that does not resolve
would otherwise be discovered by the first container, with a plan already
bought (measured at 85,555 tokens). The daemon's own 404 is the only answer
read as "no such image": a 500 or a dropped socket is the daemon failing to
answer, is retried, and if it persists is reported as the daemon being
unavailable rather than sending an operator to rebuild an image that is fine.
Two things to read the results against¶
Neither is a defect, and both change what a number means, so they belong beside the number rather than in a run log nobody opens afterwards.
Depth 3 is too shallow for this specification. In the cap-3 run, 21 units hit
depth_exhausted at current_depth=2 carrying 5 to 12 objective criteria each
against the atomicity limit of 1. At that cap the leaves are therefore NOT
atomic, and a delivery difference between the arms is partly a difference in how
oversized the units each arm was left holding were, rather than in what gating
the merges did.
docker.execute.failed is not a health metric. An agent probing what int()
accepts logs one per deliberate ValueError. That is the tool reporting a
non-zero exit from a command the agent meant to fail, which is correct
behaviour and useless as a signal: the count moves with how thoroughly an agent
explored, not with how badly a run went.
What the wired engine measured¶
One cell, cap 1, the gated arm, recorded 2026-09-02 through the product's own
engine assembly with nothing missing (evals/recursion_depth/results/wired-r0/,
with the register answered question by question in its README.md and the
stop point in docs/reference/harness-round-log.md). Every recording above and
below this section ran 8 of the 51 collaborators a deployment passes; this is
the first that ran the loop the product ships, and the sweep was stopped at it
by operator decision, so it is one cell and it is read as one cell.
| what | measured |
|---|---|
| specification satisfied | 7 of 42, program LIVE |
| leaf-work survival | 1 of 8 claimed |
| tokens | 50.5M over 16 sessions, 4 h 37 min; by the journal the merge 52%, the leaves 42%, the reviews 6% (the transcript report reads 49%, 36% and 10% over the usage frames the tap kept) |
| plan | 8 units in 11 turns; five of seven submissions refused because the array arrived as JSON text |
| contract | 15 modules, 46 pending tests, no CONTRACT.md, cut at its 60-turn cap |
| leaves | 8 of 8 at the 40-turn cap with the product's extensions zeroed by the harness; 2 graded delivered |
| merge | three attempts (80 turns, 80 turns, parked on its 5.5M-token ceiling), rejected three times by a roster reviewer: the first two on the same critical lines, the third on a missing test run and NULL ordering |
| shared modules diverged from the contract | 7 of 27 |
What it says about the six root causes, in the order the dossier named them:
- RC1 (open-loop per unit). Refuted as a property of the product: the post-execution path files every finished unit for review, and the roster reviewer judged the assembly three times in about a minute each with code-quoted findings. What stands is narrower: a unit that runs out of turns is never offered for review, and every leaf here did.
- RC2 (isolation where agreement is required). Stands, smaller: the contract closed most of the divergence (7 of 27 shared modules, against 11 of 14 without one), and what remained was one unit's join signature and one unit's error taxonomy, both of which the assembly was told about and did not reconcile.
- RC3 (the merge cannot do its job). Stands, and now with the mechanism on
the wire: the assembly was briefed with the reviewer's findings by name. One
attempt read the named file four times, edited through
sedand here-documents rather than the edit tool, and wrote nothing in 80 turns; the next fixed both lines at turn 50 of 68 and never ran the suite, so aNameErroron every query's first line reached the kept tree past three read-only reviews. Repair rounds do not converge because the assembly neither acts promptly on what it is told nor verifies what it writes. - RC4 (nothing manages context). Refuted as a cause at this pair: the largest request was 125K tokens against an 838K compaction threshold, so compaction correctly never fired; tool-output abbreviation fired ten times. Context is not what ran out.
- RC5 (configuration validated, capability not). Resolved by construction: the engine is the product's and the smoke reads each treatment off the wire. The smoke itself read three findings wrong on this cell and was corrected.
- RC6 (unvalidated instruments). Partly stands: the session-flow report read the planning session as idle because it records a non-streamed completion, and the wiring smoke misjudged three treatments; both are fixed and tested on this branch.
What decided the outcomes was not the loop's architecture. It was a 40-turn cap with no extension, and a tool surface that refused a tokeniser as a secret, raced concurrent edits of one file, refused missing parent directories, hid parameter names behind a load round-trip, refused an array sent as JSON text and told the agent how a test run is recorded only after refusing it. All of that is fixed on the branch that carries this recording, with tests. Underneath it the model dominates cost: 95 to 100 percent of emitted text is hidden reasoning, and single turns ran to the 131K output cap and sat silent for twenty minutes.
The design decision this round records is none of the five the issue offered. The sweep was stopped before the repetitions that would have powered any of them, because the same evidence that argues for fixing the plumbing argues that the inner loop is not where this product's value is, which is #2916's question now. The INTEGRATE stage keeps its design; leaf repair rounds and tool-result abbreviation are not motivated by this cell; the sweep design question is moot until a next recording exists.
What the sweep measured¶
The figures below are the PILOT: six cells, caps 1 to 3, one repetition each, both arms, on an engine wired at 8 of 51 collaborators. They are superseded by the wired-engine cell above as a description of the product's loop, and kept because the process figures (attempts, amendments, parks) were measured over 35 merges rather than one.
evals/recursion_depth/results/pilot/ holds it: chart.svg,
depth_curve.json and depth_curve.md, beside the cells.jsonl and
progress.jsonl the run journalled as it went, plus the cells.raw.jsonl the
first of those replaced when its per-call spend repair became the recording's
own ledger. 240 units across 482 agent sessions, no cell unavailable. It is
re-scorable in place with
--rescore --out-dir evals/recursion_depth/results/pilot.
| achieved depth | gated | ungated |
|---|---|---|
| 1 | 0.000 (0/42) | 0.000 (0/42) |
| 2 | 0.857 (36/42) | 0.786 (33/42) |
| 3 | 0.857 (36/42) | 0.833 (35/42) |
Every cap reached the depth it allowed, so achieved depth and cap agree throughout and the histogram holds no surprises.
The answer, and why it is not the answer the question expected¶
Neither arm collapsed. The gated line is flat from depth 2 to depth 3 while its tree grew from 38 leaves to 58, and the ungated line ROSE, from 0.786 to 0.833. The question this experiment was built around assumed the ungated arm decays, because that is what ARIES measured, and asked only whether gating rescues it. There was nothing to rescue.
What that does NOT establish is whether the 11-to-25 coherent-unit ceiling is per level or global. The tempting reading is that it is per level and depth buys scale, since 58 units scored 86% and 58 is well past the band. But a single tree at each cap, on a recording whose merge verdicts are known to be unreliable, cannot carry that: it is one draw, and the contrast it would rest on is the depth-1 row the section below shows cannot be read. The question is open and the replication is what will answer it. What survives here is the weaker and still useful fact that neither arm decayed with depth.
The depth-1 zero is not a fan-in effect, and this recording cannot say what it is. The obvious reading, that seven units feeding one merge is an integration too wide to survive, is contradicted by the table it is drawn from: the deeper trees have more merges and more total fan-in, and they scored better. What actually distinguishes depth 1 is that it has exactly ONE merge, so that merge is a single point of failure with no second assembly to compensate.
That merge was also judged by a rule since replaced. Both cap-1 merges record
no assembly attempt changed anything the node declared, and both record their
two declared paths as absent. The journal says nothing about what they wrote
INSTEAD, because the rule in force recorded only declarations: that is the
whole defect, and it is why the replacement reads the produced tree. So the row
may be an honest zero or a verdict written off a naming disagreement, and this
recording cannot tell them apart.
The rule was not confined to depth 1. Merges carrying that same verdict: 1 of 1 at cap 1, 6 of 7 at cap 2, and 12 of 19 (gated) and 12 of 16 (ungated) at cap 3, in both arms throughout. Every row of the table above rests on cells where most assemblies were judged that way; the deeper rows are not clean readings spoiled by a contaminated first row, they are the same defect not proving fatal.
What this run cannot support¶
One repetition per cell, and each cell plans its own tree, so treatment and tree draw are confounded everywhere and there is not one controlled comparison in the run.
The depth finding survives that on effect size alone, 36 requirements against a largest arm difference of 3. It no longer draws support from cap 1 having failed twice with the same logged mechanism: that mechanism is the declared-path rule described above, so the repetition evidences a defect in how delivery was judged rather than anything about depth. Call it supported, not proven.
The arm difference does not survive it. 36 against 35 at depth 3, with the arms on different trees (58 leaves against 43), is inside the noise. The effect of the gate on quality cannot be distinguished from zero here, and equally cannot be ruled out as substantial.
What the arms DO differ in is process, measured over 35 merges rather than 2 cells: gated merges amend a child's interface 1.05 times each against 0.31, and spend 3.05 attempts against 6.00, because an ungated merge has no verdict to stop on and always burns its whole budget. Per recorded unit, plan row included, the gated arm used 1.73 sessions against 2.58, and its cell cost 1.15M tokens per leaf against 1.13M. The gate changes how the work converges; whether it changes the result is unmeasured.
This run's own attribution is not trustworthy¶
The figures below are the SPECIFICATION curve, which is the share of the
specification the merged tree satisfies rather than the share of leaf work
surviving the merge. This recording has a survival curve too, re-scored from
its own journal, but it reads n/a for the ungated arm at depths 2 and 3:
every claim that arm's planner made below the root named a criterion it had
invented one level up, so 143 of them attributed to nothing. That is the
per-level vocabulary defect The metric above describes, which
this recording ran under, so the figures here are read on the specification
curve throughout. A claim naming no requirement is refused where the planner
writes it and ends its cell before any leaf is paid for, so a later recording
carrying that caveat is reporting a regression rather than a known gap.
Reading the verdicts, which are easy to read wrongly¶
A reject does not mean the merge was discarded. The gate is a repair loop:
findings feed the next attempt, the workspace is mutated in place, and the final
tree is used whatever the last verdict said. Both arms get the same attempt
budget; only the gated arm's repairs are informed.
Parking short-circuited repair, against the gated arm, for this pilot.
run_merge broke on approved is True or parked when this recording ran, so a
merge escalating with no human to decide got fewer rounds than a rejected one.
The gated arm parked 6 of 19 merges at depth 3 (against 1 of 7 at depth 2) and
the ungated arm parks never, so the arm credited with repair received less of
it as depth rose. This was a harness bug: a park no longer takes the
approval branch, so a parked merge now keeps repairing through its remaining
attempts like a rejected one does, and the report separates cells that never
reached a verdict from the judged curve rather than counting them as gated.
The figures above are this pilot's own, measured under the old behaviour.
The reviewer pays a compliance tax the executor does not. 31 verdict submissions were refused for rejecting with an empty findings list, gated arms only. Every retry recovered and no rejection landed without findings, so the gate never degraded, but the cost falls entirely on the arm whose spend is being compared.
What was not recorded¶
Caps 4 to 6. ARIES puts the transition at 3 to 4, so the depth where the literature expects a blow-up is exactly the one beyond this recording: the chart's right end is absent rather than flat.
That is what the committed matrix now records: caps 1 to 4 at five repetitions each, one arm. It answers the two things this recording could not support, and takes the arm comparison off the table to pay for them.
What the run keeps¶
Every request and response crossing the recorder's own gateway is written to a
JSONL transcript, one file per session, keyed on the same execution id the
ledger keys its spend to, so a transcript and the cost it produced name the
same session. They land under the run's work root beside the trees
--keep-workspaces leaves, and are read against them.
This is not diagnostics for its own sake. The chart answers what each cell scored; the questions actually worth asking afterwards are why a merge was rejected, what the reviewer said, and whether a repair round addressed the finding or talked past it. None of that is recoverable once the run ends, and a sweep costs too much to repeat because nobody kept the reasoning.
Failures¶
Three outcomes, not two.
A missing provider, a dead gateway, a dead Docker daemon, or an oracle that cannot run at all is true of every remaining run, so it stops the matrix rather than being rediscovered once per cell at full retry cost. No report is written: there is nothing to report on.
The provider account running out of quota is the second, and it is a property of the ACCOUNT rather than of the cell that happened to ask last. Every remaining cell would be refused within seconds and filed under a cell-shaped reason, so the sweep stops there too, but it keeps what it paid for: the triggering cell is recorded unavailable, a caveat naming quota is added, and the report is emitted. One live sweep lost its whole remaining matrix in sixteen seconds before this outcome existed, and each lost row blamed decomposition.
Anything else records that one cell as unavailable with its reason and the sweep continues: the report is always written, a cell that cost real money is never dropped from it, and a run where nothing was measured is refused rather than published as a curve of zeros.
Where a sweep runs agent-authored code¶
Everywhere, in a container. The agents run in the sandbox image the CLI verified, and so does everything that grades what they produced: each unit's own suite, and the held-out oracle. Nothing a sweep executes runs on the host.
That is not belt-and-braces. Grading a tree means importing every conftest.py
and package __init__.py in it, so the process that grades is a process the
agent wrote. Running it on the host would give that code the network, the
operator's provider credentials, the bootstrap secrets the recording host puts
in its own environment, and the Docker socket, which is host root. The harness
already treats the agent's shell commands as untrusted and runs them at
network=none; running the artefacts of those same commands anywhere else would
have been the same code with the restrictions taken off.
Two consequences worth stating, because they are easy to conflate:
- Containment is complete. No network, no credentials, host unreachable, container thrown away.
- The grade is still self-reported, and nothing can change that. A tree that
wants to claim its tests passed is running arbitrary code in the process doing
the claiming. Reading the verdict from the machine-written XML report rather
than the exit code stops the ORDINARY failures reading as passes (
os._exit(0)in aconftest.py, a suite that collected nothing, a collection hook that deselected everything), which is what a model under pressure actually does. Deliberate forgery remains possible and is bounded elsewhere: forging only ever grows the survival DENOMINATOR, so it makes the measured result worse rather than better, and the numerator is decided by the held-out oracle, which never enters the tree and which the tree cannot write.
The oracle's container is built per grading from a scratch directory holding a copy of the tree beside a copy of the oracle, and destroyed after. That is what holds the oracle out from the workspaces: it exists only somewhere no agent runs, rather than being kept from agents by nothing having copied it.
Inside that container the two are unavoidably adjacent. pytest has to read the
assertions and the delivered program has to be executable, and there is one
filesystem, so a delivery spawned with its working directory in tree/ is one
.. from oracle/. The suite therefore deletes its own expectations once
collection has imported them. That happens before any test body runs, which is
before the delivered program is ever spawned.
What may remain is an allowlist rather than a set of patterns to sweep:
conftest.py, __init__.py and data/. That distinction is load-bearing. The
first version removed test_*.py and left __pycache__ behind, where the same
queries and expected rows were readable out of co_consts, and the test written
against the same predicate agreed that the directory was clean. Nothing compiled
is staged now, the sweep is keyed on what stays, the suite re-checks before every
spawn and the harness re-checks afterwards and refuses the measurement outright.
The adjacency is enforced, not prevented by construction, and it is worth saying
so in those words.
Related¶
- Coordination and resilience for the wave dispatcher the production tail drives a plan with.
- Verification and quality for the completion-oracle gate this experiment treats as its independent variable.
- Inner-loop A/B recording for the measurement the shared recording spine was first built for.