@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.
@@ -0,0 +1,138 @@
1
+ // completions/complete.mjs — the full PLAN_COMPLETIONS.md pipeline, wired end to end:
2
+ // broadSearch (Stage 1) -> groupHits (Stage 2) -> rankSentences per group + inferRelations
3
+ // across groups (Stage 3+4) -> pruneCompletion's keep/drop (Stage 5) -> assemble the kept
4
+ // sentences into prose -> finish()'s grammar/voice pass (Stage 6). §4's staging table, row 3:
5
+ // "Stage 5+6 — pruning + grammar/voice pass wired end to end... Stage 6 reuses finish.mjs
6
+ // directly... Exit criterion: A full end-to-end completion reads as one consistent voice and
7
+ // every sentence traces to a source span."
8
+ //
9
+ // Extractive, by construction (PLAN_COMPLETIONS.md §3's honest ceiling): the assembled text is
10
+ // the KEPT sentences, in order, joined by a single space — never paraphrased, never reordered
11
+ // beyond what pruning's own group/rank order already decided, never smoothed over a genuine
12
+ // disjointedness in the source material. Stage 6 fixes CAPITALISATION and PUNCTUATION at every
13
+ // sentence boundary (this dispatch's own generalisation of src/finish.mjs's ruleCapitalise/
14
+ // ruleTerminal); it does not invent, reorder, or merge any sentence's WORDS. Where the source
15
+ // material simply doesn't chain into a fluent read, the output reads disjointedly and stays
16
+ // honest about it — that is the plan's own accepted, documented limit, not a bug this module
17
+ // tries to paper over.
18
+ //
19
+ // Traceability: `sourceSpans` is built directly from pruneCompletion()'s own `kept` list (which
20
+ // already carries sourceBlockId + groupId per sentence) BEFORE the grammar pass runs, and the
21
+ // grammar pass never reorders, drops, or merges sentences (only mutates casing/punctuation of
22
+ // prose text) — so sourceSpans stays index-aligned with "one entry per output sentence" even
23
+ // though the FINAL text is produced by finish(), not directly from `kept`. `relations` is
24
+ // infer.mjs's own output, untouched by pruning (relations are never pruned, only the sentences
25
+ // that anchor them are decided) — every relation still carries its own `licensingTest`/
26
+ // `evidence` per infer.mjs's contract.
27
+ //
28
+ // Determinism: every stage this module chains is already deterministic (broadSearch, groupHits,
29
+ // rankSentences, inferRelations, pruneCompletion, finish() are all pure/deterministic functions
30
+ // over their inputs); this module adds no randomness of its own. See
31
+ // test/completions-complete.test.mjs's own double-run diff.
32
+
33
+ import { broadSearch } from "./search.mjs";
34
+ import { groupHits } from "./group.mjs";
35
+ import { rankSentences } from "./rank.mjs";
36
+ import { inferRelations } from "./infer.mjs";
37
+ import { pruneCompletion } from "./prune.mjs";
38
+ import { loadMemory } from "../memory/core.mjs";
39
+ import { finish, grammarRules } from "../finish.mjs";
40
+
41
+ /** grammarRules() with sentence-capitalisation FORCE-ENABLED, everything else exactly as the
42
+ * live table has it. sentence-capitalisation stays PARKED (enabled=false) in the live chat
43
+ * table (grammar-rules.toml's own cycle-006 note: it regresses case-sensitive single-answer
44
+ * goldens pinning lowercase openers) — that is a CHAT-voice decision, not a limitation of the
45
+ * rule itself. A genuinely multi-sentence extractive completion has no such single-opener
46
+ * voice to protect and NEEDS every internal sentence boundary capitalised to read as one
47
+ * consistent voice (this pipeline's own exit criterion) — so this module's own Stage 6 call
48
+ * overrides just that one rule's enabled flag, via finish()'s ctx.rules seam, without touching
49
+ * the cached global table or any other caller's behaviour. */
50
+ function completionGrammarRules() {
51
+ return grammarRules().map((r) => (r.id === "sentence-capitalisation" ? { ...r, enabled: true } : r));
52
+ }
53
+
54
+ const DEFAULT_MAX_SENTENCES_PER_GROUP = 3; // see prune.mjs's own file header for the reasoning
55
+
56
+ /**
57
+ * The full mechanical-text-generation pipeline, Stage 1 through Stage 6.
58
+ *
59
+ * @param {string} dir repo root (broadSearch's block corpus + loadMemory's fact store)
60
+ * @param {string} prompt the broad prompt driving retrieval, grouping-focus ranking, and
61
+ * (by default) relation-anchored pruning
62
+ * @param {object} [opts]
63
+ * @param {number} [opts.blockK] broadSearch's block k (default: search.mjs's own default)
64
+ * @param {object|null} [opts.graphService=null] optional Repository-Interface graph service,
65
+ * passed straight through to broadSearch (see search.mjs)
66
+ * @param {number} [opts.graphLimit] broadSearch's graph search() limit
67
+ * @param {number} [opts.overlapMin] groupHits' shared-token edge threshold
68
+ * @param {object} [opts.memory] an already-loaded memory/core.mjs loadMemory() payload; when
69
+ * omitted, this module loads it itself via loadMemory(dir) (a fresh/empty memory is a
70
+ * perfectly valid, honest input — inferRelations() simply asserts nothing over it)
71
+ * @param {string} [opts.query] the query rankSentences()/pruning focus on; defaults to `prompt`
72
+ * itself (query-focused summarization, PLAN_COMPLETIONS.md §1.4's own literature framing) —
73
+ * pass `null` explicitly to fall back to self-weighted (LexRank-style) ranking instead
74
+ * @param {number} [opts.maxSentencesPerGroup=3] prune.mjs's top-K-per-group cutoff
75
+ * @param {object} [opts.graph] optional loaded graph (src/codegraph.mjs parseEntities() shape)
76
+ * handed to finish()'s maskSegments so known entity labels inside the assembled text are
77
+ * protected during the grammar pass — the same ctx.graph existing finish() call sites pass
78
+ * @returns {Promise<{
79
+ * text: string,
80
+ * sourceSpans: Array<{sourceBlockId:string, groupId:string, sentence:string}>,
81
+ * relations: Array<object>,
82
+ * dropped: Array<{item:object, reason:string}>,
83
+ * declined?: boolean,
84
+ * reason?: string,
85
+ * }>}
86
+ */
87
+ export async function generateCompletion(dir, prompt, opts = {}) {
88
+ const {
89
+ blockK, graphService = null, graphLimit, overlapMin,
90
+ memory: memoryOpt, query = prompt, maxSentencesPerGroup = DEFAULT_MAX_SENTENCES_PER_GROUP,
91
+ graph,
92
+ } = opts;
93
+
94
+ // Stage 1 — broad search
95
+ const hits = await broadSearch(dir, prompt, { blockK, graphService, graphLimit });
96
+
97
+ // Stage 2 — grouping
98
+ const groups = groupHits(hits, { overlapMin });
99
+
100
+ // Stage 3 — cross-group inference (needs a loaded memory; a fresh/empty one is honest and
101
+ // simply asserts nothing, never fabricates something to compensate)
102
+ const memory = memoryOpt || await loadMemory(dir);
103
+ const relations = groups.length >= 2 ? await inferRelations(groups, memory) : [];
104
+
105
+ // Stage 4 — extractive sentence ranking, per group (query-focused unless the caller opted out)
106
+ const rankedByGroup = {};
107
+ for (const g of groups) rankedByGroup[g.id] = rankSentences(g, { query });
108
+
109
+ // Stage 5 — pruning: decide keep/drop, with an itemized, auditable drop log
110
+ const { kept, dropped } = pruneCompletion(
111
+ { hits, groups, relations, rankedByGroup },
112
+ { maxSentencesPerGroup },
113
+ );
114
+
115
+ if (!kept.length) {
116
+ // Honest decline (PLAN_COMPLETIONS.md §3): nothing cleared the pruning bar for this prompt
117
+ // over this corpus — never fabricate a completion to fill the gap.
118
+ return { text: "", sourceSpans: [], relations, dropped, declined: true, reason: "no source span cleared the pruning bar for this prompt" };
119
+ }
120
+
121
+ // Assemble: kept sentences, in pruneCompletion's own (group-id, rank-order) order, joined by a
122
+ // single space — purely extractive, no reordering/paraphrasing beyond that (§3's honest
123
+ // ceiling; see file header).
124
+ const rawText = kept.map((k) => k.sentence).join(" ");
125
+
126
+ // Stage 6 — grammar/voice pass. Reuses finish() verbatim (its maskSegments() masker still
127
+ // protects any known entity/path/number/receipt/provenance span WITHIN the assembled text,
128
+ // exactly as it does for a single-answer composed answer); only the rule table differs
129
+ // (sentence-capitalisation force-enabled — see completionGrammarRules() above).
130
+ const finished = finish({ answer: rawText, via: "completion" }, { graph, rules: completionGrammarRules() });
131
+
132
+ // sourceSpans traces every OUTPUT sentence back to its block id + group id — built from
133
+ // `kept` (pre-grammar-pass), which stays index-aligned with the final text because finish()'s
134
+ // grammar rules only mutate casing/punctuation of prose, never reorder/drop/merge sentences.
135
+ const sourceSpans = kept.map((k) => ({ sourceBlockId: k.sourceBlockId, groupId: k.groupId, sentence: k.sentence }));
136
+
137
+ return { text: finished.answer, sourceSpans, relations, dropped };
138
+ }
@@ -0,0 +1,171 @@
1
+ // completions/group.mjs — Stage 2 ("grouping") of PLAN_COMPLETIONS.md's six-stage
2
+ // mechanical-text-generation pipeline. Stage-0 scope ONLY per the plan's own §4 staging
3
+ // table: cluster search.mjs's broadSearch() hits into topical groups; no cross-group
4
+ // inference (Stage 3), summarization (Stage 4), pruning (Stage 5), or voice pass (Stage 6)
5
+ // happens here.
6
+ //
7
+ // Genuinely greenfield, as the plan's own prior research established: PLAN_COMPLETIONS.md's
8
+ // citation of "insights-panel tag clustering" as prior art turned out to be a different,
9
+ // sibling project's capability, not tmct's — there was no clustering/tag-grouping code
10
+ // anywhere in this repo before this file.
11
+ //
12
+ // Granularity: this module clusters at BLOCK granularity — the exact unit search.mjs's
13
+ // broadSearch() hands it (one node per whole retrieveBlocks hit, or one node per whole
14
+ // graph-search/ask result). PLAN_COMPLETIONS.md's own prose loosely says "spans"; that is
15
+ // resolved explicitly here rather than silently assumed: no sub-block span segmentation is
16
+ // attempted in this build. A finer-grained span-level increment, if the pipeline ever needs
17
+ // it, is future work, out of scope for Stage 0.
18
+ //
19
+ // Algorithm: CONNECTED COMPONENTS over the block-similarity graph (shared-token-overlap
20
+ // edges), reusing memory/blocks.mjs's buildNeighbours()/OVERLAP_MIN — the exact adjacency
21
+ // rankBlocks()/degreeOf() already build for PageRank, exported from blocks.mjs for this
22
+ // purpose rather than re-derived. This is the strategy-advisor's explicit Stage-0
23
+ // recommendation: "the cheapest deterministic starting point" for grouping — genuinely
24
+ // CoRank-adjacent (CoRank = "clustering cum graph ranking", PLAN_COMPLETIONS.md §1.2) since
25
+ // it clusters over the same graph-ranking substrate blocks.mjs's PageRank runs on, and fully
26
+ // deterministic/auditable, unlike any softer/fuzzier clustering. Richer ranking WITHIN a
27
+ // cluster (e.g. running rankBlocks per component, or a true joint clustering+ranking pass)
28
+ // is a future increment if the pipeline needs it later — not attempted here.
29
+ //
30
+ // Labeling: each group carries its member ids AND a human-inspectable label — its top
31
+ // shared-IDF tokens. The only term-frequency machinery this repo ships is the
32
+ // idf = log(1 + N/(1+df)) formula memory/blocks.mjs's retrieveBlocks() already computes
33
+ // inline (there is no standalone exported idf() to import — it lives inline in
34
+ // retrieveBlocks, scoped to a single query's tokens); that exact formula is replicated here,
35
+ // scoped instead to df/N over the hit set passed into groupHits(), rather than inventing a
36
+ // new weighting scheme.
37
+ //
38
+ // Determinism: no randomness anywhere. Same hits in (same ids, same text, same order) ->
39
+ // same groups out (same partition, same member order, same label) — this is exactly what
40
+ // test/completions-stage0.test.mjs's double-run diff asserts as Stage 0's exit criterion.
41
+
42
+ import { buildNeighbours, OVERLAP_MIN, tokenizeBlock } from "../memory/blocks.mjs";
43
+ import { STOPWORDS } from "../prose.mjs";
44
+
45
+ const LABEL_TOKEN_COUNT = 5;
46
+
47
+ // tokenizeBlock unions tokenizeProse(line) (stopword-filtered, punctuation-stripped) with
48
+ // splitIdentifierWords(line) (NOT stopword-filtered — it's built for code identifiers, where
49
+ // a stopword-shaped fragment is rare, and it doesn't strip trailing sentence punctuation
50
+ // either). For prose-similarity clustering that union re-admits filler ("the", "and", "is",
51
+ // "q:", "a:", "do?", …) as if it were real overlap signal — harmless for retrieveBlocks'
52
+ // IDF-weighted single-query scoring (rare query tokens still dominate), but fatal for
53
+ // unweighted shared-token-overlap clustering: almost any two English sentences share ≥2
54
+ // stopwords, which would collapse everything into one giant component. isContentToken()
55
+ // closes that gap for THIS module's own adjacency/labeling use, without changing
56
+ // tokenizeBlock's shipped behavior (still exactly what retrieveBlocks/rankBlocks use).
57
+ const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
58
+
59
+ /** tokenizeBlock(text), narrowed to real content tokens (see isContentToken above) — the
60
+ * token set this module actually clusters and labels on. */
61
+ function contentTokens(text) {
62
+ return tokenizeBlock(text).filter(isContentToken);
63
+ }
64
+
65
+ /** Plain union-find (path halving, union-by-index) — small N here (a single broad search's
66
+ * hit count), so this is deliberately the simplest correct structure, not a fancy one. */
67
+ function unionFind(n) {
68
+ const parent = Array.from({ length: n }, (_, i) => i);
69
+ function find(x) {
70
+ while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
71
+ return x;
72
+ }
73
+ function union(a, b) {
74
+ const ra = find(a);
75
+ const rb = find(b);
76
+ if (ra !== rb) parent[Math.max(ra, rb)] = Math.min(ra, rb);
77
+ }
78
+ return { find, union };
79
+ }
80
+
81
+ /**
82
+ * Stage 2 — grouping. Clusters a flat hit list (search.mjs's broadSearch() output, or any
83
+ * `{ id, text }` array) into topical groups via connected components over the shared-token-
84
+ * overlap similarity graph (the same adjacency memory/blocks.mjs's PageRank runs over).
85
+ *
86
+ * @param {Array<{id:string, text:string}>} hits
87
+ * @param {object} [opts]
88
+ * @param {number} [opts.overlapMin=OVERLAP_MIN] shared-token threshold for a similarity
89
+ * edge — defaults to the exact value memory/blocks.mjs's own PageRank graph uses, so
90
+ * grouping and ranking agree on what "related" means unless the caller deliberately
91
+ * overrides it.
92
+ * @returns {Array<{ id: string, members: Array<{id:string, text:string}>, memberIds: string[],
93
+ * tokens: string[], label: string }>}
94
+ * One entry per connected component (a singleton hit that shares no edge with anything
95
+ * else is still a valid, if lonely, one-member group — never dropped). Deterministic
96
+ * order: groups sorted by their lowest member id; members within a group sorted by id.
97
+ * `id` is the group's own stable id (`"g:" + memberIds[0]`, deterministic from content).
98
+ */
99
+ export function groupHits(hits, { overlapMin = OVERLAP_MIN } = {}) {
100
+ const list = Array.isArray(hits) ? hits.filter((h) => h && h.id != null) : [];
101
+ if (!list.length) return [];
102
+
103
+ // dedupe by id — a hit can legitimately appear from more than one source (e.g. a block
104
+ // and a graph-ask answer both id-tagged distinctly, but a caller re-running broadSearch
105
+ // for an overlapping prompt set could still hand duplicate ids); first occurrence wins.
106
+ const seen = new Set();
107
+ const deduped = [];
108
+ for (const h of list) {
109
+ if (seen.has(h.id)) continue;
110
+ seen.add(h.id);
111
+ deduped.push(h);
112
+ }
113
+
114
+ const tokensById = {};
115
+ for (const h of deduped) tokensById[h.id] = contentTokens(h.text || "");
116
+
117
+ const { ids, neighbours } = buildNeighbours(tokensById, overlapMin);
118
+ const { find, union } = unionFind(ids.length);
119
+ for (let i = 0; i < ids.length; i += 1) {
120
+ for (const j of neighbours[i]) union(i, j);
121
+ }
122
+
123
+ const byId = new Map(deduped.map((h) => [h.id, h]));
124
+ const componentIdx = new Map(); // root index -> [member indices]
125
+ for (let i = 0; i < ids.length; i += 1) {
126
+ const root = find(i);
127
+ if (!componentIdx.has(root)) componentIdx.set(root, []);
128
+ componentIdx.get(root).push(i);
129
+ }
130
+
131
+ // IDF over THIS hit set (df/N here, not the whole corpus) — grouping is scoped to what
132
+ // Stage 1 actually retrieved for this prompt, same discipline retrieveBlocks applies to a
133
+ // single query's tokens.
134
+ const N = ids.length;
135
+ const df = new Map();
136
+ for (const id of ids) {
137
+ for (const t of new Set(tokensById[id])) df.set(t, (df.get(t) || 0) + 1);
138
+ }
139
+ const idf = (t) => Math.log(1 + N / (1 + (df.get(t) || 0)));
140
+
141
+ const groups = [];
142
+ for (const memberIdx of componentIdx.values()) {
143
+ const members = memberIdx
144
+ .map((i) => byId.get(ids[i]))
145
+ .sort((a, b) => a.id.localeCompare(b.id));
146
+ const memberIds = members.map((m) => m.id);
147
+
148
+ // label tokens: rank by (a) how many members share the token — full coverage first, so
149
+ // a group's label prefers what its members have IN COMMON over what one member merely
150
+ // contains a lot of — then (b) IDF (rarer across the hit set wins ties), then (c) token
151
+ // text for a fully deterministic order.
152
+ const coverage = new Map();
153
+ for (const i of memberIdx) {
154
+ for (const t of new Set(tokensById[ids[i]])) coverage.set(t, (coverage.get(t) || 0) + 1);
155
+ }
156
+ const tokens = [...coverage.keys()]
157
+ .sort((a, b) => (coverage.get(b) - coverage.get(a)) || (idf(b) - idf(a)) || a.localeCompare(b))
158
+ .slice(0, LABEL_TOKEN_COUNT);
159
+
160
+ groups.push({
161
+ id: `g:${memberIds[0]}`,
162
+ members,
163
+ memberIds,
164
+ tokens,
165
+ label: tokens.join(" ") || "(untitled group)",
166
+ });
167
+ }
168
+
169
+ groups.sort((a, b) => a.memberIds[0].localeCompare(b.memberIds[0]));
170
+ return groups;
171
+ }