pi-weave 0.1.22 → 0.2.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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Deterministic stale-link detection and repair for the vault.
3
+ *
4
+ * A wiki-link goes stale for two reasons: the note it pointed at was renamed
5
+ * or moved (and nothing rewrote the inbound links), or it was written by hand
6
+ * as a bare title — `[[Quarterly Roadmap]]` — while the note lives at
7
+ * `planning/roadmap-2026`.
8
+ * Graph building already notices both (`GraphModel.danglingLinks`) but only
9
+ * ever reports a count. This module resolves and repairs them.
10
+ *
11
+ * ## Why no fuzzy matching
12
+ *
13
+ * The resolver is a ladder of three rules, each of which must produce
14
+ * **exactly one** candidate to fire:
15
+ *
16
+ * 1. exact slug — the target already names a note; nothing to do
17
+ * 2. unique basename — `roadmap-2026` → `planning/roadmap-2026`
18
+ * 3. unique slug-title — front-matter `title` slugified
19
+ *
20
+ * Two candidates is *ambiguous* and is reported, never guessed. Zero is
21
+ * *unresolvable* and is reported so a human can write the note or drop the
22
+ * link. There is no edit distance, no scoring, no embedding: a repair pass
23
+ * that is only probably right is worse than no repair pass, because it
24
+ * silently rewrites knowledge. The degenerate case here is always "ask the
25
+ * human", never "wrong link".
26
+ *
27
+ * ## Relationship to `GraphModel.danglingLinks`
28
+ *
29
+ * Not the same set, deliberately. `buildGraph` counts every `[[…]]` in a body,
30
+ * including ones inside code fences and below the `## Raw` tail, because
31
+ * over-reporting a link on a graph is harmless. This module reports the
32
+ * **safe-to-repair subset**, so it excludes both: a link in a code sample is
33
+ * a string literal, and a link in the raw tail is the user's quoted words.
34
+ * An audit total below the graph's dangling count is therefore expected.
35
+ *
36
+ * Pure: no I/O lives here. The disk-touching repair entry points are in
37
+ * `../vault` (`repairVaultLinks`, and the backlink rewrite that rename/move
38
+ * perform), which already owns the vault lock and the write path. The
39
+ * dependency runs one way — vault imports repair, never the reverse — so
40
+ * there is no import cycle to reason about at 3am.
41
+ */
42
+
43
+ import { slugify } from "../slug";
44
+ import type { HtmlArtifact, Note } from "../types";
45
+
46
+ /**
47
+ * The append-only tail heading.
48
+ *
49
+ * Defined here rather than in `vault.ts` (which re-exports it, so its public
50
+ * API is unchanged) because repair is the module that must *not* cross it,
51
+ * and vault imports repair. One definition, no cycle.
52
+ */
53
+ export const RAW_NOTES_HEADING = "## Raw";
54
+
55
+ /** One `[[wiki-link]]` found in a body, with enough context to rewrite it. */
56
+ export interface LinkOccurrence {
57
+ /** Index of the opening `[[` in the body. */
58
+ start: number;
59
+ /** Index just past the closing `]]`. */
60
+ end: number;
61
+ /** The link target as written, before normalisation. */
62
+ rawTarget: string;
63
+ /** The target normalised to a slug, the way the graph builder sees it. */
64
+ target: string;
65
+ /** The pipe alias, when the link has one. */
66
+ alias: string | undefined;
67
+ }
68
+
69
+ /**
70
+ * Normalise a link target to a slug.
71
+ *
72
+ * Deliberately identical to `extractWikilinks` (`../graph/wikilinks.ts`):
73
+ * if repair normalised differently from graph building, the audit would
74
+ * report links the graph considers fine, or miss ones it flags. HTML
75
+ * artifacts keep their extension — it is part of their identity.
76
+ */
77
+ export function normalizeTarget(raw: string): string {
78
+ const trimmed = raw.trim();
79
+ return /\.html?$/i.test(trimmed)
80
+ ? trimmed.replace(/\\/g, "/").replace(/^\.\//, "")
81
+ : trimmed
82
+ .split("/")
83
+ .map((part) => slugify(part))
84
+ .join("/");
85
+ }
86
+
87
+ const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\]]*))?\]\]/g;
88
+ const FENCE_RE = /^[ \t]*(`{3,}|~{3,})(.*)$/gm;
89
+ /** A `## Raw` heading on a line of its own — not `## Rawhide`. */
90
+ const RAW_HEADING_RE = new RegExp(`^${RAW_NOTES_HEADING}[ \\t]*\\r?$`, "m");
91
+
92
+ /**
93
+ * Half-open `[start, end)` ranges covering fenced code blocks.
94
+ *
95
+ * CommonMark, not "every third-backtick-line toggles": a fence is closed only
96
+ * by the **same marker character**, at **at least the opening length**, and
97
+ * with no info string. Treating fences as a blind alternation gets two cases
98
+ * backwards — a `~~~` nested inside a ``` block would close it, and a ```
99
+ * inside a ```` block would too — and each mistake ends the protected region
100
+ * early, exposing example links in documentation to rewriting.
101
+ */
102
+ function fenceRanges(body: string): [number, number][] {
103
+ const ranges: [number, number][] = [];
104
+ let open: { at: number; marker: string; length: number } | null = null;
105
+ for (const match of body.matchAll(FENCE_RE)) {
106
+ const run = match[1]!;
107
+ // Group 2 is `(.*)`, which always participates: the info string.
108
+ const info = match[2]!;
109
+ if (open === null) {
110
+ open = { at: match.index, marker: run[0]!, length: run.length };
111
+ } else if (run[0] === open.marker && run.length >= open.length && info.trim() === "") {
112
+ ranges.push([open.at, match.index + match[0].length]);
113
+ open = null;
114
+ }
115
+ }
116
+ // An unterminated fence runs to the end of the body, the way a renderer
117
+ // treats it.
118
+ if (open !== null) ranges.push([open.at, body.length]);
119
+ return ranges;
120
+ }
121
+
122
+ /**
123
+ * The byte offset where the `## Raw` tail begins, or the body length when
124
+ * there is none.
125
+ *
126
+ * The tail is append-only and verbatim — "NEVER edit below this line" is
127
+ * written into every note that has one. A link inside a user's dictation is
128
+ * *their* text, quoted; repairing it would edit words the vault promises not
129
+ * to touch.
130
+ *
131
+ * Fence-aware, and for the opposite reason to everything else here: a note
132
+ * *documenting* the raw-tail convention contains `## Raw` inside a code
133
+ * sample, and treating that as the real tail would silently protect — and so
134
+ * refuse to repair — every link below it. Matching a whole heading line also
135
+ * keeps `## Rawhide` from starting a tail.
136
+ */
137
+ function rawTailStart(body: string, fences: readonly [number, number][]): number {
138
+ let from = 0;
139
+ for (;;) {
140
+ const rest = body.slice(from);
141
+ const hit = RAW_HEADING_RE.exec(rest);
142
+ if (hit === null) return body.length;
143
+ const at = from + hit.index;
144
+ const fence = fences.find(([a, b]) => at >= a && at < b);
145
+ if (fence === undefined) return at;
146
+ from = fence[1]; // skip the whole fenced block and keep looking
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Every repairable `[[wiki-link]]` in a body, in order.
152
+ *
153
+ * Skips the `## Raw` tail and fenced code blocks. `extractWikilinks` does
154
+ * neither, on purpose: it feeds the graph, where over-reporting a link is
155
+ * harmless. Here the output drives a rewrite, so a `[[…]]` inside a code
156
+ * sample is a string literal and must stay one.
157
+ */
158
+ export function scanLinks(body: string): LinkOccurrence[] {
159
+ const fences = fenceRanges(body);
160
+ const limit = rawTailStart(body, fences);
161
+ const out: LinkOccurrence[] = [];
162
+ for (const match of body.matchAll(WIKILINK_RE)) {
163
+ const start = match.index;
164
+ if (start >= limit) break;
165
+ if (fences.some(([a, b]) => start >= a && start < b)) continue;
166
+ // Group 1 is mandatory in WIKILINK_RE, and `split` always yields at
167
+ // least one segment: both `!`s below are the regex's guarantee, not
168
+ // optimism.
169
+ const rawTarget = match[1]!;
170
+ if (rawTarget.trim().length === 0) continue;
171
+ out.push({
172
+ start,
173
+ end: start + match[0].length,
174
+ rawTarget: rawTarget.trim(),
175
+ target: normalizeTarget(rawTarget),
176
+ alias: match[2],
177
+ });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /**
183
+ * Rewrite link targets in a body. `resolve` returns the new slug for a
184
+ * target, or null to leave the link alone.
185
+ *
186
+ * The alias is preserved — `[[Quarterly Roadmap]]` becomes
187
+ * `[[planning/roadmap-2026|Quarterly Roadmap]]`, and `[[Quarterly Roadmap|Q2]]`
188
+ * becomes `[[planning/roadmap-2026|Q2]]` — so the rendered
189
+ * prose is byte-identical before and after a repair. A human rereading the
190
+ * note sees no diff; only the graph changes.
191
+ */
192
+ export function rewriteLinks(body: string, resolve: (target: string) => string | null): { body: string; changed: number } {
193
+ const occurrences = scanLinks(body);
194
+ let out = "";
195
+ let cursor = 0;
196
+ let changed = 0;
197
+ for (const occ of occurrences) {
198
+ const to = resolve(occ.target);
199
+ if (to === null || to === occ.target) continue;
200
+ const alias = occ.alias ?? occ.rawTarget;
201
+ out += body.slice(cursor, occ.start) + `[[${to}|${alias}]]`;
202
+ cursor = occ.end;
203
+ changed++;
204
+ }
205
+ return { body: out + body.slice(cursor), changed };
206
+ }
207
+
208
+ /** One link that resolves to exactly one note and can be rewritten. */
209
+ export interface LinkFix {
210
+ /** The note containing the stale link. */
211
+ slug: string;
212
+ /** The stale target, normalised. */
213
+ from: string;
214
+ /** The note it unambiguously means. */
215
+ to: string;
216
+ /** Which ladder rung resolved it. */
217
+ rule: "basename" | "title";
218
+ /** Occurrences of this target in this note. */
219
+ count: number;
220
+ }
221
+
222
+ /** A stale link with more than one candidate: reported, never repaired. */
223
+ export interface AmbiguousLink {
224
+ slug: string;
225
+ target: string;
226
+ candidates: string[];
227
+ }
228
+
229
+ /** A stale link with no candidate at all, and who points at it. */
230
+ export interface UnresolvableLink {
231
+ target: string;
232
+ /** Note slugs referencing it, sorted. */
233
+ notes: string[];
234
+ }
235
+
236
+ /** The result of one audit pass over a vault. */
237
+ export interface LinkAudit {
238
+ /** Every link occurrence considered (raw tail and code fences excluded). */
239
+ total: number;
240
+ /** Occurrences already pointing at a real note or artifact. */
241
+ resolved: number;
242
+ /** Unambiguously repairable, sorted by note then target. */
243
+ fixable: LinkFix[];
244
+ /** Multiple candidates — a human has to choose. */
245
+ ambiguous: AmbiguousLink[];
246
+ /** No candidate: the note was never written. */
247
+ unresolvable: UnresolvableLink[];
248
+ }
249
+
250
+ /** The slice of a vault snapshot the audit needs. */
251
+ export interface LinkAuditInput {
252
+ notes: readonly Pick<Note, "slug" | "title" | "body">[];
253
+ artifacts?: readonly Pick<HtmlArtifact, "slug">[];
254
+ }
255
+
256
+ function indexBy<T>(items: readonly T[], key: (item: T) => string): Map<string, string[]> {
257
+ const out = new Map<string, string[]>();
258
+ for (const item of items) {
259
+ const k = key(item);
260
+ const list = out.get(k);
261
+ if (list) list.push((item as { slug: string }).slug);
262
+ else out.set(k, [(item as { slug: string }).slug]);
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Audit every wiki-link in a vault. Pure: no I/O, no clock, no randomness —
269
+ * the same vault always produces the same audit, which is what makes the
270
+ * repair reviewable.
271
+ */
272
+ export function auditLinks(input: LinkAuditInput): LinkAudit {
273
+ const notes = input.notes;
274
+ const slugs = new Set(notes.map((n) => n.slug));
275
+ const artifactSlugs = new Set((input.artifacts ?? []).map((a) => a.slug));
276
+ const byBasename = indexBy(notes, (n) => n.slug.split("/").pop()!);
277
+ const byTitle = indexBy(notes, (n) => slugify(n.title));
278
+
279
+ let total = 0;
280
+ let resolved = 0;
281
+ // Keyed `slug\u0000target` so repeated occurrences of the same stale link
282
+ // in one note collapse into a single fix carrying a count.
283
+ const fixes = new Map<string, LinkFix>();
284
+ const ambiguous: AmbiguousLink[] = [];
285
+ const unresolvable = new Map<string, Set<string>>();
286
+
287
+ for (const note of notes) {
288
+ const seenAmbiguous = new Set<string>();
289
+ for (const occ of scanLinks(note.body)) {
290
+ total++;
291
+ if (slugs.has(occ.target) || artifactSlugs.has(occ.target)) {
292
+ resolved++;
293
+ continue;
294
+ }
295
+ const basename = occ.target.split("/").pop()!;
296
+ const byBase = byBasename.get(basename) ?? [];
297
+ const byTtl = byTitle.get(basename) ?? [];
298
+ const rule: LinkFix["rule"] | null = byBase.length === 1 ? "basename" : byTtl.length === 1 ? "title" : null;
299
+ const to = rule === "basename" ? byBase[0] : rule === "title" ? byTtl[0] : undefined;
300
+ if (rule !== null && to !== undefined) {
301
+ const key = `${note.slug}\u0000${occ.target}`;
302
+ const existing = fixes.get(key);
303
+ if (existing) existing.count++;
304
+ else fixes.set(key, { slug: note.slug, from: occ.target, to, rule, count: 1 });
305
+ continue;
306
+ }
307
+ const candidates = [...new Set([...byBase, ...byTtl])].sort();
308
+ if (candidates.length > 1) {
309
+ if (!seenAmbiguous.has(occ.target)) {
310
+ seenAmbiguous.add(occ.target);
311
+ ambiguous.push({ slug: note.slug, target: occ.target, candidates });
312
+ }
313
+ continue;
314
+ }
315
+ const refs = unresolvable.get(occ.target);
316
+ if (refs) refs.add(note.slug);
317
+ else unresolvable.set(occ.target, new Set([note.slug]));
318
+ }
319
+ }
320
+
321
+ return {
322
+ total,
323
+ resolved,
324
+ fixable: [...fixes.values()].sort((a, b) => a.slug.localeCompare(b.slug) || a.from.localeCompare(b.from)),
325
+ ambiguous: ambiguous.sort((a, b) => a.slug.localeCompare(b.slug) || a.target.localeCompare(b.target)),
326
+ unresolvable: [...unresolvable.entries()]
327
+ .map(([target, notes]) => ({ target, notes: [...notes].sort() }))
328
+ .sort((a, b) => a.target.localeCompare(b.target)),
329
+ };
330
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Suggest connections between notes that are related but not linked.
3
+ *
4
+ * The companion to `repair.ts`, and deliberately the opposite kind of tool.
5
+ * Repair fixes a link that exists and points wrong: the answer is exact, so
6
+ * repair writes. This finds links that were never written: the answer is a
7
+ * *ranking*, so this only ever reports. A soft signal must not produce a hard
8
+ * `[[link]]` — once written into a body it is indistinguishable from one the
9
+ * user wrote deliberately, and a wrong link is worse than a missing one
10
+ * because a missing link is visible and a wrong one is believed.
11
+ *
12
+ * ## The signal
13
+ *
14
+ * Classic IDF-weighted cosine over every term a note carries — title words,
15
+ * tags, and body words, all in one bag. Terms are weighted by how rare they
16
+ * are *in this vault*:
17
+ *
18
+ * idf(t) = ln(N / df(t))
19
+ *
20
+ * That one line is why nothing here is domain-specific. A tag on 2400 of
21
+ * 2500 notes scores ~0 and cannot connect anything; a ticket id on 6 notes
22
+ * scores high and connects them strongly. The vault decides what is
23
+ * meaningful by frequency, so there is no list of "important" fields to keep
24
+ * up to date, and a vault of recipes clusters by ingredient exactly as a
25
+ * vault of meeting notes clusters by project. No field names appear in this
26
+ * file for the same reason.
27
+ *
28
+ * ## Why cosine, not Jaccard
29
+ *
30
+ * Weighted Jaccard divides by the union, so it punishes length mismatch: a
31
+ * short journal entry and a long profile about the same subject score low
32
+ * purely for differing in size. Cosine normalises each note by its own
33
+ * magnitude, which is the behaviour wanted when comparing documents of
34
+ * uneven length.
35
+ *
36
+ * Pure: no I/O, no clock.
37
+ */
38
+
39
+ import { slugify } from "../slug";
40
+ import type { Note } from "../types";
41
+ import { scanLinks } from "./repair";
42
+
43
+ /** One suggested connection, with the evidence for it. */
44
+ export interface LinkSuggestion {
45
+ /** The two notes, ordered so `a` < `b` — a suggestion has no direction. */
46
+ a: string;
47
+ b: string;
48
+ /** Cosine similarity over IDF-weighted terms, 0..1. */
49
+ score: number;
50
+ /**
51
+ * The terms that earned the score, strongest first.
52
+ *
53
+ * The most important field here. A bare number is unreviewable — "0.16,
54
+ * trust me" — while "they share `acme-1234`, `release-pipeline`" is a claim
55
+ * a human can accept or reject in a second. A suggestion nobody can check
56
+ * is a suggestion nobody should act on.
57
+ */
58
+ shared: string[];
59
+ }
60
+
61
+ /** What {@link suggestLinks} was asked for, and what it found. */
62
+ export interface SuggestionReport {
63
+ /** Notes considered (those with at least one distinctive term). */
64
+ considered: number;
65
+ /** Suggestions, strongest first, then by slug for determinism. */
66
+ suggestions: LinkSuggestion[];
67
+ }
68
+
69
+ export interface SuggestOptions {
70
+ /** Only suggest neighbours of this note. Omit for vault-wide pairs. */
71
+ slug?: string;
72
+ /** Maximum suggestions returned. Default 20. */
73
+ limit?: number;
74
+ /**
75
+ * Ignore terms carried by more than this fraction of notes. Default 0.05.
76
+ *
77
+ * Common terms are the vocabulary of a *genre* — every 1:1 note says
78
+ * "sprint", every recipe says "oven" — so they make unrelated notes of the
79
+ * same shape look related. Rare terms are what a note is actually about.
80
+ *
81
+ * Only a ceiling, never a floor: see {@link MIN_TERM_CEILING}.
82
+ */
83
+ maxDocFrequency?: number;
84
+ /** Suggestions below this score are dropped. Default 0.02. */
85
+ minScore?: number;
86
+ /** How many shared terms to cite as evidence. Default 8. */
87
+ evidence?: number;
88
+ }
89
+
90
+ /** Words too structural to carry meaning in any vault, in any domain. */
91
+ const TERM_RE = /[\p{L}][\p{L}\p{N}_-]{2,}/gu;
92
+
93
+ /**
94
+ * Smallest document-frequency ceiling, whatever `maxDocFrequency` computes.
95
+ *
96
+ * A pure percentage collapses on a small vault: at 40 notes, 5% floors to 2,
97
+ * so a term shared by three notes is discarded as "common" and the tool goes
98
+ * silent exactly where a user is most likely to try it. This floor keeps a
99
+ * young vault working while staying far below the point where a term is
100
+ * genuinely genre vocabulary — on any vault large enough for the percentage
101
+ * to exceed it, the percentage wins and this never applies.
102
+ */
103
+ export const MIN_TERM_CEILING = 10;
104
+
105
+ /**
106
+ * Split a note into terms.
107
+ *
108
+ * Title and tags are folded into the same bag as the body rather than being
109
+ * weighted separately: a term's importance is already expressed by its
110
+ * rarity, so boosting "the title" would be a second, redundant opinion about
111
+ * significance — and a wrong one whenever a title is generic ("Notes",
112
+ * "Index") or a body mentions the decisive term once.
113
+ */
114
+ function terms(note: Pick<Note, "title" | "tags" | "body">): Set<string> {
115
+ const text = `${note.title} ${note.tags.join(" ")} ${note.body}`;
116
+ const out = new Set<string>();
117
+ for (const match of text.matchAll(TERM_RE)) out.add(match[0].toLowerCase());
118
+ return out;
119
+ }
120
+
121
+ /** The slice of a vault this needs. */
122
+ export interface SuggestInput {
123
+ notes: readonly Pick<Note, "slug" | "title" | "tags" | "body">[];
124
+ }
125
+
126
+ /**
127
+ * Rank pairs of notes that share distinctive vocabulary but no link.
128
+ *
129
+ * Already-linked pairs are excluded in both directions: the point is to
130
+ * surface connections the vault is *missing*, and re-suggesting a link the
131
+ * user already wrote is noise that buries the real findings.
132
+ */
133
+ export function suggestLinks(input: SuggestInput, options: SuggestOptions = {}): SuggestionReport {
134
+ const limit = options.limit ?? 20;
135
+ const maxDf = options.maxDocFrequency ?? 0.05;
136
+ const minScore = options.minScore ?? 0.02;
137
+ const evidenceCount = options.evidence ?? 8;
138
+ const notes = input.notes;
139
+ const n = notes.length;
140
+
141
+ const df = new Map<string, number>();
142
+ const bags = new Map<string, Set<string>>();
143
+ for (const note of notes) {
144
+ const bag = terms(note);
145
+ bags.set(note.slug, bag);
146
+ for (const term of bag) df.set(term, (df.get(term) ?? 0) + 1);
147
+ }
148
+
149
+ // A term in one note connects nothing; a term in most notes connects
150
+ // everything. Only what lies between can carry a signal.
151
+ const ceiling = Math.max(MIN_TERM_CEILING, Math.floor(n * maxDf));
152
+ const idf = new Map<string, number>();
153
+ for (const [term, count] of df) {
154
+ if (count < 2 || count > ceiling) continue;
155
+ idf.set(term, Math.log(n / count));
156
+ }
157
+
158
+ // Unit-normalised vectors, so the dot product below *is* the cosine.
159
+ const vectors = new Map<string, Map<string, number>>();
160
+ for (const note of notes) {
161
+ const vec = new Map<string, number>();
162
+ let norm = 0;
163
+ // Every note got a bag above; the `!` is that loop's invariant.
164
+ for (const term of bags.get(note.slug)!) {
165
+ const weight = idf.get(term);
166
+ if (weight === undefined) continue;
167
+ vec.set(term, weight);
168
+ norm += weight * weight;
169
+ }
170
+ if (norm === 0) continue;
171
+ const len = Math.sqrt(norm);
172
+ for (const [term, weight] of vec) vec.set(term, weight / len);
173
+ vectors.set(note.slug, vec);
174
+ }
175
+
176
+ // Existing links, both directions, so a connection the user already made
177
+ // is never offered back to them.
178
+ const linked = new Set<string>();
179
+ const bySlug = new Set(notes.map((note) => note.slug));
180
+ const byBasename = new Map<string, string[]>();
181
+ const byTitle = new Map<string, string[]>();
182
+ for (const note of notes) {
183
+ const base = note.slug.split("/").pop()!;
184
+ byBasename.set(base, [...(byBasename.get(base) ?? []), note.slug]);
185
+ const title = slugify(note.title);
186
+ if (title.length > 0) byTitle.set(title, [...(byTitle.get(title) ?? []), note.slug]);
187
+ }
188
+ for (const note of notes) {
189
+ for (const link of scanLinks(note.body)) {
190
+ // The same three-rung ladder `auditLinks` walks — exact slug, unique
191
+ // basename, unique title. It has to be all three: a link repair would
192
+ // resolve counts as a connection that already exists, so resolving a
193
+ // rung short here would suggest a pair the vault has already linked.
194
+ const byBase = byBasename.get(link.target) ?? [];
195
+ const byTtl = byTitle.get(link.target) ?? [];
196
+ const target = bySlug.has(link.target)
197
+ ? link.target
198
+ : byBase.length === 1
199
+ ? byBase[0]!
200
+ : byTtl.length === 1
201
+ ? byTtl[0]!
202
+ : null;
203
+ if (target !== null) linked.add(pairKey(note.slug, target));
204
+ }
205
+ }
206
+
207
+ // Candidate generation through an inverted index on distinctive terms
208
+ // only. Comparing every pair is O(n²) and mostly compares notes with
209
+ // nothing in common; postings lists for rare terms are short by
210
+ // definition, so this touches only pairs that can actually score.
211
+ const postings = new Map<string, string[]>();
212
+ for (const [slug, vec] of vectors) {
213
+ for (const term of vec.keys()) postings.set(term, [...(postings.get(term) ?? []), slug]);
214
+ }
215
+
216
+ const focus = options.slug;
217
+ if (focus !== undefined && !vectors.has(focus)) {
218
+ return { considered: vectors.size, suggestions: [] };
219
+ }
220
+
221
+ const scores = new Map<string, number>();
222
+ const sources = focus !== undefined ? [focus] : [...vectors.keys()];
223
+ // Vault-wide, every pair is reachable from both of its ends, so each
224
+ // contribution would be counted twice and the "cosine" could exceed 1.
225
+ // Accumulating one direction only keeps the score a real cosine; a focused
226
+ // run walks one source, so it never double-counts to begin with.
227
+ const oneWay = focus === undefined;
228
+ for (const slug of sources) {
229
+ const vec = vectors.get(slug)!;
230
+ for (const [term, weight] of vec) {
231
+ // `term` survived the idf filter, so it has a postings list containing
232
+ // at least this note.
233
+ for (const other of postings.get(term)!) {
234
+ if (other === slug || (oneWay && other < slug)) continue;
235
+ const key = pairKey(slug, other);
236
+ if (linked.has(key)) continue;
237
+ // `other` came from this term's postings list, so both lookups are
238
+ // guaranteed present — the `!`s are the index's invariant.
239
+ scores.set(key, (scores.get(key) ?? 0) + weight * vectors.get(other)!.get(term)!);
240
+ }
241
+ }
242
+ }
243
+
244
+ const suggestions: LinkSuggestion[] = [];
245
+ for (const [key, score] of scores) {
246
+ if (score < minScore) continue;
247
+ const [a, b] = key.split("\u0000") as [string, string];
248
+ suggestions.push({ a, b, score, shared: sharedTerms(vectors.get(a)!, vectors.get(b)!, evidenceCount) });
249
+ }
250
+ suggestions.sort((x, y) => y.score - x.score || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
251
+
252
+ return { considered: vectors.size, suggestions: suggestions.slice(0, limit) };
253
+ }
254
+
255
+ /** Order-independent key for an unordered pair. */
256
+ function pairKey(a: string, b: string): string {
257
+ return a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
258
+ }
259
+
260
+ /** The terms contributing most to a pair's score, strongest first. */
261
+ function sharedTerms(a: Map<string, number>, b: Map<string, number>, count: number): string[] {
262
+ const shared: [string, number][] = [];
263
+ for (const [term, weight] of a) {
264
+ const other = b.get(term);
265
+ if (other !== undefined) shared.push([term, weight * other]);
266
+ }
267
+ return shared
268
+ .sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))
269
+ .slice(0, count)
270
+ .map(([term]) => term);
271
+ }
272
+
273
+ /**
274
+ * Slugify for comparison. Exported so the adapter can echo a target the way
275
+ * the resolver would see it.
276
+ */
277
+ export function suggestionTarget(slug: string): string {
278
+ return slug.split("/").map(slugify).join("/");
279
+ }