@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,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
|
+
}
|