@polycode-projects/the-mechanical-code-talker 1.8.12 → 1.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -152,6 +152,50 @@ export function correctMisspellings(text) {
152
152
  return String(text || "").replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
153
153
  }
154
154
 
155
+ /** FILLER_WORDS (ask-vocab.mjs), pre-built into one alternation once at module
156
+ * load — same "build the regex from the table once, reuse it" discipline as
157
+ * every other closed-vocabulary regex in this file (CONTRACTION_RE,
158
+ * MISSPELLING_RE, …), rather than rebuilding it inside stripFillerWords on
159
+ * every call. */
160
+ const FILLER_RE = FILLER_WORDS.length
161
+ ? new RegExp(
162
+ "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b\\s*,?",
163
+ "gi",
164
+ )
165
+ : null;
166
+
167
+ /** Just the filler/politeness-word strip (ask-vocab.mjs's FILLER_WORDS),
168
+ * standalone — the same "one piece of the pipeline, not the whole thing"
169
+ * need correctMisspellings above exists for: a caller with its own
170
+ * closed anchor regex (chat.mjs's moduleOrientLane matches "^what does …
171
+ * do$" itself) wants leading/embedded filler cleared WITHOUT running
172
+ * normalizeQuery's other, more invasive rewrites (contraction expansion,
173
+ * preamble/subordination/conditional frame rewrites) that can restructure
174
+ * the sentence in ways its own shape-matcher never expects — the exact
175
+ * risk correctMisspellings' own docblock describes for the same reason.
176
+ *
177
+ * A comma trailing the filler word/phrase itself (across any whitespace,
178
+ * e.g. "um, like," or "quickly,") is swallowed WITH it — leading
179
+ * conversational filler is routinely comma-spliced onto the real question
180
+ * ("so um, like, what does X do exactly?"), and stripping only the word
181
+ * leaves the comma stranded as parse-corrupting punctuation debris (a
182
+ * leading "," defeats every `^`-anchored template downstream, both parse
183
+ * strategies and lane-local regexes alike) — the same species of
184
+ * leftover-debris bug the preamble frames elsewhere in this file
185
+ * (GREETING_PREAMBLE_RE et al.) already avoid by consuming their own
186
+ * delimiter. The trailing `,?` is safe to make unconditional (unlike the
187
+ * preamble frames, no delimiter-required gate is needed): it only ever
188
+ * fires immediately after a MATCHED filler word, so it can only ever eat a
189
+ * comma that was already glued to filler, never a comma separating real
190
+ * content ("the modules, and the classes" — that comma sits after the
191
+ * content word "modules", not after any filler word). Pure, idempotent;
192
+ * unmatched text passes through byte-unchanged. */
193
+ export function stripFillerWords(text) {
194
+ let q = String(text || "");
195
+ if (FILLER_RE) q = q.replace(FILLER_RE, " ");
196
+ return q.replace(/\s+/g, " ").trim();
197
+ }
198
+
155
199
  // ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
156
200
  // conversational wrapping a developer puts AROUND a real question: a greeting
157
201
  // lead-in with a delimiter ("hey there, quick question - …"), a thanks lead-in
@@ -634,13 +678,7 @@ export function normalizeQuery(text) {
634
678
  // leaving punctuation/clause debris that poisons the parse.
635
679
  q = applySubordinationFrames(q);
636
680
  q = applyConditionalFrames(q);
637
- if (FILLER_WORDS.length) {
638
- const fillerRe = new RegExp(
639
- "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
640
- "gi",
641
- );
642
- q = q.replace(fillerRe, " ");
643
- }
681
+ q = stripFillerWords(q);
644
682
  // emphatic trailing punctuation (item 10): a run of terminal "?" collapses to
645
683
  // one — the anchored templates consume exactly one optional trailing "?", so
646
684
  // "…walk.mjs??" otherwise leaks a stray "?" into the captured object term (the
@@ -1162,6 +1162,18 @@ export function normFactTerm(t) {
1162
1162
  // every seeded fact — the golden-equivalence test pins the two paths together.
1163
1163
  const factIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
1164
1164
 
1165
+ /** Content-address a fact's id from its (subject, predicate, object) WITHOUT
1166
+ * writing it — the SAME normalize+NUL-join+hash contract appendFact/
1167
+ * appendFacts use internally (factIdFor, above). Exposed so a caller that
1168
+ * needs to name a premise's or a not-yet-written conclusion's id (e.g.
1169
+ * syllogise.mjs's justification-tracking retraction machinery, PLAN_SYLLOGIST.md
1170
+ * §3) can compute it deterministically without an extra read — ids are
1171
+ * content-addressed, never sequence-assigned, so this is safe to call
1172
+ * before, instead of, or in place of an actual append. Pure, no I/O. */
1173
+ export function factIdForTriple(subject, predicate, object) {
1174
+ return factIdFor(normFactTerm(subject), normText(predicate), normFactTerm(object));
1175
+ }
1176
+
1165
1177
  /** Append one grammar-derived OWL triple, RDF-reified: a `Fact` individual
1166
1178
  * carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
1167
1179
  * Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
@@ -1238,6 +1250,14 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
1238
1250
  * appendFact's own entailed-hook passthrough, batched: syllogise.mjs's
1239
1251
  * materializing pass is this function's main caller, so this is the write
1240
1252
  * path a rule's conclusion trust actually rides (recomputeFactTrust, above).
1253
+ * A fact may also carry `justification` (optional, array of premise fact
1254
+ * ids — PLAN_SYLLOGIST.md §3's persisted-justification step): stored
1255
+ * verbatim as `mgx:factJustification` (space-joined; fact ids never contain
1256
+ * a space), last-write-wins per id (a re-derivation via a DIFFERENT premise
1257
+ * pair, after the original was retracted and this fact re-earned its place
1258
+ * some other way, should overwrite the stale justification, not keep it) —
1259
+ * never written at all for a plain taught/asserted fact (`justification`
1260
+ * omitted), which stays byte-identical to before this field existed.
1241
1261
  * Returns { ids, appended, skipped } — ids one per applied fact (in order),
1242
1262
  * appended = ids.length, skipped = malformed count. */
1243
1263
  export async function appendFacts(dir, facts) {
@@ -1258,6 +1278,7 @@ export async function appendFacts(dir, facts) {
1258
1278
  quantifier: normText(f?.quantifier),
1259
1279
  premiseTrusts: Array.isArray(f?.premiseTrusts) ? f.premiseTrusts : undefined,
1260
1280
  ruleConfidence: typeof f?.ruleConfidence === "number" ? f.ruleConfidence : undefined,
1281
+ justification: Array.isArray(f?.justification) ? f.justification.filter(Boolean) : undefined,
1261
1282
  });
1262
1283
  }
1263
1284
  const ids = [];
@@ -1290,6 +1311,7 @@ export async function appendFacts(dir, facts) {
1290
1311
  ...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
1291
1312
  ...(f.tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: f.tokens.join(" ") }] : []),
1292
1313
  ...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
1314
+ ...(f.justification && f.justification.length ? [{ prop: "mgx:factJustification", key: "justification", value: f.justification.join(" ") }] : []),
1293
1315
  ],
1294
1316
  };
1295
1317
  // Upsert into BOTH the array (replace-in-place keeps order) and the index.
@@ -1664,6 +1686,7 @@ export function readFactRows(memory) {
1664
1686
  const sourceTypes = sourceIds
1665
1687
  .map((id) => (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value)
1666
1688
  .filter(Boolean);
1689
+ const justificationRaw = get("justification");
1667
1690
  rows.push({
1668
1691
  id: ind.id,
1669
1692
  subject: get("subject"), predicate: get("predicate"), object: get("object"),
@@ -1671,11 +1694,51 @@ export function readFactRows(memory) {
1671
1694
  quantifier: get("quantifier"), // "" unless a plural class-membership teach set one (Feature A pt.3)
1672
1695
  sourceIds, sourceTypes,
1673
1696
  trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
1697
+ // [] unless a rule persisted its premise fact ids (PLAN_SYLLOGIST.md §3's
1698
+ // justification-tracking step — scm-sco only, today; see syllogise.mjs).
1699
+ justification: justificationRaw ? justificationRaw.split(" ").filter(Boolean) : [],
1674
1700
  });
1675
1701
  }
1676
1702
  return rows;
1677
1703
  }
1678
1704
 
1705
+ /**
1706
+ * Retract Fact individuals by id — a real DELETE, the mechanism `syllogise.mjs`'s
1707
+ * own header comment has always PROMISED ("fully RETRACTABLE by provenance when
1708
+ * the source graph moves") but that, until PLAN_SYLLOGIST.md §3's retraction
1709
+ * build, nothing in this file actually implemented: un-believing something used
1710
+ * to mean re-running the whole batch pass and hoping dedup naturally sorted it
1711
+ * out, with no targeted removal at all. Drops each matching Fact individual and
1712
+ * scrubs any edge group (`statedBy`, etc.) that referenced it as subject OR
1713
+ * object, so no dangling edge survives the delete — then recounts classes once.
1714
+ * A Source left with zero remaining statedBy edges is NOT itself deleted (an
1715
+ * orphaned Source individual is harmless — it materialises nothing and costs
1716
+ * nothing to leave — so this stays a pure, minimal retraction, not a GC pass).
1717
+ * Ids that don't resolve to a live Fact are silently skipped (an idempotent,
1718
+ * honest no-op — never an error: a caller may retry a retraction against a
1719
+ * concurrently-mutated store). Returns { removed } — the ids ACTUALLY deleted,
1720
+ * a possibly-smaller set than the input. */
1721
+ export async function removeFacts(dir, ids) {
1722
+ const idSet = new Set((ids || []).filter(Boolean));
1723
+ const removed = [];
1724
+ if (!idSet.size) return { removed };
1725
+ await mutateMemory(dir, (payload) => {
1726
+ payload.individuals = (payload.individuals || []).filter((ind) => {
1727
+ if (ind?.class === FACT_CLASS && idSet.has(ind.id)) { removed.push(ind.id); return false; }
1728
+ return true;
1729
+ });
1730
+ if (!removed.length) return; // honest no-op — nothing matched, no write needed beyond this
1731
+ const removedSet = new Set(removed);
1732
+ for (const group of payload.objectProperties || []) {
1733
+ const before = group.examples || [];
1734
+ group.examples = before.filter((e) => !removedSet.has(e?.subject) && !removedSet.has(e?.object));
1735
+ group.count = group.examples.length;
1736
+ }
1737
+ recountClasses(payload);
1738
+ });
1739
+ return { removed };
1740
+ }
1741
+
1679
1742
  /** The trust floor a fact must clear before a differing object counts as a real
1680
1743
  * contradiction (below it the fact is too weak to contradict anything). */
1681
1744
  export const CONTRADICTION_TRUST_FLOOR = 0.5;
@@ -0,0 +1,131 @@
1
+ // paraphrase.mjs — PLAN_BREADTH_FIRST_NLU.md (c) / ROADMAP.md "Ambition":
2
+ // "Paraphrase alongside the original, verified, never instead of it." A
3
+ // surface-realization variant of an isa-family (`rdfs:subClassOf`) teach
4
+ // confirmation, shown NEXT TO the original literal confirmation, never
5
+ // replacing it — and never shown at all unless its accuracy is checked by
6
+ // running tmct's OWN deterministic inference machinery (`src/syllogise.mjs`)
7
+ // against both the original and the paraphrase.
8
+ //
9
+ // Scope, deliberately narrow: isa-family (`rdfs:subClassOf`) facts only — the
10
+ // one predicate family `syllogise.mjs`'s `deriveSubClassClosure` actually
11
+ // reasons over. tmct's other taught predicate families (someValuesFrom,
12
+ // disjointWith, cardinality) have their own entailment rules in syllogise.mjs
13
+ // too, but this pass covers the single most common teach shape ("X is a kind
14
+ // of Y") the plan's own worked example (ROADMAP.md's Ambition section, the
15
+ // `PLAN_BREADTH_FIRST_NLU.md` §8 canonical example) already anchors on;
16
+ // widening to the other predicate families is a natural, separately-scoped
17
+ // follow-on once this shape is proven live.
18
+ //
19
+ // The generator and the recognizer are a MATCHED PAIR by construction — every
20
+ // template `paraphraseSubClass` can produce has a corresponding branch in
21
+ // `recoverSubClassTriple` that parses it back to exactly the same
22
+ // {subject, object} pair. This is a CLOSED set (never open-ended NLP), so
23
+ // recognition is exact, not fuzzy. "Verified" means: re-derive the
24
+ // `rdfs:subClassOf` transitive closure (the real conclusions this fact would
25
+ // license, via `deriveSubClassClosure`) once seeded with the ORIGINAL triple
26
+ // and once seeded with the triple RECOVERED FROM the paraphrase, over the
27
+ // SAME existing taught edges — the two closures must be identical (both
28
+ // derived edge SETS byte-for-byte equal). A generator bug that silently
29
+ // swapped subject/object (a real risk for a passive-voice template) would be
30
+ // caught here even in a graph with no other taught facts at all, because a
31
+ // swapped pair changes the closure's own subject/object roles the moment any
32
+ // OTHER edge touches either term — exactly the "must entail the same
33
+ // conclusions, neither may contradict the other" check the Ambition asks for,
34
+ // not a shallower string-equality stand-in for it.
35
+
36
+ import { deriveSubClassClosure } from "./syllogise.mjs";
37
+ import { normFactTerm } from "./memory/core.mjs";
38
+ import { fnv1aHex } from "./hash.mjs";
39
+
40
+ // Every template reads "SUBJECT ⊑ OBJECT" left to right — no passive/reordered
41
+ // form is offered, since a reordered form is exactly the shape most likely to
42
+ // invert subject/object under a naive regex recognizer; keeping the pair's
43
+ // left-to-right order fixed across every template keeps the recognizer trivial
44
+ // AND correct by construction, rather than needing the closure check to catch
45
+ // a bug the generator could have avoided entirely.
46
+ const articleFor = (word) => (/^[aeiou]/i.test(String(word || "")) ? "an" : "a");
47
+ const SUBCLASS_TEMPLATES = [
48
+ (s, o) => `${s} is a kind of ${o}`,
49
+ (s, o) => `${s} is a type of ${o}`,
50
+ (s, o) => `every ${s} is ${articleFor(o)} ${o}`,
51
+ (s, o) => `${s} counts as ${articleFor(o)} ${o}`,
52
+ ];
53
+
54
+ // One regex per template above, in the SAME order — deliberately paired by
55
+ // index rather than derived/inverted from the generator, so a change to one
56
+ // side can never silently desync from the other without a test catching it
57
+ // (see paraphrase.test.mjs's own round-trip check over every template).
58
+ const SUBCLASS_RECOGNIZERS = [
59
+ /^(.+?)\s+is\s+a\s+kind\s+of\s+(.+)$/i,
60
+ /^(.+?)\s+is\s+a\s+type\s+of\s+(.+)$/i,
61
+ /^every\s+(.+?)\s+is\s+an?\s+(.+)$/i,
62
+ /^(.+?)\s+counts\s+as\s+an?\s+(.+)$/i,
63
+ ];
64
+
65
+ /** Deterministic template pick — same (subject, object) always picks the same
66
+ * template (pinnable in tests), spread across the table by a pure hash, never
67
+ * Math.random/Date.now (same discipline as answer-variants.mjs's pickPhrase). */
68
+ function pickTemplateIndex(subject, object) {
69
+ const h = fnv1aHex(`${subject}\0${object}`);
70
+ return parseInt(h.slice(0, 8), 16) % SUBCLASS_TEMPLATES.length;
71
+ }
72
+
73
+ /** Generate a paraphrase of the `rdfs:subClassOf` confirmation "SUBJECT is a
74
+ * kind of OBJECT" for the given (already-normalized-or-not) subject/object —
75
+ * never null, always one of the closed templates. Rule/template-based only,
76
+ * no LLM, matching every other generator in this product. */
77
+ export function paraphraseSubClass(subject, object) {
78
+ const idx = pickTemplateIndex(subject, object);
79
+ return SUBCLASS_TEMPLATES[idx](subject, object);
80
+ }
81
+
82
+ /** Recover {subject, object} from a paraphrase this module itself generated
83
+ * (or any text matching one of the closed templates) — null if it matches
84
+ * none of them. Never a fuzzy/NLP parse; a plain closed-set regex match, the
85
+ * exact inverse of paraphraseSubClass by construction. */
86
+ export function recoverSubClassTriple(text) {
87
+ const s = String(text || "").trim();
88
+ for (const re of SUBCLASS_RECOGNIZERS) {
89
+ const m = s.match(re);
90
+ if (m) return { subject: m[1].trim(), object: m[2].trim() };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** The actual verification step: does re-deriving the rdfs:subClassOf closure
96
+ * from the paraphrase's own recovered triple produce the SAME entailed edge
97
+ * set as deriving it from the original triple, over the same pre-existing
98
+ * taught edges? `existingEdges` is `[[a,b], …]` — the SAME already-normalized
99
+ * pair-list shape `deriveSubClassClosure` itself takes (subClassEdges read
100
+ * straight off taught Fact rows elsewhere in this codebase). Returns
101
+ * `{verified, closure}` — `closure` is the original triple's own derived
102
+ * conclusions (handed back so a caller can show/log them), `verified` is
103
+ * false when the paraphrase's recovered triple doesn't reparse, doesn't match
104
+ * the original's normalized (subject, object), or derives a different
105
+ * closure — any one of those means the paraphrase must NOT be shown. */
106
+ export function verifySubClassParaphrase(subject, object, paraphraseText, existingEdges = []) {
107
+ const origSubj = normFactTerm(subject);
108
+ const origObj = normFactTerm(object);
109
+ const recovered = recoverSubClassTriple(paraphraseText);
110
+ const closureOf = (a, b) => deriveSubClassClosure([...existingEdges, [a, b]]);
111
+ const keyOf = (edges) => edges.map((e) => `${e.subject}\0${e.object}`).sort().join("|");
112
+ const closure = closureOf(origSubj, origObj);
113
+ if (!recovered) return { verified: false, closure };
114
+ const recSubj = normFactTerm(recovered.subject);
115
+ const recObj = normFactTerm(recovered.object);
116
+ if (recSubj !== origSubj || recObj !== origObj) return { verified: false, closure };
117
+ const paraphraseClosure = closureOf(recSubj, recObj);
118
+ const verified = keyOf(closure) === keyOf(paraphraseClosure);
119
+ return { verified, closure };
120
+ }
121
+
122
+ /** Top-level convenience: generate + verify in one call. Returns the
123
+ * paraphrase text ONLY when verified (never an unverified paraphrase);
124
+ * `null` otherwise — the caller shows nothing extra rather than something
125
+ * unchecked, keeping "verified, never instead of the original" strict even
126
+ * on a genuine (unexpected) verification failure. */
127
+ export function paraphraseVerifiedSubClass(subject, object, existingEdges = []) {
128
+ const text = paraphraseSubClass(subject, object);
129
+ const { verified } = verifySubClassParaphrase(subject, object, text, existingEdges);
130
+ return verified ? text : null;
131
+ }
@@ -25,10 +25,6 @@
25
25
  // chain. Bounded depth + a hard step counter GUARANTEE termination — no
26
26
  // unbounded search can ever wedge the caller (the harness also caps us).
27
27
  //
28
- // THE OPEN-WORLD BOUNDARY, named honestly: novelty the declared methods + operators
29
- // do not cover (a sub-goal that resolves to nothing, a connective we do not model)
30
- // is REFUSED/ESCALATED, not guessed. Sound/complete is claimed only INSIDE the
31
- // declared world.
32
28
 
33
29
  import { resolveOne, extractEntity } from "./resolver.mjs";
34
30
 
package/src/server.mjs CHANGED
@@ -162,7 +162,14 @@ export const TOOLS = [
162
162
  },
163
163
  ];
164
164
 
165
- async function loadGraph(config, source) {
165
+ // Exported (was module-private) so chat.mjs's compare lane can load the SAME
166
+ // graph dispatchTool's own tools load — no new loading path, just direct reuse
167
+ // of the existing config -> source.fetchEntities -> parseEntities chain, for
168
+ // the case where runAsk's own `graph` param is null (the common case; it's
169
+ // only preloaded when a caller already has one in hand — see runAsk's own
170
+ // `if (graph && ...)` / dispatchTool("tmct_ask", …) split just above the
171
+ // compare lane's call site for the existing precedent).
172
+ export async function loadGraph(config, source) {
166
173
  const payload = await source.fetchEntities(config);
167
174
  const graph = parseEntities(payload);
168
175
  if (!graph.individuals.length) {
@@ -54,8 +54,7 @@ export async function readSpanSafe({ readFile, repoRoot, path, start, end, maxLi
54
54
  // Normalize repoRoot to absolute here too (defense in depth) — resolve(repoRoot, path)
55
55
  // is always absolute, so comparing it against a RELATIVE repoRoot would make this guard
56
56
  // reject every read, not just traversal attempts (the actual bug this normalization
57
- // fixes; callers should already pass an absolute repoRoot via src/config.mjs, but this
58
- // function is the real security boundary and must not depend on that).
57
+ // fixes; callers should already pass an absolute repoRoot via src/config.mjs.
59
58
  const root = resolve(repoRoot);
60
59
  const resolved = resolve(root, path);
61
60
  if (resolved !== root && !resolved.startsWith(root + sep)) {
package/src/syllogise.mjs CHANGED
@@ -101,7 +101,7 @@
101
101
  // exist, so a general "no" is provable without needing one
102
102
  // (`proveMaxCardinalityZeroDenial`).
103
103
 
104
- import { loadMemory, appendFacts, readFactRows, normFactTerm } from "./memory/core.mjs";
104
+ import { loadMemory, appendFacts, readFactRows, normFactTerm, factIdForTriple, removeFacts } from "./memory/core.mjs";
105
105
 
106
106
  /** scm-sco: the subClassOf-transitivity rule, and the provenance tag its
107
107
  * conclusions carry. */
@@ -1035,6 +1035,18 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
1035
1035
  ...scmDerived.map((d) => ({
1036
1036
  subject: d.subject, predicate: SUBCLASS_PREDICATE, object: d.object,
1037
1037
  provenance: ENTAILED_PROVENANCE,
1038
+ // PLAN_SYLLOGIST.md §3's persisted-justification step, scm-sco only: the
1039
+ // two premise fact ids THIS conclusion actually rode (a⊑b, b⊑c) — ids
1040
+ // are content-addressed (factIdForTriple/memory/core.mjs), so this works
1041
+ // whether the premise is a stated fact or another entailment this SAME
1042
+ // pass just derived a round earlier (its id is predictable before it's
1043
+ // even written). Read back by retractSubClassOf (below) to find every
1044
+ // entailment a retracted premise could have supported, without a
1045
+ // whole-graph re-scan.
1046
+ justification: [
1047
+ factIdForTriple(d.subject, SUBCLASS_PREDICATE, d.via),
1048
+ factIdForTriple(d.via, SUBCLASS_PREDICATE, d.object),
1049
+ ],
1038
1050
  })),
1039
1051
  ...caxDerived.map((d) => ({
1040
1052
  subject: d.subject, predicate: TYPE_PREDICATE, object: d.object,
@@ -1137,6 +1149,153 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
1137
1149
  return { derived: written, count: written.length, budget, depth, truncated: written.length >= budget };
1138
1150
  }
1139
1151
 
1152
+ /** True when EVERY provenance tag on a fact's (possibly " | "-joined) union is
1153
+ * an `entailed:*` tag — i.e. the fact has never been independently stated or
1154
+ * taught, only ever derived. A fact first entailed, then LATER also directly
1155
+ * taught (same (s,p,o) → same id → provenance union, appendFact's own upsert
1156
+ * contract), is NOT purely entailed any more — `retractSubClassOf`'s cascade
1157
+ * must never delete it just because its now-stale justification broke; the
1158
+ * taught half is a real, independent reason to keep believing it (the trust-
1159
+ * tier concern PLAN_SYLLOGIST.md §3 names: "must never touch a higher-trust
1160
+ * taught-only derivation"). */
1161
+ function isPurelyEntailed(provenance) {
1162
+ const tags = String(provenance || "").split(" | ").filter(Boolean);
1163
+ return tags.length > 0 && tags.every((t) => t.startsWith("entailed:"));
1164
+ }
1165
+
1166
+ /**
1167
+ * PLAN_SYLLOGIST.md §3's first real, scoped retraction slice: JTMS-style
1168
+ * dependency-directed removal, for scm-sco ONLY (subClassOf transitivity —
1169
+ * this file's simplest rule, and §3's own worked example: "a premise later
1170
+ * disappears, what else must be un-believed?"). Deliberately NOT the fuller
1171
+ * ATMS §3 also sketches (tracking every alternate premise-SET per fact, "a
1172
+ * further, NOT-currently-planned step") — this tracks exactly ONE
1173
+ * justification per entailed fact (the premise pair it actually rode, set at
1174
+ * write time — syllogise()'s own toWrite mapping, above): the JTMS-shaped
1175
+ * step §3 names as missing today ("a JTMS-shaped single justification per
1176
+ * fact in spirit, though not yet a persisted, walkable one").
1177
+ *
1178
+ * Retracting `subject ⊑ object` (a STATED or a previously-ENTAILED fact —
1179
+ * either may be retracted) proceeds in bounded rounds:
1180
+ * 1. Remove the named fact.
1181
+ * 2. Scan stored entailed scm-sco facts (purely-entailed ones only —
1182
+ * `isPurelyEntailed`, above) for any whose persisted justification cites
1183
+ * an id removed so far — candidates.
1184
+ * 3. VERIFY, never assume: a candidate is removed only if `subject ⊑
1185
+ * object` is NO LONGER reachable over the SURVIVING subClassOf edge set
1186
+ * (a full ⊑-ancestor walk, `buildAncestorCloser` — the SAME shared
1187
+ * machinery `deriveTypePropagation`/`deriveDisjointViolations` already
1188
+ * reuse, not reimplemented here). A fact with a SECOND, independent
1189
+ * derivation path survives — a real possibility scm-sco's transitive
1190
+ * closure allows (a⊑b⊑d AND a⊑c⊑d both license a⊑d) — exactly the
1191
+ * failure mode a bare "delete anything citing the retracted id" JTMS
1192
+ * walk gets wrong, and precisely why de Kleer's ATMS exists at all (§3's
1193
+ * own citation). This VERIFY step is this slice's cheap, bounded answer
1194
+ * to that known JTMS over-retraction limitation: one local graph walk
1195
+ * per candidate, never a full alternate-justification enumeration.
1196
+ * 4. Repeat: a fact confirmed-removed this round becomes a new cascade
1197
+ * source for the next round (removing a mid-chain link can ripple).
1198
+ *
1199
+ * Bounded by `budget` (max facts examined+removed, default 50 — the SAME
1200
+ * default every other rule in this file uses) and `depth` (max cascade
1201
+ * rounds, default 32, mirroring `deriveSubClassClosure`'s own fixpoint cap).
1202
+ * `truncated` flags the cascade may have been cut short before reaching a
1203
+ * fixpoint (candidates still pending when budget/depth ran out) — the SAME
1204
+ * honest-signal discipline `syllogise()`'s own `truncated` flag follows: a
1205
+ * caller must not read a truncated cascade's survivors as "provably still
1206
+ * consistent," only as "not yet shown inconsistent within budget."
1207
+ *
1208
+ * Known, DELIBERATE scope limit (not a bug): this only ever touches scm-sco's
1209
+ * own entailed subClassOf facts. The other four rules (cax-sco/cax-dw/
1210
+ * cls-svf1/scm-svf1) do not yet persist a justification (none call
1211
+ * `factIdForTriple`/write `justification` — only `syllogise()`'s scmDerived
1212
+ * mapping does, above), so a type/disjointWith/someValuesFrom conclusion that
1213
+ * ALSO went stale when this same premise was retracted is not cascaded here.
1214
+ * Extending justification-tracking to the other four rules is mechanical
1215
+ * (each already computes a `via`/`viaX` pivot) but is a separate follow-up,
1216
+ * not attempted in this slice.
1217
+ *
1218
+ * Returns { retracted, count, budget, depth, truncated, found } — `retracted`
1219
+ * is every id actually removed (target first, then cascade order); `found`
1220
+ * is false (nothing else meaningful) when `subject ⊑ object` was never a
1221
+ * stored fact at all — an honest no-op, matching this module's "never guess"
1222
+ * discipline. No I/O beyond the one `removeFacts` call (skipped entirely when
1223
+ * `found` is false).
1224
+ */
1225
+ export async function retractSubClassOf(repoDir, subject, object, { budget = 50, depth = 32 } = {}) {
1226
+ const s = normFactTerm(subject);
1227
+ const o = normFactTerm(object);
1228
+ const targetId = factIdForTriple(s, SUBCLASS_PREDICATE, o);
1229
+ const memory = await loadMemory(repoDir);
1230
+ const rows = readFactRows(memory);
1231
+ const byId = new Map(rows.map((r) => [r.id, r]));
1232
+ if (!byId.has(targetId)) return { retracted: [], count: 0, budget, depth, truncated: false, found: false };
1233
+
1234
+ // The FULL current subClassOf edge set (stated + every prior entailment) —
1235
+ // the working graph this function's VERIFY step walks each round; a
1236
+ // removed id's own edge is excluded from that round's walk onward.
1237
+ const scRows = rows.filter((r) => isSubClassOf(r.predicate));
1238
+ const edgeOf = new Map(scRows.map((r) => [r.id, [r.subject, r.object]]));
1239
+ // Only a purely-entailed scm-sco fact ever carries a walkable justification
1240
+ // (see syllogise()'s toWrite mapping + isPurelyEntailed, above) — every
1241
+ // other row's justification is [] (or the fact is also independently
1242
+ // taught, so it is EXCLUDED here even if it happens to carry a stale one),
1243
+ // so this candidate pool is naturally, correctly scoped.
1244
+ const entailedScRows = scRows.filter((r) => r.justification.length && isPurelyEntailed(r.provenance));
1245
+
1246
+ const removed = new Set([targetId]);
1247
+ const order = [targetId]; // deterministic report order: target first, then removal order
1248
+ let truncated = false;
1249
+ let round = 0;
1250
+ for (; round < depth; round += 1) {
1251
+ const candidates = entailedScRows
1252
+ .filter((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)))
1253
+ .sort((a, b) => a.subject.localeCompare(b.subject) || a.object.localeCompare(b.object));
1254
+ if (!candidates.length) break; // fixpoint — nothing left to (re-)check
1255
+
1256
+ // The surviving edge set for THIS round's verify walk — DRed's own
1257
+ // "delete a superset, then selectively rederive" discipline (§3's own
1258
+ // citation, Gupta/Mumick/Subrahmanian 1993): every candidate's OWN edge
1259
+ // is excluded too, alongside every OTHER candidate under suspicion this
1260
+ // SAME round, not just `removed` — otherwise a candidate would trivially
1261
+ // "reach itself" through its own not-yet-deleted edge (or lean on a
1262
+ // sibling candidate that is itself only standing on the same broken
1263
+ // premise), understating what actually still needs re-verifying. A
1264
+ // candidate that reaches its target through some OTHER, untouched edge
1265
+ // (a genuinely independent derivation path this fact's single persisted
1266
+ // justification never recorded) correctly survives.
1267
+ const candidateIds = new Set(candidates.map((c) => c.id));
1268
+ const survivingEdges = [...edgeOf.entries()]
1269
+ .filter(([id]) => !removed.has(id) && !candidateIds.has(id))
1270
+ .map(([, e]) => e);
1271
+ const ancestorsOf = buildAncestorCloser(survivingEdges);
1272
+
1273
+ let progressed = false;
1274
+ let hitBudget = false;
1275
+ for (const c of candidates) {
1276
+ if (removed.size >= budget) { hitBudget = true; break; }
1277
+ // does subject⊑object still hold WITHOUT the retracted premise, via ANY
1278
+ // surviving path (not just the one this fact was originally derived
1279
+ // through)? A survivor keeps its (now possibly re-groundable, still
1280
+ // TRUE) fact and is never re-examined again this call.
1281
+ if (ancestorsOf(c.subject).has(c.object)) continue; // a second, independent path still supports it — keep
1282
+ removed.add(c.id);
1283
+ order.push(c.id);
1284
+ progressed = true;
1285
+ }
1286
+ if (hitBudget) { truncated = true; break; }
1287
+ if (!progressed) break; // every candidate this round survived verification — fixpoint
1288
+ }
1289
+ if (!truncated && round >= depth) {
1290
+ // depth exhausted, not a natural fixpoint — honestly flag it if a
1291
+ // pending candidate would still have been checked next round.
1292
+ truncated = entailedScRows.some((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)));
1293
+ }
1294
+
1295
+ const { removed: actuallyRemoved } = await removeFacts(repoDir, order);
1296
+ return { retracted: actuallyRemoved, count: actuallyRemoved.length, budget, depth, truncated, found: true };
1297
+ }
1298
+
1140
1299
  /**
1141
1300
  * PROOF SEARCH (not a third rule — a bounded ROOTED chase that composes the
1142
1301
  * two rules above for a single "does `subj` reach one of `targets`?" query,