@polycode-projects/the-mechanical-code-talker 1.11.0 → 1.11.6

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.
package/src/plan-viz.mjs CHANGED
@@ -1,13 +1,12 @@
1
1
  // plan-viz.mjs — renders a computed plan (result.plan from the chat plan lane)
2
2
  // as a self-contained, animated HTML page: the "blocks" archetype.
3
3
  //
4
- // Same factoring as viz.mjs: a pure layout step (computeBlocksLayout) and a
5
- // pure string builder (renderPlanHtml). No I/O here — callers pass the plan,
6
- // the class→archetype map (rendersAs), and the size-order pairs; both derive
7
- // from fact rows at wiring time.
4
+ // A pure layout step (computeBlocksLayout) and a pure string builder
5
+ // (renderPlanHtml). No I/O here — callers pass the plan, the class→archetype
6
+ // map (rendersAs), and the size-order pairs; both derive from fact rows at
7
+ // wiring time.
8
8
 
9
- import { escapeHtml, embedJson } from "./viz.mjs";
10
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK } from "./viz-theme.mjs";
9
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
11
10
 
12
11
  const BOARD_W = 640;
13
12
  const BOARD_H = 260;
@@ -10,25 +10,35 @@
10
10
  // which are ..." request -> the planner, HTN-decomposed into an ordered call
11
11
  // sequence with a POP causal-link proof chain, then folded into ONE composed
12
12
  // answer via the same set-algebra the HTN method names (relative-filter ->
13
- // intersect; conditional -> fallback/guard). A request neither stage can
14
- // ground escalates to the closed-world goal-reasoner a maintenance-invariant
15
- // deduction (coverage-gap / cochange-risk), never a keyword guess. Anything
16
- // none of the three grounds is an honest refuse, the same "grounded or an
17
- // honest miss" contract as every other tmct answer path.
13
+ // intersect; conditional -> fallback/guard). A refused WORLD goal ("make every
14
+ // disk rest on peg-c") is tried against the taught capability records next
15
+ // (runTaughtPlan selected by backward chaining, grounded by pure simulation,
16
+ // never dispatched). A request none of those ground escalates to the
17
+ // closed-world goal-reasoner a maintenance-invariant deduction
18
+ // (coverage-gap / cochange-risk), never a keyword guess. Anything no stage
19
+ // grounds is an honest refuse, the same "grounded or an honest miss" contract
20
+ // as every other tmct answer path.
18
21
 
19
- import { resolveOne } from "./resolver.mjs";
22
+ import { resolveOne, backwardChainWorld } from "./resolver.mjs";
20
23
  import { plan, isMultiStep, decompose, MAX_STEPS } from "./planner.mjs";
21
24
  import { goalReason } from "./goal-reasoner.mjs";
22
25
  import { capabilities } from "./registry.mjs";
26
+ import { registerTaughtActions } from "./taught.mjs";
23
27
  import { intersect, fallbackIfEmpty, guardIfEmpty, memberIndividuals, membersReaching, resultSetOf } from "./results.mjs";
24
28
  import { resolveObject } from "../ask.mjs";
25
29
  import { parseEntities } from "../codegraph.mjs";
26
30
  import { dispatchTool } from "../server.mjs";
27
31
  import { ToolError } from "../config.mjs";
32
+ import { loadMemory, readFactRows, readRuleRows } from "../memory/core.mjs";
33
+ import {
34
+ compileDomain, stateFromFacts, stateKeyFor, movesFromRules, compileGoal, PlanBudgetError,
35
+ } from "../domain.mjs";
36
+ import { findActionPath } from "../planning.mjs";
28
37
  import * as defaultSource from "../source.mjs";
29
38
 
30
39
  export const ROUTER_DRIVER = "resolver-0.8.0";
31
40
  export const GOAL_DRIVER = "goal-0.8.1";
41
+ export const TAUGHT_DRIVER = "taught-0.1.0";
32
42
 
33
43
  /** Every registered capability's name — the default declared toolset for a
34
44
  * caller that doesn't want to hand-pick a subset. */
@@ -138,14 +148,102 @@ export async function runResolverPlan(request, tools, ctx) {
138
148
  };
139
149
  }
140
150
 
141
- /** The full drive: resolver/planner first; a refusal there escalates to the
142
- * closed-world goal-reasoner. Mirrors agentbench's driver-resolver.mjs +
143
- * driver-goal.mjs composition, with no agentbench/ dependency (agentbench/
144
- * is dev-only, never shipped). Returns a loopResult:
151
+ // ---- the taught world-goal lane -----------------------------------------------
152
+
153
+ // The one closed world-goal recognizer: "make/get (every|each|all)? <term>
154
+ // <verb>s? <prep> <object>". The preposition set mirrors chat.mjs's PREP_SRC
155
+ // but stays LOCAL and closed — this lane must never widen because a chat
156
+ // frame did, or the two surfaces drift apart silently instead of loudly.
157
+ const WORLD_PREP_SRC = "on|in|at|onto|upon|under|over|beside|near|behind|above|below|inside|outside";
158
+ const WORLD_GOAL_RE = new RegExp(
159
+ `^(?:make|get)\\s+(?:(every|each|all)\\s+)?([\\w-]+)\\s+([a-z]+?)s?\\s+(${WORLD_PREP_SRC})\\s+([\\w-]+)[.!?\\s]*$`,
160
+ "i",
161
+ );
162
+
163
+ /** The taught world-goal lane: recognize "make every disk rest on peg-c",
164
+ * backward-chain the goal predicate to a REGISTERED taught capability record
165
+ * (the record src/router/taught.mjs bridged in is the thing consumed), then
166
+ * ground the move sequence by pure simulation over the taught rules
167
+ * (compileDomain + stateFromFacts + compileGoal + findActionPath — all
168
+ * read-only). Returned calls are NEVER dispatched: taught records carry
169
+ * readOnly:false / dispatchable:false, so the plan is simulated and chat's
170
+ * "next" executes move 1. Returns a loopResult, or null when the request is
171
+ * not a world-goal shape (the caller falls through to the goal-reasoner). */
172
+ export async function runTaughtPlan(request, tools, ctx) {
173
+ const m = WORLD_GOAL_RE.exec(String(request || "").trim());
174
+ if (!m) return null;
175
+ const universal = Boolean(m[1]);
176
+ const term = m[2].toLowerCase();
177
+ const predicate = `${m[3].toLowerCase()}-${m[4].toLowerCase()}`;
178
+ const object = m[5].toLowerCase();
179
+
180
+ const cap = backwardChainWorld(predicate);
181
+ if (!cap) {
182
+ return refuse(`the world goal needs a taught action whose effect achieves "${predicate}", and no taught: record with that world-effect is registered — teach the action rules first (honest miss)`, TAUGHT_DRIVER);
183
+ }
184
+ if (!tools.includes(cap.name)) {
185
+ return refuse(`selected ${cap.name} but it is not in the declared toolset`, TAUGHT_DRIVER);
186
+ }
187
+ if (!ctx.memoryDir) {
188
+ return refuse(`${cap.name} plans over a taught memory store, and this context carries none`, TAUGHT_DRIVER);
189
+ }
190
+
191
+ let domain;
192
+ let state;
193
+ let isGoal;
194
+ try {
195
+ const memory = await loadMemory(ctx.memoryDir);
196
+ const factRows = readFactRows(memory);
197
+ domain = compileDomain(factRows, readRuleRows(memory));
198
+ state = stateFromFacts(factRows, domain);
199
+ isGoal = compileGoal([{ universal, term, predicate, object }], domain);
200
+ } catch (e) {
201
+ return refuse(`the taught domain does not ground this goal: ${e?.message ?? e}`, TAUGHT_DRIVER);
202
+ }
203
+ if (!state.length) {
204
+ return refuse(`no current world state is taught yet — state the board first (e.g. "disk-1 rests on peg-a"), then re-ask`, TAUGHT_DRIVER);
205
+ }
206
+ let found;
207
+ try {
208
+ found = findActionPath(state, isGoal, (s) => movesFromRules(s, domain), { maxDepth: 300, stateKey: stateKeyFor });
209
+ } catch (e) {
210
+ if (e instanceof PlanBudgetError) return refuse(`the taught search space is too large (${e.message}) — narrow the classes involved`, TAUGHT_DRIVER);
211
+ throw e;
212
+ }
213
+ if (!found) {
214
+ return refuse(`no move sequence within 300 steps reaches the goal from the taught state (honest miss)`, TAUGHT_DRIVER);
215
+ }
216
+
217
+ const calls = found.actions.map((a) => ({ name: `taught:${a.name}`, input: { subject: a.subject, target: a.target } }));
218
+ for (const c of calls) {
219
+ if (!tools.includes(c.name)) return refuse(`the plan needs ${c.name}, which is not in the declared toolset`, TAUGHT_DRIVER);
220
+ }
221
+ const proof = [
222
+ { step: "backward-chain", pred: "taught:world-effect", predicate, capability: cap.name, ok: true },
223
+ ...calls.map((c, i) => ({ step: "effect", pred: "taught:world-effect", predicate, consumer: `step-${i + 1}:${c.name}`, ok: true })),
224
+ ];
225
+ const why = [
226
+ `world goal (${universal ? "every " : ""}${term} ${predicate} ${object}) => backward-chain over taught:world-effect => ${cap.name}`,
227
+ `grounded by simulation: compileDomain + findActionPath over the taught rules (${calls.length} move${calls.length === 1 ? "" : "s"}, shortest)`,
228
+ ];
229
+ return {
230
+ calls, refused: false, terminated: true, proof, driver: TAUGHT_DRIVER, why,
231
+ observed: `plan(taught): ${calls.length} move${calls.length === 1 ? "" : "s"} simulated over the taught rules — taught: calls are never dispatched; in chat, "next" executes move 1`,
232
+ };
233
+ }
234
+
235
+ /** The full drive: resolver/planner first; a refusal there falls through to
236
+ * the taught world-goal lane (runTaughtPlan, above), and only a request that
237
+ * is not a world goal escalates to the closed-world goal-reasoner. Mirrors
238
+ * agentbench's driver-resolver.mjs + driver-goal.mjs composition, with no
239
+ * agentbench/ dependency (agentbench/ is dev-only, never shipped). Returns a
240
+ * loopResult:
145
241
  * `{ calls, refused, terminated, proof, why, driver, composed?, observed? }`. */
146
242
  export async function runCapabilityPlan(request, tools, ctx) {
147
243
  const c1 = await runResolverPlan(request, tools, ctx);
148
244
  if (!c1.refused) return c1;
245
+ const taught = await runTaughtPlan(request, tools, ctx);
246
+ if (taught) return taught.refused ? { ...taught, c1Why: c1.why } : taught;
149
247
  const c2 = await goalReason(request, tools, ctx, { driver: GOAL_DRIVER });
150
248
  // Both stages refused: carry the resolver/planner's own reason alongside the
151
249
  // goal-reasoner's so a caller can show why the direct route AND the
@@ -163,8 +261,14 @@ export async function runCapabilityPlan(request, tools, ctx) {
163
261
  * Pass an already-parsed `graph` (e.g. a chat session's own) to skip reloading
164
262
  * it — mirrors the config -> source.fetchEntities -> parseEntities chain
165
263
  * dispatchTool runs internally, so a passed-in graph must come from that same
166
- * chain to stay consistent. */
167
- export async function buildCapabilityPlanCtx({ config, source = defaultSource, tel = null, graph = null } = {}) {
264
+ * chain to stay consistent.
265
+ *
266
+ * Pass a `memoryDir` to open the taught world-goal lane: the memory store's
267
+ * action families are registered as taught: capability records (idempotent —
268
+ * an already-registered name is skipped) and runTaughtPlan simulates over the
269
+ * same store. The new registrations' unregister disposers ride the ctx as
270
+ * `ctx.disposers`; the caller runs them when the ctx is done. */
271
+ export async function buildCapabilityPlanCtx({ config, source = defaultSource, tel = null, graph = null, memoryDir = null } = {}) {
168
272
  const g = graph || parseEntities(await source.fetchEntities(config));
169
273
  const resolve = (term) => resolveObject(g, term);
170
274
  const dispatch = async (name, input) => {
@@ -179,5 +283,10 @@ export async function buildCapabilityPlanCtx({ config, source = defaultSource, t
179
283
  throw e;
180
284
  }
181
285
  };
182
- return { dispatch, resolve, graph: g, config };
286
+ const ctx = { dispatch, resolve, graph: g, config };
287
+ if (memoryDir) {
288
+ ctx.memoryDir = memoryDir;
289
+ ctx.disposers = registerTaughtActions(await loadMemory(memoryDir));
290
+ }
291
+ return ctx;
183
292
  }
@@ -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([
@@ -10,7 +10,7 @@
10
10
  import { readRuleRows } from "../memory/core.mjs";
11
11
  import { capabilityByName, registerCapability } from "./registry.mjs";
12
12
 
13
- const ACTION_KINDS = new Set(["action-signature", "action-precond", "action-effect"]);
13
+ const ACTION_KINDS = new Set(["action-signature", "action-precond", "action-effect", "action-constraint"]);
14
14
 
15
15
  /** Group a memory payload's action-family rule rows by rule name. */
16
16
  export function actionFamilies(memory) {
@@ -28,6 +28,7 @@ export function capabilityFromActionRules(name, family) {
28
28
  const signatures = family.filter((r) => r.kind === "action-signature");
29
29
  const preconds = family.filter((r) => r.kind === "action-precond");
30
30
  const effects = family.filter((r) => r.kind === "action-effect");
31
+ const constraints = family.filter((r) => r.kind === "action-constraint");
31
32
  return {
32
33
  name: `taught:${name}`,
33
34
  label: name,
@@ -37,13 +38,23 @@ export function capabilityFromActionRules(name, family) {
37
38
  { name: "subject", classes: [...new Set(signatures.map((s) => s.slots.subjectClass))].sort() },
38
39
  { name: "target", classes: [...new Set(signatures.map((s) => s.slots.targetClass))].sort() },
39
40
  ],
40
- preconditions: preconds.map((p) => ({
41
- pred: "taught:world-precond",
42
- shape: p.slots.shape,
43
- predicate: p.slots.predicate,
44
- role: p.slots.role,
45
- scope: p.slots.scope,
46
- })),
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
+ ],
47
58
  effects: {
48
59
  add: effects.map((e) => ({
49
60
  pred: "taught:world-effect",
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);
package/src/viz-theme.mjs CHANGED
@@ -1,6 +1,7 @@
1
- // viz-theme.mjs — the shared visual tokens for tmct's generated HTML pages
2
- // (the ledger explorer and the plan player draw from this one table; the
3
- // values are PLAN_VIZ_LEDGER.md's reference token table).
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.
4
5
  //
5
6
  // Trust tiers are precomputed rgba() values per provenance color so pages
6
7
  // render identically on browsers without color-mix() support.
@@ -8,6 +9,21 @@
8
9
  export const SERIF_STACK = `"Charter", "Bitstream Charter", Georgia, "Times New Roman", serif`;
9
10
  export const MONO_STACK = `ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`;
10
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
+
11
27
  /** hex "#RRGGBB" -> "rgba(r, g, b, a)" */
12
28
  function rgba(hex, alpha) {
13
29
  const n = parseInt(hex.slice(1), 16);
@@ -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
- };