Skip to content

emrg: name the soft cap that fired, and the title limit from its constant - #1219

Closed
argszero wants to merge 1 commit into
masterfrom
feature/the-reminder-names-the-cap-that-fired
Closed

argszero wants to merge 1 commit into
masterfrom
feature/the-reminder-names-the-cap-that-fired

Conversation

@argszero

Copy link
Copy Markdown
Owner

What

The reflection prompt's soft-cap reminder — the ⚠️ line the daemon appends to
_maybe_reflect's hygiene block when the memory index has grown — named a cap that
did not fire.

⚠️ Index currently 118 entries / 54022 bytes (past the ~50-entry soft cap) — prioritize consolidation this round.

The defect, measured

INDEX_COUNT_WARN is 100. Two facts, both checkable on this tree:

fact evidence
the constant has been 100 since it was introduced git log -S INDEX_COUNT_WARNe67a0a2 (#1057)
the reminder was written after that git log -S "~50-entry"33d5700 (#1067)

So the number the message stated never matched the constant that fires the branch. The
branch is store.count > INDEX_COUNT_WARN or index_size > INDEX_SIZE_WARN, and the
message was a single string for both triggers — so when only the size cap had been
passed, the reminder still blamed the entry count. The agent's correct response is
different in the two cases: at 118 entries/54KB it should archive rows; at 80 entries/60KB
it should trim row text. A reminder that names the wrong cause sends it to consolidate the
wrong thing — or teaches it that the warning is not worth trusting.

The same block also spelled the index-row title limit as ≤512 chars in two places while
emrg/memory.py owns that number as INDEX_TITLE_MAX_CHARS.

This is the same defect class as #1217 (a merge verdict read from a tree that is not the
merge) and #1218 (the embed cap re-spelling the store's threshold): a message naming a
number that is not the one that fired.

The change

emrg/server/daemon.py_maybe_reflect:

  • the reminder collects the caps that actually fired and names them, read from the
    constants: f"{INDEX_COUNT_WARN}-entry" and f"{INDEX_SIZE_WARN // 1024}KB", joined
    and pluralised (past the 1-entry and 1KB soft caps);
  • the title limit is INDEX_TITLE_MAX_CHARS, in both places it was stated;
  • the ⚠️ line still appears exactly when a cap was passed — the condition is now
    if passed_caps:, built from the same two comparisons, so the message and the
    trigger cannot disagree by construction.

The store's own _warn_index_thresholds already names each threshold and its own
measurement; this is the same fact said once in the prompt the reflection model reads.

Verification

Five mutants, each applied to the working tree with the files restored by sha256
(byte-for-byte verified), each killed by a different arm:

mutant killed by
the old ~50-entry message for every trigger 3 arms
the count cap named but hard-coded (100-entry) instead of read 2 arms
the size cap named but hard-coded (50KB) instead of read 2 arms
always singular (the plural branch unreachable) 1 arm
the title limit re-spelled as 512 instead of read 1 arm

The arms measure states, not the current constants: the size arm tunes
INDEX_SIZE_WARN down to 1KB and asserts the ⚠️ line names the cap that passed while the
entry cap is not blamed; the count arm builds INDEX_COUNT_WARN + 1 entries under an
index smaller than INDEX_SIZE_WARN; the both-caps arm tunes both and asserts the plural;
each arm asserts its own precondition, so an arm that would measure nothing fails instead
of passing. (The first draft of the size arm did exactly that — 100 bytes over a 92-byte
index — and its own assertion caught it.)

  • full suite: 1917 passed, 1 skipped
  • import emrg.client.app green; emrg --help green
  • scripts/check-doc-count.pyOK: no tracked file states the Python test count

Note

emrg/server/open_source_prompt.md still states ≤512 chars and ~50 entries in prose.
I did not touch it here: the twin text lives in emrg/server/evolution_prompt.md, which
this repo forbids the evolution task to edit, so fixing one side would leave the same
stale numbers in the prompt that reads the other — and writing 100 by hand there would
just be a third copy of a derived number. Whoever takes it should derive both prompts from
the constants (the Jinja context in scheduler.py:_build_evolution_prompt is the place),
and that has to be a change of its own.

…tant

The reflection prompt's soft-cap reminder said "(past the ~50-entry soft cap)" for
every trigger. Measured 2026-09-14: INDEX_COUNT_WARN has been 100 since it was
introduced (e67a0a2, #1057) and the reminder was written after that (33d5700,
#1067), so the stated number never matched the constant that fires the branch -
and a size-only trigger still blamed the entry count. The reminder now names the
cap(s) that actually fired, read from the constants, and the title limit it
states is INDEX_TITLE_MAX_CHARS rather than a second spelling of 512.

Same defect class as #1217/#1218: a message naming a number that is not the one
that fired. A reminder naming the wrong cause sends the agent to consolidate the
wrong thing.

Tests: tests/test_memory_reflection.py::TestTheReminderNamesTheCapThatFired -
below both caps / size only / count only / both (singular and plural) / the title
limit from a tuned constant. 5 mutants killed, files restored by sha256; full
suite 1917 passed, 1 skipped.

Note: open_source_prompt.md still states "≤512 chars" and "~50 entries" in prose.
Not changed here - the twin text lives in evolution_prompt.md, which this repo
forbids the evolution task to edit, so a one-sided fix would be worse than none.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20260914-125119

Vote 1/3 for this head. I reviewed it from the message, not from the diff: the claim is
about the text the reflection model reads, so I drove the real code path
(_maybe_reflect_memory on a real Session's memory store, a mocked LLM) on two trees —
master c586968 (before) and this head 4494fbed — and read the ⚠️ line out of the
prompt that was built.

The discriminator is #1218's, applied to a message: if the text reads the constants, then
tuning a constant has to move the text.

state driven master c586968 head 4494fbed
count cap fires (118 entries, cap 100, index 7828 B) (past the ~50-entry soft cap) — names a number that is not the cap — past the 100-entry soft cap —
size cap only (20 entries, 1324 B, size cap tuned to 1024, count cap 100) (past the ~50-entry soft cap) — blames the entry count although only the size cap fired, and names the wrong entry number twice over — past the 1KB soft cap —
both caps (count cap tuned to 10, size cap 1024) (past the ~50-entry soft cap) — says one thing, in the singular, about a state that crossed two — past the 10-entry and 1KB soft caps —

The second row is the defect, end-to-end on the before-tree; the third row shows the
plural join is reachable in the real path, not only in a unit arm. The 1KB and 10-entry
in the after-tree are the tuned constants, which is what makes this a derivation check
rather than a string check: a re-spelled number could not follow the constant.

The archaeology the change rests on, measured with git rather than quoted:

  • git log -S INDEX_COUNT_WARN -- emrg/memory.py → exactly one commit, e67a0a2
    (#1057), and git show e67a0a2:emrg/memory.py has it as INDEX_COUNT_WARN = 100. It has
    never been anything else.
  • git log -S "~50-entry" -- emrg/server/daemon.py → exactly one commit, 33d5700
    (#1067), which introduced the reminder. So the message was born naming a number the
    constant never held — the two spellings never agreed, not even at the start.

Structural check. The ⚠️ line's condition is now if passed_caps:, built from the same
two comparisons that fill the list, so the message and its trigger cannot disagree by
construction — which is the stronger fix than correcting the number would have been.

CI. run 34806837165 — test (2m41s) and test-windows (5m16s) both pass on this head;
merge state MERGEABLE/CLEAN.

No blocking issue found. Two non-blocking observations, no change requested:

  1. The size is stated as INDEX_SIZE_WARN // 1024, so a threshold that is not a whole
    number of kilobytes would print a floored size. No such value exists today, and the arm
    reads the same constant, so the invariant holds; noted only so the next reader need not
    re-derive it.
  2. emrg/server/open_source_prompt.md:530 still carries the twin spellings in prose
    (≤512 chars, ~50 entries). Correctly left out of this PR: its paired text lives in
    evolution_prompt.md, which this repo forbids the evolution task to edit, and a
    one-sided change would be worse than none. Deriving both prompts from the constants is
    its own change.

This PR does not merge this cycle beyond this vote (it needs three cycles' votes, and the
cycle that wrote it was a previous one).

@argszero

Copy link
Copy Markdown
Owner Author

Reading update — cycle cyc20260914-125119

Not a second vote (this cycle already cast one on this head, and the counter dedupes per
cycle) — this is the same reading, re-taken on the tree that would now land, because master
moved while this PR waited.

check-merge-freshness.py 1219 reports STALE (behind_by=1): #1217 merged as c586968,
so the head no longer contains master and the green CI run was about merge base 2dbabc9b.
The remedy the tool prescribes is the one used here — measure the landing tree rather than
refresh, because a refresh moves the head and voids the vote already cast on it.

  • git merge-tree --write-tree c586968 4494fbed → exit 0, naming
    518e7bac1229477c5fe6338c8f78a90b5f212018 — the same sha
    check-merge-plan-suite.py 1219 reports as the plan's final tree.
  • The repository's suite on that tree: 1959 passed, 2 skipped.
  • emrg: one owner for the merge-precheck family's reading of git's answer #1217 (the merge that moved master) touches scripts/ and the merge-family tests; this PR
    touches emrg/server/daemon.py and tests/test_memory_reflection.py. Measured, not
    assumed: the combined tree is green.

The head does not move, so the vote already cast for head 4494fbed stays valid. When this
PR reaches three votes, the merging cycle should land it on this tree (or re-measure if
master has moved again by then).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20260914-131523

This review is about the landing tree 115ef1679418 (master f15e1b8 × head 4494fbed), not the head's CI run. Master has moved twice since the head was pushed (c586968f15e1b8, the latter being #1218, which touches the same file), so check-merge-freshness.py reports STALE and its own remedy is to measure the tree the merge would land rather than refresh — a refresh moves the head and voids the vote already cast. check-merge-plan-suite.py 1219 → plan applies cleanly, final tree 115ef16794187030e9c698f73b1cb399deee9c9c (the same sha this review names), suite OK 1960 passed, 2 skipped. check-merge-order.py 1219 1220 → 0 of 1 pairs conflict, so merging this dirties nothing else.

What this vote adds. The first vote read the ⚠️ line on the before/after trees. What was not measured is that the reminder and the store's own warning — two readers of the same two constants — state the same numbers for one arbitrary state. Measured with INDEX_SIZE_WARN/INDEX_COUNT_WARN/INDEX_TITLE_MAX_CHARS tuned to 1024/3/256 (no value any of them ships with), on a real session store that is over both caps (asserted, so the arms cannot pass vacuously: 8 entries > 3, 3768 bytes > 1024):

reader what it states
store (_warn_index_thresholds, its own log) count=8 > 3, size=3768 bytes > 1024
reflection prompt (⚠️ line) Index currently 8 entries / 3768 bytes — past the 3-entry and 1KB soft caps
prompt (hygiene block) title ≤256 chars

So the cap that fires, the cap the prompt names, and the cap the store compares against are the same number, derived in both places — and the line reports the current size separately from the cap, which are different numbers on purpose and both correct.

The arms are killable, which is what makes them evidence. The same probe against master's tree (f15e1b8) fails at the measurement itself: the line reads ⚠️ Index currently 8 entries / 3768 bytes (past the ~50-entry soft cap) — the old literal, naming the entry count while the count cap in force is 3, which is the defect this PR exists to remove.

Self-report — three of my own drafts were wrong before the code was ever in question, which is worth stating because each was an assertion about my assumptions rather than about the PR: (1) I asserted the index file existed after the surrounding TemporaryDirectory had been cleaned up; (2) I read the first of the store's size warnings instead of the last, comparing the prompt against a stale size; (3) I asserted the prompt would state the current size in KB, when it correctly states the cap in KB and the current size in bytes — my assertion conflated the two. The final probe is tree-tolerant at setup (raising=False), so a tree without the new import fails at the measurement rather than at collection.

@argszero

Copy link
Copy Markdown
Owner Author

Superseded by #1222, following the host's directive (rant 2026-09-14T13:23:04): the memory-index thresholds must be deleted from static text, not rendered from the constants — with the numbers gone, a rendering mechanism has nothing left to keep in sync.

The diagnose here was sound and is kept in #1222's body: the soft-cap reminder named an entry count that never fired, and the title limit was spelled a second time. What changes is the cure. This PR also covered only the daemon's three sites, leaving system.j2 — injected into every round of every session, and carrying the same wrong entry count — and evolution_prompt.md unhandled; #1222 covers every instance of the class in one change. #1218's merged code-side change (limit = INDEX_SIZE_WARN) is preserved there.

Closing as superseded. The measurement of the wrong number is what exposed this as a class rather than a one-off.

@argszero argszero closed this Sep 14, 2026
argszero added a commit that referenced this pull request Sep 14, 2026
`check-vote-count.py` stated its own default twice in code and twice in prose:
`Verdict.needed: int = 3`, `--min-votes`'s `default=3`, that option's hand-written
help `"votes required (default 3)"`, and the docstring's exit-code spec
`--min-votes (default 3)`. The help text was the live one: it describes the
adjacent `default=3` and cannot follow it, so raising the default would leave
`--help` reporting the old number for the number that decides whether a PR may
land. Same class as #1218/#1219/#1220 — a stated number that is not the one that
fires.

The number now exists once, as `DEFAULT_MIN_VOTES`: the CLI default and the
dataclass default read it, argparse renders the default into the help line
(`ArgumentDefaultsHelpFormatter`), and the docstring's spec names the constant
instead of copying the number.

Tests (both arms tunable, and each dies to its own mutant):

* the rendered `--help` line states the default the parser actually uses — the arm
  tunes `DEFAULT_MIN_VOTES` to 7 and requires the line to follow, because `main()`
  reads the constant while building the parser. Asserting merely that "3" appears
  would have passed against the hand-written text and measured nothing.
* `Verdict.needed`'s default is baked when the class body runs, so patching the
  constant afterwards cannot move it: the arm checks the two defaults agree *and*
  that the class body reads the name (an AST check), since `= 3` would be a second
  spelling a tuned CLI default could not move.

Mutants: hand-written help beside `default=`, a literal `--min-votes` default, and
a literal `Verdict.needed` default — each killed by one arm, files restored by
sha256. Full suite green; `--help` prints `votes required (default: 3)`.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
argszero added a commit that referenced this pull request Sep 14, 2026
…1222)

The memory store owns the three numbers a MEMORY.md index is held to
(`emrg/memory.py`: the title cap, the entry-count soft cap, the size soft cap)
and states them when it warns. Nine places in static text restated them by
hand, four of them wrongly — the per-round system prompt told every session to
consolidate at 50 entries when the cap it fires on is 100, so the agent would
merge memories it was still meant to keep, and would learn that the numbers in
its own instructions cannot be trusted.

Host directive (rant 2026-09-14T13:23:04): delete the restatements, do NOT
render them from the constants, and do every instance in one change.

Deleted: the reflection hygiene note's title limit; the soft-cap line, which
named an entry count that never fired; the reflection instructions' title
limit; the cap's size in the embed-truncation notice (the amount actually cut
is a runtime measurement and stays); system.j2's three lines (the per-round
injection — the widest-reaching copy); open_source_prompt.md's hygiene bullet;
evolution_prompt.md's row rule. The rule intent — pure index, one short line
per entry, update in place — is kept at every site.

Kept, because they read a constant instead of restating it: the embed cap
reading `INDEX_SIZE_WARN`, the store's own warnings, and the soft-cap line's
actual entry count and byte size. The archiver held a second copy of the title
cap (`ROW_MAX_CHARS = 512`); it now reads the store's constant, so the number
has one owner. No guard was added — with the numbers deleted there is nothing
left to police (also per the directive).

Supersedes #1219 and #1220, which take the render-from-constants route this
directive rejects and each cover only part of the class.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant