Skip to content

Commit 4e6903f

Browse files
fix(typecheck): infer recursive functions' return type by fixpoint (#88) (#91)
Recursive functions returning a scalar were **unwritable**. The canonical shape, straight out of `examples/trefoil.tangle`: ```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: ```lean | 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](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b53fc0e commit 4e6903f

2 files changed

Lines changed: 180 additions & 23 deletions

File tree

compiler/lib/typecheck.ml

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,109 @@ and check_pattern (gamma : env) (scrutinee_ty : ty) (pat : pattern)
652652
let valid_invariants =
653653
["jones"; "alexander"; "homfly"; "kauffman"; "writhe"; "linking"]
654654

655+
(** Does [e] contain a call to [f]? Used to detect self-recursion in a
656+
function definition, so that only recursive definitions pay the cost of the
657+
return-type search below. *)
658+
let rec expr_calls (f : string) (e : expr) : bool =
659+
let go = expr_calls f in
660+
match e with
661+
| Call (g, args) -> g = f || List.exists go args
662+
| Match (scrut, arms) ->
663+
go scrut || List.exists (fun a -> go a.arm_body) arms
664+
| Let (_, e1, e2) -> go e1 || go e2
665+
| Pipeline (e1, e2) -> go e1 || go e2
666+
| BinOp (_, e1, e2) -> go e1 || go e2
667+
| Cap (e1, e2) | Cup (e1, e2) | Pair (e1, e2)
668+
| EchoAdd (e1, e2) | EchoEq (e1, e2) -> go e1 || go e2
669+
| UnaryOp (_, e1) | Close e1 | Mirror e1 | Reverse e1 | Simplify e1
670+
| Twist e1 | EchoClose e1 | Lower e1 | Residue e1 | Fst e1 | Snd e1 -> go e1
671+
| BraidLit _ | Identity | BoolLit _ | IntLit _ | FloatLit _
672+
| StringLit _ | Var _ | Crossing _ -> false
673+
674+
(** Candidate return types for a recursive function, in the order they are
675+
tried. Seeds drawn from the body come first (a match's non-recursive arms
676+
are the usual source), then the scalar types, then the original [TWord 0]
677+
placeholder so the previous behaviour remains reachable. *)
678+
let recursive_return_candidates (gamma : env) (f : string) (body : expr) : ty list =
679+
(* Types of the arms that do NOT mention [f] — those can be inferred before
680+
anything is known about the function's return type. For
681+
def length(w) = match w with
682+
| identity => 0 <- typeable now: Num
683+
| s1 . rest => 1 + length(rest) <- not yet
684+
| _ => 0 <- typeable now: Num
685+
this yields [TNum], which is the fixpoint. *)
686+
let seeds_from_body =
687+
match body with
688+
| Match (_, arms) ->
689+
List.filter_map (fun a ->
690+
if expr_calls f a.arm_body then None
691+
else (try Some (infer_expr gamma [] a.arm_body) with _ -> None)
692+
) arms
693+
| _ -> []
694+
in
695+
let dedup l =
696+
List.fold_left (fun acc x -> if List.mem x acc then acc else acc @ [x]) [] l
697+
in
698+
dedup (seeds_from_body @ [TNum; TBool; TStr; TWord 0])
699+
700+
(** Infer the return type of a function definition and bind it in [gamma].
701+
[T-Def-Fun].
702+
703+
This is the single implementation of function-definition typing. It used
704+
to exist twice — once here and once inlined in [check_program]'s pass 1b —
705+
and only the copy in [check_program] was ever reached for whole programs.
706+
A fix applied to one had no effect on the other, so they are now one
707+
function called from both. *)
708+
let bind_function_def (gamma : env) (def : definition) : env =
709+
let param_tys = List.map (fun _p -> TWord 0) def.def_params in
710+
(* Bind the params, and the function itself so recursive calls resolve.
711+
[seed] is the return type assumed for those recursive calls. *)
712+
let with_seed (seed : ty) : env =
713+
let fsig = { fsig_params = param_tys; fsig_return = seed } in
714+
let g = env_bind_fun gamma def.def_name fsig in
715+
List.fold_left2 (fun g pname pty -> env_bind_val g pname pty)
716+
g def.def_params param_tys
717+
in
718+
let body_ty =
719+
if not (expr_calls def.def_name def.def_body) then
720+
(* Non-recursive: the seed is never consulted, so one pass suffices.
721+
Unchanged from the original behaviour. *)
722+
infer_expr (with_seed (TWord 0)) [] def.def_body
723+
else begin
724+
(* Recursive. The return type occurs in its own derivation, so it must
725+
be a FIXPOINT: assume a return type, check the body under that
726+
assumption, and accept only if the body then has exactly the assumed
727+
type. Anything weaker is a guess that merely failed to crash.
728+
729+
Previously the seed was hard-coded to [TWord 0] and marked
730+
"placeholder", with nothing checking the result against it. So
731+
732+
def length(w) = match w with
733+
| identity => 0
734+
| s1 . rest => 1 + length(rest)
735+
| _ => 0
736+
737+
failed with "Cannot add Num and Word[0]" — the recursive call was
738+
typed at the placeholder rather than at Num, which made every
739+
recursive function returning a scalar unwritable.
740+
741+
The candidate list is finite and ordered, so this terminates. If no
742+
candidate is a fixpoint we fall back to the original single pass,
743+
which re-raises its original error: a definition that did not
744+
typecheck before still does not, with the same message. *)
745+
let rec first_fixpoint = function
746+
| [] -> infer_expr (with_seed (TWord 0)) [] def.def_body
747+
| seed :: rest ->
748+
(match infer_expr (with_seed seed) [] def.def_body with
749+
| t when t = seed -> t (* consistent: a real fixpoint *)
750+
| _ -> first_fixpoint rest
751+
| exception _ -> first_fixpoint rest)
752+
in
753+
first_fixpoint (recursive_return_candidates gamma def.def_name def.def_body)
754+
end
755+
in
756+
env_bind_fun gamma def.def_name { fsig_params = param_tys; fsig_return = body_ty }
757+
655758
(** Type-check a single statement, returning the (possibly extended) environment.
656759
* Implements [T-Def-Val], [T-Def-Fun], [T-Assert], [T-Compute], [T-Weave].
657760
*)
@@ -671,19 +774,7 @@ let check_statement (gamma : env) (stmt : statement) : env =
671774
* A more sophisticated implementation would use constraint-based
672775
* inference; here we use a simple forward analysis.
673776
*)
674-
let param_tys = List.map (fun _p -> TWord 0) def.def_params in
675-
(* Bind params and the function itself (for recursion) into the env *)
676-
let ret_ty = TWord 0 in (* placeholder *)
677-
let fsig = { fsig_params = param_tys; fsig_return = ret_ty } in
678-
let gamma' = env_bind_fun gamma def.def_name fsig in
679-
let gamma' = List.fold_left2 (fun g pname pty ->
680-
env_bind_val g pname pty
681-
) gamma' def.def_params param_tys in
682-
(* Infer the body type *)
683-
let body_ty = infer_expr gamma' [] def.def_body in
684-
(* Re-register with inferred return type *)
685-
let fsig' = { fsig_params = param_tys; fsig_return = body_ty } in
686-
env_bind_fun gamma def.def_name fsig'
777+
bind_function_def gamma def
687778
end
688779

689780
(* [T-Weave] (section 3.10) *)
@@ -813,16 +904,7 @@ let check_program (prog : program) : check_result =
813904
let ty = infer_expr gamma [] def.def_body in
814905
env_bind_val gamma def.def_name ty
815906
end else begin
816-
let param_tys = List.map (fun _p -> TWord 0) def.def_params in
817-
let fsig_placeholder =
818-
{ fsig_params = param_tys; fsig_return = TWord 0 } in
819-
let gamma' = env_bind_fun gamma def.def_name fsig_placeholder in
820-
let gamma' = List.fold_left2 (fun g pname pty ->
821-
env_bind_val g pname pty
822-
) gamma' def.def_params param_tys in
823-
let body_ty = infer_expr gamma' [] def.def_body in
824-
let fsig = { fsig_params = param_tys; fsig_return = body_ty } in
825-
env_bind_fun gamma def.def_name fsig
907+
bind_function_def gamma def
826908
end
827909
with Type_error msg ->
828910
add_error_at def.def_line

compiler/test/test_typecheck.ml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,81 @@ let test_functions () =
623623
let r = check_ok prog in
624624
r.result_ok);
625625

626+
(* ---------------------------------------------------------------- *)
627+
(* #88: recursive functions returning a SCALAR. *)
628+
(* The return type occurs in its own derivation, so it must be a *)
629+
(* fixpoint. Previously the recursive call was typed at the hard-coded *)
630+
(* [TWord 0] "placeholder" seed with nothing checking the result *)
631+
(* against it, so every one of these failed with *)
632+
(* "Cannot add Num and Word[0]" *)
633+
(* and recursive scalar functions were simply unwritable. *)
634+
(* ---------------------------------------------------------------- *)
635+
636+
test "#88 recursive fn returning Num (the examples/ `length` shape)" (fun () ->
637+
(* def length(w) = match w with
638+
| identity => 0
639+
| s1 . rest => 1 + length(rest)
640+
| _ => 0 *)
641+
let prog = [
642+
def_fun "length" ["w"] (Match (Var "w", [
643+
{ arm_pattern = PatIdentity; arm_body = IntLit 0 };
644+
{ arm_pattern = PatCons ({ gpat_index = 1; gpat_exponent = 1 }, PatVar "rest");
645+
arm_body = BinOp (Add, IntLit 1, Call ("length", [Var "rest"])) };
646+
{ arm_pattern = PatWildcard; arm_body = IntLit 0 };
647+
]))
648+
] in
649+
let r = check_ok prog in
650+
r.result_ok);
651+
652+
test "#88 recursive fn's return type is Num, not the seed" (fun () ->
653+
(* Guard: it must not merely typecheck — the signature must be right,
654+
otherwise callers would still see Word[0]. *)
655+
let prog = [
656+
def_fun "length" ["w"] (Match (Var "w", [
657+
{ arm_pattern = PatIdentity; arm_body = IntLit 0 };
658+
{ arm_pattern = PatCons ({ gpat_index = 1; gpat_exponent = 1 }, PatVar "rest");
659+
arm_body = BinOp (Add, IntLit 1, Call ("length", [Var "rest"])) };
660+
{ arm_pattern = PatWildcard; arm_body = IntLit 0 };
661+
]))
662+
] in
663+
let r = check_ok prog in
664+
(match env_lookup r.result_env "length" with
665+
| Some (EFun s) -> s.fsig_return = TNum
666+
| _ -> false));
667+
668+
test "#88 recursive fn result is usable at Num by a caller" (fun () ->
669+
let prog = [
670+
def_fun "length" ["w"] (Match (Var "w", [
671+
{ arm_pattern = PatIdentity; arm_body = IntLit 0 };
672+
{ arm_pattern = PatCons ({ gpat_index = 1; gpat_exponent = 1 }, PatVar "rest");
673+
arm_body = BinOp (Add, IntLit 1, Call ("length", [Var "rest"])) };
674+
{ arm_pattern = PatWildcard; arm_body = IntLit 0 };
675+
]));
676+
(* Only well-typed if `length` really returns Num. *)
677+
def_val "total" (BinOp (Add, Call ("length", [BraidLit [sigma 1]]), IntLit 1));
678+
] in
679+
let r = check_ok prog in
680+
r.result_ok);
681+
682+
test "#88 non-recursive definitions are unaffected" (fun () ->
683+
(* Regression guard: the fixpoint search must only engage for genuinely
684+
recursive bodies. A word-returning function still returns a word. *)
685+
let prog = [def_fun "twice" ["x"] (BinOp (Compose, Var "x", Var "x"))] in
686+
let r = check_ok prog in
687+
r.result_ok &&
688+
(match env_lookup r.result_env "twice" with
689+
| Some (EFun s) -> s.fsig_return = TWord 0
690+
| _ -> false));
691+
692+
test "#88 a genuinely ill-typed recursive fn still fails" (fun () ->
693+
(* The fallback must not turn errors into silent successes: no candidate
694+
return type makes `identity + 1` well-formed. *)
695+
let prog = [
696+
def_fun "bad" ["w"] (BinOp (Add, Identity, Call ("bad", [Var "w"])))
697+
] in
698+
let r = check_ok prog in
699+
not r.result_ok);
700+
626701
(* [T-App]: function application *)
627702
test "T-App" (fun () ->
628703
let prog = [

0 commit comments

Comments
 (0)