From 9b6870b390c304ca6ca49f42cbb24629c9812898 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 16:44:44 -0700 Subject: [PATCH] feat(graph): add a labeled relation graph with a query surface Add buildKnowledgeRelationGraph, neighbors, walk, and isReachable over KnowledgeRelation edges keyed by (sourceId, targetId, predicate), with KnowledgeRelationSchema, KnowledgeRelationNodeSchema, and KnowledgeRelationGraphSchema so a persisted graph round-trips its metadata. Add knowledgePageRelations and derive buildKnowledgeGraph from it; the collapsed page graph bytes do not change. Closes #124 --- CHANGELOG.md | 9 + README.md | 3 + api-surface.json | 20 ++ docs/architecture.md | 1 + package.json | 2 +- src/graph.test.ts | 233 +++++++++++++++++++++++ src/graph.ts | 168 ++++++++++------- src/index.ts | 1 + src/relation-graph.test.ts | 259 +++++++++++++++++++++++++ src/relation-graph.ts | 376 +++++++++++++++++++++++++++++++++++++ src/schemas.ts | 32 ++-- src/types.ts | 16 ++ 12 files changed, 1043 insertions(+), 77 deletions(-) create mode 100644 src/graph.test.ts create mode 100644 src/relation-graph.test.ts create mode 100644 src/relation-graph.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dab2547..653bd5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 8.1.0 — 2026-08-20 + +### Added + +- Add `buildKnowledgeRelationGraph({ nodes, relations })`, which builds a labeled multi-edge graph from `KnowledgeRelation[]` and caller-declared nodes with a `kind`, `label`, and `metadata`. The graph keeps one edge per `(sourceId, targetId, predicate)`; a repeated triple is accepted only when it is byte-identical, and an endpoint outside the declared nodes is refused. +- Add `neighbors`, `walk`, and `isReachable` over that graph, filtered by predicate and direction (`out`, `in`, `both`), with a cycle-safe breadth-first walk. +- Add `KnowledgeRelationSchema`, `KnowledgeRelationNodeSchema`, and `KnowledgeRelationGraphSchema`, with an explicit `metadata` field so a persisted graph round-trips. `KnowledgeBaseCandidateSchema` uses the same relation schema. +- Add `knowledgePageRelations(pages)`, the labeled page relations (`wikilink`, `citation`, `shared-source`, `contradicts`) that `buildKnowledgeGraph` now collapses into its weighted edges; the collapsed graph bytes are unchanged. + ## 8.0.10 — 2026-08-19 ### Changed diff --git a/README.md b/README.md index 06260a4..fb9f262 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ The provider uses the package's local text search. Pass `refresh: 'always'` to rebuild its index before every query, or call `invalidate()` after changing files. Use `asRetrievalEvalRetriever()` to send the same search path into retrieval tests. +`knowledgePageRelations(pages)` lists the labeled relations between pages (`wikilink`, `citation`, `shared-source`, `contradicts`), and `buildKnowledgeGraph` collapses them into the weighted page graph stored in the index. +For caller-defined provenance (runs, claims, models, any predicate), `buildKnowledgeRelationGraph({ nodes, relations })` keeps one edge per `(sourceId, targetId, predicate)`, refuses a conflicting repeat or an undeclared endpoint, and `neighbors`, `walk`, and `isReachable` query it by predicate and direction; `KnowledgeRelationGraphSchema` round-trips a persisted graph with its metadata. + ## Prove what the agent saw and used A page existing in a knowledge base, a page appearing in retrieval results, and a page influencing a decision are three different facts. The receipt APIs preserve those joins without pretending they prove the page is true or that it improved the outcome. diff --git a/api-surface.json b/api-surface.json index 19f1efe..66b9593 100644 --- a/api-surface.json +++ b/api-surface.json @@ -70,6 +70,7 @@ "AuditKnowledgeCitationsOptions": "value", "BuildAgentMemorySequencesFromBenchmarkCasesOptions": "type", "BuildEvalKnowledgeBundleOptions": "value", + "BuildKnowledgeRelationGraphInput": "value", "BuildRetrievalBenchmarkCasesFromQrelsOptions": "type", "BuildRetrievalEvalDispatchOptions": "value", "CHECKABLE_RUNG_THRESHOLD": "value", @@ -240,6 +241,8 @@ "KnowledgePage": "value", "KnowledgePageInvalidation": "value", "KnowledgePageInvalidationSchema": "value", + "KnowledgePageRelation": "value", + "KnowledgePageRelationPredicate": "value", "KnowledgePageSchema": "value", "KnowledgePolicy": "value", "KnowledgePolicyDispatch": "type", @@ -249,6 +252,18 @@ "KnowledgeReadinessSpec": "value", "KnowledgeReceiptAttributeValue": "value", "KnowledgeRelation": "value", + "KnowledgeRelationDirection": "value", + "KnowledgeRelationGraph": "value", + "KnowledgeRelationGraphError": "value", + "KnowledgeRelationGraphErrorCode": "value", + "KnowledgeRelationGraphSchema": "value", + "KnowledgeRelationNeighbor": "value", + "KnowledgeRelationNode": "value", + "KnowledgeRelationNodeSchema": "value", + "KnowledgeRelationQuery": "value", + "KnowledgeRelationSchema": "value", + "KnowledgeRelationWalkOptions": "value", + "KnowledgeRelationWalkStep": "value", "KnowledgeRelease": "value", "KnowledgeReleaseInput": "value", "KnowledgeReleaseReport": "value", @@ -488,6 +503,7 @@ "buildKnowledgeBenchmarkScenarios": "value", "buildKnowledgeGraph": "value", "buildKnowledgeIndex": "value", + "buildKnowledgeRelationGraph": "value", "buildRetrievalBenchmarkCasesFromQrels": "value", "buildRetrievalEvalDispatch": "value", "calibrateRagAnswerJudge": "value", @@ -566,6 +582,7 @@ "isKernelAnchoredPath": "value", "isKnowledgeMemoryBenchmarkCase": "value", "isMissingFile": "value", + "isReachable": "value", "isSafeKnowledgePath": "value", "isScaffoldPath": "value", "jsonCandidateCodec": "value", @@ -577,6 +594,7 @@ "knowledgeImprovementRunDir": "value", "knowledgeImprovementRunId": "value", "knowledgePageDigest": "value", + "knowledgePageRelations": "value", "knowledgeReleaseReport": "value", "layoutFor": "value", "lensDistribution": "value", @@ -600,6 +618,7 @@ "memoryWriteResultToSourceRecord": "value", "mergeClaimLedgers": "value", "mergeTrackedClaims": "value", + "neighbors": "value", "normalizeClaimText": "value", "normalizeExternalRagScores": "value", "normalizeLinkTarget": "value", @@ -686,6 +705,7 @@ "verifyGradeableEvidence": "value", "verifyKnowledgeRetrievalReceipt": "value", "verifyKnowledgeUseReceipt": "value", + "walk": "value", "withCitedClaim": "value", "withKnowledgeImprovementCandidate": "value", "withKnowledgeImprovementComparison": "value", diff --git a/docs/architecture.md b/docs/architecture.md index 14533d8..75e3dc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,7 @@ It owns the small set of primitives every serious agent knowledge system needs: - generated knowledge pages and units - claims with source references - deterministic indexing, graph construction, search, and lint +- labeled relation graphs with one edge per `(source, target, predicate)` and neighbor, walk, and reachability queries - retrieval/RAG candidate surfaces, gold-target scoring, and eval-loop adapters - safe LLM write proposals - eval-gated release confidence through `@tangle-network/agent-eval` diff --git a/package.json b/package.json index 1cf2e90..4e636c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "8.0.10", + "version": "8.1.0", "description": "Build, search, evaluate, and improve source-backed knowledge bases.", "homepage": "https://github.com/tangle-network/agent-knowledge#readme", "repository": { diff --git a/src/graph.test.ts b/src/graph.test.ts new file mode 100644 index 0000000..db3b608 --- /dev/null +++ b/src/graph.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest' +import { buildKnowledgeGraph, knowledgePageRelations } from './graph' +import { buildKnowledgeRelationGraph, neighbors } from './relation-graph' +import type { KnowledgeGraph, KnowledgePage } from './types' + +function page( + input: Partial & Pick, +): KnowledgePage { + return { + text: `${input.title} body`, + frontmatter: {}, + sourceIds: [], + tags: [], + outLinks: [], + ...input, + } +} + +// Covers: a link and a citation that resolve to the same page twice each, a +// missing link target, a self link, an origin-qualified citation, a malformed +// citation, an ambiguous duplicate id, a wikilink resolved through a path +// basename, and a `contradicts` entry at every resolution outcome. +const pages: KnowledgePage[] = [ + page({ + id: 'attention', + title: 'Attention', + path: 'knowledge/concepts/attention.md', + sourceIds: ['s1', 's2'], + tags: ['transformer'], + outLinks: ['flash-attention', 'Flash Attention', 'missing-page', 'attention'], + cites: ['flash-attention', 'here::flash-attention'], + contradicts: ['orphan'], + }), + page({ + id: 'flash-attention', + title: 'Flash Attention', + path: 'knowledge/concepts/flash-attention.md', + sourceIds: ['s1'], + outLinks: ['attention'], + }), + page({ id: 'orphan', title: 'Orphan', path: 'knowledge/orphan.md', sourceIds: ['s2', 's3'] }), + page({ id: 'dup', title: 'Dup A', path: 'knowledge/dup-a.md', sourceIds: ['s3'] }), + page({ id: 'dup', title: 'Dup B', path: 'knowledge/dup-b.md', sourceIds: ['s3'] }), + page({ + id: 'citer', + title: 'Citer', + path: 'knowledge/citer.md', + outLinks: ['dup-a', 'orphan.md'], + cites: ['dup', 'citer', 'here::', 'orphan'], + contradicts: ['citer', 'dup', 'orphan'], + }), +] + +// Recorded from `buildKnowledgeGraph` before it derived its edges from +// `knowledgePageRelations`. Key order, edge order, weights, and degrees are the +// published index shape and must not move. +const goldenGraph: KnowledgeGraph = { + nodes: [ + { + id: 'attention', + title: 'Attention', + path: 'knowledge/concepts/attention.md', + tags: ['transformer'], + sourceIds: ['s1', 's2'], + outDegree: 4, + inDegree: 1, + }, + { + id: 'flash-attention', + title: 'Flash Attention', + path: 'knowledge/concepts/flash-attention.md', + tags: [], + sourceIds: ['s1'], + outDegree: 1, + inDegree: 4, + }, + { + id: 'orphan', + title: 'Orphan', + path: 'knowledge/orphan.md', + tags: [], + sourceIds: ['s2', 's3'], + outDegree: 0, + inDegree: 2, + }, + { + id: 'dup', + title: 'Dup A', + path: 'knowledge/dup-a.md', + tags: [], + sourceIds: ['s3'], + outDegree: 0, + inDegree: 1, + }, + { + id: 'dup', + title: 'Dup B', + path: 'knowledge/dup-b.md', + tags: [], + sourceIds: ['s3'], + outDegree: 0, + inDegree: 1, + }, + { + id: 'citer', + title: 'Citer', + path: 'knowledge/citer.md', + tags: [], + sourceIds: [], + outDegree: 3, + inDegree: 0, + }, + ], + edges: [ + { + source: 'attention', + target: 'flash-attention', + weight: 4.5, + reasons: ['wikilink', 'citation', 'shared-source'], + }, + { source: 'citer', target: 'orphan', weight: 2, reasons: ['wikilink', 'citation'] }, + { source: 'flash-attention', target: 'attention', weight: 1, reasons: ['wikilink'] }, + { source: 'citer', target: 'dup', weight: 1, reasons: ['wikilink'] }, + { source: 'orphan', target: 'dup', weight: 1, reasons: ['shared-source'] }, + { source: 'attention', target: 'orphan', weight: 0.5, reasons: ['shared-source'] }, + { source: 'dup', target: 'dup', weight: 0.5, reasons: ['shared-source'] }, + ], +} + +describe('knowledgePageRelations', () => { + it('emits one labeled relation per (source, target, predicate) with occurrence weights', () => { + expect(knowledgePageRelations(pages)).toEqual([ + { sourceId: 'attention', targetId: 'flash-attention', predicate: 'wikilink', weight: 2 }, + { sourceId: 'attention', targetId: 'flash-attention', predicate: 'citation', weight: 2 }, + { sourceId: 'attention', targetId: 'orphan', predicate: 'contradicts', weight: 1 }, + { sourceId: 'flash-attention', targetId: 'attention', predicate: 'wikilink', weight: 1 }, + { sourceId: 'citer', targetId: 'dup', predicate: 'wikilink', weight: 1 }, + { sourceId: 'citer', targetId: 'orphan', predicate: 'wikilink', weight: 1 }, + { sourceId: 'citer', targetId: 'orphan', predicate: 'citation', weight: 1 }, + { sourceId: 'citer', targetId: 'orphan', predicate: 'contradicts', weight: 1 }, + { + sourceId: 'attention', + targetId: 'flash-attention', + predicate: 'shared-source', + weight: 0.5, + metadata: { sourceIds: ['s1'] }, + }, + { + sourceId: 'attention', + targetId: 'orphan', + predicate: 'shared-source', + weight: 0.5, + metadata: { sourceIds: ['s2'] }, + }, + { + sourceId: 'orphan', + targetId: 'dup', + predicate: 'shared-source', + weight: 0.5, + metadata: { sourceIds: ['s3'] }, + }, + { + sourceId: 'orphan', + targetId: 'dup', + predicate: 'shared-source', + weight: 0.5, + metadata: { sourceIds: ['s3'] }, + }, + { + sourceId: 'dup', + targetId: 'dup', + predicate: 'shared-source', + weight: 0.5, + metadata: { sourceIds: ['s3'] }, + }, + ]) + }) + + it('keeps two predicates between one pair as two relations in a relation graph', () => { + const relations = knowledgePageRelations(pages).filter( + (relation) => relation.predicate !== 'shared-source', + ) + const graph = buildKnowledgeRelationGraph({ relations }) + const out = neighbors(graph, 'attention', { direction: 'out' }) + expect(out.map((neighbor) => [neighbor.nodeId, neighbor.relation.predicate])).toEqual([ + ['flash-attention', 'wikilink'], + ['flash-attention', 'citation'], + ['orphan', 'contradicts'], + ]) + expect(neighbors(graph, 'orphan', { direction: 'in', predicate: 'contradicts' })).toHaveLength( + 2, + ) + }) + + it('emits nothing for a missing, ambiguous, or self target', () => { + const relations = knowledgePageRelations(pages) + expect(relations.some((relation) => relation.targetId === 'missing-page')).toBe(false) + expect( + relations.some( + (relation) => + relation.sourceId === relation.targetId && relation.predicate !== 'shared-source', + ), + ).toBe(false) + expect( + relations.some( + (relation) => + relation.sourceId === 'citer' && + relation.targetId === 'dup' && + relation.predicate !== 'wikilink', + ), + ).toBe(false) + }) +}) + +describe('buildKnowledgeGraph', () => { + it('is byte-identical to the recorded collapsed page graph', () => { + expect(JSON.stringify(buildKnowledgeGraph(pages))).toBe(JSON.stringify(goldenGraph)) + }) + + it('sums the relation weights per ordered pair and leaves contradicts out', () => { + const graph = buildKnowledgeGraph(pages) + const pairWeights = new Map() + for (const relation of knowledgePageRelations(pages)) { + if (relation.predicate === 'contradicts') continue + const key = `${relation.sourceId}->${relation.targetId}` + pairWeights.set(key, (pairWeights.get(key) ?? 0) + relation.weight) + } + expect( + new Map(graph.edges.map((edge) => [`${edge.source}->${edge.target}`, edge.weight])), + ).toEqual(pairWeights) + expect(graph.edges.some((edge) => edge.reasons.includes('contradicts'))).toBe(false) + }) +}) diff --git a/src/graph.ts b/src/graph.ts index 3a1fe2a..0c135ba 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,8 +1,37 @@ import { parseKnowledgeCitationReference } from './citation-resolution' -import type { KnowledgeGraph, KnowledgeGraphEdge, KnowledgeGraphNode, KnowledgePage } from './types' +import type { + KnowledgeGraph, + KnowledgeGraphEdge, + KnowledgeGraphNode, + KnowledgePage, + KnowledgeRelation, +} from './types' import { normalizeLinkTarget } from './wikilinks' -export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { +export type KnowledgePageRelationPredicate = + | 'wikilink' + | 'citation' + | 'shared-source' + | 'contradicts' + +/** One labeled relation between two pages; `weight` counts the occurrences that produced it. */ +export interface KnowledgePageRelation extends KnowledgeRelation { + predicate: KnowledgePageRelationPredicate + weight: number +} + +/** + * The labeled relations between pages, one per `(sourceId, targetId, predicate)`. + * + * `wikilink`, `citation`, and `contradicts` follow an authored link, `cites` + * entry, or `contradicts` entry to the one page it resolves to; a missing, + * ambiguous, or self-referencing target emits nothing. `shared-source` joins + * every pair of pages that registers a common source, in page order, weighted + * 0.5 per shared source id, with the shared ids in `metadata.sourceIds`. It is + * implicit, so it is listed after the authored relations and never counts + * toward a page's degree in `buildKnowledgeGraph`. + */ +export function knowledgePageRelations(pages: KnowledgePage[]): KnowledgePageRelation[] { const bySlug = new Map() const byId = new Map() for (const page of pages) { @@ -12,28 +41,89 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { bySlug.set(normalizeLinkTarget(page.path.split('/').pop()!.replace(/\.md$/, '')), page) } - const incoming = new Map() - const outgoing = new Map() - const edgesByKey = new Map() - for (const page of pages) { - outgoing.set(page.id, 0) - incoming.set(page.id, 0) + const authored = new Map() + const count = ( + source: KnowledgePage, + target: KnowledgePage, + predicate: KnowledgePageRelationPredicate, + ): void => { + const key = JSON.stringify([source.id, target.id, predicate]) + const relation = authored.get(key) + if (relation) relation.weight += 1 + else authored.set(key, { sourceId: source.id, targetId: target.id, predicate, weight: 1 }) + } + const uniqueTarget = (id: string): KnowledgePage | undefined => { + const targets = byId.get(id) ?? [] + return targets.length === 1 ? targets[0] : undefined } for (const page of pages) { for (const raw of page.outLinks) { const target = bySlug.get(normalizeLinkTarget(raw)) if (!target || target.id === page.id) continue - addDirectedEdge(page, target, 'wikilink', edgesByKey, incoming, outgoing) + count(page, target, 'wikilink') } for (const persisted of page.cites ?? []) { - const targets = byId.get(citedPageId(persisted)) ?? [] - if (targets.length !== 1 || targets[0]!.id === page.id) continue - addDirectedEdge(page, targets[0]!, 'citation', edgesByKey, incoming, outgoing) + const target = uniqueTarget(citedPageId(persisted)) + if (!target || target.id === page.id) continue + count(page, target, 'citation') + } + for (const contradicted of page.contradicts ?? []) { + const target = uniqueTarget(contradicted) + if (!target || target.id === page.id) continue + count(page, target, 'contradicts') } } - addSourceOverlapEdges(pages, edgesByKey) + const relations: KnowledgePageRelation[] = [...authored.values()] + for (let i = 0; i < pages.length; i++) { + for (let j = i + 1; j < pages.length; j++) { + const a = pages[i]! + const b = pages[j]! + const overlap = a.sourceIds.filter((source) => b.sourceIds.includes(source)) + if (overlap.length === 0) continue + relations.push({ + sourceId: a.id, + targetId: b.id, + predicate: 'shared-source', + weight: overlap.length * 0.5, + metadata: { sourceIds: overlap }, + }) + } + } + return relations +} + +/** + * The weighted page graph: `knowledgePageRelations` collapsed to one edge per + * ordered page pair, with the predicates merged into `reasons` and the weights + * summed. `contradicts` stays out of this projection; read it from the relation + * list. Node degrees count the authored `wikilink` and `citation` occurrences. + */ +export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { + const incoming = new Map() + const outgoing = new Map() + for (const page of pages) { + outgoing.set(page.id, 0) + incoming.set(page.id, 0) + } + + const edgesByKey = new Map() + for (const relation of knowledgePageRelations(pages)) { + if (relation.predicate === 'contradicts') continue + const key = `${relation.sourceId}->${relation.targetId}` + let edge = edgesByKey.get(key) + if (!edge) { + edge = { source: relation.sourceId, target: relation.targetId, weight: 0, reasons: [] } + edgesByKey.set(key, edge) + } + edge.weight += relation.weight + if (!edge.reasons.includes(relation.predicate)) edge.reasons.push(relation.predicate) + if (relation.predicate === 'wikilink' || relation.predicate === 'citation') { + outgoing.set(relation.sourceId, (outgoing.get(relation.sourceId) ?? 0) + relation.weight) + incoming.set(relation.targetId, (incoming.get(relation.targetId) ?? 0) + relation.weight) + } + } const nodes: KnowledgeGraphNode[] = pages.map((page) => ({ id: page.id, @@ -60,55 +150,3 @@ function citedPageId(persisted: string): string { return persisted } } - -function addDirectedEdge( - source: KnowledgePage, - target: KnowledgePage, - reason: string, - edges: Map, - incoming: Map, - outgoing: Map, -): void { - const key = `${source.id}->${target.id}` - const edge = edges.get(key) - if (edge) { - edge.weight += 1 - if (!edge.reasons.includes(reason)) edge.reasons.push(reason) - } else { - edges.set(key, { - source: source.id, - target: target.id, - weight: 1, - reasons: [reason], - }) - } - outgoing.set(source.id, (outgoing.get(source.id) ?? 0) + 1) - incoming.set(target.id, (incoming.get(target.id) ?? 0) + 1) -} - -function addSourceOverlapEdges( - pages: KnowledgePage[], - edges: Map, -): void { - for (let i = 0; i < pages.length; i++) { - for (let j = i + 1; j < pages.length; j++) { - const a = pages[i]! - const b = pages[j]! - const overlap = a.sourceIds.filter((source) => b.sourceIds.includes(source)) - if (overlap.length === 0) continue - const key = `${a.id}->${b.id}` - const edge = edges.get(key) - if (edge) { - edge.weight += overlap.length * 0.5 - if (!edge.reasons.includes('shared-source')) edge.reasons.push('shared-source') - } else { - edges.set(key, { - source: a.id, - target: b.id, - weight: overlap.length * 0.5, - reasons: ['shared-source'], - }) - } - } - } -} diff --git a/src/index.ts b/src/index.ts index b5d67b3..0e7ebd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ export * from './rag-eval' export * from './rag-improvement-loop' export * from './rag-optimization' export * from './readiness-check' +export * from './relation-graph' export * from './release' export * from './research-driving-driver' export * from './research-loop' diff --git a/src/relation-graph.test.ts b/src/relation-graph.test.ts new file mode 100644 index 0000000..849e89b --- /dev/null +++ b/src/relation-graph.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest' +import { + buildKnowledgeRelationGraph, + isReachable, + KnowledgeRelationGraphError, + neighbors, + walk, +} from './relation-graph' +import { KnowledgeRelationGraphSchema } from './schemas' +import type { KnowledgeRelation, KnowledgeRelationNode } from './types' + +const nodes: KnowledgeRelationNode[] = [ + { id: 'run-1', kind: 'run', label: 'Run 1' }, + { id: 'run-2', kind: 'run', label: 'Run 2', metadata: { seed: 7, tags: ['baseline'] } }, + { id: 'run-3', kind: 'run' }, + { id: 'claim-a', kind: 'claim' }, + { id: 'model-x', kind: 'model', metadata: { provider: 'local' } }, +] + +const relations: KnowledgeRelation[] = [ + { sourceId: 'run-2', targetId: 'run-1', predicate: 'branched-from' }, + { sourceId: 'run-2', targetId: 'run-1', predicate: 'supersedes', weight: 1 }, + { sourceId: 'run-3', targetId: 'run-2', predicate: 'branched-from' }, + { sourceId: 'run-2', targetId: 'claim-a', predicate: 'cites-evidence', metadata: { at: 3 } }, + { sourceId: 'run-1', targetId: 'model-x', predicate: 'executed-with' }, + { sourceId: 'run-3', targetId: 'model-x', predicate: 'executed-with' }, +] + +function expectError(run: () => unknown, code: KnowledgeRelationGraphError['code']): void { + try { + run() + } catch (error) { + expect(error).toBeInstanceOf(KnowledgeRelationGraphError) + expect((error as KnowledgeRelationGraphError).code).toBe(code) + return + } + throw new Error(`expected KnowledgeRelationGraphError ${code}`) +} + +describe('buildKnowledgeRelationGraph', () => { + it('keeps one edge per (source, target, predicate) in first-seen order', () => { + const graph = buildKnowledgeRelationGraph({ nodes, relations }) + expect(graph.nodes).toEqual(nodes) + expect(graph.edges).toEqual(relations) + expect( + graph.edges.filter((edge) => edge.sourceId === 'run-2' && edge.targetId === 'run-1'), + ).toHaveLength(2) + }) + + it('accepts a repeated triple only when it is byte-identical', () => { + const repeated: KnowledgeRelation = { + sourceId: 'run-2', + targetId: 'claim-a', + predicate: 'cites-evidence', + metadata: { at: 3 }, + } + const graph = buildKnowledgeRelationGraph({ relations: [...relations, repeated] }) + expect(graph.edges).toEqual(relations) + + expectError( + () => + buildKnowledgeRelationGraph({ + relations: [...relations, { ...repeated, metadata: { at: 4 } }], + }), + 'duplicate-relation', + ) + expectError( + () => + buildKnowledgeRelationGraph({ + relations: [ + { sourceId: 'a', targetId: 'b', predicate: 'p', weight: 1 }, + { sourceId: 'a', targetId: 'b', predicate: 'p', weight: 2 }, + ], + }), + 'duplicate-relation', + ) + }) + + it('refuses an endpoint outside the declared nodes instead of adding a node', () => { + expectError( + () => + buildKnowledgeRelationGraph({ + nodes, + relations: [{ sourceId: 'run-1', targetId: 'worker-9', predicate: 'authored-by' }], + }), + 'undeclared-endpoint', + ) + }) + + it('carries edges only when no nodes are declared', () => { + const graph = buildKnowledgeRelationGraph({ relations }) + expect(graph.nodes).toEqual([]) + expect(neighbors(graph, 'run-1', { direction: 'in' }).map((n) => n.nodeId)).toEqual([ + 'run-2', + 'run-2', + ]) + }) + + it('refuses duplicate node ids, empty ids, and non-JSON metadata', () => { + expectError( + () => + buildKnowledgeRelationGraph({ + nodes: [ + { id: 'x', kind: 'run' }, + { id: 'x', kind: 'claim' }, + ], + relations: [], + }), + 'duplicate-node', + ) + expectError( + () => buildKnowledgeRelationGraph({ nodes: [{ id: '', kind: 'run' }], relations: [] }), + 'invalid-node', + ) + expectError( + () => buildKnowledgeRelationGraph({ nodes: [{ id: 'x', kind: '' }], relations: [] }), + 'invalid-node', + ) + expectError( + () => + buildKnowledgeRelationGraph({ + nodes: [{ id: 'x', kind: 'run', metadata: { at: Number.NaN } }], + relations: [], + }), + 'invalid-node', + ) + expectError( + () => + buildKnowledgeRelationGraph({ + relations: [{ sourceId: 'a', targetId: '', predicate: 'p' }], + }), + 'invalid-relation', + ) + expectError( + () => + buildKnowledgeRelationGraph({ + relations: [ + { sourceId: 'a', targetId: 'b', predicate: 'p', weight: Number.POSITIVE_INFINITY }, + ], + }), + 'invalid-relation', + ) + expectError( + () => + buildKnowledgeRelationGraph({ + relations: [ + { sourceId: 'a', targetId: 'b', predicate: 'p', metadata: { note: undefined } }, + ], + }), + 'invalid-relation', + ) + }) + + it('round-trips node and edge metadata through the schema', () => { + const graph = buildKnowledgeRelationGraph({ nodes, relations }) + const parsed = KnowledgeRelationGraphSchema.parse(JSON.parse(JSON.stringify(graph))) + expect(parsed).toEqual(graph) + expect(parsed.nodes[1]?.metadata).toEqual({ seed: 7, tags: ['baseline'] }) + expect(parsed.edges[3]?.metadata).toEqual({ at: 3 }) + expect(walk(parsed, 'run-3', { direction: 'out', predicate: 'branched-from' })).toEqual( + walk(graph, 'run-3', { direction: 'out', predicate: 'branched-from' }), + ) + }) +}) + +describe('relation graph queries', () => { + const graph = buildKnowledgeRelationGraph({ nodes, relations }) + + it('lists neighbors by predicate and direction', () => { + expect(neighbors(graph, 'run-2', { direction: 'out', predicate: 'branched-from' })).toEqual([ + { nodeId: 'run-1', relation: relations[0] }, + ]) + expect(neighbors(graph, 'run-1', { direction: 'in' }).map((n) => n.relation.predicate)).toEqual( + ['branched-from', 'supersedes'], + ) + expect(neighbors(graph, 'run-2', { direction: 'both' }).map((n) => n.nodeId)).toEqual([ + 'run-1', + 'run-1', + 'claim-a', + 'run-3', + ]) + expect(neighbors(graph, 'claim-a', { direction: 'out' })).toEqual([]) + }) + + it('walks ancestors over out edges and descendants over in edges', () => { + expect(walk(graph, 'run-3', { direction: 'out', predicate: 'branched-from' })).toEqual([ + { nodeId: 'run-2', depth: 1, from: 'run-3', relation: relations[2] }, + { nodeId: 'run-1', depth: 2, from: 'run-2', relation: relations[0] }, + ]) + expect( + walk(graph, 'run-1', { direction: 'in', predicate: 'branched-from' }).map((s) => [ + s.nodeId, + s.depth, + ]), + ).toEqual([ + ['run-2', 1], + ['run-3', 2], + ]) + expect( + walk(graph, 'run-3', { direction: 'out', predicate: 'branched-from', maxDepth: 1 }).map( + (s) => s.nodeId, + ), + ).toEqual(['run-2']) + expect(walk(graph, 'run-3', { direction: 'out', maxDepth: 0 })).toEqual([]) + }) + + it('reports each node once on a cycle and terminates', () => { + const cyclic = buildKnowledgeRelationGraph({ + relations: [ + { sourceId: 'a', targetId: 'b', predicate: 'next' }, + { sourceId: 'b', targetId: 'c', predicate: 'next' }, + { sourceId: 'c', targetId: 'a', predicate: 'next' }, + { sourceId: 'c', targetId: 'c', predicate: 'next' }, + ], + }) + expect(walk(cyclic, 'a', { direction: 'out' }).map((s) => s.nodeId)).toEqual(['b', 'c']) + expect(walk(cyclic, 'a', { direction: 'both' }).map((s) => s.nodeId)).toEqual(['b', 'c']) + expect(neighbors(cyclic, 'c', { direction: 'both' }).map((n) => n.nodeId)).toEqual([ + 'a', + 'c', + 'b', + ]) + expect(isReachable(cyclic, 'a', 'a', { direction: 'out' })).toBe(true) + expect(isReachable(cyclic, 'b', 'a', { direction: 'out' })).toBe(true) + }) + + it('answers reachability along the requested predicate and direction', () => { + expect( + isReachable(graph, 'run-3', 'run-1', { direction: 'out', predicate: 'branched-from' }), + ).toBe(true) + expect( + isReachable(graph, 'run-1', 'run-3', { direction: 'out', predicate: 'branched-from' }), + ).toBe(false) + expect( + isReachable(graph, 'run-1', 'run-3', { direction: 'in', predicate: 'branched-from' }), + ).toBe(true) + expect( + isReachable(graph, 'run-3', 'model-x', { direction: 'out', predicate: 'branched-from' }), + ).toBe(false) + expect(isReachable(graph, 'run-3', 'model-x', { direction: 'out' })).toBe(true) + expect(isReachable(graph, 'claim-a', 'model-x', { direction: 'both' })).toBe(true) + }) + + it('refuses an unknown node and a malformed query', () => { + expectError(() => neighbors(graph, 'ghost', { direction: 'out' }), 'unknown-node') + expectError(() => walk(graph, 'ghost', { direction: 'out' }), 'unknown-node') + expectError(() => isReachable(graph, 'run-1', 'ghost', { direction: 'out' }), 'unknown-node') + expectError( + () => neighbors(graph, 'run-1', { direction: 'sideways' as 'out' }), + 'invalid-query', + ) + expectError( + () => neighbors(graph, 'run-1', { direction: 'out', predicate: '' }), + 'invalid-query', + ) + expectError(() => walk(graph, 'run-1', { direction: 'out', maxDepth: -1 }), 'invalid-query') + expectError(() => walk(graph, 'run-1', { direction: 'out', maxDepth: 1.5 }), 'invalid-query') + }) +}) diff --git a/src/relation-graph.ts b/src/relation-graph.ts new file mode 100644 index 0000000..5f90218 --- /dev/null +++ b/src/relation-graph.ts @@ -0,0 +1,376 @@ +import { canonicalCandidateJson } from '@tangle-network/agent-interface' +import type { + KnowledgeId, + KnowledgeRelation, + KnowledgeRelationGraph, + KnowledgeRelationNode, +} from './types' + +export type KnowledgeRelationGraphErrorCode = + | 'invalid-node' + | 'duplicate-node' + | 'invalid-relation' + | 'duplicate-relation' + | 'undeclared-endpoint' + | 'unknown-node' + | 'invalid-query' + +/** A relation graph input or query that cannot be honored exactly. */ +export class KnowledgeRelationGraphError extends Error { + readonly code: KnowledgeRelationGraphErrorCode + + constructor(code: KnowledgeRelationGraphErrorCode, message: string) { + super(message) + this.name = 'KnowledgeRelationGraphError' + this.code = code + } +} + +export interface BuildKnowledgeRelationGraphInput { + /** + * Declared node inventory. When present, every relation endpoint must name + * one declared node; an undeclared endpoint is refused rather than added. + * When absent, the graph carries edges only and `nodes` is empty. + */ + nodes?: readonly KnowledgeRelationNode[] + relations: readonly KnowledgeRelation[] +} + +/** + * `out` follows `sourceId -> targetId`, `in` follows `targetId -> sourceId`, + * `both` follows a relation from either end. + */ +export type KnowledgeRelationDirection = 'out' | 'in' | 'both' + +export interface KnowledgeRelationQuery { + /** Restrict to relations with this predicate. Every predicate when absent. */ + predicate?: string + direction: KnowledgeRelationDirection +} + +export interface KnowledgeRelationWalkOptions extends KnowledgeRelationQuery { + /** Most relations between the start node and a reported node. Unbounded when absent. */ + maxDepth?: number +} + +export interface KnowledgeRelationNeighbor { + nodeId: KnowledgeId + relation: KnowledgeRelation +} + +export interface KnowledgeRelationWalkStep { + nodeId: KnowledgeId + /** Number of relations between the start node and this node. */ + depth: number + /** The node the search came from when it first reached this node. */ + from: KnowledgeId + relation: KnowledgeRelation +} + +/** + * Build a labeled multi-edge graph from caller relations. + * + * The graph keeps one edge per `(sourceId, targetId, predicate)`, in first-seen + * order. A repeated triple is accepted only when its weight and metadata are + * identical in canonical JSON; any other repeat is a conflict and is refused. + * Node and relation metadata must be finite, acyclic JSON so the graph + * round-trips through `KnowledgeRelationGraphSchema` unchanged. + */ +export function buildKnowledgeRelationGraph( + input: BuildKnowledgeRelationGraphInput, +): KnowledgeRelationGraph { + const declared = + input.nodes === undefined ? undefined : new Map() + const nodes: KnowledgeRelationNode[] = [] + if (declared !== undefined) { + for (const [position, node] of input.nodes!.entries()) { + const normalized = normalizeNode(node, position) + if (declared.has(normalized.id)) { + throw new KnowledgeRelationGraphError( + 'duplicate-node', + `node "${normalized.id}" is declared more than once`, + ) + } + declared.set(normalized.id, normalized) + nodes.push(normalized) + } + } + + const edges: KnowledgeRelation[] = [] + const canonicalByTriple = new Map() + for (const [position, relation] of input.relations.entries()) { + const normalized = normalizeRelation(relation, position) + if (declared !== undefined) { + for (const endpoint of [normalized.sourceId, normalized.targetId]) { + if (!declared.has(endpoint)) { + throw new KnowledgeRelationGraphError( + 'undeclared-endpoint', + `relation ${describeTriple(normalized)} names undeclared node "${endpoint}"`, + ) + } + } + } + const canonical = canonicalRelationJson(normalized) + const key = tripleKey(normalized) + const prior = canonicalByTriple.get(key) + if (prior !== undefined) { + if (prior === canonical) continue + throw new KnowledgeRelationGraphError( + 'duplicate-relation', + `relation ${describeTriple(normalized)} is repeated with different weight or metadata`, + ) + } + canonicalByTriple.set(key, canonical) + edges.push(normalized) + } + return { nodes, edges } +} + +/** Relations touching `id`, each with the node at the other end, in graph order. */ +export function neighbors( + graph: KnowledgeRelationGraph, + id: KnowledgeId, + query: KnowledgeRelationQuery, +): KnowledgeRelationNeighbor[] { + const index = indexOf(graph) + validateQuery(query) + assertKnownNode(index, id) + return neighborsOf(index, id, query) +} + +/** + * Breadth-first traversal from `id` over matching relations. Each reachable + * node is reported once, at the depth where the search first reached it; the + * start node is never reported, so a cycle back to it adds no step. + */ +export function walk( + graph: KnowledgeRelationGraph, + id: KnowledgeId, + options: KnowledgeRelationWalkOptions, +): KnowledgeRelationWalkStep[] { + const index = indexOf(graph) + validateQuery(options) + validateMaxDepth(options.maxDepth) + assertKnownNode(index, id) + const steps: KnowledgeRelationWalkStep[] = [] + for (const step of traverse(index, id, options, options.maxDepth)) steps.push(step) + return steps +} + +/** + * Whether `to` is `from` or lies on a path of matching relations out of `from`. + */ +export function isReachable( + graph: KnowledgeRelationGraph, + from: KnowledgeId, + to: KnowledgeId, + query: KnowledgeRelationQuery, +): boolean { + const index = indexOf(graph) + validateQuery(query) + assertKnownNode(index, from) + assertKnownNode(index, to) + if (from === to) return true + for (const step of traverse(index, from, query, undefined)) { + if (step.nodeId === to) return true + } + return false +} + +interface RelationIndex { + readonly outgoing: ReadonlyMap + readonly incoming: ReadonlyMap + readonly known: ReadonlySet +} + +// The query index is derived once per graph object. A graph is a value: build +// a new one instead of mutating `nodes` or `edges` after the first query. +const indexes = new WeakMap() + +function indexOf(graph: KnowledgeRelationGraph): RelationIndex { + const cached = indexes.get(graph) + if (cached !== undefined) return cached + const outgoing = new Map() + const incoming = new Map() + const known = new Set() + for (const node of graph.nodes) known.add(node.id) + for (const relation of graph.edges) { + known.add(relation.sourceId) + known.add(relation.targetId) + push(outgoing, relation.sourceId, relation) + push(incoming, relation.targetId, relation) + } + const index: RelationIndex = { outgoing, incoming, known } + indexes.set(graph, index) + return index +} + +function push( + map: Map, + key: KnowledgeId, + relation: KnowledgeRelation, +): void { + const list = map.get(key) + if (list === undefined) map.set(key, [relation]) + else list.push(relation) +} + +function neighborsOf( + index: RelationIndex, + id: KnowledgeId, + query: KnowledgeRelationQuery, +): KnowledgeRelationNeighbor[] { + const matches = (relation: KnowledgeRelation): boolean => + query.predicate === undefined || relation.predicate === query.predicate + const result: KnowledgeRelationNeighbor[] = [] + if (query.direction !== 'in') { + for (const relation of index.outgoing.get(id) ?? []) { + if (matches(relation)) result.push({ nodeId: relation.targetId, relation }) + } + } + if (query.direction !== 'out') { + for (const relation of index.incoming.get(id) ?? []) { + // A self-loop already appears in the outgoing pass when both ends are followed. + if (query.direction === 'both' && relation.sourceId === id) continue + if (matches(relation)) result.push({ nodeId: relation.sourceId, relation }) + } + } + return result +} + +function* traverse( + index: RelationIndex, + start: KnowledgeId, + query: KnowledgeRelationQuery, + maxDepth: number | undefined, +): Generator { + const visited = new Set([start]) + const queue: Array<{ nodeId: KnowledgeId; depth: number }> = [{ nodeId: start, depth: 0 }] + for (let head = 0; head < queue.length; head++) { + const current = queue[head]! + if (maxDepth !== undefined && current.depth >= maxDepth) continue + for (const neighbor of neighborsOf(index, current.nodeId, query)) { + if (visited.has(neighbor.nodeId)) continue + visited.add(neighbor.nodeId) + const depth = current.depth + 1 + yield { nodeId: neighbor.nodeId, depth, from: current.nodeId, relation: neighbor.relation } + queue.push({ nodeId: neighbor.nodeId, depth }) + } + } +} + +function assertKnownNode(index: RelationIndex, id: KnowledgeId): void { + if (!index.known.has(id)) { + throw new KnowledgeRelationGraphError('unknown-node', `node "${id}" is not in the graph`) + } +} + +function validateQuery(query: KnowledgeRelationQuery): void { + if (query.direction !== 'out' && query.direction !== 'in' && query.direction !== 'both') { + throw new KnowledgeRelationGraphError( + 'invalid-query', + `direction must be "out", "in", or "both", got ${JSON.stringify(query.direction)}`, + ) + } + if (query.predicate !== undefined && !isNonEmptyString(query.predicate)) { + throw new KnowledgeRelationGraphError('invalid-query', 'predicate must be a non-empty string') + } +} + +function validateMaxDepth(maxDepth: number | undefined): void { + if (maxDepth === undefined) return + if (!Number.isInteger(maxDepth) || maxDepth < 0) { + throw new KnowledgeRelationGraphError( + 'invalid-query', + `maxDepth must be a non-negative integer, got ${JSON.stringify(maxDepth)}`, + ) + } +} + +function normalizeNode(node: KnowledgeRelationNode, position: number): KnowledgeRelationNode { + if (!isNonEmptyString(node.id)) { + throw new KnowledgeRelationGraphError('invalid-node', `node at ${position} has no id`) + } + if (!isNonEmptyString(node.kind)) { + throw new KnowledgeRelationGraphError('invalid-node', `node "${node.id}" has no kind`) + } + if (node.label !== undefined && typeof node.label !== 'string') { + throw new KnowledgeRelationGraphError('invalid-node', `node "${node.id}" label is not a string`) + } + if (node.metadata !== undefined) + assertCanonicalMetadata(node.metadata, `node "${node.id}"`, 'invalid-node') + return { + id: node.id, + kind: node.kind, + ...(node.label !== undefined ? { label: node.label } : {}), + ...(node.metadata !== undefined ? { metadata: node.metadata } : {}), + } +} + +function normalizeRelation(relation: KnowledgeRelation, position: number): KnowledgeRelation { + for (const field of ['sourceId', 'targetId', 'predicate'] as const) { + if (!isNonEmptyString(relation[field])) { + throw new KnowledgeRelationGraphError( + 'invalid-relation', + `relation at ${position} has no ${field}`, + ) + } + } + if (relation.weight !== undefined && !Number.isFinite(relation.weight)) { + throw new KnowledgeRelationGraphError( + 'invalid-relation', + `relation ${describeTriple(relation)} weight must be a finite number`, + ) + } + return { + sourceId: relation.sourceId, + targetId: relation.targetId, + predicate: relation.predicate, + ...(relation.weight !== undefined ? { weight: relation.weight } : {}), + ...(relation.metadata !== undefined ? { metadata: relation.metadata } : {}), + } +} + +function canonicalRelationJson(relation: KnowledgeRelation): string { + if (relation.metadata !== undefined) { + assertCanonicalMetadata( + relation.metadata, + `relation ${describeTriple(relation)}`, + 'invalid-relation', + ) + } + return canonicalCandidateJson({ + ...(relation.weight !== undefined ? { weight: relation.weight } : {}), + ...(relation.metadata !== undefined ? { metadata: relation.metadata } : {}), + }) +} + +function assertCanonicalMetadata( + metadata: Record, + subject: string, + code: 'invalid-node' | 'invalid-relation', +): void { + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new KnowledgeRelationGraphError(code, `${subject} metadata must be an object`) + } + try { + canonicalCandidateJson(metadata) + } catch (error) { + throw new KnowledgeRelationGraphError( + code, + `${subject} metadata must be finite, acyclic JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +function tripleKey(relation: KnowledgeRelation): string { + return JSON.stringify([relation.sourceId, relation.targetId, relation.predicate]) +} + +function describeTriple(relation: KnowledgeRelation): string { + return `${relation.sourceId} -[${relation.predicate}]-> ${relation.targetId}` +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} diff --git a/src/schemas.ts b/src/schemas.ts index e428c30..cf9c3e8 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -76,6 +76,26 @@ export const KnowledgeGraphEdgeSchema = z.object({ reasons: z.array(z.string()), }) +export const KnowledgeRelationSchema = z.object({ + sourceId: z.string(), + targetId: z.string(), + predicate: z.string(), + weight: z.number().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const KnowledgeRelationNodeSchema = z.object({ + id: z.string(), + kind: z.string(), + label: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const KnowledgeRelationGraphSchema = z.object({ + nodes: z.array(KnowledgeRelationNodeSchema), + edges: z.array(KnowledgeRelationSchema), +}) + export const KnowledgeIndexSchema = z.object({ root: z.string(), generatedAt: z.string(), @@ -206,17 +226,7 @@ export const KnowledgeBaseCandidateSchema = z.object({ }), ) .optional(), - relations: z - .array( - z.object({ - sourceId: z.string(), - targetId: z.string(), - predicate: z.string(), - weight: z.number().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - }), - ) - .optional(), + relations: z.array(KnowledgeRelationSchema).optional(), sourceIds: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), metadata: z.record(z.string(), z.unknown()).optional(), diff --git a/src/types.ts b/src/types.ts index fcffab3..e2964dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,22 @@ export interface KnowledgeRelation { metadata?: Record } +/** A caller-declared vertex of a labeled relation graph. */ +export interface KnowledgeRelationNode { + id: KnowledgeId + /** Caller vocabulary, such as `run`, `claim`, `page`, or `model`. */ + kind: string + label?: string + metadata?: Record +} + +/** A labeled multi-edge graph with one edge per `(sourceId, targetId, predicate)`. */ +export interface KnowledgeRelationGraph { + /** Declared nodes in declaration order; empty when the graph was built from relations alone. */ + nodes: KnowledgeRelationNode[] + edges: KnowledgeRelation[] +} + export interface KnowledgeUnit { id: KnowledgeId title: string