2026-09-29 17:48:20 +03:00
|
|
|
/**
|
2026-09-30 03:22:52 +03:00
|
|
|
* PRACTICE-MODULE CORE: chainsForSelection over the STORED chains
|
|
|
|
|
* (chains-primary, owner 2026-09-30: a chain rides along iff every member
|
|
|
|
|
* is selected; nodes in no surviving chain are singletons) and
|
|
|
|
|
* chainCoverage (the set's completeness law: union of the problems' chains
|
|
|
|
|
* must equal the set's chains).
|
2026-09-29 17:48:20 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { describe, it } from "node:test";
|
2026-09-30 03:22:52 +03:00
|
|
|
import type { GraphChain } from "../src/core/contracts.ts";
|
|
|
|
|
import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts";
|
2026-09-29 17:48:20 +03:00
|
|
|
|
|
|
|
|
// ─── fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const n = (id: string) => ({ id });
|
2026-09-30 03:22:52 +03:00
|
|
|
const arg = (id: string, premises: string[], conclusion: string): GraphChain => ({
|
|
|
|
|
id,
|
|
|
|
|
premises,
|
|
|
|
|
conclusion,
|
|
|
|
|
provenance: [],
|
|
|
|
|
createdAt: "2026-09-30T00:00:00.000Z",
|
|
|
|
|
});
|
2026-09-29 17:48:20 +03:00
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
/** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored
|
|
|
|
|
* arguments (each its own arg_ id, conclusion ids may repeat). */
|
|
|
|
|
const diamondNodes = [n("a"), n("b"), n("c"), n("d")];
|
|
|
|
|
const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")];
|
2026-09-29 17:48:20 +03:00
|
|
|
|
|
|
|
|
// ─── chainsForSelection ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("chainsForSelection", () => {
|
2026-09-30 03:22:52 +03:00
|
|
|
it("keeps every fully-selected stored argument, each under its own id", () => {
|
|
|
|
|
assert.deepEqual(chainsForSelection(diamondNodes, diamondArgs), [
|
|
|
|
|
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
|
|
|
|
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
|
|
|
|
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
|
|
|
|
]);
|
2026-09-29 17:48:20 +03:00
|
|
|
});
|
|
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => {
|
|
|
|
|
const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id);
|
2026-09-29 17:48:20 +03:00
|
|
|
assert.ok(!ids.includes("a")); // a is only ever a premise
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("an isolated node becomes a singleton chain", () => {
|
2026-09-30 03:22:52 +03:00
|
|
|
const chains = chainsForSelection([...diamondNodes, n("z")], diamondArgs);
|
2026-09-29 17:48:20 +03:00
|
|
|
assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
it("two independent arguments for one conclusion stay DISTINCT chains", () => {
|
|
|
|
|
// The bijection problem that killed edge-grouping (owner, 2026-09-30):
|
|
|
|
|
// A∧B⇒E and C∧D⇒E must never union into A∧B∧C∧D⇒E.
|
|
|
|
|
const chains = chainsForSelection(
|
|
|
|
|
[n("a"), n("b"), n("c"), n("d"), n("e")],
|
|
|
|
|
[arg("arg_e1", ["a", "b"], "e"), arg("arg_e2", ["c", "d"], "e")],
|
|
|
|
|
);
|
2026-09-29 17:48:20 +03:00
|
|
|
assert.deepEqual(chains, [
|
2026-09-30 03:22:52 +03:00
|
|
|
{ id: "arg_e1", premises: ["a", "b"], conclusion: "e" },
|
|
|
|
|
{ id: "arg_e2", premises: ["c", "d"], conclusion: "e" },
|
2026-09-29 17:48:20 +03:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
it("an argument with ANY member outside the selection is excluded entirely", () => {
|
|
|
|
|
// arg_ab needs b; b is not selected, so the argument is gone and
|
2026-09-29 17:48:20 +03:00
|
|
|
// a (nothing else left) falls back to a singleton.
|
2026-09-30 03:22:52 +03:00
|
|
|
const chains = chainsForSelection([n("a")], [arg("arg_ab", ["a"], "b"), arg("arg_ia", ["in"], "a")]);
|
2026-09-29 17:48:20 +03:00
|
|
|
assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
it("a chain referencing unknown node ids simply never applies", () => {
|
|
|
|
|
const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "b")]);
|
|
|
|
|
assert.deepEqual(chains, [
|
|
|
|
|
{ id: "a", premises: [], conclusion: "a" },
|
|
|
|
|
{ id: "b", premises: [], conclusion: "b" },
|
|
|
|
|
]);
|
2026-09-29 17:48:20 +03:00
|
|
|
});
|
|
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
it("is deterministic — chains sorted by id, premises sorted", () => {
|
2026-09-29 17:48:20 +03:00
|
|
|
const shuffled = chainsForSelection(
|
|
|
|
|
[n("d"), n("c"), n("b"), n("a")],
|
2026-09-30 03:22:52 +03:00
|
|
|
[arg("arg_d", ["c", "b"], "d"), arg("arg_c", ["a"], "c"), arg("arg_b", ["a"], "b")],
|
2026-09-29 17:48:20 +03:00
|
|
|
);
|
2026-09-30 03:22:52 +03:00
|
|
|
assert.deepEqual(shuffled, [
|
|
|
|
|
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
|
|
|
|
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
|
|
|
|
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
2026-09-29 17:48:20 +03:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── chainCoverage ───────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-09-30 03:22:52 +03:00
|
|
|
const diamondChains = [
|
|
|
|
|
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
|
|
|
|
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
|
|
|
|
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-09-29 17:48:20 +03:00
|
|
|
describe("chainCoverage", () => {
|
|
|
|
|
it("complete cover: every chain exercised, no missing", () => {
|
|
|
|
|
const set = {
|
|
|
|
|
chains: diamondChains,
|
2026-09-30 03:22:52 +03:00
|
|
|
problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }],
|
2026-09-29 17:48:20 +03:00
|
|
|
};
|
|
|
|
|
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("missing: a chain no problem exercises", () => {
|
|
|
|
|
const set = {
|
|
|
|
|
chains: diamondChains,
|
2026-09-30 03:22:52 +03:00
|
|
|
problems: [{ chains: ["arg_d"] }],
|
2026-09-29 17:48:20 +03:00
|
|
|
};
|
2026-09-30 03:22:52 +03:00
|
|
|
assert.deepEqual(chainCoverage(set), { missing: ["arg_b", "arg_c"], duplicated: [] });
|
2026-09-29 17:48:20 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("duplicated is informational — a chain exercised twice is allowed", () => {
|
|
|
|
|
const set = {
|
|
|
|
|
chains: diamondChains,
|
2026-09-30 03:22:52 +03:00
|
|
|
problems: [{ chains: ["arg_b", "arg_c", "arg_d"] }, { chains: ["arg_b", "arg_d"] }],
|
2026-09-29 17:48:20 +03:00
|
|
|
};
|
2026-09-30 03:22:52 +03:00
|
|
|
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["arg_b", "arg_d"] });
|
2026-09-29 17:48:20 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("problems claiming chain ids outside the set neither cover nor fail anything", () => {
|
|
|
|
|
const set = {
|
2026-09-30 03:22:52 +03:00
|
|
|
chains: [{ id: "arg_b" }] as Array<{ id: string }>,
|
|
|
|
|
problems: [{ chains: ["arg_b", "ghost"] }],
|
2026-09-29 17:48:20 +03:00
|
|
|
};
|
|
|
|
|
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-29 22:41:52 +03:00
|
|
|
|
|
|
|
|
describe("formatChainLine (the chain table's one-line shape, 2026-09-29)", () => {
|
|
|
|
|
const props = {
|
|
|
|
|
p1: { name: "Premise one" },
|
|
|
|
|
p2: { name: "Premise two" },
|
|
|
|
|
c1: { name: "Conclusion" },
|
|
|
|
|
solo: { name: "Standalone" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("joins sorted premise NAMES by the derivation arrow", () => {
|
|
|
|
|
assert.equal(formatChainLine({ premises: ["p2", "p1"], conclusion: "c1" }, props), "Premise one, Premise two → Conclusion");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a singleton shows its (no premises) marker", () => {
|
|
|
|
|
assert.equal(formatChainLine({ premises: [], conclusion: "solo" }, props), "Standalone (no premises)");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("unknown ids fall back to the raw id (honest, never guessed)", () => {
|
|
|
|
|
assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion");
|
|
|
|
|
});
|
|
|
|
|
});
|