@polycode-projects/the-mechanical-code-talker 3.0.5 → 3.0.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.
Files changed (53) hide show
  1. package/README.md +6 -6
  2. package/corpus/child/README.md +2 -2
  3. package/corpus/child/index.json.gz +0 -0
  4. package/corpus/child/manifest.json +74 -74
  5. package/corpus/child/shards/child-00.jsonl.gz +0 -0
  6. package/corpus/child/shards/child-01.jsonl.gz +0 -0
  7. package/corpus/child/shards/child-02.jsonl.gz +0 -0
  8. package/corpus/child/shards/child-03.jsonl.gz +0 -0
  9. package/corpus/child/shards/child-04.jsonl.gz +0 -0
  10. package/corpus/child/shards/child-05.jsonl.gz +0 -0
  11. package/corpus/child/shards/child-06.jsonl.gz +0 -0
  12. package/corpus/child/shards/child-07.jsonl.gz +0 -0
  13. package/corpus/child/shards/child-08.jsonl.gz +0 -0
  14. package/corpus/child/shards/child-09.jsonl.gz +0 -0
  15. package/corpus/child/shards/child-0a.jsonl.gz +0 -0
  16. package/corpus/child/shards/child-0b.jsonl.gz +0 -0
  17. package/corpus/child/shards/child-0c.jsonl.gz +0 -0
  18. package/corpus/child/shards/child-0d.jsonl.gz +0 -0
  19. package/corpus/child/shards/child-0e.jsonl.gz +0 -0
  20. package/corpus/child/shards/child-0f.jsonl.gz +0 -0
  21. package/corpus/child/shards/child-10.jsonl.gz +0 -0
  22. package/corpus/child/shards/child-11.jsonl.gz +0 -0
  23. package/corpus/child/shards/child-12.jsonl.gz +0 -0
  24. package/corpus/child/shards/child-13.jsonl.gz +0 -0
  25. package/corpus/child/shards/child-14.jsonl.gz +0 -0
  26. package/corpus/child/shards/child-15.jsonl.gz +0 -0
  27. package/corpus/child/shards/child-16.jsonl.gz +0 -0
  28. package/corpus/child/shards/child-17.jsonl.gz +0 -0
  29. package/corpus/child/shards/child-18.jsonl.gz +0 -0
  30. package/corpus/child/shards/child-19.jsonl.gz +0 -0
  31. package/corpus/child/shards/child-1a.jsonl.gz +0 -0
  32. package/corpus/child/shards/child-1b.jsonl.gz +0 -0
  33. package/corpus/child/shards/child-1c.jsonl.gz +0 -0
  34. package/corpus/child/shards/child-1d.jsonl.gz +0 -0
  35. package/corpus/child/shards/child-1e.jsonl.gz +0 -0
  36. package/corpus/child/shards/child-1f.jsonl.gz +0 -0
  37. package/corpus/conceptnet/quality-filter.mjs +28 -4
  38. package/corpus/tier2/generate.mjs +4 -8
  39. package/corpus/tier2/human-large.jsonl +0 -2
  40. package/corpus/tier2/human-medium.jsonl +0 -2
  41. package/corpus/tier2/manifest.json +6 -6
  42. package/data/templates/constructions/digest-sentence-structures.toml +146 -1
  43. package/package.json +1 -1
  44. package/src/adapters/corpus/digest-bank.mjs +15 -3
  45. package/src/domain/digest/compose.mjs +142 -10
  46. package/src/domain/digest/config.json +5 -0
  47. package/src/domain/digest/structures.mjs +39 -14
  48. package/src/services/chat-page-viz.mjs +15 -4
  49. package/src/services/chat.mjs +118 -49
  50. package/src/services/extract-facts.mjs +46 -4
  51. package/src/services/research.mjs +80 -3
  52. package/src/surfaces/web/chat-browser-entry.mjs +23 -3
  53. package/src/surfaces/web/memory-ask-browser.bundle.js +108 -108
@@ -10,19 +10,30 @@
10
10
  # reads only [[relation]] and [[construction]] tables, so it ignores this file's
11
11
  # [[structure]] rows; the digest layer reads them through its own path.
12
12
  #
13
- # Each row is one (family, form) pairing:
13
+ # Each row is one wording for a (family, form) pairing. A key may hold several
14
+ # rows: the renderer collects them all and picks one by a deterministic hash of
15
+ # term|family|form|variantSeed, so a key with one wording always renders that
16
+ # wording, while a key with several gives natural variety that stays stable
17
+ # across runs. An exact-string duplicate under a key is folded; a second, truly
18
+ # different wording is kept as another variant.
14
19
  # family one of the digest families (isa | location | partOf | capableOf |
15
20
  # usedFor) — the closed set stage 1 groups predicates under. Each is
16
21
  # verb-coherent: every predicate in it shares this frame's verb.
17
22
  # form single — one fact of the family
18
23
  # several — two or more facts of the family, merged into one clause
19
24
  # chained — one isa fact plus its rendered ancestry chain
25
+ # group — one body sentence of the multi-sentence isa digest: the
26
+ # facts that share an immediate parent class, named together
27
+ # closer — the closing sentence of the multi-sentence isa digest:
28
+ # the highest-level ontology roots the term sits under
20
29
  # template the skeleton. Placeholders the renderer fills (words.mjs supplies
21
30
  # the article, plural and casing):
22
31
  # {TERM} {TERM_CAP} the term, lower / sentence-leading
23
32
  # {A_TERM} {A_TERM_CAP} "an aardvark" / "An aardvark"
24
33
  # {TERMS} {TERMS_CAP} "aardvarks" / "Aardvarks"
25
34
  # {PRONOUN} {PRONOUN_CAP} "it" / "It" (after first mention)
35
+ # {PARENT} {A_PARENT} the shared immediate parent of a group
36
+ # — "mammal" / "a mammal"
26
37
  # {OBJECT} the first object, raw
27
38
  # {A_OBJECT} "a mammal" (article chosen per word)
28
39
  # {OBJECTS_A} "a mammal, a burrowing animal, and …"
@@ -48,6 +59,50 @@ family = "isa"
48
59
  form = "chained"
49
60
  template = "{A_TERM_CAP} is {CHAIN}."
50
61
 
62
+ # ---- isa group: one body sentence for facts under a shared parent class -----
63
+
64
+ [[structure]]
65
+ family = "isa"
66
+ form = "group"
67
+ template = "As a kind of {PARENT}, {PRONOUN} is also {OBJECTS_A}."
68
+
69
+ [[structure]]
70
+ family = "isa"
71
+ form = "group"
72
+ template = "Being {A_PARENT}, {PRONOUN} is {OBJECTS_A} too."
73
+
74
+ [[structure]]
75
+ family = "isa"
76
+ form = "group"
77
+ template = "Within the {PARENT} family, {PRONOUN} counts as {OBJECTS_A}."
78
+
79
+ [[structure]]
80
+ family = "isa"
81
+ form = "group"
82
+ template = "As {A_PARENT}, {PRONOUN} is {OBJECTS_A}."
83
+
84
+ # ---- isa closer: the highest-level roots the term sits under ----------------
85
+
86
+ [[structure]]
87
+ family = "isa"
88
+ form = "closer"
89
+ template = "More broadly, {TERMS} sit within {OBJECTS_A} in the wider hierarchy."
90
+
91
+ [[structure]]
92
+ family = "isa"
93
+ form = "closer"
94
+ template = "More generally, {TERMS} fall under {OBJECTS_A}."
95
+
96
+ [[structure]]
97
+ family = "isa"
98
+ form = "closer"
99
+ template = "Higher up, {TERMS} are kinds of {OBJECTS_PLURAL}."
100
+
101
+ [[structure]]
102
+ family = "isa"
103
+ form = "closer"
104
+ template = "Zooming out, {TERMS} belong among {OBJECTS_PLURAL}."
105
+
51
106
  # ---- location: where it is found (atLocation / locatedNear) ----------------
52
107
 
53
108
  [[structure]]
@@ -60,6 +115,21 @@ family = "location"
60
115
  form = "several"
61
116
  template = "{PRONOUN_CAP} is found in {OBJECTS_RAW}."
62
117
 
118
+ [[structure]]
119
+ family = "location"
120
+ form = "several"
121
+ template = "{PRONOUN_CAP} lives in {OBJECTS_RAW}."
122
+
123
+ [[structure]]
124
+ family = "location"
125
+ form = "several"
126
+ template = "You'll find {PRONOUN} in {OBJECTS_RAW}."
127
+
128
+ [[structure]]
129
+ family = "location"
130
+ form = "several"
131
+ template = "{PRONOUN_CAP} turns up in {OBJECTS_RAW}."
132
+
63
133
  # ---- partOf: what it belongs to -------------------------------------------
64
134
 
65
135
  [[structure]]
@@ -67,11 +137,41 @@ family = "partOf"
67
137
  form = "single"
68
138
  template = "{PRONOUN_CAP} is part of {A_OBJECT}."
69
139
 
140
+ [[structure]]
141
+ family = "partOf"
142
+ form = "single"
143
+ template = "{PRONOUN_CAP} belongs to {A_OBJECT}."
144
+
145
+ [[structure]]
146
+ family = "partOf"
147
+ form = "single"
148
+ template = "{PRONOUN_CAP} forms part of {A_OBJECT}."
149
+
150
+ [[structure]]
151
+ family = "partOf"
152
+ form = "single"
153
+ template = "{PRONOUN_CAP} makes up part of {A_OBJECT}."
154
+
70
155
  [[structure]]
71
156
  family = "partOf"
72
157
  form = "several"
73
158
  template = "{PRONOUN_CAP} is part of {OBJECTS_A}."
74
159
 
160
+ [[structure]]
161
+ family = "partOf"
162
+ form = "several"
163
+ template = "{PRONOUN_CAP} belongs to {OBJECTS_A}."
164
+
165
+ [[structure]]
166
+ family = "partOf"
167
+ form = "several"
168
+ template = "{PRONOUN_CAP} forms part of {OBJECTS_A}."
169
+
170
+ [[structure]]
171
+ family = "partOf"
172
+ form = "several"
173
+ template = "{PRONOUN_CAP} is one part of {OBJECTS_A}."
174
+
75
175
  # ---- capableOf: what it can do --------------------------------------------
76
176
 
77
177
  [[structure]]
@@ -84,6 +184,21 @@ family = "capableOf"
84
184
  form = "several"
85
185
  template = "{PRONOUN_CAP} can {OBJECTS_RAW}."
86
186
 
187
+ [[structure]]
188
+ family = "capableOf"
189
+ form = "several"
190
+ template = "{PRONOUN_CAP} is able to {OBJECTS_RAW}."
191
+
192
+ [[structure]]
193
+ family = "capableOf"
194
+ form = "several"
195
+ template = "{PRONOUN_CAP} knows how to {OBJECTS_RAW}."
196
+
197
+ [[structure]]
198
+ family = "capableOf"
199
+ form = "several"
200
+ template = "{PRONOUN_CAP} tends to {OBJECTS_RAW}."
201
+
87
202
  # ---- usedFor: what it is used for -----------------------------------------
88
203
 
89
204
  [[structure]]
@@ -91,7 +206,37 @@ family = "usedFor"
91
206
  form = "single"
92
207
  template = "{PRONOUN_CAP} is used for {OBJECT}."
93
208
 
209
+ [[structure]]
210
+ family = "usedFor"
211
+ form = "single"
212
+ template = "{PRONOUN_CAP} helps with {OBJECT}."
213
+
214
+ [[structure]]
215
+ family = "usedFor"
216
+ form = "single"
217
+ template = "People use {PRONOUN} for {OBJECT}."
218
+
219
+ [[structure]]
220
+ family = "usedFor"
221
+ form = "single"
222
+ template = "{PRONOUN_CAP} works for {OBJECT}."
223
+
94
224
  [[structure]]
95
225
  family = "usedFor"
96
226
  form = "several"
97
227
  template = "{PRONOUN_CAP} is used for {OBJECTS_RAW}."
228
+
229
+ [[structure]]
230
+ family = "usedFor"
231
+ form = "several"
232
+ template = "{PRONOUN_CAP} helps with {OBJECTS_RAW}."
233
+
234
+ [[structure]]
235
+ family = "usedFor"
236
+ form = "several"
237
+ template = "People use {PRONOUN} for {OBJECTS_RAW}."
238
+
239
+ [[structure]]
240
+ family = "usedFor"
241
+ form = "several"
242
+ template = "{PRONOUN_CAP} works for {OBJECTS_RAW}."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "3.0.5",
3
+ "version": "3.0.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -5,9 +5,15 @@
5
5
  // in src/domain/digest; only the TOML read lives here, so every node surface
6
6
  // (chat, `tmct digest`, the page generators) shares one seam.
7
7
  //
8
- // A browser bundle stubs this module out (the ask/chat dock never digests
9
- // in-page the pages digest client-side from a table embedded at build time),
10
- // so every consumer treats a null article as "fall back to the flat fact list".
8
+ // build-chat-bundle.mjs swaps this module for a live in-memory twin (the
9
+ // chat dock DOES digest in-page there chat-browser-entry.mjs feeds it the
10
+ // build-time-embedded [[structure]] rows via setDigestStructures); every
11
+ // other browser bundle (ask, ledger's static render) still stubs it out to
12
+ // null, so those consumers keep treating a null article as "fall back to the
13
+ // flat fact list". setDigestStructures below is a no-op on THIS, the real
14
+ // node-side module — kept only for interface parity with the live bundle
15
+ // twin, so a file that imports both under plain Node (as a chat-engine
16
+ // contract test does) resolves the same export set either way.
11
17
 
12
18
  import { readFileSync } from "node:fs";
13
19
  import { fileURLToPath } from "node:url";
@@ -19,6 +25,12 @@ import { digestStoreStats, chainsForObjects, isaObjectsOf } from "../../domain/d
19
25
  const HERE = dirname(fileURLToPath(import.meta.url));
20
26
  const BANK_FILE = join(HERE, "..", "..", "..", "data", "templates", "constructions", "digest-sentence-structures.toml");
21
27
 
28
+ /** No-op here — the node-side adapter always reads its structures from the
29
+ * committed TOML bank (below), never from an externally-supplied table. Only
30
+ * the browser bundle's live twin (build-chat-bundle.mjs's stub) actually
31
+ * holds state behind this setter. */
32
+ export function setDigestStructures() {}
33
+
22
34
  /** The raw [[structure]] rows parsed from the committed bank, once. A missing or
23
35
  * unparseable bank yields an empty list — the digest degrades to the caller's
24
36
  * flat fallback rather than throwing, the same posture the construction-bank
@@ -8,6 +8,7 @@
8
8
  import { renderStructure } from "./structures.mjs";
9
9
  import { FAMILY_PRIORITY } from "./select.mjs";
10
10
  import { articleFor, capitalizeFirst } from "./words.mjs";
11
+ import DEFAULT_CONFIG from "./config.json" with { type: "json" };
11
12
 
12
13
  const DESCRIPTION_FAMILIES = FAMILY_PRIORITY.filter((f) => f !== "isa" && f !== "other");
13
14
 
@@ -22,6 +23,137 @@ function rowsByFamily(selected) {
22
23
  return by;
23
24
  }
24
25
 
26
+ /** Split `rows` into runs of at most `size`, keeping order. Never returns an
27
+ * empty run; a size below 1 is treated as 1. */
28
+ export function chunk(rows, size) {
29
+ const n = Math.max(1, Math.trunc(size) || 1);
30
+ const out = [];
31
+ for (let i = 0; i < (rows || []).length; i += n) out.push(rows.slice(i, i + n));
32
+ return out;
33
+ }
34
+
35
+ /** Bucket isa rows by their object's immediate ancestor (`chains[object][1]`),
36
+ * so facts that share a parent class can be named in one coherent sentence.
37
+ * A parent with two or more rows is a real group; a parent with only one row,
38
+ * and any row whose object has no recorded ancestry, fold into one shared
39
+ * leftover pool. Groups come first in the order their earliest member ranked;
40
+ * the leftover pool, in rank order, comes last. Returns
41
+ * `[{ parent, rows }, …]` where `parent` is null for the leftover pool. */
42
+ export function groupIsaByParent(rows, chains) {
43
+ const parentOf = (row) => (chains?.[row.object] || [])[1] || null;
44
+ const counts = new Map();
45
+ for (const row of rows || []) {
46
+ const p = parentOf(row);
47
+ if (p) counts.set(p, (counts.get(p) || 0) + 1);
48
+ }
49
+ const groupRows = new Map();
50
+ const order = [];
51
+ const leftover = [];
52
+ for (const row of rows || []) {
53
+ const p = parentOf(row);
54
+ if (p && counts.get(p) >= 2) {
55
+ if (!groupRows.has(p)) { groupRows.set(p, []); order.push(p); }
56
+ groupRows.get(p).push(row);
57
+ } else {
58
+ leftover.push(row);
59
+ }
60
+ }
61
+ const groups = order.map((parent) => ({ parent, rows: groupRows.get(parent) }));
62
+ if (leftover.length) groups.push({ parent: null, rows: leftover });
63
+ return groups;
64
+ }
65
+
66
+ /** The highest-level ontology roots the spoken isa objects reach: for each row,
67
+ * the last (most general) node its ancestry chain walked. Roots rank by how
68
+ * many chains reach them (ties broken alphabetically for determinism); the top
69
+ * `count` are kept. Returns `{ roots, rows }` — `roots` names the concepts a
70
+ * closer sentence renders, `rows` are the real backing isa facts so the closer
71
+ * keeps its provenance without ever synthesising a row. */
72
+ export function closerRootsFor(chains, usedRows, count) {
73
+ const rootCount = new Map();
74
+ const rootRows = new Map();
75
+ for (const row of usedRows || []) {
76
+ const chain = chains?.[row.object];
77
+ if (!Array.isArray(chain) || chain.length < 2) continue;
78
+ const root = chain[chain.length - 1];
79
+ if (!root || root === row.object) continue;
80
+ rootCount.set(root, (rootCount.get(root) || 0) + 1);
81
+ if (!rootRows.has(root)) rootRows.set(root, []);
82
+ rootRows.get(root).push(row);
83
+ }
84
+ const roots = [...rootCount.keys()]
85
+ .sort((a, b) => (rootCount.get(b) - rootCount.get(a)) || a.localeCompare(b))
86
+ .slice(0, Math.max(0, count));
87
+ const rows = [];
88
+ const seen = new Set();
89
+ for (const root of roots) for (const r of rootRows.get(root) || []) {
90
+ if (!seen.has(r.id)) { seen.add(r.id); rows.push(r); }
91
+ }
92
+ return { roots, rows };
93
+ }
94
+
95
+ /** Compose the isa (definition) sentences. Gated purely on how many isa facts
96
+ * survived selection: none says nothing; one renders the single or chained
97
+ * form; a handful (up to the split threshold) merges into one clause. Past the
98
+ * threshold the run-on breaks apart — an opener names the top concepts, body
99
+ * sentences group the rest by shared parent, and a closer names the roots they
100
+ * all sit under. Returns tagged sentence objects, or an empty list. */
101
+ function composeIsaSection(isaRows, chains, term, table, config) {
102
+ const out = [];
103
+ if (!isaRows.length) return out;
104
+
105
+ if (isaRows.length === 1) {
106
+ const chain = chains[isaRows[0].object];
107
+ const form = chain && chain.length > 1 ? "chained" : "single";
108
+ const s = renderStructure(table, "isa", isaRows, { term, form, chain });
109
+ if (s) out.push({ ...s, role: "names-term", paragraph: "definition" });
110
+ return out;
111
+ }
112
+
113
+ if (isaRows.length <= config.groupSplitThreshold) {
114
+ const s = renderStructure(table, "isa", isaRows, { term, form: "several" });
115
+ if (s) out.push({ ...s, role: "names-term", paragraph: "definition" });
116
+ return out;
117
+ }
118
+
119
+ const openerRows = isaRows.slice(0, config.openerConceptCount);
120
+ const restRows = isaRows.slice(config.openerConceptCount);
121
+ const opener = renderStructure(table, "isa", openerRows, {
122
+ term, form: openerRows.length > 1 ? "several" : "single", variantSeed: term,
123
+ });
124
+ if (opener) out.push({ ...opener, role: "names-term", paragraph: "definition" });
125
+
126
+ let units = [];
127
+ for (const g of groupIsaByParent(restRows, chains)) {
128
+ for (const c of chunk(g.rows, config.maxObjectsPerBodySentence)) units.push({ parent: g.parent, rows: c });
129
+ }
130
+ // Cap the sentence count. Overflow folds into the last sentence rather than
131
+ // drop a selected fact — the merged sentence loses its single-parent coherence,
132
+ // so it renders as a plain "several" clause.
133
+ if (units.length > config.maxBodySentences) {
134
+ const kept = units.slice(0, config.maxBodySentences);
135
+ const last = kept[kept.length - 1];
136
+ for (const o of units.slice(config.maxBodySentences)) last.rows = last.rows.concat(o.rows);
137
+ last.parent = null;
138
+ units = kept;
139
+ }
140
+ for (const u of units) {
141
+ const s = u.parent
142
+ ? renderStructure(table, "isa", u.rows, { term, form: "group", parent: u.parent, variantSeed: u.parent })
143
+ : renderStructure(table, "isa", u.rows, { term, form: "several", variantSeed: term });
144
+ if (s) out.push({ ...s, role: "describes-isa", paragraph: "definition" });
145
+ }
146
+
147
+ const { roots, rows: closerRows } = closerRootsFor(chains, isaRows, config.closerRootCount);
148
+ if (roots.length) {
149
+ const s = renderStructure(table, "isa", closerRows.length ? closerRows : isaRows, {
150
+ term, form: "closer", objectsOverride: roots, variantSeed: term,
151
+ });
152
+ if (s) out.push({ ...s, role: "closes-isa", paragraph: "definition" });
153
+ }
154
+ return out;
155
+ }
156
+
25
157
  /** The first sentence must introduce the term. When no isa fact led (so the
26
158
  * first sentence opens with a bare "It"), rewrite that pronoun into the term's
27
159
  * own noun phrase so the pronoun has an antecedent. */
@@ -59,8 +191,11 @@ function paragraphsFrom(sentences, cap) {
59
191
  *
60
192
  * opts:
61
193
  * - chains: { object -> [object, parent, …] } ancestry chains, so a lone isa
62
- * fact can render as a chain ("a mammal, and so an animal") when one exists.
194
+ * fact can render as a chain ("a mammal, and so an animal") when one exists,
195
+ * and a many-fact term can split into opener, grouped body and closer.
63
196
  * - maxSentencesPerParagraph: the sentence cap (default 3).
197
+ * - config: digest tuning overrides (the group split threshold and the
198
+ * per-sentence / per-digest caps), defaulting to config.json.
64
199
  *
65
200
  * Returns { term, sentences, paragraphs, provenanceRows }. Every sentence in
66
201
  * `sentences` carries { text, rows, family, role, paragraph }; `paragraphs`
@@ -71,22 +206,19 @@ export function composeTermDigest(selection, table, opts = {}) {
71
206
  const term = selection?.term || "";
72
207
  const cap = Number.isInteger(opts.maxSentencesPerParagraph) ? opts.maxSentencesPerParagraph : 3;
73
208
  const chains = opts.chains || {};
209
+ const config = { ...DEFAULT_CONFIG, ...(opts.config || {}) };
74
210
  const byFamily = rowsByFamily(selection?.selected || []);
75
211
  const sentences = [];
76
212
 
77
- const isaRows = byFamily.get("isa") || [];
78
- if (isaRows.length) {
79
- const chain = isaRows.length === 1 ? chains[isaRows[0].object] : null;
80
- const form = chain && chain.length > 1 ? "chained" : (isaRows.length > 1 ? "several" : "single");
81
- const s = renderStructure(table, "isa", isaRows, { term, form, chain });
82
- if (s) sentences.push({ ...s, role: "names-term", paragraph: "definition" });
83
- }
213
+ for (const s of composeIsaSection(byFamily.get("isa") || [], chains, term, table, config)) sentences.push(s);
84
214
 
85
215
  for (const family of DESCRIPTION_FAMILIES) {
86
216
  const rows = byFamily.get(family);
87
217
  if (!rows || !rows.length) continue;
88
- const s = renderStructure(table, family, rows, { term });
89
- if (s) sentences.push({ ...s, role: "describes", paragraph: "description" });
218
+ for (const c of chunk(rows, config.maxObjectsPerBodySentence)) {
219
+ const s = renderStructure(table, family, c, { term });
220
+ if (s) sentences.push({ ...s, role: "describes", paragraph: "description" });
221
+ }
90
222
  }
91
223
 
92
224
  ensureFirstNamesTerm(sentences, term);
@@ -4,6 +4,11 @@
4
4
  "entailedDepthPenalty": 0.2,
5
5
  "minoritySensePenalty": 0.5,
6
6
  "minScore": 0.05,
7
+ "groupSplitThreshold": 4,
8
+ "openerConceptCount": 2,
9
+ "maxObjectsPerBodySentence": 4,
10
+ "maxBodySentences": 4,
11
+ "closerRootCount": 2,
7
12
  "budget": {
8
13
  "chatReply": 5,
9
14
  "researchPanel": 10,
@@ -6,9 +6,10 @@
6
6
  // sibling word helpers.
7
7
 
8
8
  import { articleFor, pluralOf, capitalizeFirst, series } from "./words.mjs";
9
+ import { fnv1a32 } from "../hash.mjs";
9
10
 
10
11
  const VALID_FAMILIES = new Set(["isa", "location", "partOf", "capableOf", "usedFor"]);
11
- const VALID_FORMS = new Set(["single", "several", "chained"]);
12
+ const VALID_FORMS = new Set(["single", "several", "chained", "group", "closer"]);
12
13
 
13
14
  const keyFor = (family, form) => `${family}:${form}`;
14
15
 
@@ -16,8 +17,10 @@ const keyFor = (family, form) => `${family}:${form}`;
16
17
  * Validate and index the raw [[structure]] rows into a Map keyed by
17
18
  * `family:form`. Closed-vocabulary discipline, same as the construction banks:
18
19
  * an unknown family or form, or a missing template, drops the row rather than
19
- * coercing it. First occurrence of a (family, form) wins; a later duplicate is
20
- * ignored.
20
+ * coercing it. Every valid row for a (family, form) is collected into that
21
+ * key's `templates` list, so the renderer can pick one deterministically from
22
+ * the pool; an exact-string duplicate is folded, but a second distinct wording
23
+ * is kept as another variant.
21
24
  */
22
25
  export function buildStructureTable(structures) {
23
26
  const table = new Map();
@@ -26,8 +29,9 @@ export function buildStructureTable(structures) {
26
29
  if (typeof s.form !== "string" || !VALID_FORMS.has(s.form)) continue;
27
30
  if (typeof s.template !== "string" || !s.template.trim()) continue;
28
31
  const key = keyFor(s.family, s.form);
29
- if (table.has(key)) continue;
30
- table.set(key, { family: s.family, form: s.form, template: s.template });
32
+ if (!table.has(key)) table.set(key, { family: s.family, form: s.form, templates: [] });
33
+ const entry = table.get(key);
34
+ if (!entry.templates.includes(s.template)) entry.templates.push(s.template);
31
35
  }
32
36
  return table;
33
37
  }
@@ -44,12 +48,15 @@ function renderChain(chain) {
44
48
  return `${head}, and so ${series(rest.map(withArticle))}`;
45
49
  }
46
50
 
47
- /** The slot values for one render, derived from the term, its objects and an
48
- * optional ancestry chain. A placeholder with no derived value renders empty. */
49
- function slotsFor(term, objects, chain) {
51
+ /** The slot values for one render, derived from the term, its objects, an
52
+ * optional ancestry chain and an optional parent class (the shared immediate
53
+ * ancestor a `group` sentence hangs off). A placeholder with no derived value
54
+ * renders empty. */
55
+ function slotsFor(term, objects, chain, parent) {
50
56
  const t = String(term || "").trim();
51
57
  const objs = (objects || []).map((o) => String(o || "").trim()).filter(Boolean);
52
58
  const first = objs[0] || "";
59
+ const p = String(parent || "").trim();
53
60
  return {
54
61
  TERM: t,
55
62
  TERM_CAP: capitalizeFirst(t),
@@ -59,6 +66,8 @@ function slotsFor(term, objects, chain) {
59
66
  TERMS_CAP: capitalizeFirst(pluralOf(t)),
60
67
  PRONOUN: "it",
61
68
  PRONOUN_CAP: "It",
69
+ PARENT: p,
70
+ A_PARENT: p ? withArticle(p) : "",
62
71
  OBJECT: first,
63
72
  A_OBJECT: first ? withArticle(first) : "",
64
73
  OBJECTS_A: series(objs.map(withArticle)),
@@ -82,9 +91,22 @@ function fill(template, slots) {
82
91
  * Render one clause for `facts` (all of one family) using the structure the
83
92
  * table holds for (family, form). `form` defaults by fact count — one fact is
84
93
  * "single", more is "several" — unless the caller names a form (e.g. "chained"
85
- * with an ancestry chain). Returns { text, rows, family, form } so the sentence
86
- * traces to the exact fact rows behind it, or null when no structure matches or
87
- * there are no facts.
94
+ * with an ancestry chain, or "group"/"closer" for the multi-sentence isa body).
95
+ *
96
+ * When a key holds more than one wording, the one that renders is picked by a
97
+ * deterministic FNV-1a hash of `term|family|form|variantSeed` — the same
98
+ * paraphrase-pool tool answer-variants.mjs uses — so the choice is stable across
99
+ * runs but varies by term (and by `variantSeed`, e.g. the shared parent, so two
100
+ * group sentences under one term can read differently). A single-wording key
101
+ * always resolves to that one wording.
102
+ *
103
+ * `opts.objectsOverride` renders words other than the raw fact objects (a closer
104
+ * sentence names root concepts, not the facts) while the returned `rows` stays
105
+ * the real backing facts, so provenance survives. `opts.parent` fills the
106
+ * `{PARENT}`/`{A_PARENT}` slots a group sentence hangs off.
107
+ *
108
+ * Returns { text, rows, family, form } so the sentence traces to the exact fact
109
+ * rows behind it, or null when no structure matches or there are no facts.
88
110
  */
89
111
  export function renderStructure(table, family, facts, opts = {}) {
90
112
  const rows = (facts || []).filter(Boolean);
@@ -92,9 +114,12 @@ export function renderStructure(table, family, facts, opts = {}) {
92
114
  const term = opts.term ?? rows[0].subject ?? "";
93
115
  const form = opts.form || (rows.length > 1 ? "several" : "single");
94
116
  const entry = table instanceof Map ? table.get(keyFor(family, form)) : null;
95
- if (!entry) return null;
96
- const objects = rows.map((r) => r.object);
97
- const text = fill(entry.template, slotsFor(term, objects, opts.chain));
117
+ const templates = entry ? entry.templates : null;
118
+ if (!templates || !templates.length) return null;
119
+ const variantSeed = opts.variantSeed ?? "";
120
+ const idx = fnv1a32(`${term}|${family}|${form}|${variantSeed}`) % templates.length;
121
+ const objects = opts.objectsOverride || rows.map((r) => r.object);
122
+ const text = fill(templates[idx], slotsFor(term, objects, opts.chain, opts.parent));
98
123
  if (!text) return null;
99
124
  return { text, rows, family, form };
100
125
  }
@@ -172,10 +172,18 @@ export function transcriptMarkdown(turns, meta, headerMd, turnMd) {
172
172
  }
173
173
 
174
174
  /** The self-contained "talk to it" full-screen page. Pure — the same output
175
- * for the same `title` every time; every other piece of state (the session,
176
- * every message, every chip) is computed live in the browser once the
177
- * sibling chat bundle loads, exactly as the embedded widget already works. */
178
- export function renderChatHtml({ title = DEFAULT_TITLE } = {}) {
175
+ * for the same `title`/`digestStructures` every time; every other piece of
176
+ * state (the session, every message, every chip) is computed live in the
177
+ * browser once the sibling chat bundle loads, exactly as the embedded widget
178
+ * already works. `digestStructures` are the pre-parsed [[structure]] rows of
179
+ * the digest sentence-structure bank, embedded so a long answer can lead
180
+ * with a composed digest instead of the flat fact list — the same table
181
+ * research.html/ledger.html already embed, fed here to the chat bundle's own
182
+ * live digest-bank twin (see chat-browser-entry.mjs) rather than to a
183
+ * client-side digest panel of this page's own; an empty list degrades to the
184
+ * flat list exactly as before this page could digest at all. */
185
+ export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [] } = {}) {
186
+ const digestStructuresJson = JSON.stringify(Array.isArray(digestStructures) ? digestStructures : []);
179
187
  const legendHtml = PROV_LEGEND.map(
180
188
  ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
181
189
  ).join("");
@@ -421,6 +429,7 @@ ${THEME_TOKENS_CSS}
421
429
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
422
430
  const createTicker = ${createTicker.toString()};
423
431
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
432
+ const DIGEST_STRUCTURES = ${digestStructuresJson};
424
433
  const el = (id) => document.getElementById(id);
425
434
 
426
435
  if ("serviceWorker" in navigator) navigator.serviceWorker.register("./tmct-sw.js").catch(() => {});
@@ -655,6 +664,7 @@ ${THEME_TOKENS_CSS}
655
664
  liveReference: liveReferenceForMode(checkedWikiMode()),
656
665
  synthesisBudget: readSynthBudget(),
657
666
  onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
667
+ digestStructures: DIGEST_STRUCTURES,
658
668
  });
659
669
  }
660
670
 
@@ -1139,6 +1149,7 @@ ${THEME_TOKENS_CSS}
1139
1149
  liveReference: liveReferenceForMode(initialMode),
1140
1150
  synthesisBudget: readSynthBudget(),
1141
1151
  onLiveLookup: function () { statusEl.textContent = "searching wikipedia\\u2026"; },
1152
+ digestStructures: DIGEST_STRUCTURES,
1142
1153
  });
1143
1154
  } else {
1144
1155
  window.tmctChatSession = newSession();