Skip to content

Eval-call memo: the growing-accumulator key is derived per push, not rehashed per call (ruling: key derivation, not admission) - #12066

Merged
gunbai-bot[bot] merged 9 commits into
mainfrom
session/royal-otter-431
Sep 23, 2026
Merged

gunbai-bot[bot] merged 9 commits into
mainfrom
session/royal-otter-431

Conversation

@gunbai-bot

@gunbai-bot gunbai-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #12065 (its branch is merged in); land after it.

The ruling

The link that breaks its contract is key derivation. Admission is fine, so this PR adds no "can this call recur" criterion.

  • The list content hash (eval_recompute_value_hash, EvalRecomputeFrameKind::List) is a left fold mix(h, child) from a fixed seed, with no finalizer. So hash(push(xs, x)) == mix(hash(xs), hash(x)) holds exactly. The key relation can be derived in O(item). The implementation computed it in O(|xs|) on every call, because the identity memo is keyed by Rc pointer and every list_push returns a new Rc. This is the same defect class Interpreter: a threaded String argument is content-hashed once per lineage, not once per call #12065 closed for strings. It is a relation realized at the wrong cost, not a provider that fails admission.
  • Why not an admission criterion? "Can never recur" is undecidable. Every candidate structural proxy (a freshly built argument, a growing accumulator, key cost proportional to a growing value) is a heuristic. A heuristic threshold fails without signal in the cost direction: it would stop memoizing hot calls and nothing would report it (DESIGN §5, absorbing fallback; §4 says a heuristic is never necessary in a closed system). Once the key costs O(item), an unrecurrable call costs a bounded constant: one key, one map insert, and Rc clones of its arguments. So the super-linear defect is gone without deciding recurrence at all.
  • What remains at admission (stated, not fixed here): each unrecurrable call still stores one entry and keeps its arguments alive. That cost is bounded per call and capped by EVAL_CALL_MEMO_ENTRY_CAP, and the RRB vectors share structure. Whether that retention passes std.materialization_ladder's serving-cost test is a separate question about retention, not key cost. Answering it needs a recurrence census from the trace ledger, not a heuristic.

Change

eval_recompute_extend_push_hash runs on both list_push arms (the method arm and the free-call arm). If the parent list's hash is alive in the memo, it inserts the child's hash as mix(parent_h, item_h). The item is hashed through the same memo. The extension only runs when demand has already paid for the parent: a list that was never keyed is never hashed. A Closure item bails, leaving the ordinary derivation to refuse it. Correctness does not depend on the hash alone, because memo hits still verify arguments by equality (eval_call_memo_args_match).

Evidence

rung before, memo on before, memo off after, memo on after, memo off
build_only_48000 108851 108362 118214 115028
parse_16000 186109 143285 142655 130965
parse_48000 649582 182831 199260 182104

Parse cost above the build-only baseline at 48000 went from about 541s with the memo on (vs about 75s off) to about 81s on (vs about 67s off). Re-derive with GUNBC_EVAL_MEMO={1,0} gunbc run --source-root dag --source-root src/v2 --entry dag/gunbc/instruments/json_parse_ladder.dag --function <rung>.

  • The real pinned DeepSeek v4.1 index (sha256 74b0686a…98fa8, 7,470,294 bytes, both verified) parsed through parse_json_document to 96085 members with the memo on, in 239s wall including corpus resolution.

🤖 Generated with Claude Code

gunbc-ci-auto-heal and others added 6 commits September 22, 2026 15:00
…neage, not once per call

parse_json_document was reported as not returning on a large document. The
json_parse_ladder instrument (tools/json_parse_ladder) discriminates the three
readings: the parser is linear with GUNBC_EVAL_MEMO=0 on the same binary and
inputs (not a parser defect), plain deep and flat recursion is cheap (not a
general interpreter property), and a walk that merely passes a large string
through a recursion pays the whole cost (a shared primitive).

The link is the default-on eval-call memo's key derivation: composites reach
the content key through an identity memo, strings did not, so every pure call
rehashed its String arguments in full. RcStr now carries its content hash,
filled on first demand, beside the ASCII flag; v1_rt::str_content_hash is the
one hash function; the two Value::Str key sites read the carried hash. Key
values are unchanged; re-serialized documents are byte-identical between the
fixed and unfixed binaries.

Residue, not fixed here: a freshly built accumulator argument misses the
identity memo on every step and is rehashed in full, so the parse is still
super-linear with the memo on. That is a provider-admission question for the
eval-call memo, not a parser repair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a declared rung with a capability trigger

The route by which the eval-call memo's key sites read RcStr's carried hash is
established by measurement only: reverting a Value::Str key site leaves every
committed test green. That gap now lives on a gunbc.recurring_failure_mode row,
not in a PR body, with its restoration trigger named as the capability that
would make a reverted key site go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rd, so a growing list is keyed per push, not rehashed per call

The list content hash is a left fold with no finalizer, so
hash(push(xs, x)) == mix(hash(xs), hash(x)). A recursion threading a growing
list_push accumulator handed every call a new Rc, missed the identity hash
memo, and rehashed the whole accumulator: one relation realized at per-call
O(size). The push constructor now extends the parent's entry in O(item),
demand-gated on the parent already having been keyed.

Ruling: the failing link is key derivation, not memo admission. With the key
O(item), an unrecurrable call costs a bounded constant, so no "can this recur"
criterion is needed and none is added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ag, every rung checks its own value

Review 70155: the committed ladder read gitignored fixtures that nothing in the
tree produced, and its Python generator was never committed (the repo ignores
*.py by policy). tools.json_parse_ladder now lives with the other instruments in
dag/gunbc/instruments, builds its documents in memory with a bounded-depth
builder, and returns ProcessExit per rung -- 0 only when the rung returns the
value it owes, a located failure otherwise. The failure-mode row and the seed
admission now cite it by symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… from the RcStr row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gunbai-bot

gunbai-bot Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressing review 70156 (dashboard artifact /api/reviews/70156/artifacts/stdout.log):

  1. Seed change without its own admission row. Valid, fixed in c667974. dag/gunbc/v1/v1_maintenance_standing.dag now carries a separate row for the list_push key extension, directly after Interpreter: a threaded String argument is content-hashed once per lineage, not once per call #12065's RcStr row. It records the change, why the defect is in key derivation rather than admission, the purpose test, the behaviour-preservation argument (pinned by push_hash_extension_tests), and that no public surface grows. It does not widen the RcStr row.

  2. tools/json_parse_ladder has no consumer and its documents are not generated. Those files are Interpreter: a threaded String argument is content-hashed once per lineage, not once per call #12065's content, not this PR's. This PR is stacked on Interpreter: a threaded String argument is content-hashed once per lineage, not once per call #12065 (its branch is merged in), so they show in this diff until Interpreter: a threaded String argument is content-hashed once per lineage, not once per call #12065 lands, and they will drop out of it after that. The instrument question belongs on Interpreter: a threaded String argument is content-hashed once per lineage, not once per call #12065, and I'm under instruction not to modify that PR. This PR's own evidence does not depend on the gitignored documents. The consumer of the change is the unit test above. The accumulator figures come from tools/json_parse_ladder/acc.dag, which is self-contained (it builds its own list, no document). The json_ladder figures used generated documents; the PR body says so and does not rely on them for the ruling.

— sent from royal-otter-431

gunbc-ci-auto-heal and others added 2 commits September 22, 2026 16:14
…arms; admission row says the route is pinned

Addresses review 70163: the test previously called the helper directly, so
deleting either real integration left it green. Verified: deleting the
extension call from the method arm or the free-call arm each turns it red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gunbai-bot

gunbai-bot Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressing review 70163 (/api/reviews/70163/artifacts/stdout.log), fixed in aed4985 using remedy (a).

  • push_hash_extension_tests::push no longer calls the helper. It drives list_push through both production arms, alternating per step: eval_builtin (the free-call arm in v1_builtin_arms) and eval_algebra_method_inner (the method arm in v1_algebra_method_arms). It still asserts that the extended entry equals the full fold on a fresh memo, and that a parent nobody keyed is never extended.
  • I checked that the test catches a deleted integration, in one remote dispatch. The test passes on the real code. With the extension call deleted from the method arm, it panics at the "a keyed lineage is extended on push" expect and fails. With the call deleted from the free-call arm instead, it fails the same way.
  • The v1_maintenance_standing row no longer says the test pins only the value. It now names the route control and states that this route is pinned, unlike the string half's key sites. So the failure-mode row's declaration for the Value::Str sites stays accurate and needs no widening.

— sent from royal-otter-431

@briansrls
briansrls added this pull request to the merge queue Sep 23, 2026
# Conflicts:
#	dag/gunbc/v1/v1_maintenance_standing.dag
@gunbai-bot
gunbai-bot Bot removed this pull request from the merge queue due to a manual request Sep 23, 2026
@gunbai-bot
gunbai-bot Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 1f76eef Sep 23, 2026
4 of 5 checks passed
@gunbai-bot
gunbai-bot Bot deleted the session/royal-otter-431 branch September 23, 2026 03:37
@briansrls
briansrls restored the session/royal-otter-431 branch September 23, 2026 03:42
gunbai-bot Bot pushed a commit that referenced this pull request Sep 23, 2026
… its own step, behind a hard memory.max

The token scan was a stated divergence from extdeps.languages.json, kept because parse_json_document
did not read a 7.4MB index in the interpreter. After #12066 the operator ruled to drop the scaffold
rather than admit it (option A): the scan, its local diagnostic and the parser probe are deleted, and
the selection is a fold over parse_json_document's value (V41IndexScan -> V41IndexParse).

The read moves out of the materialize run into its own entry and fleet-converge step,
v41_index_selection_ci_wet, so a parse stopped for memory cannot take the fetch's per-file receipts
with it. It verifies the index against its manifest row itself (v41_observe). Both entries now share
one CI bracket, v41_group_a_ci_run, instead of two copies of the target, trust and credential setup.

The parse runs only inside a hard memory fence: v41_memory_fence reads /proc/self/cgroup and every
bounding level's memory.max and memory.high, and takes the tightest hard limit
(extdeps.linux.cgroup_v2_memory cgroup_tightest_hard_memory_limit, the kernel's min-over-ancestors
rule). memory.high alone is a located refusal. The fence goes in the step's receipt.
gunbc.memory_cgroup_binding was tried first and dropped: it is a no-op under an existing hard limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gunbai-bot Bot pushed a commit that referenced this pull request Sep 27, 2026
…ap key derived per insert

Replaces the admission change (option A) under neat-boar-16's ruling (B-lite): #12066 is the
standing authority for this class, and #11741 showed A is a program of its own.

Mechanism, corrected by measurement: with the key cost removed the parse was still quadratic. The
cost was eval_call_memo_get's verification. value_fast_eq shortcut only on top-level allocation
identity, so a served hit on parse_table_with_furthest -- a ParseTable rebuilt as a new record
around the SAME entries map and grammar analysis -- fell into a deep Value::eq over the whole
table: O(|table|) per hit, quadratic per parse. value_fast_eq now descends to the first differing
allocation, agreeing with Value::eq arm for arm.

Also, per the ruling: the map key is derived per insert and overwrite
(eval_recompute_extend_insert_hash, beside #12066's per-push list key), with a property control
(derived == from-scratch over randomized insert/overwrite, through both production arms).

Residue filed as an rfm: instrumentation_counters_inside_a_semantic_value_defeat_identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

0 participants