Skip to content

Commit 8d0836a

Browse files
committed
MeTTa TS 1.1.7
1 parent 052b097 commit 8d0836a

15 files changed

Lines changed: 342 additions & 68 deletions

File tree

RELEASE_NOTES.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,42 @@
1+
# MeTTa TS 1.1.7
2+
3+
MeTTa TS 1.1.7 fixes a unification soundness bug in grounded substitution.
4+
5+
## Substitution resolves to a fixpoint
6+
7+
A specialized forward chainer emulating backward chaining on propositional
8+
calculus returned an extra spurious proof (GitHub issue #2). Applying a binding
9+
set as a substitution was single-pass: a variable whose value mentioned another
10+
still-bound variable left that inner variable unresolved, and a later scope
11+
restriction then dropped its binding and lost the derived constraint, so a freed
12+
type unified with the wrong axiom. `instantiate` now resolves to a fixpoint. The
13+
query returns the single proof that Hyperon 0.2.10 and PeTTa (SWI-Prolog) both
14+
produce.
15+
16+
The resolution stays bounded and scalable: a variable chain is followed
17+
iteratively, so a four-million-link chain resolves rather than overflowing the
18+
stack; a name-to-value index makes a long chain linear rather than quadratic;
19+
binding cycles truncate deterministically; and a shared value DAG is resolved
20+
once by object identity, so a term with an exponential number of paths resolves
21+
in constant time with bounded memory.
22+
23+
## Verification
24+
25+
- Full test suite passes, with new fixpoint and backward-chaining regression
26+
tests, each shown to fail on the single-pass version.
27+
- Core/ST conformance is unchanged from 1.1.6: 431 passed, 77 established
28+
failures, 60 manifest expected failures, byte-identical failure set.
29+
- Performance is neutral against 1.1.6 on the nondeterminism benchmark suite.
30+
31+
## Packages
32+
33+
All public packages use version `1.1.7`:
34+
35+
```bash
36+
npm install @metta-ts/core@1.1.7
37+
npm install -g @metta-ts/node@1.1.7
38+
```
39+
140
# MeTTa TS 1.1.6
241

342
MeTTa TS 1.1.6 reduces the cold and loaded cost of compiled nondeterministic

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.1.6",
3+
"version": "1.1.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.1.6",
3+
"version": "1.1.7",
44
"license": "MIT",
55
"type": "module",
66
"main": "./dist/index.js",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
// Regression: a specialized forward chainer emulating backward chaining on propositional calculus
6+
// (GitHub issue #2). A triangular binding built during proof search (a rule variable constrained to a
7+
// ground type through a nonlinear unification) must be resolved to a fixpoint when the rule RHS is
8+
// instantiated. A one-pass substitution left the constraint unresolved in the accumulated type, which
9+
// scope restriction then dropped, admitting a spurious extra solution. The correct answer is a single
10+
// proof, confirmed against Hyperon 0.2.10 and PeTTa (SWI-Prolog), which both yield exactly one solution.
11+
import { describe, it, expect } from "vitest";
12+
import { runProgram } from "./runner";
13+
import { format } from "./parser";
14+
15+
const solutions = (src: string): string[] => {
16+
const r = runProgram(src);
17+
return r[r.length - 1]!.results.map(format);
18+
};
19+
20+
// The propositional-calculus chainer from the issue. `ax₁`/`ax₂` are the K/S axioms, `mpⁱ` inverse
21+
// modus ponens; `obfc` searches for a proof term of a target type up to a depth.
22+
const OFC = `
23+
(= (ofc $depth $hypcnt $tgt (: $x $a))
24+
(if (< 0 $hypcnt)
25+
(if (and (< 0 $depth) (<= $hypcnt $depth))
26+
(ofc-rec (- $depth 1) (- $hypcnt 1) $tgt (: $x $a))
27+
(empty))
28+
(let (: $x $a) $tgt (: $x $a))))
29+
(= (ofc-rec $dd $hd $tgt (: $f (-> (→ $𝜑 (→ $𝜓 $𝜑)) $b)))
30+
(ofc $dd $hd $tgt (: ($f ax₁) $b)))
31+
(= (ofc-rec $dd $hd $tgt (: $f (-> (→ (→ $𝜑 (→ $𝜓 $𝜒)) (→ (→ $𝜑 $𝜓) (→ $𝜑 $𝜒))) $b)))
32+
(ofc $dd $hd $tgt (: ($f ax₂) $b)))
33+
(= (ofc-rec $dd $hd $tgt (: $f (-> $b $c)))
34+
(ofc $dd (+ $hd 2) $tgt (: (mpⁱ $f) (-> (→ $a $b) (-> $a $c)))))
35+
(= (obfc $depth (: $x $a))
36+
(ofc $depth 1 (: $x $a) (: I (-> $a $a))))
37+
`;
38+
39+
describe("backward-chaining unification soundness", () => {
40+
it("proves 𝜑 → 𝜑 with exactly one proof term (issue #2)", () => {
41+
const out = solutions(`${OFC}\n!(obfc 5 (: $x (→ 𝜑 𝜑)))`);
42+
expect(out).toEqual(["(: ((((mpⁱ (mpⁱ I)) ax₂) ax₁) ax₁) (→ 𝜑 𝜑))"]);
43+
});
44+
45+
it("finds no proof below the required depth", () => {
46+
for (const d of [1, 2, 3, 4]) {
47+
expect(solutions(`${OFC}\n!(obfc ${d} (: $x (→ 𝜑 𝜑)))`)).toEqual([]);
48+
}
49+
});
50+
});

packages/core/src/bindings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ export function makeValRel(x: string, a: Atom): ValRel {
162162
return valRel(x, a);
163163
}
164164

165+
/** Build a single variable-equality relation. */
166+
export function makeEqRel(x: string, y: string): EqRel {
167+
return eqRel(x, y);
168+
}
169+
165170
/** Build a binding set from an explicit list of relations (newest-first). */
166171
export function fromRelations(rels: readonly BindingRel[]): Bindings {
167172
return rels;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-FileCopyrightText: 2026 MesTTo
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
// `instantiate` applies a binding set as a substitution resolved to a fixpoint. A binding set from
6+
// unification is triangular (a value can mention another still-bound variable), so a one-pass
7+
// application would leave inner variables unresolved and later lose their constraints. These tests pin
8+
// the transitive resolution and its termination on binding cycles.
9+
import { describe, it, expect } from "vitest";
10+
import { instantiate } from "./instantiate";
11+
import { fromRelations, makeValRel, makeEqRel } from "./bindings";
12+
import { sym, variable, expr, type Atom } from "./atom";
13+
import { format } from "./parser";
14+
15+
const f = (a: Atom): string => format(a);
16+
17+
describe("instantiate transitive (fixpoint) resolution", () => {
18+
it("resolves a variable whose value mentions another bound variable", () => {
19+
const b = fromRelations([
20+
makeValRel("x", expr([sym("f"), variable("y")])),
21+
makeValRel("y", sym("a")),
22+
]);
23+
// One-pass would leave `$y`: `(out (f $y))`. The fixpoint bakes in `$y := a`.
24+
expect(f(instantiate(b, expr([sym("out"), variable("x")])))).toBe("(out (f a))");
25+
});
26+
27+
it("resolves regardless of relation order", () => {
28+
const b = fromRelations([
29+
makeValRel("y", sym("a")),
30+
makeValRel("x", expr([sym("f"), variable("y")])),
31+
]);
32+
expect(f(instantiate(b, expr([sym("out"), variable("x")])))).toBe("(out (f a))");
33+
});
34+
35+
it("resolves through several levels of nested structure", () => {
36+
const b = fromRelations([
37+
makeValRel(
38+
"a",
39+
expr([sym("Arr"), variable("p"), expr([sym("Arr"), variable("q"), variable("p")])]),
40+
),
41+
makeValRel("p", sym("P")),
42+
makeValRel("q", sym("Q")),
43+
]);
44+
expect(f(instantiate(b, expr([sym("t"), variable("a")])))).toBe("(t (Arr P (Arr Q P)))");
45+
});
46+
47+
it("bakes the deep resolution into a rule RHS via the scoping suffix", () => {
48+
// The reduce path instantiates a rule RHS with a per-application suffix. A value pulled from the
49+
// binding set already carries final (suffixed) names and must still be resolved transitively.
50+
const b = fromRelations([
51+
makeValRel("b#1", expr([sym("Pair"), variable("a#0")])),
52+
makeValRel("a#0", expr([sym("S"), variable("p#1")])),
53+
makeValRel("p#1", sym("Z")),
54+
]);
55+
expect(f(instantiate(b, expr([sym("res"), variable("b")]), "#1"))).toBe("(res (Pair (S Z)))");
56+
});
57+
58+
it("terminates on a direct variable-alias cycle, truncating at the variable", () => {
59+
const b = fromRelations([makeValRel("x", variable("y")), makeValRel("y", variable("x"))]);
60+
expect(f(instantiate(b, expr([sym("out"), variable("x")])))).toBe("(out $x)");
61+
});
62+
63+
it("terminates on a structural binding cycle", () => {
64+
const b = fromRelations([
65+
makeValRel("x", expr([sym("f"), variable("y")])),
66+
makeValRel("y", expr([sym("g"), variable("x")])),
67+
]);
68+
expect(f(instantiate(b, expr([sym("out"), variable("x")])))).toBe("(out (f (g $x)))");
69+
});
70+
71+
it("leaves a genuinely unbound variable free", () => {
72+
const b = fromRelations([makeValRel("x", expr([sym("S"), variable("y")]))]);
73+
expect(f(instantiate(b, expr([sym("out"), variable("x")])))).toBe("(out (S $y))");
74+
});
75+
76+
it("ignores eq aliases, resolving only value bindings", () => {
77+
const b = fromRelations([makeEqRel("x", "y"), makeValRel("y", sym("a"))]);
78+
// `instantiate` applies value bindings only (eq aliases are dropped), so `$x` stays free.
79+
expect(f(instantiate(b, expr([sym("out"), variable("x")])))).toBe("(out $x)");
80+
});
81+
});

0 commit comments

Comments
 (0)