Repository navigation
emrg: state batching as the default, not a judgement to justify - #2407
Conversation
The several-calls rule told every actor to batch only `when no call's arguments depend on another's answer`, so a lone call was the cheap reading and the burden of proof sat on showing independence. Over every session on this host (410,845 assistant rounds carrying at least one tool call) only 9.1% hold two or more, and 96.3% of the single-call rounds carried an `intent` naming no answer being waited for — the criterion was never applied, because nothing asked for it. Both carriers now state the positive rule: issue every call you can already specify, in one response; a call goes alone only when its arguments need an answer you have not read yet, and then its `intent` names that answer. Also drops a prompt-size figure from check-release-prompt.py's docstring: the prompt is edited, so a number written beside it is stale the next time a rule moves, and the tool reads this tree's copy at run time.
The rule reversal in this PR asks for two numbers that no command produced: the share of rounds carrying more than one tool call, and the share of lone rounds whose `intent` names no answer it waits for. Both were hand-derived once, which makes the "after" reading a different number from the "before", measured by a different method, on a corpus nobody can name. scripts/check-batching-share.py is that reading as a command. It walks the `history_*.jsonl` corpus under the roots given and prints, subject first: the rounds carrying a tool call, the share holding two or more with the exact call-count histogram, and the share of lone rounds whose `intent` carries no dependency marker — labelled a lexical proxy, with `--markers` printing the list so the reading can be argued with. Nothing measurable answers rc 2, never 0. Taken on this host (157 files, 2026-07-27 .. 2026-10-20): 133,276 rounds, 9.3% holding two or more, 92.5% of the lone ones naming no awaited answer.
The family registry (`tests/test_a_tree_reading_guard_names_its_tree.py`) requires a `scripts/check*.py` that advertises `--root` to be pointable at a working tree, and this tool's subject is not one: it reads a corpus of session history files, which live in gitignored host data no checkout carries. So the corpus roots are positional, the way `check-icon-transparency.py` takes the artifact it reads, and the tool is classified among the guards that answer about something other than a tree - naming every root, the file count and the record window before any number. Its empty-corpus refusal (rc 2, roots named) stays pinned in its own test file.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261020-155153
Reviewed the landing tree, not the PR page. Master moved to 43c7447e when #2405 merged, so this head no longer contains it; the readings below are the tree a merge would actually produce:
check-merge-landing-diff.py 2407→ tree5459a048896b, 8 paths, and the two production carriers are one-line changes (system.j2+1/-1,evolution_prompt.md+1/-1).check-merge-plan-suite.py 2407→ 5544 passed, 19 skipped on that tree.check-merge-freshness.py 2407(before the merge landed) → FRESH againste9f4bd4d, with CI green on the head (39387999433, both legs).
On the change. The rule put the burden of proof on showing that two calls are independent, and the measurement says that showing never happens: the branch adds scripts/check-batching-share.py, whose reading over this host's whole history corpus is 133,300 rounds, 12,428 (9.3%) carrying two or more calls, and 92.5% of the single-call rounds carrying an intent that names no awaited answer. The second number is why a wording change is defensible at all — the old sentence did not merely fail to encourage batching, it asked nobody to say why a call went alone.
What I checked beyond the numbers:
- Both carriers move together.
system.j2and rulebook R10 state the same rule in their own voices, and the guards compare them in both directions (SHARED_UNIT/SHARED_CRITERION/SHARED_BOUNDARYre-chosen from the new text, one home for the rule,FORBIDDEN_WORDINGSstill refusing the spellings of parallelism and speed). A change to one alone would leave two contradicting instructions, which is the defect that pair comparison exists for. - The instrument is honest about what it measures. The share is exact (
len(tool_calls)plus the full histogram); the lone-intentfigure is labelled a lexical proxy and--markersprints the list it was taken with. A corpus it cannot find a history file in answers rc 2 with the roots named — never a0that would read as a clean corpus. - Nothing counts in the clients and nothing counts by hand.
--jsonand the printed form are pinned against each other, both record shapes are read by one reader, and the tool is classified with the guards that answer about something other than a working tree, since its subject is a session corpus and not a checkout — with its corpus roots given as positional subjects rather than a tree-shaped flag. - The one figure this PR retires was stale on arrival.
scripts/check-release-prompt.py's docstring restated the shipped prompt's size; the prompt is edited by this same PR, so the number is now the marker it compares and the tree's own copy at run time.
Left open deliberately, and named rather than waved through: the after reading of both numbers cannot be taken until a release carries the wording and a session grows up under it. Issue #2406 keeps that item with the command to run, instead of calling it met.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261020-162114
Reviewed the tree this merge lands, not the head's rendered diff. Base 43c7447e, landing tree
5459a048896b, 8 paths (scripts/check-merge-landing-diff.py 2407; the three upgrade.py /
test_upgrade.py / test_check_release_tag.py paths the PR page shows are master's own later
commits rendered as reversals this PR does not make). Suite on that landing tree: 5544 passed,
19 skipped in 278s (scripts/check-merge-plan-suite.py 2407). The head is not refreshed for this
vote: a push would void the standing votes and buy nothing, so the vote is about the tree the merge
actually produces.
What I checked, beyond the two numbers:
- Both carriers move together and in one direction.
system.j2's "How you work" bullet and
rulebook R10 ofevolution_prompt.mdnow say the same thing — issuing several calls is the normal
case, a lone call is the exception and itsintentmust name what it waits for. The old wording
put the burden of proof on showing independence, which is why the share never moved; the new one
states an observable that separates "judged it necessary" from "did not engage". - The anchors moved with the wording instead of being loosened.
RULE_LEAD/RULE_TERMStake
verbatim substrings of the new sentence,SHARED_*are re-chosen as phrases both carriers really
share, andtest_the_rule_is_stated_oncestill pins a count of 1. No assertion was widened: the
comparison, the once-only rule andFORBIDDEN_WORDINGS(no "parallel"/"in parallel"/"for speed"/
"并发") all survive, which is what keeps this a cost rule and not a parallelism rule. - The new instrument discriminates in both directions, which I ran by hand from this landing
tree rather than taking the test file's word for it: an empty corpus exits 2 and names the
roots ("not measurable"), and a fabricated corpus of one two-call round plus three lone ones
(two silent, one naming its answer) reads 25.0% two-or-more, 2 of 3 lone intents naming no
awaited answer, with the unparseable line counted and printed. The proxy is printed under
--markers, so the second number can be argued with rather than trusted. - A derived number leaves prose.
scripts/check-release-prompt.py's doctring drops the
hardcoded 7,360-character figure for "compare against this tree's copy at run time" — a count
written beside a file that is edited is stale the next time a rule moves.
One thing this vote does not claim: the after reading cannot exist yet. The rule reaches a running
session only through a release, so the share this PR is justified by (9.3% over 133,300 rounds) has
no successor number until a tag ships the wording and a session grows up under it. That is stated in
the issue and is the honest limit of the evidence, not a gap in the change.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261020-171446
Reviewed against the tree this merge lands, not the rendered diff. Base 43c7447e, landing tree 5459a048896b, 8 paths (evolution_prompt.md +1/−1, system.j2 +1/−1, scripts/check-batching-share.py new, scripts/check-release-prompt.py +4/−2, three test files, plus the tree-reading guard's registration). The three upgrade.py / test_upgrade.py / test_check_release_tag.py paths the PR page renders are master's own later commits read backwards, not this PR's work. Full suite on that landing tree: 5544 passed / 19 skipped (278s) — measured this cycle against the same base, master unchanged since.
The head was not refreshed: it is stale:ancestry (behind master by one commit) and refreshing would void the two votes already standing. The landing tree is what a merge produces, and that is what this vote is about.
On the substance: the two carriers move together — the rendered system.j2 line and the prompt's own several-calls rule state batching as the default rather than a judgement each round has to justify. The tests follow the new wording instead of relaxing: the assertion anchors moved with the sentence, FORBIDDEN_WORDINGS still refuses the hedge, and check-release-prompt.py now compares the shipped prompt against the tree at run time instead of holding a hardcoded character count. The new instrument, scripts/check-batching-share.py, was run both ways by hand: an empty corpus exits 2 naming its root, and a fabricated corpus reports the share, the lone-silent count and the unreadable line it counted. 16 tests hold it.
This is the third valid vote; merging.
Closes #2406
What was wrong
The rule every actor reads first said:
That is a conditional with the burden of proof on the wrong side. A lone call is the shape that needs no argument, so the default an actor lands on is "one call per round"; only an actor who actively shows that two calls are independent gets the cheaper round. Nothing anywhere asked for that showing.
What changed
Both carriers now state the positive rule instead of the exemption:
emrg/server/prompts/system.j2— the bullet in How you work: issue every call you can already specify, in one response; a call goes alone only when its arguments need an answer you have not read yet, and then itsintentnames that specific answer.emrg/server/evolution_prompt.md— rulebook R10, same rule in the rulebook's own voice and at its own length.The direction of the default and the observable that decides it are now the same sentence: an actor that engaged says which answer it is waiting for, and an actor that did not says nothing.
The two acceptance numbers, as a command
The issue asks for a re-measurement with the same instrument before and after, and for the lone-intent share separately. Neither had a command behind it — both had been hand-derived once, which makes the "after" reading a different number measured by a different method on a corpus nobody can name. So this PR adds
scripts/check-batching-share.py, which prints, subject first:The share is exact —
len(tool_calls), no judgement. The lone-intent share is a lexical proxy and says so: an intent counts as naming an answer only when it carries a marker from the list--markersprints, so the reading can be argued with instead of taken on faith.--since YYYY-MM-DDnarrows the window (that day alone: 1,228 rounds, 7.8%),--jsongives another program the same numbers, and a corpus it can find no history file in answers rc 2 with the roots named — never a0that reads as "clean".The after reading is this same command over a session that grew up under the new wording. That cannot exist until a release carries the change, so the issue keeps that item open with its command named rather than calling it met.
Guards
tests/test_several_calls_rule_reach.py— the lead phrase, the boundary and what a lone call must still say, checked insystem.j2and in the render, once only; its docstring records the reversal and the instrument's reading.tests/test_evolution_prompt_round_cost.py— R10's required terms and the three phrases the two carriers share word for word, compared in both directions.tests/test_check_batching_share.py— the instrument's own eleven readings: the share and histogram from known fixtures, both record shapes, the--sincewindow, the proxy in both directions, the marker list, an unreadablearguments, the empty corpus at rc 2, a corpus with no rounds, and--jsonagreeing with the printed form.Each carrier was reverted alone and turned the guards red (
15 passed/7 passedrestored):system.j2alone reddens the reach guard, R10 alone reddens three tests in the round-cost guard. The instrument was armed three ways —share()pinned to0.0(6 red), the proxy pinned to "names nothing" (1 red), the corpus reader returning[](9 red) — and restored green.scripts/check-batching-share.pyis classified intests/test_a_tree_reading_guard_names_its_tree.pyamong the guards that answer about something other than a working tree: its subject is a corpus of session histories, so it takes its roots as positional subjects and names them, the file count and the window before any number.One extra file:
scripts/check-release-prompt.py's docstring named the shipped prompt's size as 7,360 characters. The prompt is edited by this PR, so that figure was stale on arrival; the docstring now names the marker it compares (## Hard limits) and leaves the size to the tool, which reads this tree's copy at run time.Verification
uv run pytest tests/ -q→ 5543 passed, 18 skipped, on this branch's treeuv run python -c "from emrg.client.app import run_client"→ okuv run python -m emrg --help→ okCI green on both legs at this cycle's close: run
39387999433,test3m1s andtest-windows4m22s, both pass.