Skip to content

eval_cast: general identity arm; refuse List→String fabrication - #8098

Merged
briansrls merged 2 commits into
mainfrom
session/royal-dove-167
Aug 10, 2026
Merged

briansrls merged 2 commits into
mainfrom
session/royal-dove-167

Conversation

@gunbai-bot

@gunbai-bot gunbai-bot Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Add a general runtime identity cast in eval_cast: when the inferred source type name equals the cast target (validate_cast's same-type arm), the value is returned unchanged — fixing xs as List<Int> on an already-List value that previously errored with cannot cast List to List.
  • Dissolve str_identity_cast_if_string_family into cast_identity_result (String value → target whose alias kernel is String), keeping string-family alias casts working without a separate bridge.
  • Remove the (v, "String") catch-all that fabricated strings via Display for arbitrary types; only explicit primitive→String conversions remain, so e.g. List as String fails closed at runtime.

Test helper: where-refinement unenforced: filter (b, not a regression silencer)

The widened assert_resolved_no_hard_errors filter in interp_string_family_cast_test.rs is not hiding a diagnostic introduced by this PR.

  • (b) The advisory was already emitted on the existing string_family_alias_casts_are_identity_at_runtime compile path (NonEmptyStr / SecretValue refined carriers with non-literal operands in the fixture). The first remote ctrl-build run of interp_string_family_cast failed on that pre-existing test — before any new cast-runtime logic ran — with only where-refinement unenforced: messages and a present graph.
  • This PR's eval_cast edits are runtime-only in v1_interpreter.rs; they do not touch infer/where-refinement emission.
  • Sibling interpreter integration tests already exclude the same advisory class (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)
  • New witness dag/test/claim/eval_cast_identity_witness_test.dag (list_identity_cast_holds, int_identity_cast_holds)
  • CI on PR (build, regen, heal_generated_artifacts green on HEAD)

Brian Searls and others added 2 commits August 10, 2026 05:38
…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>
@briansrls
briansrls merged commit 3f8b0d6 into main Aug 10, 2026
5 checks passed
@briansrls
briansrls deleted the session/royal-dove-167 branch August 10, 2026 12:56
@gunbai-bot

gunbai-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

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 !m.starts_with("where-refinement unenforced:") to assert_resolved_no_hard_errors, which reads like a suppression added to make new code pass — the §5 tell where a constraint is satisfied by editing the assertion. It is not. That filter is already present on origin/main in this same helper, and cross_representation_equality_test.rs carries it with an explaining comment ("filtered for the same reason"). The diagnostic became reachable when #7438 (2026-07-30) enforced where-refinements at literal sites, and this helper's pre-existing source casts s as Secret / s as NonEmptyStr / s as SecretValue — all where-refined String-family types. So it is pre-existing corpus-wide noise, correctly filtered, not a manufactured green. No action.

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 format! on a Variant is a legitimate serialization while format! on a List was a fabrication. If the honest answer is that the corpus currently depends on it, that is a normal interim — but DESIGN §6 requires every scaffold to land with a named dissolution trigger, and this one has a comment in its place.

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

gunbai-bot Bot pushed a commit that referenced this pull request Aug 10, 2026
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>
briansrls added a commit that referenced this pull request Aug 10, 2026
…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>
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