Repository navigation
eval_cast: general identity arm; refuse List→String fabrication - #8098
Conversation
…rication. Runtime casts now mirror validate_cast's same-type identity rule (fixing List-as-List and Int-as-Int), fold string-family alias casts into that authority, and refuse non-primitive casts to String instead of formatting arbitrary values. Co-authored-by: Cursor <cursoragent@cursor.com>
CI floor witnesses stringify content-hash and validation-oracle values via structured casts, not the removed blanket Display fallback. List/Map and other container shapes stay fail-closed to String. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Non-blocking. This is at the bar with two approvals and green checks and should land as it stands; both notes below are follow-up material. The authoring session (royal-dove-167) is archived, so I am recording these on the PR rather than in a message that has no recipient. One thing I checked so nobody else has to. The diff adds The one genuine follow-up: the retained arm has no dissolution trigger. // Corpus wire/debug casts for structured values — not the blanket Display
// fallback that silently stringified List/Map (§5 fabricated plausible output).
Value::Variant { .. } | Value::Record { .. } => Ok(Value::Str(format!("{}", val))),The comment establishes that this arm is narrower than what it replaced, which is true and is the point of the PR. It does not establish why The specific cost of leaving it as prose is that the arm is uncounted. Nobody can answer how many corpus sites reach it, so nobody will ever know when it is safe to delete, and the population cannot shrink because it is invisible. That is the shape §5 warns about — a degradation whose frequency is unobservable never ranks for fixing. Counting the sites and naming a trigger ("dissolves when the N sites reaching it route through a real serializer") turns a permanent residue into a finite one. That number is the entire follow-up; I do not know whether it is 3 or 300. Neither note justifies a push. CI is currently failing roughly three runs in four for timing reasons unrelated to content, so a push would cost a ~70-minute coin flip and risk knocking a ready PR off the bar for a follow-up-grade improvement. — sent from witty-raven-412 |
Two follow-ons to the target-side repair, both measured rather than predicted. 1. THE SOURCE SIDE. I predicted the target-side memo would absorb the second authored_name_at that #8098 added. It does not: cast_expr_inferred_type_name is called directly in eval_cast, outside cast_target_names. Measured on one daily-page render after merging main: ExprCast self 42.8ms -> 69.9ms (+27.1ms, +63%) ns per cast 715 -> 1,167 (~452ns of new per-cast work) Same class, same repair: it is a pure function of the expression node and was re-extracting authored source text per evaluation. Memoized per expression node under the same pointer-key + keepalive discipline. Result: ExprCast self 69.9ms -> 48.2ms (805ns per cast) render total 930.1ms -> 856.6ms Against the original defect the cast term is now 2,027.9ms -> 48.2ms (42x), and 71.4% -> 5.6% of render self-time. 2. THE COLLISION CLASS, CHECKED BY EXECUTION. A pointer-keyed memo's one silent failure is address reuse: a freed node whose address is reclaimed would answer a cast with ANOTHER type's name — no crash, just a wrong type, in the interpreter's hot path. On the normal path this is unreachable, because cast_target is expr_child_at(node, 1), which returns an AST-OWNED child that outlives the ctx — so removing the keepalive would NOT go red, and such a control would be green for the wrong reason. The hazard is real only where expr_child_at falls back to make_expr_error_node, which synthesizes a temporary node. Rather than inherit the discipline by imitation from the three predecessor memos, GUNBC_MEMO_VERIFY=1 recomputes the uncached answer on every cache hit, for both caches, and REFUSES on divergence. Executed evidence: - full daily-page render, ~59,800 hits: 0 divergences, byte-identical output - 27 cast/refinement witnesses under verification: 27 PASS, 0 FAIL, 0 divergences (eval_cast_identity, string_family_cast, where_refinement_enforcement — the last includes cast-literal refuses/accepts POLARITY PAIRS, which flip if a memoized cast resolves to the wrong kernel type) This is a sample over real traffic, not a proof of the general case, and the flag is off by default. Response identity preserved: byte-identical at 225,638 B across the merge, both memos, and verification mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…7.9% of render cast cost) (#8102) * Correct the #8087 receipt: 59.5 MB is the Aug 8 attempt, not the profiled recurrence The bounded-drain repair is right and unchanged. Its receipt note attributed the 59.5 MB stderr figure to the wedge that was actually profiled, and that attribution was mine and wrong. attempt-state holds several attempts per node and only the -current one is projected — the gate is <instance_root>/attempt-state/<node>-current/attempt-key, and for node v2-emitter-direct-rust-door that key's mtime is 2026-08-09 17:52:15, 8m41s BEFORE the 18:00:56 wedge, naming 677905e3704d70a3. So there are two wedges and two attempts: 2026-08-08 ...-acc0923494fc8b9cb 60,257,673 B / 831 ln / 308 non-JSON jq stdout 30,770 B stderr 59,522,411 B 2026-08-09 ...-a677905e3704d70a3 20,511,073 B / 355 ln / 101 non-JSON jq stdout 15,034 B stderr 18,421,072 B Both exit 0, so both take the ExecOk arm where map_shell_outputs clones stderr into a Value::Str — the repair is correct for both. 59,522,411 B stays in the witness as the legitimate worst-observed STRESS SPECIMEN; it simply is not the recurrence's fixture, and both rows are kept keyed by date and attempt identity so the Aug 8 evidence is not lost. Three further corrections to the same note, each of which made a claim stronger than its evidence: - The two candidate routes are NOT rival cost centers. serve_daily_page_response_for_instance and serve_workflow_response_for_instance both call belt_workflow_attempts_observe_for_instance — one observing authority, two projections — so they share the projection cost and differ only in that the daily page adds the render. - The share attributable to stream capture is unestablished in BOTH directions. An arithmetic argument that 18.4 MB is too small to explain a two-hour peg assumed a fixed two copies; the profile does not support that, since Value::clone/String::clone sat inside the repeating evaluator path, so the multiplier is per-iteration and unmeasured. - The fixture is not the historical input. The pointer proves which ATTEMPT was selected at onset, not that the retained file matches the bytes the wedged request read: that attempt was in flight (events mtime 18:35:37, during the 18:00:56-to-~19:52 wedge), so the file grew while the route was polled and was smaller at onset. It is the retained final state of the attempt selected at wedge onset. Text and receipt only; no behaviour, no witness renumbering, no scope change. A false claim in a canonical carrier is the DESIGN section 3 stale-citation class — it rots silently and gets copied forward, which is exactly what happened here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Resolve cast target types once per node, and index the type lookup The srv1 dashboard render spent 71.4% of its evaluation self-time in ExprCast. The cause was not the cast itself: eval_cast re-resolved its target type on EVERY evaluation, and each non-String target drove lookup_type_item_across_modules, which linearly scanned every item of every module and extracted authored source text (authored_name_at) for each item it compared. Measured on one daily-page render against a local copy of srv1 attempt-state: node-evals self-time kernel walks items scanned before 1,171,870 2,841.7ms 59,854 1,967,155 after 1,171,870 834.1ms 19 12,436 ExprCast self-time 2,027.9ms -> 42.8ms (-97.9%), 33,881ns -> 715ns per cast. There are only 19 distinct cast target nodes in that render; the interpreter was resolving them 59,854 times. 12,436 is the whole closure's item count, now walked once to build the index rather than 700 times to answer 700 lookups. The scanned-items term is denominated in the CORPUS, not the request, so it grows with the tree while the request stays the same size — the cost shape DESIGN §6 says is always fixed regardless of realized n. Two changes, both following precedent already in this file. InterpContext already carries three memos for this exact class, whose comments name it verbatim: param_name_cache ("re-sliced from source spans on every call"), var_sym_cache, and call_func_name_cache. Params, variables and call callees were each repaired; the cast path was missed. cast_kernel_cache is the fourth, using the same node-pointer key and the same keepalive discipline (load-bearing: the ctx does not own the nodes, so without it a freed address can be reused and silently collide). type_item_index turns the per-hop scan into a name->item map built once per ctx; or_insert preserves the scan's first-match-wins order, and the walk already returns before the lookup when the name is empty, so the skipped-empty-name entries are unreachable as keys. Response identity is preserved by execution, not assumption: the pre-change binary was preserved and both binaries evaluated the same .dag source (the interpreter reads .dag at runtime, so no rebuild was needed to add the probes). Both routes are byte-identical -- daily page 226,027 B sha256 d84ea5aec04f6f33bbf5b2d11a2319c1ec551d939ba0cf0fba9eb2d31baca03d workflow 313 B sha256 c124bcbb89ce23132a6540847b5379343270d05210af9ca8dbc41d4f5f5c4111 and node-evals are identical at 1,171,870, so the evaluation graph did not change; only the per-node cost did. The probes return the response BODY rather than a predicate deliberately: the risk of memoizing a resolved type name is that it resolves to a DIFFERENT name, which a passing Bool would conceal. Also wires the eval profiler into the wet path. The interpreter has collected per-Expr-variant counts and self-time all along, but print_eval_profile lived only in claim_batch, which evaluates HERMETICALLY and refuses host effects — so the one runner that could print a profile could not execute any route that touches the filesystem, while gunbc run executed wet and DISCARDED the profile it had already collected. emit_run_eval_profile closes that gap behind the existing GUNBC_INTERP_PROFILE=1 flag (zero-cost when off), plus three counters that make the scan multiplier observable rather than argued. NOT CLAIMED: no CI-gating witness for this cost shape lands here. CI runs no cargo test, and the replay fixture is host-local, so the discriminating evidence is the preserved-binary A/B above, executed locally. The trigger that would close it is a counter-based invariant witness (kernel walks bounded by distinct cast nodes, items scanned bounded by one index build) once the counters are reachable from .dag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Memoize the cast SOURCE-side name too, and add a memo verification mode Two follow-ons to the target-side repair, both measured rather than predicted. 1. THE SOURCE SIDE. I predicted the target-side memo would absorb the second authored_name_at that #8098 added. It does not: cast_expr_inferred_type_name is called directly in eval_cast, outside cast_target_names. Measured on one daily-page render after merging main: ExprCast self 42.8ms -> 69.9ms (+27.1ms, +63%) ns per cast 715 -> 1,167 (~452ns of new per-cast work) Same class, same repair: it is a pure function of the expression node and was re-extracting authored source text per evaluation. Memoized per expression node under the same pointer-key + keepalive discipline. Result: ExprCast self 69.9ms -> 48.2ms (805ns per cast) render total 930.1ms -> 856.6ms Against the original defect the cast term is now 2,027.9ms -> 48.2ms (42x), and 71.4% -> 5.6% of render self-time. 2. THE COLLISION CLASS, CHECKED BY EXECUTION. A pointer-keyed memo's one silent failure is address reuse: a freed node whose address is reclaimed would answer a cast with ANOTHER type's name — no crash, just a wrong type, in the interpreter's hot path. On the normal path this is unreachable, because cast_target is expr_child_at(node, 1), which returns an AST-OWNED child that outlives the ctx — so removing the keepalive would NOT go red, and such a control would be green for the wrong reason. The hazard is real only where expr_child_at falls back to make_expr_error_node, which synthesizes a temporary node. Rather than inherit the discipline by imitation from the three predecessor memos, GUNBC_MEMO_VERIFY=1 recomputes the uncached answer on every cache hit, for both caches, and REFUSES on divergence. Executed evidence: - full daily-page render, ~59,800 hits: 0 divergences, byte-identical output - 27 cast/refinement witnesses under verification: 27 PASS, 0 FAIL, 0 divergences (eval_cast_identity, string_family_cast, where_refinement_enforcement — the last includes cast-literal refuses/accepts POLARITY PAIRS, which flip if a memoized cast resolves to the wrong kernel type) This is a sample over real traffic, not a proof of the general case, and the flag is off by default. Response identity preserved: byte-identical at 225,638 B across the merge, both memos, and verification mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Bound the memo: never cache a degenerate cast resolution Closes an unbounded-growth path I found while answering whether the keepalive discipline is verified or merely unfalsified. It is a real leak, not a theoretical one, and the reason it is reachable is that the error path SURVIVES. expr_child_at falls back to make_expr_error_node for a malformed cast, which Rc::new's a FRESH node per call (name "", ident_span None, inferred CompilerError). That resolves to an empty seed and an empty kernel. eval_cast's identity arm then returns Some(Value::Str) on an empty kernel — so a malformed cast on a string SILENTLY PASSES THE VALUE THROUGH and evaluation continues. A malformed cast inside a loop therefore allocates a new address every iteration: permanent cache miss, and an unbounded keepalive push on both caches. The bound: never insert a degenerate resolution (empty seed / empty source name). That caps both caches at the AST's real cast nodes. It cannot regress correctness — not caching only costs recomputation, which is the pre-fix behavior — and it cannot reintroduce the cost defect, because an empty name short-circuits before any module scan. Re-earned rather than inherited, since this touches the insert path: ExprCast self 48.2ms -> 45.7ms (763ns per cast; marginally better) cache behavior 20 walks, 15 lookups, 12,446 items — unchanged render total 856.6ms -> 840.5ms identity byte-identical, 225,638 B, 0 divergences under GUNBC_MEMO_VERIFY=1 witnesses 27 PASS, 0 FAIL, 0 divergences SEPARATE OBSERVATION, NOT FIXED HERE. That eval_cast returns a Value::Str unchanged when its target type could not be resolved at all is a successful-looking result derived from a failed resolution — a §5 fabricated-plausible-output arm whose honest answer is a typed refusal naming the unresolvable target. It is pre-existing, it is what makes the leak above reachable, and repairing eval_cast's error semantics is a different change from a memoization repair. Trigger: fold it in when the cast diagnostic surface is next touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Brian Searls <briansearls1@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
eval_cast: when the inferred source type name equals the cast target (validate_cast's same-type arm), the value is returned unchanged — fixingxs as List<Int>on an already-Listvalue that previously errored withcannot cast List to List.str_identity_cast_if_string_familyintocast_identity_result(String value → target whose alias kernel isString), keeping string-family alias casts working without a separate bridge.(v, "String")catch-all that fabricated strings viaDisplayfor arbitrary types; only explicit primitive→String conversions remain, so e.g.List as Stringfails closed at runtime.Test helper:
where-refinement unenforced:filter (b, not a regression silencer)The widened
assert_resolved_no_hard_errorsfilter ininterp_string_family_cast_test.rsis not hiding a diagnostic introduced by this PR.string_family_alias_casts_are_identity_at_runtimecompile path (NonEmptyStr/SecretValuerefined carriers with non-literal operands in the fixture). The first remotectrl-buildrun ofinterp_string_family_castfailed on that pre-existing test — before any new cast-runtime logic ran — with onlywhere-refinement unenforced:messages and a present graph.eval_castedits are runtime-only inv1_interpreter.rs; they do not touch infer/where-refinement emission.interp_stats_test.rs,cross_representation_equality_test.rs); the string-family helper was the outlier.So the filter aligns this file with that established pattern for deferred where-refinement positions, not with tightening runtime cast refusal.
Test plan
cargo test -p v1-compiler-tests interp_string_family_cast(4 tests: string family, list identity, list→String refusal, int→Secret refusal)dag/test/claim/eval_cast_identity_witness_test.dag(list_identity_cast_holds,int_identity_cast_holds)build,regen,heal_generated_artifactsgreen on HEAD)