@polycode-projects/the-mechanical-code-talker 2.8.1 → 2.8.4

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.
@@ -170,6 +170,11 @@ export function computeLedgerDataFromPayload(payload, { focus, term, rowLimit =
170
170
  contradictions: { count: contradictions.length, firstFocusTerm: firstContra ? firstContra.s : null },
171
171
  biggestHub: terms.length ? { term: terms[0].term, degree: terms[0].degree } : null,
172
172
  };
173
+ // Dashboard-strip stats: always over the FULL rows/terms/contradictions —
174
+ // computed here, before the row-cap section below, so a huge graph's
175
+ // tile numbers stay the whole-graph truth even when the ledger view itself
176
+ // degrades to "recent + local".
177
+ const stats = computeLedgerStats(rows, terms, contradictions);
173
178
 
174
179
  // Row cap: the focus 2-hop neighborhood survives first (the minimap's own
175
180
  // radius, so facet counts and the map stay mutually correct), the rest by
@@ -190,7 +195,7 @@ export function computeLedgerDataFromPayload(payload, { focus, term, rowLimit =
190
195
  }
191
196
  const meta = { shown: shownRows.length, total, truncated: shownRows.length < total };
192
197
 
193
- return { rows: shownRows, terms, edges, focus: focusTerm, contradictions, worthALook, payload, meta };
198
+ return { rows: shownRows, terms, edges, focus: focusTerm, contradictions, worthALook, payload, meta, stats };
194
199
  }
195
200
 
196
201
  /** Load the memory graph under `repoDir` and derive the ledger data. Never
@@ -261,11 +266,207 @@ export function facetCounts(rows, focus, sel, now) {
261
266
  return counts;
262
267
  }
263
268
 
269
+ // ---- dashboard-strip stats: real counts over the FULL fact-row set --------
270
+ // (never the row-capped view — meta.total's own honesty contract) for the
271
+ // telemetry-style tiles atop the existing 3-column layout.
272
+
273
+ const BUNDLE_TOP_N = 6;
274
+ const PREDICATE_TOP_N = 6;
275
+ const SPARK_MAX_POINTS = 48;
276
+
277
+ /** Corpus-bundle grouping key for one fact's `src` string: the first
278
+ * pipe-segment, folded to its stable source-family prefix
279
+ * ("teach:chat:<session>@<ts>" -> "teach:chat", "corpus:human /r/IsA" ->
280
+ * "corpus:human", "import:hanoi-3.txt" stays whole — the filename IS the
281
+ * bundle). A closed table with a verbatim fallback, same posture as
282
+ * phraseFor: an unrecognized tag still reads as itself. */
283
+ export function bundleKeyFor(src) {
284
+ const first = String(src || "").split(" | ")[0].trim();
285
+ if (!first) return "unrecorded";
286
+ if (first.startsWith("teach:chat")) return "teach:chat";
287
+ if (first.startsWith("ace:chat")) return "ace:chat";
288
+ if (first === "operator" || first.startsWith("operator:")) return "operator";
289
+ if (first.startsWith("entailed")) {
290
+ const rule = first.slice("entailed".length).replace(/^[:\s]+/, "").split(/[\s:]/)[0];
291
+ return rule ? `entailed:${rule}` : "entailed";
292
+ }
293
+ if (first.startsWith("corpus:")) {
294
+ const name = first.slice("corpus:".length).split(" ")[0];
295
+ return name ? `corpus:${name}` : "corpus";
296
+ }
297
+ return first; // import:<file>, provider/reference/web sourceTypes, etc — verbatim
298
+ }
299
+
300
+ /** Human label for a bundleKeyFor() key. */
301
+ export function bundleLabelFor(key) {
302
+ const k = String(key || "");
303
+ if (k === "teach:chat") return "taught via chat";
304
+ if (k === "ace:chat") return "taught (parsed)";
305
+ if (k === "operator") return "operator-asserted";
306
+ if (k === "unrecorded") return "unrecorded";
307
+ if (k.startsWith("corpus:")) return `corpus: ${k.slice(7)}`;
308
+ if (k.startsWith("import:")) return `imported: ${k.slice(7)}`;
309
+ if (k.startsWith("entailed:")) return `entailed: ${k.slice(9)}`;
310
+ if (k === "entailed") return "entailed";
311
+ return k;
312
+ }
313
+
314
+ /** Dashboard-strip stats over `rows` (the FULL set — pass it before any
315
+ * row-cap slicing) and `terms`/`contradictions` from the same derivation.
316
+ * Every number is a straight count or ratio over real rows; nothing here is
317
+ * estimated or placeholder. The ingestion sparkline plots cumulative fact
318
+ * count in TEACH order (oldest first), not wall-clock time — a store built
319
+ * in one script run has timestamps seconds apart, so order carries the
320
+ * signal the clock can't. */
321
+ export function computeLedgerStats(rows, terms, contradictions) {
322
+ const list = rows || [];
323
+ const byProv = { taught: 0, corpus: 0, entailed: 0 };
324
+ const byTier = { 1: 0, 2: 0, 3: 0 };
325
+ const bundleCounts = new Map();
326
+ const predicateCounts = new Map(); // predicate -> { phrase, count }
327
+ for (const r of list) {
328
+ byProv[r.prov] = (byProv[r.prov] || 0) + 1;
329
+ byTier[r.trustTier] = (byTier[r.trustTier] || 0) + 1;
330
+ const bk = bundleKeyFor(r.src);
331
+ bundleCounts.set(bk, (bundleCounts.get(bk) || 0) + 1);
332
+ const pc = predicateCounts.get(r.p) || { phrase: r.phrase, count: 0 };
333
+ pc.count += 1;
334
+ predicateCounts.set(r.p, pc);
335
+ }
336
+ const bundles = [...bundleCounts.entries()]
337
+ .map(([key, count]) => ({ key, label: bundleLabelFor(key), count }))
338
+ .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
339
+ .slice(0, BUNDLE_TOP_N);
340
+ const predicates = [...predicateCounts.entries()]
341
+ .map(([predicate, v]) => ({ predicate, phrase: v.phrase, count: v.count }))
342
+ .sort((a, b) => b.count - a.count || a.predicate.localeCompare(b.predicate))
343
+ .slice(0, PREDICATE_TOP_N);
344
+
345
+ const totalDegree = (terms || []).reduce((sum, t) => sum + (t.degree || 0), 0);
346
+ const density = terms && terms.length ? totalDegree / terms.length : 0;
347
+
348
+ // Cumulative fact count in teach order, sampled to SPARK_MAX_POINTS for a
349
+ // large graph — every plotted value is a real cumulative count at a real
350
+ // index, never interpolated.
351
+ const ordered = list.filter((r) => r.createdAt).slice().reverse(); // rows sort newest-first
352
+ const n = ordered.length;
353
+ const sparkline = [];
354
+ if (n > 0) {
355
+ const stride = Math.max(1, Math.ceil(n / SPARK_MAX_POINTS));
356
+ for (let i = stride - 1; i < n; i += stride) sparkline.push(i + 1);
357
+ if (sparkline[sparkline.length - 1] !== n) sparkline.push(n);
358
+ }
359
+
360
+ return {
361
+ totalFacts: list.length,
362
+ totalTerms: (terms || []).length,
363
+ byProv, byTier, bundles, predicates, density,
364
+ contradictionCount: (contradictions || []).length,
365
+ firstLearned: n ? ordered[0].createdAt : null,
366
+ lastLearned: n ? ordered[n - 1].createdAt : null,
367
+ sparkline,
368
+ };
369
+ }
370
+
371
+ const pct = (n, total) => (total > 0 ? (n / total) * 100 : 0);
372
+
373
+ /** The facts.by-tier tile: a proportional bar plus a counts legend, both over
374
+ * the SAME three taught/corpus/entailed tokens the rest of the page already
375
+ * colors provenance with. Server-rendered once; the tier split doesn't
376
+ * change with the interactive segment filters below it. */
377
+ function tierTileHtml(byProv, total) {
378
+ const segs = [["taught", byProv.taught || 0], ["corpus", byProv.corpus || 0], ["entail", byProv.entailed || 0]];
379
+ const bar = segs.map(([cls, n]) => `<span class="tseg t-${cls}" style="width:${pct(n, total).toFixed(2)}%"></span>`).join("");
380
+ const legend = segs.map(([cls, n]) => `<span class="tleg t-${cls}">${n} ${cls === "entail" ? "entailed" : cls}</span>`).join("");
381
+ return `<div class="tierbar" role="img" aria-label="${segs.map(([c, n]) => `${n} ${c === "entail" ? "entailed" : c}`).join(", ")} of ${total} facts">${bar}</div><div class="tierlegend">${legend}</div>`;
382
+ }
383
+
384
+ /** A small leaderboard bar list — bundles or predicates, each scaled to the
385
+ * list's own max (not the grand total), like a top-N panel on a dashboard. */
386
+ function microbarsHtml(items) {
387
+ const max = items.reduce((m, it) => Math.max(m, it.count), 0) || 1;
388
+ return items.map((it) =>
389
+ `<div class="bbar"><span class="bblabel">${escapeHtml(it.label)}</span><span class="bbtrack"><span class="bbfill" style="width:${pct(it.count, max).toFixed(1)}%"></span></span><span class="bbn">${it.count}</span></div>`,
390
+ ).join("");
391
+ }
392
+
393
+ /** The dashboard strip: fact/term totals, the tier bar, graph density, a data-
394
+ * quality tile keyed off the SAME contradiction count worthALook surfaces,
395
+ * and the corpus-bundle/predicate leaderboards. Every tile reads straight
396
+ * off `stats` (computeLedgerStats) — no client-side recomputation. */
397
+ function dashboardHtml(stats) {
398
+ const s = stats || {};
399
+ const total = s.totalFacts || 0;
400
+ if (!total) {
401
+ return `<section class="dash" aria-label="Ledger metrics"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></section>`;
402
+ }
403
+ const terms = s.totalTerms || 0;
404
+ const qualityCls = s.contradictionCount > 0 ? " tile-alert" : "";
405
+ const bundlesHtml = s.bundles?.length ? microbarsHtml(s.bundles) : `<span class="tile-sub">no sources recorded</span>`;
406
+ const predicatesHtml = s.predicates?.length
407
+ ? microbarsHtml(s.predicates.map((p) => ({ label: p.phrase || p.predicate, count: p.count })))
408
+ : `<span class="tile-sub">none yet</span>`;
409
+ return `<section class="dash" aria-label="Ledger metrics">
410
+ <div class="tile">
411
+ <span class="tile-label">facts.total</span>
412
+ <span class="tile-value">${total}</span>
413
+ <span class="tile-sub">${terms} term${terms === 1 ? "" : "s"} tracked</span>
414
+ </div>
415
+ <div class="tile tile-wide">
416
+ <span class="tile-label">facts.by-tier</span>
417
+ ${tierTileHtml(s.byProv || {}, total)}
418
+ </div>
419
+ <div class="tile">
420
+ <span class="tile-label">graph.avg-degree</span>
421
+ <span class="tile-value">${(s.density || 0).toFixed(1)}</span>
422
+ <span class="tile-sub">facts per term</span>
423
+ </div>
424
+ <div class="tile${qualityCls}">
425
+ <span class="tile-label">data.quality</span>
426
+ <span class="tile-value">${s.contradictionCount || 0}</span>
427
+ <span class="tile-sub">term${s.contradictionCount === 1 ? "" : "s"} with more than one answer</span>
428
+ </div>
429
+ <div class="tile tile-wide">
430
+ <span class="tile-label">corpus.bundles</span>
431
+ <div class="bundlebars">${bundlesHtml}</div>
432
+ </div>
433
+ <div class="tile tile-wide">
434
+ <span class="tile-label">predicate.top</span>
435
+ <div class="bundlebars">${predicatesHtml}</div>
436
+ </div>
437
+ </section>`;
438
+ }
439
+
440
+ /** The aside's ingestion sparkline: a real cumulative-count polyline over
441
+ * `stats.sparkline` (see computeLedgerStats), server-rendered as inline SVG
442
+ * so it needs no client JS and repaints for free on a theme switch via the
443
+ * CSS custom properties its classes resolve against. */
444
+ function sparklineSvg(stats) {
445
+ const pts = stats?.sparkline || [];
446
+ const W = 200, H = 44, PAD = 3;
447
+ if (!pts.length) return `<p class="mapnote">not enough dated facts yet</p>`;
448
+ if (pts.length === 1) {
449
+ return `<svg class="spark" viewBox="0 0 ${W} ${H}" role="img" aria-label="1 fact taught"><circle class="dot" cx="${W / 2}" cy="${H / 2}" r="2.5"></circle></svg>`;
450
+ }
451
+ const max = pts[pts.length - 1]; // cumulative and monotonic — last is the max
452
+ const stepX = (W - PAD * 2) / (pts.length - 1);
453
+ const y = (v) => H - PAD - (v / max) * (H - PAD * 2);
454
+ const coords = pts.map((v, i) => [PAD + i * stepX, y(v)]);
455
+ const line = coords.map(([x, yy], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${yy.toFixed(1)}`).join(" ");
456
+ const area = `${line} L${coords[coords.length - 1][0].toFixed(1)},${H} L${coords[0][0].toFixed(1)},${H} Z`;
457
+ const last = coords[coords.length - 1];
458
+ return `<svg class="spark" viewBox="0 0 ${W} ${H}" role="img" aria-label="${pts.length} sampled points, ${max} facts total">
459
+ <path class="fill" d="${area}"></path>
460
+ <path class="line" d="${line}"></path>
461
+ <circle class="dot" cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="2.5"></circle>
462
+ </svg>`;
463
+ }
464
+
264
465
  /** One complete, self-contained document: the ledger, segment rail,
265
466
  * worth-a-look panel, breadcrumb/search, two-hop minimap, and (when the
266
467
  * memory-ask bundle is present) the ask-the-graph chat dock, all over the
267
468
  * embedded LEDGER/PAYLOAD data. */
268
- export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle } = {}) {
469
+ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats } = {}) {
269
470
  const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false } });
270
471
  const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
271
472
  const shown = meta?.shown ?? (rows || []).length;
@@ -287,6 +488,11 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
287
488
  </form>
288
489
  </div>`
289
490
  : `<div class="chat chat-off"><p class="chatnote">chat unavailable — run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
491
+ const sparkCaption = stats?.firstLearned && stats?.lastLearned
492
+ ? (stats.firstLearned.slice(0, 10) === stats.lastLearned.slice(0, 10)
493
+ ? `learned ${escapeHtml(stats.lastLearned.slice(0, 10))}`
494
+ : `first ${escapeHtml(stats.firstLearned.slice(0, 10))} &middot; last ${escapeHtml(stats.lastLearned.slice(0, 10))}`)
495
+ : "cumulative facts, teach order";
290
496
 
291
497
  return `<!doctype html>
292
498
  <html lang="en">
@@ -312,6 +518,30 @@ ${THEME_TOKENS_CSS}
312
518
  .search { margin-left: auto; display: flex; align-items: center; gap: .5rem; }
313
519
  .search input { font-family: ${MONO_STACK}; font-size: .78rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .3rem .6rem; width: 170px; }
314
520
  .search .miss { font-size: .72rem; color: var(--alert); font-family: ${MONO_STACK}; }
521
+ .dash { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: .6rem; margin: 0 0 1.1rem; }
522
+ .tile { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .55rem .7rem .6rem; display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
523
+ .tile-wide { grid-column: span 2; }
524
+ .tile-alert { border-color: var(--alert); }
525
+ .tile-label { font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
526
+ .tile-value { font-family: ${MONO_STACK}; font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; }
527
+ .tile-alert .tile-value { color: var(--alert); }
528
+ .tile-sub { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
529
+ .tierbar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: var(--line); margin-top: .3rem; }
530
+ .tseg { height: 100%; }
531
+ .tseg.t-taught { background: var(--taught); } .tseg.t-corpus { background: var(--corpus); } .tseg.t-entail { background: var(--entail); }
532
+ .tierlegend { display: flex; flex-wrap: wrap; gap: .15rem .7rem; margin-top: .35rem; font-family: ${MONO_STACK}; font-size: .65rem; }
533
+ .tleg.t-taught { color: var(--taught); } .tleg.t-corpus { color: var(--corpus); } .tleg.t-entail { color: var(--entail); }
534
+ .bundlebars { display: flex; flex-direction: column; gap: .22rem; margin-top: .35rem; }
535
+ .bbar { display: grid; grid-template-columns: minmax(0,1fr) 3.4rem 1.6rem; align-items: center; gap: .4rem; font-family: ${MONO_STACK}; font-size: .68rem; }
536
+ .bblabel { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
537
+ .bbtrack { background: var(--line); border-radius: 99px; height: 5px; overflow: hidden; }
538
+ .bbfill { display: block; height: 100%; background: var(--ink); opacity: .55; border-radius: 99px; }
539
+ .bbn { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
540
+ .sparkwrap { margin-top: .5rem; }
541
+ .spark { display: block; width: 100%; height: 44px; }
542
+ .spark .fill { fill: var(--muted); opacity: .18; stroke: none; }
543
+ .spark .line { fill: none; stroke: var(--ink); stroke-width: 1.4; }
544
+ .spark .dot { fill: var(--ink); }
315
545
  .app { display: grid; grid-template-columns: 190px minmax(0,1fr) 230px; gap: 1.3rem; grid-template-areas: "rail ledger aside"; }
316
546
  .rail { grid-area: rail; } .ledger { grid-area: ledger; } .aside { grid-area: aside; }
317
547
  @media (max-width: 880px) { .app { grid-template-columns: 1fr; grid-template-areas: "ledger" "aside" "rail"; } .search { margin-left: 0; } }
@@ -341,7 +571,7 @@ ${THEME_TOKENS_CSS}
341
571
  .chip { font-family: ${MONO_STACK}; font-size: .76rem; padding: .05rem .45rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); }
342
572
  button.chip:hover { border-color: var(--ink); }
343
573
  .chip.here { background: var(--ink); color: var(--bg); border-color: var(--ink); }
344
- .prov { margin-left: auto; font-family: ${MONO_STACK}; font-size: .64rem; padding: .08rem .5rem; border-radius: 99px; white-space: nowrap; }
574
+ .prov { margin-left: auto; font-family: ${MONO_STACK}; font-size: .64rem; padding: .08rem .5rem; border-radius: 99px; max-width: 100%; overflow-wrap: anywhere; }
345
575
  .prov.p-taught { color: var(--taught); background: var(--taught-soft); }
346
576
  .prov.p-corpus { color: var(--corpus); background: var(--corpus-soft); }
347
577
  .prov.p-entail { color: var(--entail); background: var(--entail-soft); }
@@ -376,6 +606,7 @@ ${THEME_TOKENS_CSS}
376
606
  <main>
377
607
  <div class="eyebrow"><span>tmct &middot; memory ledger</span><span id="counts"></span></div>
378
608
  <h1>A graph you can read</h1>
609
+ ${dashboardHtml(stats)}
379
610
  <div class="topbar">
380
611
  <nav class="crumbs" id="crumbs" aria-label="Focus trail"></nav>
381
612
  <div class="search">
@@ -400,6 +631,11 @@ ${THEME_TOKENS_CSS}
400
631
  <canvas id="map" width="230" height="160" aria-label="Two-hop neighborhood minimap"></canvas>
401
632
  <p class="mapnote">dots = terms &middot; click to refocus &middot; dim = filtered out</p>
402
633
  </div>
634
+ <h2>ingestion</h2>
635
+ <div class="mapwrap sparkwrap">
636
+ ${sparklineSvg(stats)}
637
+ <p class="mapnote">${sparkCaption}</p>
638
+ </div>
403
639
  </aside>
404
640
  </div>
405
641
  </main>
@@ -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
+ }