@polycode-projects/the-mechanical-code-talker 1.4.0 → 1.5.2
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 +1 -1
- package/ROADMAP.md +98 -11
- package/corpus/README.md +23 -22
- package/corpus/seon/README.md +7 -6
- package/corpus/seon/concepts.jsonl +119 -0
- package/corpus/tier2/general.jsonl +49 -0
- package/corpus/tier2/generate.mjs +68 -0
- package/corpus/tier2/manifest.json +14 -0
- package/data/templates/constructions/agent-noun-relations.toml +98 -0
- package/data/templates/responses.jsonl +1 -0
- package/package.json +5 -1
- package/src/ask-vocab.mjs +39 -1
- package/src/ask.mjs +278 -32
- package/src/chat.mjs +681 -212
- package/src/completions/complete.mjs +138 -0
- package/src/completions/group.mjs +171 -0
- package/src/completions/infer.mjs +395 -0
- package/src/completions/prune.mjs +156 -0
- package/src/completions/rank.mjs +154 -0
- package/src/completions/search.mjs +85 -0
- package/src/corpus/conceptnet.mjs +36 -3
- package/src/corpus/unknown-ingest.mjs +209 -0
- package/src/extensions.mjs +14 -4
- package/src/finish.mjs +61 -18
- package/src/grammar/ace.mjs +24 -338
- package/src/grammar/lexicon.mjs +37 -194
- package/src/interpret/pipeline.mjs +23 -2
- package/src/interpret/strategies/constructions.mjs +207 -0
- package/src/interpret/strategies/grammar.mjs +24 -3
- package/src/interpret/strategies/keywords.mjs +34 -0
- package/src/memory/blocks.mjs +7 -2
- package/src/memory/core.mjs +283 -20
- package/src/memory/shacl.mjs +114 -0
- package/src/prose.mjs +5 -1
- package/src/syllogise.mjs +0 -0
- package/src/grammar/lexicon-core.json +0 -302
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// completions/rank.mjs — Stage 4 ("mechanical summarization") of PLAN_COMPLETIONS.md's
|
|
2
|
+
// six-stage mechanical-text-generation pipeline. Stage-2-of-staging scope per the plan's own
|
|
3
|
+
// §4 table: extractive SENTENCE ranking within a group.mjs group — no cross-group inference
|
|
4
|
+
// (Stage 3, §1.3 — separately scoped), pruning (Stage 5), or voice pass (Stage 6) happens
|
|
5
|
+
// here.
|
|
6
|
+
//
|
|
7
|
+
// "Extractive sentence selection over the grouped-and-inferred material, not abstractive
|
|
8
|
+
// rewriting... query-focused multi-document summarization (feature-fusion sentence
|
|
9
|
+
// selection, graph-ranking approaches in the LexRank/TextRank family, clustering-cum-ranking
|
|
10
|
+
// as in CoRank)" — PLAN_COMPLETIONS.md §1.4/§4. The graph-ranking machinery this stage needs
|
|
11
|
+
// already exists and ships: memory/blocks.mjs's rankBlocks() (PageRank, d=0.85, 20
|
|
12
|
+
// iterations, over the shared-token block-similarity graph) and degreeOf() (hub dampening),
|
|
13
|
+
// both generic over any `{ id: tokens[] }` map — NOT block-specific despite the module name.
|
|
14
|
+
// This file reuses rankBlocks()/degreeOf() VERBATIM at sentence granularity (no PageRank
|
|
15
|
+
// reimplementation), combined with the same idf = log(1 + N/(1+df)) formula group.mjs already
|
|
16
|
+
// replicates for group-scoped (not whole-corpus-scoped) weighting, and the same
|
|
17
|
+
// idfSum * (1+rank) / sqrt(1+degree) combination retrieveBlocks() uses to fuse relevance,
|
|
18
|
+
// centrality, and hub-dampening into one score.
|
|
19
|
+
//
|
|
20
|
+
// New in this file: splitSentences() (no sentence-splitter existed anywhere in this repo —
|
|
21
|
+
// grepped for "sentence" across src/ and found none; a simple regex splitter is intentional
|
|
22
|
+
// here per the dispatch's own instruction not to pull in an NLP dependency for this) and the
|
|
23
|
+
// sentence-level id/token/scoring wiring that adapts rankBlocks/degreeOf from block to
|
|
24
|
+
// sentence granularity.
|
|
25
|
+
//
|
|
26
|
+
// Determinism: no randomness anywhere. Same group in (same members, same text, same order)
|
|
27
|
+
// -> same ranked sentence list out (stable score-descending sort with a deterministic
|
|
28
|
+
// sourceBlockId/sentence-text tiebreak) — see test/completions-stage2.test.mjs's double-run
|
|
29
|
+
// diff, the exact discipline test/completions-stage0.test.mjs established for search+group.
|
|
30
|
+
|
|
31
|
+
import { degreeOf, rankBlocks, tokenizeBlock, OVERLAP_MIN } from "../memory/blocks.mjs";
|
|
32
|
+
import { STOPWORDS } from "../prose.mjs";
|
|
33
|
+
|
|
34
|
+
// Same content-token filter group.mjs applies to its own adjacency/labeling (not exported
|
|
35
|
+
// from group.mjs, so replicated here rather than reached across files — group.mjs's own
|
|
36
|
+
// header explains why raw tokenizeBlock output is unsuitable for unweighted overlap: it
|
|
37
|
+
// re-admits stopwords/filler that would collapse unrelated sentences into false edges).
|
|
38
|
+
const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
|
|
39
|
+
|
|
40
|
+
/** tokenizeBlock(text), narrowed to real content tokens — see isContentToken above. */
|
|
41
|
+
function contentTokens(text) {
|
|
42
|
+
return tokenizeBlock(text).filter(isContentToken);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Sentence boundary: a run of [.!?] followed by whitespace and an uppercase letter or digit —
|
|
46
|
+
// deliberately simple (no abbreviation dictionary, no NLP dependency, per the dispatch's own
|
|
47
|
+
// instruction). Applied per-line (a block's own "Q: ...\nA: ..." shape already gives clean
|
|
48
|
+
// boundaries at the newline; this regex further splits any line that itself holds more than
|
|
49
|
+
// one sentence).
|
|
50
|
+
const SENTENCE_SPLIT_RE = /(?<=[.!?])\s+(?=[A-Z0-9])/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Split raw block text into trimmed, non-empty sentences (order-preserving, no dedup —
|
|
54
|
+
* dedup, if ever needed, is the caller's call). Splits on blank lines first (a block's own
|
|
55
|
+
* natural line structure), then on sentence-ending punctuation within each line.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {string[]}
|
|
59
|
+
*/
|
|
60
|
+
export function splitSentences(text) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const rawLine of String(text || "").split("\n")) {
|
|
63
|
+
const line = rawLine.trim();
|
|
64
|
+
if (!line) continue;
|
|
65
|
+
for (const part of line.split(SENTENCE_SPLIT_RE)) {
|
|
66
|
+
const s = part.trim();
|
|
67
|
+
if (s) out.push(s);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stage 4 — extractive sentence ranking within one group.mjs group. Splits every member's
|
|
75
|
+
* text into sentences, builds a sentence-level shared-token-overlap similarity graph (the
|
|
76
|
+
* exact adjacency buildNeighbours/rankBlocks/degreeOf already establish, just re-keyed to
|
|
77
|
+
* sentence ids instead of block ids), runs the same PageRank (rankBlocks) and hub-dampening
|
|
78
|
+
* (degreeOf) over it, and combines with group-scoped IDF the same way retrieveBlocks() fuses
|
|
79
|
+
* relevance × centrality × hub-dampening — self-weighted (LexRank-style: a sentence's own
|
|
80
|
+
* rare/informative tokens) unless `opts.query` is supplied, in which case only tokens
|
|
81
|
+
* overlapping the query are IDF-summed (query-focused summarization, PLAN_COMPLETIONS.md's
|
|
82
|
+
* own literature framing) — a sentence with zero query overlap honestly scores 0 rather than
|
|
83
|
+
* silently falling back to self-weighting, the same "never a guessed match" discipline
|
|
84
|
+
* retrieveBlocks() applies when idfSum <= 0.
|
|
85
|
+
*
|
|
86
|
+
* @param {{members: Array<{id:string, text:string}>}} group a group.mjs groupHits() entry
|
|
87
|
+
* (or any object shaped `{ members: [{id, text}, ...] }`)
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @param {number} [opts.overlapMin=OVERLAP_MIN] shared content-token threshold for a
|
|
90
|
+
* sentence-similarity edge — defaults to the same value group.mjs/blocks.mjs use, so
|
|
91
|
+
* grouping, block-ranking, and sentence-ranking all agree on what "related" means unless
|
|
92
|
+
* the caller deliberately overrides it.
|
|
93
|
+
* @param {string|null} [opts.query=null] optional query text to focus ranking on (see above)
|
|
94
|
+
* @returns {Array<{sentence:string, score:number, sourceBlockId:string}>} best-first;
|
|
95
|
+
* deterministic tiebreak (sourceBlockId, then sentence text) on equal score.
|
|
96
|
+
*/
|
|
97
|
+
export function rankSentences(group, { overlapMin = OVERLAP_MIN, query = null } = {}) {
|
|
98
|
+
const members = Array.isArray(group?.members) ? group.members : [];
|
|
99
|
+
if (!members.length) return [];
|
|
100
|
+
|
|
101
|
+
// One entry per sentence: a stable id ("<blockId>#<index>") so PageRank/degree/IDF can be
|
|
102
|
+
// keyed exactly like rankBlocks/degreeOf already key blocks — deterministic since it's
|
|
103
|
+
// derived purely from member order (group.mjs's members are already id-sorted) and each
|
|
104
|
+
// block's own sentence order.
|
|
105
|
+
const sentences = [];
|
|
106
|
+
for (const m of members) {
|
|
107
|
+
const parts = splitSentences(m?.text || "");
|
|
108
|
+
parts.forEach((sentence, i) => {
|
|
109
|
+
sentences.push({ id: `${m.id}#${i}`, sentence, sourceBlockId: m.id });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (!sentences.length) return [];
|
|
113
|
+
|
|
114
|
+
const tokensById = {};
|
|
115
|
+
for (const s of sentences) tokensById[s.id] = contentTokens(s.sentence);
|
|
116
|
+
|
|
117
|
+
// Reused verbatim — the exact PageRank/hub-dampening machinery rankBlocks()/degreeOf()
|
|
118
|
+
// already run for block-level ranking, generic over any id->tokens map.
|
|
119
|
+
const ranks = rankBlocks(tokensById, { overlapMin });
|
|
120
|
+
const degrees = degreeOf(tokensById, { overlapMin });
|
|
121
|
+
|
|
122
|
+
// IDF scoped to THIS group's sentence set (df/N here, not the whole corpus) — the same
|
|
123
|
+
// scoping discipline group.mjs applies to its own label tokens.
|
|
124
|
+
const ids = Object.keys(tokensById);
|
|
125
|
+
const N = ids.length;
|
|
126
|
+
const df = new Map();
|
|
127
|
+
for (const id of ids) {
|
|
128
|
+
for (const t of new Set(tokensById[id])) df.set(t, (df.get(t) || 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
const idf = (t) => Math.log(1 + N / (1 + (df.get(t) || 0)));
|
|
131
|
+
|
|
132
|
+
const queryTokens = query ? new Set(contentTokens(query)) : null;
|
|
133
|
+
|
|
134
|
+
const scored = sentences.map((s) => {
|
|
135
|
+
const tokens = tokensById[s.id];
|
|
136
|
+
const idfTokens = queryTokens ? tokens.filter((t) => queryTokens.has(t)) : tokens;
|
|
137
|
+
let idfSum = 0;
|
|
138
|
+
for (const t of idfTokens) idfSum += idf(t);
|
|
139
|
+
const rank = ranks[s.id] ?? 0;
|
|
140
|
+
const degree = degrees[s.id] ?? 0;
|
|
141
|
+
// idfSum * (1 + rank) / sqrt(1 + degree) — retrieveBlocks()'s own combination formula
|
|
142
|
+
// (memory/blocks.mjs), reused at sentence granularity: IDF-weighted informativeness
|
|
143
|
+
// decides content, PageRank centrality breaks ties toward well-connected (consensus)
|
|
144
|
+
// sentences, and the degree divisor dampens a sentence that merely shares vocabulary
|
|
145
|
+
// with disproportionately many others from winning on inflated rank alone.
|
|
146
|
+
const score = (idfSum * (1 + rank)) / Math.sqrt(1 + degree);
|
|
147
|
+
return { sentence: s.sentence, score, sourceBlockId: s.sourceBlockId };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
scored.sort((a, b) => b.score - a.score
|
|
151
|
+
|| a.sourceBlockId.localeCompare(b.sourceBlockId)
|
|
152
|
+
|| a.sentence.localeCompare(b.sentence));
|
|
153
|
+
return scored;
|
|
154
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// completions/search.mjs — Stage 1 ("broad search") of PLAN_COMPLETIONS.md's six-stage
|
|
2
|
+
// mechanical-text-generation pipeline. This is Stage-0 scope ONLY per the plan's own §4
|
|
3
|
+
// staging table (Stage 1 + Stage 2, no inference/summarization/pruning/voice-pass yet — see
|
|
4
|
+
// group.mjs for Stage 2).
|
|
5
|
+
//
|
|
6
|
+
// Near-total reuse, as the plan's own prior research established: this is a thin composition
|
|
7
|
+
// of retrieveBlocks (src/memory/blocks.mjs's best-first block ranker) and the graph
|
|
8
|
+
// search()/ask() services (src/providers/graph-service.mjs, contracted by
|
|
9
|
+
// src/repository-interface.mjs's SERVICE_GROUPS.search) — a WIDER query shape ("broad
|
|
10
|
+
// search": more hits per source, both sources asked) rather than any new retrieval
|
|
11
|
+
// machinery. Nothing here re-implements ranking, tokenizing, or graph traversal.
|
|
12
|
+
//
|
|
13
|
+
// Granularity note (explicit, per the strategy-advisor's Stage-0 correction): retrieveBlocks
|
|
14
|
+
// only ever returns whole BLOCK text — one node per ~800-token document/transcript chunk, the
|
|
15
|
+
// unit memory/blocks.mjs's index actually stores. PLAN_COMPLETIONS.md's own prose loosely
|
|
16
|
+
// says "spans"; this module does NOT invent sub-block span segmentation. Every hit this
|
|
17
|
+
// module returns is whole-block (or a whole graph-search/ask result) granularity. Finer-grain
|
|
18
|
+
// spans, if the pipeline ever needs them, are a real future increment, out of scope here.
|
|
19
|
+
//
|
|
20
|
+
// Determinism: retrieveBlocks is already deterministic (stable sort with an id tiebreak);
|
|
21
|
+
// the graph service's search()/ask() are pure functions over a fixed graph. Given the same
|
|
22
|
+
// dir/query/graphService, broadSearch() always returns the same array in the same order.
|
|
23
|
+
|
|
24
|
+
import { retrieveBlocks } from "../memory/blocks.mjs";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_BLOCK_K = 8; // "broad" > chat's narrow single-answer k (typically 3)
|
|
27
|
+
const DEFAULT_GRAPH_LIMIT = 8;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stage 1 — broad search. Runs retrieveBlocks (memory/blocks.mjs) for text-block hits and,
|
|
31
|
+
* when a Repository-Interface graph service is supplied, its search() and ask() services for
|
|
32
|
+
* graph hits — merged into one flat, source-tagged hit list.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} dir repo root (retrieveBlocks reads <dir>/.tmct/memory/blocks/)
|
|
35
|
+
* @param {string} query the broad prompt driving retrieval
|
|
36
|
+
* @param {object} [opts]
|
|
37
|
+
* @param {number} [opts.blockK=8] retrieveBlocks' k (how many blocks to pull)
|
|
38
|
+
* @param {object|null} [opts.graphService=null] an optional Repository-Interface service
|
|
39
|
+
* (e.g. createGraphService(graph) from src/providers/graph-service.mjs, or any provider
|
|
40
|
+
* satisfying SERVICE_GROUPS.search's `search`/`ask`). When supplied, its search() and
|
|
41
|
+
* ask() services are queried too. Omitted -> block-only search (still a valid, honest
|
|
42
|
+
* broad search; graph access is opt-in, not required — this module never constructs a
|
|
43
|
+
* graph service itself, it only calls one it's handed).
|
|
44
|
+
* @param {number} [opts.graphLimit=8] graph search()'s result limit
|
|
45
|
+
* @returns {Promise<Array<{source:"block"|"graph-search"|"graph-ask", id:string, text:string, score:number}>>}
|
|
46
|
+
* best-first within each source; blocks first, then graph-search, then graph-ask (stable,
|
|
47
|
+
* deterministic order — never shuffled/merged by score across sources, since block scores
|
|
48
|
+
* and graph relevance are not on a comparable scale; Stage 2's grouping doesn't need them
|
|
49
|
+
* to be).
|
|
50
|
+
*/
|
|
51
|
+
export async function broadSearch(dir, query, {
|
|
52
|
+
blockK = DEFAULT_BLOCK_K, graphService = null, graphLimit = DEFAULT_GRAPH_LIMIT,
|
|
53
|
+
} = {}) {
|
|
54
|
+
const q = String(query || "").trim();
|
|
55
|
+
if (!q) return [];
|
|
56
|
+
|
|
57
|
+
const hits = [];
|
|
58
|
+
|
|
59
|
+
const blockHits = await retrieveBlocks(dir, q, blockK);
|
|
60
|
+
for (const b of blockHits) {
|
|
61
|
+
hits.push({ source: "block", id: b.id, text: b.text || "", score: typeof b.score === "number" ? b.score : 0 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (graphService) {
|
|
65
|
+
if (typeof graphService.search === "function") {
|
|
66
|
+
const res = graphService.search(q, { limit: graphLimit });
|
|
67
|
+
if (res?.ok) {
|
|
68
|
+
for (const r of res.value.results || []) {
|
|
69
|
+
if (!r) continue;
|
|
70
|
+
hits.push({ source: "graph-search", id: r.id, text: r.label || r.id, score: 0 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (typeof graphService.ask === "function") {
|
|
75
|
+
const res = graphService.ask(q);
|
|
76
|
+
if (res?.ok && res.value && res.value.content) {
|
|
77
|
+
// one honest hit per prompt — the ask() answer itself, id-tagged with the query so
|
|
78
|
+
// it never collides with a block/search id and stays traceable to what produced it.
|
|
79
|
+
hits.push({ source: "graph-ask", id: `ask:${q}`, text: String(res.value.content), score: 0 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return hits;
|
|
85
|
+
}
|
|
@@ -147,8 +147,27 @@ export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet")
|
|
|
147
147
|
* survivors are written in ONE batched appendFacts call, not a per-fact loop.
|
|
148
148
|
* Returns { appended, skipped, total }. `provenancePrefix` is threaded through to
|
|
149
149
|
* toFacts (default "corpus:conceptnet" → byte-identical seed) so a seon/tier-2
|
|
150
|
-
* corpus can tag its facts "corpus:seon" / "corpus:tier2:<id>".
|
|
151
|
-
|
|
150
|
+
* corpus can tag its facts "corpus:seon" / "corpus:tier2:<id>".
|
|
151
|
+
*
|
|
152
|
+
* `captureUnknownContext` (default false — every existing call stays
|
|
153
|
+
* byte-identical): when true, also runs corpus/unknown-ingest.mjs's
|
|
154
|
+
* `ingestUnknownFromAssertions` over this same assertions/map pair — the
|
|
155
|
+
* PLAN_AGENTS.md §4 "context-preserving ingestion for unknown words"
|
|
156
|
+
* mechanism — so a term that ONLY ever appears in a row `toFacts` silently
|
|
157
|
+
* drops (an `ace = "none"` relation like RelatedTo/HasContext) still lands
|
|
158
|
+
* in memory, tagged with the passage it was found in, instead of vanishing.
|
|
159
|
+
* `unknownContextLimit` bounds how many distinct unknown terms one call
|
|
160
|
+
* captures (default 500 — see that module's own doc comment for why an
|
|
161
|
+
* unbounded sweep over a wide slice would not be "bounded, not padding").
|
|
162
|
+
* The result's `unknown` key is present only when the flag is set. Loaded
|
|
163
|
+
* dynamically (not a static import) to avoid a load-time import cycle with
|
|
164
|
+
* unknown-ingest.mjs, which itself statically imports `termText` from here —
|
|
165
|
+
* the same "avoid the cycle" discipline extensions.mjs's
|
|
166
|
+
* seedActiveCorpusEntries already uses for this very module. */
|
|
167
|
+
export async function seedMemory(dir, {
|
|
168
|
+
limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix,
|
|
169
|
+
captureUnknownContext = false, unknownContextLimit,
|
|
170
|
+
} = {}) {
|
|
152
171
|
const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
|
|
153
172
|
let facts = toFacts(assertions, map, provenancePrefix);
|
|
154
173
|
if (Array.isArray(prefer) && prefer.length) {
|
|
@@ -186,5 +205,19 @@ export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath =
|
|
|
186
205
|
// for the 6 k-fact slice). appendFacts also skips any malformed row rather than
|
|
187
206
|
// throwing, so its skipped count folds into the dedup skips here.
|
|
188
207
|
const res = await appendFacts(dir, toWrite);
|
|
189
|
-
|
|
208
|
+
|
|
209
|
+
let unknown;
|
|
210
|
+
if (captureUnknownContext) {
|
|
211
|
+
const { ingestUnknownFromAssertions } = await import("./unknown-ingest.mjs");
|
|
212
|
+
unknown = await ingestUnknownFromAssertions(dir, {
|
|
213
|
+
assertions, map, mappedFacts: facts, memory,
|
|
214
|
+
provenancePrefix: provenancePrefix ? `${provenancePrefix}-unknown` : undefined,
|
|
215
|
+
limit: unknownContextLimit,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
appended: res.appended, skipped: skipped + res.skipped, total: facts.length,
|
|
221
|
+
...(unknown ? { unknown } : {}),
|
|
222
|
+
};
|
|
190
223
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
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).
|
|
3
|
+
//
|
|
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.
|
|
14
|
+
//
|
|
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.
|
|
53
|
+
|
|
54
|
+
import { appendFacts, loadMemory, normFactTerm, FACT_CLASS } from "../memory/core.mjs";
|
|
55
|
+
import { termText } from "./conceptnet.mjs";
|
|
56
|
+
|
|
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). */
|
|
59
|
+
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. */
|
|
63
|
+
export const CO_OCCURS_PREDICATE = "mgx:coOccursWith";
|
|
64
|
+
|
|
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.
|
|
68
|
+
const STOPWORDS = new Set([
|
|
69
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "being", "been",
|
|
70
|
+
"to", "of", "for", "in", "on", "at", "with", "by", "as", "and", "or",
|
|
71
|
+
"that", "this", "it", "its", "you", "your", "related", "kind", "used",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const MAX_EXTRA_LINKS_PER_PASSAGE = 3; // bounded — not an open-ended sweep
|
|
75
|
+
|
|
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`. */
|
|
81
|
+
export function knownTermsFrom(memory, mappedFacts) {
|
|
82
|
+
const known = new Set();
|
|
83
|
+
for (const ind of memory?.individuals || []) {
|
|
84
|
+
if (ind?.class !== FACT_CLASS) continue;
|
|
85
|
+
const get = (k) => (ind.attributes || []).find((a) => a?.key === k)?.value;
|
|
86
|
+
const s = get("subject");
|
|
87
|
+
const o = get("object");
|
|
88
|
+
if (s) known.add(normFactTerm(s));
|
|
89
|
+
if (o) known.add(normFactTerm(o));
|
|
90
|
+
}
|
|
91
|
+
for (const f of mappedFacts || []) {
|
|
92
|
+
if (f?.subject) known.add(normFactTerm(f.subject));
|
|
93
|
+
if (f?.object) known.add(normFactTerm(f.object));
|
|
94
|
+
}
|
|
95
|
+
return known;
|
|
96
|
+
}
|
|
97
|
+
|
|
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). */
|
|
103
|
+
export function passageFor(assertion, row) {
|
|
104
|
+
const raw = assertion?.surfaceText;
|
|
105
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
106
|
+
return raw.replace(/\[\[([^\]]+)\]\]/g, "$1").replace(/\s+/g, " ").trim();
|
|
107
|
+
}
|
|
108
|
+
const s = termText(assertion?.start);
|
|
109
|
+
const o = termText(assertion?.end);
|
|
110
|
+
if (row?.surface && s && o) {
|
|
111
|
+
return row.surface.replace("{start}", s).replace("{end}", o);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
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). */
|
|
121
|
+
function knownMentionsIn(passage, known, exclude) {
|
|
122
|
+
const tokens = String(passage || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
123
|
+
const hits = [];
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (let i = 0; i < tokens.length && hits.length < MAX_EXTRA_LINKS_PER_PASSAGE; i += 1) {
|
|
126
|
+
const candidates = [tokens[i]];
|
|
127
|
+
if (i + 1 < tokens.length) candidates.push(`${tokens[i]} ${tokens[i + 1]}`);
|
|
128
|
+
for (const c of candidates) {
|
|
129
|
+
if (STOPWORDS.has(c) || seen.has(c) || exclude.has(c)) continue;
|
|
130
|
+
if (known.has(c)) {
|
|
131
|
+
hits.push(c);
|
|
132
|
+
seen.add(c);
|
|
133
|
+
if (hits.length >= MAX_EXTRA_LINKS_PER_PASSAGE) break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return hits;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
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.
|
|
146
|
+
*
|
|
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.
|
|
158
|
+
*
|
|
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).
|
|
162
|
+
*/
|
|
163
|
+
export async function ingestUnknownFromAssertions(dir, {
|
|
164
|
+
assertions, map, mappedFacts = [], memory, provenancePrefix = "corpus:unknown", limit = 500,
|
|
165
|
+
} = {}) {
|
|
166
|
+
if (!Array.isArray(assertions) || !map) return { captured: 0, linked: 0, appended: 0, skipped: 0 };
|
|
167
|
+
const mem = memory || await loadMemory(dir);
|
|
168
|
+
const known = knownTermsFrom(mem, mappedFacts);
|
|
169
|
+
const capturedTerms = new Set();
|
|
170
|
+
const toWrite = [];
|
|
171
|
+
|
|
172
|
+
for (const a of assertions) {
|
|
173
|
+
if (capturedTerms.size >= limit) break;
|
|
174
|
+
const row = map.get(a.rel);
|
|
175
|
+
if (!row || row.ace !== "none") continue; // only the genuinely-dropped rows
|
|
176
|
+
const subject = termText(a.start);
|
|
177
|
+
const object = termText(a.end);
|
|
178
|
+
if (!subject || !object) continue; // non-en endpoint — nothing to anchor a passage to
|
|
179
|
+
const sKey = normFactTerm(subject);
|
|
180
|
+
const oKey = normFactTerm(object);
|
|
181
|
+
const subjectUnknown = !known.has(sKey);
|
|
182
|
+
const objectUnknown = !known.has(oKey);
|
|
183
|
+
if (!subjectUnknown && !objectUnknown) continue; // both sides already recognized
|
|
184
|
+
|
|
185
|
+
const passage = passageFor(a, row);
|
|
186
|
+
if (!passage) continue;
|
|
187
|
+
|
|
188
|
+
const pairs = [[subject, object, subjectUnknown], [object, subject, objectUnknown]];
|
|
189
|
+
for (const [term, other, isUnknown] of pairs) {
|
|
190
|
+
if (!isUnknown || capturedTerms.size >= limit) continue;
|
|
191
|
+
const termKey = normFactTerm(term);
|
|
192
|
+
if (capturedTerms.has(termKey)) continue; // one context capture per term per run
|
|
193
|
+
const tag = `${provenancePrefix} ${a.rel}`;
|
|
194
|
+
toWrite.push({ subject: term, predicate: CONTEXT_PASSAGE_PREDICATE, object: passage, provenance: tag });
|
|
195
|
+
toWrite.push({ subject: term, predicate: CO_OCCURS_PREDICATE, object: other, provenance: tag });
|
|
196
|
+
const extra = knownMentionsIn(passage, known, new Set([sKey, oKey]));
|
|
197
|
+
for (const m of extra) {
|
|
198
|
+
toWrite.push({ subject: term, predicate: CO_OCCURS_PREDICATE, object: m, provenance: tag });
|
|
199
|
+
}
|
|
200
|
+
capturedTerms.add(termKey);
|
|
201
|
+
known.add(termKey); // now recognized — a captured term is never re-captured
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!toWrite.length) return { captured: 0, linked: 0, appended: 0, skipped: 0 };
|
|
206
|
+
const res = await appendFacts(dir, toWrite);
|
|
207
|
+
const linked = toWrite.filter((f) => f.predicate === CO_OCCURS_PREDICATE).length;
|
|
208
|
+
return { captured: capturedTerms.size, linked, appended: res.appended, skipped: res.skipped };
|
|
209
|
+
}
|
package/src/extensions.mjs
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
// resolveExtensions(repoRoot) → { entries: Map<name, ResolvedEntry>, biasByBundle }
|
|
7
7
|
//
|
|
8
8
|
// BUILTIN_EXTENSIONS ships the exact two bundles chat.mjs's bootstrap has
|
|
9
|
-
// always seeded — `seon` and `conceptnet`, both active — plus
|
|
10
|
-
// but-INACTIVE tier-2 bundles (`tier2-aws` / `tier2-python` / `tier2-java`
|
|
11
|
-
// Activating one is a config-only edit (`tmct init --corpus
|
|
12
|
-
// `[extensions.tier2-aws] active = true` in tmct.toml) — zero code
|
|
9
|
+
// always seeded — `seon` and `conceptnet`, both active — plus four shipped-
|
|
10
|
+
// but-INACTIVE tier-2 bundles (`tier2-aws` / `tier2-python` / `tier2-java` /
|
|
11
|
+
// `tier2-general`). Activating one is a config-only edit (`tmct init --corpus
|
|
12
|
+
// aws`, or a `[extensions.tier2-aws] active = true` in tmct.toml) — zero code
|
|
13
|
+
// change. `tier2-general` (PLAN_AGENTS.md Phase 1) is deliberately NOT a
|
|
14
|
+
// language/domain bundle like the other three — everyday-knowledge concepts
|
|
15
|
+
// with zero code-domain framing, the "wider general-knowledge seed set"
|
|
16
|
+
// bullet made real instead of just mechanically activatable.
|
|
13
17
|
//
|
|
14
18
|
// A `tmct.toml` may carry a top-level `[extensions]` table-of-tables
|
|
15
19
|
// (`[extensions.tier2-aws]`, …): a RECOGNIZED name (one of the builtins above)
|
|
@@ -87,6 +91,12 @@ function builtinExtensions() {
|
|
|
87
91
|
corpusPath: join(TIER2_DIR, "java.jsonl"),
|
|
88
92
|
provenancePrefix: "corpus:tier2-java",
|
|
89
93
|
},
|
|
94
|
+
"tier2-general": {
|
|
95
|
+
kind: "corpus",
|
|
96
|
+
active: false,
|
|
97
|
+
corpusPath: join(TIER2_DIR, "general.jsonl"),
|
|
98
|
+
provenancePrefix: "corpus:tier2-general",
|
|
99
|
+
},
|
|
90
100
|
};
|
|
91
101
|
}
|
|
92
102
|
|
package/src/finish.mjs
CHANGED
|
@@ -312,13 +312,46 @@ function ruleAgreement(segments, rule) {
|
|
|
312
312
|
return out;
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
// Rule 3 — sentence capitalisation.
|
|
316
|
-
//
|
|
317
|
-
//
|
|
315
|
+
// Rule 3 — sentence capitalisation. Capitalises (a) the very first character
|
|
316
|
+
// when the answer OPENS on a prose span (the ORIGINAL single-answer scope,
|
|
317
|
+
// unchanged), (b) every INTERNAL sentence boundary inside a single prose span
|
|
318
|
+
// — a run of terminal punctuation + whitespace followed by a lowercase letter
|
|
319
|
+
// — and (c) a sentence boundary that CROSSES a span boundary: a prose span
|
|
320
|
+
// ends in terminal punctuation (+ optional trailing whitespace) and the next
|
|
321
|
+
// real-content span is itself prose starting lowercase (any purely-whitespace
|
|
322
|
+
// prose spans in between are skipped over). (b) and (c) are the
|
|
323
|
+
// PLAN_COMPLETIONS.md Stage 6 generalisation — a genuinely multi-sentence
|
|
324
|
+
// completion needs every internal boundary capitalised, not just the whole-
|
|
325
|
+
// answer opener. A boundary that lands on a PROTECTED span (path/entity/…) is
|
|
326
|
+
// left exactly as grounded — a protected span's casing is never
|
|
327
|
+
// rule-transformed, the same guard (a) always applied to an answer that opens
|
|
328
|
+
// on one.
|
|
318
329
|
function ruleCapitalise(segments) {
|
|
319
|
-
if (!segments.length
|
|
330
|
+
if (!segments.length) return segments;
|
|
320
331
|
const out = segments.map((s) => ({ ...s }));
|
|
321
|
-
|
|
332
|
+
|
|
333
|
+
// (a) answer-initial capitalisation
|
|
334
|
+
if (out[0].type === "prose") {
|
|
335
|
+
out[0].text = out[0].text.replace(/^(\s*)([a-z])/, (m, sp, ch) => sp + ch.toUpperCase());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// (b) every internal sentence boundary WITHIN a single prose span
|
|
339
|
+
for (const seg of out) {
|
|
340
|
+
if (seg.type !== "prose") continue;
|
|
341
|
+
seg.text = seg.text.replace(/([.!?])(\s+)([a-z])/g, (m, stop, sp, ch) => stop + sp + ch.toUpperCase());
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// (c) a sentence boundary that CROSSES a span boundary
|
|
345
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
346
|
+
const seg = out[i];
|
|
347
|
+
if (seg.type !== "prose" || !/[.!?]\s*$/.test(seg.text)) continue;
|
|
348
|
+
let j = i + 1;
|
|
349
|
+
while (j < out.length && out[j].type === "prose" && /^\s*$/.test(out[j].text)) j += 1;
|
|
350
|
+
if (j < out.length && out[j].type === "prose") {
|
|
351
|
+
out[j].text = out[j].text.replace(/^(\s*)([a-z])/, (m, sp, ch) => sp + ch.toUpperCase());
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
322
355
|
return out;
|
|
323
356
|
}
|
|
324
357
|
|
|
@@ -345,19 +378,18 @@ function ruleList(segments, rule) {
|
|
|
345
378
|
return out;
|
|
346
379
|
}
|
|
347
380
|
|
|
348
|
-
// Rule 5 — terminal punctuation.
|
|
349
|
-
//
|
|
350
|
-
//
|
|
381
|
+
// Rule 5 — terminal punctuation. Collapses ANY run of 2+ sentence stops within
|
|
382
|
+
// a prose span to a single stop ("done.." → "done."; "Sentence one.. Sentence
|
|
383
|
+
// two!!" → "Sentence one. Sentence two!") — generalised (PLAN_COMPLETIONS.md
|
|
384
|
+
// Stage 6) from the original single-answer scope (only the LAST prose span,
|
|
385
|
+
// only a run anchored at the very end of the answer) to every internal
|
|
386
|
+
// sentence boundary a multi-sentence completion can carry. A legitimate
|
|
387
|
+
// fragment/list answer that ends without a stop is unaffected — there is
|
|
388
|
+
// nothing to collapse.
|
|
351
389
|
function ruleTerminal(segments, rule) {
|
|
352
390
|
const stops = (rule.stops && rule.stops.length ? rule.stops : [".", "!", "?"]).map(escapeRe2).join("");
|
|
353
|
-
const
|
|
354
|
-
|
|
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;
|
|
391
|
+
const re = new RegExp(`([${stops}])(?:\\s*[${stops}])+(\\s*)`, "g");
|
|
392
|
+
return segments.map((s) => (s.type === "prose" ? { ...s, text: s.text.replace(re, "$1$2") } : { ...s }));
|
|
361
393
|
}
|
|
362
394
|
|
|
363
395
|
const HANDLERS = {
|
|
@@ -421,16 +453,27 @@ export function applyGrammar(segments, rules = grammarRules()) {
|
|
|
421
453
|
// runTurn, at the `withLast` seam, `result = finish(result, { graph })` so every
|
|
422
454
|
// producer passes through once. Until then finish() is exercised by its unit +
|
|
423
455
|
// golden tests; wiring it changes no fact, only fixes our own generated defects.
|
|
456
|
+
//
|
|
457
|
+
// ctx.rules (optional, additive): a caller-supplied rule table overriding the
|
|
458
|
+
// cached grammarRules() for this call only — e.g. completions/complete.mjs's
|
|
459
|
+
// Stage 6 pass force-enables the sentence-capitalisation rule (PARKED in the
|
|
460
|
+
// live chat table per grammar-rules.toml's own cycle-006 note) because a
|
|
461
|
+
// genuinely multi-sentence extractive completion needs every internal sentence
|
|
462
|
+
// boundary capitalised to read as one voice, without touching the chat
|
|
463
|
+
// pipeline's default live/parked flags. Every EXISTING call site omits
|
|
464
|
+
// ctx.rules and is therefore byte-identical to before this option existed.
|
|
424
465
|
|
|
425
466
|
/** Finish a turn result: grammar-correct its prose spans, preserving every fact.
|
|
426
467
|
* 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).
|
|
468
|
+
* genuine fix. Throws if finishing would move any protected span (guard #4).
|
|
469
|
+
* @param {{rules?: object[]}} [ctx.rules] optional rule-table override (see above) */
|
|
428
470
|
export function finish(result, ctx = {}) {
|
|
429
471
|
if (!result || typeof result.answer !== "string") return result;
|
|
472
|
+
const rules = Array.isArray(ctx.rules) ? ctx.rules : grammarRules();
|
|
430
473
|
const before = Array.isArray(result.segments) && result.segments.length
|
|
431
474
|
? result.segments
|
|
432
475
|
: maskSegments(result.answer, ctx);
|
|
433
|
-
const after = applyGrammar(before,
|
|
476
|
+
const after = applyGrammar(before, rules);
|
|
434
477
|
assertInvariance(before, after);
|
|
435
478
|
const answer = flatten(after);
|
|
436
479
|
if (answer === result.answer) return result; // neutral → byte-stable, same reference
|