@polycode-projects/the-mechanical-code-talker 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +441 -217
  2. package/bin/tmct.mjs +126 -1
  3. package/corpus/seon/README.md +1 -2
  4. package/package.json +4 -2
  5. package/src/answer-variants.mjs +8 -36
  6. package/src/ask-browser-entry.mjs +5 -23
  7. package/src/ask-browser.bundle.js +1 -2
  8. package/src/ask-nlp.mjs +9 -23
  9. package/src/ask-vocab.mjs +139 -589
  10. package/src/ask.mjs +627 -1729
  11. package/src/chat.mjs +1684 -2874
  12. package/src/cli-args.mjs +14 -28
  13. package/src/codegraph.mjs +236 -644
  14. package/src/completions/complete.mjs +18 -62
  15. package/src/completions/graph-adapter.mjs +14 -60
  16. package/src/completions/group.mjs +12 -68
  17. package/src/completions/infer.mjs +38 -126
  18. package/src/completions/prune.mjs +17 -70
  19. package/src/completions/rank.mjs +16 -69
  20. package/src/completions/search.mjs +8 -31
  21. package/src/concept.mjs +32 -88
  22. package/src/conformance.mjs +11 -15
  23. package/src/corpus/conceptnet.mjs +31 -89
  24. package/src/corpus/templates.mjs +19 -45
  25. package/src/corpus/unknown-ingest.mjs +31 -92
  26. package/src/embed.mjs +10 -22
  27. package/src/extensions.mjs +50 -154
  28. package/src/finish.mjs +35 -91
  29. package/src/grammar/ace.mjs +16 -40
  30. package/src/grammar/assert.mjs +1 -1
  31. package/src/grammar/lexicon-core.json +1 -1
  32. package/src/grammar/lexicon.mjs +9 -27
  33. package/src/graph-merge.mjs +2 -3
  34. package/src/hash.mjs +6 -14
  35. package/src/index.mjs +6 -10
  36. package/src/init.mjs +38 -125
  37. package/src/interpret/fuzzy.mjs +10 -29
  38. package/src/interpret/merge.mjs +9 -27
  39. package/src/interpret/normalize.mjs +137 -585
  40. package/src/interpret/pipeline.mjs +23 -71
  41. package/src/interpret/strategies/ace.mjs +7 -31
  42. package/src/interpret/strategies/constructions.mjs +14 -41
  43. package/src/interpret/strategies/grammar.mjs +21 -60
  44. package/src/interpret/strategies/keywords.mjs +42 -131
  45. package/src/interpret/strategies/noise-strip.mjs +18 -89
  46. package/src/memory/bias.mjs +11 -54
  47. package/src/memory/blocks.mjs +18 -69
  48. package/src/memory/core.mjs +171 -591
  49. package/src/memory/fold.mjs +0 -0
  50. package/src/memory/inspect.mjs +7 -25
  51. package/src/memory/shacl.mjs +10 -39
  52. package/src/memory/trust.mjs +26 -127
  53. package/src/memory-ask-browser-entry.mjs +7 -30
  54. package/src/memory-ask-browser.bundle.js +1 -1
  55. package/src/paraphrase.mjs +20 -53
  56. package/src/planning.mjs +15 -157
  57. package/src/prose-nlp.mjs +4 -17
  58. package/src/prose.mjs +19 -67
  59. package/src/providers/bootstrap.mjs +1 -2
  60. package/src/providers/fixture.mjs +1 -2
  61. package/src/providers/graph-service.mjs +28 -59
  62. package/src/repository-interface.mjs +6 -8
  63. package/src/router/drive.mjs +183 -0
  64. package/src/router/goal-reasoner.mjs +66 -231
  65. package/src/router/guardrail.mjs +20 -58
  66. package/src/router/planner.mjs +15 -46
  67. package/src/router/registry.mjs +13 -43
  68. package/src/router/resolver.mjs +46 -131
  69. package/src/router/results.mjs +231 -0
  70. package/src/schema-docs.mjs +10 -27
  71. package/src/server-http.mjs +10 -19
  72. package/src/server.mjs +22 -28
  73. package/src/sessions.mjs +15 -30
  74. package/src/source-slice.mjs +5 -7
  75. package/src/source.mjs +10 -20
  76. package/src/syllogise.mjs +187 -575
  77. package/src/telemetry.mjs +3 -3
  78. package/src/toml-config.mjs +4 -4
  79. package/src/tui/app.mjs +9 -19
  80. package/src/viz.mjs +66 -123
  81. package/src/wink-model.mjs +10 -24
@@ -1,24 +1,14 @@
1
- // corpus/conceptnet.mjs — the ConceptNet slice loader + memory seeder
2
- // (ROADMAP Phase 2, "ConceptNet corpus slice").
1
+ // corpus/conceptnet.mjs — the ConceptNet slice loader + memory seeder.
3
2
  //
4
3
  // loadSlice(path?) stream corpus/conceptnet/slice.jsonl → assertions
5
4
  // loadMap(path?) src/corpus/conceptnet-map.toml → Map(rel → row)
6
5
  // toFacts(assertions,map) assertions → appendFact-shaped triples
7
- // seedMemory(dir, opts) write them into <dir>/.tmct/memory via appendFacts (one batched write)
6
+ // seedMemory(dir, opts) write them into <dir>/.tmct/memory via appendFacts
8
7
  //
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 appendFacts() (memory is import-only
17
- // here): fact ids are content-hashed from (s,p,o), so re-seeding is idempotent by
18
- // construction. seedMemory pre-loads the store once and skips triples already
19
- // present, then hands the survivors to appendFacts as ONE batched read-modify-
20
- // write — so seeding the whole slice is O(N), not the O(N²) a per-fact appendFact
21
- // loop would incur (the 6 k-fact slice: ~7 min → a couple of seconds).
8
+ // The slice is committed data (en→en only; CC-BY-SA 4.0 see
9
+ // corpus/conceptnet/LICENSE-NOTICE). The mapping table decides which relations become
10
+ // memory facts and under which predicate; a slice relation missing from the table is a
11
+ // drift error, loud, never guessed around.
22
12
 
23
13
  import { createReadStream } from "node:fs";
24
14
  import { readFile } from "node:fs/promises";
@@ -32,29 +22,18 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
32
22
  export const SLICE_FILE = join(PKG_ROOT, "corpus", "conceptnet", "slice.jsonl");
33
23
  export const MAP_FILE = join(PKG_ROOT, "src", "corpus", "conceptnet-map.toml");
34
24
 
35
- // The tier-1 curated Software-Engineering ontology (SEON). concepts.jsonl is in
36
- // the SAME slice shape as ConceptNet ({start, rel, end, weight}), so it loads +
37
- // maps through the identical loadSlice/loadMap/toFacts path just with a
38
- // "corpus:seon" provenance prefix. definitions.jsonl is a separate {term,
39
- // definition, sense} list the chat answer layer prefers for a lexicon term's
40
- // "what is a <term>". tier-2 corpuses (aws/python/java) share the slice shape too.
25
+ // The tier-1 curated Software-Engineering ontology (SEON): concepts.jsonl shares
26
+ // ConceptNet's slice shape and loads through the same loadSlice/loadMap/toFacts path.
27
+ // definitions.jsonl is a separate {term, definition, sense} list for lexicon lookups.
28
+ // Tier-2 corpuses (aws/python/java) share the slice shape too.
41
29
  export const SEON_CONCEPTS_FILE = join(PKG_ROOT, "corpus", "seon", "concepts.jsonl");
42
30
  export const SEON_DEFINITIONS_FILE = join(PKG_ROOT, "corpus", "seon", "definitions.jsonl");
43
31
  export const TIER2_DIR = join(PKG_ROOT, "corpus", "tier2");
44
32
  export const TIER2_MANIFEST_FILE = join(TIER2_DIR, "manifest.json");
45
33
 
46
- // corpus/wordnet/generate.mjs's output: the Open English WordNet ->
47
- // ConceptNet-shape conversion, same slice shape/loader path as tier-1/tier-2.
48
- // Lives alongside its own generator (corpus/conceptnet/'s own precedent:
49
- // fetch-slice.mjs + slice.jsonl share one directory) rather than under a new
50
- // "tier-3" name — corpus/README.md's tiering policy already uses "tier-3" for
51
- // something else entirely (runtime-learned facts, NEVER committed), and this
52
- // bundle is curated + committed, the same shape as a tier-2 corpus, just too
53
- // large/mechanically-derived to hand-curate. "wordnet-xl"/"wordnet-full" are
54
- // wired as BUILTIN_EXTENSIONS corpus entries in src/extensions.mjs, so
55
- // `tmct import --corpus wordnet-xl` resolves directly there rather than
56
- // through TIER2_MANIFEST_FILE's id lookup — see that module's own
57
- // BUILTIN_EXTENSIONS comment.
34
+ // corpus/wordnet/generate.mjs's output: the Open English WordNet -> ConceptNet-shape
35
+ // conversion, same slice shape/loader path as tier-1/tier-2. "wordnet-xl"/"wordnet-full"
36
+ // are wired as BUILTIN_EXTENSIONS corpus entries in src/extensions.mjs.
58
37
  export const WORDNET_DIR = join(PKG_ROOT, "corpus", "wordnet");
59
38
  export const WORDNET_MANIFEST_FILE = join(WORDNET_DIR, "manifest.json");
60
39
 
@@ -114,17 +93,10 @@ export const termText = (uri) => {
114
93
  return m ? m[1].replace(/_/g, " ") : null;
115
94
  };
116
95
 
117
- /** Map slice assertions → appendFact-shaped triples:
118
- * { subject, predicate, object, provenance }
119
- * (provenance is a STRING exactly what src/memory/core.mjs appendFact
120
- * takes; it names the corpus and the originating ConceptNet relation).
121
- * Rows whose relation maps ace="none" are skipped — deliberate non-emission.
122
- * A relation with NO row in the map throws: that is table drift, not data.
123
- *
124
- * `provenancePrefix` names the corpus half of the provenance string; it defaults
125
- * to "corpus:conceptnet" so the ConceptNet seed stays BYTE-IDENTICAL to before.
126
- * The seon / tier-2 corpuses reuse the same slice shape, tagged "corpus:seon" or
127
- * "corpus:tier2:<id>" so a reader can tell a curated SE fact from ConceptNet noise. */
96
+ /** Map slice assertions → appendFact-shaped triples { subject, predicate, object,
97
+ * provenance }. Rows whose relation maps ace="none" are skipped; a relation with no row
98
+ * in the map throws (table drift, not data). `provenancePrefix` tags the corpus half of
99
+ * provenance, e.g. "corpus:seon" / "corpus:tier2:<id>" for non-ConceptNet callers. */
128
100
  export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet") {
129
101
  const facts = [];
130
102
  for (const a of assertions) {
@@ -136,10 +108,8 @@ export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet")
136
108
  const subject = termText(a.start);
137
109
  const object = termText(a.end);
138
110
  if (!subject || !object) continue; // non-en endpoint slipped in — filtered, not fatal
139
- // mgx:relatedTo is real but low-precision (undirected, ambiguous) — routed
140
- // through the corpus-weak: prefix so memory/trust.mjs's SOURCE_PRIOR.corpusWeak
141
- // (below plain corpus, above web) applies, computed from the Source's kind
142
- // like every other tier, never hand-set on the Fact.
111
+ // mgx:relatedTo is low-precision (undirected, ambiguous) — routed through the
112
+ // corpus-weak: prefix so memory/trust.mjs's SOURCE_PRIOR.corpusWeak applies.
143
113
  const prefix = row.predicate === "mgx:relatedTo"
144
114
  ? provenancePrefix.replace(/^corpus:/, "corpus-weak:")
145
115
  : provenancePrefix;
@@ -153,39 +123,18 @@ export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet")
153
123
  return facts;
154
124
  }
155
125
 
156
- /** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the
157
- * committed slice. Options: limit (cap the facts written handy for tests
158
- * and fast bootstraps), slicePath/mapPath overrides, and `prefer` an array
159
- * of predicate URIs that STABLE-partitions the facts before the limit is
160
- * applied (facts whose predicate appears earlier in `prefer` come first;
161
- * everything else keeps slice order after them). A capped bootstrap seed
162
- * wants the DEFINITIONAL band ("a cache is a kind of buffer") ahead of the
163
- * location trivia the slice happens to open with; without `prefer` the
164
- * behavior is byte-identical to before.
165
- *
166
- * Idempotent twice over: appendFacts' content-hashed ids make a blind
167
- * re-append an upsert, and we pre-read the store once to skip triples that
168
- * are already there (so re-seeding costs one read, not N rewrites). The
169
- * survivors are written in ONE batched appendFacts call, not a per-fact loop.
170
- * Returns { appended, skipped, total }. `provenancePrefix` is threaded through to
171
- * toFacts (default "corpus:conceptnet" → byte-identical seed) so a seon/tier-2
172
- * corpus can tag its facts "corpus:seon" / "corpus:tier2:<id>".
126
+ /** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the committed slice.
127
+ * `limit` caps the facts written; `prefer` stable-partitions facts by predicate (so a
128
+ * capped seed favors the definitional band over whatever trivia the slice opens with).
129
+ * Idempotent: pre-reads the store to skip triples already there, then writes survivors
130
+ * in one batched appendFacts call. Returns { appended, skipped, total }.
131
+ * `provenancePrefix` tags facts (default "corpus:conceptnet").
173
132
  *
174
- * `captureUnknownContext` (default false every existing call stays
175
- * byte-identical): when true, also runs corpus/unknown-ingest.mjs's
176
- * `ingestUnknownFromAssertions` over this same assertions/map pair the
177
- * PLAN_AGENTS.md §4 "context-preserving ingestion for unknown words"
178
- * mechanism so a term that ONLY ever appears in a row `toFacts` silently
179
- * drops (an `ace = "none"` relation like RelatedTo/HasContext) still lands
180
- * in memory, tagged with the passage it was found in, instead of vanishing.
181
- * `unknownContextLimit` bounds how many distinct unknown terms one call
182
- * captures (default 500 — see that module's own doc comment for why an
183
- * unbounded sweep over a wide slice would not be "bounded, not padding").
184
- * The result's `unknown` key is present only when the flag is set. Loaded
185
- * dynamically (not a static import) to avoid a load-time import cycle with
186
- * unknown-ingest.mjs, which itself statically imports `termText` from here —
187
- * the same "avoid the cycle" discipline extensions.mjs's
188
- * seedActiveCorpusEntries already uses for this very module. */
133
+ * `captureUnknownContext` (default false): also runs unknown-ingest.mjs's
134
+ * ingestUnknownFromAssertions so a term that only appears in an `ace="none"` row (never
135
+ * reified as a Fact) still lands in memory, tagged with the passage it was found in.
136
+ * `unknownContextLimit` bounds how many distinct terms one call captures (default 500).
137
+ * Loaded dynamically to avoid a load-time import cycle with unknown-ingest.mjs. */
189
138
  export async function seedMemory(dir, {
190
139
  limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix,
191
140
  captureUnknownContext = false, unknownContextLimit,
@@ -194,15 +143,11 @@ export async function seedMemory(dir, {
194
143
  let facts = toFacts(assertions, map, provenancePrefix);
195
144
  if (Array.isArray(prefer) && prefer.length) {
196
145
  const rank = new Map(prefer.map((p, i) => [p, i]));
197
- // stable partition: Array.prototype.sort is stable in Node, so equal-rank
198
- // facts keep their slice order — deterministic across runs by construction.
199
146
  facts = facts.slice().sort((a, b) => (rank.get(a.predicate) ?? prefer.length) - (rank.get(b.predicate) ?? prefer.length));
200
147
  }
201
148
  if (limit !== undefined) facts = facts.slice(0, limit);
202
149
 
203
- // One read up front: what does the store already reify? Keys are built with
204
- // memory's own normFactTerm so they match the normalized read-back exactly
205
- // (appendFact converges /c/en/foo_bar, tmct:Foo and "Foo bar" to one term).
150
+ // Keyed with normFactTerm so it matches the store's own normalized read-back.
206
151
  const factKey = (s, p, o) => `${normFactTerm(s)} ${p} ${normFactTerm(o)}`;
207
152
  const existing = new Set();
208
153
  const memory = await loadMemory(dir);
@@ -223,9 +168,6 @@ export async function seedMemory(dir, {
223
168
  existing.add(key);
224
169
  toWrite.push(fact);
225
170
  }
226
- // ONE read-modify-write for the whole seed (was one per fact — O(N²) I/O, ~7 min
227
- // for the 6 k-fact slice). appendFacts also skips any malformed row rather than
228
- // throwing, so its skipped count folds into the dedup skips here.
229
171
  const res = await appendFacts(dir, toWrite);
230
172
 
231
173
  let unknown;
@@ -1,5 +1,5 @@
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:
1
+ // corpus/templates.mjs — the response-template library + SE phrase book loaders.
2
+ // Plain diffable data in, strict renderers out:
3
3
  //
4
4
  // data/templates/responses.jsonl {id, class, template, register} rows
5
5
  // data/phrasebook/software-phrases.txt one phrase pattern per line
@@ -8,8 +8,8 @@
8
8
  // loadTemplates() validates the whole file (parse, required fields, unique
9
9
  // ids) and caches; render(id, slots) is then synchronous and STRICT — an
10
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.
11
+ // The response surface fills templates from grounded data only, so a thrown
12
+ // slot is a programming error, not a user-facing miss.
13
13
 
14
14
  import { readFile } from "node:fs/promises";
15
15
  import { fileURLToPath } from "node:url";
@@ -19,20 +19,14 @@ 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
- // Registers (Phase 6, archive/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.
22
+ // Registers: `terse|friendly` are the conversational bands; `technical` is the C1
23
+ // technical-paper band, rendering mechanical conclusions (count/comparison/superlative
24
+ // + provenance) as advanced prose.
28
25
  const REGISTERS = new Set(["terse", "friendly", "technical"]);
29
26
  const SLOT_RE = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
30
27
 
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).
28
+ // Slot-lint for the technical band: only mechanical values, no free-text slot and
29
+ // every technical row must carry a {provenance} fill.
36
30
  export const TECHNICAL_SLOTS = Object.freeze(new Set([
37
31
  "subject", "count", "noun", "scope", "comparison", "metric", "unit",
38
32
  "superlative", "provenance",
@@ -47,18 +41,12 @@ export function slotsOf(template) {
47
41
  return out;
48
42
  }
49
43
 
50
- // --- Segmentation IR (Phase 7, lever 1) -------------------------------------
44
+ // --- Segmentation IR ---------------------------------------------------------
51
45
  // 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.
46
+ // type ∈ prose | entity | path | number | code | provenance | receipt, satisfying
47
+ // flatten(segments) === render(). Everything except `prose` is PROTECTED — a grammar
48
+ // rule may only transform prose spans, never touch a fact. A slot fill is always
49
+ // protected; unknown slot names fall back to the conservative `entity`.
62
50
  const SLOT_KIND = {
63
51
  count: "number",
64
52
  when: "number",
@@ -152,22 +140,11 @@ export async function loadTemplates(path = TEMPLATES_FILE) {
152
140
  return byId;
153
141
  }
154
142
 
155
- /** Load + merge several EXTENSION-PACK template files (each independently
156
- * validated by loadTemplates() same loud-on-malformed-row guarantee) into
157
- * one Map<id,row>. Every extension-pack template id MUST be namespaced
158
- * "<packname>:<id>"enforced here as a validation rule (also the check
159
- * Part 4's validateExtensionPack runs on a single candidate file), rather
160
- * than inventing a same-id collision-precedence policy: a bare, unnamespaced
161
- * id, or the SAME id appearing under two different paths, both throw loudly
162
- * naming the offending path/id.
163
- *
164
- * loadTemplates(path) mutates the module's own render()-serving `cache` as a
165
- * side effect; this function restores it to whatever it was before the merge
166
- * ran, so calling loadTemplatesMerged() never clobbers the "current" default
167
- * templates map for an unrelated caller (e.g. a concurrent render() call
168
- * elsewhere in the same process). The returned map is NOT installed as the
169
- * render() default — a caller that wants render() to serve the merged set
170
- * passes it explicitly: render(id, slots, mergedMap). */
143
+ /** Load + merge several EXTENSION-PACK template files into one Map<id,row>. Every
144
+ * extension-pack template id must be namespaced "<packname>:<id>" (a bare or duplicate
145
+ * id throws, naming the offending path/id). Restores the module's `cache` to its
146
+ * pre-call state afterward the returned map is not installed as the render() default;
147
+ * a caller passes it explicitly: render(id, slots, mergedMap). */
171
148
  export async function loadTemplatesMerged(paths = []) {
172
149
  const savedCache = cache;
173
150
  const merged = new Map();
@@ -196,9 +173,6 @@ export async function loadTemplatesMerged(paths = []) {
196
173
  * `templates` explicitly to bypass the module cache, e.g. in tests). */
197
174
  export function render(id, slots = {}, templates = cache) {
198
175
  if (!templates) throw new Error("render() before loadTemplates() — load the template library first");
199
- // render() IS the flattened segmentation, by construction: the byte output is
200
- // provably identical to the old `.replace(SLOT_RE, …)` (test/segments.test.mjs
201
- // renders every responses.jsonl row both ways and asserts equality).
202
176
  return flatten(renderSegments(id, slots, templates));
203
177
  }
204
178
 
@@ -1,70 +1,29 @@
1
- // corpus/unknown-ingest.mjs — context-preserving ingestion for unknown terms
2
- // (PLAN_AGENTS.md §4 Phase 1, the "still not built at all" bullet).
1
+ // corpus/unknown-ingest.mjs — context-preserving ingestion for unknown terms.
3
2
  //
4
- // The problem this closes: `toFacts()` (conceptnet.mjs) only emits a Fact for
5
- // a relation the ACE-OWL map marks axiom-worthy (`ace != "none"`) a row
6
- // whose relation is RelatedTo/Synonym/FormOf/SimilarTo/HasContext/etc is
7
- // SILENTLY skipped (`if (row.ace === "none") continue;`), on purpose, because
8
- // the relation itself doesn't fit a clean OWL axiom. That is the right call
9
- // for the AXIOM graph, but it means a term that ONLY ever shows up in one of
10
- // those dropped rows never as the endpoint of a relation tmct actually
11
- // reifies has NO anchor in memory at all. A wider seed set (a broader
12
- // ConceptNet slice, a tier-2 bundle, Phase 4's scraped web content) makes
13
- // this common: real terms, genuinely mentioned, quietly vanishing.
3
+ // `toFacts()` (conceptnet.mjs) skips a row whose relation isn't axiom-worthy
4
+ // (`ace === "none"`, e.g. RelatedTo/Synonym/HasContext), which is right for the axiom
5
+ // graph but leaves a term that only ever appears in such a row with no anchor in memory
6
+ // at all. This module captures those terms as a separate, honestly-labelled kind of
7
+ // individual: a term is "unknown" if it's never been the subject/object of any reified
8
+ // Fact (in memory already, or in this same seeding batch). Each captured term gets a
9
+ // `mgx:contextPassage` fact (the passage it was found in ConceptNet's own surfaceText,
10
+ // or the map's `surface` template) and `mgx:coOccursWith` links to its row's other
11
+ // endpoint plus any already-known terms recognizable in the same passage (bounded)
12
+ // deliberately closed-vocabulary co-occurrence, not distributional/embedding inference.
14
13
  //
15
- // This module does NOT change what counts as an axiom. It adds a SEPARATE,
16
- // honestly-labelled kind of individual for exactly the terms that would
17
- // otherwise vanish: a term is "unknown" here if it has never been the
18
- // subject/object of any reified Fact — not in memory already, and not in the
19
- // mapped facts this same seeding batch is about to write. For each dropped
20
- // (ace="none") row that touches an unknown term, the term becomes a Fact
21
- // tagged with the PASSAGE it was found in (ConceptNet's own `surfaceText`
22
- // when present, else the map's own `surface` template filled with the row's
23
- // two endpoints — both are committed, closed-vocabulary text; nothing here
24
- // ever generates free text), via a dedicated `mgx:contextPassage` predicate.
25
- // The row's OTHER endpoint (always) plus any already-known term recognizable
26
- // by exact word/bigram match in the passage (bounded to a handful) are linked
27
- // to the unknown term via one plain, closed-vocabulary co-occurrence
28
- // predicate, `mgx:coOccursWith` — deliberately NOT distributional/embedding
29
- // meaning induction (PLAN_AGENTS.md's own scoping): this buys traceable
30
- // context, never automatic sense disambiguation.
31
- //
32
- // Reuses src/memory/core.mjs's existing appendFacts/loadMemory/normFactTerm
33
- // machinery unmodified — a captured term is a completely ordinary Fact
34
- // individual (same trust/provenance/Source pipeline every other fact gets),
35
- // just carrying two new-but-closed predicates instead of an ACE-OWL one.
36
- //
37
- // Wiring: `seedMemory` (conceptnet.mjs) accepts an opt-in
38
- // `captureUnknownContext: true` (default false — every existing seed call
39
- // stays byte-identical) that calls `ingestUnknownFromAssertions` after the
40
- // mapped facts are computed, dynamically imported (the same "avoid a static
41
- // import cycle" discipline extensions.mjs's seedActiveCorpusEntries already
42
- // uses for conceptnet.mjs itself).
43
- //
44
- // NOT covered here (out of scope for this module, see the caller's report):
45
- // the LIVE chat teach/miss path (src/chat.mjs) has its own, separate
46
- // unknown-word moment — a visitor's utterance mentioning a term the grammar
47
- // can't classify — which has no "assertion batch" or ConceptNet-shaped
48
- // surfaceText to draw a passage from at all. That needs its own hook (the
49
- // raw utterance text IS the passage there); this module only ever consumes
50
- // {start, rel, end, surfaceText?} shaped rows, so it cannot be reused as-is
51
- // for that path without a chat.mjs-side adapter. See the report for the
52
- // exact shape that hook would need — deliberately not built here.
14
+ // Wired as an opt-in `captureUnknownContext: true` on seedMemory (conceptnet.mjs),
15
+ // dynamically imported to avoid a load-time cycle with conceptnet.mjs.
53
16
 
54
17
  import { appendFacts, loadMemory, normFactTerm, FACT_CLASS } from "../memory/core.mjs";
55
18
  import { termText } from "./conceptnet.mjs";
56
19
 
57
- /** unknown term -> the passage it was captured from (object = the passage
58
- * text itself, capped by normFactTerm's own TEXT_CAP like any fact term). */
20
+ /** unknown term -> the passage it was captured from. */
59
21
  export const CONTEXT_PASSAGE_PREDICATE = "mgx:contextPassage";
60
- /** plain, undirected-in-spirit co-occurrence edge: term -> another term seen
61
- * in the SAME passage. Deliberately the ONE relation this module ever
62
- * emits for "these two showed up together" — no relation-type inference. */
22
+ /** term -> another term seen in the SAME passage no relation-type inference. */
63
23
  export const CO_OCCURS_PREDICATE = "mgx:coOccursWith";
64
24
 
65
- // Function words filtered out of passage word/bigram scanning never
66
- // candidates for a co-occurrence link (a "the"<->term edge would be noise,
67
- // not context). Closed, small, hand-curated — not a general stopword list.
25
+ // Function words filtered out of passage word/bigram scanning (a "the"<->term edge
26
+ // would be noise, not context). Closed, small, hand-curated.
68
27
  const STOPWORDS = new Set([
69
28
  "a", "an", "the", "is", "are", "was", "were", "be", "being", "been",
70
29
  "to", "of", "for", "in", "on", "at", "with", "by", "as", "and", "or",
@@ -73,11 +32,8 @@ const STOPWORDS = new Set([
73
32
 
74
33
  const MAX_EXTRA_LINKS_PER_PASSAGE = 3; // bounded — not an open-ended sweep
75
34
 
76
- /** The set of terms tmct already "recognizes": every subject/object across
77
- * every reified Fact currently in memory, union every term the mapped facts
78
- * THIS batch is about to write introduce. A term with a real Fact anywhere
79
- * already has structured knowledge — only a term that never gets one is a
80
- * candidate for context-only capture. Pure; does not mutate `memory`. */
35
+ /** The set of terms tmct already "recognizes": every subject/object across every
36
+ * reified Fact in memory, union every term this batch's mapped facts introduce. Pure. */
81
37
  export function knownTermsFrom(memory, mappedFacts) {
82
38
  const known = new Set();
83
39
  for (const ind of memory?.individuals || []) {
@@ -95,11 +51,9 @@ export function knownTermsFrom(memory, mappedFacts) {
95
51
  return known;
96
52
  }
97
53
 
98
- /** The human-readable context passage for one DROPPED assertion — ConceptNet's
99
- * own `surfaceText` (bracket-stripped) when present, else the mapping row's
100
- * own `surface` template filled with the two endpoint terms. Both sources
101
- * are committed, closed-vocabulary text — this never generates free text.
102
- * Returns null when neither source is available (never fatal). */
54
+ /** The human-readable context passage for one dropped assertion — ConceptNet's own
55
+ * `surfaceText` (bracket-stripped) when present, else the map's `surface` template
56
+ * filled with the two endpoint terms. Returns null when neither is available. */
103
57
  export function passageFor(assertion, row) {
104
58
  const raw = assertion?.surfaceText;
105
59
  if (typeof raw === "string" && raw.trim()) {
@@ -113,11 +67,8 @@ export function passageFor(assertion, row) {
113
67
  return null;
114
68
  }
115
69
 
116
- /** Single words + adjacent bigrams in `passage` that are already-known terms
117
- * (present in `known`), excluding anything in `exclude` (the row's own two
118
- * endpoints — already linked directly). Bounded to
119
- * MAX_EXTRA_LINKS_PER_PASSAGE hits; independent of `known`'s size (a Set
120
- * lookup per token/bigram, never a substring sweep over every known term). */
70
+ /** Single words + adjacent bigrams in `passage` that are already-known terms, excluding
71
+ * `exclude` (the row's own two endpoints). Bounded to MAX_EXTRA_LINKS_PER_PASSAGE hits. */
121
72
  function knownMentionsIn(passage, known, exclude) {
122
73
  const tokens = String(passage || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
123
74
  const hits = [];
@@ -138,27 +89,15 @@ function knownMentionsIn(passage, known, exclude) {
138
89
  }
139
90
 
140
91
  /**
141
- * Capture unknown terms out of the assertions a corpus-seeding batch is
142
- * about to (or just did) write. Scans only rows whose relation maps to
143
- * `ace = "none"` (the genuinely-dropped rows) — a mapped (ace != "none") row
144
- * always gets a real reified Fact via toFacts()/appendFacts() already, so it
145
- * is never a "silently dropped" case this module needs to rescue.
92
+ * Capture unknown terms out of the assertions a corpus-seeding batch is about to (or
93
+ * just did) write. Scans only rows whose relation maps to `ace = "none"`.
146
94
  *
147
- * { assertions, map, mappedFacts, memory?, provenancePrefix?, limit? }:
148
- * - assertions/map: the SAME loadSlice()/loadMap() results seedMemory has.
149
- * - mappedFacts: the toFacts() output for this same batch (feeds
150
- * knownTermsFrom so a term this batch itself just defined isn't
151
- * re-captured as "unknown").
152
- * - memory: a pre-loaded payload (seedMemory already has one) — loaded
153
- * fresh via loadMemory(dir) when omitted.
154
- * - provenancePrefix: tags the captured facts, default "corpus:unknown".
155
- * - limit: caps how many DISTINCT unknown terms get captured in one call
156
- * (default 500) — a wide slice's RelatedTo rows alone number in the tens
157
- * of thousands; this keeps one run bounded, not a flood.
95
+ * { assertions, map, mappedFacts, memory?, provenancePrefix?, limit? }: assertions/map are
96
+ * the same loadSlice()/loadMap() results seedMemory has; mappedFacts is this batch's
97
+ * toFacts() output; memory defaults to loadMemory(dir); limit (default 500) caps distinct
98
+ * unknown terms captured per call.
158
99
  *
159
- * Returns { captured, linked, appended, skipped } — `captured` = distinct
160
- * unknown terms newly given a contextPassage fact, `linked` = co-occurrence
161
- * edges written (the direct pair plus any bounded extra known-term hits).
100
+ * Returns { captured, linked, appended, skipped }.
162
101
  */
163
102
  export async function ingestUnknownFromAssertions(dir, {
164
103
  assertions, map, mappedFacts = [], memory, provenancePrefix = "corpus:unknown", limit = 500,
package/src/embed.mjs CHANGED
@@ -1,27 +1,15 @@
1
- // embed.mjs — deterministic static-embedding lookup (model2vec potion-base-8M).
2
- // PLAN_SEON_TUNING.md §7.6(5b), 2026-07-02 library-leverage review.
1
+ // embed.mjs — deterministic static-embedding lookup (model2vec potion-base-8M,
2
+ // 29,528 WordPiece subwords × 256 fp32 dims). Pure table lookup + float
3
+ // arithmetic, no ONNX runtime, no network after the one-time fetch — the same
4
+ // text always embeds to the same vector.
3
5
  //
4
- // The honest "near-LLM" locate lever: a STATIC per-token embedding table (model2vec's
5
- // potion-base-8M, MIT 29,528 WordPiece subwords × 256 fp32 dims, ~30 MB) read straight
6
- // from its safetensors export, mean-pooled and L2-normalised. No ONNX runtime, no model
7
- // calls, no network after the one-time fetch — pure table lookup + float arithmetic, so
8
- // the same text always embeds to the same vector ($0, deterministic, offline).
6
+ // The safetensors reader and WordPiece tokenizer below are hand-rolled: both
7
+ // formats are simple enough to parse directly, avoiding an ONNX/HF tokenizer
8
+ // dependency for it.
9
9
  //
10
- // Dependency choice (documented per the review): the safetensors format is an 8-byte LE
11
- // header length + JSON header + raw little-endian tensor bytes, and the tokenizer is a
12
- // plain Bert-style WordPiece (tokenizer.json: BertNormalizer lowercase + BertPreTokenizer
13
- // + greedy longest-match with "##" continuations) — both are small enough to hand-roll
14
- // with node built-ins, so neither the @yarflam/potion-base-8m fallback package nor an HF
15
- // tokenizer dependency is taken. Numerical intent follows model2vec's own encode (subword
16
- // ids WITHOUT the [CLS]/[SEP] template, mean pool, normalize per config.json) — exact
17
- // float parity with the Python lib is not claimed; determinism and rank usefulness are.
18
- //
19
- // The weights are NEVER committed and NEVER in the npm package: they live in the
20
- // gitignored vendor/ tree (fetched by scripts/fetch-embeddings.mjs, `npm run
21
- // refs:embeddings`, SHA-256-pinned like the repo's other binary artefacts). Everything
22
- // here degrades gracefully — loadEmbedder() returns null when the weights dir is absent,
23
- // and callers (codegraph.mjs's opt-in embedRank, scripts/rank-gate.mjs) no-op with a
24
- // clear note instead of failing, so CI/tests never require the 30 MB download.
10
+ // Weights are gitignored (vendor/, fetched by scripts/fetch-embeddings.mjs)
11
+ // and never in the npm package; loadEmbedder() returns null when absent so
12
+ // CI/tests never require the download.
25
13
 
26
14
  import { readFileSync, existsSync } from "node:fs";
27
15
  import { join, dirname } from "node:path";