@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.4.0

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 +77 -3
  2. package/ROADMAP.md +416 -3
  3. package/bin/tmct.mjs +308 -12
  4. package/corpus/README.md +52 -0
  5. package/corpus/conceptnet/LICENSE-NOTICE +37 -0
  6. package/corpus/conceptnet/README.md +103 -0
  7. package/corpus/conceptnet/fetch-slice.mjs +136 -0
  8. package/corpus/conceptnet/filter-dump.mjs +89 -0
  9. package/corpus/conceptnet/slice.jsonl +14258 -0
  10. package/data/phrasebook/software-phrases.txt +231 -0
  11. package/data/templates/grammar-rules.toml +89 -0
  12. package/data/templates/responses.jsonl +68 -0
  13. package/package.json +40 -3
  14. package/src/ask-nlp.mjs +22 -10
  15. package/src/ask-vocab.mjs +35 -1
  16. package/src/ask.mjs +171 -494
  17. package/src/chat.mjs +709 -81
  18. package/src/corpus/conceptnet-map.toml +251 -0
  19. package/src/corpus/conceptnet.mjs +167 -0
  20. package/src/corpus/templates.mjs +188 -0
  21. package/src/finish.mjs +443 -0
  22. package/src/grammar/ace.mjs +341 -0
  23. package/src/grammar/assert.mjs +40 -0
  24. package/src/grammar/lexicon-core.json +287 -0
  25. package/src/grammar/lexicon.mjs +202 -0
  26. package/src/hash.mjs +32 -0
  27. package/src/index.mjs +21 -5
  28. package/src/init.mjs +264 -0
  29. package/src/interpret/fuzzy.mjs +89 -0
  30. package/src/interpret/merge.mjs +148 -0
  31. package/src/interpret/normalize.mjs +151 -0
  32. package/src/interpret/pipeline.mjs +112 -0
  33. package/src/interpret/strategies/grammar.mjs +137 -0
  34. package/src/interpret/strategies/keywords.mjs +241 -0
  35. package/src/interpret/strategies/noise-strip.mjs +114 -0
  36. package/src/memory/blocks.mjs +221 -0
  37. package/src/memory/core.mjs +533 -0
  38. package/src/memory/fold.mjs +0 -0
  39. package/src/memory/inspect.mjs +141 -0
  40. package/src/memory/trust.mjs +113 -0
  41. package/src/prose-nlp.mjs +14 -16
  42. package/src/providers/bootstrap.mjs +24 -0
  43. package/src/providers/fixture.mjs +118 -0
  44. package/src/providers/graph-service.mjs +312 -0
  45. package/src/repository-interface.mjs +318 -0
  46. package/src/server.mjs +44 -28
  47. package/src/sessions.mjs +137 -4
  48. package/src/source.mjs +44 -5
  49. package/src/syllogise.mjs +0 -0
  50. package/src/toml-config.mjs +14 -0
  51. package/src/tui/app.mjs +173 -0
  52. package/src/wink-model.mjs +74 -0
  53. package/bin/cli.mjs +0 -226
package/src/finish.mjs ADDED
@@ -0,0 +1,443 @@
1
+ // finish.mjs — Phase 7 response finishing: the segmentation IR seam.
2
+ // (PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
3
+ //
4
+ // The governing principle is fact-invariance BY CONSTRUCTION. An answer is a
5
+ // list of typed spans, [{ type, text }, …], carried alongside the flat string
6
+ // (never replacing it). Every type except `prose` is PROTECTED: entities,
7
+ // paths, numbers, code, provenance and receipts are byte-copied through
8
+ // finishing untouched, and only prose spans are ever handed to a (future)
9
+ // grammar-rule engine. Segmentation makes "turn app/lib/a.mjs into an.mjs"
10
+ // UNREPRESENTABLE — the protected spans are not in the rule engine's input.
11
+ //
12
+ // This module is the FOUNDATION step: pure structure, ZERO behaviour change.
13
+ // It provides:
14
+ // - the segment type vocabulary + the protected/prose split,
15
+ // - maskSegments(answer, { graph }) — a conservative masker for the composed
16
+ // (non-template) path (the templated path segments in corpus/templates.mjs),
17
+ // - an INVARIANCE CHECKER (the protected-span multiset must survive any
18
+ // prose-only transform), the property future grammar rules are gated on,
19
+ // - a NO-OP finish(result, ctx) — the seam a later wave wires into chat.mjs.
20
+ //
21
+ // Byte-exact reconstruction is the whole contract here: flatten(segments) ===
22
+ // answer for every producer, and finish() returns its input byte-for-byte.
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { fileURLToPath } from "node:url";
26
+ import { join, dirname } from "node:path";
27
+ import { parse as parseToml } from "smol-toml";
28
+
29
+ import { flatten } from "./corpus/templates.mjs";
30
+
31
+ export { flatten };
32
+
33
+ const GRAMMAR_DIR = dirname(fileURLToPath(import.meta.url));
34
+ /** The data-driven grammar-rule table (Phase 7, lever 2). */
35
+ export const GRAMMAR_RULES_FILE = join(GRAMMAR_DIR, "..", "data", "templates", "grammar-rules.toml");
36
+
37
+ /** The segment type vocabulary. `prose` is the only unprotected type. */
38
+ export const SEGMENT_TYPES = Object.freeze([
39
+ "prose", "entity", "path", "number", "code", "provenance", "receipt",
40
+ ]);
41
+
42
+ /** Protected types: byte-copied through finishing, never rule-transformed. */
43
+ export const PROTECTED_TYPES = Object.freeze(
44
+ new Set(SEGMENT_TYPES.filter((t) => t !== "prose")),
45
+ );
46
+
47
+ /** Is this span type protected (i.e. anything that is not prose)? */
48
+ export function isProtected(type) {
49
+ return type !== "prose";
50
+ }
51
+
52
+ // --- Conservative masker for the composed path ------------------------------
53
+ // The templated path gets segments almost for free (corpus/templates.mjs
54
+ // renderSegments). The composed path (ask engine, plain/conversational turns)
55
+ // hands finishing a hand-built flat string; maskSegments walks it and marks
56
+ // PROTECTED anything matching one of the patterns below, leaving everything
57
+ // else prose. Policy: CONSERVATIVE — when unsure, protect. An un-adopted render
58
+ // site simply presents its whole answer as a single prose span (pass no graph
59
+ // and match nothing → one prose segment), and the invariance checker still
60
+ // guards it. flatten(maskSegments(answer, ctx)) === answer, always.
61
+
62
+ // Parenthesized receipts: "(traversal: calls edges where object = fnAlpha)" and
63
+ // the repair receipt 'read as "which modules import a.mjs"'.
64
+ const RECEIPT_PAREN_RE = /\((?:traversal|read as)\b[^)]*\)/gi;
65
+ const RECEIPT_READAS_RE = /read as\s+"[^"]*"/gi;
66
+
67
+ // Provenance / licence tags: "(source: …)", "(licence: …)", bare CC licences
68
+ // ("CC-BY-SA") and the ConceptNet source name.
69
+ const PROVENANCE_PAREN_RE = /\((?:source|licen[sc]e)\b[^)]*\)/gi;
70
+ const LICENCE_CODE_RE = /\bCC-BY(?:-[A-Z]+)*\b/g;
71
+ const CONCEPTNET_RE = /\bConceptNet\b/g;
72
+
73
+ // Path tokens: anything with a slash-joined segment, or a file extension.
74
+ const PATH_RE = /(?:[\w@.-]+\/)+[\w@.-]+|\b[\w-]+\.(?:mjs|cjs|js|jsx|ts|tsx|json|jsonl|md|txt|py|rb|go|rs|toml|yml|yaml)\b/g;
75
+
76
+ // Bare numbers (integers or decimals), lowest precedence so a number inside a
77
+ // path/receipt/entity is claimed by that span first.
78
+ const NUMBER_RE = /\b\d+(?:\.\d+)?\b/g;
79
+
80
+ // Precedence: lower wins when candidate spans overlap.
81
+ const PRECEDENCE = { receipt: 0, provenance: 1, path: 2, entity: 3, number: 4 };
82
+
83
+ /** Collect the string labels of a loaded graph's individuals (defensive: any
84
+ * non-array / missing-label shape yields an empty set). Longest labels first
85
+ * so a compound label wins over a substring of it. */
86
+ function graphLabels(graph) {
87
+ const labels = [];
88
+ const individuals = graph && Array.isArray(graph.individuals) ? graph.individuals : [];
89
+ for (const ind of individuals) {
90
+ const label = ind && ind.label != null ? String(ind.label) : "";
91
+ if (label) labels.push(label);
92
+ }
93
+ labels.sort((a, b) => b.length - a.length);
94
+ return labels;
95
+ }
96
+
97
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
98
+
99
+ function pushMatches(re, type, text, out) {
100
+ re.lastIndex = 0;
101
+ let m;
102
+ while ((m = re.exec(text)) !== null) {
103
+ if (m[0].length === 0) { re.lastIndex += 1; continue; }
104
+ out.push({ start: m.index, end: m.index + m[0].length, type });
105
+ }
106
+ }
107
+
108
+ /** Segment a composed (non-template) flat answer into typed spans, marking as
109
+ * PROTECTED any known graph label (entity), path token, bare number, receipt
110
+ * tail or provenance/licence tag; everything else is prose. Guarantees exact
111
+ * reconstruction: flatten(maskSegments(answer, ctx)) === answer.
112
+ *
113
+ * @param {string} answer the flat answer string
114
+ * @param {{graph?: {individuals?: Array<{label?: any}>}}} [ctx]
115
+ */
116
+ export function maskSegments(answer, ctx = {}) {
117
+ const text = String(answer);
118
+ if (!text) return [];
119
+ const cand = [];
120
+
121
+ pushMatches(RECEIPT_PAREN_RE, "receipt", text, cand);
122
+ pushMatches(RECEIPT_READAS_RE, "receipt", text, cand);
123
+ pushMatches(PROVENANCE_PAREN_RE, "provenance", text, cand);
124
+ pushMatches(LICENCE_CODE_RE, "provenance", text, cand);
125
+ pushMatches(CONCEPTNET_RE, "provenance", text, cand);
126
+ pushMatches(PATH_RE, "path", text, cand);
127
+ pushMatches(NUMBER_RE, "number", text, cand);
128
+
129
+ // Known graph labels → entity spans (word-boundary, longest-first).
130
+ for (const label of graphLabels(ctx && ctx.graph)) {
131
+ const re = new RegExp(`(?<![\\w/.-])${escapeRe(label)}(?![\\w/.-])`, "g");
132
+ pushMatches(re, "entity", text, cand);
133
+ }
134
+
135
+ // Greedy non-overlapping selection: earliest start, then precedence, then
136
+ // longest span. A candidate overlapping an already-accepted span is dropped.
137
+ cand.sort((a, b) =>
138
+ a.start - b.start ||
139
+ PRECEDENCE[a.type] - PRECEDENCE[b.type] ||
140
+ (b.end - b.start) - (a.end - a.start));
141
+
142
+ const chosen = [];
143
+ let guard = -1;
144
+ for (const c of cand) {
145
+ if (c.start < guard) continue;
146
+ chosen.push(c);
147
+ guard = c.end;
148
+ }
149
+
150
+ // Emit segments, filling the gaps between protected spans with prose.
151
+ const segments = [];
152
+ let last = 0;
153
+ for (const c of chosen) {
154
+ if (c.start > last) segments.push({ type: "prose", text: text.slice(last, c.start) });
155
+ segments.push({ type: c.type, text: text.slice(c.start, c.end) });
156
+ last = c.end;
157
+ }
158
+ if (last < text.length) segments.push({ type: "prose", text: text.slice(last) });
159
+ return segments;
160
+ }
161
+
162
+ // --- Invariance checker -----------------------------------------------------
163
+ // The machine proof that no fact moved: the MULTISET of protected spans must be
164
+ // identical before and after any prose-only transform. Future grammar rules are
165
+ // gated on this — a rule that changes a protected span is a bug, not a fix.
166
+
167
+ /** The protected spans of a segment list, as {type,text} (order-independent). */
168
+ export function protectedSpans(segments) {
169
+ return segments
170
+ .filter((s) => isProtected(s.type))
171
+ .map((s) => ({ type: s.type, text: s.text }));
172
+ }
173
+
174
+ /** A canonical (sorted) multiset key for the protected spans, for comparison. */
175
+ export function protectedMultiset(segments) {
176
+ return protectedSpans(segments)
177
+ .map((s) => `${s.type} ${s.text}`)
178
+ .sort();
179
+ }
180
+
181
+ /** Does the protected-span multiset survive a transform (before → after)? */
182
+ export function invariantHolds(before, after) {
183
+ const a = protectedMultiset(before);
184
+ const b = protectedMultiset(after);
185
+ if (a.length !== b.length) return false;
186
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
187
+ return true;
188
+ }
189
+
190
+ /** Assert the invariant, returning `after` on success; throw on violation. */
191
+ export function assertInvariance(before, after) {
192
+ if (!invariantHolds(before, after)) {
193
+ throw new Error("fact-invariance violated: the protected-span multiset changed during finishing");
194
+ }
195
+ return after;
196
+ }
197
+
198
+ // --- The grammar-rule engine (lever 2) --------------------------------------
199
+ // applyGrammar transforms ONLY the prose spans of a segment list. It NEVER
200
+ // regexes the flat answer string and NEVER touches a protected span — the
201
+ // invariance checker is treated as NECESSARY-BUT-NOT-SUFFICIENT (a token the
202
+ // masker failed to protect would sit in a prose span, and a corrupting rule that
203
+ // mangled it would pass the multiset check because prose is unchecked). So the
204
+ // only defence is that a rule literally cannot receive a protected span: every
205
+ // handler below filters `type === "prose"` and byte-copies the rest. Each rule's
206
+ // NEUTRAL behaviour is byte-stable; the only byte changes are GENUINE fixes to
207
+ // defects tmct itself generates. Rules are chosen to commute → idempotent.
208
+
209
+ const escapeRe2 = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
210
+
211
+ /** Preserve the leading-letter case of `orig` on the replacement `want`. */
212
+ function matchCase(orig, want) {
213
+ return /^[A-Z]/.test(orig) ? want[0].toUpperCase() + want.slice(1) : want;
214
+ }
215
+
216
+ /** The first alphabetic word token of a string (leading punctuation skipped),
217
+ * or "" when there is none (a bare number / symbol → the caller must refuse). */
218
+ function leadingWord(text) {
219
+ const m = String(text).match(/[A-Za-z][\w-]*/);
220
+ return m ? m[0] : "";
221
+ }
222
+
223
+ /** Does `word` begin with a VOWEL SOUND? true → "an", false → "a", null → cannot
224
+ * tell (refuse). Spelling-vs-sound exceptions come from the rule's TOML row. */
225
+ function beginsWithVowelSound(word, rule) {
226
+ const w = String(word).toLowerCase().replace(/^[^a-z]+/, "");
227
+ if (!w) return null;
228
+ for (const ex of rule.vowel_sound_consonants || []) if (w.startsWith(ex)) return true;
229
+ for (const ex of rule.consonant_sound_vowels || []) if (w.startsWith(ex)) return false;
230
+ const c = w[0];
231
+ if ("aeiou".includes(c)) return true;
232
+ if (/[a-z]/.test(c)) return false;
233
+ return null;
234
+ }
235
+
236
+ /** Plurality of a span: an explicit boolean `plural` wins; else a NUMBER span is
237
+ * singular iff its value is exactly 1 (0 and >1 are plural); else null. */
238
+ function pluralityOf(seg) {
239
+ if (!seg) return null;
240
+ if (typeof seg.plural === "boolean") return seg.plural;
241
+ if (seg.type === "number") {
242
+ const v = Number(String(seg.text).replace(/,/g, ""));
243
+ return Number.isFinite(v) ? v !== 1 : null;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ // Rule 1 — article selection (a/an). Reads the following word: in-span when the
249
+ // whole "a word" pair is inside one prose span; across the boundary when the
250
+ // prose ends in "a"/"an" and the next span supplies the word (guard #3: it only
251
+ // fires when it can read the real next token, else it leaves the article alone).
252
+ function ruleArticle(segments, rule) {
253
+ const out = segments.map((s) => ({ ...s }));
254
+ for (let i = 0; i < out.length; i += 1) {
255
+ const seg = out[i];
256
+ if (seg.type !== "prose") continue;
257
+ // (a) in-span "a artifact" / "an module" (case-insensitive; case preserved)
258
+ seg.text = seg.text.replace(/\b(a|an)(\s+)([A-Za-z][\w-]*)/gi, (m, art, sp, word) => {
259
+ const vowel = beginsWithVowelSound(word, rule);
260
+ if (vowel === null) return m;
261
+ return matchCase(art, vowel ? "an" : "a") + sp + word;
262
+ });
263
+ // (b) boundary: prose ends "…a " / "…an ", next span carries the word
264
+ const bm = seg.text.match(/(^|[^\w])(a|an)(\s+)$/i);
265
+ if (bm) {
266
+ const next = out[i + 1];
267
+ const word = next && typeof next.text === "string" ? leadingWord(next.text) : "";
268
+ const vowel = word ? beginsWithVowelSound(word, rule) : null;
269
+ if (vowel !== null) {
270
+ const fixed = matchCase(bm[2], vowel ? "an" : "a");
271
+ seg.text = seg.text.slice(0, bm.index + bm[1].length) + fixed + bm[3];
272
+ }
273
+ }
274
+ }
275
+ return out;
276
+ }
277
+
278
+ // Rule 2 — subject–verb agreement. STRUCTURE-DRIVEN and deliberately narrow:
279
+ // (i) an existential "there is/are/was/were" agrees with the FOLLOWING number
280
+ // span's value (the number is genuinely the subject there);
281
+ // (ii) any listed copula agrees with a following span that carries an explicit
282
+ // `plural` flag (a producer that knows its slot's plurality sets it).
283
+ // A bare copula followed by a number that is an OBJECT count ("the file has 3")
284
+ // is NOT existential and carries no flag → left untouched. No surface guessing.
285
+ function ruleAgreement(segments, rule) {
286
+ const singular = rule.singular || [];
287
+ const plural = rule.plural || [];
288
+ const toPlural = {};
289
+ const toSingular = {};
290
+ for (let k = 0; k < singular.length; k += 1) { toPlural[singular[k]] = plural[k]; }
291
+ for (let k = 0; k < plural.length; k += 1) { toSingular[plural[k]] = singular[k]; }
292
+ const out = segments.map((s) => ({ ...s }));
293
+ for (let i = 0; i < out.length; i += 1) {
294
+ const seg = out[i];
295
+ if (seg.type !== "prose") continue;
296
+ const m = seg.text.match(/(\bthere\s+)?\b([A-Za-z]+)(\s*)$/i);
297
+ if (!m) continue;
298
+ const verb = m[2].toLowerCase();
299
+ if (!(verb in toPlural) && !(verb in toSingular)) continue;
300
+ const next = out[i + 1];
301
+ const existential = Boolean(m[1]) && next && next.type === "number";
302
+ const flagged = next && typeof next.plural === "boolean";
303
+ if (!existential && !flagged) continue; // guard: only fire on real structure
304
+ const isPlural = pluralityOf(next);
305
+ if (isPlural === null) continue;
306
+ let want = null;
307
+ if (isPlural && verb in toPlural && toPlural[verb] !== verb) want = toPlural[verb];
308
+ else if (!isPlural && verb in toSingular && toSingular[verb] !== verb) want = toSingular[verb];
309
+ if (!want) continue;
310
+ seg.text = seg.text.slice(0, m.index) + (m[1] || "") + matchCase(m[2], want) + m[3];
311
+ }
312
+ return out;
313
+ }
314
+
315
+ // Rule 3 — sentence capitalisation. Only when the answer OPENS on a prose span
316
+ // whose first non-space character is a lowercase letter. An answer that opens on
317
+ // a protected span (a path/entity) is left exactly as grounded.
318
+ function ruleCapitalise(segments) {
319
+ if (!segments.length || segments[0].type !== "prose") return segments;
320
+ const out = segments.map((s) => ({ ...s }));
321
+ out[0].text = out[0].text.replace(/^(\s*)([a-z])/, (m, sp, ch) => sp + ch.toUpperCase());
322
+ return out;
323
+ }
324
+
325
+ // Rule 4 — list punctuation. A series joined by repeated connective prose spans
326
+ // (default " and ") becomes a comma series with a single terminal conjunction:
327
+ // "a and b and c" → "a, b and c". Operates ONLY on the connective prose spans;
328
+ // the joined (protected) items are byte-copied. A two-item list is untouched.
329
+ function ruleList(segments, rule) {
330
+ const conn = rule.connective;
331
+ const sep = rule.separator;
332
+ const out = segments.map((s) => ({ ...s }));
333
+ const isConn = (i) => out[i] && out[i].type === "prose" && out[i].text === conn;
334
+ let i = 0;
335
+ while (i < out.length) {
336
+ if (!isConn(i)) { i += 1; continue; }
337
+ const run = [i];
338
+ let j = i;
339
+ while (isConn(j + 2)) { run.push(j + 2); j += 2; }
340
+ if (run.length >= 2) {
341
+ for (let k = 0; k < run.length - 1; k += 1) out[run[k]].text = sep; // last stays " and "
342
+ }
343
+ i = j + 1;
344
+ }
345
+ return out;
346
+ }
347
+
348
+ // Rule 5 — terminal punctuation. Collapse a trailing run of 2+ sentence stops in
349
+ // the LAST prose span to a single stop ("done.." → "done."). Adds nothing where
350
+ // a fragment/list answer legitimately ends without a stop.
351
+ function ruleTerminal(segments, rule) {
352
+ const stops = (rule.stops && rule.stops.length ? rule.stops : [".", "!", "?"]).map(escapeRe2).join("");
353
+ const out = segments.map((s) => ({ ...s }));
354
+ let idx = -1;
355
+ for (let i = out.length - 1; i >= 0; i -= 1) if (out[i].type === "prose") { idx = i; break; }
356
+ if (idx < 0) return out;
357
+ const re = new RegExp(`([${stops}])(?:\\s*[${stops}])+(\\s*)$`);
358
+ const m = out[idx].text.match(re);
359
+ if (m) out[idx].text = out[idx].text.slice(0, m.index) + m[1] + m[2];
360
+ return out;
361
+ }
362
+
363
+ const HANDLERS = {
364
+ article: ruleArticle,
365
+ agreement: ruleAgreement,
366
+ capitalise: ruleCapitalise,
367
+ list: ruleList,
368
+ terminal: ruleTerminal,
369
+ };
370
+
371
+ let rulesCache = null;
372
+
373
+ /** Load + parse the grammar-rule table (the `[[rule]]` array-of-tables). Sync so
374
+ * finish() stays synchronous at the turn seam. A bad/absent table is defensive:
375
+ * the caller falls back to an empty rule set (finish becomes a strict no-op). */
376
+ export function loadGrammarRules(path = GRAMMAR_RULES_FILE) {
377
+ const parsed = parseToml(readFileSync(path, "utf8"));
378
+ return Array.isArray(parsed.rule) ? parsed.rule : [];
379
+ }
380
+
381
+ /** The cached rule table (parsed once per process). */
382
+ export function grammarRules() {
383
+ if (rulesCache === null) {
384
+ try { rulesCache = loadGrammarRules(); } catch { rulesCache = []; }
385
+ }
386
+ return rulesCache;
387
+ }
388
+
389
+ /** Apply the grammar rules to a segment list, in file order, transforming ONLY
390
+ * prose spans. Asserts fact-invariance at the end (the protected multiset must
391
+ * be identical) and returns the transformed segments. Idempotent by design:
392
+ * applyGrammar(applyGrammar(x)) yields the same flattened text as applyGrammar(x). */
393
+ export function applyGrammar(segments, rules = grammarRules()) {
394
+ let cur = segments;
395
+ for (const rule of rules) {
396
+ if (rule && rule.enabled === false) continue;
397
+ const handler = rule && HANDLERS[rule.kind];
398
+ if (!handler) continue;
399
+ cur = handler(cur, rule);
400
+ }
401
+ assertInvariance(segments, cur); // necessary-but-not-sufficient; still a hard gate
402
+ return cur;
403
+ }
404
+
405
+ // --- The finish() seam ------------------------------------------------------
406
+ // finish() is the LAST transform in a turn:
407
+ // 1. take the result's `segments` (attached by a producer) or mask its
408
+ // `answer` via maskSegments(result.answer, ctx),
409
+ // 2. map ONLY the prose spans through the grammar-rule engine (applyGrammar),
410
+ // 3. re-flatten, ASSERTING the invariance checker holds (guard #4: this runs
411
+ // in production, not just in tests — a corruption throws, never ships),
412
+ // 4. rewrite result.answer (and thus logLines); `via` is unchanged.
413
+ //
414
+ // NEUTRAL finishing is BYTE-STABLE: when no rule fires, the flattened answer is
415
+ // byte-identical to the input and finish() returns its argument by REFERENCE, so
416
+ // every byte-exact assertion (test/showcase.test.mjs) stays green untouched. A
417
+ // genuine fix (e.g. "a artifact" → "an artifact") rebuilds the result with the
418
+ // corrected answer. Idempotent by construction: finish(finish(x)) === finish(x).
419
+ //
420
+ // INTENDED chat.mjs SEAM (a sibling/later wave wires this, foreign file): in
421
+ // runTurn, at the `withLast` seam, `result = finish(result, { graph })` so every
422
+ // producer passes through once. Until then finish() is exercised by its unit +
423
+ // golden tests; wiring it changes no fact, only fixes our own generated defects.
424
+
425
+ /** Finish a turn result: grammar-correct its prose spans, preserving every fact.
426
+ * Byte-stable when neutral (returns its argument unchanged); rebuilds only on a
427
+ * genuine fix. Throws if finishing would move any protected span (guard #4). */
428
+ export function finish(result, ctx = {}) {
429
+ if (!result || typeof result.answer !== "string") return result;
430
+ const before = Array.isArray(result.segments) && result.segments.length
431
+ ? result.segments
432
+ : maskSegments(result.answer, ctx);
433
+ const after = applyGrammar(before, grammarRules());
434
+ assertInvariance(before, after);
435
+ const answer = flatten(after);
436
+ if (answer === result.answer) return result; // neutral → byte-stable, same reference
437
+ const next = { ...result, answer, segments: after };
438
+ if (Array.isArray(result.logLines)) {
439
+ next.logLines = result.logLines.map((l) =>
440
+ (typeof l === "string" ? l.split(result.answer).join(answer) : l));
441
+ }
442
+ return next;
443
+ }