Origin: Eight distinct triggers of the same i32_pair-into-i64 mismatch shape, accumulated across #602, #614, PR #627, and PR #629. Each one was fixed locally; each fix added one more isinstance-handler or one more inference site. The next trigger is a matter of when, not if. Time to fix the shape.
The pattern
Every trigger has the same four-step shape:
- Some inference site in
vera/wasm/inference.py walks a TypeExpr to extract a canonical Vera type name string ("String", "Array", etc.).
- The walk is ad-hoc: each site re-implements its own combination of (a)
RefinementType unwrap, (b) alias-chain follow via _resolve_base_type_name, (c) generic substitution, (d) format-with-args.
- Some combination is missed at some site → the walk returns a non-canonical string (
"Str", an alias name) or None.
- Downstream
_translate_interpolated_string (vera/wasm/operators.py:455-486) checks vera_type == "String" → mismatch → silent fallthrough to to_string(...) over i32_pair → expected i64, found i32 at WASM validation, OR a call_indirect indirect-call type mismatch at runtime.
The cartesian product is (N inference sites) × (M type-expression shapes). We've patched roughly 7 sites and 5 shapes — both numbers will keep growing as the language grows.
Triggers fixed so far
| # |
PR |
Trigger shape |
Site fixed |
| 1 |
#602 |
Plain String-returning FnCall in interpolation |
_infer_fncall_vera_type i32_pair branch (registry consult added) |
| 2 |
#614 |
f()[i] where f returns Array<T> |
_infer_index_element_type_expr FnCall branch |
| 3 |
PR #627 |
Type alias over String (type Str = String) |
Same site, _resolve_base_type_name resolution added |
| 4 |
PR #629 |
Inline refinement @{ @String | p } |
_resolve_i32_pair_ret_te helper added |
| 5 |
PR #629 |
Nested refinements @{ @{ @String | p1 } | p2 } |
while-loop unwrap |
| 6 |
PR #629 |
Refinement-Array<T> indexed via FnCall |
Parallel while-loop in IndexExpr branch |
| 7 |
PR #629 |
apply_fn(@FnAlias.0, ()) where FnAlias has nested-refinement return |
_infer_fncall_vera_type apply_fn + _resolve_generic_fn_return + _fn_type_return_wasm (three sites) |
| 8 |
PR #629 |
apply_fn over FnType-aliased return (type Maker = fn(Unit -> Str) where Str = String) |
_format_named_type_canonical helper added |
Five overlapping helpers have accumulated, none of which does the full job:
| Helper |
Unwraps RefinementType |
Follows aliases |
Handles type_args |
_format_named_type |
✗ |
✗ |
✓ |
_format_named_type_canonical (#629) |
✗ |
✓ |
✓ |
_resolve_i32_pair_ret_te (#629) |
✓ (loop) |
✓ |
✗ |
_resolve_base_type_name |
✓ (single) |
✓ |
n/a (string-only) |
ad-hoc while loops in apply_fn / FnType helpers |
✓ |
mixed |
mixed |
Proposed fix — three tiers
Tier 1 — centralise canonicalisation (highest leverage, ~1–2 hr). Fold the five overlapping helpers into a single _canonical_vera_type(te: ast.TypeExpr, alias_map: dict[str, ast.TypeExpr] | None = None) -> str | None that handles all four concerns (RefinementType unwrap, alias-chain follow, generic substitution, format-with-args). Replace every ad-hoc walk with a call. New triggers in this class become structurally impossible on the producer side.
Sites to migrate (initial list — confirm during refactor):
_infer_fncall_vera_type apply_fn branch (vera/wasm/inference.py:638-687)
_resolve_i32_pair_ret_te (inference.py:834-884)
_resolve_generic_fn_return (inference.py:~1113)
_fn_type_return_wasm (inference.py:~1163)
_infer_index_element_type_expr FnCall branch (inference.py:937-948)
_format_named_type_canonical (inference.py:486+)
Tier 2 — make the silent fallthrough loud (~30 min). The actual silent failure is at vera/wasm/operators.py:482-486 (the else branch in _translate_interpolated_string). Pre-#602 it carried a # pragma: no cover claiming unreachability — disproved 8 times now. Convert it to a hard compile-time error with a clear E-code (something like [E614] cannot interpolate value of type X — inference returned None). Dovetails with #626 (silent-skip pattern) — natural tracking sibling.
This is independently shippable and catches any future canonicaliser gap at compile time, not WASM-validation time. Even with Tier 1 in place, an inference miss on a new type-expression shape would be diagnosed loudly rather than silently miscompiled.
Tier 3 — WASM-side defensive dispatch (medium refactor, optional). Even with Tiers 1+2, an inference miss would still produce a compile error rather than working code. The defensive option: peek at the WASM stack type at the interpolation site (i32_pair → route to string concat) regardless of what Vera-side inference says. Belt-and-braces; defer until/unless we see another trigger after T1+T2 land.
Recommended
Single PR doing Tier 1 + Tier 2 together. Tier 1 alone is producer-side; Tier 2 alone is consumer-side; together they make the next trigger either impossible (Tier 1) or instantly diagnosable (Tier 2). Tier 3 is overkill until we observe further recurrence.
Acceptance criteria
- All five existing helpers either deleted or thinly delegate to
_canonical_vera_type.
- All 8 triggers above remain pinned by their existing regression tests in
tests/test_codegen.py::TestStringInterpolation.
- The fallthrough at
operators.py:482 raises a structured diagnostic instead of emitting to_string(...) over an unknown type; new test pins the diagnostic.
- No behavioural change for any existing program — pure refactor + diagnostic conversion.
Pairs with #626
#626 covers the broader silent-skip pattern (translator return None → silent degradation). This issue is the specific instance where the silent skip happens in canonicalisation rather than translation. Tier 2 of this issue is essentially #626 Layer 1 applied to a single site; if #626 lands first, Tier 2 here folds into it.
Origin
Pattern emerged across PR #629's review cycle — five separate review passes each surfacing one or two more triggers of the same bug class. CodeRabbit and silent-failure-hunter agents both consistently identified new shapes faster than local fixes could close them. The 8th trigger pushed past the threshold where reactive fixing is cheaper than structural refactor.
Origin: Eight distinct triggers of the same
i32_pair-into-i64mismatch shape, accumulated across #602, #614, PR #627, and PR #629. Each one was fixed locally; each fix added one more isinstance-handler or one more inference site. The next trigger is a matter of when, not if. Time to fix the shape.The pattern
Every trigger has the same four-step shape:
vera/wasm/inference.pywalks aTypeExprto extract a canonical Vera type name string ("String","Array", etc.).RefinementTypeunwrap, (b) alias-chain follow via_resolve_base_type_name, (c) generic substitution, (d) format-with-args."Str", an alias name) orNone._translate_interpolated_string(vera/wasm/operators.py:455-486) checksvera_type == "String"→ mismatch → silent fallthrough toto_string(...)overi32_pair→expected i64, found i32at WASM validation, OR acall_indirectindirect-call type mismatch at runtime.The cartesian product is (N inference sites) × (M type-expression shapes). We've patched roughly 7 sites and 5 shapes — both numbers will keep growing as the language grows.
Triggers fixed so far
String-returning FnCall in interpolation_infer_fncall_vera_typei32_pair branch (registry consult added)f()[i]wherefreturnsArray<T>_infer_index_element_type_exprFnCall branchtype Str = String)_resolve_base_type_nameresolution added@{ @String | p }_resolve_i32_pair_ret_tehelper added@{ @{ @String | p1 } | p2 }while-loop unwrapArray<T>indexed via FnCallwhile-loop in IndexExpr branchapply_fn(@FnAlias.0, ())where FnAlias has nested-refinement return_infer_fncall_vera_typeapply_fn +_resolve_generic_fn_return+_fn_type_return_wasm(three sites)apply_fnoverFnType-aliased return (type Maker = fn(Unit -> Str)whereStr = String)_format_named_type_canonicalhelper addedFive overlapping helpers have accumulated, none of which does the full job:
RefinementTypetype_args_format_named_type_format_named_type_canonical(#629)_resolve_i32_pair_ret_te(#629)_resolve_base_type_namewhileloops in apply_fn / FnType helpersProposed fix — three tiers
Tier 1 — centralise canonicalisation (highest leverage, ~1–2 hr). Fold the five overlapping helpers into a single
_canonical_vera_type(te: ast.TypeExpr, alias_map: dict[str, ast.TypeExpr] | None = None) -> str | Nonethat handles all four concerns (RefinementType unwrap, alias-chain follow, generic substitution, format-with-args). Replace every ad-hoc walk with a call. New triggers in this class become structurally impossible on the producer side.Sites to migrate (initial list — confirm during refactor):
_infer_fncall_vera_typeapply_fn branch (vera/wasm/inference.py:638-687)_resolve_i32_pair_ret_te(inference.py:834-884)_resolve_generic_fn_return(inference.py:~1113)_fn_type_return_wasm(inference.py:~1163)_infer_index_element_type_exprFnCall branch (inference.py:937-948)_format_named_type_canonical(inference.py:486+)Tier 2 — make the silent fallthrough loud (~30 min). The actual silent failure is at
vera/wasm/operators.py:482-486(theelsebranch in_translate_interpolated_string). Pre-#602 it carried a# pragma: no coverclaiming unreachability — disproved 8 times now. Convert it to a hard compile-time error with a clear E-code (something like[E614] cannot interpolate value of type X — inference returned None). Dovetails with #626 (silent-skip pattern) — natural tracking sibling.This is independently shippable and catches any future canonicaliser gap at compile time, not WASM-validation time. Even with Tier 1 in place, an inference miss on a new type-expression shape would be diagnosed loudly rather than silently miscompiled.
Tier 3 — WASM-side defensive dispatch (medium refactor, optional). Even with Tiers 1+2, an inference miss would still produce a compile error rather than working code. The defensive option: peek at the WASM stack type at the interpolation site (
i32_pair→ route to string concat) regardless of what Vera-side inference says. Belt-and-braces; defer until/unless we see another trigger after T1+T2 land.Recommended
Single PR doing Tier 1 + Tier 2 together. Tier 1 alone is producer-side; Tier 2 alone is consumer-side; together they make the next trigger either impossible (Tier 1) or instantly diagnosable (Tier 2). Tier 3 is overkill until we observe further recurrence.
Acceptance criteria
_canonical_vera_type.tests/test_codegen.py::TestStringInterpolation.operators.py:482raises a structured diagnostic instead of emittingto_string(...)over an unknown type; new test pins the diagnostic.Pairs with #626
#626 covers the broader silent-skip pattern (translator
return None→ silent degradation). This issue is the specific instance where the silent skip happens in canonicalisation rather than translation. Tier 2 of this issue is essentially #626 Layer 1 applied to a single site; if #626 lands first, Tier 2 here folds into it.Origin
Pattern emerged across PR #629's review cycle — five separate review passes each surfacing one or two more triggers of the same bug class. CodeRabbit and silent-failure-hunter agents both consistently identified new shapes faster than local fixes could close them. The 8th trigger pushed past the threshold where reactive fixing is cheaper than structural refactor.