@polycode-projects/the-mechanical-code-talker 0.3.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 +411 -1
- package/bin/tmct.mjs +56 -1
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +13 -0
- package/package.json +30 -2
- package/src/ask-nlp.mjs +8 -10
- package/src/ask-vocab.mjs +22 -0
- package/src/ask.mjs +80 -2
- package/src/chat.mjs +576 -50
- package/src/corpus/conceptnet.mjs +14 -2
- package/src/corpus/templates.mjs +94 -10
- package/src/finish.mjs +443 -0
- package/src/hash.mjs +32 -0
- package/src/init.mjs +264 -0
- package/src/interpret/normalize.mjs +34 -0
- package/src/interpret/strategies/keywords.mjs +57 -1
- package/src/memory/blocks.mjs +23 -3
- package/src/memory/core.mjs +257 -16
- 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 +13 -2
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/wink-model.mjs +74 -0
|
@@ -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
|
package/src/corpus/templates.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
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
|
+
}
|