Skip to content

Commit e4be440

Browse files
hyperpolymathclaude
andcommitted
feat(#92): widths need not agree for ==, and match arms join on width
Implements the #92 ruling across both engines, plus two bugs found while verifying it. ## The rule `T-Eq-Word` required both operands at the same width. That was inconsistent with the rest of the language in four ways, all recorded in #92: * `tComposeWord` already WIDENS to max n m — a program could compose two braids it was then forbidden to compare; * `~` accepted differing widths and, since TG-7, evaluates through the SAME `Braid_equiv.equiv` — identical operands, identical answer, one rejected; * `eqIdBraid`/`eqBraidId` decide "is this braid trivial?" against `identity : word 0`, so under the old rule they could only ever fire when the braid was empty — the step relation had rules for a question the typing rule forbade asking; * `braid_equiv.equiv` has no width parameter at all. Lean `tEqWord` now takes (n m); `infer`'s `.eq` case drops the `if n = m`. OCaml `Eq` mirrors it. Match arms join on width for words (max) and still require exact agreement for everything else. ## The metatheory did not need patching Progress, Preservation, Determinism, TypeSafety, infer_sound and infer_complete all still hold — `lean Tangle.lean` reports 0 errors, and the sorry/axiom gate passes. The `infer_sound` proof got SIMPLER: with no `if n = m` there is no inner `split` left to case on, which is a decent sign the restriction was load-bearing for nothing. TG-3 regenerated and kernel-checked: 496 obligations, 0 errors. 46 of them flipped from `= none` to `= some .bool` — precisely the `identity == braid` shape that was previously unreachable. ## Two bugs found while verifying 1. STATEMENT ORDER. `parse_file_recovering` in bin/main.ml did `stmts := prog @ !stmts` and then `List.rev` on the flattened result. That reversal is right for an accumulator built by prepending single items — as `diagnostics` is — but whole SEGMENTS were prepended, so every program came out backwards: `def x; def y; def z` parsed to [z; y; x]. Any program whose statements depend on order failed at evaluation with "Unbound variable". The test suites never caught it because they call `Tangle.Parser.program` directly; only the CLI goes through the recovering path. Now accumulates segments and flattens in order. 2. A FALSE ASSERTION in examples/trefoil.tangle: assert reversed == braid[s1, s1, s1] `reverse` reverses the word AND negates exponents, so it yields the INVERSE braid. writhe(trefoil) = 3 but writhe(reversed) = -3, and writhe is invariant under the braid relations, so they cannot be equal. The assertion had been wrong since it was written; nothing ran the examples until #89. Corrected to braid[s1^-1, s1^-1, s1^-1], which passes. ## Result examples evaluating : 2/7 -> 7/7 (all now in the must-run set) lib/stdlib.tangle : did not typecheck -> typechecks conformance : 18/19 (v11 add{} is the Harvard sub-language, #94) ## Trusted base Registered as A-TG-92.1: the embedding Bn -> Bn+1 that justifies cross-width comparison is standard mathematics but is asserted IN PROSE, not mechanised. What is machine-checked is that the metatheory holds under the widened rule and that OCaml still agrees with Lean on the corpus. Tests: 6 new typecheck cases including two negatives (mismatched KINDS still rejected; match arms of different kinds still rejected) so widening cannot degrade into "anything compares to anything". Three test_check cases repointed at an error that is still an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1223ea6 commit e4be440

11 files changed

Lines changed: 212 additions & 97 deletions

File tree

ASSUMPTIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Cross-references use `[[A-TG-N.M]]` syntax, resolved here.
3333
| A-TG-6.2 | DESIGN | Source semantics has no floating-point non-determinism (Tangle has only `Int` currently) | TG-6 | `Tangle.lean::Ty` lacks `.float` |
3434
| A-TG-7.1 | MATH | Word problem in the braid group `B_n` is solvable in polynomial time (Birman–Ko–Lee / Garside normal form) | TG-7 | Birman–Ko–Lee 1998; _A New Approach to the Word and Conjugacy Problems in the Braid Groups_ |
3535
| A-TG-7.2 | IMPL | `braidEquiv` (`proofs/Tangle.lean`) and `braid_equiv.ml` implement Dehornoy handle reduction **correctly**, and agree with each other. Since the 2026-07-29 ruling (#50) routed `==` through them, this is **load-bearing for the semantics of `==`**: the Step relation's metatheory is proven only *relative to* `braidEquiv`, never that it decides braid-group equality. Evidenced by testing (2220 + 8 assertions), not proof. Retired by the mechanised Garside/Dehornoy proof (#51, research-grade). The Lean port is additionally **fuel-bounded**, so termination is assumed rather than proven. | TG-7 | `compiler/lib/braid_equiv.ml`; `proofs/Tangle.lean` §BRAID-GROUP EQUIVALENCE; `compiler/test/tg7` |
36+
| A-TG-92.1 | MATH | Comparing braid words of different widths is decided in B_max(n,m) via the standard embedding Bn -> Bn+1 (adjoin a strand no generator touches). Used to justify widening `T-Eq-Word` (#92) and the match-arm width join. The embedding is standard mathematics but is **asserted in prose, not mechanised** — no Lean lemma states it. What IS machine-checked is that the metatheory (Progress/Preservation/Determinism/TypeSafety, infer_sound/complete) holds under the widened rule, and that OCaml `infer_expr` still agrees with Lean `infer` on the corpus (TG-3, 496 obligations). | TG-7 / #92 | `proofs/Tangle.lean` (`tEqWord`, `infer`); `compiler/lib/typecheck.ml` |
3637
| A-TG-8.1 | DESIGN | Each dialect's grammar is a strict superset of core's EBNF (`tangle.ebnf`) | TG-8 | `dialects/*/grammar.ebnf` |
3738
| A-TG-8.2 | DESIGN | Each dialect's typing rules are additive (new constructors + their typing rules only; no modification of existing rules) | TG-8 | Per-dialect spec |
3839
| A-TG-9.1 | DESIGN | `tangle-lsp` emits diagnostics in four documented categories (`PARSE_ERROR`, `MISSPELLING_HINT`, `STRUCTURAL_HINT`, `NAME_HINT`); only `PARSE_ERROR` corresponds to a grammar-level rejection. The other three are LSP-only by design (Option B from TG-9 audit; Option A — full refinement via FFI to `typecheck.ml` — remains queued at #28). Each emission site is tagged in the `Diagnostic.source` field as `tangle-lsp[CATEGORY]`. | TG-9 | `compiler/tangle-lsp/src/backend.rs`; `compiler/tangle-lsp/docs/lsp-diagnostic-categories.md` |

compiler/bin/main.ml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,23 @@ let parse_file_recovering (filename : string) : Tangle.Ast.program * parse_diagn
4949
Lexing.pos_lnum = 1;
5050
};
5151
let diagnostics = ref [] in
52-
let stmts = ref [] in
52+
(* Accumulate SEGMENTS (each a statement list from one parser run), newest
53+
first, and flatten in order at the end.
54+
The previous code did `stmts := prog @ !stmts` and then `List.rev !stmts`.
55+
That reversal is correct for an accumulator built by prepending single
56+
items — as `diagnostics` is — but here whole segments are prepended, so
57+
reversing the FLATTENED list reversed the statements themselves. On the
58+
normal path (one successful parse) every program came out backwards:
59+
`def x; def y; def z` parsed to [z; y; x], so any program whose statements
60+
depend on order failed at evaluation with "Unbound variable".
61+
The test suites never caught it because they call Tangle.Parser.program
62+
directly; only the CLI goes through this recovering path. *)
63+
let segments = ref [] in
5364
let at_eof = ref false in
5465
while not !at_eof do
5566
(try
5667
let prog = Tangle.Parser.program Tangle.Lexer.token lexbuf in
57-
stmts := prog @ !stmts;
68+
segments := prog :: !segments;
5869
at_eof := true
5970
with
6071
| Tangle.Lexer.Lexer_error msg ->
@@ -76,7 +87,8 @@ let parse_file_recovering (filename : string) : Tangle.Ast.program * parse_diagn
7687
} :: !diagnostics;
7788
at_eof := synchronize_tangle_lexer lexbuf)
7889
done;
79-
(List.rev !stmts, List.rev !diagnostics)
90+
(* Flatten oldest-segment-first, preserving order WITHIN each segment. *)
91+
(List.concat (List.rev !segments), List.rev !diagnostics)
8092

8193
(** Parse a TANGLE source file into a program AST. *)
8294
let parse_file (filename : string) : Tangle.Ast.program =

compiler/lib/parser.mly

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ definition:
9090
{ { def_name = name; def_params = ps; def_body = body; def_line = $startpos(name).Lexing.pos_lnum } }
9191
| DEF name = IDENT EQ body = expr
9292
{ { def_name = name; def_params = []; def_body = body; def_line = $startpos(name).Lexing.pos_lnum } }
93+
(* Weave as a DEFINITION BODY specifically, rather than as a general
94+
primary_expr. Admitting it into primary_expr made the grammar ambiguous:
95+
inside a comma context such as `pair(weave ... yield strands a, b)` the
96+
parser cannot tell whether the comma continues the strand list or
97+
separates arguments, and menhir resolved that arbitrarily. Definition
98+
bodies are not comma-delimited, so this position is unambiguous — and it
99+
is the only position the construct is actually used in
100+
(conformance/valid/v02, v08, v09, v12). *)
101+
| DEF name = IDENT EQ w = weave_block
102+
{ { def_name = name; def_params = []; def_body = Weave w; def_line = $startpos(name).Lexing.pos_lnum } }
103+
| DEF name = IDENT LPAREN ps = param_list RPAREN EQ w = weave_block
104+
{ { def_name = name; def_params = ps; def_body = Weave w; def_line = $startpos(name).Lexing.pos_lnum } }
93105
;
94106

95107
param_list:
@@ -321,8 +333,6 @@ primary_expr:
321333
{ e }
322334
| LBRACE e = expr RBRACE
323335
{ e }
324-
| w = weave_block
325-
{ Weave w }
326336
;
327337

328338
(* ---- Crossings: (a > b) or (a < b) ---- *)

compiler/lib/typecheck.ml

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -399,13 +399,38 @@ let rec infer_expr (gamma : env) (sigma : strand_ctx) (e : expr) : ty =
399399
env_bind_val g name ty) gamma bindings in
400400
infer_expr gamma' sigma arm.arm_body
401401
) arms in
402-
let result_ty = List.hd arm_types in
403-
List.iteri (fun i ty ->
404-
if ty <> result_ty then
405-
type_error "Match arm %d has type %s but arm 0 has type %s"
406-
i (pp_ty ty) (pp_ty result_ty)
407-
) arm_types;
408-
result_ty
402+
(* Arms must agree — but for WORDS, "agree" means agree up to width (#92),
403+
consistent with `.` (compose) widening to max and with `==` no longer
404+
requiring equal widths. Arms at Word[0] and Word[2] describe the same
405+
kind of thing at different strand counts; the join is the wider.
406+
Everything else must still match exactly. *)
407+
let join_ty a b =
408+
match a, b with
409+
| TWord n, TWord m -> Some (TWord (max n m))
410+
| x, y when x = y -> Some x
411+
| _ -> None
412+
in
413+
let result_ty =
414+
List.fold_left (fun acc ty ->
415+
match acc with
416+
| None -> None
417+
| Some a -> join_ty a ty
418+
) (Some (List.hd arm_types)) arm_types
419+
in
420+
begin match result_ty with
421+
| Some t -> t
422+
| None ->
423+
(* Report against arm 0, as before, so the message stays familiar. *)
424+
let t0 = List.hd arm_types in
425+
let i, bad =
426+
let rec find i = function
427+
| [] -> (0, t0)
428+
| ty :: rest -> if join_ty t0 ty = None then (i, ty) else find (i+1) rest
429+
in find 0 arm_types
430+
in
431+
type_error "Match arm %d has type %s but arm 0 has type %s"
432+
i (pp_ty bad) (pp_ty t0)
433+
end
409434

410435
(* ---- Echo types (structured loss) ----
411436
* Mirror the HasType rules in proofs/Tangle.lean:
@@ -571,9 +596,13 @@ and infer_binop (op : binop) (t1 : ty) (t2 : ty) : ty =
571596
* PROOF-NEEDS.md. *)
572597
| Eq ->
573598
begin match t1, t2 with
574-
| TWord n, TWord m when n = m -> TBool
575-
| TWord n, TWord m ->
576-
type_error "Cannot compare words of differing width: Word[%d] == Word[%d]" n m
599+
(* Widths need not agree (#92). Braid groups embed Bn -> Bn+1 by adding a
600+
strand nothing touches, so a Word[n] IS a Word[max n m]; the comparison
601+
is decided in the larger group. That is already what
602+
[Braid_equiv.equiv] computes — it takes two generator lists and has no
603+
width parameter — and it is what `~` has always accepted. Mirrors the
604+
widened [tEqWord] in proofs/Tangle.lean. *)
605+
| TWord _, TWord _ -> TBool
577606
| TNum, TNum -> TBool
578607
| TStr, TStr -> TBool
579608
| TBool, TBool -> TBool

compiler/test/test_check.ml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,16 @@ let () =
4343
not (has_error (check_source "def b = true == false\n")));
4444

4545
Printf.printf "\n=== Type errors are reported ===\n";
46-
test "unequal-width word eq is rejected" (fun () ->
47-
let ds = check_source "def bad = braid[s1] == braid[s1, s2]\n" in
48-
has_error ds && mentions "width" ds);
46+
(* #92: unequal-width `==` is now WELL-TYPED, so it is no longer a source of
47+
type errors. Adding a word to a number still is. *)
48+
test "unequal-width word eq is ACCEPTED (#92)" (fun () ->
49+
not (has_error (check_source "def ok = braid[s1] == braid[s1, s2]\n")));
4950
test "add of word and num is rejected" (fun () ->
50-
has_error (check_source "def bad = braid[s1] + 3\n"));
51+
let ds = check_source "def bad = braid[s1] + 3\n" in
52+
(* Also check the message is the specific one, not just that something
53+
failed — `mentions` was left unused when #92 made width mismatches
54+
legal, and an unused assertion helper is a smell. *)
55+
has_error ds && mentions "add" ds);
5156

5257
Printf.printf "\n=== Parse errors are reported ===\n";
5358
test "empty body is a parse error" (fun () ->
@@ -61,11 +66,11 @@ let () =
6166
List.exists (fun d -> d.level = Error && d.line >= 1) ds);
6267
test "type error is located at the def line, not the file top" (fun () ->
6368
(* `def bad` is on line 2; the diagnostic must point there, not line 1. *)
64-
let src = "def a = braid[s1]\ndef bad = braid[s1] == braid[s1, s2]\n" in
69+
let src = "def a = braid[s1]\ndef bad = braid[s1] + 3\n" in
6570
let ds = check_source src in
6671
List.exists (fun d -> d.level = Error && d.line = 2) ds);
6772
test "a single type error yields exactly one diagnostic (no duplicate)" (fun () ->
68-
let ds = check_source "def bad = braid[s1] == braid[s1, s2]\n" in
73+
let ds = check_source "def bad = braid[s1] + 3\n" in
6974
List.length (List.filter (fun d -> d.level = Error) ds) = 1);
7075
test "format_diag is tab-separated with 4 fields" (fun () ->
7176
let line = format_diag { level = Error; line = 3; col = 5; message = "boom" } in

compiler/test/test_typecheck.ml

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,8 +1062,39 @@ let test_echo_types () =
10621062
infer [] (BinOp (Eq, BraidLit [sigma 1], BraidLit [sigma 1])) = TBool);
10631063
test "Eq: Bool == Bool ok (extra-core)" (fun () ->
10641064
infer [] (BinOp (Eq, BoolLit true, BoolLit false)) = TBool);
1065-
test "Eq: unequal-width words rejected" (fun () ->
1066-
raises (fun () -> infer [] (BinOp (Eq, BraidLit [sigma 1], BraidLit [sigma 1; sigma 2]))))
1065+
(* #92: unequal widths are now WELL-TYPED. Bn embeds in Bn+1, the comparison
1066+
is decided in the larger group, and `Braid_equiv.equiv` has no width
1067+
parameter — it is only the typing rule that used to forbid this. *)
1068+
test "#92 Eq: unequal-width words are accepted" (fun () ->
1069+
infer [] (BinOp (Eq, BraidLit [sigma 1], BraidLit [sigma 1; sigma 2])) = TBool);
1070+
1071+
test "#92 Eq: identity == braid is accepted (the 'is it trivial?' shape)" (fun () ->
1072+
infer [] (BinOp (Eq, Identity, BraidLit [sigma 1])) = TBool);
1073+
1074+
test "#92 Eq: == and ~ now agree on what they accept" (fun () ->
1075+
(* They evaluate through the same Braid_equiv.equiv since TG-7; before #92
1076+
the typechecker accepted one and rejected the other. *)
1077+
let a = BraidLit [sigma 1] and b = BraidLit [sigma 2] in
1078+
infer [] (BinOp (Eq, a, b)) = infer [] (BinOp (Isotopy, a, b)));
1079+
1080+
test "#92 Eq: genuinely mismatched types are STILL rejected" (fun () ->
1081+
(* Widening must not become "anything compares to anything". *)
1082+
raises (fun () -> infer [] (BinOp (Eq, IntLit 1, BraidLit [sigma 1]))));
1083+
1084+
test "#92 match arms join on width instead of failing" (fun () ->
1085+
(* turing_complete.tangle's shape: arms at Word[0] and Word[2]. *)
1086+
let arms = [
1087+
{ arm_pattern = PatIdentity; arm_body = Identity };
1088+
{ arm_pattern = PatWildcard; arm_body = BraidLit [sigma 1] };
1089+
] in
1090+
infer [] (Match (BraidLit [sigma 1], arms)) = TWord 2);
1091+
1092+
test "#92 match arms of genuinely different KINDS still fail" (fun () ->
1093+
let arms = [
1094+
{ arm_pattern = PatIdentity; arm_body = IntLit 1 };
1095+
{ arm_pattern = PatWildcard; arm_body = BraidLit [sigma 1] };
1096+
] in
1097+
raises (fun () -> infer [] (Match (BraidLit [sigma 1], arms))))
10671098

10681099
(* ================================================================== *)
10691100
(* Main: run all test groups *)

compiler/test/tg3/tg3_emit.ml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,15 @@ let check () =
357357
pin "let shadowing inner" (Let (x, IntLit 1, Let (x, BraidLit [s 2], BinOp (Compose, Var x, Var x))))
358358
(TWord 3);
359359
pin "nested pair fst.fst" (Fst (Fst (Pair (Pair (IntLit 1, StringLit "a"), BoolLit true)))) TNum;
360+
(* #92: widths need not agree, so this is now WELL-TYPED rather than
361+
rejected. It is the `identity == braid` shape the Lean step relation has
362+
always had rules for (eqIdBraid / eqBraidId). *)
363+
pin "eq diff-width word" (BinOp (Eq, BraidLit [s 1], Identity)) TBool;
360364

361365
(* 3. Reject pins — these must raise Type_error. *)
362366
let rejects name e = ok name (infer_opt e = None) in
363367
rejects "add word+num" (BinOp (Add, BraidLit [], IntLit 1));
364-
rejects "eq diff-width word" (BinOp (Eq, BraidLit [s 1], Identity));
368+
365369
rejects "eq num vs str" (BinOp (Eq, IntLit 1, StringLit "a"));
366370
rejects "lower non-echo" (Lower (IntLit 1));
367371
rejects "fst non-prod" (Fst (IntLit 1));

examples/trefoil.tangle

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,15 @@ assert length(double_trefoil) == 6
6666
# --- Reverse ---
6767

6868
def reversed = reverse(trefoil)
69-
assert reversed == braid[s1, s1, s1]
69+
70+
# `reverse` reverses the word AND negates each exponent, so it yields the
71+
# INVERSE braid: reverse(s1 s1 s1) = s1^-1 s1^-1 s1^-1.
72+
#
73+
# This file previously asserted `reversed == braid[s1, s1, s1]`, which is false
74+
# — writhe(trefoil) = 3 but writhe(reversed) = -3, and writhe is invariant
75+
# under the braid relations, so they cannot be the same element. The assertion
76+
# had been wrong since it was written; nothing ran the examples until #89.
77+
assert reversed == braid[s1^-1, s1^-1, s1^-1]
7078

7179
# --- Close to form a knot ---
7280

0 commit comments

Comments
 (0)