@gmickel/gno 1.30.5 → 1.30.7

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.
@@ -55,10 +55,12 @@ import type {
55
55
  EgressAuditStatusResult,
56
56
  FtsResult,
57
57
  FtsSearchOptions,
58
+ GetGraphNeighborsOptions,
58
59
  GetGraphOptions,
59
60
  GraphEdgeConfidence,
60
61
  GraphEdgeAudit,
61
62
  GraphLinkType,
63
+ GraphNeighborsResult,
62
64
  GraphQueryOptions,
63
65
  GraphQueryTraversalRows,
64
66
  GraphReportNode,
@@ -111,6 +113,10 @@ import {
111
113
  resolveConfiguredEgressPolicy,
112
114
  } from "../../config/types";
113
115
  import { analyzeGraphCommunities } from "../../core/graph-analysis";
116
+ import {
117
+ classifyResolvedGraphEdge,
118
+ mergeGraphEdgeAudit,
119
+ } from "../../core/graph-edge-confidence";
114
120
  import {
115
121
  buildWikiBestMatchSubquery,
116
122
  buildWikiBestRankMatchCountSubquery,
@@ -155,6 +161,7 @@ import {
155
161
  purgeEgressAuditReceipts as purgeStoredEgressAuditReceipts,
156
162
  } from "./egress-audit-store";
157
163
  import { loadFts5Snowball } from "./fts5-snowball";
164
+ import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
158
165
  import {
159
166
  appendExportManifest as appendStoredTraceExportManifest,
160
167
  getBoundedTrace as getBoundedStoredTrace,
@@ -4154,6 +4161,27 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4154
4161
  // Graph
4155
4162
  // ─────────────────────────────────────────────────────────────────────────
4156
4163
 
4164
+ /**
4165
+ * Seed-scoped one-hop neighbors for query-time graph expansion.
4166
+ * Does not rebuild the full collection graph or similarity edges.
4167
+ */
4168
+ async getGraphNeighborsForSeeds(
4169
+ options: GetGraphNeighborsOptions
4170
+ ): Promise<StoreResult<GraphNeighborsResult>> {
4171
+ try {
4172
+ const db = this.ensureOpen();
4173
+ return ok(queryGraphNeighborsForSeeds(db, options));
4174
+ } catch (cause) {
4175
+ return err(
4176
+ "QUERY_FAILED",
4177
+ cause instanceof Error
4178
+ ? cause.message
4179
+ : "Failed to get seed graph neighbors",
4180
+ cause
4181
+ );
4182
+ }
4183
+ }
4184
+
4157
4185
  async getGraph(options?: GetGraphOptions): Promise<StoreResult<GraphResult>> {
4158
4186
  try {
4159
4187
  const db = this.ensureOpen();
@@ -4460,87 +4488,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4460
4488
  const selectedDocids = new Set(nodes.map((node) => node.id));
4461
4489
  const nodeDocids = new Set(nodes.map((node) => node.id));
4462
4490
 
4463
- const confidenceRank: Record<GraphEdgeConfidence, number> = {
4464
- explicit: 1,
4465
- inferred: 2,
4466
- ambiguous: 3,
4467
- similarity: 4,
4468
- };
4469
- const classifyResolvedEdge = (
4470
- linkType: "wiki" | "markdown",
4471
- matchRank: number | null,
4472
- matchCount: number | null
4473
- ): { confidence: GraphEdgeConfidence; audit: GraphEdgeAudit } => {
4474
- if (linkType === "markdown") {
4475
- return {
4476
- confidence: "explicit",
4477
- audit: { resolution: "exact-path", matchCount: 1 },
4478
- };
4479
- }
4480
-
4481
- const count = matchCount ?? 0;
4482
- if (count > 1) {
4483
- return {
4484
- confidence: "ambiguous",
4485
- audit: {
4486
- resolution: "ambiguous-fallback",
4487
- matchCount: count,
4488
- },
4489
- };
4490
- }
4491
-
4492
- if (matchRank === 1 || matchRank === 2) {
4493
- return {
4494
- confidence: "explicit",
4495
- audit: { resolution: "exact-title", matchCount: count || 1 },
4496
- };
4497
- }
4498
- if (matchRank === 5 || matchRank === 6) {
4499
- return {
4500
- confidence: "explicit",
4501
- audit: { resolution: "exact-path", matchCount: count || 1 },
4502
- };
4503
- }
4504
-
4505
- return {
4506
- confidence: "inferred",
4507
- audit: { resolution: "path-fallback", matchCount: count || 1 },
4508
- };
4509
- };
4510
- const mergeAudit = (
4511
- current: {
4512
- type: GraphLinkType;
4513
- weight: number;
4514
- confidence: GraphEdgeConfidence;
4515
- audit: GraphEdgeAudit;
4516
- },
4517
- nextConfidence: GraphEdgeConfidence,
4518
- nextAudit: GraphEdgeAudit
4519
- ): void => {
4520
- if (
4521
- confidenceRank[nextConfidence] < confidenceRank[current.confidence]
4522
- ) {
4523
- current.confidence = nextConfidence;
4524
- current.audit = {
4525
- ...nextAudit,
4526
- matchCount: Math.max(
4527
- current.audit.matchCount ?? 0,
4528
- nextAudit.matchCount ?? 0
4529
- ),
4530
- };
4531
- return;
4532
- }
4533
- if (
4534
- nextAudit.matchCount !== undefined &&
4535
- (current.audit.matchCount ?? 0) < nextAudit.matchCount
4536
- ) {
4537
- current.audit = {
4538
- ...current.audit,
4539
- matchCount: nextAudit.matchCount,
4540
- };
4541
- }
4542
- };
4543
-
4544
4491
  const edgeMap = new Map<
4545
4492
  string,
4546
4493
  {
@@ -4558,7 +4505,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4558
4505
  continue;
4559
4506
  }
4560
4507
  const key = `${row.source_docid}:${row.target_docid}:${row.link_type}`;
4561
- const { confidence, audit } = classifyResolvedEdge(
4508
+ const { confidence, audit } = classifyResolvedGraphEdge(
4562
4509
  row.link_type,
4563
4510
  row.match_rank,
4564
4511
  row.match_count
@@ -4566,7 +4513,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4566
4513
  const existing = edgeMap.get(key);
4567
4514
  if (existing) {
4568
4515
  existing.weight += 1;
4569
- mergeAudit(existing, confidence, audit);
4516
+ mergeGraphEdgeAudit(existing, confidence, audit);
4570
4517
  } else {
4571
4518
  edgeMap.set(key, {
4572
4519
  type: row.link_type,
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Batched wiki/markdown target resolution for seed-scoped graph expansion.
3
+ *
4
+ * @module src/store/sqlite/graph-link-resolver
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import { stripWikiMdExt } from "../../core/links";
10
+
11
+ export interface GraphLinkTarget {
12
+ targetRefNorm: string;
13
+ targetCollection: string;
14
+ linkType: "wiki" | "markdown";
15
+ }
16
+
17
+ export interface ResolvedGraphLinkTarget {
18
+ targetId: number;
19
+ targetDocid: string;
20
+ matchRank: number;
21
+ matchCount: number;
22
+ }
23
+
24
+ const MAX_SQL_PARAMS = 900;
25
+
26
+ const chunkArray = <T>(items: T[], chunkSize: number): T[][] => {
27
+ const chunks: T[][] = [];
28
+ for (let offset = 0; offset < items.length; offset += chunkSize) {
29
+ chunks.push(items.slice(offset, offset + chunkSize));
30
+ }
31
+ return chunks;
32
+ };
33
+
34
+ const suffixMatch = (targetExpr: string, valueExpr: string): string =>
35
+ `(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
36
+ AND (length(${targetExpr}) = length(${valueExpr})
37
+ OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
38
+
39
+ const resolveUniqueGraphLinkTargets = (
40
+ db: Database,
41
+ targets: GraphLinkTarget[]
42
+ ): Array<ResolvedGraphLinkTarget | null> => {
43
+ const results: Array<ResolvedGraphLinkTarget | null> = Array.from(
44
+ { length: targets.length },
45
+ () => null
46
+ );
47
+ const wikiTargets: Array<{
48
+ idx: number;
49
+ collection: string;
50
+ baseRef: string;
51
+ baseRefMd: string;
52
+ }> = [];
53
+ const markdownTargets: Array<{
54
+ idx: number;
55
+ collection: string;
56
+ relPath: string;
57
+ }> = [];
58
+
59
+ for (const [idx, target] of targets.entries()) {
60
+ if (target.linkType === "wiki") {
61
+ const baseRef = stripWikiMdExt(target.targetRefNorm);
62
+ wikiTargets.push({
63
+ idx,
64
+ collection: target.targetCollection,
65
+ baseRef,
66
+ baseRefMd: `${baseRef}.md`,
67
+ });
68
+ } else {
69
+ markdownTargets.push({
70
+ idx,
71
+ collection: target.targetCollection,
72
+ relPath: target.targetRefNorm,
73
+ });
74
+ }
75
+ }
76
+
77
+ const titleExpr = "lower(trim(d.title))";
78
+ const relExpr = "lower(d.rel_path)";
79
+ const baseRefExpr = "t.base_ref";
80
+ const baseRefMdExpr = "t.base_ref_md";
81
+ const wikiWhere = `
82
+ ${titleExpr} = ${baseRefExpr}
83
+ OR ${titleExpr} = ${baseRefMdExpr}
84
+ OR ${suffixMatch(baseRefExpr, titleExpr)}
85
+ OR ${suffixMatch(baseRefMdExpr, `${titleExpr} || '.md'`)}
86
+ OR ${relExpr} = ${baseRefExpr}
87
+ OR ${relExpr} = ${baseRefMdExpr}
88
+ OR ${suffixMatch(relExpr, baseRefMdExpr)}
89
+ OR ${suffixMatch(relExpr, baseRefExpr)}
90
+ OR ${suffixMatch(baseRefMdExpr, relExpr)}
91
+ OR ${suffixMatch(baseRefExpr, relExpr)}
92
+ `;
93
+ const wikiRank = `CASE
94
+ WHEN ${titleExpr} = ${baseRefExpr} THEN 1
95
+ WHEN ${titleExpr} = ${baseRefMdExpr} THEN 2
96
+ WHEN ${suffixMatch(baseRefExpr, titleExpr)} THEN 3
97
+ WHEN ${suffixMatch(baseRefMdExpr, `${titleExpr} || '.md'`)} THEN 4
98
+ WHEN ${relExpr} = ${baseRefExpr} THEN 5
99
+ WHEN ${relExpr} = ${baseRefMdExpr} THEN 6
100
+ WHEN ${suffixMatch(relExpr, baseRefMdExpr)} THEN 7
101
+ WHEN ${suffixMatch(relExpr, baseRefExpr)} THEN 8
102
+ WHEN ${suffixMatch(baseRefMdExpr, relExpr)} THEN 9
103
+ WHEN ${suffixMatch(baseRefExpr, relExpr)} THEN 10
104
+ ELSE 99
105
+ END`;
106
+
107
+ const wikiBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 4));
108
+ for (const batch of chunkArray(wikiTargets, wikiBatchSize)) {
109
+ const valuesClause = batch.map(() => "(?, ?, ?, ?)").join(", ");
110
+ const params = batch.flatMap((target) => [
111
+ target.idx,
112
+ target.collection,
113
+ target.baseRef,
114
+ target.baseRefMd,
115
+ ]);
116
+ const rows = db
117
+ .query<
118
+ {
119
+ idx: number;
120
+ target_id: number;
121
+ target_docid: string;
122
+ match_rank: number;
123
+ match_count: number;
124
+ },
125
+ (string | number)[]
126
+ >(
127
+ `WITH targets(idx, collection, base_ref, base_ref_md) AS (
128
+ VALUES ${valuesClause}
129
+ ),
130
+ candidates AS (
131
+ SELECT t.idx, d.id AS target_id, d.docid AS target_docid,
132
+ ${wikiRank} AS match_rank
133
+ FROM targets t
134
+ JOIN documents d ON d.active = 1 AND d.collection = t.collection
135
+ WHERE ${wikiWhere}
136
+ ),
137
+ best_candidates AS (
138
+ SELECT candidates.*
139
+ FROM candidates
140
+ JOIN (
141
+ SELECT idx, MIN(match_rank) AS best_rank
142
+ FROM candidates
143
+ GROUP BY idx
144
+ ) best
145
+ ON best.idx = candidates.idx
146
+ AND best.best_rank = candidates.match_rank
147
+ ),
148
+ ranked AS (
149
+ SELECT *,
150
+ COUNT(*) OVER (PARTITION BY idx) AS match_count,
151
+ ROW_NUMBER() OVER (PARTITION BY idx ORDER BY target_id) AS rn
152
+ FROM best_candidates
153
+ )
154
+ SELECT idx, target_id, target_docid, match_rank, match_count
155
+ FROM ranked
156
+ WHERE rn = 1`
157
+ )
158
+ .all(...params);
159
+
160
+ for (const row of rows) {
161
+ results[row.idx] = {
162
+ targetId: row.target_id,
163
+ targetDocid: row.target_docid,
164
+ matchRank: row.match_rank,
165
+ matchCount: row.match_count,
166
+ };
167
+ }
168
+ }
169
+
170
+ const markdownBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 3));
171
+ for (const batch of chunkArray(markdownTargets, markdownBatchSize)) {
172
+ const valuesClause = batch.map(() => "(?, ?, ?)").join(", ");
173
+ const params = batch.flatMap((target) => [
174
+ target.idx,
175
+ target.collection,
176
+ target.relPath,
177
+ ]);
178
+ const rows = db
179
+ .query<
180
+ { idx: number; target_id: number; target_docid: string },
181
+ (string | number)[]
182
+ >(
183
+ `WITH targets(idx, collection, rel_path) AS (
184
+ VALUES ${valuesClause}
185
+ ),
186
+ ranked AS (
187
+ SELECT t.idx, d.id AS target_id, d.docid AS target_docid,
188
+ ROW_NUMBER() OVER (PARTITION BY t.idx ORDER BY d.id) AS rn
189
+ FROM targets t
190
+ JOIN documents d ON d.active = 1
191
+ AND d.collection = t.collection
192
+ AND d.rel_path = t.rel_path
193
+ )
194
+ SELECT idx, target_id, target_docid
195
+ FROM ranked
196
+ WHERE rn = 1`
197
+ )
198
+ .all(...params);
199
+
200
+ for (const row of rows) {
201
+ results[row.idx] = {
202
+ targetId: row.target_id,
203
+ targetDocid: row.target_docid,
204
+ matchRank: 5,
205
+ matchCount: 1,
206
+ };
207
+ }
208
+ }
209
+
210
+ return results;
211
+ };
212
+
213
+ /** Resolve all targets in bounded SQL batches while retaining confidence inputs. */
214
+ export function resolveGraphLinkTargets(
215
+ db: Database,
216
+ targets: GraphLinkTarget[]
217
+ ): Array<ResolvedGraphLinkTarget | null> {
218
+ const uniqueTargets: GraphLinkTarget[] = [];
219
+ const uniqueIndexByKey = new Map<string, number>();
220
+ const originalToUniqueIndex: number[] = [];
221
+
222
+ for (const target of targets) {
223
+ const key = JSON.stringify([
224
+ target.linkType,
225
+ target.targetCollection,
226
+ target.targetRefNorm,
227
+ ]);
228
+ let uniqueIndex = uniqueIndexByKey.get(key);
229
+ if (uniqueIndex === undefined) {
230
+ uniqueIndex = uniqueTargets.length;
231
+ uniqueIndexByKey.set(key, uniqueIndex);
232
+ uniqueTargets.push(target);
233
+ }
234
+ originalToUniqueIndex.push(uniqueIndex);
235
+ }
236
+
237
+ const uniqueResults = resolveUniqueGraphLinkTargets(db, uniqueTargets);
238
+ return originalToUniqueIndex.map(
239
+ (uniqueIndex) => uniqueResults[uniqueIndex] ?? null
240
+ );
241
+ }