@polycode-projects/the-mechanical-code-talker 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -7
- package/bin/tmct.mjs +128 -18
- package/package.json +1 -1
- package/src/chat.mjs +119 -72
- package/src/codegraph.mjs +73 -4
- package/src/config.mjs +7 -2
- package/src/conformance.mjs +59 -15
- package/src/corpus/templates.mjs +38 -0
- package/src/extensions.mjs +348 -0
- package/src/init.mjs +92 -7
- package/src/memory/bias.mjs +77 -0
- package/src/memory/blocks.mjs +57 -18
- package/src/memory/core.mjs +237 -20
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +94 -6
- package/src/providers/bootstrap.mjs +5 -3
- package/src/providers/fixture.mjs +7 -3
- package/src/providers/graph-service.mjs +205 -28
- package/src/repository-interface.mjs +21 -7
- package/src/server.mjs +39 -29
- package/src/source-slice.mjs +68 -0
- package/src/telemetry.mjs +5 -2
- package/src/toml-config.mjs +17 -0
package/src/init.mjs
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
29
29
|
import { dirname, join, resolve } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { stringify as stringifyToml } from "smol-toml";
|
|
31
32
|
|
|
32
33
|
export const CONFIG_FILE = "tmct.toml";
|
|
33
34
|
export const PROVENANCE_REL = join(".tmct", "init.json");
|
|
@@ -55,6 +56,19 @@ export function defaultConfig() {
|
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/** `tmct init --with-persona <name>` presets (Part 7 of the extension-pack
|
|
60
|
+
* batch): a named bundle of `extensions`/`bias` overrides, written into
|
|
61
|
+
* tmct.toml alongside the plain defaults. `code` is TODAY'S IMPLICIT
|
|
62
|
+
* DEFAULT made explicit — an empty `extensions` override (seon + conceptnet
|
|
63
|
+
* are already the shipped builtin defaults; nothing to add) plus an
|
|
64
|
+
* EXPLICIT bias table naming them both at neutral weight, so a repo that
|
|
65
|
+
* chose the `code` persona has a self-documenting tmct.toml rather than
|
|
66
|
+
* relying on an unstated implicit default. Kept minimal on purpose — this
|
|
67
|
+
* batch's job is the persona SEAM, not a curated library of presets. */
|
|
68
|
+
export const PERSONA_PRESETS = Object.freeze({
|
|
69
|
+
code: { extensions: {}, bias: { seon: 1.0, conceptnet: 1.0 } },
|
|
70
|
+
});
|
|
71
|
+
|
|
58
72
|
/** Read this package's version (best-effort, for provenance). */
|
|
59
73
|
async function tmctVersion() {
|
|
60
74
|
try {
|
|
@@ -83,7 +97,7 @@ export function renderTomlConfig(config = defaultConfig()) {
|
|
|
83
97
|
const c = { ...defaultConfig(), ...config };
|
|
84
98
|
const corpus = { ...defaultConfig().corpus, ...(config.corpus || {}) };
|
|
85
99
|
const seed = { ...defaultConfig().seed, ...(config.seed || {}) };
|
|
86
|
-
|
|
100
|
+
const base = `# tmct.toml — the mechanical code talker, project configuration.
|
|
87
101
|
# Written by \`tmct init\`. An ABSENT file means shipped defaults (this file
|
|
88
102
|
# just makes them explicit and editable). Documented in the repository-interface
|
|
89
103
|
# onboarding surface (ROADMAP Phase 8, "Distribution: tmct init").
|
|
@@ -109,6 +123,23 @@ enabled = ${seed.enabled ? "true" : "false"}
|
|
|
109
123
|
# To cap it, uncomment and set a number (definitional band first):
|
|
110
124
|
${seed.limit != null ? `limit = ${Number(seed.limit)}` : "# limit = 500"}
|
|
111
125
|
`;
|
|
126
|
+
// Extension-pack / bias sections (src/extensions.mjs) — ONLY emitted when a
|
|
127
|
+
// caller actually supplies them (an explicit `--with-persona`, or a manual
|
|
128
|
+
// override); the plain zero-flag `tmct init` output stays BYTE-IDENTICAL to
|
|
129
|
+
// before this feature existed. Rendered via smol-toml's own stringify (not
|
|
130
|
+
// hand-written prose like the base file above) — a plain, uncommented
|
|
131
|
+
// config fragment is honest about being machine-written/round-tripped.
|
|
132
|
+
const extras = {};
|
|
133
|
+
if (config.extensions !== undefined) extras.extensions = config.extensions;
|
|
134
|
+
if (config.bias !== undefined) extras.bias = config.bias;
|
|
135
|
+
if (!Object.keys(extras).length) return base;
|
|
136
|
+
return `${base}
|
|
137
|
+
# Extension packs + bias (src/extensions.mjs) — written by \`tmct init --with-persona\`
|
|
138
|
+
# or a manual edit. Recognized names (seon, conceptnet, tier2-aws, tier2-python,
|
|
139
|
+
# tier2-java) override the shipped defaults; any other name declares a new
|
|
140
|
+
# host-supplied bundle (needs its own "kind"). [bias] is a flat bundle-name ->
|
|
141
|
+
# weight table consumed by src/memory/bias.mjs's ranking.
|
|
142
|
+
${stringifyToml(extras)}`;
|
|
112
143
|
}
|
|
113
144
|
|
|
114
145
|
/** Should the seed run? Explicit `opts.seed` wins; otherwise the config's
|
|
@@ -132,13 +163,20 @@ function seedRequested({ optSeed, configEnabled, env }) {
|
|
|
132
163
|
* `seed.enabled` (TMCT_NO_SEED still vetoes).
|
|
133
164
|
* @param {object} [opts.env] environment (for TMCT_NO_SEED); defaults to
|
|
134
165
|
* process.env.
|
|
166
|
+
* @param {object} [opts.persona] a resolved PERSONA_PRESETS entry
|
|
167
|
+
* ({extensions?, bias?}) to merge into the FRESH config before it's written
|
|
168
|
+
* (Part 7, `tmct init --with-persona <name>`). Name -> preset resolution
|
|
169
|
+
* and unknown-name validation are the CALLER'S job (bin/tmct.mjs) — this
|
|
170
|
+
* only ever sees an already-resolved preset object (or nothing). Has no
|
|
171
|
+
* effect when tmct.toml already exists and `force` isn't set (the existing
|
|
172
|
+
* "preserve a user's tmct.toml" rule wins, same as `seed`/`corpus.tier`).
|
|
135
173
|
* @returns {Promise<{
|
|
136
174
|
* created: string[], config: object, seeded: boolean,
|
|
137
175
|
* alreadyInitialized: boolean, seedResult: (object|null), message: string
|
|
138
176
|
* }>} `created` lists the ABSOLUTE paths this call brought into being (empty on a
|
|
139
177
|
* benign no-op re-init). Never throws on a benign re-init or a corpus failure.
|
|
140
178
|
*/
|
|
141
|
-
export async function initRepo(dir, { force = false, seed, env = process.env } = {}) {
|
|
179
|
+
export async function initRepo(dir, { force = false, seed, env = process.env, persona = null } = {}) {
|
|
142
180
|
const root = resolve(dir);
|
|
143
181
|
const created = [];
|
|
144
182
|
const paths = {
|
|
@@ -162,6 +200,15 @@ export async function initRepo(dir, { force = false, seed, env = process.env } =
|
|
|
162
200
|
|
|
163
201
|
// ---- 2. The externalised config (preserve an existing file unless force) ----
|
|
164
202
|
let config = defaultConfig();
|
|
203
|
+
// Persona overrides (Part 7) apply ONLY to a FRESH write — an empty preset
|
|
204
|
+
// field (e.g. `code`'s `extensions: {}`) is a genuine no-op, never an
|
|
205
|
+
// explicit-empty-section write (renderTomlConfig only emits [extensions]/
|
|
206
|
+
// [bias] when the merged config actually carries a non-empty one — the
|
|
207
|
+
// plain zero-flag `tmct init` output stays byte-identical either way).
|
|
208
|
+
if (persona) {
|
|
209
|
+
if (persona.extensions && Object.keys(persona.extensions).length) config.extensions = persona.extensions;
|
|
210
|
+
if (persona.bias && Object.keys(persona.bias).length) config.bias = persona.bias;
|
|
211
|
+
}
|
|
165
212
|
const tomlPresent = await exists(paths.toml);
|
|
166
213
|
if (!tomlPresent || force) {
|
|
167
214
|
await writeFile(paths.toml, renderTomlConfig(config));
|
|
@@ -172,7 +219,16 @@ export async function initRepo(dir, { force = false, seed, env = process.env } =
|
|
|
172
219
|
config = await readWrittenConfig(paths.toml, config);
|
|
173
220
|
}
|
|
174
221
|
|
|
175
|
-
// ---- 3. Seed the
|
|
222
|
+
// ---- 3. Seed the committed corpus (offline, failure-tolerant) ----
|
|
223
|
+
// DELIBERATE BUG FIX (this batch): `tmct init`'s zero-flag seed step used to
|
|
224
|
+
// seed ONLY the ConceptNet band, never the curated SEON ontology — unlike
|
|
225
|
+
// chat.mjs's own first-run bootstrap (seedBootstrapMemory), which has always
|
|
226
|
+
// seeded BOTH. Both now go through the SAME unified loop
|
|
227
|
+
// (src/extensions.mjs's resolveExtensions + seedActiveCorpusEntries), so
|
|
228
|
+
// `tmct init`'s seed matches chat's bootstrap exactly — this changes
|
|
229
|
+
// `initRepo`'s seeded fact COUNT (test/init.test.mjs's seed-count assertions
|
|
230
|
+
// were updated for the larger post-fix totals, deliberately, in the same
|
|
231
|
+
// commit as this fix).
|
|
176
232
|
let seeded = false;
|
|
177
233
|
let seedResult = null;
|
|
178
234
|
let seedNote = "";
|
|
@@ -185,18 +241,42 @@ export async function initRepo(dir, { force = false, seed, env = process.env } =
|
|
|
185
241
|
seedNote = "seed skipped (already seeded — marker present)";
|
|
186
242
|
} else {
|
|
187
243
|
try {
|
|
188
|
-
const {
|
|
189
|
-
const
|
|
190
|
-
|
|
244
|
+
const { resolveExtensions, seedActiveCorpusEntries } = await import("./extensions.mjs");
|
|
245
|
+
const { entries } = await resolveExtensions(root);
|
|
246
|
+
// `tmct.toml`'s `[seed] limit` knob is documented as capping the tier-1
|
|
247
|
+
// ConceptNet band specifically (the curated SEON ontology is small and
|
|
248
|
+
// always seeds whole) — so it overrides ONLY the resolved "conceptnet"
|
|
249
|
+
// entry's limit, exactly like the pre-fix single-corpus seed did.
|
|
250
|
+
if (config.seed?.limit != null && entries.has("conceptnet")) {
|
|
251
|
+
entries.set("conceptnet", { ...entries.get("conceptnet"), limit: Number(config.seed.limit) });
|
|
252
|
+
}
|
|
253
|
+
const { appended, skipped, total, perBundle } = await seedActiveCorpusEntries(root, entries);
|
|
254
|
+
// seedActiveCorpusEntries is failure-tolerant PER BUNDLE (a bad third-party
|
|
255
|
+
// pack never aborts the others) — but initRepo's own "FAILURE-TOLERANT
|
|
256
|
+
// SEED" contract is about the SEED AS A WHOLE degrading honestly. If every
|
|
257
|
+
// active bundle failed (e.g. the memory graph file itself is unwritable —
|
|
258
|
+
// see test/init.test.mjs "seed failure degrades"), re-throw the first
|
|
259
|
+
// bundle's error so the SAME outer catch below reports the familiar "seed
|
|
260
|
+
// skipped (corpus unavailable: …)" note, rather than claiming success with
|
|
261
|
+
// zero facts actually written.
|
|
262
|
+
const bundleNames = Object.keys(perBundle);
|
|
263
|
+
const allFailed = bundleNames.length > 0 && bundleNames.every((n) => perBundle[n].error);
|
|
264
|
+
if (allFailed) throw new Error(perBundle[bundleNames[0]].error);
|
|
265
|
+
seedResult = {
|
|
266
|
+
appended, skipped, total, perBundle,
|
|
267
|
+
seon: perBundle.seon?.appended || 0,
|
|
268
|
+
conceptnet: perBundle.conceptnet?.appended || 0,
|
|
269
|
+
};
|
|
191
270
|
const markerNew = !(await exists(paths.marker));
|
|
192
271
|
await mkdir(dirname(paths.marker), { recursive: true });
|
|
193
272
|
await writeFile(
|
|
194
273
|
paths.marker,
|
|
195
274
|
JSON.stringify({
|
|
196
275
|
seededAt: new Date().toISOString(),
|
|
197
|
-
limit,
|
|
276
|
+
limit: config.seed?.limit != null ? Number(config.seed.limit) : SEED_LIMIT,
|
|
198
277
|
appended: seedResult.appended,
|
|
199
278
|
skipped: seedResult.skipped,
|
|
279
|
+
perBundle,
|
|
200
280
|
}) + "\n",
|
|
201
281
|
);
|
|
202
282
|
if (markerNew) created.push(paths.marker);
|
|
@@ -245,6 +325,11 @@ async function readWrittenConfig(tomlPath, base) {
|
|
|
245
325
|
if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
|
|
246
326
|
if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
|
|
247
327
|
}
|
|
328
|
+
// Sparse pass-through (src/extensions.mjs validates; this layer just carries
|
|
329
|
+
// the raw tables through unmodified, same discipline as toml-config.mjs's
|
|
330
|
+
// normalizeConfig).
|
|
331
|
+
if (raw.extensions !== undefined) cfg.extensions = raw.extensions;
|
|
332
|
+
if (raw.bias !== undefined) cfg.bias = raw.bias;
|
|
248
333
|
return cfg;
|
|
249
334
|
} catch {
|
|
250
335
|
return base;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// memory/bias.mjs — bias-weighted fact ranking (extension-pack batch, Part 6).
|
|
2
|
+
//
|
|
3
|
+
// A small, PURE module, deliberately SEPARATE from trust.mjs's existing closed
|
|
4
|
+
// 3-input computeTrust contract (many tests pin computeTrust's exact
|
|
5
|
+
// signature; this module never touches it). Trust answers "how much do I
|
|
6
|
+
// believe this fact"; bias answers a DIFFERENT question an operator asks
|
|
7
|
+
// explicitly — "when two facts disagree, which BUNDLE do I prefer to hear
|
|
8
|
+
// from first" (the worked example: is a class part of code, or part of a
|
|
9
|
+
// school — a `[bias]` table lets an operator say "for MY repo, weight the
|
|
10
|
+
// code-vocabulary bundle over the general-English one").
|
|
11
|
+
//
|
|
12
|
+
// biasForSourceId(sourceId, biasByBundle) "src:corpus:<name>" -> weight (default 1)
|
|
13
|
+
// biasForRow(row, biasByBundle) max bias across row.sourceIds
|
|
14
|
+
// rankByBiasThenTrust(rows, biasByBundle) stable: bias desc, trust desc, original order
|
|
15
|
+
//
|
|
16
|
+
// biasByBundle is the flat `{ bundleName: number }` table src/extensions.mjs's
|
|
17
|
+
// resolveExtensions() reads out of tmct.toml's `[bias]` table — resolved ONCE
|
|
18
|
+
// per chat session and threaded through, never re-read per turn.
|
|
19
|
+
//
|
|
20
|
+
// CRITICAL CONTRACT (the operator's own "disclosed, never dropped"
|
|
21
|
+
// requirement): bias only REORDERS a hit list. It must NEVER drop, hide, or
|
|
22
|
+
// silently prefer a lower-biased fact over a higher-trust one within the same
|
|
23
|
+
// bias tier — every hit rankByBiasThenTrust is given still comes back out,
|
|
24
|
+
// same length, same members, just reordered.
|
|
25
|
+
|
|
26
|
+
/** A Fact's statedBy Source id is shaped "src:corpus:<bundleName>" for every
|
|
27
|
+
* corpus-kind Source (memory/core.mjs's own sourceIdFor — see its `corpus`
|
|
28
|
+
* case). Any other shape (operator/teach/provider/web/entailed Source ids,
|
|
29
|
+
* or a malformed/absent id) is NOT a corpus bundle and always ranks at the
|
|
30
|
+
* neutral bias of 1 — bias is a corpus-bundle-only concept, never a proxy
|
|
31
|
+
* for a different trust dimension. */
|
|
32
|
+
const CORPUS_SOURCE_RE = /^src:corpus:(.+)$/;
|
|
33
|
+
|
|
34
|
+
/** The bias weight of one Source id, resolved against `biasByBundle`. Default
|
|
35
|
+
* 1 (neutral) for a non-corpus source id, an unconfigured bundle name, or a
|
|
36
|
+
* non-numeric configured value (never throws — a malformed `[bias]` entry is
|
|
37
|
+
* caught earlier, at resolveExtensions() load time). */
|
|
38
|
+
export function biasForSourceId(sourceId, biasByBundle = {}) {
|
|
39
|
+
const m = CORPUS_SOURCE_RE.exec(String(sourceId || ""));
|
|
40
|
+
if (!m) return 1;
|
|
41
|
+
const v = biasByBundle?.[m[1]];
|
|
42
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The bias weight of one fact ROW (readFactRows' shape: `{..., sourceIds}`)
|
|
46
|
+
* — the MAX bias across its (possibly several, corroborating) Sources, so a
|
|
47
|
+
* fact corroborated by both a neutral and a high-bias bundle ranks at the
|
|
48
|
+
* higher of the two, never averaged down. A row with no sourceIds (or an
|
|
49
|
+
* empty array) ranks at the neutral 1. */
|
|
50
|
+
export function biasForRow(row, biasByBundle = {}) {
|
|
51
|
+
const ids = Array.isArray(row?.sourceIds) ? row.sourceIds : [];
|
|
52
|
+
if (!ids.length) return 1;
|
|
53
|
+
return Math.max(...ids.map((id) => biasForSourceId(id, biasByBundle)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rank fact rows by bias (desc), then trust (desc), then original relative
|
|
58
|
+
* order (STABLE — Array.prototype.sort is stable in Node, reinforced here
|
|
59
|
+
* with an explicit index tiebreak so the guarantee never depends on engine
|
|
60
|
+
* internals). Every row that goes in comes back out — same length, same
|
|
61
|
+
* members — this ONLY reorders, it never filters or drops a hit, honouring
|
|
62
|
+
* the "disclosed, never dropped" contract every caller in chat.mjs relies on.
|
|
63
|
+
*
|
|
64
|
+
* With an empty/absent `biasByBundle` (the default, unconfigured case) every
|
|
65
|
+
* row's bias is 1 — a true no-op tier, so the sort degrades to trust-desc,
|
|
66
|
+
* ties broken by original order: BYTE-IDENTICAL to today's behaviour for any
|
|
67
|
+
* caller that previously sorted by trust alone (or didn't sort at all and
|
|
68
|
+
* every row happened to share the same trust, the common single-session
|
|
69
|
+
* operator-taught case).
|
|
70
|
+
*/
|
|
71
|
+
export function rankByBiasThenTrust(rows, biasByBundle = {}) {
|
|
72
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
73
|
+
return list
|
|
74
|
+
.map((row, index) => ({ row, index, bias: biasForRow(row, biasByBundle) }))
|
|
75
|
+
.sort((a, b) => (b.bias - a.bias) || ((b.row?.trust ?? 0) - (a.row?.trust ?? 0)) || (a.index - b.index))
|
|
76
|
+
.map((x) => x.row);
|
|
77
|
+
}
|
package/src/memory/blocks.mjs
CHANGED
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
// 2. QUERY-time IDF match — each query token is weighted by rarity across
|
|
16
16
|
// blocks (idf = log(1 + N/(1+df)), the codegraph.mjs locate discipline),
|
|
17
17
|
// so a whole-question query is not dominated by its ubiquitous words.
|
|
18
|
-
// retrieveBlocks() scores idf-sum × (1 + rank): the IDF
|
|
19
|
-
// the static rank breaks ties toward well-connected
|
|
18
|
+
// retrieveBlocks() scores idf-sum × (1 + rank) ÷ sqrt(1 + degree): the IDF
|
|
19
|
+
// match decides topic, the static rank breaks ties toward well-connected
|
|
20
|
+
// blocks, and the degree divisor (hub dampening, ported from codegraph.mjs's
|
|
21
|
+
// spiralExpand degree-quantile gate; on by default here) stops a block that
|
|
22
|
+
// merely shares vocabulary with disproportionately many others — a generic
|
|
23
|
+
// "boilerplate" hub — from winning on inflated rank alone.
|
|
20
24
|
//
|
|
21
25
|
// All writes are temp+rename atomic; saveBlock is an upsert (same id replaces —
|
|
22
26
|
// what makes fold.mjs's re-fold idempotent).
|
|
@@ -79,20 +83,15 @@ export async function loadBlockIndex(dir) {
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
|
-
*
|
|
83
|
-
* { id: tokens[] } map; an undirected edge joins two
|
|
84
|
-
* `overlapMin` tokens.
|
|
85
|
-
*
|
|
86
|
+
* Build the block-similarity adjacency shared by rankBlocks and degreeOf:
|
|
87
|
+
* `tokensById` is a plain { id: tokens[] } map; an undirected edge joins two
|
|
88
|
+
* blocks sharing at least `overlapMin` tokens. Returns { ids, neighbours }
|
|
89
|
+
* (neighbours[i] is an array of adjacent indices into ids).
|
|
86
90
|
*/
|
|
87
|
-
|
|
88
|
-
damping = PAGERANK_DAMPING, iterations = PAGERANK_ITERATIONS, overlapMin = OVERLAP_MIN,
|
|
89
|
-
} = {}) {
|
|
91
|
+
function buildNeighbours(tokensById, overlapMin) {
|
|
90
92
|
const ids = Object.keys(tokensById || {});
|
|
91
93
|
const N = ids.length;
|
|
92
|
-
if (!N) return {};
|
|
93
94
|
const sets = ids.map((id) => new Set(tokensById[id] || []));
|
|
94
|
-
|
|
95
|
-
// similarity edges: shared-token count ≥ overlapMin (undirected → both directions)
|
|
96
95
|
const neighbours = ids.map(() => []);
|
|
97
96
|
for (let i = 0; i < N; i += 1) {
|
|
98
97
|
for (let j = i + 1; j < N; j += 1) {
|
|
@@ -107,6 +106,35 @@ export function rankBlocks(tokensById, {
|
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
}
|
|
109
|
+
return { ids, neighbours };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Each block's degree (neighbour count) in the same block-similarity graph
|
|
114
|
+
* rankBlocks runs PageRank over. Pure — returns { id: degree }. Used to
|
|
115
|
+
* dampen retrieveBlocks' hub bias (a block linked to many others shouldn't
|
|
116
|
+
* win purely by being well-connected — codegraph.mjs's spiralExpand applies
|
|
117
|
+
* the same degree-quantile idea to module search).
|
|
118
|
+
*/
|
|
119
|
+
export function degreeOf(tokensById, { overlapMin = OVERLAP_MIN } = {}) {
|
|
120
|
+
const { ids, neighbours } = buildNeighbours(tokensById, overlapMin);
|
|
121
|
+
const out = {};
|
|
122
|
+
for (let i = 0; i < ids.length; i += 1) out[ids[i]] = neighbours[i].length;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Iterative PageRank over the block-similarity graph. `tokensById` is a plain
|
|
128
|
+
* { id: tokens[] } map; an undirected edge joins two blocks sharing at least
|
|
129
|
+
* `overlapMin` tokens. Standard damped iteration (d=0.85, 20 rounds), dangling
|
|
130
|
+
* mass redistributed evenly, ranks summing to ~1. Pure — returns { id: rank }.
|
|
131
|
+
*/
|
|
132
|
+
export function rankBlocks(tokensById, {
|
|
133
|
+
damping = PAGERANK_DAMPING, iterations = PAGERANK_ITERATIONS, overlapMin = OVERLAP_MIN,
|
|
134
|
+
} = {}) {
|
|
135
|
+
const { ids, neighbours } = buildNeighbours(tokensById, overlapMin);
|
|
136
|
+
const N = ids.length;
|
|
137
|
+
if (!N) return {};
|
|
110
138
|
|
|
111
139
|
let rank = new Array(N).fill(1 / N);
|
|
112
140
|
for (let round = 0; round < iterations; round += 1) {
|
|
@@ -133,7 +161,11 @@ function rerank(index) {
|
|
|
133
161
|
const tokensById = {};
|
|
134
162
|
for (const [id, b] of Object.entries(index.blocks)) tokensById[id] = b.tokens || [];
|
|
135
163
|
const ranks = rankBlocks(tokensById);
|
|
136
|
-
|
|
164
|
+
const degrees = degreeOf(tokensById);
|
|
165
|
+
for (const [id, b] of Object.entries(index.blocks)) {
|
|
166
|
+
b.rank = ranks[id] ?? 0;
|
|
167
|
+
b.degree = degrees[id] ?? 0;
|
|
168
|
+
}
|
|
137
169
|
return index;
|
|
138
170
|
}
|
|
139
171
|
|
|
@@ -177,9 +209,10 @@ export async function removeBlock(dir, id) {
|
|
|
177
209
|
/**
|
|
178
210
|
* The top-k blocks a question "touches": IDF-weighted token match (rarity-
|
|
179
211
|
* weighted, so common words can't dominate) combined with the static PageRank
|
|
180
|
-
* (score × (1 + rank)
|
|
181
|
-
*
|
|
182
|
-
*
|
|
212
|
+
* (score × (1 + rank), divided by sqrt(1 + degree) to dampen hubs — a block
|
|
213
|
+
* that shares vocabulary with disproportionately many others doesn't win on
|
|
214
|
+
* inflated rank alone). Returns [{ id, score, rank, file, text }], best
|
|
215
|
+
* first; [] when nothing matches (never a guessed block).
|
|
183
216
|
*/
|
|
184
217
|
export async function retrieveBlocks(dir, query, k = 3) {
|
|
185
218
|
const index = await loadBlockIndex(dir);
|
|
@@ -204,12 +237,18 @@ export async function retrieveBlocks(dir, query, k = 3) {
|
|
|
204
237
|
for (const t of qTokens) if (sets[i].has(t)) idfSum += idf.get(t);
|
|
205
238
|
if (idfSum <= 0) continue;
|
|
206
239
|
const rank = b.rank ?? 0;
|
|
240
|
+
const degree = b.degree ?? 0;
|
|
207
241
|
// relevance × connectivity × TRUST — a bounded trustFactor (~[0.5, 1.5]) so a
|
|
208
242
|
// corroborated/operator block outranks a lone low-trust one on a relevance tie,
|
|
209
|
-
// yet a weakly-trusted but perfectly-relevant block still surfaces.
|
|
243
|
+
// yet a weakly-trusted but perfectly-relevant block still surfaces. Divided by
|
|
244
|
+
// sqrt(1 + degree) — hub dampening (the codegraph.mjs spiralExpand idea, ported
|
|
245
|
+
// here): a block that shares vocabulary with disproportionately many others
|
|
246
|
+
// shouldn't win purely by being well-connected.
|
|
210
247
|
const trust = typeof b.trust === "number" ? b.trust : blockTrust(b.sourceType);
|
|
211
248
|
const trustFactor = trustFactorOf(trust);
|
|
212
|
-
scored.push({
|
|
249
|
+
scored.push({
|
|
250
|
+
id, score: (idfSum * (1 + rank) * trustFactor) / Math.sqrt(1 + degree), rank, trust, file: b.file,
|
|
251
|
+
});
|
|
213
252
|
}
|
|
214
253
|
scored.sort((a, b) => b.score - a.score || b.rank - a.rank || a.id.localeCompare(b.id));
|
|
215
254
|
const top = scored.slice(0, Math.max(1, k));
|