@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.
- package/README.md +77 -3
- package/ROADMAP.md +416 -3
- package/bin/tmct.mjs +308 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +68 -0
- package/package.json +40 -3
- package/src/ask-nlp.mjs +22 -10
- package/src/ask-vocab.mjs +35 -1
- package/src/ask.mjs +171 -494
- package/src/chat.mjs +709 -81
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +167 -0
- package/src/corpus/templates.mjs +188 -0
- package/src/finish.mjs +443 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/hash.mjs +32 -0
- package/src/index.mjs +21 -5
- package/src/init.mjs +264 -0
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +151 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +241 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +221 -0
- package/src/memory/core.mjs +533 -0
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +137 -4
- package/src/source.mjs +44 -5
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/tui/app.mjs +173 -0
- package/src/wink-model.mjs +74 -0
- 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
|
+
}
|