@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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# conceptnet-map.toml — the relation → ACE-OWL-pattern mapping table
|
|
2
|
+
# (ROADMAP Phase 2, ConceptNet corpus slice).
|
|
3
|
+
#
|
|
4
|
+
# One row per ConceptNet relation (the canonical closed set of 34 —
|
|
5
|
+
# docs/references/schemas/conceptnet-relations.md is the reference list this
|
|
6
|
+
# table is drift-checked against; test/corpus-conceptnet.test.mjs fails if the
|
|
7
|
+
# committed slice contains a relation with no row here).
|
|
8
|
+
#
|
|
9
|
+
# Fields:
|
|
10
|
+
# rel the ConceptNet relation URI ("/r/IsA")
|
|
11
|
+
# surface the canonical surface template, "{start}"/"{end}" slotted —
|
|
12
|
+
# the "A dog is a kind of animal" sentence shape this relation reads as
|
|
13
|
+
# ace which ACE-OWL pattern it maps to (docs/references/schemas/
|
|
14
|
+
# ace-owl-fragment.md): subClassOf | type | ObjectProperty |
|
|
15
|
+
# someValuesFrom | disjointWith | property | none
|
|
16
|
+
# predicate the predicate URI src/corpus/conceptnet.mjs emits into memory
|
|
17
|
+
# facts (absent when ace = "none" — no fact is emitted)
|
|
18
|
+
# note why, and what a "none" row is still good for
|
|
19
|
+
#
|
|
20
|
+
# Loader contract (src/corpus/conceptnet.mjs): a relation in the slice that is
|
|
21
|
+
# MISSING here is an error (drift guard); a row with ace = "none" is a
|
|
22
|
+
# deliberate non-emission, silently skipped by toFacts().
|
|
23
|
+
|
|
24
|
+
[[relation]]
|
|
25
|
+
rel = "/r/IsA"
|
|
26
|
+
surface = "a {start} is a kind of {end}"
|
|
27
|
+
ace = "subClassOf"
|
|
28
|
+
predicate = "rdfs:subClassOf"
|
|
29
|
+
note = "hyponym → hypernym; ACE pattern 1 ('every N1 is a N2')"
|
|
30
|
+
|
|
31
|
+
[[relation]]
|
|
32
|
+
rel = "/r/DefinedAs"
|
|
33
|
+
surface = "a {start} is defined as {end}"
|
|
34
|
+
ace = "subClassOf"
|
|
35
|
+
predicate = "rdfs:subClassOf"
|
|
36
|
+
note = "definitional IsA; also ACE pattern 1"
|
|
37
|
+
|
|
38
|
+
[[relation]]
|
|
39
|
+
rel = "/r/PartOf"
|
|
40
|
+
surface = "a {start} is part of a {end}"
|
|
41
|
+
ace = "ObjectProperty"
|
|
42
|
+
predicate = "mgx:partOf"
|
|
43
|
+
note = "meronymy; ACE pattern 3 (N1 VERB N2)"
|
|
44
|
+
|
|
45
|
+
[[relation]]
|
|
46
|
+
rel = "/r/HasA"
|
|
47
|
+
surface = "a {start} has a {end}"
|
|
48
|
+
ace = "ObjectProperty"
|
|
49
|
+
predicate = "mgx:hasA"
|
|
50
|
+
note = "possession/holonymy; plain edge today — ACE pattern 5 cardinality is a later refinement"
|
|
51
|
+
|
|
52
|
+
[[relation]]
|
|
53
|
+
rel = "/r/UsedFor"
|
|
54
|
+
surface = "a {start} is used for {end}"
|
|
55
|
+
ace = "ObjectProperty"
|
|
56
|
+
predicate = "mgx:usedFor"
|
|
57
|
+
note = "typical purpose; ACE pattern 3"
|
|
58
|
+
|
|
59
|
+
[[relation]]
|
|
60
|
+
rel = "/r/CapableOf"
|
|
61
|
+
surface = "a {start} can {end}"
|
|
62
|
+
ace = "ObjectProperty"
|
|
63
|
+
predicate = "mgx:capableOf"
|
|
64
|
+
note = "typical capability; ACE pattern 3"
|
|
65
|
+
|
|
66
|
+
[[relation]]
|
|
67
|
+
rel = "/r/AtLocation"
|
|
68
|
+
surface = "you are likely to find a {start} in a {end}"
|
|
69
|
+
ace = "ObjectProperty"
|
|
70
|
+
predicate = "mgx:atLocation"
|
|
71
|
+
note = "typical location; ACE pattern 3"
|
|
72
|
+
|
|
73
|
+
[[relation]]
|
|
74
|
+
rel = "/r/Causes"
|
|
75
|
+
surface = "a {start} causes {end}"
|
|
76
|
+
ace = "ObjectProperty"
|
|
77
|
+
predicate = "mgx:causes"
|
|
78
|
+
note = "causation; ACE pattern 3"
|
|
79
|
+
|
|
80
|
+
[[relation]]
|
|
81
|
+
rel = "/r/HasSubevent"
|
|
82
|
+
surface = "when you {start}, you {end}"
|
|
83
|
+
ace = "ObjectProperty"
|
|
84
|
+
predicate = "mgx:hasSubevent"
|
|
85
|
+
note = "event decomposition; ACE pattern 3"
|
|
86
|
+
|
|
87
|
+
[[relation]]
|
|
88
|
+
rel = "/r/HasFirstSubevent"
|
|
89
|
+
surface = "the first thing you do when you {start} is {end}"
|
|
90
|
+
ace = "ObjectProperty"
|
|
91
|
+
predicate = "mgx:hasFirstSubevent"
|
|
92
|
+
note = "first step; ACE pattern 3"
|
|
93
|
+
|
|
94
|
+
[[relation]]
|
|
95
|
+
rel = "/r/HasLastSubevent"
|
|
96
|
+
surface = "the last thing you do when you {start} is {end}"
|
|
97
|
+
ace = "ObjectProperty"
|
|
98
|
+
predicate = "mgx:hasLastSubevent"
|
|
99
|
+
note = "last step; ACE pattern 3"
|
|
100
|
+
|
|
101
|
+
[[relation]]
|
|
102
|
+
rel = "/r/HasPrerequisite"
|
|
103
|
+
surface = "in order to {start}, you must {end}"
|
|
104
|
+
ace = "ObjectProperty"
|
|
105
|
+
predicate = "mgx:hasPrerequisite"
|
|
106
|
+
note = "dependency; ACE pattern 3"
|
|
107
|
+
|
|
108
|
+
[[relation]]
|
|
109
|
+
rel = "/r/HasProperty"
|
|
110
|
+
surface = "a {start} is {end}"
|
|
111
|
+
ace = "property"
|
|
112
|
+
predicate = "mgx:hasProperty"
|
|
113
|
+
note = "attribute/adjective; ACE pattern 8 ('N1 is ADJ')"
|
|
114
|
+
|
|
115
|
+
[[relation]]
|
|
116
|
+
rel = "/r/MotivatedByGoal"
|
|
117
|
+
surface = "you would {start} because you want to {end}"
|
|
118
|
+
ace = "ObjectProperty"
|
|
119
|
+
predicate = "mgx:motivatedByGoal"
|
|
120
|
+
note = "motivation; ACE pattern 3"
|
|
121
|
+
|
|
122
|
+
[[relation]]
|
|
123
|
+
rel = "/r/ObstructedBy"
|
|
124
|
+
surface = "{start} can be prevented by {end}"
|
|
125
|
+
ace = "ObjectProperty"
|
|
126
|
+
predicate = "mgx:obstructedBy"
|
|
127
|
+
note = "blocker; ACE pattern 3"
|
|
128
|
+
|
|
129
|
+
[[relation]]
|
|
130
|
+
rel = "/r/Desires"
|
|
131
|
+
surface = "a {start} wants {end}"
|
|
132
|
+
ace = "ObjectProperty"
|
|
133
|
+
predicate = "mgx:desires"
|
|
134
|
+
note = "typical desire; ACE pattern 3"
|
|
135
|
+
|
|
136
|
+
[[relation]]
|
|
137
|
+
rel = "/r/CausesDesire"
|
|
138
|
+
surface = "{start} makes you want to {end}"
|
|
139
|
+
ace = "ObjectProperty"
|
|
140
|
+
predicate = "mgx:causesDesire"
|
|
141
|
+
note = "evoked desire; ACE pattern 3"
|
|
142
|
+
|
|
143
|
+
[[relation]]
|
|
144
|
+
rel = "/r/CreatedBy"
|
|
145
|
+
surface = "a {start} is created by a {end}"
|
|
146
|
+
ace = "ObjectProperty"
|
|
147
|
+
predicate = "mgx:createdBy"
|
|
148
|
+
note = "provenance; ACE pattern 3"
|
|
149
|
+
|
|
150
|
+
[[relation]]
|
|
151
|
+
rel = "/r/MadeOf"
|
|
152
|
+
surface = "a {start} is made of {end}"
|
|
153
|
+
ace = "ObjectProperty"
|
|
154
|
+
predicate = "mgx:madeOf"
|
|
155
|
+
note = "material; ACE pattern 3"
|
|
156
|
+
|
|
157
|
+
[[relation]]
|
|
158
|
+
rel = "/r/ReceivesAction"
|
|
159
|
+
surface = "a {start} can be {end}"
|
|
160
|
+
ace = "ObjectProperty"
|
|
161
|
+
predicate = "mgx:receivesAction"
|
|
162
|
+
note = "typical patient role; ACE pattern 3"
|
|
163
|
+
|
|
164
|
+
[[relation]]
|
|
165
|
+
rel = "/r/LocatedNear"
|
|
166
|
+
surface = "a {start} is typically near a {end}"
|
|
167
|
+
ace = "ObjectProperty"
|
|
168
|
+
predicate = "mgx:locatedNear"
|
|
169
|
+
note = "proximity; ACE pattern 3"
|
|
170
|
+
|
|
171
|
+
[[relation]]
|
|
172
|
+
rel = "/r/MannerOf"
|
|
173
|
+
surface = "{start} is a way to {end}"
|
|
174
|
+
ace = "ObjectProperty"
|
|
175
|
+
predicate = "mgx:mannerOf"
|
|
176
|
+
note = "verb specialization — properly rdfs:subPropertyOf between verbs; stored as a plain edge until the grammar grows verb hierarchies"
|
|
177
|
+
|
|
178
|
+
[[relation]]
|
|
179
|
+
rel = "/r/DistinctFrom"
|
|
180
|
+
surface = "a {start} is not a {end}"
|
|
181
|
+
ace = "disjointWith"
|
|
182
|
+
predicate = "owl:disjointWith"
|
|
183
|
+
note = "mutual exclusion; ACE pattern 6 ('no N1 is a N2')"
|
|
184
|
+
|
|
185
|
+
# --- unmappable relations: no clean OWL-axiom fit; kept for other consumers ---
|
|
186
|
+
|
|
187
|
+
[[relation]]
|
|
188
|
+
rel = "/r/RelatedTo"
|
|
189
|
+
surface = "{start} is related to {end}"
|
|
190
|
+
ace = "none"
|
|
191
|
+
note = "weakest, undirected association — too vague for an axiom; useful later as a fuzzy-match hint, never a fact"
|
|
192
|
+
|
|
193
|
+
[[relation]]
|
|
194
|
+
rel = "/r/Synonym"
|
|
195
|
+
surface = "{start} means the same as {end}"
|
|
196
|
+
ace = "none"
|
|
197
|
+
note = "lexical alias, not an axiom — feeds the grammar lexicon / phrasebook synonym families instead"
|
|
198
|
+
|
|
199
|
+
[[relation]]
|
|
200
|
+
rel = "/r/Antonym"
|
|
201
|
+
surface = "{start} is the opposite of {end}"
|
|
202
|
+
ace = "none"
|
|
203
|
+
note = "lexical opposition; OWL disjointness would over-claim (hot/cold are not disjoint classes) — DistinctFrom carries the real disjointness"
|
|
204
|
+
|
|
205
|
+
[[relation]]
|
|
206
|
+
rel = "/r/FormOf"
|
|
207
|
+
surface = "{start} is a form of the word {end}"
|
|
208
|
+
ace = "none"
|
|
209
|
+
note = "inflection → root; lexicon normalization, not knowledge"
|
|
210
|
+
|
|
211
|
+
[[relation]]
|
|
212
|
+
rel = "/r/DerivedFrom"
|
|
213
|
+
surface = "the word {start} is derived from {end}"
|
|
214
|
+
ace = "none"
|
|
215
|
+
note = "word derivation; lexicon material, not an axiom"
|
|
216
|
+
|
|
217
|
+
[[relation]]
|
|
218
|
+
rel = "/r/SymbolOf"
|
|
219
|
+
surface = "{start} is a symbol of {end}"
|
|
220
|
+
ace = "none"
|
|
221
|
+
note = "symbolism — no OWL fit"
|
|
222
|
+
|
|
223
|
+
[[relation]]
|
|
224
|
+
rel = "/r/SimilarTo"
|
|
225
|
+
surface = "{start} is similar to {end}"
|
|
226
|
+
ace = "none"
|
|
227
|
+
note = "graded similarity — no crisp OWL fit; potential fuzzy-match hint only"
|
|
228
|
+
|
|
229
|
+
[[relation]]
|
|
230
|
+
rel = "/r/HasContext"
|
|
231
|
+
surface = "{start} is used in the context of {end}"
|
|
232
|
+
ace = "none"
|
|
233
|
+
note = "usage-domain tag — the slice FILTER signal (tech-domain selection), not a fact to store"
|
|
234
|
+
|
|
235
|
+
[[relation]]
|
|
236
|
+
rel = "/r/EtymologicallyRelatedTo"
|
|
237
|
+
surface = "{start} shares an origin with {end}"
|
|
238
|
+
ace = "none"
|
|
239
|
+
note = "etymology — filtered out of the slice by policy"
|
|
240
|
+
|
|
241
|
+
[[relation]]
|
|
242
|
+
rel = "/r/EtymologicallyDerivedFrom"
|
|
243
|
+
surface = "the word {start} comes from {end}"
|
|
244
|
+
ace = "none"
|
|
245
|
+
note = "etymology — filtered out of the slice by policy"
|
|
246
|
+
|
|
247
|
+
[[relation]]
|
|
248
|
+
rel = "/r/ExternalURL"
|
|
249
|
+
surface = "{start} is described at {end}"
|
|
250
|
+
ace = "none"
|
|
251
|
+
note = "link out of the graph — filtered out of the slice (end is a URL, not an en concept)"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// corpus/conceptnet.mjs — the ConceptNet slice loader + memory seeder
|
|
2
|
+
// (ROADMAP Phase 2, "ConceptNet corpus slice").
|
|
3
|
+
//
|
|
4
|
+
// loadSlice(path?) stream corpus/conceptnet/slice.jsonl → assertions
|
|
5
|
+
// loadMap(path?) src/corpus/conceptnet-map.toml → Map(rel → row)
|
|
6
|
+
// toFacts(assertions,map) assertions → appendFact-shaped triples
|
|
7
|
+
// seedMemory(dir, opts) write them into <dir>/.tmct/memory via appendFact
|
|
8
|
+
//
|
|
9
|
+
// The slice is committed data (one JSON object per line: {start, rel, end,
|
|
10
|
+
// surfaceText?, weight}; en→en only; CC-BY-SA 4.0 for ConceptNet-derived rows
|
|
11
|
+
// — see corpus/conceptnet/LICENSE-NOTICE). The mapping table decides which
|
|
12
|
+
// relations become memory facts and under which predicate URI; rows marked
|
|
13
|
+
// ace = "none" are deliberate non-emissions. A slice relation MISSING from
|
|
14
|
+
// the table is a drift error — loud, never guessed around.
|
|
15
|
+
//
|
|
16
|
+
// Seeding goes through src/memory/core.mjs appendFact() ONLY (memory is
|
|
17
|
+
// import-only here): fact ids are content-hashed from (s,p,o), so re-seeding
|
|
18
|
+
// is idempotent by construction. seedMemory additionally pre-loads the store
|
|
19
|
+
// once and skips triples already present, so a re-seed is read-mostly instead
|
|
20
|
+
// of N rewrites.
|
|
21
|
+
|
|
22
|
+
import { createReadStream } from "node:fs";
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import { createInterface } from "node:readline";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { parse as parseToml } from "smol-toml";
|
|
28
|
+
import { appendFact, loadMemory, normFactTerm } from "../memory/core.mjs";
|
|
29
|
+
|
|
30
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
31
|
+
export const SLICE_FILE = join(PKG_ROOT, "corpus", "conceptnet", "slice.jsonl");
|
|
32
|
+
export const MAP_FILE = join(PKG_ROOT, "src", "corpus", "conceptnet-map.toml");
|
|
33
|
+
|
|
34
|
+
const ACE_PATTERNS = new Set(["subClassOf", "type", "ObjectProperty", "someValuesFrom", "disjointWith", "property", "none"]);
|
|
35
|
+
|
|
36
|
+
/** Load the slice JSONL as a stream (never the whole file as one string) and
|
|
37
|
+
* return the parsed assertions. Every line must carry start/rel/end; bad
|
|
38
|
+
* lines fail loudly with file:line. */
|
|
39
|
+
export async function loadSlice(path = SLICE_FILE) {
|
|
40
|
+
const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
|
|
41
|
+
const assertions = [];
|
|
42
|
+
let n = 0;
|
|
43
|
+
for await (const raw of rl) {
|
|
44
|
+
n += 1;
|
|
45
|
+
const line = raw.trim();
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
let row;
|
|
48
|
+
try {
|
|
49
|
+
row = JSON.parse(line);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`${path}:${n}: not valid JSON: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
for (const field of ["start", "rel", "end"]) {
|
|
54
|
+
if (typeof row[field] !== "string" || !row[field]) {
|
|
55
|
+
throw new Error(`${path}:${n}: assertion missing "${field}"`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
assertions.push(row);
|
|
59
|
+
}
|
|
60
|
+
return assertions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load the relation → ACE-OWL mapping table. Returns Map(rel → row); every
|
|
64
|
+
* row must have a known `ace` pattern, and a mapped (non-"none") row must
|
|
65
|
+
* name the predicate URI it emits. */
|
|
66
|
+
export async function loadMap(path = MAP_FILE) {
|
|
67
|
+
const table = parseToml(await readFile(path, "utf8"));
|
|
68
|
+
const rows = table.relation || [];
|
|
69
|
+
const map = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (!row.rel) throw new Error(`${path}: a [[relation]] row is missing "rel"`);
|
|
72
|
+
if (map.has(row.rel)) throw new Error(`${path}: duplicate mapping for ${row.rel}`);
|
|
73
|
+
if (!ACE_PATTERNS.has(row.ace)) {
|
|
74
|
+
throw new Error(`${path}: ${row.rel} has unknown ace pattern ${JSON.stringify(row.ace)}`);
|
|
75
|
+
}
|
|
76
|
+
if (row.ace !== "none" && !row.predicate) {
|
|
77
|
+
throw new Error(`${path}: ${row.rel} maps to ${row.ace} but names no predicate URI`);
|
|
78
|
+
}
|
|
79
|
+
map.set(row.rel, row);
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** /c/en/source_code → "source code" — the human term text a memory fact stores. */
|
|
85
|
+
export const termText = (uri) => {
|
|
86
|
+
const m = /^\/c\/en\/([^/]+)/.exec(String(uri || ""));
|
|
87
|
+
return m ? m[1].replace(/_/g, " ") : null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Map slice assertions → appendFact-shaped triples:
|
|
91
|
+
* { subject, predicate, object, provenance }
|
|
92
|
+
* (provenance is a STRING — exactly what src/memory/core.mjs appendFact
|
|
93
|
+
* takes; it names the corpus and the originating ConceptNet relation).
|
|
94
|
+
* Rows whose relation maps ace="none" are skipped — deliberate non-emission.
|
|
95
|
+
* A relation with NO row in the map throws: that is table drift, not data. */
|
|
96
|
+
export function toFacts(assertions, map) {
|
|
97
|
+
const facts = [];
|
|
98
|
+
for (const a of assertions) {
|
|
99
|
+
const row = map.get(a.rel);
|
|
100
|
+
if (!row) {
|
|
101
|
+
throw new Error(`slice/map drift: relation ${a.rel} has no row in conceptnet-map.toml`);
|
|
102
|
+
}
|
|
103
|
+
if (row.ace === "none") continue;
|
|
104
|
+
const subject = termText(a.start);
|
|
105
|
+
const object = termText(a.end);
|
|
106
|
+
if (!subject || !object) continue; // non-en endpoint slipped in — filtered, not fatal
|
|
107
|
+
facts.push({
|
|
108
|
+
subject,
|
|
109
|
+
predicate: row.predicate,
|
|
110
|
+
object,
|
|
111
|
+
provenance: `corpus:conceptnet ${a.rel}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return facts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the
|
|
118
|
+
* committed slice. Options: limit (cap the facts written — handy for tests
|
|
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.
|
|
126
|
+
*
|
|
127
|
+
* Idempotent twice over: appendFact's content-hashed ids make a blind
|
|
128
|
+
* re-append an upsert, and we pre-read the store once to skip triples that
|
|
129
|
+
* are already there (so re-seeding costs one read, not N rewrites).
|
|
130
|
+
* Returns { appended, skipped, total }. */
|
|
131
|
+
export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer } = {}) {
|
|
132
|
+
const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
|
|
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
|
+
}
|
|
140
|
+
if (limit !== undefined) facts = facts.slice(0, limit);
|
|
141
|
+
|
|
142
|
+
// One read up front: what does the store already reify? Keys are built with
|
|
143
|
+
// memory's own normFactTerm so they match the normalized read-back exactly
|
|
144
|
+
// (appendFact converges /c/en/foo_bar, tmct:Foo and "Foo bar" to one term).
|
|
145
|
+
const factKey = (s, p, o) => `${normFactTerm(s)} ${p} ${normFactTerm(o)}`;
|
|
146
|
+
const existing = new Set();
|
|
147
|
+
const memory = await loadMemory(dir);
|
|
148
|
+
for (const ind of memory.individuals || []) {
|
|
149
|
+
if (ind?.class !== "Fact") continue;
|
|
150
|
+
const get = (key) => (ind.attributes || []).find((x) => x.key === key)?.value;
|
|
151
|
+
existing.add(factKey(get("subject"), get("predicate"), get("object")));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let appended = 0;
|
|
155
|
+
let skipped = 0;
|
|
156
|
+
for (const fact of facts) {
|
|
157
|
+
const key = factKey(fact.subject, fact.predicate, fact.object);
|
|
158
|
+
if (existing.has(key)) {
|
|
159
|
+
skipped += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
await appendFact(dir, fact);
|
|
163
|
+
existing.add(key);
|
|
164
|
+
appended += 1;
|
|
165
|
+
}
|
|
166
|
+
return { appended, skipped, total: facts.length };
|
|
167
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// corpus/templates.mjs — the response-template library + SE phrase book loaders
|
|
2
|
+
// (ROADMAP Phase 2, items 4+7). Plain diffable data in, strict renderers out:
|
|
3
|
+
//
|
|
4
|
+
// data/templates/responses.jsonl {id, class, template, register} rows
|
|
5
|
+
// data/phrasebook/software-phrases.txt one phrase pattern per line
|
|
6
|
+
// (`#` comments, `~` synonym families)
|
|
7
|
+
//
|
|
8
|
+
// loadTemplates() validates the whole file (parse, required fields, unique
|
|
9
|
+
// ids) and caches; render(id, slots) is then synchronous and STRICT — an
|
|
10
|
+
// unknown id or a missing slot throws, it never emits a half-filled sentence.
|
|
11
|
+
// The response surface (Phase 1 pipeline) fills templates from grounded data
|
|
12
|
+
// only, so a thrown slot is a programming error, not a user-facing miss.
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
|
|
18
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
19
|
+
export const TEMPLATES_FILE = join(PKG_ROOT, "data", "templates", "responses.jsonl");
|
|
20
|
+
export const PHRASEBOOK_FILE = join(PKG_ROOT, "data", "phrasebook", "software-phrases.txt");
|
|
21
|
+
|
|
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"]);
|
|
29
|
+
const SLOT_RE = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
|
|
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
|
+
|
|
41
|
+
/** The slot names a template string requires, in first-appearance order. */
|
|
42
|
+
export function slotsOf(template) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const m of String(template).matchAll(SLOT_RE)) {
|
|
45
|
+
if (!out.includes(m[1])) out.push(m[1]);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
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
|
+
|
|
110
|
+
let cache = null; // Map<id, row> from the last loadTemplates() — render()'s source
|
|
111
|
+
|
|
112
|
+
/** Load + validate the response templates. Every line must parse as JSON with
|
|
113
|
+
* a unique `id`, a `class`, a known `register`, and a non-empty `template`
|
|
114
|
+
* (bad data fails loudly at load, never at render time). Returns Map<id,row>
|
|
115
|
+
* (each row gains `slots`, its required slot names) and primes render(). */
|
|
116
|
+
export async function loadTemplates(path = TEMPLATES_FILE) {
|
|
117
|
+
const text = await readFile(path, "utf8");
|
|
118
|
+
const byId = new Map();
|
|
119
|
+
const lines = text.split("\n");
|
|
120
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
121
|
+
const line = lines[n].trim();
|
|
122
|
+
if (!line) continue;
|
|
123
|
+
let row;
|
|
124
|
+
try {
|
|
125
|
+
row = JSON.parse(line);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new Error(`${path}:${n + 1}: not valid JSON: ${e.message}`);
|
|
128
|
+
}
|
|
129
|
+
for (const field of ["id", "class", "template", "register"]) {
|
|
130
|
+
if (typeof row[field] !== "string" || !row[field]) {
|
|
131
|
+
throw new Error(`${path}:${n + 1}: missing/empty "${field}"`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!REGISTERS.has(row.register)) {
|
|
135
|
+
throw new Error(`${path}:${n + 1}: register must be terse|friendly|technical, got "${row.register}"`);
|
|
136
|
+
}
|
|
137
|
+
if (byId.has(row.id)) throw new Error(`${path}:${n + 1}: duplicate template id "${row.id}"`);
|
|
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 });
|
|
150
|
+
}
|
|
151
|
+
cache = byId;
|
|
152
|
+
return byId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fill template `id` with `slots` — strict: unknown id throws; ANY missing
|
|
156
|
+
* slot throws (named), so a response is complete or not emitted at all.
|
|
157
|
+
* Extra slots are ignored. Uses the map from loadTemplates() (pass
|
|
158
|
+
* `templates` explicitly to bypass the module cache, e.g. in tests). */
|
|
159
|
+
export function render(id, slots = {}, templates = cache) {
|
|
160
|
+
if (!templates) throw new Error("render() before loadTemplates() — load the template library first");
|
|
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));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Load + parse the SE phrase book. Returns:
|
|
168
|
+
* patterns [{pattern, slots}] one per phrase line ("what calls {x}")
|
|
169
|
+
* synonyms [[word, …], …] one per `~` family line (≥2 entries each)
|
|
170
|
+
* `#`-prefixed lines and blank lines are skipped. */
|
|
171
|
+
export async function loadPhrasebook(path = PHRASEBOOK_FILE) {
|
|
172
|
+
const text = await readFile(path, "utf8");
|
|
173
|
+
const patterns = [];
|
|
174
|
+
const synonyms = [];
|
|
175
|
+
const lines = text.split("\n");
|
|
176
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
177
|
+
const line = lines[n].trim();
|
|
178
|
+
if (!line || line.startsWith("#")) continue;
|
|
179
|
+
if (line.startsWith("~")) {
|
|
180
|
+
const family = line.slice(1).split(",").map((w) => w.trim().toLowerCase()).filter(Boolean);
|
|
181
|
+
if (family.length < 2) throw new Error(`${path}:${n + 1}: a synonym family needs at least 2 entries`);
|
|
182
|
+
synonyms.push(family);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
patterns.push({ pattern: line, slots: slotsOf(line) });
|
|
186
|
+
}
|
|
187
|
+
return { patterns, synonyms };
|
|
188
|
+
}
|