@polycode-projects/the-mechanical-code-talker 2.8.0 → 2.8.3

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.
@@ -0,0 +1,398 @@
1
+ // chat-page-viz.mjs — "Talk to it"'s full-screen destination
2
+ // (PLAN_GAMES_UPLIFT_V3.md Part C.4 item 1): a self-contained document shaped
3
+ // exactly like spider-fly-viz.mjs/adventure-viz.mjs's own page-builders — one
4
+ // inlined <style> importing viz-theme.mjs's shared tokens, behaviour as an
5
+ // inlined IIFE — reusing the SAME chat engine the home page's embedded
6
+ // #tmct-chat widget runs (chat-browser.bundle.js's globalThis.tmctChat,
7
+ // chat-seed.json, public/reference-pack/), referenced by the same same-origin
8
+ // relative paths chat-ui.mjs already uses. This module does not import or
9
+ // re-render chat-ui.mjs — it is a second, independent consumer of the same
10
+ // engine/bundle, the same relationship spider-fly-viz.mjs's own inlined chat
11
+ // dock already has with createSpiderFlySession (both call the shared
12
+ // session.turn(line), neither reimplements it).
13
+ //
14
+ // What's actually new here, versus the embedded widget: full-screen
15
+ // post-ChatGPT-style chrome (centered message column, bottom-fixed composer,
16
+ // message bubbles) and this page's own signature element — a quiet
17
+ // per-message PROVENANCE CHIP (taught / corpus / entailed) next to every
18
+ // grounded answer, tmct's actual differentiator versus an LLM chatbot. The
19
+ // chip is read straight off the SAME "(source: ...)" citation chat.mjs's own
20
+ // factPhrase/renderFactLine convention already appends to most answers (see
21
+ // e.g. `dog is a kind of animal (source: corpus:conceptnet ...)` — already
22
+ // asserted by e2e/pages-chat.test.mjs against the embedded widget), never a
23
+ // second provenance computation against memory internals: `provBucketFor`
24
+ // (ledger-viz.mjs) is spliced in unmodified and applied to whatever citation
25
+ // text the answer already carries, so this page's chip and the ledger's own
26
+ // per-row color always agree by construction. A miss carries no citation and
27
+ // gets no chip — the absence IS the signal, matching the product's own
28
+ // honest-miss posture rather than inventing a fourth "trust tier" to badge it.
29
+ //
30
+ // renderChatHtml() is pure: no I/O, deterministic output for identical
31
+ // input. scripts/build-demo-site.mjs calls it directly and writes the result
32
+ // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
33
+ // exist (both built earlier in that same script, for the embedded widget).
34
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
35
+ import { provBucketFor } from "./ledger-viz.mjs";
36
+
37
+ const DEFAULT_TITLE = "tmct — talk to it";
38
+
39
+ // Reads left-to-right as the reader meets each tier: what you taught it
40
+ // directly, what its bundled corpus already knew, what it worked out itself.
41
+ // Mirrors ledger-viz.mjs's own PROVS legend labels verbatim, so the same
42
+ // three words mean the same thing on both pages.
43
+ const PROV_LEGEND = [
44
+ ["taught", "you taught"],
45
+ ["corpus", "corpus"],
46
+ ["entailed", "entailed"],
47
+ ];
48
+
49
+ /** This page's own tiny CSS-class fold — ledger-viz.mjs's `--entail` CSS
50
+ * custom property (viz-theme.mjs's own token name) is one letter short of
51
+ * the "entailed" bucket provBucketFor returns; every other tier's token name
52
+ * already matches its bucket name exactly. */
53
+ function provKey(tier) {
54
+ return tier === "entailed" ? "entail" : tier;
55
+ }
56
+
57
+ /**
58
+ * The chip tier for one chat turn — "taught" | "corpus" | "entailed" | null
59
+ * (no chip: a miss, or an answer that grounds in nothing citable, e.g. /help
60
+ * output or a focus-set confirmation).
61
+ *
62
+ * Reads the SAME "(source: ...)" citation(s) the visible answer text already
63
+ * carries, via `bucketFor` (the page's own spliced copy of ledger-viz.mjs's
64
+ * `provBucketFor`, injected rather than imported so this function stays
65
+ * `.toString()`-splice safe — the same discipline spider-fly-viz.mjs's own
66
+ * `threadCellsForSpiderPlan` holds its own injected `geometry` to).
67
+ * `provBucketFor`'s fallback branch (empty sourceTypes) classifies a raw
68
+ * legacy provenance TAG string directly — exactly the string chat.mjs embeds
69
+ * after "(source: " (memory/trust.mjs's `provenanceTagToSource` parses the
70
+ * identical shape) — so no Fact/individuals lookup is needed here at all.
71
+ *
72
+ * A teach-lane confirmation ("noted — remembered: ...") cites no fact yet to
73
+ * read back — nothing has been asked of it — so its own `record.via ===
74
+ * "assert"` stands in for a citation: the user just taught this, "taught" is
75
+ * the whole point of the reply.
76
+ *
77
+ * Multiple citations (a multi-step proof chain) resolve by the same
78
+ * taught-over-entailed-over-corpus precedence provBucketFor's own header
79
+ * documents for one fact's sourceTypes, applied across citations instead:
80
+ * a chain resting on any taught premise reads as "taught" overall.
81
+ *
82
+ * Self-contained (no outer refs beyond the injected `bucketFor`),
83
+ * `.toString()`-splice safe.
84
+ */
85
+ export function provenanceChipFor(answer, record, bucketFor) {
86
+ if (!record || record.miss) return null;
87
+ const cites = [...String(answer || "").matchAll(/\(source: ([^)]+)\)/g)].map((m) => m[1]);
88
+ if (!cites.length) return record.via === "assert" ? "taught" : null;
89
+ const buckets = cites.map((c) => bucketFor([], c));
90
+ if (buckets.includes("taught")) return "taught";
91
+ if (buckets.includes("entailed")) return "entailed";
92
+ return "corpus";
93
+ }
94
+
95
+ /** The self-contained "talk to it" full-screen page. Pure — the same output
96
+ * for the same `title` every time; every other piece of state (the session,
97
+ * every message, every chip) is computed live in the browser once the
98
+ * sibling chat bundle loads, exactly as the embedded widget already works. */
99
+ export function renderChatHtml({ title = DEFAULT_TITLE } = {}) {
100
+ const legendHtml = PROV_LEGEND.map(
101
+ ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
102
+ ).join("");
103
+
104
+ return `<!doctype html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8">
108
+ <meta name="viewport" content="width=device-width, initial-scale=1">
109
+ <title>${escapeHtml(title)}</title>
110
+ <style>
111
+ ${THEME_TOKENS_CSS}
112
+ html, body { height: 100%; }
113
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; flex-direction: column; overflow: hidden; }
114
+ .mono { font-family: ${MONO_STACK}; }
115
+ button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
116
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
117
+ a { color: var(--corpus); }
118
+
119
+ .topbar { flex: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
120
+ .brand { display: flex; align-items: baseline; gap: .55rem; }
121
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
122
+ .topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
123
+ .legend { display: flex; gap: .8rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
124
+ .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
125
+ .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
126
+ .dot-taught { background: var(--taught); } .dot-corpus { background: var(--corpus); } .dot-entail { background: var(--entail); }
127
+
128
+ main.chatMain { flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; }
129
+ .messages { max-width: 720px; margin: 0 auto; padding: 1.2rem 1rem 1.6rem; display: flex; flex-direction: column; gap: .15rem; min-height: 100%; }
130
+
131
+ .msg-row { display: flex; flex-direction: column; margin: .35rem 0; max-width: 100%; }
132
+ .msg-row.user { align-items: flex-end; }
133
+ .msg-row.assistant { align-items: flex-start; }
134
+ .msg-row.system { align-items: center; margin: .6rem 0; }
135
+
136
+ .bubble { max-width: 80%; padding: .55rem .85rem; border-radius: 16px; white-space: pre-wrap; word-break: break-word; }
137
+ .bubble.user { background: var(--ink); color: var(--bg); border-bottom-right-radius: 4px; }
138
+ .bubble.user .prompt { opacity: .6; font-family: ${MONO_STACK}; font-size: .82em; }
139
+ .bubble.assistant { background: var(--card); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
140
+ .bubble.assistant.pending { color: var(--muted); font-style: italic; }
141
+ .bubble.assistant.miss { color: var(--muted); border-style: dashed; }
142
+ .bubble.system { max-width: 100%; background: none; border: none; color: var(--muted); font-family: ${MONO_STACK}; font-size: .76rem; text-align: center; padding: .2rem .5rem; }
143
+
144
+ .provchip { align-self: flex-start; margin: .28rem 0 0 .15rem; font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .05em; text-transform: uppercase; padding: .12rem .55rem; border-radius: 99px; cursor: default; }
145
+ .pc-taught { color: var(--taught); background: var(--taught-soft); }
146
+ .pc-corpus { color: var(--corpus); background: var(--corpus-soft); }
147
+ .pc-entail { color: var(--entail); background: var(--entail-soft); }
148
+
149
+ form.composer { flex: 0 0 auto; border-top: 1px solid var(--line); background: var(--bg); }
150
+ .composer-inner { max-width: 720px; margin: 0 auto; padding: .7rem 1rem; display: flex; gap: .5rem; align-items: center; }
151
+ .composer-inner input { flex: 1; min-width: 0; font-family: ${SERIF_STACK}; font-size: .95rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 20px; padding: .55rem 1rem; }
152
+ .composer-inner input::placeholder { color: var(--muted); }
153
+ .composer-inner input:disabled { opacity: .55; }
154
+ .composer-inner button[type="submit"] { width: 2.3rem; height: 2.3rem; border-radius: 50%; background: var(--ink); color: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex: 0 0 auto; }
155
+ .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
156
+ .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
157
+
158
+ @media (max-width: 560px) {
159
+ .legend { display: none; }
160
+ .bubble { max-width: 92%; }
161
+ }
162
+ @media (prefers-reduced-motion: reduce) {
163
+ * { scroll-behavior: auto !important; }
164
+ }
165
+ </style>
166
+ </head>
167
+ <body>
168
+ <header class="topbar">
169
+ <div class="brand">
170
+ <span class="eyebrow">tmct</span>
171
+ <h1>Talk to it</h1>
172
+ </div>
173
+ <div class="legend" aria-hidden="true">${legendHtml}</div>
174
+ </header>
175
+ <main class="chatMain">
176
+ <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
177
+ </main>
178
+ <form class="composer" id="composer">
179
+ <div class="composer-inner">
180
+ <input id="composerInput" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
181
+ placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
182
+ <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
183
+ </div>
184
+ </form>
185
+ <div class="statusline" id="status">loading the engine&hellip;</div>
186
+ <script src="./chat-browser.bundle.js"></script>
187
+ <script>
188
+ (function () {
189
+ "use strict";
190
+ const provBucketFor = ${provBucketFor.toString()};
191
+ const provenanceChipFor = ${provenanceChipFor.toString()};
192
+ const el = (id) => document.getElementById(id);
193
+
194
+ const messagesEl = el("messages");
195
+ const composerForm = el("composer");
196
+ const inputEl = el("composerInput");
197
+ const sendBtn = el("composerSend");
198
+ const statusEl = el("status");
199
+
200
+ function scrollToEnd() {
201
+ messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight;
202
+ }
203
+
204
+ function addSystemLine(text) {
205
+ const row = document.createElement("div");
206
+ row.className = "msg-row system";
207
+ const bubble = document.createElement("div");
208
+ bubble.className = "bubble system";
209
+ bubble.textContent = text;
210
+ row.appendChild(bubble);
211
+ messagesEl.appendChild(row);
212
+ scrollToEnd();
213
+ }
214
+
215
+ function addUserBubble(text) {
216
+ const row = document.createElement("div");
217
+ row.className = "msg-row user";
218
+ const bubble = document.createElement("div");
219
+ bubble.className = "bubble user";
220
+ const prompt = document.createElement("span");
221
+ prompt.className = "prompt";
222
+ prompt.textContent = "tmct> ";
223
+ bubble.appendChild(prompt);
224
+ bubble.appendChild(document.createTextNode(text));
225
+ row.appendChild(bubble);
226
+ messagesEl.appendChild(row);
227
+ scrollToEnd();
228
+ return row;
229
+ }
230
+
231
+ function addPendingAssistantBubble() {
232
+ const row = document.createElement("div");
233
+ row.className = "msg-row assistant";
234
+ const bubble = document.createElement("div");
235
+ bubble.className = "bubble assistant pending";
236
+ bubble.textContent = "thinking\\u2026";
237
+ row.appendChild(bubble);
238
+ messagesEl.appendChild(row);
239
+ scrollToEnd();
240
+ return row;
241
+ }
242
+
243
+ const CHIP_TITLE = {
244
+ taught: "you taught tmct this fact directly",
245
+ corpus: "grounded in tmct's bundled corpus",
246
+ entailed: "tmct derived this from taught facts, not read back verbatim",
247
+ };
248
+
249
+ function settleAssistantBubble(row, answer, record) {
250
+ const bubble = row.querySelector(".bubble");
251
+ bubble.classList.remove("pending");
252
+ const missed = !record || Boolean(record.miss);
253
+ bubble.classList.toggle("miss", missed);
254
+ bubble.textContent = answer;
255
+ const tier = provenanceChipFor(answer, record, provBucketFor);
256
+ if (tier) {
257
+ const key = tier === "entailed" ? "entail" : tier;
258
+ const chip = document.createElement("span");
259
+ chip.className = "provchip pc-" + key;
260
+ chip.title = CHIP_TITLE[tier] || "";
261
+ chip.textContent = tier;
262
+ row.appendChild(chip);
263
+ }
264
+ scrollToEnd();
265
+ }
266
+
267
+ // ---- engine boot -------------------------------------------------------
268
+ // Mirrors chat-ui.mjs's own boot sequence against the SAME bundle/seed/pack
269
+ // — a second consumer of the shared engine, not a fork of it.
270
+ const WINK_LOAD_TIMEOUT_MS = 8000;
271
+ const timeoutAfter = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
272
+
273
+ let winkStatus = "pending";
274
+ async function tryLoadWink() {
275
+ try {
276
+ const [{ default: winkNLP }, { default: model }] = await Promise.race([
277
+ Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
278
+ timeoutAfter(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
279
+ ]);
280
+ window.tmctChat.registerWinkModel(() => ({ winkNLP, model }));
281
+ winkStatus = "loaded";
282
+ } catch (err) {
283
+ winkStatus = "unavailable";
284
+ console.warn("tmct chat: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
285
+ }
286
+ }
287
+
288
+ let seedPayload = null;
289
+ let seedFacts = 0;
290
+ async function fetchSeed() {
291
+ try {
292
+ const res = await fetch("./chat-seed.json");
293
+ if (!res.ok) throw new Error("HTTP " + res.status);
294
+ seedPayload = await res.json();
295
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
296
+ } catch (err) {
297
+ seedPayload = null;
298
+ console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
299
+ }
300
+ }
301
+ const cloneSeed = () => {
302
+ if (!seedPayload) return null;
303
+ try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
304
+ };
305
+ function newSession() {
306
+ return window.tmctChat.createChatSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload) });
307
+ }
308
+
309
+ let packIndexPromise = null;
310
+ function fetchPackIndex() {
311
+ if (!packIndexPromise) {
312
+ packIndexPromise = fetch("./reference-pack/index.json")
313
+ .then((res) => (res.ok ? res.json() : null))
314
+ .catch(() => null);
315
+ }
316
+ return packIndexPromise;
317
+ }
318
+ const fetchPackProvider = {
319
+ async lookup(normTerm) {
320
+ const index = await fetchPackIndex();
321
+ const id = index && index.terms ? index.terms[String(normTerm || "")] : null;
322
+ if (!id) return null;
323
+ try {
324
+ const res = await fetch("./reference-pack/articles/" + id + ".json");
325
+ return res.ok ? await res.json() : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ },
330
+ };
331
+
332
+ function renderStatus() {
333
+ const seedPart = seedPayload
334
+ ? "starter memory: " + seedFacts + " facts"
335
+ : "starter memory unavailable — starting empty";
336
+ const winkPart = winkStatus === "loaded"
337
+ ? "wink-nlp lemma/POS tier: loaded"
338
+ : winkStatus === "unavailable"
339
+ ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
340
+ : "wink-nlp: loading\\u2026";
341
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart;
342
+ }
343
+
344
+ let busy = true;
345
+ function setBusy(v) {
346
+ busy = v;
347
+ const ready = Boolean(window.tmctChatSession);
348
+ inputEl.disabled = v || !ready;
349
+ sendBtn.disabled = v || !ready;
350
+ }
351
+
352
+ composerForm.addEventListener("submit", (e) => {
353
+ e.preventDefault();
354
+ const q = inputEl.value.trim();
355
+ if (!q || busy || !window.tmctChatSession) return;
356
+ inputEl.value = "";
357
+ addUserBubble(q);
358
+ const pendingRow = addPendingAssistantBubble();
359
+ setBusy(true);
360
+ window.tmctChatSession.turn(q)
361
+ .then((result) => settleAssistantBubble(pendingRow, result.answer, result.record))
362
+ .catch((err) => settleAssistantBubble(pendingRow,
363
+ "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
364
+ { miss: true }))
365
+ .finally(() => {
366
+ setBusy(false);
367
+ inputEl.focus();
368
+ });
369
+ });
370
+
371
+ async function boot() {
372
+ if (!window.tmctChat) {
373
+ statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
374
+ inputEl.placeholder = "chat engine unavailable";
375
+ return;
376
+ }
377
+ await Promise.all([fetchSeed(), tryLoadWink()]);
378
+ window.tmctChat.registerReferencePackProvider(fetchPackProvider);
379
+ window.tmctChatSession = newSession();
380
+ addSystemLine(seedPayload
381
+ ? "tmct \\u2014 the real engine, running in this page \\u2014 " + seedFacts + " starter facts loaded"
382
+ : "tmct \\u2014 the real engine, running in this page \\u2014 no starter memory; starting empty");
383
+ inputEl.placeholder = seedPayload ? 'try "what is a dog"' : window.tmctChat.vocabExampleHint(false);
384
+ renderStatus();
385
+ setBusy(false);
386
+ inputEl.focus();
387
+ }
388
+
389
+ window.tmctChatReady = boot().catch((err) => {
390
+ console.error("tmct chat failed to boot", err);
391
+ statusEl.textContent = "the chat failed to start (" + (err && err.message ? err.message : err) + ")";
392
+ });
393
+ })();
394
+ </script>
395
+ </body>
396
+ </html>
397
+ `;
398
+ }
@@ -0,0 +1,245 @@
1
+ // plan-pddl.mjs — a PDDL-style + OWL/RDF text rendering of a solved plan
2
+ // (the plan-lane contract chat.mjs's planLaneAnswer returns, PLAN_GAMES_
3
+ // UPLIFT_V3.md Part C.4 item 3): tmct's own richest textual account of what
4
+ // findActionPath actually consulted, for the "it plans, and shows the work"
5
+ // page's new text panel.
6
+ //
7
+ // Pure formatting over already-structured data (plan.actions/.states/.goal/
8
+ // .domain) — no I/O, no new tracking. Every fact line keeps its REAL
9
+ // predicate tag verbatim (mgx:rest-on, rdf:type, rdfs:subClassOf —
10
+ // plan-viz.mjs's own displayPredicate strips the "mgx:" prefix for the
11
+ // visual board; this renderer never does, on purpose: the point is showing
12
+ // the actual reasoning surface, not decorative syntax), and every
13
+ // :precondition/:effect block is a mechanical diff between two consecutive
14
+ // plan.states snapshots — nothing here infers a taught rule's own guard
15
+ // conditions that never surface as a fact-row change (e.g. "nothing may
16
+ // rest on the target" never toggles a row when it already holds, so it
17
+ // leaves no diff to show).
18
+ //
19
+ // The `rdf:type`/`rdfs:subClassOf` split for the :ontology block is read
20
+ // straight off domain.classMembers' own one-hop shape (compileDomain, see
21
+ // domain.mjs): a class-membership edge lands under `classMembers[object] =
22
+ // [...subjects]` for BOTH "X is a Y" (rdf:type) and "X is a kind of Y"
23
+ // (rdfs:subClassOf) teach frames alike, with no record of which frame taught
24
+ // it — so the split here is a real, testable structural fact (a member that
25
+ // is itself a declared class name is a class-to-class edge; anything else is
26
+ // an individual-to-class edge), not a guess.
27
+
28
+ const attachPrefix = (predicate) => {
29
+ const p = String(predicate ?? "").trim();
30
+ return p.includes(":") ? p : `mgx:${p}`;
31
+ };
32
+
33
+ const slug = (s) => String(s ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34
+
35
+ const factAtom = (r) => `(${r.predicate} ${r.subject} ${r.object})`;
36
+ const factKeyOf = (r) => `${r.subject} ${r.predicate} ${r.object}`;
37
+
38
+ /** Every class name domain.classMembers declares — a member that is also a
39
+ * key names a class-to-class edge; anything else is a plain individual. */
40
+ function classNamesOf(classMembers) {
41
+ return new Set(Object.keys(classMembers || {}));
42
+ }
43
+
44
+ /** {rdf:type, rdfs:subClassOf}-tagged edges, one per (member, class) pair,
45
+ * sorted for deterministic output. */
46
+ function ontologyEdges(classMembers) {
47
+ const classNames = classNamesOf(classMembers);
48
+ const edges = [];
49
+ for (const cls of Object.keys(classMembers || {}).sort()) {
50
+ for (const member of [...(classMembers[cls] || [])].sort()) {
51
+ edges.push({
52
+ predicate: classNames.has(member) ? "rdfs:subClassOf" : "rdf:type",
53
+ subject: member,
54
+ object: cls,
55
+ });
56
+ }
57
+ }
58
+ return edges;
59
+ }
60
+
61
+ /** `member`'s direct class (first match in sorted class-name order), or null
62
+ * when `member` is itself a class name (not an individual) or untyped. */
63
+ function directClassOf(classMembers, member) {
64
+ if (classNamesOf(classMembers).has(member)) return null;
65
+ for (const cls of Object.keys(classMembers || {}).sort()) {
66
+ if ((classMembers[cls] || []).includes(member)) return cls;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** Every plain individual (a member that is never itself a class name),
72
+ * sorted, deduplicated across every class it happens to appear under. */
73
+ function individualsOf(classMembers) {
74
+ const classNames = classNamesOf(classMembers);
75
+ const out = new Set();
76
+ for (const members of Object.values(classMembers || {})) {
77
+ for (const m of members || []) if (!classNames.has(m)) out.add(m);
78
+ }
79
+ return [...out].sort();
80
+ }
81
+
82
+ /** The :objects block's lines — individuals grouped by their direct class,
83
+ * PDDL's own `a b c - type` typed-list shorthand. Untyped individuals (no
84
+ * direct class found) list on their own trailing line rather than being
85
+ * silently dropped. */
86
+ function objectsLines(classMembers) {
87
+ const byClass = new Map();
88
+ const untyped = [];
89
+ for (const member of individualsOf(classMembers)) {
90
+ const cls = directClassOf(classMembers, member);
91
+ if (!cls) { untyped.push(member); continue; }
92
+ if (!byClass.has(cls)) byClass.set(cls, []);
93
+ byClass.get(cls).push(member);
94
+ }
95
+ const lines = [...byClass.keys()].sort().map((cls) => ` ${byClass.get(cls).join(" ")} - ${cls}`);
96
+ if (untyped.length) lines.push(` ${untyped.join(" ")}`);
97
+ return lines;
98
+ }
99
+
100
+ /** A goal spec ({universal, term, predicate, object}) expanded into concrete
101
+ * ground atoms — a universal spec over every member domain.classMembers
102
+ * names for `term` (compileGoal's own expansion, domain.mjs), a non-
103
+ * universal spec as the single named atom. A universal term with no known
104
+ * members expands to nothing (never a placeholder atom naming an unknown
105
+ * member). */
106
+ function goalAtoms(specs, classMembers) {
107
+ const atoms = [];
108
+ for (const spec of specs || []) {
109
+ const predicate = attachPrefix(spec.predicate);
110
+ const members = spec.universal ? [...(classMembers?.[spec.term] || [])].sort() : [spec.term];
111
+ for (const member of members) atoms.push({ subject: member, predicate, object: spec.object });
112
+ }
113
+ return atoms;
114
+ }
115
+
116
+ /** One action's :precondition/:effect block as a mechanical diff between the
117
+ * before (`before`) and after (`after`) state snapshots — every fact row
118
+ * present before and absent after is a precondition that stopped holding
119
+ * (rendered as `(not …)` in the effect); every row absent before and
120
+ * present after is newly asserted. No inference beyond the two snapshots
121
+ * themselves — a rule's own guard conditions that never toggle a row (e.g.
122
+ * "nothing may rest on the target") leave no diff and so render nothing
123
+ * here; the taught rule's `becauseText` names them in prose instead. */
124
+ function diffAction(before, after) {
125
+ const beforeByKey = new Map((before || []).map((r) => [factKeyOf(r), r]));
126
+ const afterByKey = new Map((after || []).map((r) => [factKeyOf(r), r]));
127
+ const removed = [...beforeByKey.entries()].filter(([k]) => !afterByKey.has(k)).map(([, r]) => r);
128
+ const added = [...afterByKey.entries()].filter(([k]) => !beforeByKey.has(k)).map(([, r]) => r);
129
+ const bySubjObj = (a, b) => (a.subject === b.subject ? (a.object < b.object ? -1 : 1) : a.subject < b.subject ? -1 : 1);
130
+ return { removed: removed.sort(bySubjObj), added: added.sort(bySubjObj) };
131
+ }
132
+
133
+ /**
134
+ * A PDDL-style `(define (problem …) …)` block plus one `(:action …)` per
135
+ * plan step, each carrying tmct's own real ontology tags — the richest
136
+ * textual form of a solved plan the engine can express, for a visitor to
137
+ * read alongside the visual block/circle render, not instead of it.
138
+ *
139
+ * `plan`: the plan-lane contract ({ actions, states, stepGoals, goal,
140
+ * domain: { classMembers, ordering } }) — see chat.mjs's planLaneAnswer.
141
+ * Returns a string; `""` for a plan with no actions and no init facts (an
142
+ * already-satisfied goal has nothing to narrate).
143
+ */
144
+ export function planToPddl(plan, { problemName = "tmct-plan", domainName = "tmct-taught-domain" } = {}) {
145
+ const actions = plan?.actions || [];
146
+ const states = plan?.states || [];
147
+ const classMembers = plan?.domain?.classMembers || {};
148
+ const ordering = plan?.domain?.ordering || [];
149
+ const goalText = plan?.goal?.text || "";
150
+ const goal = goalAtoms(plan?.goal?.specs, classMembers);
151
+ const init = (states[0] || []);
152
+
153
+ const lines = [];
154
+ lines.push(`;; tmct plan artifact — PDDL-style action sequence + OWL/RDF ontology tags`);
155
+ lines.push(`;; goal: ${goalText || "(none stated)"}`);
156
+ if (plan?.becauseText) lines.push(`;; because: ${plan.becauseText}`);
157
+ lines.push("");
158
+ lines.push(`(define (problem ${slug(problemName) || "tmct-plan"})`);
159
+ lines.push(` (:domain ${slug(domainName) || "tmct-taught-domain"})`);
160
+ lines.push("");
161
+
162
+ const objLines = objectsLines(classMembers);
163
+ if (objLines.length) {
164
+ lines.push(" ;; :objects — individuals grounded through the taught class hierarchy");
165
+ lines.push(" (:objects");
166
+ lines.push(...objLines);
167
+ lines.push(" )");
168
+ lines.push("");
169
+ }
170
+
171
+ const edges = ontologyEdges(classMembers);
172
+ if (edges.length) {
173
+ lines.push(" ;; :ontology — the real rdf:type/rdfs:subClassOf rows compileDomain folded");
174
+ lines.push(" ;; into domain.classMembers (rdf:type = individual->class, rdfs:subClassOf =");
175
+ lines.push(" ;; class->superclass)");
176
+ lines.push(" (:ontology");
177
+ for (const e of edges) lines.push(` (${e.predicate} ${e.subject} ${e.object})`);
178
+ lines.push(" )");
179
+ lines.push("");
180
+ }
181
+
182
+ const orderingRows = [...ordering].sort((a, b) => (a.subject === b.subject ? (a.object < b.object ? -1 : 1) : a.subject < b.subject ? -1 : 1));
183
+ if (orderingRows.length) {
184
+ lines.push(" ;; :ordering — the real mgx:*-than facts the taught precondition consulted");
185
+ lines.push(" (:ordering");
186
+ for (const r of orderingRows) lines.push(` ${factAtom(r)}`);
187
+ lines.push(" )");
188
+ lines.push("");
189
+ }
190
+
191
+ if (init.length) {
192
+ lines.push(" ;; :init — state@0, the taught starting board (real mgx:* predicate tags)");
193
+ lines.push(" (:init");
194
+ for (const r of [...init].sort((a, b) => (a.subject === b.subject ? (a.object < b.object ? -1 : 1) : a.subject < b.subject ? -1 : 1))) {
195
+ lines.push(` ${factAtom(r)}`);
196
+ }
197
+ lines.push(" )");
198
+ lines.push("");
199
+ }
200
+
201
+ if (goal.length) {
202
+ lines.push(" ;; :goal");
203
+ lines.push(" (:goal (and");
204
+ for (const a of goal) lines.push(` ${factAtom(a)}`);
205
+ lines.push(" ))");
206
+ }
207
+ lines.push(")");
208
+
209
+ if (actions.length) {
210
+ lines.push("");
211
+ lines.push(`;; action sequence — findActionPath's own shortest path (${actions.length} move${actions.length === 1 ? "" : "s"})`);
212
+ actions.forEach((action, i) => {
213
+ const before = states[i] || [];
214
+ const after = states[i + 1] || [];
215
+ const { removed, added } = diffAction(before, after);
216
+ const name = `${slug(action.name) || "move"}-step${i + 1}`;
217
+ lines.push("");
218
+ lines.push(`(:action ${name}`);
219
+ lines.push(` :label "${action.label || `${action.name} ${action.subject} ${action.target}`}"`);
220
+ lines.push(` :subject ${action.subject}`);
221
+ lines.push(` :target ${action.target}`);
222
+ if (removed.length) {
223
+ lines.push(" :precondition (and");
224
+ for (const r of removed) lines.push(` ${factAtom(r)}`);
225
+ lines.push(" )");
226
+ } else {
227
+ lines.push(" :precondition (and)");
228
+ }
229
+ const effectLines = [
230
+ ...removed.map((r) => ` (not ${factAtom(r)})`),
231
+ ...added.map((r) => ` ${factAtom(r)}`),
232
+ ];
233
+ if (effectLines.length) {
234
+ lines.push(" :effect (and");
235
+ lines.push(...effectLines);
236
+ lines.push(" )");
237
+ } else {
238
+ lines.push(" :effect (and)");
239
+ }
240
+ lines.push(")");
241
+ });
242
+ }
243
+
244
+ return `${lines.join("\n")}\n`;
245
+ }