Skip to content

Commit 12baf0f

Browse files
committed
MeTTa TS 1.5.0
1 parent 5870b3e commit 12baf0f

33 files changed

Lines changed: 3783 additions & 226 deletions

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ On the reproducible corpus benchmark
316316
([`corpus-bench.mjs`](packages/node/bench/corpus-bench.mjs) runs the PeTTa
317317
example corpus through both engines and checks each program’s `(test …)`
318318
assertions), MeTTa TS passes all 98 Hyperon-faithful shared programs and is
319-
**faster than PeTTa on every one**, median 1.63x, from pure TypeScript.
319+
**faster than PeTTa on every one**, median 1.55x, from pure TypeScript.
320320

321321
The core list operations run in linear time. `size-atom`, `map-atom`,
322322
`filter-atom`, and `foldl-atom` over N elements are O(N) on a constant native
@@ -331,6 +331,43 @@ bounded-backward-chaining benchmarks are in
331331
release’s notes in [`RELEASE_NOTES.md`](RELEASE_NOTES.md) describe the engine
332332
work behind that version’s numbers.
333333

334+
## Compared with DataScript
335+
336+
DataScript is the standard immutable in-browser database. The two differ first in the data model:
337+
338+
| | DataScript | MeTTa TS |
339+
| -------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
340+
| Data model | Flat entity-attribute-value datoms, a triple store | Metagraph: atoms nest, so a fact can be about another fact |
341+
| Code and data | Queries and rules sit outside the datoms | Rules are atoms in the same space; a program can query and rewrite its own rules |
342+
| Computation | Datalog query and `pull` over relations | Term rewriting with nondeterministic evaluation; querying and computing are one mechanism |
343+
| Types | Attribute schema: cardinality, refs, uniqueness | Optional gradual dependent types: GADTs, dependent types, types as propositions |
344+
| Host code in a query | None | Grounded TypeScript calls inside rules, including async I/O and concurrency |
345+
346+
On DataScript's own workloads (120,000 edge records, five isolated Node processes per engine,
347+
medians, every result cross-checked between engines before timing), MeTTa TS 1.5.0 wins every
348+
declarative query at both tested sizes and under uniform and skewed distributions:
349+
350+
| Workload, 120k records, uniform | DataScript 1.7.8 | MeTTa TS | Winner |
351+
| ------------------------------- | ---------------: | -------: | -------------: |
352+
| Source lookup, declarative | 14.14 ms | 0.011 ms | MeTTa TS 1349x |
353+
| Reverse lookup, declarative | 4.25 ms | 0.010 ms | MeTTa TS 422x |
354+
| Group lookup, declarative | 14.47 ms | 1.72 ms | MeTTa TS 8.4x |
355+
| One-percent range, declarative | 42.32 ms | 2.08 ms | MeTTa TS 20.3x |
356+
| Anchored two-hop join | 26.14 ms | 0.14 ms | MeTTa TS 184x |
357+
| Count all edges | 114.31 ms | 0.002 ms | MeTTa TS |
358+
| Triangle join count | 3101 ms | 1079 ms | MeTTa TS 2.9x |
359+
| Bulk build | 385.9 ms | 322.6 ms | MeTTa TS 1.20x |
360+
| Retained heap after build | 48.8 MiB | 36.5 MiB | MeTTa TS 1.34x |
361+
| Peak process RSS | 2493 MiB | 2037 MiB | MeTTa TS 1.22x |
362+
| Immutable insert, 1000 records | 43.4 ms | 11.6 ms | MeTTa TS 3.75x |
363+
364+
DataScript keeps its direct index APIs on microsecond point reads: entity by id (~1.9x), the
365+
`index_range` seek (its ordered-index specialty, ~118x over MeTTa's declarative range), the
366+
bound-value `datoms` seeks (within about 2x and noisy between sessions), and every cold first
367+
call on point rows, where MeTTa TS pays JIT warmup. Every MeTTa TS number comes from the default
368+
configuration, and each routing path behind them is differential-gated byte-identical to the
369+
reference evaluator, including result order.
370+
334371
## Provenance
335372

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

RELEASE_NOTES.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,60 @@
1+
# MeTTa TS 1.5.0
2+
3+
MeTTa TS 1.5.0 is an engine release. Declarative queries over large fact bases route through new
4+
evaluation paths, each proven byte-identical to the reference path by a differential suite and on
5+
by default with no configuration. On DataScript's own browser-database workloads, MeTTa TS now
6+
wins every declarative query at both tested sizes and distributions; the README carries the
7+
comparison table.
8+
9+
## Query routing
10+
11+
- Anchored acyclic conjunctions run as a source-ordered indexed nested loop instead of the
12+
worst-case-optimal join, when the first goal is anchored by a ground argument and every later
13+
goal connects through exactly one shared variable over ground, duplicate-free facts. The
14+
anchored two-hop join over 120,000 facts answers in 0.14 ms.
15+
- Single-pattern numeric range templates, the `(if (>= $x lo) (if (< $x hi) R (empty)) (empty))`
16+
shape, enumerate an ordered numeric column slice instead of scanning the functor's whole
17+
bucket: a one-percent range over 120,000 facts answers in 2.1 ms (a full scan took 2343 ms).
18+
- A public-entry bare `(match &self pattern template)` answers straight from its match plan,
19+
skipping the interpreter's generator driver, worklist, and per-result reduce probe when those
20+
are provably no-ops. An anchored single-row lookup drops from 10.1 us to 5.5 us, and the warm
21+
indexed source lookup reaches parity with DataScript's direct `datoms` seek at 120,000 records.
22+
- Normal-form ground match results are pre-marked evaluated, so consumers skip the redundant
23+
reduce probe on first visit.
24+
25+
## The compiled search
26+
27+
The zero-allocation trail search now serves rule groups that query spaces. Space-match goals
28+
compile into the clause skeletons; at a match goal the trail run resolves the call through its
29+
cells, asks the immutable matcher, advances the fresh-variable counter by exactly the interpreted
30+
match's cost, and binds each solution onto the cells with trail undo between candidates, like a
31+
clause dispatch. The JIT declines match-bearing groups, which keep the immutable engine as a
32+
runtime fallback. Alongside this, merge results in the compiled search check for binding loops
33+
incrementally (a merge only prepends relations onto its base, so the cycle search roots at the
34+
prepended variables), and the instantiation memos allocate lazily. The nilbc backward chainer,
35+
the workload these serve, drops from 918 ms to 485 ms.
36+
37+
## Memory and build
38+
39+
Bulk static loads sweep large all-ground flat functors into a compact interned column store. The
40+
object forest and its per-argument postings are released; candidates decode on demand and sorted
41+
columns serve equality and range probes. Retained heap after building 120,000 facts is 36.5 MiB
42+
against DataScript's 48.8 MiB, peak process RSS 2037 MiB against 2493 MiB, and counting a swept
43+
functor's facts is a per-arity tally (0.002 ms). Numeric ground interning keys int and float
44+
pools by number instead of by string, buildEnv pre-plans which functors the sweep will compact
45+
and skips their throwaway argument postings, and the flat store's probe loop no longer clones:
46+
encoding 120,000 facts drops from 428 ms to 151 ms and buildEnv from 751 ms to 224 ms, taking
47+
the full build past DataScript's (322.6 ms against 385.9 ms, uniform).
48+
49+
## Verification
50+
51+
The PeTTa corpus gate holds and strengthens: 105 examples, 98 passing on both engines, no example
52+
changed status, and no shared row slower than PeTTa. Median speedup 1.55x, geomean 1.61x; nilbc,
53+
which had drifted to a loss under this session's environment, reads 1.54x after the trail match
54+
bridge, and peano rises to 5.19x. The full workspace gate is green: 940 core tests across 77
55+
files, including the seven differential suites, the 23-file byte-identical oracle, typecheck,
56+
lint, and format.
57+
158
# MeTTa TS 1.4.0
259

360
MeTTa TS 1.4.0 replaces the two command-line tools with one `metta` command and

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

packages/core/src/bindings.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,29 +90,50 @@ const noSucc: readonly string[] = [];
9090
* overflow, and 3-colours each variable so it is visited once: O(vars + total value size), like Hyperon's
9191
* bitset walk. `atomVars` is cached by object identity, so a DAG-shared value is scanned once. */
9292
export function hasLoop(b: Bindings): boolean {
93+
const quick = relQuickLoop(b, b.length);
94+
if (quick !== undefined) return quick;
95+
const vals = firstVals(b);
96+
return loopReachableFrom(vals, vals.keys());
97+
}
98+
99+
// The self-relation quick checks over the first `count` relations: a self-equality or self-value is a
100+
// loop outright (true); no non-ground value among them means no graph edge to walk (false); otherwise
101+
// the colour DFS must decide (undefined).
102+
function relQuickLoop(b: Bindings, count: number): boolean | undefined {
93103
let needsGraph = false;
94-
for (const r of b) {
104+
for (let i = 0; i < count; i++) {
105+
const r = b[i]!;
95106
if (r.tag === "eq") {
96107
if (r.x === r.y) return true;
97108
} else {
98109
if (r.a.kind === "var" && r.a.name === r.x) return true;
99110
if (!r.a.ground) needsGraph = true;
100111
}
101112
}
102-
if (!needsGraph) return false;
113+
return needsGraph ? undefined : false;
114+
}
115+
116+
// First (most recently prepended) value per variable, matching `lookupVal`'s precedence.
117+
function firstVals(b: Bindings): Map<string, Atom> {
103118
const vals = new Map<string, Atom>();
104119
for (const r of b) if (r.tag === "val" && !vals.has(r.x)) vals.set(r.x, r.a);
105-
// 3-colour iterative DFS over the variable graph. A variable's successors are the distinct variables in
106-
// its bound value; a grey (on the current path) revisit is a back-edge, i.e. a cycle. color: 1 = grey
107-
// (on path), 2 = black (done); absent = white (unvisited).
120+
return vals;
121+
}
122+
123+
// 3-colour iterative DFS over the variable graph, rooted at `seeds`. A variable's successors are the
124+
// distinct variables in its bound value; a grey (on the current path) revisit is a back-edge, i.e. a
125+
// cycle. color: 1 = grey (on path), 2 = black (done); absent = white (unvisited). Seeds bound to a
126+
// ground value (or unbound) root nothing.
127+
function loopReachableFrom(vals: ReadonlyMap<string, Atom>, seeds: Iterable<string>): boolean {
108128
const color = new Map<string, 1 | 2>();
109129
const succ = (x: string): readonly string[] => {
110130
const v = vals.get(x);
111131
return v === undefined || v.ground ? noSucc : atomVars(v);
112132
};
113133
const stack: Array<{ v: string; kids: readonly string[]; i: number }> = [];
114-
for (const [x, a] of vals) {
115-
if (a.ground) continue;
134+
for (const x of seeds) {
135+
const a = vals.get(x);
136+
if (a === undefined || a.ground) continue;
116137
if (color.get(x) === 2) continue;
117138
color.set(x, 1);
118139
stack.push({ v: x, kids: succ(x), i: 0 });
@@ -134,6 +155,41 @@ export function hasLoop(b: Bindings): boolean {
134155
return false;
135156
}
136157

158+
/** `hasLoop` for a `merge` result whose base was already loop-free.
159+
*
160+
* `merge` builds every output by prepending relations onto its base: `addVarBinding` prepends a value
161+
* relation only for a variable the current set leaves unbound (a bound variable either no-ops or goes
162+
* through `reconcile`, whose own extensions bottom out in the same prepend-or-no-op steps), and
163+
* `addVarEquality` prepends an equality. The base is therefore a reference-identical suffix of the
164+
* result, no prepend can shadow a base variable's first value, and the base's first-value graph embeds
165+
* unchanged in the result's. A cycle in the result must then pass through a prepended value binding,
166+
* so running the colour DFS only from the prepended variables is answer-equivalent to the full scan.
167+
* The self-relation quick checks run on the prepended prefix alone for the same reason. Anything that
168+
* is not a reference-identical suffix extension falls back to the full `hasLoop`.
169+
*
170+
* Callers must pass the exact base the result was merged from, and that base must itself be loop-free;
171+
* the equivalence fuzz in match.test.ts pins this predicate to `hasLoop` over merge outputs. */
172+
export function hasLoopFromBase(merged: Bindings, base: Bindings): boolean {
173+
const added = merged.length - base.length;
174+
if (merged === base || added === 0) return false; // no-op merge: the base was loop-free
175+
if (
176+
added < 0 ||
177+
(base.length > 0 &&
178+
(merged[added] !== base[0] || merged[merged.length - 1] !== base[base.length - 1]))
179+
)
180+
return hasLoop(merged); // not a suffix extension of `base`: no incremental argument
181+
// Quick checks over the prepended prefix only; all-ground prepends add no edges, and the base's
182+
// graph is acyclic by precondition.
183+
const quick = relQuickLoop(merged, added);
184+
if (quick !== undefined) return quick;
185+
const seeds: string[] = [];
186+
for (let i = 0; i < added; i++) {
187+
const r = merged[i]!;
188+
if (r.tag === "val" && !r.a.ground) seeds.push(r.x);
189+
}
190+
return loopReachableFrom(firstVals(merged), seeds);
191+
}
192+
137193
/** Bind `$x ← a`, dropping any previous value binding for `$x`. Raw: no consistency check. */
138194
export function addValRaw(b: Bindings, x: string, a: Atom): Bindings {
139195
const first = firstValIndex(b, x);

packages/core/src/builtins.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ function arithBin(
317317
}
318318

319319
/** Three-way compare: exact for two Ints (promoting to bigint as needed), f64 otherwise. */
320-
function compareNumbers(a: Atom, b: Atom): number | undefined {
320+
export function compareNumbers(a: Atom, b: Atom): number | undefined {
321321
const ai = asIntVal(a);
322322
const bi = asIntVal(b);
323323
if (ai !== undefined && bi !== undefined) {

0 commit comments

Comments
 (0)