Genesis — Persistent Recursive Worlds¶
A software world that persists while its agents do not. An agent is situated by an accepted version and a repository path; delegation moves the path, only acceptance moves the version. Runs through
evolve()as oneStrategyand threePoliciesfields, with no engine change. Example:examples/genesis/genesis_recursive_worlds.py.
| Paper | Persistent Recursive Worlds Enable Autonomous Software Evolution — Beichen Huang, Zhenyu Liang, Bowen Zheng, Ran Cheng, 2026 (arXiv:2608.10450) |
| Upstream code | EMI-Group/genesis @ v0.12.6 (Elixir, AGPL-3.0) |
| Example | examples/genesis/genesis_recursive_worlds.py |
| Domains | Two compact formation runs, --domain: minilang (an integer expression language, 2 nodes deep, 4 files) and stackvm (a stack machine and its assembler, 4 nodes deep, 10 files), each grown from an implementation-empty repository against a frozen staged suite |
| Layer | L2 (blast_radius=0.2) — the evaluator is outside the artifact |
| Fidelity | mechanism_microport — what the classes mean |
The mechanism¶
The paper's model is four objects and two operations.
A local software world is a pair
w = (v, p)
of an accepted version and a repository-relative path. v fixes the whole
inheritable project; p says where an agent starts and what it is answerable
for. A finite-lived agent produces a candidate Δᵢ = Aᵢ((v,p), gᵢ) and then
ceases to exist — its conversation is not the identity of any later agent.
The two operations differ in exactly one way:
| operation | version | path |
|---|---|---|
recursive delegation (v,p) ⇝ (v,q) |
unchanged | moves to q |
accepted event (v,p) → (v′,p′) |
advances | stays, or is explicitly mapped |
Managers decompose and judge; leaf executors write files. A parent merges what
its children return with git merge --octopus and resolves a real conflict
itself. Only what the parent accepts is offered to the version history; a
refused change leaves nothing behind except, where the refusal was recorded in
CONTEXT.md, its reason.
Three rules keep that recursion honest, and each is upstream's:
- A node is a directory. Delegating to a file is refused — a path is a node when it can hold children.
- An agent writes only inside its own path, and a change it needs outside
it is reported upward, not made: the ancestor with authority there handles
it, under its own name. Upstream says this in as many words — "report the need
back up to your parent agent, which will handle it" (
agents/executor.ex:64), and the manager prompt explains it is the spatial contract, not an exception to it. - A sibling touching the same file is a merge, not a fault. One child may sit
inside another's subtree, so both may legally write one path; the parent
three-way merges them and only a real overlap goes back as re-planning, which
is what
git merge --octopusplus the conflict-file list does upstream.
CONTEXT.md is not documentation in this system, and the port treats it the same
way. It is part of v, so a later agent inherits it; the chain from the root
down to p is what an entering agent is given; and its routing table is
where a manager may delegate. A node its parent does not route to is a node
later agents cannot find, so a manager that opens one writes the entry at its own
level — which is a write to the accepted version like any other, gated like any
other.
How it plugs into evolve()¶
Every piece is a seam the engine already had. Nothing in agentdescent/ changed.
| Plug-in | evolve() slot |
What it does |
|---|---|---|
SpatialContract |
strategy= |
A directory, one key per path. An edit carries the path of the agent that made it, and an edit outside that subtree is dropped and counted. |
RecursiveDelegation |
Policies(proposal=) |
One rollout is a whole episode tree at one version: manager → children → leaf executors → the parent's verdict → the merged edit set. |
OctopusConflict |
Policies(conflict=) |
Three-way merges the contested values, so two agents editing two functions of one file both survive. Real overlaps fall through to the shipped rule. |
ParentJudge |
Policies(acceptance=) |
Upstream's monotone rule: partial progress is accepted; a regression is refused; a tie commits and sends more work to that subtree. |
Suite.review |
RecursiveDelegation(review=) |
The parent's own test run on one child's contribution, inside the episode — the tests and integration evidence of paper §3.3, which the acceptance gate cannot see because it only ever sees what the whole episode returned. |
LocalWorld / WorldLog |
— | (v,p) itself; the CONTEXT.md chain an entering agent is given; routing(), the table that decides where a manager may delegate; and the archive that outlives the agents. |
--keyed-union and --engine-gate turn the third and fourth rows back into the
engine's own defaults, which is how the rows below were measured.
Four domains, and what each one answers¶
--domain minilang is the original: a lexer, a parser and an evaluator under
src/frontend and src/backend, two nodes deep, four files for the agents to
write. It exercises every mechanism, and its recursion bottoms out at depth 2 —
which is fine for a merge comparison and thin for a system whose whole claim is
recursive organisation.
--domain stackvm grows a stack machine and its assembler: labels the assembler
resolves to instruction indices, a dispatch table that depends on three sibling
modules, and a loop in the suite. src/vm/ops is a node whose parent is itself a
child, so an episode reaches depth 3 on the way to a leaf, and arith.py holds
one independently-fillable function per opcode — so two agents working from two
different failing programs edit two different parts of one file, which is the case
a keyed union cannot fuse and a three-way merge can.
--domain jqx grows a JSON query language whose command-line entry point is
frozen beside the specification. The agents never write jqx.py; they write the
library it imports. A finished run is therefore a program you can run, not a
package nobody can invoke — and the difference showed up immediately as a class of
bug the other two domains cannot have, because a library that satisfies every case
can still be unusable from a shell.
--domain md grows Lennard-Jones molecular dynamics in reduced units — geometry,
two pair potentials, velocity Verlet, thermodynamic observables, a pair-distance
histogram — under a frozen driver that writes XYZ trajectories and an ASCII g(r).
It answers the thing the first three cannot, and it is a change of kind rather than
of size: there is no oracle in its scoring path.
Two ways to score a formation run, and only one of them is honest about it¶
The first three domains are scored by Suite:
a human writes a reference implementation, the loader runs it to compute the
expected answer for every case, and a candidate is scored by matching. It is cheap,
it is exact, and it is circular — you cannot ask a system to grow software you had
to write first. It also shapes what can be asked: an expected-output line cannot
carry a tolerance, an invariant, or "these two ways of computing this must agree".
md uses TestSuite instead. A human writes a specification and a test suite,
which is what a human actually writes. One task is one test function, a task's
prompt is that test's own source — prelude included, so the agent sees what
CONFIG is — and the reward is whether it passes. Nothing is compared against a
reference. This is also what upstream does: Genesis validates against c-testsuite,
LLVM and Csmith, which are assertions, with no reference compiler anywhere in the
loop.
What that buys is assertions an oracle cannot make. Many of md's tests are
invariants: the forces sum to zero, every force component matches a central
difference of the energy, energy and momentum survive a trajectory, reversing the
velocities retraces it. One of them asserts that a far-too-large timestep does
not conserve energy, so an implementation that fakes conservation fails. Euler
with a single force evaluation is stable, plausible, and caught — tests/test_genesis_example.py
breaks the integrator that way and checks the suite notices, because a sampled
position from a bounded trajectory often agrees to six decimal places.
It also buys a trap, and a real run walked into it. A field that returns zero
everywhere satisfies every invariant above: zero sums to zero, zero is the
gradient of a constant, nothing moves so nothing drifts, and reversing nothing
retraces it. A run shipped exactly that — d -= box * (d / box + 0.5) // 1, where
the // 1 binds after the multiplication, computing floor(d + L/2) instead of
L * floor(d/L + 0.5) and pushing every pair in a periodic box past the cutoff —
and 42 of the then 44 tests passed. Two noticed: the harmonic gradient, because
harmonic has no cutoff, and the negative control, whose whole docstring is the
conservation tests must be able to fail, or they assert nothing.
And a suite is only as good as how it is run. The finished md run reported
audit reward 1.000, and its repository contains this:
# src/observe/__init__.py
def rdf(positions, box, bins, rmax):
from .rdf import histogram # importing the submodule rebinds
... # src.observe.rdf from this function to
# the module: it destroys itself
The first call in a process works and every call after it raises
TypeError: 'module' object is not callable. Scored one test per process — which is
what one-task-per-test requires — it is perfect. Run the way a person runs a suite, in
one interpreter, five tests fail. Upstream has no such blind spot: its manager runs
mix test and its executor is told to "run ALL tests". So TestSuite.suite_failures
now runs the whole suite in one process, the run reports it beside the per-task
number, and --complete-task uses it as its precondition rather than the per-task
score. SUITE_ONLY_BASELINES keeps that exact code as a baseline: 65/65 per test,
60/65 in one process, and a test asserts both halves.
The lesson is not "add more invariants". A suite of invariants needs value anchors, and this one was missing the ones that matter: not a single driven test pinned a nonzero number in a periodic box. It does now — a pair in mid-box and a pair across the seam against the closed form, a large box against no box, and an explicit "this field is not identically zero".
And the mechanism, rather than my having noticed: TestSuite.baselines holds
deliberately wrong implementations, and a test requires every one of them to fail
the suite — to at least two tests each, because the zero field died to exactly one
and that one was the negative control. Six for md: the zero field, the real
precedence bug, round for floor, an unshifted Lennard-Jones, Euler dressed as
Verlet, and a centre-of-mass-corrected temperature. Writing it found two further
holes in the same sitting: the round geometry — the one thing the specification
explicitly forbids — passed the entire suite, and the temperature requirement hung
on a single test. It is mutation testing in its small form, and note what it does not
need: no reference in the scoring path. The reference sits beside the domain for
jobs that are not scoring, and suite QA is one of them. Coverage would have caught
none of it; the zero field executes every line of every test.
A reference implementation still exists next to the domain, for two jobs that are not scoring: driving the offline rule-based actor, and letting a test prove the suite is passable at all. Shipping a specification nobody has ever seen satisfied is its own kind of dishonesty.
The held-out tail is not a validation split¶
evolve() holds out the tail of the task list and reports its reward as
final_reward. For the oracle domains that is a generalisation estimate and it
means something: the cases are sampled inputs over one grammar, and passing inputs
you were not shown is the claim.
For a test suite it is a category error, and an expensive one. Every task is a
distinct requirement, so holding back 40% of them means refusing to tell the
system four tenths of what it has to do and then grading it on them. Worse, the
search never sees those requirements fail, so nothing is ever proposed for them.
That is exactly how the jqx run stalled at 0.923 with stop reason: rounds: the
case list was ordered by filter, two builtins appeared only in the held-out tail,
and no rollout could ever fail on them. A test guards the property now, for every
domain, and TestSuite interleaves its tasks across files so a positional split
cannot quietly hide a file.
Dropping the split entirely is also wrong, though, and for a reason specific to
this port: the executor is handed the source of the test it is failing, so it
could satisfy one assertion at a time without the physics underneath holding
together. So md splits the two jobs the single knob was doing:
- every driven test is part of the specification and every one of them drives
the search —
HELD_OUT_FRACis computed so thatevolve()'s positional cut lands exactly on the boundary; - the tail is an audit set of thirteen further tests that are not in the
repository at all —
frozenstops a file being written, not read, and an audit test in the tree is one the executor can read and write code against. They are injected into the scratch copy only while a held-out task is being scored: a different box shape, a brute-force search over periodic images, a harmonic oscillator's period, the structure layer checked against the geometry layer.
audit reward in the run header is therefore the honest headline number — the
score on tests nothing that wrote the code has ever seen.
What the human supplies, and what the run has to invent¶
A formation domain starts from fifteen files and not one line of implementation. That number deserves a challenge, and it got one. Sorted by who the author should be:
| what | why a human writes it |
|---|---|
spec/CONTEXT.md |
the goal, in prose and formulas. Unavoidable: something has to say what to build. |
tests/** |
the scoring. Upstream's equivalents are c-testsuite, LLVM and Csmith — also human-supplied, also frozen. |
md.py / jqx.py |
a frozen entry point, so the result is a program rather than a package nobody can invoke. A choice, and a defensible one. |
fly.py |
not supplied, not frozen. The fly domain freezes only REQUIREMENTS.md; the entry point's calling convention is written into the requirement (its section 4) and the agents write fly.py themselves. See the recorded deviations. |
CONTEXT.md per node, with routing tables |
…this one is not defensible. |
Six of md's fifteen files are CONTEXT.md records that name the nodes and what
each is for: src/potentials/pair/ -> one module per pair interaction. That is the
decomposition, and the paper's first phase is architecture and design — so a tree
that ships with it has had its architecture handed to it. The machinery to grow one
was always there and always measured (routes_opened: a manager may name a node
its table does not reach, and the table records it afterwards); there was simply
never a domain that started without one.
--cold-start takes it away. What is left is the goal, the contract, the suite and
the frozen driver — eight files for md — with the root's routing table emptied and
the skills removed, because a hint about how to lay out Python packages is a hint
about the shape of the answer. Nothing frozen is touched, so the scoring is
identical; what changes is that the run has to write its own CONTEXT.md chain as
it goes.
Two defects were hiding behind the scaffolding, both of them invisible while every domain shipped a table at the root:
routing()falls back to the sub-directories that exist when a node has no table. Cold-started, the root's only sub-directories arespec/andtests/— so a manager was cheerfully told to delegate into the two places it is forbidden to write. The world now carries the read-only globs and the fallback skips a directory with nothing writable in it.- A routing note was always bookkeeping, trimmed before a source file when an episode's edits exceeded the trust region. That is right for a note that adds a line to an existing table and wrong for one that creates a node's record: the source file is re-proposable next round, and the structure the system just invented is written nowhere else. A record that brings a node into existence is now trimmed last.
Offline on md, cold, --episodes 120: audit reward 1.000, depth 3,
routes_opened=30 — the tables the run wrote are in the repository it wrote. That
says the mechanism works from eight files; it says nothing about inventing a good
decomposition, because the rule-based actor's plan is a decomposition. Only a
model arm can speak to that, and it is measured separately below.
What the domains share¶
Everything that is not the software itself lives in _suite.py:
the child-process harness, the loader, the frozen-file restore, the parent's
integration check and the LLM actors. A domain is data on top of it — which is also
why the second, third and fourth could be added without touching a single
mechanism, and why TestSuite sits beside Suite rather than replacing it.
Measured results — compact formation domains¶
With a real model¶
deepseek-v4-flash through an Anthropic-shaped endpoint, --episodes 60
--workers 4 --no-thinking, three seeds. The repository starts implementation-empty
and the model writes every line of what comes out.
All of these ran under --sync, which was the default until the staleness seam above
was wired; the default is barrier-free now and the accepted-event counts are not
comparable across the two.
| domain | seed | held-out | accepted | episodes | depth | calls | wall-clock |
|---|---|---|---|---|---|---|---|
md, barrier-free + reflective (the default, stops itself) |
0 | 1.000 | 32 | 111 | 4 | 3 731 | 9.3 min |
md under --sync (test-scored, stops itself) |
0 | 1.000 | 48 | 346 | 3 | 2 821 | 48.6 min |
minilang |
0 | 1.000 | 5 | 34 | 3 | 284 | 135 s |
minilang |
1 | 1.000 | 5 | — | 3 | 306 | 173 s |
minilang |
2 | 1.000 | 5 | — | 2 | 242 | 100 s |
stackvm (--episodes 160) |
0 | 1.000 | 12 | 114 | 3 | 659 | 902 s |
Both seed-0 repositories were re-scored independently from what --write-repo
wrote: 30/30 each. On minilang it grew src/__init__.py plus
frontend/{lexer,parser}.py and backend/evaluator.py, with each stage imported
lazily because the specification says to — so the agents read the contract rather
than guessing it.
md: the run ended itself, and the result agrees with an independent implementation
The only run here that was not stopped by its round budget. Given a ceiling of
4 000 episodes it used 244, and ended on stop_when because the root agent
said so:
All 51 tests pass, every required function in
src/is implemented with real physics (Lennard-Jones with cutoff shift, harmonic, Velocity Verlet, observables, RDF, geometry), no stubs or dead modules remain, and the codebase is ready to hand.
Checked afterwards, three ways. pytest on what --write-repo wrote: 65/65,
matching the harness. The suite in one interpreter: 67/67, which is the check
that caught the previous run's self-destroying rdf. And a 108-particle,
600-step thermostatted run against the reference implementation: identical line
for line, every temperature, kinetic energy and momentum to six decimal places,
over 600 steps of chaotic dynamics.
The layout is the agents' own, not the one the CONTEXT.md records proposed:
core/geometry.py instead of the two modules suggested, no separate registry,
rdf inside observe/__init__.py. Eleven files under src/, none written by a
person.
The parent's reading gate did real work on the way: 188 diffs read, 28 sent back. The workspace ledger balanced — 437 worktrees created, 437 removed, 437 commits, 116 of them merges, five parents at the widest.
The suite constrains the surface, not the structure — and stackvm shows it
The stackvm run passes every case and the repository it wrote is not the
one the CONTEXT.md tree proposed. The agents moved the tokenizer and the
interpreter to src/tokenize.py and src/run.py (their own node's files, in
the accountability turn), made src/asm/parser a package, and gave the
opcodes a calling convention of their own — consistently, and the public
surface honours the frozen spec exactly, which is why it scores 1.000.
It also left two pieces of dead code behind: src/asm/lexer.py, shadowed by
the src/asm/lexer/ package that took its place, and src/vm/machine.py,
which imports op_add from a module that defines add and would raise on
import — nothing imports it, because src/run.py became the real interpreter.
This is the organization working as specified, not failing: acceptance is gated on validation, the validation checks three entry points and the data shapes, and anything the suite cannot see is not something any gate here can refuse. Upstream has the same property, with a suite that checks far more. It is worth stating plainly because "held-out 1.000" and "no dead code" are different claims and only the first one is measured.
Three defects of this port stood between a model and that result, each invisible until the one before it was gone; they are the section below, and none of them was the model.
With the offline actors¶
Offline rule-based actors, three seeds, one machine: --episodes 96 on minilang
and 160 on stackvm. Both repositories start at 0.000 — context records, a
frozen spec and one skill, no implementation — and every number below is identical
on all three seeds, because a rule-based actor on a fixed plan is deterministic.
| domain | arm | accepted events | merged |
held-out |
|---|---|---|---|---|
minilang |
--serial (upstream semantics) |
5 | 0 | 1.000 |
minilang |
4 workers | 4 | 1 | 1.000 |
minilang |
8 workers | 3 | 1 | 1.000 |
minilang |
4 or 8 workers, --keyed-union |
5 | — | 1.000 |
stackvm |
8 workers | 6 | 1 | 1.000 |
stackvm |
8 workers, --keyed-union |
8 | — | 1.000 |
The three-way merge buys accepted events, and the keyed union buys nothing from
parallelism at all. That is the whole table in one line: under the engine's rule
— where "same key" means "same file" — five accepted events at one worker, five at
four, five at eight. Under a textual three-way merge: five, then four, then three.
stackvm says the same at a different scale, 8 against 6. Two agents filling two
different functions of one file is an edit a keyed union cannot fuse and a
three-way merge can, and the counter (merged=1) says when it happened.
A claim this page used to make, and the measurement that retired it. Before
the manager's accountability turn existed, the offline arm under --engine-gate
reached 0.333 / 0.750 / 0.000 on the three seeds, refusing 8–11 candidates
each, and this page said the engine's statistical gate could not get a formation
run started. The reason given was right: a node's first files are structure, and
structure moves no case, so a gate demanding a measured improvement refuses them
and the run never reaches the steps that would have improved.
The accountability turn removed the condition, not the reasoning. A manager now
writes its own node's file in the same episode as its children's work, so commits
carry measurable progress from the first one, and --engine-gate reaches 1.000
on every seed of both domains. What is left is a cost, not a wall: on stackvm
it refuses 8–11 candidates where the parent's rule refuses none, and on minilang
0–1. Recorded this way round because a mechanism fix moving a published
measurement is the normal case, and quietly keeping the old headline is how a
results page stops being one.
What a real-model run found that the offline arm could not¶
The offline actors are rule-based, so they never mistake a file for a node and
never write outside their subtree. A run against deepseek-v4-flash through an
Anthropic-shaped endpoint did both within forty episodes, and turned up two
defects that no offline test could have reached.
A node is a directory, and nothing said so. A manager delegated to
src/frontend/lexer.py as though it were a node. The child was situated there,
wrote lexer.py/CONTEXT.md and lexer.py/__init__.py inside it, and every
stage accepted it: the key space is a flat dict, so a/b.py and a/b.py/c.py
coexist happily until something tries to put them on a filesystem. The run died
in EvolutionResult.write_to with FileExistsError after 328 model calls — the
whole thing lost at the one point where it was being saved. Two rules now stop
it, and they are separate on purpose because they need opposite fixes:
RecursiveDelegation refuses a delegation whose target is an existing file
(mistaken_nodes), and SpatialContract refuses any edit that would make the
key space stop being a tree (shape_violations).
The manager prompt never taught what a node is. With the rule in place the refusals were counted rather than fatal — 32 of them in one run — which is the counter doing its job and the prompt not doing its own. It now states that a node is a directory, lists what the node's routing table actually routes to, and says that naming a new child adds it to that table.
Two more mechanisms were missing, and the model found them too. With the node
rule in place, one run logged 70 refused children and 17 dropped edits: executors
kept producing changes outside their own path — src/frontend wanting to write
src/__init__.py. Upstream that is not an overstep, it is a report: "report
the need back up to your parent agent, which will handle it"
(agents/executor.ex:64), and the manager prompt says in as many words that this
is the spatial contract rather than an exception to it. The port had no such
channel, so every one of those needs died as a refusal. It has one now — the
ancestor with authority over the path makes the change, under its own name — and
the same run goes to 0 refusals and 0 dropped edits, with 53 of 99 requests
handled. The other was the sibling case: a child situated inside another's
subtree may legally write the same file, and rejecting on that collision threw
away a whole contribution for touching a file a sibling also touched. That is now
a three-way merge, like every other concurrent edit here.
This is the general point about the offline arm, stated in one place: it exercises the mechanism and it cannot exercise the mechanism's failure paths, because a rule-based actor does not fail that way. Every counter on the world line exists because a model does.
"Relative to what?" cost four files and a whole run. The run above put
lexer.py, parser.py, evaluator.py and __init__.py at the top of the
repository while the suite stayed at 0.000, and the first reading of that was
"the model asks for paths the project does not use". It was not. The root
__init__.py it wrote says "Frontend package: tokenizer and parser" and
from . import lexer: the agent situated at src/frontend meant
src/frontend/__init__.py and wrote the path relative to its own node. The
protocol said "relative path" without saying relative to what, this port read it
as repository-relative, the files fell outside the agent's subtree, became
upward requests, and the root — which does have authority everywhere — granted
them at the top level. Every layer behaved exactly as designed.
Three defects of this port's own stood between a model and a working toolchain, and the second and third were only visible once the first was gone.
"Relative to what?" Two fixes, because one alone is not enough. The protocol now names the agent's own
path in every example it shows and is rendered per episode rather than once, so
"relative" has one meaning. And resolve_edit_path resolves a path against the
node before anything else looks at it, ordered so that a genuine cross-node
request is never mangled into a nested copy of its own path: already inside the
node, or an existing file of the node's, means node-relative; a path whose first
segment is an existing top-level entry is repository-relative and therefore a
request about somebody else's node. Resolutions are counted
(node_relative_paths), because the alternative to counting is a file appearing
somewhere nobody asked for it. After it, every file landed where it belongs.
The agents could not see the contract they were judged against. The language
specification lives at spec/CONTEXT.md — a sibling of src/, so on nobody's
CONTEXT.md chain — and situate() handed an agent the chain, its own file
listing, and nothing else. So every agent inferred the whole language from one
failing input, and built a coherent toolchain with ('NUMBER', '1') tokens and a
parse(tokens) signature against a specification that says ("num", 1) and
parse(source). Five correct files, every one to the wrong contract.
That was a straight misreading of the paper: "An agent may inspect the complete
project represented by v, but it begins from p" (§3.1). The chain is where an
agent begins, not a wall around what it may read — upstream it would open the
spec with a read tool, and an agent here has no tools, so the human-supplied
contract travels in the brief or it is invisible. situate(contracts=) now shows
it in full wherever the agent stands. The next run's lexer emitted
[('num', 1), ('op', '+'), ('num', 2)].
A manager that delegates was never asked to finish. With the contract visible
and every path correct, the run produced five correct modules and no
src/__init__.py — the public surface the specification names did not exist, so
every case still scored zero, and the src node's own CONTEXT.md had told its
manager "Own src/__init__.py". It delegated every time instead.
Upstream's Architect works in three phases — architecture & design →
implementation delegation → review & accountability — and is "ACCOUNTABLE for
all code in its node path" (agents/architect.ex:23). This port stopped after the
second, twice over: a manager never wrote its own node, and an architect never came
back to a node it had designed.
The second of those is now --refine (on by default, --no-refine turns it off).
After a node's episode, its record is checked against what is actually in the
directory — a routing table promising a child nobody created, an ## API Surface
that never mentions a file sitting right there — and where it has drifted, an
architect is re-spawned on that one node to rewrite the record against the code
as it is. It fires on leaves too, and leaves need it most: a leaf is where the code
lands, so its API Surface is the first thing to go stale. This is where upstream's
62 later accepted CONTEXT.md updates come from, against this port's previous
two occasions — and a --mode a run sat at 0.938 for 30 003 rollouts reading a map
of a layout the work had already left behind, with nothing in the mechanism able to
say so. A manager now gets one turn at its own node after its children return,
seeing the tree as they left it, restricted to files directly at the node because a
manager free to rewrite its children's work would make the decomposition
decorative (--no-accountability turns it back into a pure router). situate()
also splits "files AT this node (yours to write)" from "files below it (each
child's own)", because an agent is bad at noticing an absence inside a long list
and the absence is the actionable part.
Where the Context Tree comes from — both of upstream's modes¶
Genesis.run branches on one question, and the two answers are two different root
agents:
if mode == :new do
run_new_codebase(...) # Mode B: Architect, then Manager
else
run_existing_codebase(...) # Mode A: ContextExtractor
end
--mode is that branch. b is the formation path: an architect designs the tree —
intent, API surface, constraints, routing table, one node at a time, recursively — and
the implementation phase then grows what it designed. a is the existing-codebase
path, and its root agent is the ContextExtractor: a repository with code and no
CONTEXT.md cannot be worked on by recursive delegation at all, because the routing
table is the map, so the first thing Genesis does to such a repository is read it and
write the tree over it. given, the default, is what this port did before either
existed: the domain's hand-written records, which is Phase 1 done by a person and
leaves only Phase 2 to measure.
The difference between the two agents is where children come from. An architect
invents them — nothing exists yet, and the decomposition is the product. An
extractor finds them: the directories are already there, and inventing one would be
describing a repository that does not exist. Extraction is the easier job and it shows:
--mode a over the md reference produced a tree whose routing tables name the real
directories at every level, with the classes, the signatures and even the
floor(x + 0.5)-not-round constraint read off the code. --mode b on the same model
took five attempts to stop producing degenerate trees, and the reason is the row below
about what an episode is: architecture is exactly the task that wants iteration and
review, and upstream's architect has both — 2 048 turns, file and shell tools, and a
Phase 1 (cont.) that reviews its sub-architects and re-spawns refinement architects
where a node misaligns. One completion per node has none of that.
Two of those five attempts were the port's mistake rather than the model's, and both are worth stating because they are the mistakes a port of this makes.
An objective is not a catalogue line. Phase 1 was handed DOMAIN_BLURB — "Lennard-
Jones molecular dynamics with a frozen driver (6 nodes, 14 files), test suite" — which
is the string the header prints, and an architect given twelve words invents the rest.
It read spec/CONTEXT.md correctly; the API Surface in the root record it wrote names
src/geometry.py. Then its routing table said ./geometry/, dropping the package root,
so src/ was never delegated to and the seven names the suite imports were never
written where it imports them. Every child that read the same specification then tried
to build its own src/ inside its own node, and 900 episodes later the world was
integrate/step/src/geometry/displacement/src/, held out at 0.562. Each domain now
carries an OBJECTIVE: what a person would brief an architect with, and the header
keeps its catalogue line.
The architect designs the codebase, not the repository around it. Fixing the brief
did not fix the tree: situated at the repository root and told the library lives at
src/, the architect resolved the contradiction by deciding it was src — record
titled # src, API Surface naming __init__.py, children geometry/, potentials/,
integrator/ hung at the repository root where nothing imports them. Three samples out
of three, and saying "you are situated at ./ and that is the directory your record
describes" in the prompt did not move it. Upstream's architect is started on the
codebase it creates; the specification, the suite and the entry point a person already
wrote are the harness around that codebase and exist before any agent does. So the
repository root is not a design problem and is no longer designed: --mode b generates
it from what the domain already declares — the frozen paths, and the package root
implied by its entry point — and phase 1 starts at the package root. That is one record
a person did not have to write for each domain and every node below it invented, against
eight hand-written records in given.
A finding the parent cannot delegate¶
--mode a over a repository whose src/observe/rdf.py was a zero stub, shadowing the
real implementation the child had written at src/observe/rdf/rdf.py. The parent review
diagnosed it exactly, in the run's own words:
the actual file
src/observe/rdf/rdf.pydefines a function namedrdf, nothistogram, so this import will fail at runtime
and rejected the child over it. But rdf.py and __init__.py belong to the
parent; no child may write them. So the finding went into the child's
## Known Issues, the node sat in open rework for 10 003 rollouts across two
samplers, and the repository never moved off 0.812 — while rewriting that one
parent-owned file takes it to 65/65. The fix was one edit at one node, and the system
had already written down what the edit was.
Upstream this cannot happen, because review and accountability are one phase and the
agent running it is "ACCOUNTABLE for all code in its node path"
(agents/architect.ex:23). A finding it cannot delegate is its own to act on. The port
had the accountability turn — it is what stops a manager delegating forever and never
writing src/__init__.py — but ran it blind: the manager took its own turn without
being told what its own review had just returned work over. It now carries them, and
the counters report findings_carried.
That moved the work to the right agent and the right file — the rerun's manager did
rewrite its own rdf.py, which neither earlier arm ever did — and the repository still
did not move, because the repair it wrote was from src.observe.rdf.rdf import rdf,
which raises 'src.observe.rdf' is not a package. The name resolves back to the file
doing the forwarding. rdf/ and rdf.py are one name to Python and the module
wins: everything the child wrote in that directory was unreachable from every import
in the repository, and no edit from either side can fix it while both exist. So the
collision is refused where it is created — _is_node and the architect both reject a
child that would shadow a sibling module, counted as shadowed_nodes — and where mode
A inherits one, the extractor writes it into the node's ## Known Issues naming the
consequence and the fact that one of the two has to go. The guard cannot undo history;
the record can tell the next agent what it is looking at.
It arrives from the other side too, and that is the side that cost --mode b its
completion. That run reached 1.000 and its root agent still refused to sign the
objective off — "the tree contains several empty __init__.py files and stub modules
(e.g. src/potentials/kernels/lennard_jones/__init__.py) that are dead scaffolding".
It was right that the node was dead and wrong about why: the manager at
src/potentials/kernels had delegated lennard_jones/ to a child and then, on its own
accountability turn, written lennard_jones.py beside it. _is_node cannot catch that
one, because at delegation time the file did not exist yet. So an edit that would
shadow an existing node directory is dropped and counted as shadowing_edits, on both
the leaf path and the accountability turn.
With both halves in, --mode b stops asking to be let off. The rerun ends on
stop_when — the root agent asked twice and the second time said complete: "All 51
tests pass, every required entry point is implemented with real physics … using lazy
imports". No collision survives in the result, and it got there in 243 rollouts
rather than 2 003, because none of the work landed in a directory no import could
reach.
The episode, and --executor claude-code¶
The gap that produced that result is the one the table below calls the largest, so the
port now offers the other side of it. --executor claude-code makes an executor episode
one headless Claude Code session in a throwaway worktree: it can read the failing
test, open the module next door, run the suite and fix what it broke — the loop a single
completion cannot have — and what it did is recovered by diffing the worktree rather
than parsed out of a reply.
The contract does not move. Three fences, none of which replaces the others:
- the session's own —
Read,Write,Edit,Glob,Grep,Bash, with the network tools off and a settings file denying every frozen glob by name, so the suite cannot be edited even by accident; - the sandbox — cwd is a throwaway copy of the accepted version, never a real repository, and nothing survives except through the diff;
- the port's own, unchanged and last — an edit outside the node is an upward request rather than an error, and a write to a frozen path is dropped and counted.
It signs in with the local claude CLI's credentials rather than the --model
endpoint, which is why it is opt-in and why the run header says so.
What the worktree does not bound: --sandbox. The worktree bounds what survives,
not what can be seen. A session with a shell reads whatever the process can read, and
in one run four of twelve episodes ran find / and opened a previous run's output from
/tmp — one of them read the very _cli.py that answered the acceptance failure it had
been handed to reproduce. The blind property was a line in a prompt.
The engine already owns the fix. agentdescent/sandbox_container.py is titled "A
sandbox that is actually a boundary", and --sandbox (on by default where an engine
answers) runs every agent session inside it. examples/genesis/_sandbox.py subclasses
its ContainerProvider and adds the mounts an agent session needs and a candidate's
test run does not: the claude binary's own install, the proxy's CA bundle, and a
per-session CLI state directory so the transcript outlives the container. Nothing else
of the host. Asked from inside, on this machine:
ls /home/user/agentdescent No such file or directory
find / -name 'algo-genesis.md' (nothing)
ls /tmp | wc -l 0
ls /work CONTEXT.md md.py spec src tests
touch /etc/x Read-only file system
grep CapEff /proc/self/status CapEff: 0000000000000000
Two honest limits. The network is on — SandboxSpec.network="inherit", because a
session's whole job is to reach a model endpoint — so this is a boundary against
contamination, which is the failure that happened, and not against hostile code, which
could still send what it read. And when no engine answers the run says so once and
falls back to a plain directory rather than pretending; the brief's "do not read outside
this checkout" rule is what is left, and a rule is not a wall.
Exercised once against a real session rather than only the stand-in binary the tests
drive. One leaf episode at src/frontend on minilang, twelve turns: it wrote
lexer.py and the package marker, nothing else, and the tokens it emits are
("num", 1) / ("op", "+") — the shape the specification pins, which is the exact
detail an early completion-based run got wrong in five files at once (('NUMBER','1'),
inferred from one failing input because the contract was not in the brief). With the
one line its parent owes — src/__init__.py re-exporting tokenize — the tok
stage goes 10/10.
It also declined to write that line itself, which is the more interesting half: asked
for the entry point as well, a session situated at src/frontend wrote only its own
node and said so, so the port's request channel never even had to fire (requests=0).
The contract held inside the session rather than at the boundary.
What actually ends an episode¶
A formation run on the fly domain reported sessions=52 failed=43 — 83% of
implementation episodes judged failed — while the review rejected only three and the
spatial contract dropped nothing. The CLI writes a transcript per session, so the
answer was on disk rather than in the counters, and it is not what the counters
suggested:
| sessions | ||
|---|---|---|
| ran into the wall (≥ 860 s of a 900 s limit) | 27 | 25 of them killed mid-tool-call |
| the endpoint dropped the stream first | 17 | 26 s to 830 s, also mid-tool |
| ended on a text turn, having finished | 8 | |
| reached the 128-turn child budget | 0 | the busiest made 97 |
Three things follow, and each was a defect rather than a tuning problem.
The wall is the binding constraint, not the turn budget. Upstream's 2 048/128 turns
are this port's numbers too and they are fine; what ends an episode here is wall-clock.
So it is --session-timeout, its own flag, printed in the run header beside the turn
budget — and not --timeout, which examples/_common documents as "seconds for one
model call" and defaults to 120 s. A session is a loop of many calls; sharing one number
between the two means either the call timeout is absurdly long or the session wall is
absurdly short.
The executor was the one role running on defaults. The architect, the manager, the
reviewer and the extractor were all constructed from the run's session settings; the
executor was constructed from three arguments of its own, so --timeout 600 and
--thinking-tokens 2048 reached every role except the one that writes the code. It ran
that whole domain at a 900 s wall nobody had chosen, with no reasoning cap while every
other role had one.
A session that hits the wall is interrupted, not refuted. The port reads the
worktree, not the exit status, so the work it did before the wall comes back and is
judged like any other — which is the right behaviour and was already the behaviour. What
the wall costs is the report: there is no JSON after a SIGKILL, so num_turns is
never read, and the run that reported turns=309 had 2 607 assistant turns in its
transcripts. Timeouts are now counted separately (failed=43 timeout=27) because the
two failures have different fixes.
One more thing the transcripts showed: subprocess.run(timeout=) signals the CLI and
nothing else. A session is told to run the suite, a suite run starts servers, and one of
them was still listening two hours later with its working directory already deleted.
Sessions now lead their own process group and the group is killed on the way out —
after the wall, and after a clean finish too.
A signed-in run cannot use the container sandbox at all¶
Worse than the prompt size, and found the same way. A key is a string in the
environment, and it crosses into a container with it — that is how CONTAINER_ENV
works and why a keyed run is isolated and authenticated at once. A sign-in is not a
string: the CLI reaches the endpoint through the host's session ingress, which the
container does not have.
So every containerised session answered Not logged in · Please run /login. The run's
own summary, for four episodes of four:
Phase 1 : architect designed 0 nodes, deepest 0, 1 replies unusable
claude code : sessions=4 failed=4 timeout=0 turns=4 edits=0 requests=0
architect : sessions=1 failed=1 timeout=0 turns=1 strays=0
A whole run for a condition that was knowable before the first episode started. The sandbox now says so up front, beside the two reasons it already gave — no container engine, and a toolchain that cannot be mounted — and degrades to a plain directory with the reason printed rather than failing an episode at a time.
That leaves three arrangements, and the first is the one to prefer:
| container sandbox | context per turn | where the credential is | |
|---|---|---|---|
| an API key | yes | 4 868 (--bare) |
an environment variable, inside the container |
signed in, --sandbox off |
no | 8 042 | on the host, which the session shares anyway |
| signed in, container | — | — | cannot authenticate |
The middle row is worth reading carefully, because it looks safer than it is: a session running without the sandbox is on the host as the host's user, so it reaches everything the sign-in reaches regardless. The isolation is what was lost, not the exposure.
A signed-in run pays for thirty-seven tools it cannot reach¶
--bare is what removes the inherited schemas, and it cannot be used by a run that is
signed in rather than keyed: it sets CLAUDE_CODE_SIMPLE=1, and that same switch
makes the CLI refuse to read OAuth. Measured, with a valid sign-in and no key:
duration_api_ms: 0 and an authentication error. The two cannot be separated.
So a signed-in run got the full prompt, and the full prompt is mostly tool schemas.
Asked to list what it has, such a session names forty-two tools — Artifact,
ArtifactComments, ArtifactData, CronCreate, CronDelete, CronList,
DesignSync, PushNotification, ShowOnboardingRolePicker, Workflow, SendUserFile
and the rest — when the run asked for four. --allowedTools is an auto-approve list: it
says what may be called, and every other schema is sent anyway.
What decides which schemas exist is which tools are defined, and --agents defines
its own. Measured on one endpoint with the same one-line prompt:
| context per turn | ||
|---|---|---|
signed in, --allowedTools Read,Edit,Glob,Grep |
24 550 | 1.00× |
signed in, --agents declaring five tools |
6 522 | 0.27× |
| signed in, the port's own executor command | 8 042 | 0.33× |
keyed, --bare |
4 868 | 0.20× |
The executor's number is higher than the bare probe because it declares three more
tools — Write, TodoWrite and Bash, the last being the only way a session can run
the suite it is judged by. The 3 174 that --bare still saves is the system prompt
itself, which --agents cannot touch: eight sections, and small beside the schemas.
lean_agent_flags returns nothing when --bare is already in play, so the two
mechanisms never both run — a key still takes the better path, and the declaration is
what a sign-in gets instead.
One rollout is the whole repository, and a cap of four was cutting it to four¶
RecursiveDelegation.propose descends the entire Context Tree: the root delegates,
every leaf writes, the parents fold what came back, and one proposal carries all of it.
So a rollout is not "pick a node and implement it" — it is a complete draft of the
repository, judged and merged as one accepted event, which is what w = (v, p) means.
Two bounds stood between that draft and the accepted version, and only the tighter one
ever applied. SpatialContract.max_files_per_diff is the one raised to 64 above;
RecursiveDelegation.max_edits, in _bound, sits upstream of it and was 4 — a
trust region sized for a single completion proposing a file or two. Measured on an
8-node fly tree, host sign-in, Haiku:
| implementation files the sessions wrote | 153 |
| files each sweep committed | 4 |
| Python files in the accepted state after three rollouts | 6 |
CONTEXT.md records in it |
9 |
src/training wrote 26 files, src/brain/circuits 24, src/arena 22, src/frontend
21, src/backend 17 — and the version grew by four a round. Raising the other cap had
changed nothing, because this one always cut first.
They are one number now — and so is a third one behind them, which took another run to
find; see Five gates below. What _bound does within the bound was not a defect and is
worth stating, since it looks like one: a node-creating record is trimmed last
(nothing else in the accepted version says the node exists, and the source file it
would be dropped for is re-proposable next round), work next, routine context
upkeep first.
The cap that discards the episode rather than the surplus¶
A third thing the transcripts showed, found by counting rather than reading. The spatial
contract caps a proposal at max_files_per_diff, and the cap rejects the diff, not
the files over the line — so an episode one file past it contributes nothing at all.
Counting distinct paths written per session across 309 productive sessions on this
machine:
| files written in one episode | sessions | at a cap of 6 |
|---|---|---|
| 1–6 | 274 | kept |
| 7–12 | 32 | lost whole |
| 13–23 | 3 | lost whole |
11% of the episodes that did work contributed none of it, the largest of them carrying 23 files, and nothing in the run said so: the two violation counters are about authority — an agent wrote outside its subtree, or mistook a file for a node — and neither moves for this. A run that lost an eighth of its work and a run whose agents had nothing to say printed the same header.
Both halves were wrong, and they are separate fixes.
The drop is now counted — discarded_diffs=N (M files) in the world summary —
because a silent loss is the thing this port keeps finding.
And the cap is no longer a trust region, because it was never doing that job here. The
trust region is the node's subtree, enforced edit by edit by the spatial contract:
twelve files under src/brain/olfactory/ are not more dangerous than six, and the
parent's verdict and the frozen suite judge both the same way. A file count on top of
that is a third bound that only ever fires on legitimate work. So for a session executor
it is set where only pathology reaches it — a loop that dumps a tree — and nowhere near
a node's build:
| cap | episodes lost whole, of 309 |
|---|---|
| 6 | 35 (11.3%) |
| 12 | 3 (1.0%) |
| 24 | 0 |
| 64 | 0 |
24 is where it stops binding; 64 is the number, three times the largest legitimate episode. A single completion asked for whole files keeps the tight 6 — that executor proposes one or two files, and six there really is a runaway.
Five gates, and the habit of raising one at a time¶
Both fixes above were right and neither was enough, because both were found the same way: follow the path a proposal takes until something rejects it, raise that, re-run. Three runs in a row died that way. The last of them — one rollout, thirteen sessions, 680 turns, forty minutes, with the two caps above already raised — merged this:
sweep 0 reward=0.000 files=9 committed=0 rejected=1 rollouts=1 40m [oversized=1]
world : accepted=5 rejected=4 rework=4 depth=4 discarded_diffs=0 truncated_edits=0
wrote 9 files (nine CONTEXT.md records, not one line of implementation)
discarded_diffs=0 and truncated_edits=0: the port's own two bounds passed the
proposal through cleanly. It was rejected behind them, by a third cap, in the engine.
A proposal crosses five bounds between the session that writes a file and the state that keeps it. Listing them all was the fix; each of them had been the answer once:
| where | field | was | now | |
|---|---|---|---|---|
| 1 | RecursiveDelegation._bound |
max_edits |
4 | the cap |
| 2 | SpatialContract.to_diff |
max_files_per_diff |
6 | the cap |
| 3 | SpatialContract.to_diff |
max_file_bytes |
28 000 | unchanged |
| 4 | Aggregator._apply_trust_region |
trust_region_ops |
6 | the cap |
| 5 | Aggregator._apply_trust_region |
trust_region_chars |
32 000 | ≥ #3 |
4 and 5 are the engine's defaults, and this port had never passed an agg_config
at all — so they were invisible from the example, sized for the rule tables the engine
was built on, and applied unchanged to a proposal carrying an eight-node repository.
Six ops. The measurement that settles it: across that rollout the largest single file
any session wrote was 12 813 chars, well inside both character bounds, so only the
op count was ever binding.
They are one number now, engine_bounds(strategy), and the shape is deliberate: the
port's cap decides, because it is the one that counts what it drops
(discarded_diffs) and the one whose value is argued for from measurement. The engine's
two are placed where they cannot bind first — ops at the same cap, chars no tighter than
the per-file bound that already filtered every op, so a file over #3 is dropped on its
own instead of returning as a whole diff lost to #5.
One trap on the way, worth writing down because it is silent in the other direction:
evolve() does not fall back to AggregatorConfig() when it is passed nothing. It
builds AggregatorConfig(batch_trigger=2, max_wait_rounds=1) — and a config passed in
replaces that object whole. A run that only wanted a wider trust region would have
doubled its batch trigger and tripled its wait on the way past. Both arms were measured
and both arrive at (2, 1); the port carries them across, and a test reads them back out
of a real evolve() rather than repeating the literals, so the day the engine picks
different ones the port is told rather than drifting.
The habit is the finding, not the constant. Following one path to the first rejection finds a cap; it cannot tell you it was the only one. Enumerating every gate on the path costs one reading of the merge routine and would have saved three runs.
One rollout was an hour, and four workers spent it on the same tree¶
A rollout here is RecursiveDelegation.propose: a walk of the whole Context Tree with
one Claude Code session per node. It was a for loop, so the walk was serial — and on
the fly tree (8 nodes, deepest 2) a session takes five to fifteen minutes, which makes
a rollout about an hour. py-spy on a live run, 75 minutes in:
Thread-1: _episode → _episode → _episode → _episode
Thread-2: _accountability_pass → _episode → _episode → _episode
Thread-3: _accountability_pass → _episode → _episode
All four workers still inside their first propose(). The accepted state held three
files. That looked like an acceptance problem and was not one: the parent gate accepts a
tie, the reviewer said ACCEPT in four of the five reviews that had run, and the staleness
policy keeps a card whose reward is merely unchanged. Nothing was being rejected. Nothing
had finished.
Worse, --workers is the number of whole tree walks in flight, so four workers were
four independent descents of the same eight nodes — two of them sat on
src/brain/navigation at the same moment, doing the same node's work, of which only one
result could ever survive.
So siblings run together now, and the knobs say which kind of parallelism you are asking for:
| flag | what it multiplies |
|---|---|
--workers |
whole tree walks at once (keep small; they duplicate each other) |
--node-workers |
siblings one manager runs at once (this is the useful one) |
--max-sessions |
the hard bound on what the endpoint sees; defaults to their product |
The spatial contract is what makes the concurrency safe rather than a merge problem: a
child may write only under its own subtree, so two siblings cannot touch the same path,
and each gets its own throwaway worktree branched from the same base. What the parent
does with the results — the octopus fold, the conflict check, the requests it adopts —
is unchanged and still runs in delegations order, so the same results produce the same
proposal. The bound lives in run_cli, not in the thread pools, because sessions are the
scarce thing and a pool per level would multiply; sessions never nest (a manager's own
session runs before and after its children's, never during), so one semaphore there
bounds the whole tree with no way for a parent to deadlock on its own children.
Every counter the port reports is now incremented under a lock. x += 1 is a read and a
write with a bytecode boundary between them, and this port's claim is that its numbers
are measured.
A session launched from a session is not that session¶
The transcripts turned up a second thing, and it is the more expensive one. A run launched from inside a Claude Code session had every episode inheriting that session's situation. Measured against the same endpoint with the same one-line prompt:
| input tokens | latency | transcript | |
|---|---|---|---|
| inherited | 31 850 | 6.7 s | 224 KB |
the run's own --allowedTools / --disallowedTools |
31 099 | ||
--bare --strict-mcp-config |
1 317 | 2.4 s | 16 KB |
The middle row is the surprise and the whole mechanism. Permission flags do not
shorten the request. --allowedTools Read,Write,Edit,Glob,Grep,Bash says what the
session may call; every other tool's schema is still sent, and the port had been
passing those flags all along.
What is actually in there, from the transcript's own prompt_snapshot: the system
prompt is 5 720 characters, and the tool schemas are 178 742 — 26 of them, of which
Artifact alone is 64 168, then Monitor at 14 335 and DesignSync at 13 255. The
executor is allowed six tools; Read and Bash together are 6 887 characters of that
list. Beside it ride a 13.5 KB skill listing, a 3 KB agent listing, 900 bytes of
deferred tool names, and the user's email address.
So it is not that the episode was given competing instructions — nobody told it to go review a pull request. It is that a CLI launched inside a managed session is handed that session's equipment, and equipment is priced per turn whether or not it is reachable: 30 000 tokens before the brief, ~50 turns a session, 52 sessions. That feeds straight back into the wall above.
Three things were shared and are now not:
- Identity.
CLAUDE_CODE_SESSION_IDand its siblings are dropped, so each episode is its own session. Inherited, all 52 wrote transcripts named with the host's session id, and theirTodoWritestate — keyed by that id — landed in the host's own task list, two hundred entries of "Implement src/brain package". - State.
CLAUDE_CONFIG_DIRpoints at one directory per run, so transcripts, todos and synced skills stay out of~/.claude, which one run had left 685 project directories in. The directory is reported at the end of the run and deliberately not deleted: those transcripts are the only record of what an episode did. - Context.
--bare --strict-mcp-config, which drops hooks, LSP, plugin sync, commit attribution, auto-memory, MCP servers andCLAUDE.mdauto-discovery — the artifact carriesCONTEXT.mdrecords the brief names, not aCLAUDE.md.
Bare mode reads credentials strictly from ANTHROPIC_API_KEY, never OAuth and never the
keychain, so it is used only when the run brought its own key. A session billed to
the local CLI's sign-in makes no API call at all with it — measured, duration_api_ms:
0 — which is a worse failure than a long prompt. The three fences are untouched: a bare
session still honours .claude/settings.local.json, checked by asking one to append to
a denied path and watching it refuse.
Synchronous or barrier-free, and which one is upstream's¶
--async switches evolve() to its barrier-free runtime, and it is a fair question
which of the two this port should be run in, because upstream has no round barrier at
all: an agent commits, releases its worktree, is re-queued, and the archive shows up
to 22 episodes overlapping. Measured offline, same seed, same budget, deterministic
actors:
md, sync |
md, --async |
minilang, sync |
minilang, --async |
|
|---|---|---|---|---|
| held-out / audit | 1.000 | 1.000 | 1.000 | 1.000 |
| accepted events | 5 | 10 | 3 | 7 |
| agent episodes | 84 | 214 | 87 | 218 |
| stale considered / discarded | 15 / 0 | 40 / 20 | 22 / 0 | 56 / 1 |
| wall clock | 7.2 s | 8.2 s | 2.9 s | 5.1 s |
Same destination, twice the accepted events and two and a half times the episodes to
get there. That is the cost of no barrier, and the mechanism is visible in the row
above it: a proposal built on a version that has since moved is discarded —
20 whole episode trees on md, thrown away for being stale.
Which raises the thing worth saying, because it is a difference in kind rather than in
tuning. Neither mode is upstream's concurrency, because upstream's concurrency is
not between rollouts at all: it is a parent and its children, inside one episode tree,
merged by that parent with an octopus merge — and this port does exactly that in the
proposal policy, in both modes. The engine's round barrier is a property of how
evolve() searches, not of Genesis.
So --async is closer in shape (no barrier) and introduces a behaviour upstream does
not have: discard on staleness. Upstream never discards for lag — the parent merges what
its child returns, whatever the child branched from, and that is what
Git.merge_octopus/2 is for.
Which the engine already has a seam for: Policies(staleness=). The first reading of
that sentence was FullStaleness — never discards for lag — and it was wrong, in a way
that took a model run to show. The operative word is merges. Full does not
merge: it takes the diff as-is, so a stale whole-file edit replaces the current file and
silently reverts whatever was done to it in between. Upstream's parent does not do that;
it merges the child's commit into the tree it now has.
ReflectiveStaleness is that: replay the diff on the current head, keep it if it still
improves, discard only if it no longer does — never for being late. Which is the
parent's own monotone rule (agents/manager.ex:58) applied to a lagging contribution,
and it is the default here.
The evidence is one model run each, so it is a demonstration rather than a comparison:
| model arm | rollouts | result |
|---|---|---|
--sync |
244 (ended itself, root agent) | 1.000 |
barrier-free, --staleness full |
4 003 (budget exhausted) | 0.812, open rework: src, src/observe/rdf |
barrier-free, --staleness reflective |
2 791 (ended itself, root agent) | 1.000, 67/67 in one process |
Sixteen times the rollouts and a worse repository, with the self-clobbering rdf back
in it — and the same domain, same model, same budget under reflective ends itself at
1.000 with one stale proposal discarded, for no longer improving rather than for being
late. The mechanism explains it without needing the sample size: merged=0
conflicted=0 in that run, because in barrier-free mode cards arrive one at a time and
the conflict policy only sees contests within a batch — so nothing was three-way
merged, and every stale whole-file edit simply overwrote.
| sync | --async |
--async --staleness full |
|
|---|---|---|---|
md — accepted / discarded / episodes |
5 / 0 / 84 | 10 / 22 / 198 | 22 / 0 / 183 |
minilang — accepted / discarded / episodes |
3 / 0 / 87 | 7 / 6 / 202 | 13 / 0 / 154 |
| held-out / audit | 1.000 | 1.000 | 1.000 |
All four domains, offline, re-measured under both defaults at one budget
(--episodes 96 --workers 4), which is the comparison the flip should be judged on:
| domain | --sync accepted |
default (barrier-free, nothing discarded) | discarded | reward |
|---|---|---|---|---|
minilang |
4 | 12 (+2 refused) | 0 | 1.000 / 1.000 |
stackvm |
7 | 22 (+2 refused) | 0 | 1.000 / 1.000 |
jqx |
8 | 14 (+2 refused) | 0 | 1.000 / 1.000 |
md |
5 | 22 | 0 | 1.000 / 1.000 |
Every domain reaches the same place, nothing is discarded in either arm at this budget,
and the barrier-free arm commits two to three times as often — each proposal landing as
its own version instead of waiting for a barrier that would have folded several into
one. The below-threshold refusals that appear in the async column are the parent's
gate doing its job on proposals that arrive after the version has moved past them: a
tie or a regression against a head that is no longer what the proposer saw.
Nothing is thrown away and the episode count does not rise to pay for it — the work was always being done, the question was only whether a proposal that arrived late was kept. What does rise is the number of accepted events, and that is the same fact seen from the other side: each proposal the guarded policy dropped now lands as a version of its own. So "accepted events to reach 1.000" stops being a cost and starts being a count of commits, which is worth saying because that number is the merge comparison's cost metric two sections up, and the two are not measured under the same policy.
One thing this does not show: that the three-way merge rescues a contested stale
edit. merged is 0 on md and 2 on minilang here, so in these runs almost nothing
landed on a file another proposal had touched. The claim measured is narrower and it is
the one that matters for fidelity — under --async --staleness full nothing is
discarded for being late, which is upstream's rule.
Offline, all four staleness arms reach 1.000 on every domain, which is worth stating because it is a limit of the offline arm rather than a result: rule-based actors follow one plan and two rollouts never genuinely contest a file, so the failure mode cannot appear there at all.
Barrier-free is the default now, with reflective. A run is barrier-free with nothing discarded
for lag unless --sync (or --serial, which is the one-worker arm of the comparison
above and means the barrier too) puts the barrier back. The header prints which one is
in force, and every number on this page that predates the change was measured under
--sync and is labelled so.
An open question the runs raised and could not settle¶
--episodes is a budget of root episodes, and late in a run most of it buys nothing.
Measured on two model runs: 2 791 rollouts produced 111 agent episodes, and 2 003
produced 28. The rest were no-ops — a worker samples a task, the artifact already
passes it, and there is nothing to propose against.
RoundRobin is the engine's default sampler and says so itself: it "spends rollouts
uniformly, including on tasks the agent already solves". DifficultyWeighted exists
beside it and down-weights a task whose pass rate has gone to 1, which is also the more
faithful of the two — upstream's manager runs all the tests, identifies the failures,
and spawns one agent per failure, never one per passing test.
It is nonetheless off by default, because nothing here has shown it to help. The
offline arm cannot: it reaches 1.000 long before most tasks pass, so the waste has no
time to appear, and round-robin and weighted come out at 218 and 208 episodes from the
same 403-rollout budget. Settling it needs a model run per arm, which is the one
measurement this page does not have. --signal-weighted turns it on.
The paper's numbers, and this port's¶
The mechanism is the thing being ported; the numbers are not, and putting them side by
side is the clearest way to say which is which. Upstream's figures are the formation
run — the one md stands in for — from §4.1 and the appendix tables.
| paper, formation run | this port, md |
comparable? | |
|---|---|---|---|
| wall clock | 123.402 h (666.385 h of agent time) | 0.6 h | no — the domain is a stand-in, by design |
| archived agent episodes | 1,019 | 237 (from ~42 proposals over 4 000 rollouts) | in kind only |
| observed delegation depth | 5 (configured max 8, retries 15) | 3 (configured max 4) | in kind — both bottom out below their ceiling |
| what one episode is | a supervised session of up to 2 048 root turns / 128 child turns, with file, shell and test tools | one model call by default; --executor claude-code makes it a Claude Code session at upstream's own two budgets — 2 048 at the root, 128 below |
with --executor claude-code, and a plain completion otherwise |
| result size | 750 tracked files, 248 989 physical lines | 25 files, ~700 lines | no |
| model-token cost | US$44.3760 | not billed by this endpoint; 2.9 M prompt + 0.15 M completion tokens | no |
| concurrency | max 22 overlapping episodes | 4 workers | in kind |
| validation | complete c-testsuite, most LLVM and Csmith | a 67-task frozen suite, 16 of them held out | in kind |
CONTEXT.md maintenance |
26 files created, 62 later accepted updates affecting 19 files | creations, plus routing entries and refusals; no other updates | no — and this is the second gap |
Two of those rows are gaps rather than scale differences, and both are recorded below:
an episode here is a single completion rather than a tool-using session, and the
context records are written on two occasions rather than maintained. Everything else
in the table differs by three orders of magnitude because the domain was chosen to
finish in an afternoon, which is what mechanism_microport means.
What is aligned is the shape: (v,p), delegation that moves the path and not the
version, the spatial contract, the parent's three-part validation, partial progress
accepted, the octopus merge, CONTEXT.md as the routing mechanism, a worktree and a
commit per episode with the worktree released, and termination by an agent's judgment.
Each has its own row in the table at the top of this page and a test behind it.
Honesty boundary¶
--offline — the default — proposes with rule-based actors that reveal
pre-written module implementations one step at a time, the same device as
DGM's surrogate objective and the molecule search's
offline operators. It exercises the port's mechanism and says nothing about a
model's ability to write software. --model supplies manager and executor agents
that are asked instead of computed.
Upstream's own task-level acceptance is a human action: merge_and_report/4
never merges, it only creates a genesis/agent_<hex> branch, and the merge is a
button on the dashboard (EvoGit.Review.merge_branch/2,3). The judging this port
automates is the one that runs inside an episode — a parent on its child — plus
the task-level gate, which is the aggregator. A reader who takes "123 hours
autonomous" to mean "no human in the accept path" is reading upstream wrong, and
so would a reader of this page without this paragraph.
Recorded deviations¶
- Upstream has no objective function, and this port's scalar is the engine's, not
Genesis's.
grep -rio 'fitness|reward|score'overapps/evo_gitreturns exactly one hit,oom_score_adjust. Nothing in the released code computes a number over a validation suite. What stands in that place is local: the executor verifies its own work and writes tests (agents/executor.ex), the parent reviews the result and runs the suite (agents/manager.ex), the architect's third phase runs the build and reviews the implementation (agents/architect.ex), the tests are "the definition of done" handed to the agents as guidance (runtime/genesis.ex:152), termination is an agent callingcomplete_task"only when the codebase is complete, functional, and polished", and a human merges or rejects on the dashboard review page.evolve(), by contrast, is a reward-driven loop: it samples a failing task, asks for a proposal, scores it, accepts or rejects, and reports a number. So the per-node gate in this port is upstream's mechanism, andaudit rewardis measurement — the thing that makes the port legible, not the thing that makes it work. - The benchmark is not reproduced. See the warning above. Fidelity class is
mechanism_microportfor that reason and no other — the mechanism maps cleanly. - Tensor parallelism is refused rather than used. TP is the engine's own
"each worker owns a disjoint section, conflict-free by construction", which is
word for word the spatial contract. It cannot be used here: its ownership map is
built once before round 0 from a declared key space, and a key outside it
belongs to no section — so every newly created file is a
section-violation(evolution.py:2739;FileTree.keyssays so itself). Formation is nothing but creating files.SpatialContracttherefore declares no key space, which makesevolve()refuse TP with a message naming the reason instead of silently discarding the run's entire output, and enforces the contract into_diff, where creation is free. - The world is L2, not L1. The audit gate runs before acceptance and, above
FAST_MAX, vetoes every candidate that does not strictly improve — which contradicts "partial progress is accepted" outright, and at L1 this domain commits nothing at all. L2 is honest here because the agents cannot reach the evaluator: the suite, its expectations and the harness that runs them live outside the artifact, andspec/**is refused to every proposal and restored pristine before scoring. Governance is deliberately not a seam, so this is recorded as a boundary rather than routed around. - "Request more work" is reconstructed.
AcceptDecisionis a boolean whosecategoryis a closed vocabulary — the aggregator turns it into aMergeOutcome, so an invented name raises inside the merge. A parent's not yet therefore commits the partial step and leaves a request in theWorldLogthat the next round's manager re-delegates from, rather than becoming a third engine outcome. - Observed depth is 2 here, 4–8 upstream, because the domain's decomposition is three nodes deep. The number reported is always the depth observed, never the depth configured.
- Multi-repository work (
foreign_repos), the Tauri desktop shell, the Phoenix dashboard and peak-hour scheduling are out of scope. - The parent's validation is both halves now, and was one for a long time.
agents/manager.exstates it as three things in order — "Review subagent results. Run tests to validate changes. Check for code quality: duplicated code, defensive code that silently swallows errors, and missing test coverage. Reject work that introduces these anti-patterns" — and the architect's Phase 3 is the same shape. This port had only the middle one,Suite.review, which is a pass count. What a pass count cannot see is a function that computes nothing, and that is exactly what it failed to see: the zero-field run above._review.pyadds the reading half as one model call per returned child, asking upstream's questions about the whole file; a rejection sends the work back with a reason the next round re-delegates from. "Missing test coverage" is left out of the questions on purpose — the suite is frozen here, so no child could act on that finding.--no-parent-reviewturns it off; the offline arm has no model and therefore no reviewer, which is one more reason the offline numbers say less than they look like they do. CONTEXT.mdis inherited and routed from, but only partly maintained. The read half is faithful: the chain is assembled root-first exactly asContextNode.build_context/2does, each record truncated per file at upstream's owntruncation.context_max_bytes(65 536) with upstream's own marker and no global budget — this port had one global 8 000-char cap instead, eight times tighter, which is half of how md's agents never received their specification. The routing table is load-bearing: the offline manager's decomposition is parsed from it, not hardcoded, so editing the table changes where the run delegates (there is a test that does exactly that). The human-written records carry upstream's four standard sections — Intent, API Surface, Constraints, Routing Table — andAPI Surfacewas missing from every one of them until the md run lost five of seven keyword parameters. The write half is still thinner than upstream, where every:read_writeagent keeps its node current and two read-only roles exist to do nothing else: here an agent writesCONTEXT.mdon its own in exactly two cases, the routing entry for a node it opened and the refusal for a child it rejected — the latter under## Known Issues, which is upstream's heading for "problems to avoid re-discovering". An LLM executor may write more; nothing requires it to.- What the agent is handed, versus what it could fetch. Upstream an agent has file
tools and pulls what it needs; the brief carries only the
CONTEXT.mdchain plus its location. Agents here have no tools, so the contract has to travel with the brief or be invisible — which is whysituate(contracts=)exists at all. The push is therefore deliberately narrower than "everything frozen": md pushes the specification and the frozen driver, while the 12 kB test suite arrives one failing test at a time in the task prompt.FROZEN(what may not be written),CONTRACTS(what is pushed) andreadonly(where a manager may not be sent) are three sets for three jobs; conflating the first two is howmd.pyonce crowded out the spec. - Termination can be an agent's judgment, with
--complete-task. Upstream an agent callscomplete_taskwhen it believes the objective is met, and a human merges or rejects afterwards on the dashboard; here the run ends when the round budget ends, which is why a finished domain still reportsstop reason: rounds— nobody decided it was done.CompletionJudgeasks instead, throughevolve(stop_when=), with upstream's two gates in upstream's order: every test the agents can see must pass first ("treat them as the definition of done"), and below that the question is not asked and no model call is spent; then the root agent judges the codebase — stubs, dead modules, anything it would not hand over as finished. It is never shown the held-out reward: that number is this port's measurement, computed from tests no agent may read, and handing it over would turn the judgment back into a threshold. The human review at the end of upstream's chain is still out of scope. - Worktree isolation and "commit before delegating", with
--worktrees. Upstream this is the scheduling model, not a detail of it: "commit your changes, release your worktree, and wait... If you don't commit, your changes are invisible to subagents." Each episode now gets a git worktree of its own, commits its work there on a branch of its own, and the worktree is removed — the release a cooperative scheduler requires — with a ledger that has to balance. What that buys is not correctness: siblings here branch from the same base by construction because the episode tree is a pure function over a state dict, and a test holds that property directly. It buys three things that were missing. The phylogenetic graph becomes git history — one commit per episode, and a parent that kept three children leaves a four-parent merge commit, soGit.merge_octopus/2's shape is there andphylo_graph_node.ex'sfind_merge_base/2has something to find. "Commit before delegating" becomes checkable, and acting on it found a real gap: a manager that opens a node now writes that node into its routing table before briefing the child, which is whatmake_dir(auto-commits) then spawn means, and before this the child arrived at a node its own parent's table did not mention. And every episode record carries the sha it could be resurrected from, which is the third field of upstream's(node_path, commit_sha, objective)tuple and the one this port did not have. Measured on md offline: 98 worktrees created, 98 removed, 98 commits, 41 of them merges, five parents at the widest, same reward, 4% wall-clock. - Parallelism is still the engine's workers. Upstream's concurrency is unbounded
and each subagent's worktree is where its commands run; here
evolve()'s workers propose concurrently against one accepted version andOctopusConflictdoes the three-way merge in the parent. The observable consequence is the same — two children editing one file both survive — and the measurement of that is in the table above. - Skills are inherited, and now extracted, with
--extract-skills.LocalWorld.skills()collects.agents/skills/along the node chain, which is whathierarchical_skill_names/2does upstream and what the paper means by listing "reusable skills" among what an accepted version carries (§3.1). This port had only that half. The other half is upstream'sSkillExtractor(agents/skill_extractor.ex, a:read_write,delegation_level :lowrole): it distils a completed contribution into new skills, checks what already exists so it does not duplicate one, places each at the Context Tree node where it is relevant, and is explicitly allowed to find nothing.SkillExtractorinexamples/genesis/_skills.pyis that role, run at the root episode boundary on the contribution the episode accepted -- upstream fires it after the PR merges, but on this engine a post-merge version is one no later episode of the same run sees, so the loop would still not close. Two things are adapted rather than ported: the model returns the skill's node and body and this side rendersskill_add's frontmatter, and the bodies travel in the brief once the flag is on. The second one is the whole point --skill_readis a tool, this port's agents have none, and a name line saying "read one before using it" is an instruction nobody can follow, which is why inheritance was inert before. - An episode here is one model call; upstream it is a session. The paper is
explicit — "the agent can execute multiple model–tool turns during one supervised
episode" — and the runs are configured at 2 048 root turns and 128 turns per
non-root episode, with file, shell and test tools inside each one. Here an episode
is a single completion with no tools: the manager is asked once where to delegate,
the executor is asked once for whole files, and the brief has to carry everything
either of them could otherwise have fetched (which is what
situate(contracts=)is for). Everything downstream of that follows from it — why the domains are small, why the actors cannot run the tests themselves, why a "rework" costs a whole new episode rather than another turn. It is the largest single distance between this port and the system it ports, and it is a property of the harness rather than of the algorithm:evolve()proposes with one completion per rollout. - Only two roles are ported, plus one capability the paper calls a label. The
released code has ten agent modules; the paper's appendix §1.3 says the model has two
— manager and leaf executor — and that "codebase lead / investigator / task scheduler
are implementation labels, not additional roles". Porting ten would be porting an
implementation. The one exception is the investigator, and it is ported as a
capability rather than a role:
--investigatelets a manager mark a delegation read-only, andInvestigator(examples/genesis/_investigator.py) reads that node's files and records what it finds under## Findingsin the node'sCONTEXT.md. The reason it earns its place is the port's own blind spot —_MANAGER_PROMPTcarries theCONTEXT.mdchain and a listing of files but never their contents, because this port's agents have no read tool, so the investigator is the one actor that can read a node's code and leave a note about it for the next agent. It is deliberately not the upstream role in full: there is no fan-out tosubagent_investigator, because fanning out read-only sub-agents is what a manager with read tools of its own is for, and that is the port's session work. - The
flydomain does not freeze its entry point (fly.py), and that is a realignment toward upstream, not a deviation from it.mdandjqxfreeze a human-written driver (md.py,jqx.py) so the result is a program rather than a package nobody can invoke; the table above calls that "a choice, and a defensible one" — it is a choice of this port's, not something upstream has: an upstream Mode B run is handed a one-sentence objective, and nothing in the repository is frozen for it.flytherefore freezes onlyREQUIREMENTS.md(FROZEN = ("REQUIREMENTS.md",), also the only entry inCONTRACTS). Whoever uses the simulator needs a calling convention, and for a fly simulator nothing implies one the way "a C compiler" impliescc -o foo foo.c, so the convention is stated as a requirement (section 4 ofREQUIREMENTS.md: the four namesfly.pyimports) andfly.pyitself is written by the agents. The cost is real: a run can now fail to produce an entry point at all, which a frozen driver made impossible, soflyruns are not directly comparable tomd/jqxnumbers. - The
flyacceptance suite is blind and lives outside the repository — a realignment with how upstream is evaluated, and a deviation from this port's other domains. Inmd,jqx,minilangandstackvmthe human writes the suite, it sits in the frozentests/**, and the failing test's source is pasted into the agent's prompt. In upstream the agents write their own tests and run them ("tests are the definition of done",agents/manager.ex); the external benchmarks (c-testsuite, LLVM, Csmith) are applied by the experimenter afterwards and never enter the inner loop.flyfollows that: thehiddensuite drivesevolve()but is merged into a scratch copy only while one evaluation runs, the prompt carries the requirement heading and the assertion's name (no threshold, no inputs, no source), and a secondauditset is sealed and measured afterwards. The repository's own tests are the agents' and are enforced byown_review(a child's failing own tests, or implementation code with no test under its node, sends the work back — test file means a.pybasename matchingtest_*.pyor*_test.py; seeTEST_FILE_GLOBS). What this does not reproduce is upstream's human merge, nor its benchmarks: the hidden suite is still this port's own, written by the port's author, so the realignment is of where the tests sit relative to the agents, not of what they measure.
Run it¶
python -m examples.genesis.genesis_recursive_worlds # offline, no API key
python -m examples.genesis.genesis_recursive_worlds --domain md # tests, not an oracle
python -m examples.genesis.genesis_recursive_worlds --episodes 96 --workers 8
python -m examples.genesis.genesis_recursive_worlds --episodes 96 --serial # upstream semantics
python -m examples.genesis.genesis_recursive_worlds --keyed-union # control: engine merge
python -m examples.genesis.genesis_recursive_worlds --engine-gate # control: engine gate
python -m examples.genesis.genesis_recursive_worlds --model claude-haiku-4-5 # real agents
python -m examples.genesis.genesis_recursive_worlds --write-repo /tmp/world # keep what it grew
--dry-run prints the plan with zero network access and no API key.
Offline tests:
tests/test_genesis_example.py.