@gmickel/gno 1.30.4 → 1.30.6

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,368 @@
1
+ /**
2
+ * Seed-scoped one-hop graph neighbor resolution for query-time expansion.
3
+ * Avoids collection-wide getGraph correlated link resolution.
4
+ *
5
+ * @module src/store/sqlite/graph-neighbors
6
+ */
7
+
8
+ import type { Database } from "bun:sqlite";
9
+
10
+ import type {
11
+ GetGraphNeighborsOptions,
12
+ GraphEdgeAudit,
13
+ GraphEdgeConfidence,
14
+ GraphLink,
15
+ GraphLinkType,
16
+ GraphNeighborsResult,
17
+ } from "../types";
18
+
19
+ import {
20
+ classifyResolvedGraphEdge,
21
+ mergeGraphEdgeAudit,
22
+ } from "../../core/graph-edge-confidence";
23
+ import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
24
+ import { resolveGraphLinkTargets } from "./graph-link-resolver";
25
+
26
+ const MAX_SEED_DOCUMENTS = 5;
27
+ const DEFAULT_EDGE_LIMIT = 10_000;
28
+
29
+ interface SeedDocRow {
30
+ id: number;
31
+ docid: string;
32
+ title: string | null;
33
+ rel_path: string;
34
+ collection: string;
35
+ }
36
+
37
+ interface ResolvedEdgeRow {
38
+ source_docid: string;
39
+ target_docid: string;
40
+ link_type: "wiki" | "markdown";
41
+ match_rank: number | null;
42
+ match_count: number | null;
43
+ }
44
+
45
+ interface RawLinkRow {
46
+ id: number;
47
+ source_docid: string;
48
+ source_collection: string;
49
+ target_ref_norm: string;
50
+ target_collection: string | null;
51
+ link_type: "wiki" | "markdown";
52
+ }
53
+
54
+ const addWikiKeyVariants = (keySet: Set<string>, value: string): void => {
55
+ if (!value) {
56
+ return;
57
+ }
58
+ const base = stripWikiMdExt(value);
59
+ const md = `${base}.md`;
60
+ keySet.add(value);
61
+ keySet.add(base);
62
+ keySet.add(md);
63
+ };
64
+
65
+ const wikiKeysForSeed = (seed: SeedDocRow): Set<string> => {
66
+ const keySet = new Set<string>();
67
+ addWikiKeyVariants(keySet, normalizeWikiName(seed.title ?? ""));
68
+ const relPathKey = normalizeWikiName(seed.rel_path);
69
+ addWikiKeyVariants(keySet, relPathKey);
70
+ const basename = relPathKey.split("/").pop() ?? relPathKey;
71
+ if (basename !== relPathKey) {
72
+ addWikiKeyVariants(keySet, basename);
73
+ }
74
+ return keySet;
75
+ };
76
+
77
+ const matchesWikiKey = (targetRefNorm: string, keys: Set<string>): boolean => {
78
+ for (const key of keys) {
79
+ if (targetRefNorm === key || targetRefNorm.endsWith(`/${key}`)) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ };
85
+
86
+ const loadSeeds = (db: Database, seedDocumentIds: number[]): SeedDocRow[] => {
87
+ const uniqueIds = [...new Set(seedDocumentIds)]
88
+ .filter((id) => Number.isInteger(id) && id > 0)
89
+ .slice(0, MAX_SEED_DOCUMENTS);
90
+ if (uniqueIds.length === 0) {
91
+ return [];
92
+ }
93
+ const placeholders = uniqueIds.map(() => "?").join(",");
94
+ return db
95
+ .query<SeedDocRow, number[]>(
96
+ `SELECT id, docid, title, rel_path, collection
97
+ FROM documents
98
+ WHERE active = 1 AND id IN (${placeholders})
99
+ ORDER BY id ASC`
100
+ )
101
+ .all(...uniqueIds);
102
+ };
103
+
104
+ const collectIncomingCandidateLinks = (
105
+ db: Database,
106
+ seeds: SeedDocRow[],
107
+ collection: string | undefined
108
+ ): { links: RawLinkRow[]; examinedLinkRows: number } => {
109
+ const linksById = new Map<number, RawLinkRow>();
110
+ let examinedLinkRows = 0;
111
+ const seedIdSet = new Set(seeds.map((seed) => seed.id));
112
+
113
+ const wikiKeysByCollection = new Map<string, Set<string>>();
114
+ for (const seed of seeds) {
115
+ const keys = wikiKeysByCollection.get(seed.collection) ?? new Set<string>();
116
+ for (const key of wikiKeysForSeed(seed)) {
117
+ keys.add(key);
118
+ }
119
+ wikiKeysByCollection.set(seed.collection, keys);
120
+ }
121
+
122
+ const targetCollections = [...wikiKeysByCollection.keys()];
123
+ if (targetCollections.length > 0) {
124
+ const collectionPlaceholders = targetCollections.map(() => "?").join(",");
125
+ const wikiRows = db
126
+ .query<RawLinkRow & { source_doc_id: number }, string[]>(
127
+ `SELECT dl.id, dl.source_doc_id, src.docid AS source_docid,
128
+ src.collection AS source_collection, dl.target_ref_norm,
129
+ dl.target_collection, dl.link_type
130
+ FROM doc_links dl
131
+ JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
132
+ WHERE dl.link_type = 'wiki'
133
+ AND (
134
+ (dl.target_collection IS NULL
135
+ AND src.collection IN (${collectionPlaceholders}))
136
+ OR dl.target_collection IN (${collectionPlaceholders})
137
+ )
138
+ ${collection ? "AND src.collection = ?" : ""}`
139
+ )
140
+ .all(
141
+ ...targetCollections,
142
+ ...targetCollections,
143
+ ...(collection ? [collection] : [])
144
+ );
145
+ examinedLinkRows += wikiRows.length;
146
+
147
+ for (const row of wikiRows) {
148
+ const targetCollection = row.target_collection ?? row.source_collection;
149
+ const keys = wikiKeysByCollection.get(targetCollection);
150
+ if (
151
+ keys &&
152
+ !seedIdSet.has(row.source_doc_id) &&
153
+ matchesWikiKey(row.target_ref_norm, keys)
154
+ ) {
155
+ linksById.set(row.id, row);
156
+ }
157
+ }
158
+ }
159
+
160
+ for (const seed of seeds) {
161
+ const mdRows = db
162
+ .query<RawLinkRow & { source_doc_id: number }, string[]>(
163
+ `SELECT dl.id, dl.source_doc_id, src.docid AS source_docid,
164
+ src.collection AS source_collection, dl.target_ref_norm,
165
+ dl.target_collection, dl.link_type
166
+ FROM doc_links dl
167
+ JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
168
+ WHERE dl.link_type = 'markdown'
169
+ AND dl.target_ref_norm = ?
170
+ AND (
171
+ (dl.target_collection IS NULL AND src.collection = ?)
172
+ OR dl.target_collection = ?
173
+ )
174
+ ${collection ? "AND src.collection = ?" : ""}`
175
+ )
176
+ .all(
177
+ seed.rel_path,
178
+ seed.collection,
179
+ seed.collection,
180
+ ...(collection ? [collection] : [])
181
+ );
182
+ examinedLinkRows += mdRows.length;
183
+ for (const row of mdRows) {
184
+ if (!seedIdSet.has(row.source_doc_id)) {
185
+ linksById.set(row.id, row);
186
+ }
187
+ }
188
+ }
189
+
190
+ return { links: [...linksById.values()], examinedLinkRows };
191
+ };
192
+
193
+ const loadRawLinksForSources = (
194
+ db: Database,
195
+ sourceIds: number[],
196
+ collection: string | undefined
197
+ ): RawLinkRow[] => {
198
+ if (sourceIds.length === 0) {
199
+ return [];
200
+ }
201
+ const sourcePlaceholders = sourceIds.map(() => "?").join(",");
202
+ const params: (string | number)[] = [...sourceIds];
203
+ if (collection) {
204
+ params.push(collection);
205
+ }
206
+ return db
207
+ .query<RawLinkRow, (string | number)[]>(
208
+ `SELECT dl.id, src.docid AS source_docid,
209
+ src.collection AS source_collection,
210
+ dl.target_ref_norm, dl.target_collection, dl.link_type
211
+ FROM documents src
212
+ JOIN doc_links dl ON dl.source_doc_id = src.id
213
+ WHERE src.active = 1
214
+ AND src.id IN (${sourcePlaceholders})
215
+ ${collection ? "AND src.collection = ?" : ""}
216
+ ORDER BY src.id ASC, dl.id ASC`
217
+ )
218
+ .all(...params);
219
+ };
220
+
221
+ const resolveRawEdges = (
222
+ db: Database,
223
+ rawRows: RawLinkRow[],
224
+ incomingLinkIds: Set<number>,
225
+ seedIds: Set<number>,
226
+ collection: string | undefined
227
+ ): ResolvedEdgeRow[] => {
228
+ const inScopeRows = rawRows.filter(
229
+ (row) =>
230
+ !collection ||
231
+ (row.target_collection ?? row.source_collection) === collection
232
+ );
233
+ const resolvedTargets = resolveGraphLinkTargets(
234
+ db,
235
+ inScopeRows.map((row) => ({
236
+ targetRefNorm: row.target_ref_norm,
237
+ targetCollection: row.target_collection ?? row.source_collection,
238
+ linkType: row.link_type,
239
+ }))
240
+ );
241
+ const rows: ResolvedEdgeRow[] = [];
242
+ for (const [index, rawRow] of inScopeRows.entries()) {
243
+ const target = resolvedTargets[index];
244
+ if (
245
+ !target ||
246
+ (incomingLinkIds.has(rawRow.id) && !seedIds.has(target.targetId))
247
+ ) {
248
+ continue;
249
+ }
250
+ rows.push({
251
+ source_docid: rawRow.source_docid,
252
+ target_docid: target.targetDocid,
253
+ link_type: rawRow.link_type,
254
+ match_rank: target.matchRank,
255
+ match_count: target.matchCount,
256
+ });
257
+ }
258
+ return rows;
259
+ };
260
+
261
+ const toGraphLinks = (
262
+ rows: ResolvedEdgeRow[],
263
+ limitEdges: number
264
+ ): GraphLink[] => {
265
+ const edgeMap = new Map<
266
+ string,
267
+ {
268
+ type: GraphLinkType;
269
+ weight: number;
270
+ confidence: GraphEdgeConfidence;
271
+ audit: GraphEdgeAudit;
272
+ }
273
+ >();
274
+
275
+ for (const row of rows) {
276
+ const key = `${row.source_docid}:${row.target_docid}:${row.link_type}`;
277
+ const { confidence, audit } = classifyResolvedGraphEdge(
278
+ row.link_type,
279
+ row.match_rank,
280
+ row.match_count
281
+ );
282
+ const existing = edgeMap.get(key);
283
+ if (existing) {
284
+ existing.weight += 1;
285
+ mergeGraphEdgeAudit(existing, confidence, audit);
286
+ } else {
287
+ edgeMap.set(key, {
288
+ type: row.link_type,
289
+ weight: 1,
290
+ confidence,
291
+ audit,
292
+ });
293
+ }
294
+ }
295
+
296
+ return [...edgeMap.entries()]
297
+ .map(([key, val]) => {
298
+ const parts = key.split(":");
299
+ return {
300
+ source: parts[0] ?? "",
301
+ target: parts[1] ?? "",
302
+ type: val.type,
303
+ weight: val.weight,
304
+ confidence: val.confidence,
305
+ audit: val.audit,
306
+ };
307
+ })
308
+ .sort(
309
+ (left, right) =>
310
+ left.source.localeCompare(right.source) ||
311
+ left.target.localeCompare(right.target) ||
312
+ left.type.localeCompare(right.type)
313
+ )
314
+ .slice(0, limitEdges);
315
+ };
316
+
317
+ /**
318
+ * Resolve one-hop explicit/inferred/ambiguous neighbors for a small seed set.
319
+ * Does not compute similarity edges (vector retrieval already supplies those).
320
+ */
321
+ export function queryGraphNeighborsForSeeds(
322
+ db: Database,
323
+ options: GetGraphNeighborsOptions
324
+ ): GraphNeighborsResult {
325
+ const limitEdges = Math.max(
326
+ 1,
327
+ Math.min(50_000, options.limitEdges ?? DEFAULT_EDGE_LIMIT)
328
+ );
329
+ const seeds = loadSeeds(db, options.seedDocumentIds);
330
+ if (seeds.length === 0) {
331
+ return {
332
+ links: [],
333
+ meta: {
334
+ seedDocumentIds: [],
335
+ examinedLinkRows: 0,
336
+ returnedEdges: 0,
337
+ },
338
+ };
339
+ }
340
+
341
+ const seedIds = seeds.map((seed) => seed.id);
342
+ const outgoingLinks = loadRawLinksForSources(db, seedIds, options.collection);
343
+ const { links: incomingLinks, examinedLinkRows: incomingCandidates } =
344
+ collectIncomingCandidateLinks(db, seeds, options.collection);
345
+ const incomingLinkIds = new Set(incomingLinks.map((link) => link.id));
346
+ const rawLinksById = new Map(
347
+ [...outgoingLinks, ...incomingLinks].map((link) => [link.id, link])
348
+ );
349
+ const resolvedRows = resolveRawEdges(
350
+ db,
351
+ [...rawLinksById.values()],
352
+ incomingLinkIds,
353
+ new Set(seedIds),
354
+ options.collection
355
+ );
356
+
357
+ const examinedLinkRows = outgoingLinks.length + incomingCandidates;
358
+ const links = toGraphLinks(resolvedRows, limitEdges);
359
+
360
+ return {
361
+ links,
362
+ meta: {
363
+ seedDocumentIds: seedIds,
364
+ examinedLinkRows,
365
+ returnedEdges: links.length,
366
+ },
367
+ };
368
+ }
@@ -889,6 +889,30 @@ export interface GetGraphOptions {
889
889
  similarTopK?: number;
890
890
  }
891
891
 
892
+ /** Options for seed-scoped one-hop graph neighbor lookup (query-time expansion). */
893
+ export interface GetGraphNeighborsOptions {
894
+ /** Seed document primary keys; implementations clamp to a small bound (≤5). */
895
+ seedDocumentIds: number[];
896
+ /** Filter neighbors to a single collection */
897
+ collection?: string;
898
+ /** Max edges to return (default 10000) */
899
+ limitEdges?: number;
900
+ }
901
+
902
+ /** Result of seed-scoped one-hop graph neighbor lookup. */
903
+ export interface GraphNeighborsResult {
904
+ /** One-hop wiki/markdown edges touching the seeds (no similarity edges). */
905
+ links: GraphLink[];
906
+ meta: {
907
+ /** Seeds that were actually resolved (active docs only). */
908
+ seedDocumentIds: number[];
909
+ /** Link rows examined during scoped resolution (for latency regressions). */
910
+ examinedLinkRows: number;
911
+ /** Edges returned after merge/cap. */
912
+ returnedEdges: number;
913
+ };
914
+ }
915
+
892
916
  /** Direction for bounded typed-edge graph traversal. */
893
917
  export type GraphQueryDirection = "out" | "in" | "both";
894
918
 
@@ -1973,6 +1997,15 @@ export interface StorePort {
1973
1997
  */
1974
1998
  getGraph(options?: GetGraphOptions): Promise<StoreResult<GraphResult>>;
1975
1999
 
2000
+ /**
2001
+ * Seed-scoped one-hop graph neighbors for query-time expansion.
2002
+ * Resolves only outgoing/backlink edges for ≤5 seed document IDs.
2003
+ * Optional: mocks may omit this and fall back to getGraph.
2004
+ */
2005
+ getGraphNeighborsForSeeds?(
2006
+ options: GetGraphNeighborsOptions
2007
+ ): Promise<StoreResult<GraphNeighborsResult>>;
2008
+
1976
2009
  // ─────────────────────────────────────────────────────────────────────────
1977
2010
  // Status
1978
2011
  // ─────────────────────────────────────────────────────────────────────────
@@ -1 +0,0 @@
1
- 306591bd80b19de1578e718299e03d524572dc156b3d97f4ea451b9ab3604d2e gno-browser-clipper-v1.30.4.zip