@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.5.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.
@@ -116,15 +116,27 @@ export function toFacts(assertions, map) {
116
116
 
117
117
  /** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the
118
118
  * committed slice. Options: limit (cap the facts written — handy for tests
119
- * and fast bootstraps), slicePath/mapPath overrides.
119
+ * and fast bootstraps), slicePath/mapPath overrides, and `prefer` — an array
120
+ * of predicate URIs that STABLE-partitions the facts before the limit is
121
+ * applied (facts whose predicate appears earlier in `prefer` come first;
122
+ * everything else keeps slice order after them). A capped bootstrap seed
123
+ * wants the DEFINITIONAL band ("a cache is a kind of buffer") ahead of the
124
+ * location trivia the slice happens to open with; without `prefer` the
125
+ * behavior is byte-identical to before.
120
126
  *
121
127
  * Idempotent twice over: appendFact's content-hashed ids make a blind
122
128
  * re-append an upsert, and we pre-read the store once to skip triples that
123
129
  * are already there (so re-seeding costs one read, not N rewrites).
124
130
  * Returns { appended, skipped, total }. */
125
- export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE } = {}) {
131
+ export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer } = {}) {
126
132
  const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
127
133
  let facts = toFacts(assertions, map);
134
+ if (Array.isArray(prefer) && prefer.length) {
135
+ const rank = new Map(prefer.map((p, i) => [p, i]));
136
+ // stable partition: Array.prototype.sort is stable in Node, so equal-rank
137
+ // facts keep their slice order — deterministic across runs by construction.
138
+ facts = facts.slice().sort((a, b) => (rank.get(a.predicate) ?? prefer.length) - (rank.get(b.predicate) ?? prefer.length));
139
+ }
128
140
  if (limit !== undefined) facts = facts.slice(0, limit);
129
141
 
130
142
  // One read up front: what does the store already reify? Keys are built with
@@ -19,9 +19,25 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
19
  export const TEMPLATES_FILE = join(PKG_ROOT, "data", "templates", "responses.jsonl");
20
20
  export const PHRASEBOOK_FILE = join(PKG_ROOT, "data", "phrasebook", "software-phrases.txt");
21
21
 
22
- const REGISTERS = new Set(["terse", "friendly"]);
22
+ // Registers (Phase 6, PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
23
+ // conversational bands; `technical` is the C1 / technical-paper band whose
24
+ // templates render item-5 mechanical conclusions (count / comparison /
25
+ // superlative + the provenance we already compute) as advanced prose. A
26
+ // technical template is FORMULAIC COMPETENCE: it renders via:"template", so the
27
+ // dual banding counts it in the PERFORMANCE band only, never the productive one.
28
+ const REGISTERS = new Set(["terse", "friendly", "technical"]);
23
29
  const SLOT_RE = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
24
30
 
31
+ // Slot-lint for the technical band: a technical template may ONLY fill from the
32
+ // mechanical values tmct actually computes (counts, comparisons, superlatives,
33
+ // scopes, provenance) — no free-text slot can smuggle unattributable prose into
34
+ // the C1 register. Every technical row must also carry a {provenance} fill (the
35
+ // item-5 "+ provenance" contract: an advanced claim always shows its source).
36
+ export const TECHNICAL_SLOTS = Object.freeze(new Set([
37
+ "subject", "count", "noun", "scope", "comparison", "metric", "unit",
38
+ "superlative", "provenance",
39
+ ]));
40
+
25
41
  /** The slot names a template string requires, in first-appearance order. */
26
42
  export function slotsOf(template) {
27
43
  const out = [];
@@ -31,6 +47,66 @@ export function slotsOf(template) {
31
47
  return out;
32
48
  }
33
49
 
50
+ // --- Segmentation IR (Phase 7, lever 1) -------------------------------------
51
+ // A rendered answer is ALSO a list of typed spans: [{ type, text }, …] with
52
+ // type ∈ prose | entity | path | number | code | provenance | receipt. The
53
+ // invariant law is byte-exact reconstruction: flatten(segments) === render().
54
+ // Everything except `prose` is PROTECTED — finishing (a later wave) may only
55
+ // transform prose spans, so a grammar rule can never touch a fact.
56
+ //
57
+ // Slot kinds map a template hole to its protected span type. A slot fill is
58
+ // ALWAYS protected (never prose): it is grounded data, not our wording. The
59
+ // specific type is derived from the slot name; unknown slots fall back to the
60
+ // conservative `entity` (protect-when-unsure). Bytes never depend on the type,
61
+ // only on the fill, so the type is metadata layered over an exact split.
62
+ const SLOT_KIND = {
63
+ count: "number",
64
+ when: "number",
65
+ location: "path",
66
+ commit: "path",
67
+ provenance: "provenance",
68
+ };
69
+
70
+ /** The protected span type for a template slot name (default: entity). */
71
+ export function slotKind(name) {
72
+ return SLOT_KIND[name] || "entity";
73
+ }
74
+
75
+ /** Reconstruct the flat answer from its segments — a pure, total inverse of
76
+ * segmentation: `segments.map(s => s.text).join("")`. flatten(renderSegments(
77
+ * id, slots)) === render(id, slots), byte for byte. */
78
+ export function flatten(segments) {
79
+ let out = "";
80
+ for (const s of segments) out += s.text;
81
+ return out;
82
+ }
83
+
84
+ /** Render template `id` as a SEGMENTED answer: the literal text between slots
85
+ * becomes `prose` spans, each slot fill becomes a PROTECTED span typed by
86
+ * slotKind(). Validation is identical to render() (unknown id / missing slot
87
+ * throw the same messages), so render() is exactly flatten(renderSegments()). */
88
+ export function renderSegments(id, slots = {}, templates = cache) {
89
+ if (!templates) throw new Error("renderSegments() before loadTemplates() — load the template library first");
90
+ const row = templates.get(id);
91
+ if (!row) throw new Error(`unknown template id "${id}"`);
92
+ const missing = row.slots.filter((s) => slots[s] === undefined || slots[s] === null);
93
+ if (missing.length) {
94
+ throw new Error(`template "${id}" missing slot${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`);
95
+ }
96
+ const tpl = row.template;
97
+ const re = new RegExp(SLOT_RE.source, "g"); // own lastIndex; never touch the shared regex
98
+ const segments = [];
99
+ let last = 0;
100
+ let m;
101
+ while ((m = re.exec(tpl)) !== null) {
102
+ if (m.index > last) segments.push({ type: "prose", text: tpl.slice(last, m.index) });
103
+ segments.push({ type: slotKind(m[1]), text: String(slots[m[1]]) });
104
+ last = m.index + m[0].length;
105
+ }
106
+ if (last < tpl.length) segments.push({ type: "prose", text: tpl.slice(last) });
107
+ return segments;
108
+ }
109
+
34
110
  let cache = null; // Map<id, row> from the last loadTemplates() — render()'s source
35
111
 
36
112
  /** Load + validate the response templates. Every line must parse as JSON with
@@ -56,10 +132,21 @@ export async function loadTemplates(path = TEMPLATES_FILE) {
56
132
  }
57
133
  }
58
134
  if (!REGISTERS.has(row.register)) {
59
- throw new Error(`${path}:${n + 1}: register must be terse|friendly, got "${row.register}"`);
135
+ throw new Error(`${path}:${n + 1}: register must be terse|friendly|technical, got "${row.register}"`);
60
136
  }
61
137
  if (byId.has(row.id)) throw new Error(`${path}:${n + 1}: duplicate template id "${row.id}"`);
62
- byId.set(row.id, { ...row, slots: slotsOf(row.template) });
138
+ const slots = slotsOf(row.template);
139
+ // Technical-band slot-lint: mechanical-only fills, provenance mandatory.
140
+ if (row.register === "technical") {
141
+ const stray = slots.filter((s) => !TECHNICAL_SLOTS.has(s));
142
+ if (stray.length) {
143
+ throw new Error(`${path}:${n + 1}: technical template "${row.id}" uses non-mechanical slot${stray.length > 1 ? "s" : ""}: ${stray.join(", ")} (allowed: ${[...TECHNICAL_SLOTS].join(", ")})`);
144
+ }
145
+ if (!slots.includes("provenance")) {
146
+ throw new Error(`${path}:${n + 1}: technical template "${row.id}" must carry a {provenance} fill (an advanced claim always shows its source)`);
147
+ }
148
+ }
149
+ byId.set(row.id, { ...row, slots });
63
150
  }
64
151
  cache = byId;
65
152
  return byId;
@@ -71,13 +158,10 @@ export async function loadTemplates(path = TEMPLATES_FILE) {
71
158
  * `templates` explicitly to bypass the module cache, e.g. in tests). */
72
159
  export function render(id, slots = {}, templates = cache) {
73
160
  if (!templates) throw new Error("render() before loadTemplates() — load the template library first");
74
- const row = templates.get(id);
75
- if (!row) throw new Error(`unknown template id "${id}"`);
76
- const missing = row.slots.filter((s) => slots[s] === undefined || slots[s] === null);
77
- if (missing.length) {
78
- throw new Error(`template "${id}" missing slot${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`);
79
- }
80
- return row.template.replace(SLOT_RE, (_, name) => String(slots[name]));
161
+ // render() IS the flattened segmentation, by construction: the byte output is
162
+ // provably identical to the old `.replace(SLOT_RE, …)` (test/segments.test.mjs
163
+ // renders every responses.jsonl row both ways and asserts equality).
164
+ return flatten(renderSegments(id, slots, templates));
81
165
  }
82
166
 
83
167
  /** Load + parse the SE phrase book. Returns:
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
+ }
@@ -30,6 +30,14 @@
30
30
  "component": {},
31
31
  "interface": {},
32
32
  "type": {},
33
+ "category": {},
34
+ "kind": {},
35
+ "artifact": {},
36
+ "routine": {},
37
+ "part": {},
38
+ "helper": {},
39
+ "operation": {},
40
+ "change": {},
33
41
  "schema": {},
34
42
  "database": {},
35
43
  "table": {},
package/src/hash.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // hash.mjs — the single home for tmct's content-address hash.
2
+ //
3
+ // FNV-1a 32-bit is deliberately home-grown (see PLAN_DEPENDENCY_STRATEGY.md): it
4
+ // must be synchronous, browser-safe, dependency-free, and — critically —
5
+ // CROSS-VERSION STABLE, because fact ids are content-addressed by it and a fact's
6
+ // id is its identity across the whole memory graph. Every library candidate fails
7
+ // at least one of those; this eight-line function fails none. It lives here, once,
8
+ // so the fact-id contract has exactly one definition.
9
+ //
10
+ // Two historical copies are reconciled here without changing a single output byte:
11
+ // - src/memory/core.mjs used the hex form for fact ids;
12
+ // - chatbench/graded.mjs used the integer form as a PRNG seed, with a redundant
13
+ // mid-loop `>>> 0`. That `>>> 0` was always a no-op: `^` and Math.imul both
14
+ // apply ToInt32 to their operands, so the 32-bit pattern is invariant between
15
+ // iterations whether the accumulator is stored signed or unsigned. The final
16
+ // `h >>> 0` therefore yields the same value either way — proven, not assumed.
17
+
18
+ /** FNV-1a 32-bit. Returns the unsigned 32-bit integer (0 … 2^32−1). */
19
+ export function fnv1a32(str) {
20
+ let h = 0x811c9dc5;
21
+ for (let i = 0; i < str.length; i += 1) {
22
+ h ^= str.charCodeAt(i);
23
+ h = Math.imul(h, 0x01000193);
24
+ }
25
+ return h >>> 0;
26
+ }
27
+
28
+ /** FNV-1a 32-bit as a zero-padded 8-char hex string — the stable content-address
29
+ * used for fact ids (`fact:<hex>`). Same (s,p,o) → same id → upsert, never a dup. */
30
+ export function fnv1aHex(str) {
31
+ return fnv1a32(str).toString(16).padStart(8, "0");
32
+ }