Skip to content

fix(infer): return types were never checked — 13 false accepts - #18

Merged
Ch4s3 merged 2 commits into
mainfrom
claude/infer-hunt
Aug 8, 2026
Merged

Ch4s3 merged 2 commits into
mainfrom
claude/infer-hunt

Conversation

@Ch4s3

@Ch4s3 Ch4s3 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The oracle was never checking function return types

fn f(n : Int) : String do n end — march rejects it. We accepted it.

inferModule''s .dfn arm honoured parameter annotations and discarded retAnnot entirely, so every return-position type error was invisible to the oracle. Independent type checking is milestone A1/A2's entire purpose, and half of function type-checking has been absent since then — through five milestones, 334 corpus files, and several reviews.

Found by an adversarial hunt targeting Infer.lean (1,525 lines) on the grounds that it had received far less scrutiny than CapCheck.lean (4,168 lines) while judging the majority of non-capability files.

~76 probes. 13 confirmed false accepts, ZERO false rejects. 12 fixed, 1 deliberately left.

Findings

# Probe march rejects because before after
1 fn f(n : Int) : String do n end expected String, got Int accept reject
2 fn f(n : Int) : () do n end expected (), got Int accept reject
3 fn f() : Float do 1 end expected Float, got Int accept reject
4 fn f() : Int do 1.5 end expected Int, got Float accept reject
5 record field returned at wrong type expected String, got Int accept reject
6 type Age = Int; fn f(x:Int) : Age do x end aliases are nominal to march accept reject
7 fn g() : String do fact(3) end expected String, got Int accept reject
8 same, inside a nested mod expected Bool, got Int accept reject
9 no_panic: match n do 0; 1 end (Int) non-exhaustive accept reject
10 same on String non-exhaustive accept reject
11 same on Float non-exhaustive accept reject
12 match b do true -> 1 end (Bool) non-exhaustive accept reject
13 (true,true) over (Bool,Bool) non-exhaustive accept accept — left

1–8 are the one root cause. 9–12 are a second: Pattern.lit isn't a modeled shape, so the exhaustiveness safety net short-circuited before reading the scrutinee type, and Bool isn't in builtinCtors.

Finding 6 is worth flagging on its own — march treats type Age = Int nominally, so an Int is not an Age. Easy to assume structural.

Why both fixes are safe

Neither depends on an input the checker reconstructs — the governing rule adopted after slice (c)'s four failed exhaustiveness rounds:

  • retAnnot is some only when fully in-fragment, so tyToMTy cannot throw on it.
  • find_missing_mc decides infinite-domain scrutinees (Int/Float/String/Char/Atom) and Bool outright, consulting neither the constructor universe nor the or-expansion policy — the two reconstructed inputs that caused trouble before.

Validated against all 246 march-accepted files in specs/**/accept, examples/, and stdlib/: zero rejects. Plus explicit near-misses — catch-alls, k as m, true | false, guarded arms, and unit/generic/Cap returns. 13 fixtures added.

Builtin signature audit

Every registered builtin checked against march's effective type (not just its table entry — the println bug was a table entry that turned out to be dead code).

  • println is the only prelude-shadowed name. Grepped stdlib/prelude.march for all 17 registered builtins.
  • The other 16 match march's table exactly (print, print_int/float, *_to_string, string_length/concat, ++, %, +. -. *. /., && || not, Num/Ord/Eq operators, root_cap, cap_narrow), each probed at a wrong argument type — all 8 such probes agree.
  • Unregistered builtins (to_string, head, int_*, float_*, record_*) throw SKIP: unbound variable → exit 2. Safe, but a real coverage hole.

Conformance

total files: 334   MATCH 86   MISMATCH 0   ERROR 0
SKIP 246 (145 accept / 101 reject)   SKIP-LEDGER OK   RESULT PASS

Unchanged from before the fix — the corpus does not exercise a single one of these 13 shapes. Fourth time this pattern has held; the fixtures are the only coverage.

Left unfixed, deliberately

  • ci: repin to march 7c1d701c, re-baseline skip ledger at 242 files #13, nested tuple patterns — needs the real Maranget matrix, not a constructor-name set. Attempting it with the current machinery is exactly what cost four rounds in slice (c).
  • Mutual recursion always hits SKIP: unbound variable: inferModule' folds decls in order, so forward references never resolve. Safe, but it removes a common shape from coverage — including one where march rejects on a wrong return type. A pre-pass binding top-level dfn names to fresh metavars would close it.

The pattern worth noting

retAnnot was decoded, threaded into Decl.dfn, scanned by CapCheck, and even had a hand-written #eval exercising the unify — and the one site that mattered dropped it behind a rationalising comment.

That is the same shape as the println bug (an authoritative-looking builtin table that was dead code) and the ELet bug (a docstring asserting march "never produces ELet here", false on two counts). Three defects, one mechanism: a plausible comment standing in for a check that wasn't there. Every remaining decoded-but-unconsumed field deserves an audit.

Ch4s3 added 2 commits August 8, 2026 11:10
`inferModule'`'s `.dfn` arm honored every param annotation but discarded
`retAnnot` entirely, so every return-position type error was a FALSE ACCEPT.
Verified against march (`--check`, exit 1 = reject) for: `: String` over an
`Int` body, `: ()` over a non-unit body, `: Float` over an integer literal,
`: Int` over a `Float` literal, a record field of the wrong type, a nominal
type alias (`type Age = Int` is NOT transparent to march), a body calling a
correctly-typed function at the wrong return type, and the same shape inside a
nested `mod`. All eight now agree.

Cannot manufacture a false reject: `retAnnot` is `some` only when the
annotation is fully in fragment (the decoder forces the whole decl to
`Decl.unsupported` otherwise, and maps a surface type variable to
`Ty.unsupported`), so `tyToMTy` sees only ground types and cannot throw.
Confirmed empirically: 0 new rejects across all 122 `specs/**/accept/*.march`
corpus files and all 124 march-accepted files in `examples/` + `stdlib/`.

Three `#eval`s pin the reject and both near-miss accepts (annotation agrees;
annotation absent).
… scrutinees

Closes Finding I2. `matchExhaustive` declined on every non-ADT scrutinee:
`Pattern.lit` is not an `isModeledArmPattern` shape, so the safety net
short-circuited to "exhaustive" before the scrutinee type was consulted, and
`Bool` (absent from `builtinCtors`) fell through to the unknown-type
carve-out. Under `cap no_panic` that was a live FALSE ACCEPT, verified
against march for all four shapes: `0 -> ..; 1 -> ..` on an `Int`,
`"a" -> ..; "b" -> ..` on a `String`, `1.0 -> ..; 2.0 -> ..` on a
`Float`, and `true -> ..` alone on a `Bool`.

Faithful to `find_missing_mc` (typecheck.ml:4363-4381): past the
`has_first_wild` test, an infinite domain reports missing unconditionally and
`Bool` needs both literal rows. Neither decision consults the constructor
universe or the or-expansion policy, so the general-conservatism rule (which
governs exactly those two RECONSTRUCTED inputs) is not in play — the only
input either branch depends on is the wildcard test this checker genuinely
models (`isCatchAllPattern` <-> `norm_pat`'s `SPWild`). The `Bool` branch,
which does read arm shapes, keeps its own safety net (`isBoolLitPattern`).

Verified no near-miss regression: `_`/bare-var catch-alls, `true | false`,
guarded-arm-plus-wildcard, and user-ADT coverage all still accept; 0 rejects
across all 246 march-accepted files in specs/**/accept, examples/ and stdlib/.
Ten fixtures pin both directions, each labelled with march's own verdict.

Finding I1 (depth: `(true, true)` over a `(Bool, Bool)`) stays open — it
needs the real pattern matrix.
@Ch4s3
Ch4s3 merged commit 85ed7d5 into main Aug 8, 2026
1 check passed
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