Skip to content

Commit ed242af

Browse files
committed
MeTTa TS 1.0.8
1 parent 93aa040 commit ed242af

26 files changed

Lines changed: 800 additions & 61 deletions

RELEASE_NOTES.md

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
1-
# MeTTa TS 1.0.7
1+
# MeTTa TS 1.0.8
22

33
A pure-TypeScript implementation of [MeTTa](https://metta-lang.dev) (Meta Type Talk), the OpenCog Hyperon language. It runs anywhere TypeScript runs: the browser, Node, Deno, Bun, and edge or serverless functions. No native addons, no WASM, no Rust.
44

55
## Tested on Linux
66

77
This release is tested on Linux (Node 20, the CI matrix): lint, format, typecheck, the full test suite, and the build all run there. Because the engine is pure TypeScript with no native addon and no WASM, it is meant to be cross-platform and should run unchanged on any JavaScript runtime. Other operating systems are not yet part of the tested matrix.
88

9-
## What's new: the proof-size-bounded backward chainer, from losing to winning
9+
## What's new: a static analyzer and `metta-ts --check`
1010

11-
Nil Geisweiller's [`bfc-xp`](https://github.com/ngeiswei/chaining) benchmark searches a Łukasiewicz propositional calculus for a proof of a target formula under a fixed size bound, backtracking through modus ponens and three axiom schemes. It is a real nondeterministic search, not a lookup, and it was the one place PeTTa still beat MeTTa TS. This release closes that gap by compiling the search itself, not just the terms it searches over.
11+
This release adds a static analyzer that catches mistakes before a program runs and reports them the way a modern compiler does: the exact source span underlined, an error code, a message, and a suggested fix. It ships in `@metta-ts/core` as a library and on the `metta-ts` CLI as `--check`.
1212

13-
A match-free nondeterministic group (no clause queries a space, only recursion, `if`-guards, and integer arithmetic) now compiles in two steps. First, every clause becomes a skeleton: a tree of constant subtrees, clause-variable slots, and structured nodes, computed once per group rather than copied per call. Second, the skeleton compiles to specialized JavaScript, one function per functor, generated once via `new Function` and shared by every run: head unification becomes read/write-mode code that fails at the first mismatch with no allocation, and body arguments and templates become direct constructor expressions with integer arithmetic unboxed. Underneath both steps, search variables are cell variables: a binding lives in a mutable slot on the variable itself, so dereferencing is pointer-chasing and undoing a failed branch is popping a trail array, no string-keyed map anywhere. Every bind carries an occurs check, so a would-be cyclic binding fails the search instead of looping, exactly the discipline `hasLoop` enforces on the interpreter's own immutable bindings and SWI-Prolog enforces under `occurs_check(true)`. A model in [`spec/loop_reject.als`](spec/loop_reject.als) proves the two mechanisms reject exactly the same binding sets, in every possible bind order. An environment that forbids dynamic code (a CSP without `unsafe-eval`) falls back to running the skeleton directly, still far faster than the plain interpreter; a group that does query a space (like `nilbc`, MeTTa TS's dependently-typed backward chainer) is unaffected and keeps running on the original interpreter-backed search.
13+
The analyzer reads the interpreter's own signature table, so it flags exactly the calls the interpreter itself would reject. A builtin called with the wrong number of arguments is checked against the same `(-> ...)` declaration the evaluator uses at run time, not against a second copy of the arity rules that could drift from it:
1414

15-
The result, measured with `hyperfine` (mean ± σ, wall clock, engine startup included, each row from one `hyperfine` invocation so the two columns are directly comparable):
15+
```
16+
error[arity-mismatch]: prog.metta:1:2
17+
|
18+
1 | !(car-atom 1 2)
19+
| ^^^^^^^^^^^^^^ car-atom expects 1 argument, got 2
20+
```
1621

17-
| benchmark | MeTTa TS | PeTTa |
18-
| --------------------------------------------------- | -----------------: | -----------------: |
19-
| `jarr` (size 13) | 124.8 ms ± 4.2 ms | 182.9 ms ± 10.6 ms |
20-
| `pm2.27` (size 13) | 121.6 ms ± 1.3 ms | 183.0 ms ± 8.3 ms |
21-
| `imim1` (size 15) | 152.2 ms ± 4.1 ms | 212.3 ms ± 3.9 ms |
22-
| `jarr` (size 17), PeTTa with `occurs_check(true)` | 455.5 ms ± 69.5 ms | 476.0 ms ± 11.8 ms |
23-
| `loowoz` (size 19), PeTTa with `occurs_check(true)` | 2.092 s ± 0.063 s | 2.501 s ± 0.003 s |
22+
An opt-in `--undefined-symbols` pass adds a "did you mean" on an unknown head, suggesting the closest defined name by edit distance. It is off by default because MeTTa's add-mode makes an unknown head legal: `(foo 1 2)` with no rule for `foo` is data added to the space, not a typo. With the flag on, `(fibonaci 10)` next to a defined `fibonacci` becomes a warning that carries the fix, never an error.
2423

25-
The last two rows run PeTTa with `occurs_check(true)`, which its SWI-Prolog translation does not set (the flag defaults to off). At these two deeper searches that gap is not just a speed difference: PeTTa as shipped finds 94 answers for `jarr` at size 17 where only 91 exist, and 44 for `loowoz` at size 19 where only 3 exist, the surplus being cyclic-binding artifacts that `occurs_check` is exactly the guard against (the same requirement `bfc-xp`'s own SWI harness documents). Run correctly, PeTTa is a little slower here too. Every MeTTa TS answer above is checked byte-identical to the plain interpreter by a differential oracle that runs the search both ways (`packages/core/src/moded-tabling.test.ts`), and the compiled and interpreted engines' outputs are additionally diffed directly at sizes 17 and 19.
24+
Precise spans come from a span-tracking parse that reuses the interpreter's own reader primitives, so the analyzer cannot disagree with the real parser about where a token starts or ends. `--json` emits the findings as a Language Server Protocol `Diagnostic[]`, each with a range, a severity, a code, and suggestions that carry an applicability tier, so an editor or a language server can consume them directly.
25+
26+
None of this changes the evaluator. The only edit to existing run-time code is a refactor of the parser that exports the primitives the span parser reuses, and it is guarded by the parser's own tests. So the 270-assertion Hyperon oracle and the corpus benchmark below are byte-identical to 1.0.7, and the analyzer adds no dependency.
2627

2728
## Corpus benchmark
2829

29-
The existing PeTTa-corpus benchmark (107 shared programs, 97 both engines pass, median 2.01x, geomean 2.06x) is unaffected by this release: none of those programs exercise a match-free nondeterministic group, so they run the same code as before. See [`packages/node/bench/RESULTS-corpus.md`](packages/node/bench/RESULTS-corpus.md) for the full per-program table.
30+
The engine is unchanged in this release, so the PeTTa-corpus benchmark (107 shared programs, 97 both engines pass, median 2.01x, geomean 2.06x) is identical to 1.0.7. See [`packages/node/bench/RESULTS-corpus.md`](packages/node/bench/RESULTS-corpus.md) for the full per-program table.
3031

3132
## Major performance gains (since 1.0.0)
3233

@@ -39,16 +40,16 @@ The speed comes from general engine work:
3940
- automatic tabling of pure functions, including ones defined at runtime, and moded (variant) tabling for non-ground pure calls;
4041
- a native-code compiler for the pure deterministic int/bool/tuple subset, with tail-recursion compiled to loops and higher-order specialisation;
4142
- worker-thread parallelism: `(once (hyperpose ...))` races branches across CPU cores on Node, and a `SharedArrayBuffer` flat matcher scans large knowledge bases in parallel;
42-
- the compiled clause-skeleton and JavaScript-codegen search described above, for match-free nondeterministic groups.
43+
- the compiled clause-skeleton and JavaScript-codegen search for match-free nondeterministic groups, added in 1.0.7.
4344

4445
Every optimisation is verified byte-identical against the 270-assertion Hyperon oracle.
4546

4647
## What is in this release
4748

48-
- `@metta-ts/core` is the interpreter, parser, type system, pattern matching, and standard library, as a single ESM bundle. It passes all 270 assertions of Hyperon's oracle corpus (the full dependent-type tier, spaces and mutable state, nondeterminism, grounded operations, and documentation), cross-checked against [LeaTTa](https://github.com/MesTTo/LeaTTa), the machine-checked (Lean 4) MeTTa semantics pinned to the same commit.
49+
- `@metta-ts/core` is the interpreter, parser, type system, pattern matching, and standard library, as a single ESM bundle. It now also carries the static analyzer and its diagnostic model, described above. It passes all 270 assertions of Hyperon's oracle corpus (the full dependent-type tier, spaces and mutable state, nondeterminism, grounded operations, and documentation), cross-checked against [LeaTTa](https://github.com/MesTTo/LeaTTa), the machine-checked (Lean 4) MeTTa semantics pinned to the same commit.
4950
- `@metta-ts/hyperon` is a TypeScript class API modeled on Python's `hyperon`, with a JavaScript interop layer (`js-atom`, `js-dot`, `js-list`, `js-dict`) that calls into the host runtime directly.
5051
- `@metta-ts/edsl` is a typed eDSL with term builders, special-form combinators, and a tagged-template surface.
51-
- `@metta-ts/node` has the `metta-ts` CLI, file `import!`, and the worker-thread parallel matcher.
52+
- `@metta-ts/node` has the `metta-ts` CLI, now with `--check` for static analysis, plus file `import!` and the worker-thread parallel matcher.
5253
- `@metta-ts/browser` is a browser entry with an in-memory virtual file system for `import!`.
5354
- `@metta-ts/grapher` renders a MeTTa reduction as a node graph or a nested-block view, as static SVGs or an animated GIF, with a data-driven stylesheet for node size and colour.
5455
- `@metta-ts/das-client` and `@metta-ts/das-gateway` are an optional client to SingularityNET's Distributed AtomSpace, run end to end against a live cluster, with atom handles matching the AtomDB byte for byte.
@@ -60,6 +61,14 @@ npm install @metta-ts/core # the interpreter (works in any JS runtime)
6061
npm install -g @metta-ts/node # the metta-ts CLI
6162
```
6263

64+
Check a file without running it:
65+
66+
```bash
67+
metta-ts --check program.metta # arity errors, rustc-style
68+
metta-ts --check --undefined-symbols program.metta # also "did you mean" on unknown heads
69+
metta-ts --check --json program.metta # diagnostics as an LSP Diagnostic[]
70+
```
71+
6372
## Provenance
6473

6574
- Semantics: [hyperon-experimental](https://github.com/trueagi-io/hyperon-experimental), pinned to commit `3f76dc4`.

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metta-ts/browser",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"license": "MIT",
55
"type": "module",
66
"main": "./dist/index.js",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metta-ts/core",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"license": "MIT",
55
"type": "module",
66
"main": "./dist/index.js",

packages/core/src/cst.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
// SPDX-License-Identifier: MIT
3+
import { describe, it, expect } from "vitest";
4+
import { parseAllSpanned } from "./cst";
5+
import { standardTokenizer } from "./runner";
6+
import { format } from "./parser";
7+
8+
describe("parseAllSpanned", () => {
9+
it("records the span of a nested symbol precisely", () => {
10+
const src = "(map (fibonaci 10) $xs)";
11+
const [top] = parseAllSpanned(src, standardTokenizer());
12+
// top is the whole (map ...) expr
13+
expect(src.slice(top!.span.start, top!.span.end)).toBe("(map (fibonaci 10) $xs)");
14+
// children[1] is (fibonaci 10); its children[0] is `fibonaci`
15+
const fib = top!.children![1]!.children![0]!;
16+
expect(src.slice(fib.span.start, fib.span.end)).toBe("fibonaci");
17+
});
18+
19+
it("marks a top-level !-query and spans the form after the bang", () => {
20+
const src = "!(car-atom)";
21+
const [top] = parseAllSpanned(src, standardTokenizer());
22+
expect(top!.bang).toBe(true);
23+
expect(src.slice(top!.span.start, top!.span.end)).toBe("(car-atom)");
24+
});
25+
26+
it("produces the same atoms as the plain parser", () => {
27+
const src = "(= (f $x) (+ $x 1))\n!(f 2)";
28+
const spanned = parseAllSpanned(src, standardTokenizer());
29+
expect(spanned.map((n) => format(n.atom))).toEqual(["(= (f $x) (+ $x 1))", "(f 2)"]);
30+
});
31+
32+
it("spans a string literal including its quotes", () => {
33+
const src = '(greet "hi")';
34+
const [top] = parseAllSpanned(src, standardTokenizer());
35+
const strNode = top!.children![1]!;
36+
expect(src.slice(strNode.span.start, strNode.span.end)).toBe('"hi"');
37+
});
38+
});

packages/core/src/cst.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
// SPDX-License-Identifier: MIT
3+
4+
// A span-tracking parse: the same atoms `parseAll` produces, plus the source range of every node.
5+
// Off the `runFile` hot path entirely; only the static analyzer calls it. It reuses parser.ts's
6+
// char-level primitives so it cannot drift from the real parser.
7+
import { type Atom, expr } from "./atom";
8+
import { type Tokenizer } from "./tokenizer";
9+
import { isDelim, skipTrivia, readStringAt, leafAtom, MAX_DEPTH } from "./parser";
10+
11+
export interface Span {
12+
readonly start: number;
13+
readonly end: number;
14+
}
15+
16+
/** One CST node: the atom it parses to, its source range, and (for expressions) its children. The
17+
* top-level marker `bang` is set on a node parsed from a `!`-query. */
18+
export interface SpannedNode {
19+
readonly atom: Atom;
20+
readonly span: Span;
21+
readonly children?: readonly SpannedNode[];
22+
readonly bang?: boolean;
23+
}
24+
25+
function readSpanned(
26+
s: string,
27+
pos: number,
28+
tk: Tokenizer,
29+
depth: number,
30+
): { node: SpannedNode; end: number } {
31+
pos = skipTrivia(s, pos);
32+
const start = pos;
33+
const ch = s[pos];
34+
if (ch === "(") {
35+
if (depth >= MAX_DEPTH) throw new Error("MeTTa expression nesting too deep");
36+
pos++;
37+
const children: SpannedNode[] = [];
38+
for (;;) {
39+
pos = skipTrivia(s, pos);
40+
if (pos >= s.length) throw new Error("unbalanced '(' in MeTTa source");
41+
if (s[pos] === ")") {
42+
pos++;
43+
break;
44+
}
45+
const r = readSpanned(s, pos, tk, depth + 1);
46+
children.push(r.node);
47+
pos = r.end;
48+
}
49+
const atom = expr(children.map((c) => c.atom));
50+
return { node: { atom, span: { start, end: pos }, children }, end: pos };
51+
}
52+
if (ch === '"') {
53+
const { atom, end } = readStringAt(s, pos);
54+
return { node: { atom, span: { start, end } }, end };
55+
}
56+
let end = pos;
57+
while (end < s.length && !isDelim(s[end]!)) end++;
58+
const word = s.slice(pos, end);
59+
return { node: { atom: leafAtom(word, tk), span: { start, end } }, end };
60+
}
61+
62+
/** Parse a whole program into spanned top-level nodes. Mirrors `parseAll`, adding spans and the
63+
* `bang` marker for `!`-queries. */
64+
export function parseAllSpanned(src: string, tk: Tokenizer): SpannedNode[] {
65+
const out: SpannedNode[] = [];
66+
let pos = skipTrivia(src, 0);
67+
while (pos < src.length) {
68+
let bang = false;
69+
if (src[pos] === "!") {
70+
bang = true;
71+
pos = skipTrivia(src, pos + 1);
72+
}
73+
const r = readSpanned(src, pos, tk, 0);
74+
out.push({ ...r.node, bang });
75+
pos = skipTrivia(src, r.end);
76+
}
77+
return out;
78+
}

packages/core/src/diagnose.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
// SPDX-License-Identifier: MIT
3+
import { describe, it, expect } from "vitest";
4+
import { analyzeSource } from "./diagnose";
5+
import { DiagnosticSeverity } from "./diagnostic";
6+
7+
const cfg = { undefinedSymbols: false };
8+
9+
describe("analyzeSource — arity", () => {
10+
it("flags too many arguments to a signed builtin", () => {
11+
// car-atom : (-> Expression %Undefined%), one parameter
12+
const diags = analyzeSource("!(car-atom (a b) (c d))", cfg);
13+
expect(diags).toHaveLength(1);
14+
expect(diags[0]!.code).toBe("arity-mismatch");
15+
expect(diags[0]!.severity).toBe(DiagnosticSeverity.Error);
16+
expect(diags[0]!.message).toContain("car-atom");
17+
expect(diags[0]!.message).toContain("1 argument");
18+
// primary span is the whole call
19+
const src = "!(car-atom (a b) (c d))";
20+
const r = diags[0]!.range;
21+
expect(r.start).toEqual({ line: 0, character: 1 });
22+
expect(r.end).toEqual({ line: 0, character: src.length });
23+
});
24+
25+
it("accepts a correct-arity call", () => {
26+
expect(analyzeSource("!(car-atom (a b))", cfg)).toEqual([]);
27+
});
28+
29+
it("does not flag an op that has no declared signature", () => {
30+
// `foo` is unknown; with undefinedSymbols off, nothing is reported
31+
expect(analyzeSource("!(foo 1 2 3)", cfg)).toEqual([]);
32+
});
33+
34+
it("reports every arity error in one pass, sorted by position", () => {
35+
const diags = analyzeSource("!(car-atom 1 2)\n!(cdr-atom 1 2)", cfg);
36+
expect(diags.map((d) => d.code)).toEqual(["arity-mismatch", "arity-mismatch"]);
37+
expect(diags[0]!.range.start.line).toBe(0);
38+
expect(diags[1]!.range.start.line).toBe(1);
39+
});
40+
});
41+
42+
describe("analyzeSource — undefined head (gated)", () => {
43+
const on = { undefinedSymbols: true };
44+
45+
it("does not flag an unknown head when the gate is off", () => {
46+
expect(analyzeSource("!(fibonaci 10)", { undefinedSymbols: false })).toEqual([]);
47+
});
48+
49+
it("warns on an unknown head with a near-miss to a defined symbol", () => {
50+
const src = "(= (fibonacci $n) $n)\n!(fibonaci 10)";
51+
const diags = analyzeSource(src, on);
52+
const warn = diags.find((d) => d.code === "unknown-symbol");
53+
expect(warn).toBeDefined();
54+
expect(warn!.severity).toBe(DiagnosticSeverity.Warning);
55+
expect(warn!.message).toContain("fibonaci");
56+
expect(warn!.suggestions?.[0]?.replacement).toBe("fibonacci");
57+
// primary span underlines just the head, not the whole call
58+
expect(warn!.range.start.line).toBe(1);
59+
expect(warn!.range.start.character).toBe(2); // after "!("
60+
});
61+
62+
it("stays silent for an unknown head with no near match", () => {
63+
const diags = analyzeSource("!(totallyunrelated 1)", on);
64+
expect(diags.find((d) => d.code === "unknown-symbol")).toBeUndefined();
65+
});
66+
67+
it("does not warn on a known stdlib op", () => {
68+
const diags = analyzeSource("!(car-atom (a b))", on);
69+
expect(diags.find((d) => d.code === "unknown-symbol")).toBeUndefined();
70+
});
71+
});

0 commit comments

Comments
 (0)