Skip to content

fix(typecheck): infer recursive functions' return type by fixpoint (#88) - #91

Merged
hyperpolymath merged 1 commit into
mainfrom
feat/corpus-typing-88
Jul 29, 2026
Merged

hyperpolymath merged 1 commit into
mainfrom
feat/corpus-typing-88

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Recursive functions returning a scalar were unwritable. The canonical shape, straight out of examples/trefoil.tangle:

def length(w) = match w with
  | identity  => 0
  | s1 . rest => 1 + length(rest)
  | _         => 0
end

failed with Cannot add Num and Word[0].

Cause

While checking a function's own body, the function was bound with a hard-coded return type of TWord 0, marked (* placeholder *) — and nothing ever checked the inferred body type against that assumption. So length(rest) typed as Word[0], making 1 + length(rest) a Num + Word[0].

Fix

A recursive function's return type occurs in its own derivation, so it must be a fixpoint: assume a return type, check the body under that assumption, and accept only if the body then has exactly the assumed type. Anything weaker is a guess that merely failed to crash.

Candidate seeds come first from the match arms that don't mention the function — for length those are the two 0 arms, giving Num — then the scalar types, then the original TWord 0 so prior behaviour stays reachable. The list is finite and ordered, so it terminates. If no candidate is a fixpoint, it falls back to the original single pass and re-raises its original error: a definition that didn't typecheck before still doesn't, with the same message.

The logic existed twice

check_statement had one copy and check_program's pass 1b had another — and only check_program's is reached for whole programs. My first attempt at this fix therefore changed nothing observable, which is exactly how the placeholder survived so long: fixing one copy looks like fixing the bug. They're now a single bind_function_def called from both.

Scope — what this does NOT do

This closes the recursive-typing half of #88. The remaining example failures are a different question: equal-width requirements on == and on match arms.

That one cannot be changed in OCaml alone. Lean's rule requires both operands at the same width:

| tEqWord (Γ : Ctx) (e₁ e₂ : Expr) (n : Nat) :
    HasType Γ e₁ (.word n) →
    HasType Γ e₂ (.word n) →
    HasType Γ (.eq e₁ e₂) .bool

and TG-3's 496 kernel-checked obligations tie OCaml's infer_expr to that spec. Changing one engine would silently break the differential. It needs a cross-engine ruling the way #50 did — raised separately, with the evidence.

Tests

Five new cases, chosen so they can't pass vacuously:

Test Guards against
the length shape typechecks the original bug
its inferred signature is Num "compiles" ≠ "correct type"
a caller can use the result at Num the signature being unusable downstream
non-recursive definitions unaffected the fixpoint search over-reaching
a genuinely ill-typed recursive fn still fails the fallback swallowing real errors

Typecheck suite 119 → 124. TG-3 still 1008/0. All other suites unchanged; corpus and RSR gates still green.

🤖 Generated with Claude Code

Recursive functions returning a scalar were unwritable. The canonical shape,
straight out of examples/trefoil.tangle:

    def length(w) = match w with
      | identity  => 0
      | s1 . rest => 1 + length(rest)
      | _         => 0

failed with "Cannot add Num and Word[0]".

Cause: while checking a function's own body, the function was bound with a
hard-coded return type of TWord 0, marked "placeholder" — and NOTHING ever
checked the inferred body type against that assumption. So the recursive call
`length(rest)` typed as Word[0], and `1 + length(rest)` was Num + Word[0].

A recursive function's return type occurs in its own derivation, so it must
be a FIXPOINT: assume a return type, check the body under that assumption,
and accept only if the body then has exactly the assumed type. Anything
weaker is a guess that merely failed to crash.

Candidate seeds are drawn first from the match arms that do NOT mention the
function (for `length` those are the two `0` arms, giving Num), then the
scalar types, then the original TWord 0 so prior behaviour stays reachable.
The list is finite and ordered, so this terminates. If no candidate is a
fixpoint we fall back to the original single pass, which re-raises its
original error — a definition that did not typecheck before still does not,
with the same message.

## The logic existed TWICE

check_statement had one copy and check_program's pass 1b had another, and
only check_program's is reached for whole programs — so the first version of
this fix changed nothing observable. They are now a single
`bind_function_def` called from both. That duplication is why the placeholder
survived: fixing one copy looked like fixing the bug.

## Scope

This closes the recursive-typing half of #88. The remaining example failures
are a DIFFERENT question — equal-width requirements on `==` and on match arms
— which cannot be changed in OCaml alone: Lean's `tEqWord` requires both
operands at the same width `n`, and TG-3's 496 kernel-checked obligations tie
OCaml's inference to that spec. It needs a cross-engine ruling like #50 did;
raised separately.

Tests: 5 new cases covering the `length` shape, that the inferred signature is
actually Num (not merely that it compiles), that a caller can use it at Num,
that non-recursive definitions are untouched, and that a genuinely ill-typed
recursive function still fails. Typecheck suite 119 -> 124; TG-3 still
1008/0; all other suites unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hyperpolymath
hyperpolymath merged commit 4e6903f into main Jul 29, 2026
25 checks passed
@hyperpolymath
hyperpolymath deleted the feat/corpus-typing-88 branch July 29, 2026 00:49
Comment thread compiler/lib/typecheck.ml
Comment thread compiler/lib/typecheck.ml
@gitar-bot

gitar-bot Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Note

Automatic reviews are paused because your trial's included automatic processing has been used for this period. Upgrade now, or comment "Gitar review" to run a review anytime.
Learn more

Code Review ✅ Approved 2 resolved / 2 findings

Adds fixpoint iteration to infer recursive function return types, successfully enabling scalar recursion like the length example. Consider expanding the candidate seed set and refining parameter handling in recursive_return_candidates.

Auto-approved and auto-merge armed: No blocking issues found.
Please see Auto-approve Docs for details on setting custom approval criteria. — merges when pipeline and required approvals pass.

✅ 2 resolved
✅ Edge Case: Fixpoint candidates limited to scalars + TWord 0

📄 compiler/lib/typecheck.ml:695-698 📄 compiler/lib/typecheck.ml:745-753
The candidate set is seeds_from_body @ [TNum; TBool; TStr; TWord 0]. A recursive function whose true return type is TWord n (n>0), TTangle, TProd, or TEcho, and whose non-recursive arms cannot be inferred (see the param-binding note), will find no fixpoint and fall through to the [] case, which returns whatever the single TWord-0 pass yields — a signature typed under an assumption the body then contradicts. This is outside the scalar scope of #88 so severity is low, but such recursive functions still get a silently-inconsistent signature rather than an error. Consider seeding candidates from all arm types (recursive arms included, computed under the seeded env) or documenting the non-scalar limitation explicitly.

✅ Quality: Seed inference ignores params; broad with _ catch-all

📄 compiler/lib/typecheck.ml:691 📄 compiler/lib/typecheck.ml:748-751
recursive_return_candidates infers non-recursive arm bodies with infer_expr gamma [] a.arm_body where gamma has neither the function's parameters nor the arm's pattern-bound variables in scope; any arm referencing them raises and is dropped by the try ... with _ -> None, so a valid seed can be missed (masked here only because the scalar/TWord-0 fallback happens to recover length). Both the seed try ... with _ and the | exception _ in first_fixpoint swallow every exception (including Stack_overflow/Out_of_memory), not just Type_error. Prefer binding the params (and, for the seed case, the arm pattern variables) into the env before inferring, and narrow the handlers to Type_error.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@gitar-bot

gitar-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚠️ Gitar auto-approved this PR but could not enable auto-merge: auto-merge is disabled for this repository — enable "Allow auto-merge" in the repository settings.

@gitar-bot gitar-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitar has auto-approved this PR and enabled auto-merge (configure)

@gitar-bot gitar-bot Bot added the gitar-approved Added by Gitar label Jul 29, 2026
hyperpolymath added a commit that referenced this pull request Jul 29, 2026
Four of the five unparsed `conformance/valid/` programs — `v02_weave`,
`v08_crossing`, `v09_twist`, `v12_weave_expr` — use weave as a
**definition body**:

```tangle
def simple_crossing =
  weave strands a:Q, b:Q into
    (a > b)
  yield strands b:Q, a:Q
```

The grammar allowed weave only as a top-level statement, so all four
failed. Typed strands (`a:Q`) and `yield strands` were never the problem
— those already parsed. Only the **position** did.

## Why the expression form is the right fix, not a corpus edit

My first instinct was that the corpus was wrong, since
`spec/grammar.ebnf` also said statement. Then I checked what a weave
statement actually *does*:

```ocaml
(* eval.ml *)
| WeaveBlock _wb -> (env, None)          (* no-op *)

(* typecheck.ml *)
let _weave_ty = TTangle (input_boundary, output_boundary) in
gamma                                     (* type computed, then DISCARDED *)
```

**As a statement, weave is inert.** It binds nothing, produces nothing,
and its type is thrown away. The construct could be written but never
used. Binding it to a name is the only thing that makes it reachable —
which is precisely what the corpus assumed all along.

The statement form is kept, so this is **purely additive**.

## Scope — no proof implications

Weave is outside the mechanised core: **0 occurrences in
`proofs/Tangle.lean`, 0 in the TG-3 corpus**. So this touches no proof
obligation, and `Weave` joins the other non-core constructors in
`tg3_emit`'s explicit rejection branch rather than being translated.

Threaded through `ast` (new `Weave of weave_block`), `parser`
(`primary_expr`), `typecheck` (types as the `Tangle[A,B]` it denotes;
body checked in the strand context exactly as the statement form does),
`eval` (the body *is* the value), and `pretty` (re-parseable form).
`expr_calls` also traverses into a weave body — otherwise recursion
inside one would be invisible to the #91 fixpoint search.

## What this does NOT fix: `v11_add_block`

`add{ 1 + 2 + 3 }` is **not a missing parse rule**. It is the **Harvard
data sub-language**:

| | |
|---|---|
| own expression grammar | literals, vars, arithmetic, equality,
`&&`/`\|\|`/`!`, calls, `if/then/else` |
| own type system | Int, Float, Rational, Hex, Binary, Bool, String,
Symbolic |
| own environments | `Π`, with visibility rules |
| own typing judgement | `⊢_hd` |
| conversion layer | Embed / Unembed |
| calling | bidirectional with Tangle |

**288 lines of `FORMAL-SEMANTICS.md` across 12 sections.** That is a
feature, raised separately. It remains the single entry in the corpus
manifest's known-gap list.

## Results

| | before | after |
|---|---|---|
| conformance | 14/19 | **18/19** |
| corpus manifest known-unparsed | 5 | **1** |

(It reported a *fake* 3/19 before #89 fixed the runner.)

The ratchet in `check-corpus.sh` **demanded** the manifest update — it
failed the build with *"now parses — drop it from
CONFORMANCE_KNOWN_UNPARSED"* for each file. That is exactly what it was
built to do.

## Tests

- weave as a definition body (the conformance form)
- **TG-4 round-trip**: pretty-print then re-parse yields the same AST —
needed because I changed `pretty.ml`
- guard that the statement form still parses (the change is additive)

Full suite green, 0 failures; TG-3 still 1008/0. `spec/grammar.ebnf`
updated to match, with the rationale recorded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gitar-approved Added by Gitar

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant