lemmascript 0.5.15 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ See the external case studies:
30
30
  - **[rallly-lemmascript](https://github.com/midspiral/rallly-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [rallly](https://github.com/lukevella/rallly)'s meeting-poll Next.js app. 2 functions: `validateRedirectUrl` (in-place — open-redirect predicate; non-`undefined` outputs start with `/` but not `//`) and `scorePoll` (extracted ranking core — length preservation, score bounds, top-choice characterization, score-formula equality, within-poll monotonicity, tiebreaker injectivity). The injectivity proof surfaced a real spec-level constraint on the existing `(yes + ifNeedBe) * 1000 + yes` encoding: it overflows when an option has ≥ 1000 yes votes. 10 Dafny VCs, 0 errors. Drove four toolchain additions: `s.startsWith()`, `T | null` nullability, `\result` narrowing under `==>`, `Math.max(...arr)` spread. Dafny only.
31
31
  - **[opencode-lemmascript](https://github.com/midspiral/opencode-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield verification of [opencode](https://github.com/anomalyco/opencode)'s permission system and unified-diff patch parser. Highlights: (1) `Patch.parsePatch` carries conservation loop invariants over local ghost state — a parser bug here would silently corrupt user files when an AI applies a patch, and (2) the permission-engine work mechanically closes opencode bug #26514 (subagents bypassing Plan Mode's file-edit restrictions). 9 functions verified in-place, 0 errors. Dafny only.
32
32
  - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of the context-compaction cut-point selector in **pi** (the [earendil-works](https://pi.dev) agent harness). When the context window fills, pi discards history before a chosen cut; a provider API rejects a retained prefix containing an orphaned `toolResult` (a tool result whose tool call was cut away). Both selector functions proven: the cut never lets the kept suffix *start with* — nor *split a tool-use/tool-result run* into — an orphaned tool result, even across the backward metadata snap. The no-orphan result forced the session tree's tool-pairing ordering into an explicit `requires`. 4 VCs, 0 errors. Drove five toolchain additions, headlined by an **opaque fall-through type**: a union LemmaScript can't discriminate (here an array-element union of unreachable imports) becomes a single opaque `type` — the field stays present so distinct values stay distinct, and with no constructor or tag predicate it can only be passed through, never unsoundly observed. **Dafny + Lean**: the no-orphan theorem, the changelog semver core, and both tool-output truncators also carry Lean 4 (Velvet/Loom) proofs from the same annotated source, zero `sorry`; the Lean port drove the backend's brownfield batch (cross-file externs, union destructor lowering, Bool-vs-Prop contexts, `return`-in-loop elimination).
33
+ - **[flue-lemmascript](https://github.com/midspiral/flue-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of pure logic in **Flue** ([withastro/flue](https://github.com/withastro/flue)), Astro's agent harness — crash-recovery and context-compaction functions proven byte-identical. What it drove into the toolchain: native Dafny `continue`, bounds-guarded `noUncheckedIndexedAccess` optional indexing, optional narrowing past an `opt?.disc` guard composing with discriminated-union matching, and object truthiness. Dafny only.
33
34
  - **[balanced-match-lemmascript](https://github.com/midspiral/balanced-match-lemmascript/blob/lemmascript/README_LemmaScript.md)** — brownfield, **in-place** verification of [balanced-match](https://github.com/juliangruber/balanced-match), the ~70-line balanced-bracket finder pulled in by `npm`, `webpack`, and most of the JS tooling stack (1B+ downloads/month). The stack-based `range` core is verified by **refinement**: a pure recursive spec `range_spec` mirrors the loop one branch per recursive case, so the single equivalence `range == range_spec` transfers every property automatically — including an unconditional Dyck body-balance theorem for the interior of every returned pair. 2233 VCs, 0 errors under `--isolate-assertions` (registered on the `dafny-slow` track). Dafny only.
34
35
  - **[guardians-lemmascript](https://github.com/midspiral/guardians-lemmascript)** — greenfield verification of the core safety argument behind [Guardians](https://github.com/metareflection/guardians) (Erik Meijer, "Guardians of the Agents", CACM Jan 2026), a generate-verify-execute checker for AI-agent workflows. Instead of verifying an app, it proves the agent *guardrail itself sound* — that a static taint/automaton check over the real recursive workflow AST can never admit an unsafe plan. Highlights: taint over **nested conditionals** as a sound branch-union over-approximation; per-source **provenance** with a join (a multi-input tool is tainted if *any* input was); **unbounded loops** discharged by a one-step pre-fixpoint (`sat = t0 ‖ bodyTaint(t0)`) that bounds taint over any iteration count without iterating to a fixpoint; and a **unified capstone** (`verifyWfSound`) — one clean verdict rules out, on *every* path, both a tainted-data-to-sink leak and a security-automaton error. 54 Dafny obligations, 0 errors. The verified cores are reached from a Guardians-style `Workflow`/`Policy` through a thin *unverified* adapter, differentially tested against the real Python Guardians (used as the oracle, not a porting target). Dafny only.
35
36
  - **[quorum-lemmascript](https://github.com/midspiral/quorum-lemmascript)** — greenfield verified when2meet/Doodle-style group scheduler (React + Cloudflare Durable Objects), with one `domain.ts` running unchanged in the browser, the in-app query, and the server. The standout is that **the proof licenses the architecture**: `countFree` is a homomorphism from participant-list concatenation to integer addition (so the heatmap is order-independent) plus same-participant last-writer-wins convergence — which is exactly what makes the lock-free, no-login, *optimistic* multi-device backend safe, with the Durable Object and the browser applying the **same** verified `applyOp` (server-authoritatively, client-optimistically) with no rollback or operational transform. Also: heatmap is exactly the per-slot count and `isBest` exactly its argmax; monotonicity; invariant-preserving mutations + op-log `replay`; a sparse export codec round-trip; an in-app `whoIsFree(e, s)` whose length provably equals the cell's count; and a separate `grid.ts` proving the `(day, time) → slot` map in-range + injective — which makes specific-dates-vs-days-of-the-week pure shell labeling at zero proof cost; and full element-level permutation invariance (`heatmapPermInvariant` — the heatmap depends only on the *multiset* of participant rows), which drove the `perm(...)` spec predicate into LemmaScript itself. 100 Dafny VCs (90 + 10), 0 errors. The *aggregate* is proven; the React UI, WebSocket/DO I/O, and timezone labeling are the stated trust boundary. Dafny only.
@@ -85,6 +86,52 @@ lsc gen --backend=lean src/myModule.ts
85
86
  lake build
86
87
  ```
87
88
 
89
+ ## Continuous Integration
90
+
91
+ LemmaScript ships a **reusable GitHub Actions workflow** that regenerates your artifacts, verifies them, and fails the build if any committed generated file is out of date. Call it from your own repo's workflow:
92
+
93
+ ```yaml
94
+ # .github/workflows/lemmascript.yml
95
+ name: LemmaScript
96
+
97
+ on:
98
+ push:
99
+ branches: [main]
100
+ pull_request:
101
+ branches: [main]
102
+
103
+ jobs:
104
+ verify:
105
+ uses: midspiral/LemmaScript/.github/workflows/verify.yml@main
106
+ with:
107
+ backend: dafny # dafny | lean | dafny-slow
108
+ ```
109
+
110
+ The workflow installs the toolchain and (per `backend`) the Dafny or Lean stack, then runs `tools/check.sh`, which batches over a **`LemmaScript-files.txt`** at your repo root. This file is the list of sources CI verifies — you create and maintain it. One entry per line, `filepath [timeout] [extra dafny flags…]`:
111
+
112
+ ```
113
+ src/domain.ts
114
+ src/patch.ts 120
115
+ src/heavy.ts 300 --isolate-assertions
116
+ ```
117
+
118
+ The optional second column is a per-file timeout in seconds; anything after it is passed verbatim to Dafny. The same list drives `lsc check` locally when you run it with no file argument, so CI and your local runs verify exactly the same set. To verify additional Dafny files outside that list, add an executable `check-extra.sh` at the root and it runs automatically (Dafny backends only).
119
+
120
+ Inputs (all optional):
121
+
122
+ | Input | Default | Purpose |
123
+ |-------|---------|---------|
124
+ | `backend` | `dafny` | `dafny`, `lean`, or `dafny-slow` (isolate-assertions / long-running proofs) |
125
+ | `node-version` | `24` | Node.js version |
126
+ | `ls-ref` | `main` | LemmaScript ref to verify against |
127
+ | `typecheck` | `true` | Run `npm ci && npm run typecheck` in the calling repo first |
128
+
129
+ To verify against **both** backends, add a second job with `backend: lean`.
130
+
131
+ Examples:
132
+ - **[talktimer-lemmascript](https://github.com/midspiral/talktimer-lemmascript/blob/main/.github/workflows/lemmascript.yml)** — Dafny-only.
133
+ - **[pi-lemmascript](https://github.com/midspiral/pi-lemmascript/blob/lemmascript/.github/workflows/lemmascript.yml)** — Dafny + Lean (two jobs).
134
+
88
135
  ## Annotations
89
136
 
90
137
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemmascript",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "A verification toolchain for TypeScript — generates Lean 4 or Dafny from annotated TS",
5
5
  "type": "module",
6
6
  "engines": {
@@ -67,7 +67,7 @@ export function dafnyVerify(dfyPath, dir, timeLimit, extraFlags) {
67
67
  return false;
68
68
  }
69
69
  }
70
- export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags) {
70
+ export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags, noVerify = false) {
71
71
  // 1. Read old gen before overwriting (needed for base seeding)
72
72
  const oldGen = existsSync(genPath) ? readFileSync(genPath, "utf-8") : "";
73
73
  // 2. Always write new gen so user can inspect latest output
@@ -76,7 +76,7 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, ext
76
76
  if (!existsSync(dfyPath)) {
77
77
  writeFileSync(dfyPath, text);
78
78
  console.log(`Created: ${path.basename(dfyPath)}`);
79
- if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
79
+ if (!noVerify && !dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
80
80
  console.error(`FAILED: ${path.basename(dfyPath)} verification failed on first run.`);
81
81
  process.exit(1);
82
82
  }
@@ -110,8 +110,8 @@ export function dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, ext
110
110
  console.error(`FAILED: ${path.basename(dfyPath)} has modifications to generated lines.`);
111
111
  process.exit(1);
112
112
  }
113
- // 7. Verify
114
- if (!dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
113
+ // 7. Verify (skipped under --no-verify: caller verifies separately)
114
+ if (!noVerify && !dafnyVerify(dfyPath, dir, timeLimit, extraFlags)) {
115
115
  console.error(`FAILED: ${path.basename(dfyPath)} verification failed.`);
116
116
  process.exit(1);
117
117
  }
@@ -36,6 +36,7 @@ function tyToDafny(ty) {
36
36
  case "string": return "string";
37
37
  case "void": return "()";
38
38
  case "array": return `seq<${tyToDafny(ty.elem)}>`;
39
+ case "tuple": return `(${ty.elems.map(tyToDafny).join(", ")})`;
39
40
  case "map": return `map<${tyToDafny(ty.key)}, ${tyToDafny(ty.value)}>`;
40
41
  case "set": return `set<${tyToDafny(ty.elem)}>`;
41
42
  case "optional": {
@@ -228,6 +229,8 @@ function emitExpr(e) {
228
229
  if (e.elems.length === 0)
229
230
  return `[]`;
230
231
  return `[${e.elems.map(emitExpr).join(", ")}]`;
232
+ case "tupleLiteral": return `(${e.elems.map(emitExpr).join(", ")})`;
233
+ case "tupleProj": return `${emitExpr(e.obj)}.${e.index}`;
231
234
  case "emptyMap": return `map[]`;
232
235
  case "emptySet": return `{}`;
233
236
  case "mapLiteral": {
@@ -255,6 +258,10 @@ function emitExpr(e) {
255
258
  if (e.method === "concat")
256
259
  return `(${obj} + [${args.join(", ")}])`;
257
260
  if (e.method === "sort") {
261
+ if (args.length === 0) {
262
+ needPreamble("SeqSort");
263
+ return `SeqSort(${obj})`;
264
+ }
258
265
  needPreamble("SeqSortBy");
259
266
  return `SeqSortBy(${obj}, ${args[0]})`;
260
267
  }
@@ -295,6 +302,10 @@ function emitExpr(e) {
295
302
  needPreamble("SeqFindIndex");
296
303
  return `SeqFindIndex(${obj}, ${args[0]})`;
297
304
  }
305
+ if (e.method === "findLastIndex") {
306
+ needPreamble("SeqFindLastIndex");
307
+ return `SeqFindLastIndex(${obj}, ${args[0]})`;
308
+ }
298
309
  if (e.method === "flat" && args.length === 0) {
299
310
  needPreamble("SeqFlatten");
300
311
  return `SeqFlatten(${obj})`;
@@ -303,16 +314,26 @@ function emitExpr(e) {
303
314
  needPreamble("SeqJoin");
304
315
  return `SeqJoin(${obj}, ${args[0]})`;
305
316
  }
306
- if (e.method === "some" && e.args[0].kind === "lambda" &&
307
- e.args[0].body.length === 1 && e.args[0].body[0].kind === "return") {
317
+ // `.some(pred)`: inline a single-return lambda's body, else apply the
318
+ // predicate (e.g. a function reference).
319
+ if (e.method === "some") {
308
320
  const lam = e.args[0];
309
- const ret = lam.body[0];
310
- if (ret.kind !== "return")
311
- throw new Error("unreachable");
312
- const { binder: p, body: v } = comprehensionBinder(lam, ret.value, e.obj);
313
- const body = emitExpr(v);
321
+ let p, body;
322
+ if (lam.kind === "lambda" && lam.body.length === 1 && lam.body[0].kind === "return") {
323
+ const cb = comprehensionBinder(lam, lam.body[0].value, e.obj);
324
+ p = cb.binder;
325
+ body = emitExpr(cb.body);
326
+ }
327
+ else {
328
+ p = escapeName(freshBinder("x", e.obj, e.args[0]));
329
+ body = `${args[0]}(${p})`;
330
+ }
314
331
  return `(exists ${p} :: ${p} in ${obj} && ${body})`;
315
332
  }
333
+ // `.reduce(f, init)` → Std's FoldLeft(f, init, xs) (same arg order).
334
+ if (e.method === "reduce" && args.length === 2) {
335
+ return `Std.Collections.Seq.FoldLeft(${args[0]}, ${args[1]}, ${obj})`;
336
+ }
316
337
  }
317
338
  // String methods
318
339
  if (ty === "string") {
@@ -694,7 +715,7 @@ function emitStmt(s, indent) {
694
715
  case "break":
695
716
  return `${pad}break;`;
696
717
  case "continue":
697
- throw new Error("Unsupported Dafny construct: 'continue' statement");
718
+ return `${pad}continue;`;
698
719
  case "if": {
699
720
  let out = `${pad}if ${emitExpr(s.cond)} {\n${emitStmts(s.then, indent + 1)}\n${pad}}`;
700
721
  if (s.else.length > 0) {
@@ -1003,6 +1024,18 @@ function SeqIndexOfFrom<T(==)>(s: seq<T>, x: T, from: nat): int
1003
1024
  else if s[from] == x then from as int
1004
1025
  else SeqIndexOfFrom(s, x, from + 1)
1005
1026
  }`;
1027
+ const SEQ_FIND_LAST_INDEX = `function SeqFindLastIndex<T>(s: seq<T>, p: T -> bool): int
1028
+ ensures -1 <= SeqFindLastIndex(s, p) < |s|
1029
+ ensures SeqFindLastIndex(s, p) >= 0 ==> p(s[SeqFindLastIndex(s, p)])
1030
+ ensures SeqFindLastIndex(s, p) >= 0 ==>
1031
+ (forall j: int :: SeqFindLastIndex(s, p) < j < |s| ==> !p(s[j]))
1032
+ ensures SeqFindLastIndex(s, p) == -1 ==> (forall i: nat :: i < |s| ==> !p(s[i]))
1033
+ decreases |s|
1034
+ {
1035
+ if |s| == 0 then -1
1036
+ else if p(s[|s|-1]) then |s| - 1
1037
+ else SeqFindLastIndex(s[..|s|-1], p)
1038
+ }`;
1006
1039
  const SEQ_FIND_LAST = `function SeqFindLast<T>(s: seq<T>, p: T -> bool): Option<T>
1007
1040
  ensures SeqFindLast(s, p).Some? ==> p(SeqFindLast(s, p).value)
1008
1041
  ensures SeqFindLast(s, p).Some? ==> SeqFindLast(s, p).value in s
@@ -1029,12 +1062,21 @@ const SEQ_JOIN = `function SeqJoin(s: seq<string>, sep: string): string
1029
1062
  else if |s| == 1 then s[0]
1030
1063
  else s[0] + sep + SeqJoin(s[1..], sep)
1031
1064
  }`;
1032
- const SAFE_SLICE = `function SafeSlice<T>(s: seq<T>, lo: int, hi: int): seq<T>
1065
+ const SAFE_SLICE = `function NormalizeSliceIndex(n: nat, i: int): int
1066
+ ensures 0 <= NormalizeSliceIndex(n, i) <= n as int
1067
+ {
1068
+ if i < 0 then
1069
+ if n as int + i < 0 then 0 else n as int + i
1070
+ else if i > n as int then n as int
1071
+ else i
1072
+ }
1073
+
1074
+ function SafeSlice<T>(s: seq<T>, lo: int, hi: int): seq<T>
1033
1075
  ensures |SafeSlice(s, lo, hi)| <= |s|
1034
1076
  {
1035
- var lo' := if lo < 0 then 0 else if lo > |s| as int then |s| else lo;
1036
- var hi' := if hi > |s| as int then |s| else if hi < lo' then lo' else hi;
1037
- s[lo'..hi']
1077
+ var lo' := NormalizeSliceIndex(|s|, lo);
1078
+ var hi' := NormalizeSliceIndex(|s|, hi);
1079
+ if hi' < lo' then [] else s[lo'..hi']
1038
1080
  }`;
1039
1081
  const STRING_INDEX_OF = `function StringIndexOf(s: string, sub: string): int
1040
1082
  ensures StringIndexOf(s, sub) == -1
@@ -1077,6 +1119,10 @@ const STRING_SPLIT = `function {:axiom} StringSplit(s: string, d: string): seq<s
1077
1119
  // is the soundness condition — cmp must be a total preorder, otherwise no sorted
1078
1120
  // permutation exists and the axiom would be vacuous. Callers discharge it (e.g.
1079
1121
  // `(a,b) => a.k - b.k` is total + transitive by linear arithmetic).
1122
+ // Bare `.sort()`: permutation only, no sortedness (JS default order is type-dependent).
1123
+ const SEQ_SORT = `function {:axiom} SeqSort<T(==,!new)>(s: seq<T>): seq<T>
1124
+ ensures multiset(SeqSort(s)) == multiset(s)
1125
+ ensures |SeqSort(s)| == |s|`;
1080
1126
  const SEQ_SORT_BY = `function {:axiom} SeqSortBy<T(==,!new)>(s: seq<T>, cmp: (T, T) -> int): seq<T>
1081
1127
  requires forall a: T, b: T :: cmp(a, b) <= 0 || cmp(b, a) <= 0
1082
1128
  requires forall a: T, b: T, c: T :: cmp(a, b) <= 0 && cmp(b, c) <= 0 ==> cmp(a, c) <= 0
@@ -1246,6 +1292,7 @@ const PREAMBLE_CODE = [
1246
1292
  ["FloorReal", FLOOR_REAL],
1247
1293
  ["SeqIndexOf", SEQ_INDEX_OF],
1248
1294
  ["SeqFindIndex", SEQ_FIND_INDEX],
1295
+ ["SeqFindLastIndex", SEQ_FIND_LAST_INDEX],
1249
1296
  ["SeqFilterSome", SEQ_FILTER_SOME],
1250
1297
  ["SeqFindLast", SEQ_FIND_LAST],
1251
1298
  ["SeqFlatten", SEQ_FLATTEN],
@@ -1253,6 +1300,7 @@ const PREAMBLE_CODE = [
1253
1300
  ["SafeSlice", SAFE_SLICE],
1254
1301
  ["StringIndexOf", STRING_INDEX_OF],
1255
1302
  ["StringSplit", STRING_SPLIT],
1303
+ ["SeqSort", SEQ_SORT],
1256
1304
  ["SeqSortBy", SEQ_SORT_BY],
1257
1305
  ["StringTrim", STRING_TRIM],
1258
1306
  ["StringToLower", STRING_TO_LOWER],
@@ -1303,6 +1351,8 @@ function resolveTy(ty) {
1303
1351
  return { kind: "optional", inner: resolveTy(ty.inner) };
1304
1352
  if (ty.kind === "array")
1305
1353
  return { kind: "array", elem: resolveTy(ty.elem) };
1354
+ if (ty.kind === "tuple")
1355
+ return { kind: "tuple", elems: ty.elems.map(resolveTy) };
1306
1356
  if (ty.kind === "map")
1307
1357
  return { kind: "map", key: resolveTy(ty.key), value: resolveTy(ty.value) };
1308
1358
  if (ty.kind === "set")
@@ -1887,7 +1887,8 @@ export function extractModule(sourceFile) {
1887
1887
  const name = recordMatch[1];
1888
1888
  // `<T extends B>` → bare `T`: a Dafny type param, like the def path.
1889
1889
  const typeParams = recordMatch[2]?.split(",").map(s => s.trim().split(/\s+extends\s+/)[0].trim()).filter(Boolean);
1890
- const fields = recordMatch[3].split(",").map(f => f.trim()).filter(Boolean).map(f => {
1890
+ // Split on `,` OR `;` TS object types allow both.
1891
+ const fields = recordMatch[3].split(/[,;]/).map(f => f.trim()).filter(Boolean).map(f => {
1891
1892
  const [fname, ftype] = f.split(":").map(s => s.trim());
1892
1893
  const synth = _synthFromTsTypeString(ftype);
1893
1894
  return { name: fname, tsType: synth ?? ftype };
package/tools/dist/ir.js CHANGED
@@ -37,6 +37,8 @@ export function anyExpr(e, pred) {
37
37
  case "app": return e.args.some(a => anyExpr(a, pred));
38
38
  case "field": return anyExpr(e.obj, pred);
39
39
  case "index": return anyExpr(e.arr, pred) || anyExpr(e.idx, pred);
40
+ case "tupleLiteral": return e.elems.some(x => anyExpr(x, pred));
41
+ case "tupleProj": return anyExpr(e.obj, pred);
40
42
  case "implies": return e.premises.some(p => anyExpr(p, pred)) || anyExpr(e.conclusion, pred);
41
43
  case "record": return (e.spread ? anyExpr(e.spread, pred) : false) || e.fields.some(f => anyExpr(f.value, pred));
42
44
  case "arrayLiteral": return e.elems.some(x => anyExpr(x, pred));
@@ -34,6 +34,13 @@ function tyToLean(ty) {
34
34
  const elem = tyToLean(ty.elem);
35
35
  return elem.includes(" ") ? `Array (${elem})` : `Array ${elem}`;
36
36
  }
37
+ case "tuple":
38
+ // Right-nested Prod: `A × B × C` = `A × (B × C)`. Parenthesize any element
39
+ // whose rendering has a space so it binds tighter than `×` (e.g. `A → B`).
40
+ return ty.elems.map(el => {
41
+ const s = tyToLean(el);
42
+ return s.includes(" ") ? `(${s})` : s;
43
+ }).join(" × ");
37
44
  case "map": {
38
45
  const k = tyToLean(ty.key);
39
46
  const v = tyToLean(ty.value);
@@ -112,6 +119,9 @@ function collectUserRefs(ty, into) {
112
119
  case "set":
113
120
  collectUserRefs(ty.elem, into);
114
121
  break;
122
+ case "tuple":
123
+ ty.elems.forEach(el => collectUserRefs(el, into));
124
+ break;
115
125
  case "optional":
116
126
  collectUserRefs(ty.inner, into);
117
127
  break;
@@ -226,6 +236,8 @@ function emitMethodCall(tyKind, method, monadic, obj, args) {
226
236
  return `${obj}.${monadic ? "allM" : "all"} ${args[0]}`;
227
237
  if (method === "some")
228
238
  return `${obj}.${monadic ? "anyM" : "any"} ${args[0]}`;
239
+ if (method === "reduce" && args.length === 2)
240
+ return `(${obj}.foldl ${args[0]} ${args[1]})`;
229
241
  if (method === "includes")
230
242
  return args.length > 1 ? `(${obj}.extract ${args[1]} ${obj}.size).contains ${args[0]}` : `${obj}.contains ${args[0]}`;
231
243
  if (method === "find")
@@ -318,6 +330,15 @@ function emitExpr(e, parentPrec) {
318
330
  if (e.elems.length === 0)
319
331
  return `#[]`;
320
332
  return `#[${e.elems.map(el => emitExpr(el)).join(", ")}]`;
333
+ case "tupleLiteral":
334
+ return `(${e.elems.map(el => emitExpr(el)).join(", ")})`;
335
+ case "tupleProj": {
336
+ // Right-nested Prod projection: element i is `.2`×i then `.1`, except the
337
+ // last (i = arity-1) which is `.2`×i with no trailing `.1`.
338
+ const obj = emitExpr(e.obj);
339
+ const twos = ".2".repeat(e.index);
340
+ return e.index === e.arity - 1 ? `${obj}${twos}` : `${obj}${twos}.1`;
341
+ }
321
342
  case "emptyMap": return `Std.HashMap.empty`;
322
343
  case "emptySet": return `Std.HashSet.empty`;
323
344
  case "default": return `(default : ${tyToLean(e.type)})`;
@@ -457,8 +478,13 @@ function emitExpr(e, parentPrec) {
457
478
  // A real value reached the Lean backend via coercion (e.g. number `/`).
458
479
  // Same unsupported-real story as the `real` type case in tyToLean.
459
480
  throw new Error("real arithmetic is not supported by the Lean backend (needs noncomputable ℝ / Mathlib).");
460
- case "index":
461
- return `${emitExpr(e.arr)}[${emitExpr(e.idx)}]!`;
481
+ case "index": {
482
+ const arr = emitExpr(e.arr);
483
+ // Parenthesize a low-precedence array expr (e.g. a function application)
484
+ // so the index binds to the whole thing, not its last token.
485
+ const wrap = e.arr.kind === "app" || e.arr.kind === "binop" || e.arr.kind === "methodCall" || e.arr.kind === "if" || e.arr.kind === "let" || e.arr.kind === "unop";
486
+ return `${wrap ? `(${arr})` : arr}[${emitExpr(e.idx)}]!`;
487
+ }
462
488
  case "record": {
463
489
  const fields = e.fields.map(f => `${escapeName(f.name)} := ${emitExpr(f.value)}`);
464
490
  if (e.spread)
@@ -633,6 +659,59 @@ function emitStmt(s, indent) {
633
659
  }
634
660
  }
635
661
  // ── Declaration emission ─────────────────────────────────────
662
+ /** Collect every function/variable name an IR tree references (stripping any
663
+ * `Pure.` qualifier), via a generic walk over `app`/`var` nodes. */
664
+ function collectRefNames(node, into) {
665
+ if (node === null || typeof node !== "object")
666
+ return;
667
+ if (Array.isArray(node)) {
668
+ for (const x of node)
669
+ collectRefNames(x, into);
670
+ return;
671
+ }
672
+ const n = node;
673
+ if (n.kind === "app" && typeof n.fn === "string")
674
+ into.add(n.fn.replace(/^Pure\./, ""));
675
+ if (n.kind === "var" && typeof n.name === "string")
676
+ into.add(n.name.replace(/^Pure\./, ""));
677
+ for (const k of Object.keys(node))
678
+ collectRefNames(node[k], into);
679
+ }
680
+ /** Lean requires definition-before-use: order sibling decls so one that
681
+ * references another is emitted after it. Cycles are left in place (they would
682
+ * need a `mutual` block). Bails to the original order if any decl is unnamed. */
683
+ function orderDeclsByDeps(decls) {
684
+ const named = decls.filter((d) => typeof d.name === "string");
685
+ if (named.length !== decls.length)
686
+ return decls;
687
+ const names = new Set(named.map(d => d.name));
688
+ const byName = new Map(named.map(d => [d.name, d]));
689
+ const deps = new Map();
690
+ for (const d of named) {
691
+ const refs = new Set();
692
+ collectRefNames(d, refs);
693
+ refs.delete(d.name);
694
+ deps.set(d.name, [...refs].filter(r => names.has(r)));
695
+ }
696
+ const sorted = [];
697
+ const done = new Set();
698
+ const onStack = new Set();
699
+ const visit = (name) => {
700
+ if (done.has(name) || onStack.has(name))
701
+ return;
702
+ onStack.add(name);
703
+ for (const dep of deps.get(name) ?? [])
704
+ visit(dep);
705
+ onStack.delete(name);
706
+ done.add(name);
707
+ const def = byName.get(name);
708
+ if (def)
709
+ sorted.push(def);
710
+ };
711
+ for (const d of named)
712
+ visit(d.name);
713
+ return sorted;
714
+ }
636
715
  function emitDecl(d) {
637
716
  switch (d.kind) {
638
717
  case "inductive": {
@@ -711,7 +790,7 @@ function emitDecl(d) {
711
790
  }
712
791
  case "namespace": {
713
792
  const lines = [`namespace ${d.name}`];
714
- for (const inner of d.decls)
793
+ for (const inner of orderDeclsByDeps(d.decls))
715
794
  lines.push("", emitDecl(inner));
716
795
  lines.push("", `end ${d.name}`);
717
796
  return lines.join("\n");
@@ -733,8 +812,10 @@ function emitDecl(d) {
733
812
  return sig;
734
813
  // Spec axiom: ∀ params, req1 → … → (ens1 ∧ … ∧ ensN). Tagged `@[grind]` so
735
814
  // the proof automation can use it, matching the ghost-function convention.
736
- const hyps = d.requires.map(emitExpr);
737
- const concl = d.ensures.map(emitExpr).join("");
815
+ // Parenthesize each clause: an unwrapped `∀ k, P` would otherwise swallow
816
+ // the following `…` conjuncts into its body.
817
+ const hyps = d.requires.map(e => `(${emitExpr(e)})`);
818
+ const concl = d.ensures.map(e => `(${emitExpr(e)})`).join(" ∧ ");
738
819
  const axBody = [...hyps, concl].join(" → ");
739
820
  const axiom = `@[grind] axiom ${escapeName(d.name)}_spec${params ? ` ${params}` : ""} : ${axBody}`;
740
821
  return `${sig}\n${axiom}`;
package/tools/dist/lsc.js CHANGED
@@ -98,6 +98,16 @@ function main() {
98
98
  slow = true;
99
99
  args.splice(slowIdx, 1);
100
100
  }
101
+ // --no-verify (regen only): do regen + three-way merge + additions-only check
102
+ // but skip `dafny verify`. CI's `tools` job passes this to regen-dafny.sh so
103
+ // regen only enforces the drift + additions-only invariants; the separate
104
+ // `lsc check` pass over the same files does the one and only verification.
105
+ let noVerify = false;
106
+ const noVerifyIdx = args.indexOf("--no-verify");
107
+ if (noVerifyIdx >= 0) {
108
+ noVerify = true;
109
+ args.splice(noVerifyIdx, 1);
110
+ }
101
111
  // Anything flag-shaped left over is a typo or a space-separated form
102
112
  // (`--backend lean`): reject it rather than let it become a positional arg
103
113
  // or be silently ignored (which would e.g. verify with the wrong backend).
@@ -117,7 +127,7 @@ function main() {
117
127
  runBatch(cmd, backend, slow);
118
128
  return;
119
129
  }
120
- runFile(cmd, filePath, backend, timeLimit, extraFlags);
130
+ runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify);
121
131
  }
122
132
  // LemmaScript-files.txt, parsed: `filepath [timeout_in_seconds] [extra dafny
123
133
  // flags…]` per line; no timeout = Dafny default. Exits if the file is absent.
@@ -154,7 +164,7 @@ function runBatch(cmd, backend, slow) {
154
164
  }
155
165
  }
156
166
  }
157
- function runFile(cmd, filePath, backend, timeLimit, extraFlags) {
167
+ function runFile(cmd, filePath, backend, timeLimit, extraFlags, noVerify = false) {
158
168
  const absPath = path.resolve(filePath);
159
169
  if (!existsSync(absPath)) {
160
170
  console.error(`File not found: ${absPath}`);
@@ -247,7 +257,7 @@ function runFile(cmd, filePath, backend, timeLimit, extraFlags) {
247
257
  return;
248
258
  }
249
259
  if (cmd === "regen") {
250
- dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags);
260
+ dafnyRegen(genPath, dfyPath, basePath, text, dir, timeLimit, extraFlags, noVerify);
251
261
  return;
252
262
  }
253
263
  console.error(`Unknown command: ${cmd}`);
@@ -145,7 +145,7 @@ function walkStmt(s) {
145
145
  // array rule fires; independent narrows commute, so the order is harmless.)
146
146
  // && rules fire before the simple rule because they produce nested ifs whose
147
147
  // inner shape doesn't match the simple rule directly.
148
- return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? ruleExprStmtAndOptional(r) ?? r;
148
+ return ruleIfAndOptional(r) ?? ruleIfAndArrayIsArray(r) ?? ruleIfOptionalSimple(r) ?? ruleExprStmtAndOptional(r) ?? ruleOptionalIndexBinding(r) ?? r;
149
149
  }
150
150
  function walkStmts(stmts) {
151
151
  const result = [];
@@ -159,7 +159,7 @@ function walkStmts(stmts) {
159
159
  i += tagged.consumed - 1;
160
160
  continue;
161
161
  }
162
- const consumed = ruleEarlyReturnOrChain(s, rest) ?? ruleEarlyReturnConsume(s, rest);
162
+ const consumed = ruleEarlyReturnOrChain(s, rest) ?? ruleEarlyReturnConsume(s, rest) ?? ruleEarlyReturnOptChainCompare(s, rest);
163
163
  if (consumed) {
164
164
  result.push(walkStmt(consumed));
165
165
  return result;
@@ -260,54 +260,138 @@ function ruleEarlyReturnConsume(s, rest) {
260
260
  noneBody: noneBranch,
261
261
  };
262
262
  }
263
- /** Collect a `||` chain of negative optional checks (`x === undefined`).
264
- * Returns the list of checks if every leaf is a negative optional check; null otherwise. */
265
- function collectOrChainOfNegativeChecks(cond) {
266
- if (cond.kind === "binop" && cond.op === "||") {
267
- const left = collectOrChainOfNegativeChecks(cond.left);
268
- const right = collectOrChainOfNegativeChecks(cond.right);
269
- if (!left || !right)
270
- return null;
271
- return [...left, ...right];
263
+ /** Flatten a nested `||` chain into its leaf conditions. */
264
+ function flattenOr(e) {
265
+ if (e.kind === "binop" && e.op === "||")
266
+ return [...flattenOr(e.left), ...flattenOr(e.right)];
267
+ return [e];
268
+ }
269
+ function classifyDisjunct(leaf) {
270
+ // `x?.chain !== lit` — `undefined !== lit` is true when x is None.
271
+ if (leaf.kind === "binop" && leaf.op === "!==") {
272
+ const oc = leaf.left.kind === "optChain" ? leaf.left : leaf.right.kind === "optChain" ? leaf.right : null;
273
+ if (oc && oc.kind === "optChain" && oc.obj.ty.kind === "optional") {
274
+ const hint = binderHintFor(oc.obj);
275
+ if (hint === null)
276
+ return null;
277
+ const binder = freshName(hint);
278
+ const unwrapped = applyChain({ kind: "var", name: binder, ty: oc.obj.ty.inner }, oc.chain);
279
+ if (unwrapped.kind === "field" && unwrapped.obj.ty.kind === "user") {
280
+ const base = unwrapped.obj.ty.name.replace(/<.*/, "");
281
+ const decl = _typeDecls.find(d => d.name === base);
282
+ if (decl?.kind === "discriminated-union" && decl.discriminant === unwrapped.field)
283
+ unwrapped.isDiscriminant = true;
284
+ }
285
+ const lit = leaf.left === oc ? leaf.right : leaf.left;
286
+ return { scrutinee: oc.obj, innerTy: oc.obj.ty.inner, binder, residual: { kind: "binop", op: "!==", left: unwrapped, right: lit, ty: { kind: "bool" } } };
287
+ }
272
288
  }
273
- const check = parseSimpleOptionalCheck(cond);
274
- if (!check || !check.negated)
275
- return null;
276
- return [check];
289
+ // `!x` / `x === undefined`.
290
+ const chk = parseOptionalCheck(leaf);
291
+ if (chk && chk.negated) {
292
+ const residual = canBeFalsy(chk)
293
+ ? { kind: "unop", op: "!", expr: { kind: "var", name: chk.binderHint, ty: chk.innerTy }, ty: { kind: "bool" } }
294
+ : null;
295
+ return { scrutinee: chk.scrutinee, innerTy: chk.innerTy, binder: chk.binderHint, residual };
296
+ }
297
+ return null;
277
298
  }
278
- /** Rule: `if (x === undefined || y === undefined || ...) terminate; rest`.
279
- * nested someMatches narrowing each var in turn, each None branch = terminate,
280
- * deepest someBody = rest.
299
+ /** Rule: `if (D1 || || Dn) terminate; rest`. Each `Di` that detects some optional
300
+ * `x` is None (`!x`, `x === undefined`, `x?.chain !== lit`) narrows that `x` to Some
301
+ * across `rest`; the rest — value guards reading a narrowed `x` directly, plus the
302
+ * detectors' Some-case residuals — become a trailing early-return. Sound: reaching
303
+ * `rest` means every disjunct was false, so every detected optional is present.
304
+ * Covers `if (!x || x.f !== v) continue` / `if (x?.t !== 'm' || x.g) break`.
281
305
  * Closes the resolve.ts:602 TODO ("|| narrowing"). */
282
306
  function ruleEarlyReturnOrChain(s, rest) {
283
307
  if (s.kind !== "if")
284
308
  return null;
285
309
  if (rest.length === 0)
286
310
  return null;
287
- if (s.then.length === 0 || s.else.length !== 0)
311
+ if (s.then.length === 0 || s.else.length !== 0 || !isTerminating(s.then))
288
312
  return null;
289
313
  if (s.cond.kind !== "binop" || s.cond.op !== "||")
290
314
  return null; // single check is the simpler rule
291
- const checks = collectOrChainOfNegativeChecks(s.cond);
292
- if (!checks || checks.length < 2)
293
- return null;
294
- // Build nested someMatch from innermost outward
295
- let inner = rest;
296
- for (let i = checks.length - 1; i >= 0; i--) {
297
- const check = checks[i];
298
- const someBody = canBeFalsy(check)
299
- ? [{ kind: "if", cond: bound(check), then: inner, else: s.then }]
300
- : inner;
301
- inner = [{
302
- kind: "someMatch",
303
- scrutinee: check.scrutinee, binderTy: check.innerTy,
304
- binder: check.binderHint,
305
- someBody,
306
- noneBody: s.then,
307
- }];
315
+ const leaves = flattenOr(s.cond);
316
+ if (leaves.length < 2)
317
+ return null;
318
+ const detectors = [];
319
+ const residualLeaves = [];
320
+ const seen = new Set();
321
+ for (const leaf of leaves) {
322
+ const d = classifyDisjunct(leaf);
323
+ if (!d) {
324
+ residualLeaves.push(leaf);
325
+ continue;
326
+ }
327
+ const key = binderHintFor(d.scrutinee);
328
+ if (seen.has(key))
329
+ return null; // two detectors on one optional: rare; leave to other rules
330
+ seen.add(key);
331
+ detectors.push(d);
332
+ if (d.residual)
333
+ residualLeaves.push(d.residual);
334
+ }
335
+ if (detectors.length === 0)
336
+ return null;
337
+ let inner = residualLeaves.length === 0
338
+ ? rest
339
+ : [{ kind: "if", cond: residualLeaves.reduce((a, b) => ({ kind: "binop", op: "||", left: a, right: b, ty: { kind: "bool" } })), then: s.then, else: [] }, ...rest];
340
+ for (let i = detectors.length - 1; i >= 0; i--) {
341
+ const d = detectors[i];
342
+ inner = [{ kind: "someMatch", scrutinee: d.scrutinee, binderTy: d.innerTy, binder: d.binder, someBody: inner, noneBody: s.then }];
308
343
  }
309
344
  return inner[0];
310
345
  }
346
+ /** Rule: `if (opt?.chain !== lit) terminate; rest` where `opt` is optional.
347
+ * `opt?.chain` is `undefined` when `opt` is None, and `undefined !== lit` is
348
+ * true, so the None case takes the terminating branch — falling through to
349
+ * `rest` proves `opt` is Some. Rewrite to
350
+ * someMatch opt { Some(v) => [if (v.chain !== lit) terminate; rest]; None => terminate }
351
+ * narrowing `opt` to `v` across `rest` (transform substitutes the scrutinee) and
352
+ * handing the now-non-optional inner guard to the ordinary rules (e.g.
353
+ * discriminant narrowing). Bound-optional companion to ruleEarlyReturnConsume,
354
+ * which handles only a bare presence check (`opt !== undefined`). Restricted to
355
+ * `!==` so the None case is guaranteed to terminate. */
356
+ function ruleEarlyReturnOptChainCompare(s, rest) {
357
+ if (s.kind !== "if")
358
+ return null;
359
+ if (rest.length === 0)
360
+ return null;
361
+ if (s.else.length !== 0 || !isTerminating(s.then))
362
+ return null;
363
+ const c = s.cond;
364
+ if (c.kind !== "binop" || c.op !== "!==")
365
+ return null;
366
+ const oc = c.left.kind === "optChain" ? c.left : c.right.kind === "optChain" ? c.right : null;
367
+ if (!oc || oc.kind !== "optChain" || oc.obj.ty.kind !== "optional")
368
+ return null;
369
+ const lit = c.left === oc ? c.right : c.left;
370
+ const innerTy = oc.obj.ty.inner;
371
+ const hint = binderHintFor(oc.obj);
372
+ if (hint === null)
373
+ return null;
374
+ const binder = freshName(hint);
375
+ const binderVar = { kind: "var", name: binder, ty: innerTy };
376
+ const unwrapped = applyChain(binderVar, oc.chain);
377
+ // applyChain rebuilds the field without the `isDiscriminant` flag resolve sets
378
+ // on a direct `x.disc`; restore it when the unwrapped access is the binder
379
+ // union's discriminant, so the inner guard feeds discriminant narrowing.
380
+ if (unwrapped.kind === "field" && unwrapped.obj.ty.kind === "user") {
381
+ const base = unwrapped.obj.ty.name.replace(/<.*/, "");
382
+ const decl = _typeDecls.find(d => d.name === base);
383
+ if (decl?.kind === "discriminated-union" && decl.discriminant === unwrapped.field) {
384
+ unwrapped.isDiscriminant = true;
385
+ }
386
+ }
387
+ const innerGuard = { kind: "binop", op: "!==", left: unwrapped, right: lit, ty: { kind: "bool" } };
388
+ // Keep `rest` as trailing statements (not an else branch) — `s.then` terminates,
389
+ // so `if (g) terminate; rest` ≡ `if (g) terminate else rest`, and the trailing
390
+ // form lets the ordinary early-exit rules (e.g. discriminant narrowing) fire on
391
+ // the now-non-optional inner guard when `chain` is a union discriminant.
392
+ const someBody = [{ kind: "if", cond: innerGuard, then: s.then, else: [] }, ...rest];
393
+ return { kind: "someMatch", scrutinee: oc.obj, binder, binderTy: innerTy, someBody, noneBody: s.then };
394
+ }
311
395
  /** Rule (expression): `e !== undefined ? a : b`. */
312
396
  function ruleConditionalOptionalSimple(e) {
313
397
  if (e.kind !== "conditional")
@@ -491,6 +575,32 @@ function ruleOptChainIndex(e) {
491
575
  const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
492
576
  return { kind: "conditional", cond, then: body, else: undef, ty: e.ty };
493
577
  }
578
+ /** Rule (statement): reconcile a `const e = arr[i]` whose binding is optional
579
+ * (`e: T | undefined`) but whose array-index initializer is total (`T`). Model
580
+ * the index as its JS semantics — `e := (0 <= i && i < arr.length) ? arr[i] :
581
+ * undefined` — so `e` is a real `Option<T>` and a later `e?.f` someMatch is
582
+ * well-typed; an in-bounds proof makes the None branch dead, so safely-indexed
583
+ * code verifies as if total. Bound-form sibling of ruleOptChainIndex /
584
+ * ruleNullishIndex. Fires purely on the optional-binding/total-index shape (the
585
+ * usual source is `noUncheckedIndexedAccess`, but the flag itself is never
586
+ * checked). Skipped when the element type is already optional: no mismatch. */
587
+ function ruleOptionalIndexBinding(s) {
588
+ if (s.kind !== "let")
589
+ return null;
590
+ if (s.ty.kind !== "optional")
591
+ return null;
592
+ const init = s.init;
593
+ if (init.kind !== "index")
594
+ return null;
595
+ if (init.obj.ty.kind !== "array")
596
+ return null;
597
+ if (init.ty.kind === "optional")
598
+ return null; // array-of-optionals: not a flag artifact
599
+ const cond = arrayBoundsCond(init.obj, init.idx);
600
+ const undef = { kind: "var", name: "undefined", ty: { kind: "void" } };
601
+ const guarded = { kind: "conditional", cond, then: init, else: undef, ty: s.ty };
602
+ return { ...s, init: guarded };
603
+ }
494
604
  /** Rule (expression): `obj?.<chain>` — single-eval optional chain.
495
605
  * → `someMatch obj { Some(_oc{N}_val) => apply(chain, _oc{N}_val), None => undefined }`.
496
606
  * The someBody applies the chain to the binder directly (field/call/index),
@@ -24,6 +24,8 @@ function mapExpr(e, f) {
24
24
  case "toNat": return { ...e, expr: r(e.expr) };
25
25
  case "toReal": return { ...e, expr: r(e.expr) };
26
26
  case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
27
+ case "tupleLiteral": return { ...e, elems: e.elems.map(r) };
28
+ case "tupleProj": return { ...e, obj: r(e.obj) };
27
29
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null,
28
30
  fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
29
31
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
@@ -368,6 +370,8 @@ function rewriteChildrenExpr(e) {
368
370
  case "toNat": return { ...e, expr: r(e.expr) };
369
371
  case "toReal": return { ...e, expr: r(e.expr) };
370
372
  case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
373
+ case "tupleLiteral": return { ...e, elems: e.elems.map(r) };
374
+ case "tupleProj": return { ...e, obj: r(e.obj) };
371
375
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null,
372
376
  fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
373
377
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
@@ -4,7 +4,7 @@
4
4
  * Uses linked environments (Scheme-style) for lexical scoping.
5
5
  * No mutation — each let extends the chain, lookup walks it.
6
6
  */
7
- import { isBigInt } from "./typedir.js";
7
+ import { isBigInt, tyEqual } from "./typedir.js";
8
8
  import { parseTsType, tyToCanonical } from "./types.js";
9
9
  import { parseExpr } from "./specparser.js";
10
10
  import { freshName } from "./names.js";
@@ -283,6 +283,8 @@ function expandAlias(ty, typeDecls, seen = new Set()) {
283
283
  return { kind: "optional", inner: expandAlias(ty.inner, typeDecls, seen) };
284
284
  if (ty.kind === "array")
285
285
  return { kind: "array", elem: expandAlias(ty.elem, typeDecls, seen) };
286
+ if (ty.kind === "tuple")
287
+ return { kind: "tuple", elems: ty.elems.map(e => expandAlias(e, typeDecls, seen)) };
286
288
  if (ty.kind === "set")
287
289
  return { kind: "set", elem: expandAlias(ty.elem, typeDecls, seen) };
288
290
  if (ty.kind === "map")
@@ -300,7 +302,7 @@ function getDiscriminant(ctx, typeName) {
300
302
  // unions, arrays, maps, sets, and unresolved generics are reference-compared at
301
303
  // runtime, so a structural proof over them is unsound. Returns true for those.
302
304
  function refEqHazard(ty, typeDecls) {
303
- if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set")
305
+ if (ty.kind === "array" || ty.kind === "map" || ty.kind === "set" || ty.kind === "tuple")
304
306
  return true;
305
307
  if (ty.kind === "user") {
306
308
  let decl = typeDecls.find(d => d.name === ty.name);
@@ -340,6 +342,8 @@ function isUnmodeledTy(ty, typeDecls) {
340
342
  return isUnmodeledTy(ty.inner, typeDecls);
341
343
  if (ty.kind === "array")
342
344
  return isUnmodeledTy(ty.elem, typeDecls);
345
+ if (ty.kind === "tuple")
346
+ return ty.elems.some(e => isUnmodeledTy(e, typeDecls));
343
347
  if (ty.kind === "set")
344
348
  return isUnmodeledTy(ty.elem, typeDecls);
345
349
  if (ty.kind === "map")
@@ -476,8 +480,19 @@ function inferLambdaParamTypes(fn, rawArgs, ctx) {
476
480
  return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
477
481
  }
478
482
  }
483
+ // reduce's callback is (acc, elem): acc from the init arg's type, elem from the array.
484
+ if (fn.kind === "field" && fn.obj.ty.kind === "array" && fn.field === "reduce" && ctx &&
485
+ rawArgs.length >= 2 && rawArgs[0].kind === "lambda" && rawArgs[0].params.length >= 2) {
486
+ const accTs = tyToTsStr(resolveExpr(rawArgs[1], ctx).ty);
487
+ const elemTs = tyToTsStr(fn.obj.ty.elem);
488
+ if (accTs && elemTs) {
489
+ const lam = rawArgs[0];
490
+ const updatedParams = lam.params.map((p, i) => p.tsType || i > 1 ? p : { ...p, tsType: i === 0 ? accTs : elemTs });
491
+ return [{ ...lam, params: updatedParams }, ...rawArgs.slice(1)];
492
+ }
493
+ }
479
494
  if (fn.kind === "field" && fn.obj.ty.kind === "array" &&
480
- ["map", "filter", "every", "some", "find", "findLast", "findIndex"].includes(fn.field) &&
495
+ ["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex"].includes(fn.field) &&
481
496
  rawArgs.length >= 1 && rawArgs[0].kind === "lambda" &&
482
497
  rawArgs[0].params.length >= 1 && !rawArgs[0].params[0].tsType) {
483
498
  const elemTy = fn.obj.ty.elem;
@@ -600,9 +615,11 @@ function inferMethodReturnTy(fn, args, ctx) {
600
615
  return objTy;
601
616
  if (fn.field === "every" || fn.field === "some")
602
617
  return { kind: "bool" };
618
+ if (fn.field === "reduce" && args.length === 2)
619
+ return args[1].ty;
603
620
  if (fn.field === "find" || fn.field === "findLast")
604
621
  return { kind: "optional", inner: objTy.elem };
605
- if (fn.field === "findIndex")
622
+ if (fn.field === "findIndex" || fn.field === "findLastIndex")
606
623
  return { kind: "int" };
607
624
  if (fn.field === "flat" && objTy.elem.kind === "array")
608
625
  return { kind: "array", elem: objTy.elem.elem };
@@ -926,6 +943,16 @@ function resolveExpr(e, ctx) {
926
943
  if (obj.ty.kind === "array") {
927
944
  idxTy = obj.ty.elem;
928
945
  }
946
+ else if (obj.ty.kind === "tuple") {
947
+ // Tuple projection: the position must be a literal — a tuple has a
948
+ // distinct type per slot, so a runtime index has no single result type
949
+ // and no backend projection (`t.0`/`t.1`) exists for it.
950
+ const elems = obj.ty.elems;
951
+ if (idx.kind !== "num" || !Number.isInteger(idx.value) || idx.value < 0 || idx.value >= elems.length) {
952
+ throw new Error(`tuple index must be an integer literal in [0, ${elems.length}); got ${tyToCanonical(idx.ty)} index into ${tyToCanonical(obj.ty)}`);
953
+ }
954
+ idxTy = elems[idx.value];
955
+ }
929
956
  else if (obj.ty.kind === "map") {
930
957
  const objPath = asTExprAccessPath(obj);
931
958
  const idxPath = asTExprAccessPath(idx);
@@ -1090,6 +1117,17 @@ function resolveExpr(e, ctx) {
1090
1117
  return { kind: "exists", var: e.var, varTy, body: resolveExpr(e.body, withEnv(ctx, extend(ctx.env, e.var, varTy))), ty: { kind: "bool" } };
1091
1118
  }
1092
1119
  case "arrayLiteral": {
1120
+ // Expected-tuple context: type each element against its own slot type and
1121
+ // produce a tuple literal (`[1, "a"]: [number, string]`).
1122
+ if (ctx.returnTy.kind === "tuple") {
1123
+ const slots = ctx.returnTy.elems;
1124
+ const elems = e.elems.map((el, i) => {
1125
+ const slot = slots[i];
1126
+ const r = resolveExpr(el, slot ? { ...ctx, returnTy: slot } : ctx);
1127
+ return slot ? coerceStr(r, slot) : r;
1128
+ });
1129
+ return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
1130
+ }
1093
1131
  // Thread the expected element type into each element, so a record/union
1094
1132
  // literal in an array resolves to its named datatype rather than an
1095
1133
  // anonymous tuple (mirrors return-position and call-argument records, which
@@ -1104,6 +1142,12 @@ function resolveExpr(e, ctx) {
1104
1142
  return expectedElem ? coerceStr(r, expectedElem) : r;
1105
1143
  });
1106
1144
  const elemTy = elems.length > 0 ? elems[0].ty : { kind: "unknown" };
1145
+ // No expected collection type: infer array vs tuple from the elements —
1146
+ // heterogeneous element types can't be a homogeneous seq, so they form a
1147
+ // tuple (`[1, "a"]` → `(int, string)`); otherwise a seq.
1148
+ if (!expectedElem && elems.length >= 2 && !elems.every(x => tyEqual(x.ty, elemTy))) {
1149
+ return { kind: "arrayLiteral", elems, ty: { kind: "tuple", elems: elems.map(x => x.ty) } };
1150
+ }
1107
1151
  return { kind: "arrayLiteral", elems, ty: { kind: "array", elem: elemTy } };
1108
1152
  }
1109
1153
  case "lambda": {
@@ -1369,6 +1413,12 @@ function resolveStmt(s, ctx) {
1369
1413
  // Map destructuring: [key, value]
1370
1414
  nameTypes.push(iterable.ty.key, iterable.ty.value);
1371
1415
  }
1416
+ else if (s.names.length >= 2 && iterable.ty.kind === "array" && iterable.ty.elem.kind === "tuple") {
1417
+ // Array of tuples: bind each name to its slot type.
1418
+ const elems = iterable.ty.elem.elems;
1419
+ for (let i = 0; i < s.names.length; i++)
1420
+ nameTypes.push(elems[i] ?? { kind: "unknown" });
1421
+ }
1372
1422
  else {
1373
1423
  // General tuple destructuring: all unknown
1374
1424
  for (const _ of s.names)
@@ -37,6 +37,8 @@ function mapExpr(e, f) {
37
37
  case "toNat": return { ...e, expr: r(e.expr) };
38
38
  case "toReal": return { ...e, expr: r(e.expr) };
39
39
  case "index": return { ...e, arr: r(e.arr), idx: r(e.idx) };
40
+ case "tupleLiteral": return { ...e, elems: e.elems.map(r) };
41
+ case "tupleProj": return { ...e, obj: r(e.obj) };
40
42
  case "record": return { ...e, spread: e.spread ? r(e.spread) : null, fields: e.fields.map(fi => ({ ...fi, value: r(fi.value) })) };
41
43
  case "arrayLiteral": return { ...e, elems: e.elems.map(r) };
42
44
  case "if": return { ...e, cond: r(e.cond), then: r(e.then), else: r(e.else) };
@@ -187,6 +189,12 @@ export const DAFNY_OPTIONS = {
187
189
  let _opts = DAFNY_OPTIONS;
188
190
  /** Type declarations — set once per module transform for discriminated union handling. */
189
191
  let _typeDecls = [];
192
+ /** Names of functions that get a `Pure.` mirror (Lean). A bare reference to one
193
+ * in a higher-order position resolves to the monadic method, so it must be
194
+ * redirected to the pure mirror. Set once per module transform. */
195
+ let _pureDefNames = new Set();
196
+ /** Array methods that take a function argument. */
197
+ const HOF_METHODS = new Set(["map", "filter", "every", "some", "find", "findLast", "findIndex", "findLastIndex", "reduce"]);
190
198
  /** Prefix match-bound field names to avoid capturing user variables.
191
199
  * When prefix is given (the scrutinee name), include it to avoid
192
200
  * collisions in nested matches on different variables. `freshName` closes
@@ -329,7 +337,8 @@ function coerceCondToBool(cond, ty) {
329
337
  return { kind: "binop", op: "≠", left: cond, right: { kind: "num", value: 0 } };
330
338
  if (ty.kind === "string")
331
339
  return { kind: "binop", op: ">", left: { kind: "field", obj: cond, field: "length" }, right: { kind: "num", value: 0 } };
332
- if (ty.kind === "array")
340
+ // Arrays, objects, maps, sets, tuples are always truthy in JS (even `[]`/`{}`).
341
+ if (["array", "user", "map", "set", "tuple"].includes(ty.kind))
333
342
  return { kind: "bool", value: true };
334
343
  return cond;
335
344
  }
@@ -439,8 +448,9 @@ function lowerExpr(e, binds) {
439
448
  // Number truthiness: !n → n == 0
440
449
  if (e.op === "!" && (e.expr.ty.kind === "int" || e.expr.ty.kind === "nat"))
441
450
  return { kind: "binop", op: "=", left: lowerExpr(e.expr, binds), right: { kind: "num", value: 0 } };
442
- // Array truthiness: every array is truthy in JS, so !xs is always false.
443
- if (e.op === "!" && e.expr.ty.kind === "array") {
451
+ // Object truthiness: arrays, objects, maps, and sets are always truthy in
452
+ // JS, so `!x` is always false.
453
+ if (e.op === "!" && ["array", "user", "map", "set", "tuple"].includes(e.expr.ty.kind)) {
444
454
  lowerExpr(e.expr, binds); // preserve any lifted side effects; value is the constant false
445
455
  return { kind: "bool", value: false };
446
456
  }
@@ -605,6 +615,13 @@ function lowerExpr(e, binds) {
605
615
  else: { kind: "var", name: "undefined" },
606
616
  };
607
617
  }
618
+ // || on a number → `if x != 0 then x else default` (0 the only falsy int).
619
+ if (e.op === "||" && (e.left.ty.kind === "int" || e.left.ty.kind === "nat")) {
620
+ const left = lowerExpr(e.left, binds);
621
+ const right = lowerExpr(e.right, binds);
622
+ const truthy = valueTruthyCond(left, e.left.ty);
623
+ return { kind: "if", cond: truthy, then: left, else: right };
624
+ }
608
625
  // String concatenation: `+` with a string operand. Stringify int/nat
609
626
  // operands (Dafny NatToString, Lean toString) and join with arrayConcat
610
627
  // (rendered `+` in Dafny, `++` in Lean).
@@ -697,6 +714,10 @@ function lowerExpr(e, binds) {
697
714
  return { kind: "field", obj: transformExpr(e.obj), field: e.field, datatypeField: isRecordType(e.obj.ty) };
698
715
  case "index": {
699
716
  const idx = transformExpr(e.idx);
717
+ if (e.obj.ty.kind === "tuple") {
718
+ // resolve guarantees a numeric-literal index here; project at that slot.
719
+ return { kind: "tupleProj", obj: transformExpr(e.obj), index: e.idx.value, arity: e.obj.ty.elems.length };
720
+ }
700
721
  if (e.obj.ty.kind === "map") {
701
722
  // Mirrors the .get() → .getDirect switch at line ~453: when resolve has
702
723
  // narrowed the index type to non-optional (via `k in m` atoms in scope),
@@ -766,8 +787,15 @@ function lowerExpr(e, binds) {
766
787
  if (e.fn.kind === "field") {
767
788
  const recv = lowerExpr(e.fn.obj, binds);
768
789
  let method = e.fn.field;
790
+ const isHOF = e.fn.obj.ty.kind === "array" && HOF_METHODS.has(method);
769
791
  const args = e.args.map((a, i) => {
770
792
  const lowered = lowerExpr(a, binds);
793
+ // Lean: a pure fn passed to a HOF by name resolves to the monadic
794
+ // method; redirect to its pure `Pure.` mirror.
795
+ if (_opts.backend === "lean" && isHOF &&
796
+ lowered.kind === "var" && _pureDefNames.has(lowered.name)) {
797
+ return { kind: "var", name: `Pure.${lowered.name}` };
798
+ }
771
799
  // Array index args must be nat in Lean: `with`'s index (0), includes/indexOf `from` (1).
772
800
  const isArrIdxArg = e.fn.kind === "field" && e.fn.obj.ty.kind === "array" &&
773
801
  ((e.fn.field === "with" && i === 0) || ((e.fn.field === "includes" || e.fn.field === "indexOf") && i === 1));
@@ -918,6 +946,8 @@ function lowerExpr(e, binds) {
918
946
  // Set with initial elements: new Set([a, b]) → {a, b}
919
947
  if (e.ty.kind === "set")
920
948
  return { kind: "app", fn: "SetLiteral", args: e.elems.map(el => lowerExpr(el, binds)) };
949
+ if (e.ty.kind === "tuple")
950
+ return { kind: "tupleLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
921
951
  return { kind: "arrayLiteral", elems: e.elems.map(el => lowerExpr(el, binds)) };
922
952
  case "lambda": {
923
953
  // Pass the module typeDecls (not []), so type lookups inside the lambda
@@ -1397,86 +1427,73 @@ function transformStmts(stmts, typeDecls) {
1397
1427
  const s = stmts[i];
1398
1428
  // Transform for-of → for-in over range
1399
1429
  if (s.kind === "forof") {
1400
- const varName = s.names[0];
1401
- const varTy = s.nameTypes[0] ?? { kind: "unknown" };
1402
- let iterExpr = transformExpr(s.iterable);
1403
- // Map key-only iteration: for (const k in record) iterate keys only
1404
- if (s.names.length === 1 && s.iterable.ty.kind === "map") {
1405
- const keyName = s.names[0];
1430
+ const headName = s.names[0];
1431
+ const headTy = s.nameTypes[0] ?? { kind: "unknown" };
1432
+ const iterExpr = transformExpr(s.iterable);
1433
+ // All for-of shapes lower to the same range loop `for idx in 0..seq.size`;
1434
+ // they differ only in (a) which indexable `seq` is iterated and (b) the
1435
+ // per-iteration `let`s that bind the loop names. Compute the shared loop-
1436
+ // variable naming once, then let each shape fill in `seq` + `perIter`.
1437
+ const count = _forofCounters.get(headName) ?? 0;
1438
+ _forofCounters.set(headName, count + 1);
1439
+ const suffix = count === 0 ? "" : `${count + 1}`;
1440
+ const idxName = freshName(`_${headName}_idx${suffix}`);
1441
+ const idxVar = { kind: "var", name: idxName };
1442
+ let seq;
1443
+ let perIter;
1444
+ if (s.iterable.ty.kind === "map") {
1445
+ // for (const k of map) / for (const [k, v] of map): materialize the keys
1446
+ // as a seq and index into it; a value name looks up via getDirect.
1406
1447
  const keyTy = s.nameTypes[0] ?? s.iterable.ty.key ?? { kind: "unknown" };
1407
- const count = _forofCounters.get(keyName) ?? 0;
1408
- _forofCounters.set(keyName, count + 1);
1409
- const suffix = count === 0 ? "" : `${count + 1}`;
1410
- const keysSeqName = freshName(`_${keyName}_keys${suffix}`);
1411
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
1412
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
1413
- const keysVar = { kind: "var", name: keysSeqName };
1414
- const idxName = freshName(`_${keyName}_idx${suffix}`);
1415
- const idx = { kind: "var", name: idxName };
1416
- const arrSize = { kind: "field", obj: keysVar, field: "size" };
1417
- const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
1418
- const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
1419
- const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
1420
- result.push({
1421
- kind: "forin", idx: idxName, bound: arrSize,
1422
- invariants: [boundInv, ...s.invariants.map(transformExpr)],
1423
- body: [letKey, ...bodyStmts],
1424
- });
1425
- i++;
1426
- continue;
1448
+ const keysSeqName = freshName(`_${headName}_keys${suffix}`);
1449
+ result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false,
1450
+ value: { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] } });
1451
+ seq = { kind: "var", name: keysSeqName };
1452
+ const letKey = { kind: "let", name: headName, type: keyTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } };
1453
+ if (s.names.length >= 2) {
1454
+ const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
1455
+ const letVal = { kind: "let", name: s.names[1], type: valueTy, mutable: false,
1456
+ value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: headName }], monadic: false } };
1457
+ perIter = [letKey, letVal];
1458
+ }
1459
+ else {
1460
+ perIter = [letKey];
1461
+ }
1427
1462
  }
1428
- // Map iteration: for (const [k, v] of map) iterate keys, look up values
1429
- if (s.names.length >= 2 && s.iterable.ty.kind === "map") {
1430
- const keyName = s.names[0], valueName = s.names[1];
1431
- const keyTy = s.nameTypes[0] ?? { kind: "unknown" };
1432
- const valueTy = s.nameTypes[1] ?? { kind: "unknown" };
1433
- const count = _forofCounters.get(keyName) ?? 0;
1434
- _forofCounters.set(keyName, count + 1);
1435
- const suffix = count === 0 ? "" : `${count + 1}`;
1436
- const keysSeqName = freshName(`_${keyName}_keys${suffix}`);
1437
- const convExpr = { kind: "app", fn: "SetToSeq", args: [{ kind: "field", obj: iterExpr, field: "keys" }] };
1438
- result.push({ kind: "let", name: keysSeqName, type: { kind: "array", elem: keyTy }, mutable: false, value: convExpr });
1439
- const keysVar = { kind: "var", name: keysSeqName };
1440
- const idxName = freshName(`_${keyName}_idx${suffix}`);
1441
- const idx = { kind: "var", name: idxName };
1442
- const arrSize = { kind: "field", obj: keysVar, field: "size" };
1443
- const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
1444
- const letKey = { kind: "let", name: keyName, type: keyTy, mutable: false, value: { kind: "index", arr: keysVar, idx } };
1445
- const letVal = { kind: "let", name: valueName, type: valueTy, mutable: false,
1446
- value: { kind: "methodCall", obj: iterExpr, objTy: s.iterable.ty, method: "getDirect", args: [{ kind: "var", name: keyName }], monadic: false } };
1447
- const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
1448
- result.push({
1449
- kind: "forin", idx: idxName, bound: arrSize,
1450
- invariants: [boundInv, ...s.invariants.map(transformExpr)],
1451
- body: [letKey, letVal, ...bodyStmts],
1452
- });
1453
- i++;
1454
- continue;
1463
+ else if (s.names.length >= 2 && s.iterable.ty.kind === "array" && s.iterable.ty.elem.kind === "tuple") {
1464
+ // for (const [a, b] of tupleArr): bind the element once, then destructure
1465
+ // it into each name via a tuple projection.
1466
+ const tupleTy = s.iterable.ty.elem;
1467
+ seq = iterExpr;
1468
+ const elemName = freshName(`_${headName}_elem${suffix}`);
1469
+ const letElem = { kind: "let", name: elemName, type: tupleTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } };
1470
+ perIter = [letElem, ...s.names.map((n, k) => ({
1471
+ kind: "let", name: n, type: tupleTy.elems[k] ?? { kind: "unknown" }, mutable: false,
1472
+ value: { kind: "tupleProj", obj: { kind: "var", name: elemName }, index: k, arity: tupleTy.elems.length },
1473
+ }))];
1455
1474
  }
1456
- // Sets aren't indexable — bind SetToSeq to a variable for iteration
1457
- if (s.iterable.ty.kind === "set") {
1458
- const seqName = freshName(`_${varName}_seq`);
1459
- const convExpr = { kind: "app", fn: "SetToSeq", args: [iterExpr] };
1460
- const elemTy = varTy.kind !== "unknown" ? varTy : { kind: "string" };
1461
- result.push({ kind: "let", name: seqName, type: { kind: "array", elem: elemTy }, mutable: false, value: convExpr });
1462
- iterExpr = { kind: "var", name: seqName };
1475
+ else {
1476
+ // Single element by index. Sets aren't indexable, so materialize first.
1477
+ if (s.iterable.ty.kind === "set") {
1478
+ const seqName = freshName(`_${headName}_seq${suffix}`);
1479
+ const elemTy = headTy.kind !== "unknown" ? headTy : { kind: "string" };
1480
+ result.push({ kind: "let", name: seqName, type: { kind: "array", elem: elemTy }, mutable: false,
1481
+ value: { kind: "app", fn: "SetToSeq", args: [iterExpr] } });
1482
+ seq = { kind: "var", name: seqName };
1483
+ }
1484
+ else {
1485
+ seq = iterExpr;
1486
+ }
1487
+ perIter = [{ kind: "let", name: headName, type: headTy, mutable: false, value: { kind: "index", arr: seq, idx: idxVar } }];
1463
1488
  }
1464
- const count = _forofCounters.get(varName) ?? 0;
1465
- _forofCounters.set(varName, count + 1);
1466
- const suffix = count === 0 ? "" : `${count + 1}`;
1467
- const idxName = freshName(`_${varName}_idx${suffix}`);
1468
- const idx = { kind: "var", name: idxName };
1469
- const arrSize = { kind: "field", obj: iterExpr, field: "size" };
1470
- const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
1471
- const letElem = { kind: "let", name: varName, type: varTy, mutable: false, value: { kind: "index", arr: iterExpr, idx } };
1489
+ const arrSize = { kind: "field", obj: seq, field: "size" };
1472
1490
  // Auto-add bound invariant: idx ≤ bound (always true for range loops)
1473
- const boundInv = { kind: "binop", op: "≤", left: idx, right: arrSize };
1491
+ const boundInv = { kind: "binop", op: "≤", left: idxVar, right: arrSize };
1492
+ const bodyStmts = eliminateTopLevelContinue(transformStmts(s.body, typeDecls));
1474
1493
  result.push({
1475
- kind: "forin",
1476
- idx: idxName,
1477
- bound: arrSize,
1494
+ kind: "forin", idx: idxName, bound: arrSize,
1478
1495
  invariants: [boundInv, ...s.invariants.map(transformExpr)],
1479
- body: [letElem, ...bodyStmts],
1496
+ body: [...perIter, ...bodyStmts],
1480
1497
  });
1481
1498
  i++;
1482
1499
  continue;
@@ -2186,6 +2203,7 @@ export function transformModule(mod, specImport, moduleBaseOverride) {
2186
2203
  _forofCounters.clear();
2187
2204
  _liftCounter = 0;
2188
2205
  _typeDecls = mod.typeDecls;
2206
+ _pureDefNames = new Set(mod.functions.filter(f => f.isPure).map(f => f.name));
2189
2207
  const typeDecls = mod.typeDecls.map(transformTypeDecl);
2190
2208
  // Module-level constants
2191
2209
  const constDecls = (mod.constants ?? []).map(c => ({
@@ -8,3 +8,39 @@
8
8
  export function isBigInt(ty) {
9
9
  return (ty.kind === "int" || ty.kind === "nat") && !!ty.big;
10
10
  }
11
+ /** Structural equality on Ty. Used to decide whether a tuple type is homogeneous
12
+ * (all elements equal ⇒ lower to `seq`) vs heterogeneous (⇒ keep as `tuple`). */
13
+ export function tyEqual(a, b) {
14
+ if (a.kind !== b.kind)
15
+ return false;
16
+ switch (a.kind) {
17
+ case "array": return tyEqual(a.elem, b.elem);
18
+ case "set": return tyEqual(a.elem, b.elem);
19
+ case "tuple": {
20
+ const bt = b;
21
+ return a.elems.length === bt.elems.length && a.elems.every((e, i) => tyEqual(e, bt.elems[i]));
22
+ }
23
+ case "map": {
24
+ const bm = b;
25
+ return tyEqual(a.key, bm.key) && tyEqual(a.value, bm.value);
26
+ }
27
+ case "optional": return tyEqual(a.inner, b.inner);
28
+ case "user": return a.name === b.name;
29
+ case "fn": {
30
+ const bf = b;
31
+ return a.params.length === bf.params.length
32
+ && a.params.every((p, i) => tyEqual(p, bf.params[i]))
33
+ && tyEqual(a.result, bf.result);
34
+ }
35
+ case "string": {
36
+ const bs = b;
37
+ return JSON.stringify(a.values ?? null) === JSON.stringify(bs.values ?? null);
38
+ }
39
+ case "int":
40
+ case "nat": return !!a.big === !!b.big;
41
+ case "bool":
42
+ case "real":
43
+ case "void":
44
+ case "unknown": return true; // no payload
45
+ }
46
+ }
@@ -4,6 +4,7 @@
4
4
  * Single source of truth for type-related decisions.
5
5
  * The transform phase imports this.
6
6
  */
7
+ import { tyEqual } from "./typedir.js";
7
8
  import { Node, Project, SyntaxKind } from "ts-morph";
8
9
  /**
9
10
  * Parses TS type strings via a real ts-morph parse — no regex cascade. Set
@@ -90,10 +91,14 @@ function tyFromTypeNode(tn) {
90
91
  if (Node.isArrayTypeNode(tn))
91
92
  return { kind: "array", elem: tyFromTypeNode(tn.getElementTypeNode()) };
92
93
  if (Node.isTupleTypeNode(tn)) {
93
- const elems = tn.getElements();
94
+ const elems = tn.getElements().map(tyFromTypeNode);
94
95
  if (elems.length === 0)
95
96
  return { kind: "array", elem: { kind: "unknown" } };
96
- return { kind: "array", elem: tyFromTypeNode(elems[0]) };
97
+ // Homogeneous tuple seq; only heterogeneous tuples need the tuple
98
+ // representation.
99
+ if (elems.every(e => tyEqual(e, elems[0])))
100
+ return { kind: "array", elem: elems[0] };
101
+ return { kind: "tuple", elems };
97
102
  }
98
103
  if (Node.isFunctionTypeNode(tn)) {
99
104
  const params = tn.getParameters().map(p => {
@@ -158,6 +163,7 @@ export function tyToCanonical(ty) {
158
163
  case "void": return "void";
159
164
  case "unknown": return "unknown";
160
165
  case "array": return `seq<${tyToCanonical(ty.elem)}>`;
166
+ case "tuple": return `(${ty.elems.map(tyToCanonical).join(", ")})`;
161
167
  case "map": return `map<${tyToCanonical(ty.key)}, ${tyToCanonical(ty.value)}>`;
162
168
  case "set": return `set<${tyToCanonical(ty.elem)}>`;
163
169
  case "optional": return `Option<${tyToCanonical(ty.inner)}>`;