@polycode-projects/the-mechanical-code-talker 1.10.14 → 1.11.5

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.
@@ -179,10 +179,54 @@ const CAPABILITIES = Object.freeze([
179
179
  }),
180
180
  ]);
181
181
 
182
- // A frozen name→capability index (built once).
183
- const BY_NAME = Object.freeze(
184
- CAPABILITIES.reduce((m, c) => { m[c.name] = c; return m; }, Object.create(null)),
182
+ // The live capability set: the built-in frozen array is the seed; registration
183
+ // rebuilds `list`/`byName` wholesale so every accessor stays a plain read.
184
+ const buildIndex = (caps) => Object.freeze(
185
+ caps.reduce((m, c) => { m[c.name] = c; return m; }, Object.create(null)),
185
186
  );
187
+ let list = CAPABILITIES;
188
+ let byName = buildIndex(list);
189
+
190
+ function deepFreeze(value) {
191
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
192
+ Object.freeze(value);
193
+ for (const k of Object.keys(value)) deepFreeze(value[k]);
194
+ }
195
+ return value;
196
+ }
197
+
198
+ /** Register a capability at runtime (e.g. a taught action family bridged in by
199
+ * src/router/taught.mjs). `readOnly` must be an explicit boolean; a
200
+ * `readOnly: false` record is forced `dispatchable: false` — the guardrail's
201
+ * candidate enrichment re-dispatches a tool once per tied candidate, which is
202
+ * only safe when dispatch performs no writes. Returns an `unregister()`
203
+ * disposer. */
204
+ export function registerCapability(cap) {
205
+ const name = cap && typeof cap.name === "string" ? cap.name.trim() : "";
206
+ if (!name) throw new Error("registerCapability: a non-empty name is required");
207
+ if (byName[name]) throw new Error(`registerCapability: "${name}" is already registered`);
208
+ if (!Array.isArray(cap.parameters) || !Array.isArray(cap.preconditions)) {
209
+ throw new Error(`registerCapability: "${name}" needs parameters[] and preconditions[]`);
210
+ }
211
+ if (!cap.effects || !Array.isArray(cap.effects.add) || !Array.isArray(cap.effects.del)) {
212
+ throw new Error(`registerCapability: "${name}" needs effects {add: [], del: []}`);
213
+ }
214
+ if (typeof cap.readOnly !== "boolean") {
215
+ throw new Error(`registerCapability: "${name}" needs an explicit boolean readOnly`);
216
+ }
217
+ const rec = deepFreeze({
218
+ type: VOCAB.Capability,
219
+ ...cap,
220
+ name,
221
+ dispatchable: cap.readOnly === true ? cap.dispatchable !== false : false,
222
+ });
223
+ list = Object.freeze([...list, rec]);
224
+ byName = buildIndex(list);
225
+ return function unregister() {
226
+ list = Object.freeze(list.filter((c) => c !== rec));
227
+ byName = buildIndex(list);
228
+ };
229
+ }
186
230
 
187
231
  // ---- unregistered dispatch tools ---------------------------------------------
188
232
  // Dispatch tools not yet registered; each names the precondition work it needs first.
@@ -199,28 +243,28 @@ export const REGISTRY = Object.freeze({
199
243
  vocab: VOCAB,
200
244
  kinds: KINDS,
201
245
  precond: PRECOND,
202
- capabilities: CAPABILITIES,
246
+ get capabilities() { return list; },
203
247
  });
204
248
 
205
249
  // ---- pure accessors ---------------------------------------------------------
206
250
 
207
- /** All declared capabilities (the operator set). */
208
- export function capabilities() { return CAPABILITIES; }
251
+ /** All declared capabilities (the operator set, plus any registered at runtime). */
252
+ export function capabilities() { return list; }
209
253
 
210
254
  /** The capability named `n`, or undefined. */
211
- export function capabilityByName(n) { return BY_NAME[n]; }
255
+ export function capabilityByName(n) { return byName[n]; }
212
256
 
213
257
  /** True iff `n` names a declared capability. */
214
- export function isCapability(n) { return Boolean(BY_NAME[n]); }
258
+ export function isCapability(n) { return Boolean(byName[n]); }
215
259
 
216
260
  /** The parameter slots of capability `n` (empty array if unknown/no-arg). */
217
- export function parametersOf(n) { return BY_NAME[n]?.parameters ?? []; }
261
+ export function parametersOf(n) { return byName[n]?.parameters ?? []; }
218
262
 
219
263
  /** The preconditions of capability `n` (the safety gate the guardrail checks). */
220
- export function preconditionsOf(n) { return BY_NAME[n]?.preconditions ?? []; }
264
+ export function preconditionsOf(n) { return byName[n]?.preconditions ?? []; }
221
265
 
222
266
  /** The effects of capability `n` — `{ add, del }` (the proof-chain contribution). */
223
- export function effectsOf(n) { return BY_NAME[n]?.effects ?? { add: [], del: [] }; }
267
+ export function effectsOf(n) { return byName[n]?.effects ?? { add: [], del: [] }; }
224
268
 
225
269
  /** The set of arg keys capability `n` accepts (for the guardrail's unknown-arg
226
270
  * check). Returns a Set of strings. */
@@ -94,6 +94,19 @@ export function backwardChain(topic) {
94
94
  return null;
95
95
  }
96
96
 
97
+ /** Backward-chain a WORLD goal — a state predicate like "rest-on" that a
98
+ * taught action's effect establishes — to the registered record whose
99
+ * add-list carries the matching taught:world-effect (the bridge
100
+ * src/router/taught.mjs registers). Pure over the registry; null when no
101
+ * taught record achieves the predicate. The sibling of backwardChain above,
102
+ * which only ever matches epistemic `knows` effects. */
103
+ export function backwardChainWorld(predicate) {
104
+ for (const cap of capabilities()) {
105
+ if (effectsOf(cap.name).add.some((e) => e.pred === "taught:world-effect" && e.predicate === predicate)) return cap;
106
+ }
107
+ return null;
108
+ }
109
+
97
110
  // Stopwords for the imperative-frame entity extractor. Deliberately generous: a wrong
98
111
  // pick is caught by the resolveObject miss -> honest refuse, never emitted.
99
112
  const STOP = new Set([
@@ -0,0 +1,84 @@
1
+ // taught.mjs — bridge taught action-Rule families into the capability registry.
2
+ //
3
+ // A taught game action ("you can move a disk onto a peg" + its preconditions
4
+ // and effect) becomes a registered capability record so the router's operator
5
+ // model covers taught actions and built-in query tools alike. Registered
6
+ // records carry readOnly: false, so the guardrail never dispatches them — the
7
+ // resolver also never selects them on its own, because it backward-chains over
8
+ // `knows` add-effects and these records carry world-triple effects instead.
9
+
10
+ import { readRuleRows } from "../memory/core.mjs";
11
+ import { capabilityByName, registerCapability } from "./registry.mjs";
12
+
13
+ const ACTION_KINDS = new Set(["action-signature", "action-precond", "action-effect", "action-constraint"]);
14
+
15
+ /** Group a memory payload's action-family rule rows by rule name. */
16
+ export function actionFamilies(memory) {
17
+ const families = new Map();
18
+ for (const row of readRuleRows(memory)) {
19
+ if (!ACTION_KINDS.has(row.kind)) continue;
20
+ if (!families.has(row.name)) families.set(row.name, []);
21
+ families.get(row.name).push(row);
22
+ }
23
+ return families;
24
+ }
25
+
26
+ /** Map one action family to a registrable capability record. */
27
+ export function capabilityFromActionRules(name, family) {
28
+ const signatures = family.filter((r) => r.kind === "action-signature");
29
+ const preconds = family.filter((r) => r.kind === "action-precond");
30
+ const effects = family.filter((r) => r.kind === "action-effect");
31
+ const constraints = family.filter((r) => r.kind === "action-constraint");
32
+ return {
33
+ name: `taught:${name}`,
34
+ label: name,
35
+ question: `apply the taught action "${name}" to the world state`,
36
+ readOnly: false,
37
+ parameters: [
38
+ { name: "subject", classes: [...new Set(signatures.map((s) => s.slots.subjectClass))].sort() },
39
+ { name: "target", classes: [...new Set(signatures.map((s) => s.slots.targetClass))].sort() },
40
+ ],
41
+ preconditions: [
42
+ ...preconds.map((p) => ({
43
+ pred: "taught:world-precond",
44
+ shape: p.slots.shape,
45
+ predicate: p.slots.predicate,
46
+ role: p.slots.role,
47
+ scope: p.slots.scope,
48
+ })),
49
+ // A constraint is a precondition on the SUCCESSOR state; it rides the
50
+ // record's precondition list so a bridged family stays complete.
51
+ ...constraints.map((c) => ({
52
+ pred: "taught:world-constraint",
53
+ left: c.slots.left,
54
+ right: c.slots.right,
55
+ guard: c.slots.guard,
56
+ })),
57
+ ],
58
+ effects: {
59
+ add: effects.map((e) => ({
60
+ pred: "taught:world-effect",
61
+ predicate: e.slots.predicate,
62
+ subjectRole: e.slots.subjectRole,
63
+ objectRole: e.slots.objectRole,
64
+ })),
65
+ del: effects.map((e) => ({
66
+ pred: "taught:world-effect-replaced",
67
+ predicate: e.slots.predicate,
68
+ subjectRole: e.slots.subjectRole,
69
+ })),
70
+ },
71
+ };
72
+ }
73
+
74
+ /** Register every taught action family in `memory`, idempotently (a name that
75
+ * is already registered is skipped). Returns the new registrations'
76
+ * unregister disposers. */
77
+ export function registerTaughtActions(memory) {
78
+ const disposers = [];
79
+ for (const [name, family] of actionFamilies(memory)) {
80
+ if (capabilityByName(`taught:${name}`)) continue;
81
+ disposers.push(registerCapability(capabilityFromActionRules(name, family)));
82
+ }
83
+ return disposers;
84
+ }
@@ -0,0 +1,19 @@
1
+ // sentences.mjs — sentence-boundary splitting, shared by the extract-facts
2
+ // script, the chat one-shot CLI, and runTurn's multi-sentence pre-split.
3
+
4
+ import { winkInstance } from "./wink-model.mjs";
5
+
6
+ /** Split text into trimmed, non-empty sentences via wink-nlp's own
7
+ * sentence-boundary detection — never a naive regex split, matching the
8
+ * ONE way every other adapter in this repo reaches wink (wink-model.mjs).
9
+ * Returns [] (never throws) when wink isn't available or the text is
10
+ * blank — the same honest-degrade idiom every wink-model.mjs consumer
11
+ * already uses. */
12
+ export function splitSentences(text) {
13
+ const nlp = winkInstance();
14
+ if (!nlp) return [];
15
+ const raw = String(text ?? "");
16
+ if (!raw.trim()) return [];
17
+ const doc = nlp.readDoc(raw);
18
+ return doc.sentences().out().map((s) => s.trim()).filter(Boolean);
19
+ }
package/src/syllogise.mjs CHANGED
@@ -694,6 +694,7 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
694
694
  const trustByTriple = new Map();
695
695
  for (const r of rows) trustByTriple.set(`${r.subject}${SEP}${r.predicate}${SEP}${r.object}`, r.trust);
696
696
  const premiseTrust = (s, p, o) => trustByTriple.get(`${s}${SEP}${p}${SEP}${o}`);
697
+ const hasTriple = (s, p, o) => trustByTriple.has(`${s}${SEP}${p}${SEP}${o}`);
697
698
  const numericOnly = (arr) => arr.filter((t) => typeof t === "number");
698
699
 
699
700
  const scmDerived = deriveSubClassClosure(subClassEdges, { depth, budget, focus: normalizedFocus });
@@ -732,11 +733,12 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
732
733
  ...scmDerived.map((d) => ({
733
734
  subject: d.subject, predicate: SUBCLASS_PREDICATE, object: d.object,
734
735
  provenance: ENTAILED_PROVENANCE,
735
- // Persisted justification, scm-sco only: the two premise fact ids this
736
- // conclusion rode (a⊑b, b⊑c) — content-addressed ids work even when a
737
- // premise is itself an entailment this same pass just derived. Read
738
- // back by retractSubClassOf (below) to find every entailment a
739
- // retracted premise could have supported.
736
+ // Persisted justification: the premise fact ids this conclusion rode
737
+ // (a⊑b, b⊑c) — content-addressed ids work even when a premise is
738
+ // itself an entailment this same pass just derived. Read back by
739
+ // retractSubClassOf (below) to find every entailment a retracted
740
+ // premise could have supported. All five rules persist one, each
741
+ // citing its own premise shape.
740
742
  justification: [
741
743
  factIdForTriple(d.subject, SUBCLASS_PREDICATE, d.via),
742
744
  factIdForTriple(d.via, SUBCLASS_PREDICATE, d.object),
@@ -745,15 +747,24 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
745
747
  ...caxDerived.map((d) => ({
746
748
  subject: d.subject, predicate: TYPE_PREDICATE, object: d.object,
747
749
  provenance: ENTAILED_TYPE_PROVENANCE,
750
+ // The ⊑ premise is cited as the DIRECT via⊑object edge even when the
751
+ // taught chain is multi-hop: scm-sco materializes that edge (this same
752
+ // pass or an earlier one), and retraction re-VERIFIES every candidate
753
+ // anyway, so a citation left dangling by budget truncation is inert.
754
+ justification: [
755
+ factIdForTriple(d.subject, TYPE_PREDICATE, d.via),
756
+ factIdForTriple(d.via, SUBCLASS_PREDICATE, d.object),
757
+ ],
748
758
  })),
749
759
  ...dwDerived.map((d) => {
750
760
  // disjointWith is symmetric, taught as ONE direction — the premise row
751
- // could be stored either (viaClass, disjointWith, object) or its mirror.
752
- const dwTrust = premiseTrust(d.viaClass, DISJOINT_PREDICATE, d.object)
753
- ?? premiseTrust(d.object, DISJOINT_PREDICATE, d.viaClass);
761
+ // could be stored either (viaClass, disjointWith, object) or its
762
+ // mirror; resolve which, so the justification cites a real stored id.
763
+ const dwStoredForward = hasTriple(d.viaClass, DISJOINT_PREDICATE, d.object);
764
+ const [dwS, dwO] = dwStoredForward ? [d.viaClass, d.object] : [d.object, d.viaClass];
754
765
  const premiseTrusts = numericOnly([
755
766
  premiseTrust(d.subject, TYPE_PREDICATE, d.viaType),
756
- dwTrust,
767
+ premiseTrust(dwS, DISJOINT_PREDICATE, dwO),
757
768
  // the ⊑-lift premise only exists when this IS a lift (viaClass !==
758
769
  // viaType) — a direct hit has no extra subClassOf premise to price in.
759
770
  ...(d.viaClass !== d.viaType ? [premiseTrust(d.viaType, SUBCLASS_PREDICATE, d.viaClass)] : []),
@@ -761,6 +772,11 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
761
772
  return {
762
773
  subject: d.subject, predicate: DISJOINT_PREDICATE, object: d.object,
763
774
  provenance: ENTAILED_DISJOINT_PROVENANCE,
775
+ justification: [
776
+ factIdForTriple(d.subject, TYPE_PREDICATE, d.viaType),
777
+ factIdForTriple(dwS, DISJOINT_PREDICATE, dwO),
778
+ ...(d.viaClass !== d.viaType ? [factIdForTriple(d.viaType, SUBCLASS_PREDICATE, d.viaClass)] : []),
779
+ ],
764
780
  ...(premiseTrusts.length ? { premiseTrusts, ruleConfidence: CAX_DW_RULE_CONFIDENCE } : {}),
765
781
  };
766
782
  }),
@@ -777,6 +793,13 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
777
793
  return {
778
794
  subject: d.subject, predicate: TYPE_PREDICATE, object: d.object,
779
795
  provenance: ENTAILED_SVF1_PROVENANCE,
796
+ justification: [
797
+ factIdForTriple(d.subject, d.viaProperty, d.viaValue),
798
+ factIdForTriple(d.viaValue, TYPE_PREDICATE, d.viaType),
799
+ factIdForTriple(d.object, ON_PROPERTY_PREDICATE, d.viaPropertyKey),
800
+ factIdForTriple(d.object, SOME_VALUES_FROM_PREDICATE, d.viaTarget),
801
+ ...(d.viaType !== d.viaTarget ? [factIdForTriple(d.viaType, SUBCLASS_PREDICATE, d.viaTarget)] : []),
802
+ ],
780
803
  // same sub-1 discount as cax-dw, same reason (see CAX_DW_RULE_CONFIDENCE).
781
804
  ...(premiseTrusts.length ? { premiseTrusts, ruleConfidence: CLS_SVF1_RULE_CONFIDENCE } : {}),
782
805
  };
@@ -794,6 +817,13 @@ export async function syllogise(repoDir, { depth = 32, budget = 50, focus = null
794
817
  return {
795
818
  subject: d.subject, predicate: SUBCLASS_PREDICATE, object: d.object,
796
819
  provenance: ENTAILED_SCM_SVF_PROVENANCE,
820
+ justification: [
821
+ ...(r1 ? [factIdForTriple(d.subject, ON_PROPERTY_PREDICATE, r1.property)] : []),
822
+ factIdForTriple(d.subject, SOME_VALUES_FROM_PREDICATE, d.viaY1),
823
+ ...(r2 ? [factIdForTriple(d.object, ON_PROPERTY_PREDICATE, r2.property)] : []),
824
+ factIdForTriple(d.object, SOME_VALUES_FROM_PREDICATE, d.viaY2),
825
+ factIdForTriple(d.viaY1, SUBCLASS_PREDICATE, d.viaY2),
826
+ ],
797
827
  // same sub-1 discount as cax-dw/cls-svf1, same reason (see CAX_DW_RULE_CONFIDENCE).
798
828
  ...(premiseTrusts.length ? { premiseTrusts, ruleConfidence: SCM_SVF_RULE_CONFIDENCE } : {}),
799
829
  };
@@ -838,21 +868,112 @@ function isPurelyEntailed(provenance) {
838
868
  return tags.length > 0 && tags.every((t) => t.startsWith("entailed:"));
839
869
  }
840
870
 
871
+ /** Builds the per-round VERIFY oracle for retraction: given ONLY the
872
+ * surviving fact rows, returns `stillDerivable(row)` — true when the row's
873
+ * (s,p,o) conclusion is re-derivable from survivors by the rule family that
874
+ * owns its predicate (scm-sco/scm-svf1 for subClassOf, cax-sco/cls-svf1 for
875
+ * rdf:type, cax-dw for disjointWith). One shared ancestor closure plus small
876
+ * indexes per round, joining exactly what each derive kernel joins. Pure,
877
+ * no I/O. */
878
+ function buildSurvivorDerivabilityCheck(rows) {
879
+ const subClassEdges = [];
880
+ const typesOf = new Map(); // x -> Set(surviving direct type classes)
881
+ const disjointOf = new Map(); // term -> Set(disjoint partners), symmetric
882
+ const onPropertyOf = new Map(); // restriction -> owl:onProperty's object
883
+ const someValuesFromOf = new Map(); // restriction -> owl:someValuesFrom's object
884
+ const propertyEdgesOf = new Map(); // x -> [[normalized predicate, y], …]
885
+ for (const r of rows) {
886
+ const pLower = String(r.predicate || "").trim().toLowerCase();
887
+ if (isSubClassOf(r.predicate)) subClassEdges.push([r.subject, r.object]);
888
+ else if (isType(r.predicate)) {
889
+ if (!typesOf.has(r.subject)) typesOf.set(r.subject, new Set());
890
+ typesOf.get(r.subject).add(r.object);
891
+ } else if (isDisjoint(r.predicate)) {
892
+ if (!disjointOf.has(r.subject)) disjointOf.set(r.subject, new Set());
893
+ disjointOf.get(r.subject).add(r.object);
894
+ if (!disjointOf.has(r.object)) disjointOf.set(r.object, new Set());
895
+ disjointOf.get(r.object).add(r.subject);
896
+ } else if (isOnProperty(r.predicate)) onPropertyOf.set(r.subject, r.object);
897
+ else if (isSomeValuesFrom(r.predicate)) someValuesFromOf.set(r.subject, r.object);
898
+ else if (!RESERVED_PREDICATES.has(pLower)) {
899
+ if (!propertyEdgesOf.has(r.subject)) propertyEdgesOf.set(r.subject, []);
900
+ propertyEdgesOf.get(r.subject).push([normFactTerm(r.predicate), r.object]);
901
+ }
902
+ }
903
+ const ancestorsOf = buildAncestorCloser(subClassEdges);
904
+ const reaches = (a, b) => a !== b && ancestorsOf(a).has(b);
905
+ const restrictionOf = (node) => {
906
+ const property = onPropertyOf.get(node);
907
+ const target = someValuesFromOf.get(node);
908
+ return property && target ? { property: normFactTerm(property), target } : null;
909
+ };
910
+
911
+ return (row) => {
912
+ if (isSubClassOf(row.predicate)) {
913
+ // scm-sco: some surviving ⊑-path still connects subject to object.
914
+ if (reaches(row.subject, row.object)) return true;
915
+ // scm-svf1: both ends still declared restrictions over the SAME
916
+ // property, with strictly ⊑-related fillers (kernel-faithful).
917
+ const r1 = restrictionOf(row.subject);
918
+ const r2 = restrictionOf(row.object);
919
+ return Boolean(r1 && r2 && r1.property === r2.property && reaches(r1.target, r2.target));
920
+ }
921
+ if (isType(row.predicate)) {
922
+ // cax-sco: a surviving direct type whose ⊑-closure reaches the class.
923
+ for (const c of typesOf.get(row.subject) || []) {
924
+ if (reaches(c, row.object)) return true;
925
+ }
926
+ // cls-svf1: the class is a still-declared restriction node — a
927
+ // surviving property edge whose value's type (⊑-lifted) satisfies it.
928
+ const rec = restrictionOf(row.object);
929
+ if (rec) {
930
+ for (const [pKey, y] of propertyEdgesOf.get(row.subject) || []) {
931
+ if (pKey !== rec.property) continue;
932
+ for (const c of typesOf.get(y) || []) {
933
+ if (c === rec.target || reaches(c, rec.target)) return true;
934
+ }
935
+ }
936
+ }
937
+ return false;
938
+ }
939
+ if (isDisjoint(row.predicate)) {
940
+ // cax-dw: a surviving type whose ⊑-closure meets a surviving
941
+ // disjointWith partner equal to the conclusion's object.
942
+ for (const c of typesOf.get(row.subject) || []) {
943
+ for (const d of [c, ...ancestorsOf(c)]) {
944
+ if (disjointOf.get(d)?.has(row.object)) return true;
945
+ }
946
+ }
947
+ return false;
948
+ }
949
+ // An entailed predicate no rule family here owns: nothing can re-check
950
+ // it, so it is never removed on a stale citation alone.
951
+ return true;
952
+ };
953
+ }
954
+
841
955
  /**
842
- * A scoped retraction slice: JTMS-style dependency-directed removal, for
843
- * scm-sco ONLY. Retracting `subject ⊑ object` removes the fact, then cascades
844
- * to any purely-entailed scm-sco fact whose persisted justification cites a
845
- * removed id but each candidate is VERIFIED (re-derivable over the
846
- * surviving subClassOf edge set, not just "cited a removed id") before it is
956
+ * A scoped retraction slice: JTMS-style dependency-directed removal.
957
+ * Retracting `subject ⊑ object` removes the fact, then cascades to any
958
+ * purely-entailed fact across all five rules' conclusions — whose persisted
959
+ * justification cites a removed id. Each candidate is VERIFIED (re-derivable
960
+ * from the surviving facts, not just "cited a removed id") before it is
847
961
  * actually removed, since a fact can have a second, independent derivation
848
962
  * path (a⊑b⊑d AND a⊑c⊑d both license a⊑d) that a bare delete-by-justification
849
963
  * walk would wrongly discard. Repeats in rounds — a removed mid-chain link
850
964
  * can ripple — bounded by `budget` (max facts examined+removed) and `depth`
851
965
  * (max cascade rounds).
852
966
  *
853
- * Scope limit: only scm-sco persists a justification today, so a
854
- * type/disjointWith/someValuesFrom conclusion that also went stale is not
855
- * cascaded here (mechanical to extend, not attempted in this slice).
967
+ * The entry point stays subClassOf-rooted because chat's recognized
968
+ * retraction phrasings ("X is not a Y", "forget that X is a kind of Y",
969
+ * chat.mjs's teach lane) retract subClassOf facts; the cascade itself follows
970
+ * justifications into every rule's conclusions (transitive ⊑, propagated
971
+ * types, disjointness violations, restriction membership and subsumption).
972
+ *
973
+ * A survivor keeps its stale, still-single justification as-is; a later
974
+ * retraction of its OTHER supporting path therefore won't re-examine it.
975
+ * Re-grounding survivors — or tracking every alternate justification set —
976
+ * is the ATMS horizon (PLAN_SYLLOGIST.md §3), not this bounded slice.
856
977
  *
857
978
  * Returns { retracted, count, budget, depth, truncated, found } — `found` is
858
979
  * false when `subject ⊑ object` was never a stored fact.
@@ -866,43 +987,38 @@ export async function retractSubClassOf(repoDir, subject, object, { budget = 50,
866
987
  const byId = new Map(rows.map((r) => [r.id, r]));
867
988
  if (!byId.has(targetId)) return { retracted: [], count: 0, budget, depth, truncated: false, found: false };
868
989
 
869
- // The FULL current subClassOf edge set (stated + every prior entailment)
870
- // the working graph this function's VERIFY step walks each round; a
871
- // removed id's own edge is excluded from that round's walk onward.
872
- const scRows = rows.filter((r) => isSubClassOf(r.predicate));
873
- const edgeOf = new Map(scRows.map((r) => [r.id, [r.subject, r.object]]));
874
- // Only a purely-entailed scm-sco fact ever carries a walkable justification.
875
- const entailedScRows = scRows.filter((r) => r.justification.length && isPurelyEntailed(r.provenance));
990
+ // Only a purely-entailed fact ever carries a walkable justification
991
+ // a fact later independently taught is never a cascade candidate at all.
992
+ const entailedRows = rows.filter((r) => r.justification.length && isPurelyEntailed(r.provenance));
876
993
 
877
994
  const removed = new Set([targetId]);
878
995
  const order = [targetId]; // deterministic report order: target first, then removal order
879
996
  let truncated = false;
880
997
  let round = 0;
881
998
  for (; round < depth; round += 1) {
882
- const candidates = entailedScRows
999
+ const candidates = entailedRows
883
1000
  .filter((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)))
884
- .sort((a, b) => a.subject.localeCompare(b.subject) || a.object.localeCompare(b.object));
1001
+ .sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
885
1002
  if (!candidates.length) break; // fixpoint — nothing left to (re-)check
886
1003
 
887
- // The surviving edge set for THIS round's verify walk excludes every
888
- // candidate's own edge too, not just `removed` — otherwise a candidate
1004
+ // The surviving fact set for THIS round's verify walk excludes every
1005
+ // candidate's own row too, not just `removed` — otherwise a candidate
889
1006
  // could trivially "reach itself" through its own not-yet-deleted edge, or
890
1007
  // lean on a sibling candidate standing on the same broken premise.
891
1008
  const candidateIds = new Set(candidates.map((c) => c.id));
892
- const survivingEdges = [...edgeOf.entries()]
893
- .filter(([id]) => !removed.has(id) && !candidateIds.has(id))
894
- .map(([, e]) => e);
895
- const ancestorsOf = buildAncestorCloser(survivingEdges);
1009
+ const stillDerivable = buildSurvivorDerivabilityCheck(
1010
+ rows.filter((r) => !removed.has(r.id) && !candidateIds.has(r.id)),
1011
+ );
896
1012
 
897
1013
  let progressed = false;
898
1014
  let hitBudget = false;
899
1015
  for (const c of candidates) {
900
1016
  if (removed.size >= budget) { hitBudget = true; break; }
901
- // does subject⊑object still hold WITHOUT the retracted premise, via ANY
902
- // surviving path (not just the one this fact was originally derived
903
- // through)? A survivor keeps its (now possibly re-groundable, still
904
- // TRUE) fact and is never re-examined again this call.
905
- if (ancestorsOf(c.subject).has(c.object)) continue; // a second, independent path still supports it — keep
1017
+ // does the conclusion still hold WITHOUT the retracted premise, via ANY
1018
+ // surviving derivation (not just the one this fact was originally
1019
+ // derived through)? A survivor keeps its (now possibly re-groundable,
1020
+ // still TRUE) fact and is never re-examined again this call.
1021
+ if (stillDerivable(c)) continue; // a second, independent derivation still supports it — keep
906
1022
  removed.add(c.id);
907
1023
  order.push(c.id);
908
1024
  progressed = true;
@@ -913,7 +1029,7 @@ export async function retractSubClassOf(repoDir, subject, object, { budget = 50,
913
1029
  if (!truncated && round >= depth) {
914
1030
  // depth exhausted, not a natural fixpoint — honestly flag it if a
915
1031
  // pending candidate would still have been checked next round.
916
- truncated = entailedScRows.some((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)));
1032
+ truncated = entailedRows.some((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)));
917
1033
  }
918
1034
 
919
1035
  const { removed: actuallyRemoved } = await removeFacts(repoDir, order);
@@ -0,0 +1,66 @@
1
+ // viz-theme.mjs — the shared assets for tmct's generated HTML pages
2
+ // (the ledger explorer and the plan player): the visual token table
3
+ // (PLAN_VIZ_LEDGER.md's reference values) plus the escaping helpers every
4
+ // page builder needs.
5
+ //
6
+ // Trust tiers are precomputed rgba() values per provenance color so pages
7
+ // render identically on browsers without color-mix() support.
8
+
9
+ export const SERIF_STACK = `"Charter", "Bitstream Charter", Georgia, "Times New Roman", serif`;
10
+ export const MONO_STACK = `ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`;
11
+
12
+ /** Escape untrusted text for safe placement inside HTML content/attributes. */
13
+ export function escapeHtml(s) {
14
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
15
+ }
16
+
17
+ /** JSON-embed page data into a `<script>` tag safely — escape `</` so a
18
+ * label/id containing "</script>" can't break out of the tag, and escape
19
+ * U+2028/U+2029 (valid in JSON strings, invalid unescaped in JS source). */
20
+ export function embedJson(value) {
21
+ return JSON.stringify(value)
22
+ .replace(/</g, "\\u003c")
23
+ .replace(/\u2028/g, "\\u2028")
24
+ .replace(/\u2029/g, "\\u2029");
25
+ }
26
+
27
+ /** hex "#RRGGBB" -> "rgba(r, g, b, a)" */
28
+ function rgba(hex, alpha) {
29
+ const n = parseInt(hex.slice(1), 16);
30
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
31
+ }
32
+
33
+ export const TOKENS = Object.freeze({
34
+ light: Object.freeze({
35
+ bg: "#F7F6F2", ink: "#23272B", muted: "#6E7168", line: "#DDD9D0", card: "#FFFFFF",
36
+ taught: "#2E7D4F", corpus: "#5A80AC", entail: "#B07C2E", alert: "#B0503F",
37
+ }),
38
+ dark: Object.freeze({
39
+ bg: "#15181C", ink: "#E7E5DF", muted: "#9A9E95", line: "#2B3036", card: "#1C2126",
40
+ taught: "#5FBE8B", corpus: "#6C93BF", entail: "#D9A554", alert: "#D08070",
41
+ }),
42
+ });
43
+
44
+ const TIER_ALPHA = [0.35, 0.65, 1.0]; // trust tiers 1..3
45
+
46
+ function tokenBlock(t) {
47
+ const tiers = (name) =>
48
+ TIER_ALPHA.map((a, i) => `--${name}-t${i + 1}: ${rgba(t[name], a)};`).join(" ");
49
+ return [
50
+ `--bg: ${t.bg}; --ink: ${t.ink}; --muted: ${t.muted}; --line: ${t.line}; --card: ${t.card};`,
51
+ `--taught: ${t.taught}; --corpus: ${t.corpus}; --entail: ${t.entail}; --alert: ${t.alert};`,
52
+ tiers("taught"), tiers("corpus"), tiers("entail"),
53
+ `--taught-soft: ${rgba(t.taught, 0.12)}; --corpus-soft: ${rgba(t.corpus, 0.12)};`,
54
+ `--entail-soft: ${rgba(t.entail, 0.14)}; --alert-soft: ${rgba(t.alert, 0.12)};`,
55
+ ].join(" ");
56
+ }
57
+
58
+ /** The token table as CSS custom properties: light by default, dark via the
59
+ * OS preference, and explicit data-theme overrides winning in both
60
+ * directions (the viewer's toggle stamps data-theme on the root). */
61
+ export const THEME_TOKENS_CSS = `
62
+ :root { color-scheme: light dark; ${tokenBlock(TOKENS.light)} }
63
+ @media (prefers-color-scheme: dark) { :root { ${tokenBlock(TOKENS.dark)} } }
64
+ :root[data-theme="dark"] { ${tokenBlock(TOKENS.dark)} }
65
+ :root[data-theme="light"] { ${tokenBlock(TOKENS.light)} }
66
+ `;
@@ -21,6 +21,7 @@ let cached; // undefined = not tried yet; null = unavailable (tried once, honest
21
21
  export function registerWinkModel(factory) {
22
22
  injected = factory;
23
23
  cached = undefined;
24
+ instance = undefined;
24
25
  }
25
26
 
26
27
  /** Load `{ winkNLP, model }` once, or null when wink isn't available. Prefers a
@@ -46,15 +47,20 @@ function nodeRequireWink() {
46
47
  };
47
48
  }
48
49
 
49
- /** Convenience: the constructed `nlp` instance (`winkNLP(model)`) or null. Both
50
- * adapters want exactly this. Not cached here — the adapters cache their own
51
- * higher-level object; constructing `nlp` is cheap next to loading the model. */
50
+ let instance; // undefined = not built yet; null = construction failed once
51
+
52
+ /** Convenience: the constructed `nlp` instance (`winkNLP(model)`) or null.
53
+ * Cached: repeated `winkNLP(model)` construction accumulates module-level
54
+ * state inside the model package until V8 throws "Invalid string length",
55
+ * after which every later construction fails for the life of the process. */
52
56
  export function winkInstance() {
57
+ if (instance !== undefined) return instance;
53
58
  const loaded = loadWinkModel();
54
- if (!loaded) return null;
59
+ if (!loaded) { instance = null; return null; }
55
60
  try {
56
- return loaded.winkNLP(loaded.model);
61
+ instance = loaded.winkNLP(loaded.model);
57
62
  } catch {
58
- return null;
63
+ instance = null;
59
64
  }
65
+ return instance;
60
66
  }
@@ -1,19 +0,0 @@
1
- // ask-browser-entry.mjs — the esbuild entry for `tmct viz`'s embedded "Ask the
2
- // graph" chat panel: a real NL chat running client-side against the embedded
3
- // graph, via tmct's own JS engine (esbuild + a Node-builtin stub plugin
4
- // bundles tmct's real ask() into a single browser IIFE).
5
- //
6
- import { ask, parseQuery } from "./ask.mjs";
7
- import {
8
- parseEntities, spiralExpand, mostRecentIndividual, derivedUpdatedAt, MEMORY_SPIRAL_EXPAND_KINDS,
9
- MEMORY_FACT_LINK_KINDS, buildVizNodesAndEdges, deriveFactTermGraph, pickLegendDimension, legendValueFor,
10
- edgeKindsFor, collapseToTopN,
11
- } from "./codegraph.mjs";
12
-
13
- // Exported so the viewer page's client-side recentre/edge-kind-toggle/
14
- // dimension-switcher reuses the same computation as the CLI, not a second copy.
15
- globalThis.tmctViz = {
16
- ask, parseQuery, parseEntities, spiralExpand, mostRecentIndividual, derivedUpdatedAt,
17
- MEMORY_SPIRAL_EXPAND_KINDS, MEMORY_FACT_LINK_KINDS, buildVizNodesAndEdges,
18
- deriveFactTermGraph, pickLegendDimension, legendValueFor, edgeKindsFor, collapseToTopN,
19
- };