Skip to content

Commit 93aa040

Browse files
committed
MeTTa TS 1.0.7
1 parent 264b841 commit 93aa040

40 files changed

Lines changed: 2947 additions & 319 deletions

RELEASE_NOTES.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,56 @@
1-
# MeTTa TS 1.0.0
1+
# MeTTa TS 1.0.7
22

3-
The first public release of MeTTa TS, 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.
3+
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-
## Major performance gains
9+
## What's new: the proof-size-bounded backward chainer, from losing to winning
1010

11-
MeTTa TS is fast, from pure TypeScript. A reproducible black-box benchmark runs the PeTTa example corpus through both engines as subprocesses and checks each program's embedded `(test ...)` assertions. On the Hyperon-faithful shared subset, MeTTa TS passes 95 programs and is faster than PeTTa on 92 of them, median 2.18x and up to 6.2x (`fib`), even though PeTTa runs on SWI-Prolog's GMP-backed integers.
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.
12+
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.
14+
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):
16+
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 |
24+
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.
26+
27+
## Corpus benchmark
28+
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+
31+
## Major performance gains (since 1.0.0)
1232

1333
The speed comes from general engine work:
1434

1535
- an O(1)-stack reduce-loop trampoline and worklist, so deep recursion does not grow the JS stack;
1636
- deferred rule-RHS freshening with a head-shape candidate pre-filter;
1737
- Prolog-style clause indexing by head functor and by every ground-leaf argument, so a keyed query over a 1,000,000-atom space resolves in about 0.2 to 1.4 ms;
1838
- ground-atom type memoisation and an exact-match ground-fact index;
19-
- automatic tabling of pure functions, including ones defined at runtime;
39+
- automatic tabling of pure functions, including ones defined at runtime, and moded (variant) tabling for non-ground pure calls;
2040
- a native-code compiler for the pure deterministic int/bool/tuple subset, with tail-recursion compiled to loops and higher-order specialisation;
21-
- worker-thread parallelism: `(once (hyperpose ...))` races branches across CPU cores on Node, and a `SharedArrayBuffer` flat matcher scans large knowledge bases in parallel.
41+
- 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.
2243

23-
Every optimisation is verified byte-identical against the 270-assertion Hyperon oracle. See [`packages/node/bench/RESULTS-corpus.md`](packages/node/bench/RESULTS-corpus.md) for the full per-program table.
44+
Every optimisation is verified byte-identical against the 270-assertion Hyperon oracle.
2445

2546
## What is in this release
2647

27-
- `@metta-ts/core` is the interpreter, parser, type system, pattern matching, and standard library, as a single ESM bundle (~23 KB gzipped). 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.
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.
2849
- `@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.
2950
- `@metta-ts/edsl` is a typed eDSL with term builders, special-form combinators, and a tagged-template surface.
3051
- `@metta-ts/node` has the `metta-ts` CLI, file `import!`, and the worker-thread parallel matcher.
3152
- `@metta-ts/browser` is a browser entry with an in-memory virtual file system for `import!`.
53+
- `@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.
3254
- `@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.
3355

3456
## Install
@@ -42,4 +64,5 @@ npm install -g @metta-ts/node # the metta-ts CLI
4264

4365
- Semantics: [hyperon-experimental](https://github.com/trueagi-io/hyperon-experimental), pinned to commit `3f76dc4`.
4466
- Verified spec and differential oracle: [LeaTTa](https://github.com/MesTTo/LeaTTa) (Lean 4).
67+
- Formal models: [Alloy](https://alloytools.org) specs in [`spec/`](spec/) for the matcher's deep loop rejection and the compiled search's occurs check.
4568
- License: [MIT](LICENSE).

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.6",
3+
"version": "1.0.7",
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.6",
3+
"version": "1.0.7",
44
"license": "MIT",
55
"type": "module",
66
"main": "./dist/index.js",

packages/core/src/alpha.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,19 @@
44

55
// Alpha-equivalence (LeaTTa `Core/Alpha.lean`): two atoms are alpha-equal when canonicalising
66
// their variables to first-occurrence order makes them structurally equal.
7-
import { type Atom, variable, expr, atomEq } from "./atom";
7+
import { type Atom, type ExprAtom, variable, expr, atomEq } from "./atom";
88

9-
function canonicalize(a: Atom, map: Map<string, string>): Atom {
9+
/** Rename every variable in `a` to `%N`, N assigned in first-occurrence order per `map` (shared across a
10+
* whole `canonicalize` call so repeat occurrences of the same variable get the same placeholder). Exported
11+
* for tabling's moded (non-ground) cache keys: two calls that are the same up to which concrete variable
12+
* names they happen to use canonicalize to the same atom, and `map`'s insertion order (`[...map.keys()]`)
13+
* recovers which original name became which `%N`, needed to freshen a cached answer back to a new call's
14+
* actual names. `memo` is a per-call cache (fresh map, so per-call use only): within one canonicalize
15+
* call, a variable already in `map` always renames the same way, so a shared expr node (instantiate
16+
* shares unchanged subterms by reference) needs walking only once, not once per incoming path — the same
17+
* DAG-vs-tree reasoning as instantiate/occursThrough/atomEq elsewhere in this package. */
18+
export function canonicalize(a: Atom, map: Map<string, string>, memo?: Map<Atom, Atom>): Atom {
19+
if (a.ground) return a;
1020
switch (a.kind) {
1121
case "var": {
1222
let c = map.get(a.name);
@@ -16,13 +26,34 @@ function canonicalize(a: Atom, map: Map<string, string>): Atom {
1626
}
1727
return variable(c);
1828
}
19-
case "expr":
20-
return expr(a.items.map((x) => canonicalize(x, map)));
29+
case "expr": {
30+
if (memo === undefined) return canonicalizeExpr(a, map, new Map());
31+
return canonicalizeExpr(a, map, memo);
32+
}
2133
default:
2234
return a;
2335
}
2436
}
2537

38+
function canonicalizeExpr(a: ExprAtom, map: Map<string, string>, memo: Map<Atom, Atom>): Atom {
39+
const cached = memo.get(a);
40+
if (cached !== undefined) return cached;
41+
const its = a.items;
42+
let items: Atom[] | null = null;
43+
for (let i = 0; i < its.length; i++) {
44+
const it = its[i]!;
45+
const r = canonicalize(it, map, memo);
46+
if (items !== null) items.push(r);
47+
else if (r !== it) {
48+
items = its.slice(0, i);
49+
items.push(r);
50+
}
51+
}
52+
const result = items === null ? a : expr(items);
53+
memo.set(a, result);
54+
return result;
55+
}
56+
2657
export function alphaEq(a: Atom, b: Atom): boolean {
2758
return atomEq(canonicalize(a, new Map()), canonicalize(b, new Map()));
2859
}

packages/core/src/atom.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -405,23 +405,48 @@ export function atomVars(a: Atom, out: string[] = []): string[] {
405405
return out;
406406
}
407407

408+
const noVars: readonly string[] = [];
409+
410+
/** Which distinct variable names occur in an expression, first-seen order, cached by object identity.
411+
* Sound forever (not just for one call), unlike a per-call memo: an atom is immutable, so "which vars
412+
* occur in me" cannot change after construction — the same reasoning that already justifies the
413+
* precomputed `ground` flag. `instantiate` shares unchanged subterms by reference, so a rewrite-heavy
414+
* search walks a DAG, not a tree: the same expression object recurs as a stack/continuation is threaded
415+
* through many interpreter steps (scopeVars/chainLiveVars below). Without this cache, `collectVars` re-walks
416+
* a shared node once per occurrence instead of once ever, the same exponential-paths-vs-linear-nodes
417+
* blowup as the (now-fixed) unmemoized `occursThrough` and `instantiate` — this was the dominant cost left
418+
* after fixing those two (77% of CPU on a backward-chaining search that should run in well under a second). */
419+
const exprVarsCache = new WeakMap<ExprAtom, readonly string[]>();
420+
421+
function atomVarsOf(a: Atom): readonly string[] {
422+
if (a.ground) return noVars;
423+
if (a.kind === "var") return [a.name];
424+
if (a.kind !== "expr") return noVars;
425+
const cached = exprVarsCache.get(a);
426+
if (cached !== undefined) return cached;
427+
const out: string[] = [];
428+
const seen = new Set<string>();
429+
for (const it of a.items) {
430+
for (const v of atomVarsOf(it)) {
431+
if (!seen.has(v)) {
432+
seen.add(v);
433+
out.push(v);
434+
}
435+
}
436+
}
437+
exprVarsCache.set(a, out);
438+
return out;
439+
}
440+
408441
/** Collect an atom's variable names into `out`, deduping via the shared `seen` set (O(1) membership instead
409442
* of a linear `out.includes`). Hot accumulation loops (scopeVars/frameVars) reuse one `seen` across many
410443
* atoms so the whole walk stays linear; `atomVars` is the one-shot wrapper that seeds `seen` from `out`. */
411444
export function collectVars(a: Atom, out: string[], seen: Set<string>): void {
412-
if (a.ground) return; // closed term: no variables (ground short-circuit)
413-
switch (a.kind) {
414-
case "var":
415-
if (!seen.has(a.name)) {
416-
seen.add(a.name);
417-
out.push(a.name);
418-
}
419-
break;
420-
case "expr":
421-
for (const it of a.items) collectVars(it, out, seen);
422-
break;
423-
default:
424-
break;
445+
for (const v of atomVarsOf(a)) {
446+
if (!seen.has(v)) {
447+
seen.add(v);
448+
out.push(v);
449+
}
425450
}
426451
}
427452

@@ -443,6 +468,17 @@ export const isVar = (a: Atom): a is VarAtom => a.kind === "var";
443468
export const isSym = (a: Atom): a is SymAtom => a.kind === "sym";
444469
export const isGnd = (a: Atom): a is GndAtom => a.kind === "gnd";
445470

471+
// Cached by the pair's object identity, permanently: `atomEq` is a pure structural comparison of two
472+
// immutable atoms with no external context, so the answer for a given (a, b) pair can never change once
473+
// computed — the same reasoning that already justifies the precomputed `ground` flag and `exprVarsCache`
474+
// above. Without this, comparing two large expressions built independently (so `a === b` never
475+
// short-circuits the top-level call, e.g. `addVarBinding`'s `atomEq(prev, v)` on a rebind) walks the full
476+
// pair every single time they're compared, anywhere in the program. A rewrite-heavy search that keeps
477+
// re-deriving structurally-similar terms (backward chaining over recursive rules) calls `atomEq` on
478+
// overlapping large pairs repeatedly; this was 92-95% of CPU on such a search after the DAG-sharing fixes
479+
// above stopped it from also exhausting memory.
480+
const eqCache = new WeakMap<Atom, WeakMap<Atom, boolean>>();
481+
446482
/** Structural equality. Interned symbols short-circuit to reference identity. */
447483
export function atomEq(a: Atom, b: Atom): boolean {
448484
if (a === b) return true;
@@ -457,12 +493,21 @@ export function atomEq(a: Atom, b: Atom): boolean {
457493
case "expr": {
458494
const bi = (b as ExprAtom).items;
459495
if (a.items.length !== bi.length) return false;
496+
const inner = eqCache.get(a);
497+
const cached = inner?.get(b);
498+
if (cached !== undefined) return cached;
499+
let result = true;
460500
for (let i = 0; i < a.items.length; i++) {
461501
const ai = a.items[i] as Atom;
462502
const bii = bi[i] as Atom;
463-
if (!atomEq(ai, bii)) return false;
503+
if (!atomEq(ai, bii)) {
504+
result = false;
505+
break;
506+
}
464507
}
465-
return true;
508+
if (inner === undefined) eqCache.set(a, new WeakMap([[b, result]]));
509+
else inner.set(b, result);
510+
return result;
466511
}
467512
}
468513
}

0 commit comments

Comments
 (0)