diff --git a/dag/gunbc/floor/cross_claim_pure_share_seed_growth.dag b/dag/gunbc/floor/cross_claim_pure_share_seed_growth.dag index 4025333b590..2d98337f66c 100644 --- a/dag/gunbc/floor/cross_claim_pure_share_seed_growth.dag +++ b/dag/gunbc/floor/cross_claim_pure_share_seed_growth.dag @@ -48,9 +48,12 @@ data cross_claim_pure_share_seed_growth_justification: SeedGrowthJustification = DeclarationRef { module_path: "v1_compiler.cli_run.required_floor_runner", decl_name: "PureProducerShareRoster", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run.required_floor_runner", decl_name: "floor_decode_refused_share_candidates", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run.required_floor_runner", decl_name: "refuse_pure_producer_share_refused_carrier_overlap", field: WholeDeclaration }, - DeclarationRef { module_path: "v1_compiler.cli_run.shared_fill", decl_name: "consumer_modules_by_key", field: WholeDeclaration } + DeclarationRef { module_path: "v1_compiler.cli_run.shared_fill", decl_name: "consumer_modules_by_key", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.v1_interpreter", decl_name: "eval_recompute_memo_get", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.v1_interpreter", decl_name: "carry_cross_claim_served_hash", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.v1_interpreter", decl_name: "cross_claim_served_hash_count", field: WholeDeclaration } ], - reason: "WHY RUST IS STILL NEEDED: the required floor executes in the seed, and the boundary this mechanism repairs is the seed's own frame lifecycle -- the floor builds a fresh evaluation frame per claim (deliberately, so one witness cannot contaminate the next), which is exactly why no .dag construct can carry a value across it: the substrate has no modeled claim-frame boundary yet, so the tier that survives it must stand where the frames are built. The eval-frame memo already lived in the interpreter for the same reason; this generalizes its hardcoded prepare_grammar cross-claim arm into the roster-driven tier that arm's own dissolve-on comment demanded, rather than growing a second name arm beside it.\n\nWHAT IS NOT GROWN: no membership policy (the roster is v2.workflow.floor_pure_producer_share and Rust decides nothing about it), no language behavior, no compatibility route, no escape hatch -- every failure arm refuses: a roster module absent from the prepared subject, a stale warm row, a warm that fails to evaluate, and a value that fails TOTAL reification each stop the line as a typed, located REQUIRED-FLOOR REFUSAL, and a store the tier DECLINES (origin-bound value, counted cap overflow, byte budget) degrades to recompute, never to a wrong value. A hash-verified duplicate is NOT in that list and is not a decline at all: #9721 separated it out as AlreadyPresent, because an entry already retained under the same key with a structurally equal argument row is SERVABLE -- later claims can read it, which is the whole obligation -- so it completes the warm rather than recomputing. It bills no second fill, since the call that landed the value already paid for it. Publication portability is a positive coproduct checked at store time with the first non-portable child named by path (the run-33269961629 class: a raw evaluator fn reference at .produced_decl_support.render), and serves verify the full portable argument row so a hash collision cannot alias.\n\nWHY IT IS ADMITTED AGAINST THE v1 FREEZE: gunbc.v1_maintenance_standing v1_seed_standing admits work serving the v2 self-host program, and the required floor is the instrument that program gates on. Main run 33258845841 refused at the operator's 500ms per-claim CPU ceiling on a five-string_contains witness because every emit claim was re-deriving the same pure target-model work per frame -- runner variance crossing a fixed re-derivation cost, a nondeterministic gate. The repair the floor's own carriers demand is stop recomputing, never raise the line; verified on run 33272541241: FloorClean, emit rows over 350ms CPU dropped to zero, fills receipted through the existing [floor-shared-fill] ledger under cache=cross_claim_pure_share.\n\nWHAT #9721 ADDED, AND WHY IT IS A REPAIR RATHER THAN GROWTH: one item, CrossClaimStoreOutcome, replacing the bool that store_cross_claim_pure_memo and warm_cross_claim_pure_producer returned. The bool conflated four states -- fresh store, already-present entry, and three distinct refusals -- so the floor reported a CORRECTLY POPULATED tier as PureProducerShareWarmNotStored and printed \"duplicate key, entry cap, or byte budget\" where the cause belonged (the diagnostic_name_mechanism_silent failure mode). The trigger was structural, not incidental: v2.extdeps.languages.rust rust_target_model reaches rust_target_model_staging through its atom-realization catalog rows, so warming the first publishes the second, and the second row's own warm legitimately finds its entry present. AlreadyPresent is servable and does not stop the line; every Refused arm still does, now naming ONE cause. The line did not move and no arm was widened -- a state that was always servable stopped being reported as a refusal, and the three real refusals became individually legible. Its REDs are enrolled: an_already_present_entry_is_servable_while_a_declined_one_is_not (the discriminating pair -- AlreadyPresent and RefusedByteBudget both decline to write, and only one is servable) and an_already_present_publication_bills_no_second_fill.\n\nWHAT THE REFUSED-CARRIER WALL ADDS, AND WHY IT IS ADMITTED: six items, enumerated above, that adjudicate the ledger this tier already renders. v2.workflow.floor_pure_producer_share now carries a typed roster of producers this tier MEASURED AND REFUSED, and the identity-grain fold it declares beside them is not sufficient -- that file contains the case it misses, rust_target_model_staging, a distinct identity that measured clean while a wider row was enrolled and inherited that row's consumers and its regression the moment the wider row was withdrawn. The wall that catches it joins each observed share key's OBSERVED consumer modules against the refused row's measured carriers, and an observed population cannot be declared: it exists only in the run's own shared-fill ledger, which is why this half stands in the seed and not in the .dag. It decides no membership either -- the roster owns every producer, verdict and carrier; the Rust reads them and refuses. Every arm refuses: an undecodable roster, an unknown verdict variant, a refused row with no carriers (which would be enrolled coverage that can never fire), a ledger with fills but no installed roster, and an overlap whose bare ledger key two admitted spellings claim, which is unattributable rather than assignable to either. The refusal fires on the run of the diff that WITHDREW a row rather than the one that proposed the surviving key, so the diagnostic names the admitted key, the refused row it inherited the carriers from, and -- in its own sentence -- that what changed is the roster and not that key's own cost; charging a cost to a principal that did not cause it is DESIGN section 5 externalization unless the transfer is said out loud. Its REDs are enrolled in pure_producer_share_refused_carrier_overlap_tests: the discriminating red is the staging shape in miniature and its positive control varies exactly the consuming module, with the roster, the refused row and the admitted key held fixed. Its rung is MECHANICALLY PREVENTABLE and the receipt says so: the check runs at runtime over one run's observed ledger and depends on the required floor executing and staying enrolled.\n\nHAND-ITEM DELTA: the enumerated items above are the COMPLETE census of new module-scope declarations in the diff against origin/main (fns, types, consts, and thread-local statics across v1_interpreter and cli_run), derived item-by-item from the declaration diff, not from memory (review 57451 caught the earlier 8-item roster omitting portable_value_eq, portable_value_size_bytes and others). Deliberately excluded: CROSS_CLAIM_BYTE_BUDGET_TEST_OVERRIDE, which is cfg(test)-only and never compiled into the shipped seed — it is enrolled test scope, not seed growth. The remaining diff is arms and fields inside declarations that already existed on main (PortableValue arms, try/store_cross_claim_pure_memo bodies, clear_cross_claim_pure_memos, the shared-fill observer wiring, claim_batch's two trace lines), which gunbc.seed_growth_admission classifies ExistingSeedItemModified, plus enrolled test scope (the discriminating fill/serve pair, the reordered-interner field-resolution control, the publication-refusal REDs, the homonym and byte-budget REDs, and the closure-seed refusal trio). HAND-LOC DELTA AT THIS RECEIPT: roughly +1068/-82 across src/v1/stage0/src/v1_interpreter.rs, cli_run.rs, cli_run/required_floor_runner.rs, cli_run/shared_fill.rs and bin/claim_batch.rs against origin/main (claim_batch's two lines wire the previously uncalled recompute-trace printer, adding no declaration). The item observation producer named by gunbc.seed_growth_admission is not invoked by any required phase, so these diff-derived figures are review evidence rather than a mechanically joined admission, and this receipt says so rather than presenting them as measured by the join.\n\nTHE ROSTER IS DELETED (2026-10-02, gunbc#13030 C3 part 2). Admission is no longer a declared roster this tier reads: v2.workflow.floor_pure_producer_share derive_cross_claim_share decides it over the planned claims' call-site demand, and the seed installs the decided producers at their admitted call sites (gunbc.floor_call_site_demand_seed_growth). The plain warm loop and warm_cross_claim_pure_producer are deleted with it; carried-input warms remain. Where the sentences above say roster, the admitted population now means the derived rows plus the carried-input producers.", + reason: "WHY RUST IS STILL NEEDED: the required floor executes in the seed, and the boundary this mechanism repairs is the seed's own frame lifecycle -- the floor builds a fresh evaluation frame per claim (deliberately, so one witness cannot contaminate the next), which is exactly why no .dag construct can carry a value across it: the substrate has no modeled claim-frame boundary yet, so the tier that survives it must stand where the frames are built. The eval-frame memo already lived in the interpreter for the same reason; this generalizes its hardcoded prepare_grammar cross-claim arm into the roster-driven tier that arm's own dissolve-on comment demanded, rather than growing a second name arm beside it.\n\nWHAT IS NOT GROWN: no membership policy (the roster is v2.workflow.floor_pure_producer_share and Rust decides nothing about it), no language behavior, no compatibility route, no escape hatch -- every failure arm refuses: a roster module absent from the prepared subject, a stale warm row, a warm that fails to evaluate, and a value that fails TOTAL reification each stop the line as a typed, located REQUIRED-FLOOR REFUSAL, and a store the tier DECLINES (origin-bound value, counted cap overflow, byte budget) degrades to recompute, never to a wrong value. A hash-verified duplicate is NOT in that list and is not a decline at all: #9721 separated it out as AlreadyPresent, because an entry already retained under the same key with a structurally equal argument row is SERVABLE -- later claims can read it, which is the whole obligation -- so it completes the warm rather than recomputing. It bills no second fill, since the call that landed the value already paid for it. Publication portability is a positive coproduct checked at store time with the first non-portable child named by path (the run-33269961629 class: a raw evaluator fn reference at .produced_decl_support.render), and serves verify the full portable argument row so a hash collision cannot alias.\n\nWHY IT IS ADMITTED AGAINST THE v1 FREEZE: gunbc.v1_maintenance_standing v1_seed_standing admits work serving the v2 self-host program, and the required floor is the instrument that program gates on. Main run 33258845841 refused at the operator's 500ms per-claim CPU ceiling on a five-string_contains witness because every emit claim was re-deriving the same pure target-model work per frame -- runner variance crossing a fixed re-derivation cost, a nondeterministic gate. The repair the floor's own carriers demand is stop recomputing, never raise the line; verified on run 33272541241: FloorClean, emit rows over 350ms CPU dropped to zero, fills receipted through the existing [floor-shared-fill] ledger under cache=cross_claim_pure_share.\n\nWHAT #9721 ADDED, AND WHY IT IS A REPAIR RATHER THAN GROWTH: one item, CrossClaimStoreOutcome, replacing the bool that store_cross_claim_pure_memo and warm_cross_claim_pure_producer returned. The bool conflated four states -- fresh store, already-present entry, and three distinct refusals -- so the floor reported a CORRECTLY POPULATED tier as PureProducerShareWarmNotStored and printed \"duplicate key, entry cap, or byte budget\" where the cause belonged (the diagnostic_name_mechanism_silent failure mode). The trigger was structural, not incidental: v2.extdeps.languages.rust rust_target_model reaches rust_target_model_staging through its atom-realization catalog rows, so warming the first publishes the second, and the second row's own warm legitimately finds its entry present. AlreadyPresent is servable and does not stop the line; every Refused arm still does, now naming ONE cause. The line did not move and no arm was widened -- a state that was always servable stopped being reported as a refusal, and the three real refusals became individually legible. Its REDs are enrolled: an_already_present_entry_is_servable_while_a_declined_one_is_not (the discriminating pair -- AlreadyPresent and RefusedByteBudget both decline to write, and only one is servable) and an_already_present_publication_bills_no_second_fill.\n\nWHAT THE REFUSED-CARRIER WALL ADDS, AND WHY IT IS ADMITTED: six items, enumerated above, that adjudicate the ledger this tier already renders. v2.workflow.floor_pure_producer_share now carries a typed roster of producers this tier MEASURED AND REFUSED, and the identity-grain fold it declares beside them is not sufficient -- that file contains the case it misses, rust_target_model_staging, a distinct identity that measured clean while a wider row was enrolled and inherited that row's consumers and its regression the moment the wider row was withdrawn. The wall that catches it joins each observed share key's OBSERVED consumer modules against the refused row's measured carriers, and an observed population cannot be declared: it exists only in the run's own shared-fill ledger, which is why this half stands in the seed and not in the .dag. It decides no membership either -- the roster owns every producer, verdict and carrier; the Rust reads them and refuses. Every arm refuses: an undecodable roster, an unknown verdict variant, a refused row with no carriers (which would be enrolled coverage that can never fire), a ledger with fills but no installed roster, and an overlap whose bare ledger key two admitted spellings claim, which is unattributable rather than assignable to either. The refusal fires on the run of the diff that WITHDREW a row rather than the one that proposed the surviving key, so the diagnostic names the admitted key, the refused row it inherited the carriers from, and -- in its own sentence -- that what changed is the roster and not that key's own cost; charging a cost to a principal that did not cause it is DESIGN section 5 externalization unless the transfer is said out loud. Its REDs are enrolled in pure_producer_share_refused_carrier_overlap_tests: the discriminating red is the staging shape in miniature and its positive control varies exactly the consuming module, with the roster, the refused row and the admitted key held fixed. Its rung is MECHANICALLY PREVENTABLE and the receipt says so: the check runs at runtime over one run's observed ledger and depends on the required floor executing and staying enrolled.\n\nHAND-ITEM DELTA: the enumerated items above are the COMPLETE census of new module-scope declarations in the diff against origin/main (fns, types, consts, and thread-local statics across v1_interpreter and cli_run), derived item-by-item from the declaration diff, not from memory (review 57451 caught the earlier 8-item roster omitting portable_value_eq, portable_value_size_bytes and others). Deliberately excluded: CROSS_CLAIM_BYTE_BUDGET_TEST_OVERRIDE, which is cfg(test)-only and never compiled into the shipped seed — it is enrolled test scope, not seed growth. The remaining diff is arms and fields inside declarations that already existed on main (PortableValue arms, try/store_cross_claim_pure_memo bodies, clear_cross_claim_pure_memos, the shared-fill observer wiring, claim_batch's two trace lines), which gunbc.seed_growth_admission classifies ExistingSeedItemModified, plus enrolled test scope (the discriminating fill/serve pair, the reordered-interner field-resolution control, the publication-refusal REDs, the homonym and byte-budget REDs, and the closure-seed refusal trio). HAND-LOC DELTA AT THIS RECEIPT: roughly +1068/-82 across src/v1/stage0/src/v1_interpreter.rs, cli_run.rs, cli_run/required_floor_runner.rs, cli_run/shared_fill.rs and bin/claim_batch.rs against origin/main (claim_batch's two lines wire the previously uncalled recompute-trace printer, adding no declaration). The item observation producer named by gunbc.seed_growth_admission is not invoked by any required phase, so these diff-derived figures are review evidence rather than a mechanically joined admission, and this receipt says so rather than presenting them as measured by the join.\n\nTHE ROSTER IS DELETED (2026-10-02, gunbc#13030 C3 part 2). Admission is no longer a declared roster this tier reads: v2.workflow.floor_pure_producer_share derive_cross_claim_share decides it over the planned claims' call-site demand, and the seed installs the decided producers at their admitted call sites (gunbc.floor_call_site_demand_seed_growth). The plain warm loop and warm_cross_claim_pure_producer are deleted with it; carried-input warms remain. Where the sentences above say roster, the admitted population now means the derived rows plus the carried-input producers.\n\nTHE SERVED VALUE'S CONTENT HASH IS CARRIED WITH THE TIER (2026-10-05). Each claim's fresh frame has an empty content-hash memo, so the first call in a claim that took a served value, or a part of one such as a prepared grammar's prepared_exprs, as an argument re-hashed it whole, natively and outside the step budget: a per-claim tax proportional to the served value's size. The hash reads only content and process-canonical spellings, so it is a fact about the instance; it is computed once at publication into the CROSS_CLAIM_SERVED_HASH_MEMO thread-local (macro-declared, uncitable at item grain, as above) and read by eval_recompute_memo_get after the frame's own memo. The join is the live Rc allocation, never a digest, and the tier retains every served value for its lifetime, so the read hash is exactly the one a walk would compute. It decides only the cost of a key: serving a memoized call still verifies its arguments. Three items: eval_recompute_memo_get, carry_cross_claim_served_hash, and cross_claim_served_hash_count (the population reading the control uses). Control: a_fresh_context_keys_a_served_value_and_its_parts_without_rehashing.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, trigger: "Delete the seed declarations when the generic cross-claim pure memo lands (gunbc.roadmap_authority five_minute_ci_gate_program_note -- v2.workflow.floor_pure_producer_share's own dissolve-on) AND the interpreter's memo tier migrates with the evaluator into the self-emitted substrate, so the claim-frame boundary and the tier that crosses it are both modeled facts and the hand plumbing disappears with the v1 floor bridge. NOT retired by the roster emptying: an empty roster still needs the tier that would serve it, and a trigger satisfied by the population draining would delete the mechanism while leaving the capability unowned.", current_boundary: "v2.workflow.floor_pure_producer_share floor_cross_claim_share_derivation / floor_cross_claim_carried_input_warm_rows / floor_cross_claim_refused_candidates -> v1_compiler.cli_run install_pure_producer_share and derive_and_install_cross_claim_share -> v1_compiler.v1_interpreter cross-claim tier (store, verify, serve, warm) -> [floor-shared-fill] cache=cross_claim_pure_share -> v1_compiler.cli_run refuse_pure_producer_share_refused_carrier_overlap" diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index a9f72092c87..1edafe239cb 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -2381,6 +2381,7 @@ impl CrossClaimSiteSet { /// under it. pub fn clear_cross_claim_pure_memos() { CROSS_CLAIM_PURE_MEMO.with(|m| *m.borrow_mut() = CrossClaimPureMemo::default()); + CROSS_CLAIM_SERVED_HASH_MEMO.with(|m| m.borrow_mut().clear()); CROSS_CLAIM_FN_KEEPALIVE.with(|k| k.borrow_mut().clear()); CROSS_CLAIM_PURE_ROSTER.with(|r| r.borrow_mut().clear()); CROSS_CLAIM_SITE_GATED.with(|g| g.borrow_mut().clear()); @@ -2999,6 +3000,8 @@ fn store_cross_claim_pure_memo( // The portable form has done its two jobs by here — it proved total portability and it // measured the entry — and nothing downstream needs it again. let served = value_from_portable_ctx(ctx, &portable); + // Its content hash, once, for every fresh context that will key a call on it. + carry_cross_claim_served_hash(ctx, &served); // The served value's root is registered under its digest, so a later call that passes // this value as an argument is verified without walking it; and each composite argument // of THIS entry carries its digest for the same comparison from the other side. @@ -4250,6 +4253,88 @@ mod cross_claim_memo_tests { super::clear_cross_claim_pure_memos(); } + // A FRESH CONTEXT KEYS A SERVED VALUE WITHOUT RE-HASHING IT. Producer A's value (a list of + // lists) is published; a fresh context then keys a call on the served value AND on one list + // INSIDE it (the `prepared_exprs` shape: a field of a served grammar). Neither walk lands in + // the fresh context's own memo, because both hashes are read from the run-scoped served memo, + // and each key equals the one a cold walk computes with that memo emptied. The control is an + // EQUAL value that is not the served instance: it is walked in the context (its memo grows) + // and keys the same, so the carried hash changes cost only, never the key. + #[test] + fn a_fresh_context_keys_a_served_value_and_its_parts_without_rehashing() { + use super::{ + cross_claim_served_hash_count, eval_recompute_key, + install_cross_claim_pure_share_roster, list_value, store_cross_claim_pure_memo, + try_cross_claim_pure_memo, CrossClaimStoreOutcome, CROSS_CLAIM_SERVED_HASH_MEMO, + }; + super::clear_cross_claim_pure_memos(); + let node = || { + make_expr_node( + Rc::new( + crate::std_occurrence_identity::NodeOccurrenceIdentity::OccurrenceSynthetic, + ), + Rc::new(ExprData::NoExprData), + Rc::new(im_vec![]), + None, + no_span(), + ) + }; + let (a, consumer) = (node(), node()); + install_cross_claim_pure_share_roster([a.clone()]); + let built = || { + list_value(vec![ + list_value(vec![Value::Int(1), Value::Int(2)]), + list_value(vec![Value::Int(3), Value::Int(4)]), + ]) + }; + let publisher = fresh_ctx(); + assert_eq!( + store_cross_claim_pure_memo(&publisher, &a, "tm_a", &[], &built(), None), + CrossClaimStoreOutcome::Stored + ); + assert_eq!( + cross_claim_served_hash_count(), + 3, + "the root and both inner lists" + ); + let claim = fresh_ctx(); + let served = try_cross_claim_pure_memo(&claim, &a, "tm_a", &[]).expect("A is served"); + let Value::List(items) = &served else { + panic!("A serves a list") + }; + let part = items[1].clone(); + let key_of = |ctx: &InterpContext, v: &Value| { + eval_recompute_key(ctx, &consumer, &[(Some("x".to_string()), v.clone())]) + .expect("a list of ints is keyable") + }; + let warm_whole = key_of(&claim, &served); + let warm_part = key_of(&claim, &part); + assert_eq!( + claim.eval_recompute_hash_memo.borrow().len(), + 0, + "a served value and its parts are keyed from the carried hashes, with no walk" + ); + let carried = CROSS_CLAIM_SERVED_HASH_MEMO.with(|m| std::mem::take(&mut *m.borrow_mut())); + let cold = fresh_ctx(); + assert!( + key_of(&cold, &served) == warm_whole, + "the carried hash is the walked hash" + ); + assert!( + key_of(&cold, &part) == warm_part, + "and so is an inner part's" + ); + CROSS_CLAIM_SERVED_HASH_MEMO.with(|m| *m.borrow_mut() = carried); + let other = fresh_ctx(); + assert!(key_of(&other, &built()) == warm_whole); + assert!( + !other.eval_recompute_hash_memo.borrow().is_empty(), + "an equal value that is not the served instance is walked in its own context" + ); + super::clear_cross_claim_pure_memos(); + assert_eq!(cross_claim_served_hash_count(), 0, "cleared with the tier"); + } + // A DIGEST IS NOT AN IDENTITY. `portable_value_digest` is a 64-bit FNV hash, so two distinct // values can share one. An entry is forged whose argument digest EQUALS the served probe's but // whose stored argument is a different value and a different instance: it must not match (the @@ -11316,6 +11401,60 @@ enum EvalRecomputeStep { Bail, } +thread_local! { + /// THE CONTENT HASHES OF EVERY VALUE THE CROSS-CLAIM TIER SERVES, computed once, at + /// publication, over the served instance and each composite inside it. A served value is + /// handed to every claim by `Rc` clone, but each claim runs in a FRESH `InterpContext`, whose + /// own memo is empty, so the first call in each claim that took a served value (or any part of + /// it, such as a prepared grammar's `prepared_exprs`) as an argument re-hashed the whole thing + /// natively: a per-claim cost proportional to the served value's size, outside the step budget. + /// The hash is a fact about the instance, not the context: it reads only content and + /// process-canonical symbol spellings. So it is computed once and carried with the tier. + /// + /// THE JOIN IS INSTANCE IDENTITY, NEVER A DIGEST. An entry is consulted by `Rc` pointer and + /// used only while its allocation is alive, and the tier retains every served value for its + /// lifetime, so the pointer cannot be reused while the entry stands: the hash read is exactly + /// the one a fresh walk of that instance would compute. Serving a memoized call still verifies + /// its arguments, so this memo decides only the cost of a key, never what a key admits. + /// Cleared with the tier (`clear_cross_claim_pure_memos`). + static CROSS_CLAIM_SERVED_HASH_MEMO: RefCell = + RefCell::new(EvalRecomputeHashMemo::default()); +} + +/// A composite's memoized content hash: the context's own memo first, then the hashes carried +/// for tier-served instances. Both are joined by the live allocation the pointer names. While the +/// served memo is itself being filled (`memo` IS that memo, mutably borrowed), the second read is +/// skipped. +fn eval_recompute_memo_get(memo: &EvalRecomputeHashMemo, ptr: usize) -> Option { + if let Some((w, h)) = memo.get(&ptr) { + if w.alive() { + return Some(*h); + } + } + CROSS_CLAIM_SERVED_HASH_MEMO.with(|served| { + let served = served.try_borrow().ok()?; + match served.get(&ptr) { + Some((w, h)) if w.alive() => Some(*h), + _ => None, + } + }) +} + +/// Hash a value the tier is about to serve, once, into the served-instance memo. A value carrying +/// a closure has no content hash and is simply not carried; every later key derivation then +/// refuses it exactly as before. +fn carry_cross_claim_served_hash(ctx: &InterpContext, served: &Value) { + let interner = ctx.symbols.borrow(); + CROSS_CLAIM_SERVED_HASH_MEMO.with(|memo| { + let _ = eval_recompute_value_hash(&mut memo.borrow_mut(), &interner, served); + }); +} + +/// Composite hashes carried for tier-served instances (the served-instance memo's population). +pub fn cross_claim_served_hash_count() -> usize { + CROSS_CLAIM_SERVED_HASH_MEMO.with(|m| m.borrow().len()) +} + fn eval_recompute_value_hash( memo: &mut EvalRecomputeHashMemo, interner: &SymbolInterner, @@ -11346,8 +11485,8 @@ fn eval_recompute_value_hash( Value::Closure { .. } => EvalRecomputeStep::Bail, Value::Set(s) => { let ptr = Rc::as_ptr(s) as usize; - match memo.get(&ptr) { - Some((w, h)) if w.alive() => EvalRecomputeStep::Have(*h), + match eval_recompute_memo_get(memo, ptr) { + Some(h) => EvalRecomputeStep::Have(h), _ => { let mut h: u64 = 0xA5A5_00A0; for item in s.iter() { @@ -11360,8 +11499,8 @@ fn eval_recompute_value_hash( } Value::List(xs) => { let ptr = Rc::as_ptr(xs) as usize; - match memo.get(&ptr) { - Some((w, h)) if w.alive() => EvalRecomputeStep::Have(*h), + match eval_recompute_memo_get(memo, ptr) { + Some(h) => EvalRecomputeStep::Have(h), _ => { frames.push(EvalRecomputeFrame { kind: EvalRecomputeFrameKind::List { rc: xs.clone() }, @@ -11375,10 +11514,10 @@ fn eval_recompute_value_hash( Value::Record { type_name, fields } => { let ptr = Rc::as_ptr(fields) as usize; let type_sym_hash = eval_recompute_str_hash(interner.resolve(*type_name)); - match memo.get(&ptr) { - Some((w, h)) if w.alive() => EvalRecomputeStep::Have(eval_recompute_mix( + match eval_recompute_memo_get(memo, ptr) { + Some(h) => EvalRecomputeStep::Have(eval_recompute_mix( eval_recompute_mix(0xA5A5_0070, type_sym_hash), - *h, + h, )), _ => { let field_name_hashes = fields @@ -11408,13 +11547,13 @@ fn eval_recompute_value_hash( let ptr = Rc::as_ptr(fields) as usize; let type_sym_hash = eval_recompute_str_hash(interner.resolve(*type_name)); let variant_sym_hash = eval_recompute_str_hash(interner.resolve(*variant_name)); - match memo.get(&ptr) { - Some((w, h)) if w.alive() => EvalRecomputeStep::Have(eval_recompute_mix( + match eval_recompute_memo_get(memo, ptr) { + Some(h) => EvalRecomputeStep::Have(eval_recompute_mix( eval_recompute_mix( eval_recompute_mix(0xA5A5_0080, type_sym_hash), variant_sym_hash, ), - *h, + h, )), _ => { let field_name_hashes = fields @@ -11438,8 +11577,8 @@ fn eval_recompute_value_hash( } Value::Map(m) => { let ptr = Rc::as_ptr(m) as usize; - match memo.get(&ptr) { - Some((w, h)) if w.alive() => EvalRecomputeStep::Have(*h), + match eval_recompute_memo_get(memo, ptr) { + Some(h) => EvalRecomputeStep::Have(h), _ => { let mut key_hashes = Vec::with_capacity(m.len()); let mut values = Vec::with_capacity(m.len()); @@ -11523,9 +11662,8 @@ fn eval_recompute_extend_push_hash( let Ok(mut memo) = ctx.eval_recompute_hash_memo.try_borrow_mut() else { return; }; - let parent_h = match memo.get(&(Rc::as_ptr(parent) as usize)) { - Some((w, h)) if w.alive() => *h, - _ => return, + let Some(parent_h) = eval_recompute_memo_get(&memo, Rc::as_ptr(parent) as usize) else { + return; }; let interner = ctx.symbols.borrow(); let Some(item_h) = eval_recompute_value_hash(&mut memo, &interner, item) else { @@ -11567,9 +11705,8 @@ fn eval_recompute_extend_insert_hash( let Ok(mut memo) = ctx.eval_recompute_hash_memo.try_borrow_mut() else { return; }; - let parent_h = match memo.get(&(Rc::as_ptr(parent) as usize)) { - Some((w, h)) if w.alive() => *h, - _ => return, + let Some(parent_h) = eval_recompute_memo_get(&memo, Rc::as_ptr(parent) as usize) else { + return; }; let interner = ctx.symbols.borrow(); let key_h = eval_recompute_canon_key_hash(key);