@gmickel/gno 1.4.2 → 1.5.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.
@@ -30,6 +30,10 @@ import type {
30
30
  FtsResult,
31
31
  FtsSearchOptions,
32
32
  GetGraphOptions,
33
+ GraphEdgeConfidence,
34
+ GraphEdgeAudit,
35
+ GraphLinkType,
36
+ GraphReportNode,
33
37
  GraphResult,
34
38
  IndexStatus,
35
39
  IngestErrorInput,
@@ -45,6 +49,7 @@ import type {
45
49
  import type { SqliteDbProvider } from "./types";
46
50
 
47
51
  import { buildUri, deriveDocid, stripUriIndex } from "../../app/constants";
52
+ import { analyzeGraphCommunities } from "../../core/graph-analysis";
48
53
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
49
54
  import { migrations, runMigrations } from "../migrations";
50
55
  import { err, ok } from "../types";
@@ -807,6 +812,59 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
807
812
  }
808
813
  }
809
814
 
815
+ async getDocumentsByDocids(
816
+ docids: string[],
817
+ options: {
818
+ collection?: string;
819
+ activeOnly?: boolean;
820
+ } = {}
821
+ ): Promise<StoreResult<DocumentRow[]>> {
822
+ try {
823
+ if (docids.length === 0) {
824
+ return ok([]);
825
+ }
826
+
827
+ const uniqueDocids = [
828
+ ...new Set(docids.filter((docid) => docid.trim().length > 0)),
829
+ ];
830
+ if (uniqueDocids.length === 0) {
831
+ return ok([]);
832
+ }
833
+
834
+ const db = this.ensureOpen();
835
+ const rows: DbDocumentRow[] = [];
836
+ const SQL_PARAM_LIMIT = options.collection ? 899 : 900;
837
+
838
+ for (let i = 0; i < uniqueDocids.length; i += SQL_PARAM_LIMIT) {
839
+ const batch = uniqueDocids.slice(i, i + SQL_PARAM_LIMIT);
840
+ const placeholders = batch.map(() => "?").join(",");
841
+ const clauses = [`docid IN (${placeholders})`];
842
+ const params: string[] = [...batch];
843
+
844
+ if (options.activeOnly ?? true) {
845
+ clauses.push("active = 1");
846
+ }
847
+ if (options.collection) {
848
+ clauses.push("collection = ?");
849
+ params.push(options.collection);
850
+ }
851
+
852
+ const sql = `SELECT * FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id`;
853
+ rows.push(...db.query<DbDocumentRow, string[]>(sql).all(...params));
854
+ }
855
+
856
+ return ok(rows.map(mapDocumentRow));
857
+ } catch (cause) {
858
+ return err(
859
+ "QUERY_FAILED",
860
+ cause instanceof Error
861
+ ? cause.message
862
+ : "Failed to get documents by docids",
863
+ cause
864
+ );
865
+ }
866
+ }
867
+
810
868
  async listDocumentsPaginated(options: {
811
869
  collection?: string;
812
870
  limit: number;
@@ -2333,17 +2391,43 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2333
2391
  )
2334
2392
  ORDER BY t.id LIMIT 1
2335
2393
  `;
2394
+
2395
+ const wikiBestRank = (
2396
+ collectionExpr: string,
2397
+ targetRefExpr: string
2398
+ ): string => `
2399
+ SELECT MIN(${wikiOrder("t", targetRefExpr)}) FROM documents t
2400
+ WHERE t.active = 1
2401
+ AND t.collection = ${collectionExpr}
2402
+ AND ${wikiMatch("t", targetRefExpr)}
2403
+ `;
2404
+
2405
+ const wikiBestRankMatchCount = (
2406
+ collectionExpr: string,
2407
+ targetRefExpr: string
2408
+ ): string => `
2409
+ SELECT COUNT(*) FROM documents t
2410
+ WHERE t.active = 1
2411
+ AND t.collection = ${collectionExpr}
2412
+ AND ${wikiMatch("t", targetRefExpr)}
2413
+ AND ${wikiOrder("t", targetRefExpr)} = (${wikiBestRank(
2414
+ collectionExpr,
2415
+ targetRefExpr
2416
+ )})
2417
+ `;
2336
2418
  interface ResolvedEdgeRow {
2337
2419
  source_id: number;
2338
2420
  source_docid: string;
2339
2421
  target_id: number;
2340
2422
  target_docid: string;
2341
2423
  link_type: "wiki" | "markdown";
2342
- weight: number;
2424
+ match_rank: number | null;
2425
+ match_count: number | null;
2343
2426
  }
2344
2427
 
2345
- interface UnresolvedCountRow {
2346
- unresolved: number | null;
2428
+ interface UnresolvedByTypeRow {
2429
+ link_type: "wiki" | "markdown";
2430
+ unresolved: number;
2347
2431
  }
2348
2432
 
2349
2433
  interface NodeMetaRow {
@@ -2369,7 +2453,20 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2369
2453
  tgt.id as target_id,
2370
2454
  tgt.docid as target_docid,
2371
2455
  dl.link_type,
2372
- COUNT(*) as weight
2456
+ CASE dl.link_type
2457
+ WHEN 'wiki' THEN (${wikiBestRank(
2458
+ "COALESCE(dl.target_collection, src.collection)",
2459
+ "dl.target_ref_norm"
2460
+ )})
2461
+ WHEN 'markdown' THEN 5
2462
+ END as match_rank,
2463
+ CASE dl.link_type
2464
+ WHEN 'wiki' THEN (${wikiBestRankMatchCount(
2465
+ "COALESCE(dl.target_collection, src.collection)",
2466
+ "dl.target_ref_norm"
2467
+ )})
2468
+ WHEN 'markdown' THEN 1
2469
+ END as match_count
2373
2470
  FROM documents src
2374
2471
  JOIN doc_links dl ON dl.source_doc_id = src.id
2375
2472
  JOIN documents tgt ON tgt.id = CASE dl.link_type
@@ -2387,8 +2484,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2387
2484
  END
2388
2485
  WHERE src.active = 1 AND tgt.active = 1
2389
2486
  ${edgeCollectionClause}
2390
- GROUP BY src.id, src.docid, tgt.id, tgt.docid, dl.link_type
2391
- ORDER BY weight DESC, src.id ASC, tgt.id ASC
2487
+ ORDER BY src.id ASC, tgt.id ASC, dl.link_type ASC
2392
2488
  `;
2393
2489
 
2394
2490
  const resolvedEdgeRows = db
@@ -2403,9 +2499,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2403
2499
  }
2404
2500
  const unresolvedQuery = `
2405
2501
  SELECT
2406
- SUM(CASE WHEN target_id IS NULL THEN 1 ELSE 0 END) as unresolved
2502
+ link_type,
2503
+ COUNT(*) as unresolved
2407
2504
  FROM (
2408
2505
  SELECT
2506
+ dl.link_type,
2409
2507
  CASE dl.link_type
2410
2508
  WHEN 'wiki' THEN (
2411
2509
  ${wikiBestMatch(
@@ -2426,11 +2524,21 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2426
2524
  WHERE src.active = 1
2427
2525
  ${unresolvedCollectionClause}
2428
2526
  )
2527
+ WHERE target_id IS NULL
2528
+ GROUP BY link_type
2429
2529
  `;
2430
- const unresolvedRow = db
2431
- .query<UnresolvedCountRow, string[]>(unresolvedQuery)
2432
- .get(...unresolvedParams);
2433
- const totalEdgesUnresolved = unresolvedRow?.unresolved ?? 0;
2530
+ const unresolvedRows = db
2531
+ .query<UnresolvedByTypeRow, string[]>(unresolvedQuery)
2532
+ .all(...unresolvedParams);
2533
+ const unresolvedByType: Record<"wiki" | "markdown", number> = {
2534
+ wiki: 0,
2535
+ markdown: 0,
2536
+ };
2537
+ for (const row of unresolvedRows) {
2538
+ unresolvedByType[row.link_type] = row.unresolved;
2539
+ }
2540
+ const totalEdgesUnresolved =
2541
+ unresolvedByType.wiki + unresolvedByType.markdown;
2434
2542
 
2435
2543
  const outNeighbors = new Map<number, Set<number>>();
2436
2544
  const inNeighbors = new Map<number, Set<number>>();
@@ -2481,6 +2589,46 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2481
2589
  .filter((row): row is NodeMetaRow & { degree: number } => row !== null)
2482
2590
  .sort((a, b) => b.degree - a.degree || a.id - b.id);
2483
2591
 
2592
+ const toReportNode = (
2593
+ row: NodeMetaRow & { degree: number }
2594
+ ): GraphReportNode => ({
2595
+ id: row.docid,
2596
+ uri: row.uri,
2597
+ title: row.title,
2598
+ collection: row.collection,
2599
+ relPath: row.rel_path,
2600
+ degree: row.degree,
2601
+ });
2602
+
2603
+ const isolatedBaseParams: (string | number)[] = [];
2604
+ const isolatedBaseConditions = ["active = 1"];
2605
+ if (collection) {
2606
+ isolatedBaseConditions.push("collection = ?");
2607
+ isolatedBaseParams.push(collection);
2608
+ }
2609
+ if (connectedIdList.length > 0) {
2610
+ const placeholders = connectedIdList.map(() => "?").join(",");
2611
+ isolatedBaseConditions.push(`id NOT IN (${placeholders})`);
2612
+ isolatedBaseParams.push(...connectedIdList);
2613
+ }
2614
+ const isolatedWhereClause = isolatedBaseConditions.join(" AND ");
2615
+ const isolatedCountRow = db
2616
+ .query<{ cnt: number }, (string | number)[]>(
2617
+ `SELECT COUNT(*) as cnt FROM documents WHERE ${isolatedWhereClause}`
2618
+ )
2619
+ .get(...isolatedBaseParams);
2620
+ const isolatedTotal = isolatedCountRow?.cnt ?? 0;
2621
+ const isolatedExampleRows = db
2622
+ .query<NodeMetaRow, (string | number)[]>(
2623
+ `SELECT id, docid, uri, title, collection, rel_path
2624
+ FROM documents
2625
+ WHERE ${isolatedWhereClause}
2626
+ ORDER BY id ASC
2627
+ LIMIT 10`
2628
+ )
2629
+ .all(...isolatedBaseParams)
2630
+ .map((row) => ({ ...row, degree: 0 }));
2631
+
2484
2632
  let totalNodes = linkedOnly ? connectedNodes.length : 0;
2485
2633
  if (!linkedOnly) {
2486
2634
  const countParams: string[] = [];
@@ -2542,9 +2690,95 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2542
2690
  const selectedDocids = new Set(nodes.map((node) => node.id));
2543
2691
  const nodeDocids = new Set(nodes.map((node) => node.id));
2544
2692
 
2693
+ const confidenceRank: Record<GraphEdgeConfidence, number> = {
2694
+ explicit: 1,
2695
+ inferred: 2,
2696
+ ambiguous: 3,
2697
+ similarity: 4,
2698
+ };
2699
+ const classifyResolvedEdge = (
2700
+ linkType: "wiki" | "markdown",
2701
+ matchRank: number | null,
2702
+ matchCount: number | null
2703
+ ): { confidence: GraphEdgeConfidence; audit: GraphEdgeAudit } => {
2704
+ if (linkType === "markdown") {
2705
+ return {
2706
+ confidence: "explicit",
2707
+ audit: { resolution: "exact-path", matchCount: 1 },
2708
+ };
2709
+ }
2710
+
2711
+ const count = matchCount ?? 0;
2712
+ if (count > 1) {
2713
+ return {
2714
+ confidence: "ambiguous",
2715
+ audit: {
2716
+ resolution: "ambiguous-fallback",
2717
+ matchCount: count,
2718
+ },
2719
+ };
2720
+ }
2721
+
2722
+ if (matchRank === 1 || matchRank === 2) {
2723
+ return {
2724
+ confidence: "explicit",
2725
+ audit: { resolution: "exact-title", matchCount: count || 1 },
2726
+ };
2727
+ }
2728
+ if (matchRank === 5 || matchRank === 6) {
2729
+ return {
2730
+ confidence: "explicit",
2731
+ audit: { resolution: "exact-path", matchCount: count || 1 },
2732
+ };
2733
+ }
2734
+
2735
+ return {
2736
+ confidence: "inferred",
2737
+ audit: { resolution: "path-fallback", matchCount: count || 1 },
2738
+ };
2739
+ };
2740
+ const mergeAudit = (
2741
+ current: {
2742
+ type: GraphLinkType;
2743
+ weight: number;
2744
+ confidence: GraphEdgeConfidence;
2745
+ audit: GraphEdgeAudit;
2746
+ },
2747
+ nextConfidence: GraphEdgeConfidence,
2748
+ nextAudit: GraphEdgeAudit
2749
+ ): void => {
2750
+ if (
2751
+ confidenceRank[nextConfidence] < confidenceRank[current.confidence]
2752
+ ) {
2753
+ current.confidence = nextConfidence;
2754
+ current.audit = {
2755
+ ...nextAudit,
2756
+ matchCount: Math.max(
2757
+ current.audit.matchCount ?? 0,
2758
+ nextAudit.matchCount ?? 0
2759
+ ),
2760
+ };
2761
+ return;
2762
+ }
2763
+ if (
2764
+ nextAudit.matchCount !== undefined &&
2765
+ (current.audit.matchCount ?? 0) < nextAudit.matchCount
2766
+ ) {
2767
+ current.audit = {
2768
+ ...current.audit,
2769
+ matchCount: nextAudit.matchCount,
2770
+ };
2771
+ }
2772
+ };
2773
+
2545
2774
  const edgeMap = new Map<
2546
2775
  string,
2547
- { type: "wiki" | "markdown" | "similar"; weight: number }
2776
+ {
2777
+ type: GraphLinkType;
2778
+ weight: number;
2779
+ confidence: GraphEdgeConfidence;
2780
+ audit: GraphEdgeAudit;
2781
+ }
2548
2782
  >();
2549
2783
  for (const row of resolvedEdgeRows) {
2550
2784
  if (
@@ -2554,7 +2788,23 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2554
2788
  continue;
2555
2789
  }
2556
2790
  const key = `${row.source_docid}:${row.target_docid}:${row.link_type}`;
2557
- edgeMap.set(key, { type: row.link_type, weight: row.weight });
2791
+ const { confidence, audit } = classifyResolvedEdge(
2792
+ row.link_type,
2793
+ row.match_rank,
2794
+ row.match_count
2795
+ );
2796
+ const existing = edgeMap.get(key);
2797
+ if (existing) {
2798
+ existing.weight += 1;
2799
+ mergeAudit(existing, confidence, audit);
2800
+ } else {
2801
+ edgeMap.set(key, {
2802
+ type: row.link_type,
2803
+ weight: 1,
2804
+ confidence,
2805
+ audit,
2806
+ });
2807
+ }
2558
2808
  }
2559
2809
 
2560
2810
  // Phase 3: Similarity edges (if requested)
@@ -2661,7 +2911,12 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2661
2911
  // Keep max score
2662
2912
  const existing = edgeMap.get(key);
2663
2913
  if (!existing || clampedScore > existing.weight) {
2664
- edgeMap.set(key, { type: "similar", weight: clampedScore });
2914
+ edgeMap.set(key, {
2915
+ type: "similar",
2916
+ weight: clampedScore,
2917
+ confidence: "similarity",
2918
+ audit: { resolution: "similarity", score: clampedScore },
2919
+ });
2665
2920
  }
2666
2921
  }
2667
2922
  } catch {
@@ -2687,9 +2942,39 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2687
2942
  target: parts[1] ?? "",
2688
2943
  type: val.type,
2689
2944
  weight: val.weight,
2945
+ confidence: val.confidence,
2946
+ audit: val.audit,
2690
2947
  };
2691
2948
  });
2692
2949
 
2950
+ const edgeTypes: Record<GraphLinkType, number> = {
2951
+ wiki: 0,
2952
+ markdown: 0,
2953
+ similar: 0,
2954
+ };
2955
+ for (const edge of allEdges) {
2956
+ edgeTypes[edge.type] += 1;
2957
+ }
2958
+ const edgeConfidence: Record<GraphEdgeConfidence, number> = {
2959
+ explicit: 0,
2960
+ inferred: 0,
2961
+ ambiguous: 0,
2962
+ similarity: 0,
2963
+ };
2964
+ for (const edge of allEdges) {
2965
+ edgeConfidence[edge.confidence] += 1;
2966
+ }
2967
+
2968
+ const communityAnalysis = analyzeGraphCommunities(nodes, allEdges);
2969
+ warnings.push(...communityAnalysis.warnings);
2970
+ const nodesWithCommunities = nodes.map((node) => {
2971
+ const communityId = communityAnalysis.assignments[node.id];
2972
+ return communityId ? { ...node, communityId } : node;
2973
+ });
2974
+ const communityByNodeId = new Map(
2975
+ Object.entries(communityAnalysis.assignments)
2976
+ );
2977
+
2693
2978
  const truncatedEdges = allEdges.length > limitEdges;
2694
2979
  const links = allEdges.slice(0, limitEdges);
2695
2980
 
@@ -2702,8 +2987,50 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2702
2987
  }
2703
2988
 
2704
2989
  return ok({
2705
- nodes,
2990
+ nodes: nodesWithCommunities,
2706
2991
  links,
2992
+ report: {
2993
+ hubs: connectedNodes.slice(0, 10).map((node) => ({
2994
+ ...toReportNode(node),
2995
+ communityId: communityByNodeId.get(node.docid),
2996
+ })),
2997
+ bridgeCandidates: connectedNodes
2998
+ .filter(
2999
+ (row) =>
3000
+ (inNeighbors.get(row.id)?.size ?? 0) > 0 &&
3001
+ (outNeighbors.get(row.id)?.size ?? 0) > 0
3002
+ )
3003
+ .slice(0, 10)
3004
+ .map((node) => ({
3005
+ ...toReportNode(node),
3006
+ communityId: communityByNodeId.get(node.docid),
3007
+ })),
3008
+ isolated: {
3009
+ total: isolatedTotal,
3010
+ examples: isolatedExampleRows.map((node) => ({
3011
+ ...toReportNode(node),
3012
+ communityId: communityByNodeId.get(node.docid),
3013
+ })),
3014
+ },
3015
+ unresolvedLinks: {
3016
+ total: totalEdgesUnresolved,
3017
+ byType: unresolvedByType,
3018
+ },
3019
+ edgeTypes,
3020
+ edgeConfidence,
3021
+ audit: {
3022
+ inferredEdges: edgeConfidence.inferred,
3023
+ ambiguousEdges: edgeConfidence.ambiguous,
3024
+ similarityEdges: edgeConfidence.similarity,
3025
+ },
3026
+ communities: {
3027
+ total: communityAnalysis.total,
3028
+ algorithm: communityAnalysis.algorithm,
3029
+ skipped: communityAnalysis.skipped,
3030
+ assignments: communityAnalysis.assignments,
3031
+ top: communityAnalysis.communities,
3032
+ },
3033
+ },
2707
3034
  meta: {
2708
3035
  collection,
2709
3036
  nodeLimit: limitNodes,
@@ -2712,7 +3039,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2712
3039
  // totalEdges = collapsed edge count within selected nodes (matches allEdges)
2713
3040
  totalEdges: allEdges.length,
2714
3041
  totalEdgesUnresolved,
2715
- returnedNodes: nodes.length,
3042
+ returnedNodes: nodesWithCommunities.length,
2716
3043
  returnedEdges: links.length,
2717
3044
  truncated: truncatedNodes || truncatedEdges,
2718
3045
  linkedOnly,
@@ -418,6 +418,28 @@ export interface EmbeddingCleanupStats {
418
418
  /** Graph link type (wiki, markdown, or similarity) */
419
419
  export type GraphLinkType = "wiki" | "markdown" | "similar";
420
420
 
421
+ /** Trust classification for graph edges */
422
+ export type GraphEdgeConfidence =
423
+ | "explicit"
424
+ | "inferred"
425
+ | "ambiguous"
426
+ | "similarity";
427
+
428
+ /** Audit metadata explaining how an edge was derived */
429
+ export interface GraphEdgeAudit {
430
+ /** Resolution path used to create the edge */
431
+ resolution:
432
+ | "exact-title"
433
+ | "exact-path"
434
+ | "path-fallback"
435
+ | "ambiguous-fallback"
436
+ | "similarity";
437
+ /** Number of equally ranked target candidates, when applicable */
438
+ matchCount?: number;
439
+ /** Similarity score copied from weight for similarity edges */
440
+ score?: number;
441
+ }
442
+
421
443
  /** Graph node representing a document */
422
444
  export interface GraphNode {
423
445
  /** Document ID (#hex) - primary identifier */
@@ -432,6 +454,42 @@ export interface GraphNode {
432
454
  relPath: string;
433
455
  /** Total degree (in + out unique neighbors) */
434
456
  degree: number;
457
+ /** Optional deterministic community id from graph analysis */
458
+ communityId?: string;
459
+ }
460
+
461
+ /** Compact graph report node summary */
462
+ export interface GraphReportNode {
463
+ /** Document ID (#hex) */
464
+ id: string;
465
+ /** Document URI (gno://collection/path) */
466
+ uri: string;
467
+ /** Document title */
468
+ title: string | null;
469
+ /** Collection name */
470
+ collection: string;
471
+ /** Relative path within collection */
472
+ relPath: string;
473
+ /** Total degree (in + out unique neighbors) */
474
+ degree: number;
475
+ /** Optional deterministic community id from graph analysis */
476
+ communityId?: string;
477
+ }
478
+
479
+ /** Deterministic graph community summary */
480
+ export interface GraphCommunity {
481
+ /** Stable community id within this graph response */
482
+ id: string;
483
+ /** Human-readable label derived from the highest-degree member */
484
+ label: string;
485
+ /** Number of returned nodes assigned to this community */
486
+ size: number;
487
+ /** Internal edge count before edge-limit truncation */
488
+ edgeCount: number;
489
+ /** Internal density, 0-1 */
490
+ density: number;
491
+ /** Highest-degree example nodes in the community */
492
+ topNodes: GraphReportNode[];
435
493
  }
436
494
 
437
495
  /** Graph link (edge) between two nodes */
@@ -444,6 +502,55 @@ export interface GraphLink {
444
502
  type: GraphLinkType;
445
503
  /** Edge weight (link count for wiki/md, similarity score for similar) */
446
504
  weight: number;
505
+ /** Trust classification for retrieval and agent audit */
506
+ confidence: GraphEdgeConfidence;
507
+ /** Audit metadata for how this edge was resolved */
508
+ audit: GraphEdgeAudit;
509
+ }
510
+
511
+ /** Graph report summary over the current graph result */
512
+ export interface GraphReport {
513
+ /** Highest-degree documents */
514
+ hubs: GraphReportNode[];
515
+ /** Bridge-like documents with both incoming and outgoing links */
516
+ bridgeCandidates: GraphReportNode[];
517
+ /** Isolated documents with no resolved explicit graph links */
518
+ isolated: {
519
+ /** Total isolated active documents in scope */
520
+ total: number;
521
+ /** First isolated documents by stable document id order */
522
+ examples: GraphReportNode[];
523
+ };
524
+ /** Unresolved explicit links */
525
+ unresolvedLinks: {
526
+ /** Total unresolved wiki/markdown links in scope */
527
+ total: number;
528
+ /** Unresolved links by type */
529
+ byType: Record<Exclude<GraphLinkType, "similar">, number>;
530
+ };
531
+ /** Edge breakdown by type before edge-limit truncation */
532
+ edgeTypes: Record<GraphLinkType, number>;
533
+ /** Edge confidence breakdown before edge-limit truncation */
534
+ edgeConfidence: Record<GraphEdgeConfidence, number>;
535
+ /** Explicit links resolved through fallback or ambiguous matching */
536
+ audit: {
537
+ inferredEdges: number;
538
+ ambiguousEdges: number;
539
+ similarityEdges: number;
540
+ };
541
+ /** Optional deterministic community/cluster analysis over returned nodes */
542
+ communities: {
543
+ /** Number of detected communities */
544
+ total: number;
545
+ /** Algorithm used for deterministic cluster labels */
546
+ algorithm: "deterministic-label-propagation";
547
+ /** Whether community detection was skipped for graph size */
548
+ skipped: boolean;
549
+ /** Node docid to community id map */
550
+ assignments: Record<string, string>;
551
+ /** Top communities by size */
552
+ top: GraphCommunity[];
553
+ };
447
554
  }
448
555
 
449
556
  /** Graph metadata with truncation info */
@@ -484,6 +591,7 @@ export interface GraphMeta {
484
591
  export interface GraphResult {
485
592
  nodes: GraphNode[];
486
593
  links: GraphLink[];
594
+ report: GraphReport;
487
595
  meta: GraphMeta;
488
596
  }
489
597
 
@@ -641,6 +749,18 @@ export interface StorePort {
641
749
  }
642
750
  ): Promise<StoreResult<DocumentRow[]>>;
643
751
 
752
+ /**
753
+ * Fetch documents by docids in batch.
754
+ * Useful for graph traversal pipelines to avoid per-node document lookups.
755
+ */
756
+ getDocumentsByDocids(
757
+ docids: string[],
758
+ options?: {
759
+ collection?: string;
760
+ activeOnly?: boolean;
761
+ }
762
+ ): Promise<StoreResult<DocumentRow[]>>;
763
+
644
764
  /**
645
765
  * List documents with pagination support.
646
766
  * Returns documents and total count for efficient browsing.