diff --git a/docs/run-scoped-citations.md b/docs/run-scoped-citations.md new file mode 100644 index 0000000..f88e2c7 --- /dev/null +++ b/docs/run-scoped-citations.md @@ -0,0 +1,106 @@ +# Run-scoped citations + +A run-scoped knowledge chain contains three visibility classes: + +1. pages written by the current run (`here`); +2. pages written by declared ancestors (`inherited:`); +3. pages in an optional curated shared store (`shared`). + +`createRunScopedStores()` preserves every visible page and its origin. It does not shadow a page merely because a nearer store has the same stable id. + +## Persisted citation form + +A page records stable page references in `cites` frontmatter: + +```yaml +--- +id: later-result +cites: + - prior-result +--- +``` + +An unqualified id is valid only when exactly one visible page has that id. When duplicate ids are intentional, qualify the origin: + +```yaml +cites: + - here::current-result + - inherited:run-2026-08-16::prior-result + - shared::instrument-calibration +``` + +Use `parseKnowledgeCitationReference()` and `formatKnowledgeCitationReference()` rather than assembling qualified strings in application code. + +## Resolution + +```ts +import { + assertCurrentRunCitationsResolved, + createRunScopedStores, + resolveRunScopedCitation, +} from '@tangle-network/agent-knowledge' + +const stores = createRunScopedStores({ + root: './runs', + sharedRoot: './curated-knowledge', +}) + +const resolved = await resolveRunScopedCitation(stores, 'run-b', { + pageId: 'prior-result', +}) + +if (resolved.status !== 'resolved') { + console.error(resolved.status, resolved.candidates) +} + +await assertCurrentRunCitationsResolved(stores, 'run-b') +``` + +Resolution has three non-coercing outcomes: + +- `resolved`: exactly one visible page matches; +- `missing`: no visible page matches; +- `ambiguous`: more than one visible page matches. + +Missing and ambiguous references remain explicit. They never select the nearest page, the newest page, or the shared page by default. + +## Product-owned lineage + +A product that already owns run ancestry should provide a `RunLineageAuthority` rather than copying its manifest into `lineage.json`: + +```ts +const stores = createRunScopedStores({ + root: './runs', + runStorePath: (runId) => `./runs/${runId}/knowledge-base`, + sharedRoot: './curated-knowledge', + lineageAuthority: { + async parentOf(runId) { + const manifest = await readRunManifest(runId) + return manifest.parentRunId + }, + }, +}) +``` + +A read-only authority must already contain the lineage before `init()` is called. `init()` verifies the requested parent against that authority and fails on disagreement. An authority that also implements `record()` may durably create the lineage itself. + +The default file-backed authority is idempotent. Reopening a run with the same parent is accepted; reopening it with another parent is a lineage conflict. + +## Lint and graph behavior + +`auditCurrentRunCitations()` checks current-run pages against one materialized visibility chain. `lintCurrentRunCitations()` converts missing, ambiguous, and self-citations into blocking package lint findings. + +Within one knowledge index, unambiguous `cites` relations become graph edges with reason `citation`. Duplicate target ids do not produce a guessed edge; the relation remains unresolved until it is qualified or the duplicate is removed. + +## Migration rule + +For an existing application-owned store: + +1. freeze the old reader and writer behavior with golden fixtures; +2. expose the existing run manifest as a `RunLineageAuthority`; +3. dual-read the same frozen corpus through both implementations; +4. classify every mismatch without coercion; +5. switch new reads and writes only after parity is demonstrated; +6. retain historical bytes and delete the duplicate live owner. + +A migration is not complete while two implementations can independently write lineage, page identities, or citation relations. diff --git a/src/citation-lint.test.ts b/src/citation-lint.test.ts new file mode 100644 index 0000000..073451d --- /dev/null +++ b/src/citation-lint.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { knowledgeCitationAuditFindings } from './citation-lint' +import { auditKnowledgeCitations } from './citation-resolution' +import type { OriginatedPage, PageOrigin } from './run-scoped' +import type { KnowledgePage } from './types' + +function page(id: string, origin: PageOrigin, path: string, cites?: string[]): OriginatedPage { + const value: KnowledgePage = { + id, + path, + title: id, + text: id, + frontmatter: { id }, + sourceIds: [], + tags: [], + outLinks: [], + ...(cites ? { cites } : {}), + } + return { page: value, origin } +} + +describe('knowledgeCitationAuditFindings', () => { + it('produces blocking missing, ambiguous, and self-citation findings', () => { + const visible = [ + page('author', 'here', 'knowledge/author.md', ['missing', 'reused', 'author']), + page('reused', 'inherited:parent', 'knowledge/parent.md'), + page('reused', 'shared', 'knowledge/shared.md'), + ] + + const findings = knowledgeCitationAuditFindings( + auditKnowledgeCitations(visible, { sourceOrigins: ['here'] }), + ) + + expect(findings.map((finding) => [finding.type, finding.severity])).toEqual([ + ['broken-citation', 'error'], + ['ambiguous-citation', 'error'], + ['broken-citation', 'error'], + ]) + expect(findings[1]?.message).toMatch(/qualify it as here::/) + expect(findings[1]?.metadata).toMatchObject({ + sourcePageId: 'author', + candidates: [ + { origin: 'inherited:parent', path: 'knowledge/parent.md' }, + { origin: 'shared', path: 'knowledge/shared.md' }, + ], + }) + }) +}) diff --git a/src/citation-lint.ts b/src/citation-lint.ts new file mode 100644 index 0000000..53c066a --- /dev/null +++ b/src/citation-lint.ts @@ -0,0 +1,59 @@ +import { + auditCurrentRunCitations, + type KnowledgeCitationAuditIssue, + type KnowledgeCitationAuditReport, +} from './citation-resolution' +import type { RunScopedStores } from './run-scoped' +import type { KnowledgeLintFinding } from './types' + +/** Convert a chain-aware citation audit into the package lint vocabulary. */ +export function knowledgeCitationAuditFindings( + report: KnowledgeCitationAuditReport, +): KnowledgeLintFinding[] { + return report.issues.map(issueToFinding) +} + +/** Lint only current-run pages against their complete declared visibility chain. */ +export async function lintCurrentRunCitations( + stores: RunScopedStores, + runId: string, +): Promise { + return knowledgeCitationAuditFindings(await auditCurrentRunCitations(stores, runId)) +} + +function issueToFinding(issue: KnowledgeCitationAuditIssue): KnowledgeLintFinding { + const candidates = issue.candidates.map((candidate) => ({ + pageId: candidate.pageId, + path: candidate.page.path, + origin: candidate.origin, + })) + if (issue.kind === 'ambiguous') { + return { + type: 'ambiguous-citation', + severity: 'error', + page: issue.sourcePath, + message: + `Citation "${issue.persistedCitation}" resolves to ${issue.candidates.length} visible pages; ` + + 'qualify it as here::, inherited:::, or shared::.', + metadata: { + sourcePageId: issue.sourcePageId, + sourceOrigin: issue.sourceOrigin, + candidates, + }, + } + } + return { + type: 'broken-citation', + severity: 'error', + page: issue.sourcePath, + message: + issue.kind === 'self' + ? `Page "${issue.sourcePageId}" cites itself through "${issue.persistedCitation}".` + : `Citation "${issue.persistedCitation}" resolves to no visible page.`, + metadata: { + sourcePageId: issue.sourcePageId, + sourceOrigin: issue.sourceOrigin, + candidates, + }, + } +} diff --git a/src/citation-resolution.test.ts b/src/citation-resolution.test.ts new file mode 100644 index 0000000..465a04e --- /dev/null +++ b/src/citation-resolution.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + assertKnowledgeCitationAudit, + assertKnowledgeCitationsResolved, + auditKnowledgeCitations, + formatKnowledgeCitationReference, + KnowledgeCitationAuditError, + KnowledgeCitationResolutionError, + parseKnowledgeCitationReference, + resolveKnowledgeCitation, + resolveKnowledgeCitations, +} from './citation-resolution' +import type { OriginatedPage, PageOrigin } from './run-scoped' +import type { KnowledgePage } from './types' + +function page(id: string, origin: PageOrigin, path = `${id}.md`, cites?: string[]): OriginatedPage { + const value: KnowledgePage = { + id, + path: `knowledge/${path}`, + title: id, + text: `knowledge for ${id}`, + frontmatter: { id }, + sourceIds: [], + tags: [], + outLinks: [], + ...(cites ? { cites } : {}), + } + return { page: value, origin } +} + +describe('knowledge citation resolution', () => { + it('resolves an unqualified id only when one visible page owns it', () => { + const resolution = resolveKnowledgeCitation( + [page('current', 'here'), page('parent', 'inherited:run-a'), page('prior', 'shared')], + { pageId: 'parent' }, + ) + + expect(resolution).toMatchObject({ + status: 'resolved', + reference: { pageId: 'parent' }, + resolved: { pageId: 'parent', origin: 'inherited:run-a' }, + }) + expect(resolution.candidates).toHaveLength(1) + expect(Object.isFrozen(resolution)).toBe(true) + }) + + it('retains a missing row instead of silently dropping it', () => { + const resolution = resolveKnowledgeCitation([page('known', 'here')], { pageId: 'missing' }) + + expect(resolution.status).toBe('missing') + expect(resolution.candidates).toEqual([]) + expect(Object.hasOwn(resolution, 'resolved')).toBe(false) + }) + + it('reports an unqualified id as ambiguous across visible stores', () => { + const visible = [ + page('reused', 'here', 'current.md'), + page('reused', 'inherited:run-a', 'ancestor.md'), + page('reused', 'shared', 'shared.md'), + ] + + const resolution = resolveKnowledgeCitation(visible, { pageId: 'reused' }) + + expect(resolution.status).toBe('ambiguous') + expect(resolution.candidates.map((candidate) => candidate.origin)).toEqual([ + 'here', + 'inherited:run-a', + 'shared', + ]) + }) + + it('uses an explicit origin to disambiguate an intentional id reuse', () => { + const visible = [ + page('reused', 'here', 'current.md'), + page('reused', 'inherited:run-a', 'ancestor.md'), + page('reused', 'shared', 'shared.md'), + ] + + const resolution = resolveKnowledgeCitation(visible, { + pageId: 'reused', + origin: 'inherited:run-a', + }) + + expect(resolution).toMatchObject({ + status: 'resolved', + resolved: { pageId: 'reused', origin: 'inherited:run-a' }, + }) + }) + + it('round-trips persisted origin qualifiers', () => { + const references = [ + { pageId: 'plain' }, + { pageId: 'current', origin: 'here' as const }, + { pageId: 'prior', origin: 'shared' as const }, + { pageId: 'parent', origin: 'inherited:run-a' as const }, + ] + + expect( + references.map((reference) => + parseKnowledgeCitationReference(formatKnowledgeCitationReference(reference)), + ), + ).toEqual(references) + expect(parseKnowledgeCitationReference('unknown-prefix::still-one-page-id')).toEqual({ + pageId: 'unknown-prefix::still-one-page-id', + }) + }) + + it('fails a batch with exact missing and ambiguity diagnostics', () => { + const visible = [ + page('unique', 'here'), + page('reused', 'here', 'current.md'), + page('reused', 'shared', 'shared.md'), + ] + const references = [{ pageId: 'unique' }, { pageId: 'missing' }, { pageId: 'reused' }] + + expect(resolveKnowledgeCitations(visible, references).map((row) => row.status)).toEqual([ + 'resolved', + 'missing', + 'ambiguous', + ]) + expect(() => assertKnowledgeCitationsResolved(visible, references)).toThrow( + /missing: missing; ambiguous: reused \(2 matches\)/, + ) + try { + assertKnowledgeCitationsResolved(visible, references) + throw new Error('expected citation resolution to fail') + } catch (error) { + expect(error).toBeInstanceOf(KnowledgeCitationResolutionError) + expect( + (error as KnowledgeCitationResolutionError).resolutions.map((row) => row.status), + ).toEqual(['missing', 'ambiguous']) + } + }) + + it('audits persisted citations without silently shadowing duplicate ids', () => { + const visible = [ + page('current', 'here', 'current.md', [ + 'unique', + 'missing', + 'reused', + 'shared::reused', + 'current', + ]), + page('unique', 'inherited:run-a'), + page('reused', 'inherited:run-a', 'ancestor.md'), + page('reused', 'shared', 'shared.md'), + ] + + const report = auditKnowledgeCitations(visible, { sourceOrigins: ['here'] }) + + expect(report.checkedPages).toBe(1) + expect(report.checkedCitations).toBe(5) + expect(report.issues.map((issue) => [issue.persistedCitation, issue.kind])).toEqual([ + ['missing', 'missing'], + ['reused', 'ambiguous'], + ['current', 'self'], + ]) + expect(report.ok).toBe(false) + expect(() => assertKnowledgeCitationAudit(report)).toThrow(KnowledgeCitationAuditError) + }) + + it('refuses malformed references before matching', () => { + expect(() => resolveKnowledgeCitation([], { pageId: ' ' })).toThrow(/non-empty string/) + expect(() => + resolveKnowledgeCitation([], { pageId: 'known', origin: 'inherited:' as never }), + ).toThrow(/origin is invalid/) + expect(() => parseKnowledgeCitationReference(' ')).toThrow(/non-empty string/) + }) +}) diff --git a/src/citation-resolution.ts b/src/citation-resolution.ts new file mode 100644 index 0000000..66c45c0 --- /dev/null +++ b/src/citation-resolution.ts @@ -0,0 +1,307 @@ +import type { OriginatedPage, PageOrigin, RunScopedStores } from './run-scoped' +import type { KnowledgeId, KnowledgePage } from './types' + +/** + * A citation into the knowledge visible to one run. + * + * An unqualified reference is accepted only when exactly one visible page has + * the requested id. Callers can qualify a reference by origin when a current, + * inherited, or shared page intentionally reuses the same stable id. + */ +export interface KnowledgeCitationReference { + readonly pageId: KnowledgeId + readonly origin?: PageOrigin +} + +export interface KnowledgeCitationCandidate { + readonly pageId: KnowledgeId + readonly origin: PageOrigin + readonly page: KnowledgePage +} + +export type KnowledgeCitationResolutionStatus = 'resolved' | 'missing' | 'ambiguous' + +export interface KnowledgeCitationResolution { + readonly reference: KnowledgeCitationReference + readonly status: KnowledgeCitationResolutionStatus + readonly candidates: readonly KnowledgeCitationCandidate[] + readonly resolved?: KnowledgeCitationCandidate +} + +export type KnowledgeCitationAuditIssueKind = 'self' | 'missing' | 'ambiguous' + +export interface KnowledgeCitationAuditIssue { + readonly kind: KnowledgeCitationAuditIssueKind + readonly sourcePageId: KnowledgeId + readonly sourcePath: string + readonly sourceOrigin: PageOrigin + readonly persistedCitation: string + readonly reference: KnowledgeCitationReference + readonly candidates: readonly KnowledgeCitationCandidate[] +} + +export interface KnowledgeCitationAuditReport { + readonly ok: boolean + readonly checkedPages: number + readonly checkedCitations: number + readonly issues: readonly KnowledgeCitationAuditIssue[] +} + +export interface AuditKnowledgeCitationsOptions { + /** Limit audited source pages to these origins. All visible origins are checked by default. */ + readonly sourceOrigins?: readonly PageOrigin[] +} + +/** A batch contains at least one citation that is missing or ambiguous. */ +export class KnowledgeCitationResolutionError extends Error { + readonly resolutions: readonly KnowledgeCitationResolution[] + + constructor(resolutions: readonly KnowledgeCitationResolution[]) { + const unresolved = resolutions.filter((resolution) => resolution.status !== 'resolved') + const missing = unresolved + .filter((resolution) => resolution.status === 'missing') + .map((resolution) => formatKnowledgeCitationReference(resolution.reference)) + const ambiguous = unresolved + .filter((resolution) => resolution.status === 'ambiguous') + .map( + (resolution) => + `${formatKnowledgeCitationReference(resolution.reference)} (${resolution.candidates.length} matches)`, + ) + const parts = [ + missing.length > 0 ? `missing: ${missing.join(', ')}` : '', + ambiguous.length > 0 ? `ambiguous: ${ambiguous.join(', ')}` : '', + ].filter(Boolean) + super(`knowledge citation resolution failed: ${parts.join('; ')}`) + this.name = 'KnowledgeCitationResolutionError' + this.resolutions = Object.freeze([...unresolved]) + } +} + +/** A persisted page contains a self, missing, or ambiguous citation. */ +export class KnowledgeCitationAuditError extends Error { + readonly report: KnowledgeCitationAuditReport + + constructor(report: KnowledgeCitationAuditReport) { + const counts = new Map() + for (const issue of report.issues) counts.set(issue.kind, (counts.get(issue.kind) ?? 0) + 1) + const summary = [...counts.entries()].map(([kind, count]) => `${kind}=${count}`).join(', ') + super(`knowledge citation audit failed: ${summary}`) + this.name = 'KnowledgeCitationAuditError' + this.report = report + } +} + +/** + * Parse the persisted citation form. + * + * `page-id` is unqualified. `here::page-id`, `shared::page-id`, and + * `inherited:::page-id` bind an intentional duplicate to one origin. + */ +export function parseKnowledgeCitationReference(value: string): KnowledgeCitationReference { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError('persisted knowledge citation must be a non-empty string') + } + const normalized = value.trim() + const separator = normalized.indexOf('::') + if (separator < 0) return Object.freeze({ pageId: normalized }) + const possibleOrigin = normalized.slice(0, separator) + const pageId = normalized.slice(separator + 2) + if (!isPageOrigin(possibleOrigin)) { + return Object.freeze({ pageId: normalized }) + } + return normalizeReference({ pageId, origin: possibleOrigin }) +} + +/** Serialize one reference into the canonical frontmatter representation. */ +export function formatKnowledgeCitationReference(reference: KnowledgeCitationReference): string { + const normalized = normalizeReference(reference) + return normalized.origin === undefined + ? normalized.pageId + : `${normalized.origin}::${normalized.pageId}` +} + +/** Resolve one reference against an already materialized visibility chain. */ +export function resolveKnowledgeCitation( + visiblePages: readonly OriginatedPage[], + reference: KnowledgeCitationReference, +): KnowledgeCitationResolution { + const normalized = normalizeReference(reference) + const candidates = visiblePages + .filter( + (entry) => + entry.page.id === normalized.pageId && + (normalized.origin === undefined || entry.origin === normalized.origin), + ) + .map((entry) => + Object.freeze({ + pageId: entry.page.id, + origin: entry.origin, + page: entry.page, + }), + ) + const status: KnowledgeCitationResolutionStatus = + candidates.length === 0 ? 'missing' : candidates.length === 1 ? 'resolved' : 'ambiguous' + return Object.freeze({ + reference: normalized, + status, + candidates: Object.freeze(candidates), + ...(status === 'resolved' ? { resolved: candidates[0]! } : {}), + }) +} + +/** Resolve a batch without discarding missing or ambiguous rows. */ +export function resolveKnowledgeCitations( + visiblePages: readonly OriginatedPage[], + references: readonly KnowledgeCitationReference[], +): readonly KnowledgeCitationResolution[] { + return Object.freeze( + references.map((reference) => resolveKnowledgeCitation(visiblePages, reference)), + ) +} + +/** + * Resolve a batch and fail closed unless every reference names exactly one + * visible page. Returned candidates preserve the caller's reference order. + */ +export function assertKnowledgeCitationsResolved( + visiblePages: readonly OriginatedPage[], + references: readonly KnowledgeCitationReference[], +): readonly KnowledgeCitationCandidate[] { + const resolutions = resolveKnowledgeCitations(visiblePages, references) + if (resolutions.some((resolution) => resolution.status !== 'resolved')) { + throw new KnowledgeCitationResolutionError(resolutions) + } + return Object.freeze(resolutions.map((resolution) => resolution.resolved!)) +} + +/** Audit persisted `cites` relations against one immutable visibility snapshot. */ +export function auditKnowledgeCitations( + visiblePages: readonly OriginatedPage[], + options: AuditKnowledgeCitationsOptions = {}, +): KnowledgeCitationAuditReport { + const origins = options.sourceOrigins + ? new Set(options.sourceOrigins.map((origin) => validatePageOrigin(origin))) + : null + const sources = visiblePages.filter((entry) => origins === null || origins.has(entry.origin)) + const issues: KnowledgeCitationAuditIssue[] = [] + let checkedCitations = 0 + + for (const source of sources) { + for (const persistedCitation of new Set(source.page.cites ?? [])) { + checkedCitations += 1 + const reference = parseKnowledgeCitationReference(persistedCitation) + const resolution = resolveKnowledgeCitation(visiblePages, reference) + const self = resolution.candidates.some( + (candidate) => + candidate.page === source.page || + (candidate.origin === source.origin && candidate.page.path === source.page.path), + ) + const kind: KnowledgeCitationAuditIssueKind | null = self + ? 'self' + : resolution.status === 'missing' + ? 'missing' + : resolution.status === 'ambiguous' + ? 'ambiguous' + : null + if (kind === null) continue + issues.push( + Object.freeze({ + kind, + sourcePageId: source.page.id, + sourcePath: source.page.path, + sourceOrigin: source.origin, + persistedCitation, + reference, + candidates: resolution.candidates, + }), + ) + } + } + + return Object.freeze({ + ok: issues.length === 0, + checkedPages: sources.length, + checkedCitations, + issues: Object.freeze(issues), + }) +} + +/** Audit and fail closed when any persisted citation is unresolved. */ +export function assertKnowledgeCitationAudit(report: KnowledgeCitationAuditReport): void { + if (!report.ok) throw new KnowledgeCitationAuditError(report) +} + +/** Resolve one citation using a run-scoped store's declared visibility chain. */ +export async function resolveRunScopedCitation( + stores: RunScopedStores, + runId: string, + reference: KnowledgeCitationReference, +): Promise { + return resolveKnowledgeCitation(await stores.loadChain(runId), reference) +} + +/** Resolve citations using one chain read so every row sees the same snapshot. */ +export async function resolveRunScopedCitations( + stores: RunScopedStores, + runId: string, + references: readonly KnowledgeCitationReference[], +): Promise { + return resolveKnowledgeCitations(await stores.loadChain(runId), references) +} + +/** Resolve a run-scoped batch and fail closed on any missing or ambiguous id. */ +export async function assertRunScopedCitationsResolved( + stores: RunScopedStores, + runId: string, + references: readonly KnowledgeCitationReference[], +): Promise { + return assertKnowledgeCitationsResolved(await stores.loadChain(runId), references) +} + +/** Audit only pages authored in the current run against the full visible chain. */ +export async function auditCurrentRunCitations( + stores: RunScopedStores, + runId: string, +): Promise { + return auditKnowledgeCitations(await stores.loadChain(runId), { sourceOrigins: ['here'] }) +} + +/** Audit current-run citations and fail closed on every unresolved relation. */ +export async function assertCurrentRunCitationsResolved( + stores: RunScopedStores, + runId: string, +): Promise { + const report = await auditCurrentRunCitations(stores, runId) + assertKnowledgeCitationAudit(report) + return report +} + +function normalizeReference(reference: KnowledgeCitationReference): KnowledgeCitationReference { + if (!reference || typeof reference !== 'object') { + throw new TypeError('knowledge citation reference must be an object') + } + if (typeof reference.pageId !== 'string' || reference.pageId.trim().length === 0) { + throw new TypeError('knowledge citation pageId must be a non-empty string') + } + if (reference.origin !== undefined) validatePageOrigin(reference.origin) + return Object.freeze({ + pageId: reference.pageId.trim(), + ...(reference.origin === undefined ? {} : { origin: reference.origin }), + }) +} + +function validatePageOrigin(value: PageOrigin): PageOrigin { + if (!isPageOrigin(value)) { + throw new TypeError(`knowledge citation origin is invalid: ${String(value)}`) + } + return value +} + +function isPageOrigin(value: unknown): value is PageOrigin { + if (value === 'here' || value === 'shared') return true + return ( + typeof value === 'string' && + value.startsWith('inherited:') && + value.slice('inherited:'.length).trim().length > 0 + ) +} diff --git a/src/citation-storage.test.ts b/src/citation-storage.test.ts new file mode 100644 index 0000000..f5be302 --- /dev/null +++ b/src/citation-storage.test.ts @@ -0,0 +1,152 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { buildKnowledgeGraph } from './graph' +import { KnowledgeIndexSchema } from './schemas' +import { initKnowledgeBase, loadKnowledgePages } from './store' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('knowledge page citations', () => { + it('parses stable ids and projects citation edges into the validated index', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'prior.md'), + ['---', 'id: prior-result', 'title: Prior result', '---', 'The measured prior.', ''].join( + '\n', + ), + ) + await writeFile( + join(root, 'knowledge', 'new.md'), + [ + '---', + 'id: new-result', + 'title: New result', + 'cites:', + ' - prior-result', + ' - prior-result', + '---', + 'This result builds on the prior.', + '', + ].join('\n'), + ) + + const pages = await loadKnowledgePages(root) + const current = pages.find((page) => page.id === 'new-result') + expect(current?.cites).toEqual(['prior-result']) + + const graph = buildKnowledgeGraph(pages) + expect(graph.edges).toContainEqual({ + source: 'new-result', + target: 'prior-result', + weight: 1, + reasons: ['citation'], + }) + expect(graph.nodes.find((node) => node.id === 'new-result')?.outDegree).toBe(1) + expect(graph.nodes.find((node) => node.id === 'prior-result')?.inDegree).toBe(1) + + expect(() => + KnowledgeIndexSchema.parse({ + root, + generatedAt: '2026-08-17T00:00:00.000Z', + sources: [], + pages, + graph, + }), + ).not.toThrow() + }) + + it('keeps the graph edge when a citation is origin-qualified', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'prior.md'), + ['---', 'id: prior-result', 'title: Prior result', '---', 'The measured prior.', ''].join( + '\n', + ), + ) + await writeFile( + join(root, 'knowledge', 'new.md'), + [ + '---', + 'id: new-result', + 'title: New result', + 'cites:', + ' - here::prior-result', + '---', + 'Qualified per the ambiguity remedy.', + '', + ].join('\n'), + ) + + const graph = buildKnowledgeGraph(await loadKnowledgePages(root)) + expect(graph.edges).toContainEqual({ + source: 'new-result', + target: 'prior-result', + weight: 1, + reasons: ['citation'], + }) + }) + + it('drops a malformed citation without failing the graph build', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'broken.md'), + [ + '---', + 'id: broken-citer', + 'title: Broken citer', + 'cites:', + ' - "here::"', + '---', + 'Origin with an empty page id.', + '', + ].join('\n'), + ) + + const graph = buildKnowledgeGraph(await loadKnowledgePages(root)) + expect(graph.edges.filter((edge) => edge.source === 'broken-citer')).toEqual([]) + }) + + it('does not project an ambiguous duplicate id into a false graph edge', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'first.md'), + ['---', 'id: reused', 'title: First', '---', 'First page.', ''].join('\n'), + ) + await writeFile( + join(root, 'knowledge', 'second.md'), + ['---', 'id: reused', 'title: Second', '---', 'Second page.', ''].join('\n'), + ) + await writeFile( + join(root, 'knowledge', 'consumer.md'), + [ + '---', + 'id: consumer', + 'title: Consumer', + 'cites:', + ' - reused', + '---', + 'Ambiguous citation.', + '', + ].join('\n'), + ) + + const graph = buildKnowledgeGraph(await loadKnowledgePages(root)) + expect(graph.edges.some((edge) => edge.source === 'consumer' && edge.target === 'reused')).toBe( + false, + ) + }) +}) diff --git a/src/graph.ts b/src/graph.ts index 44c45a4..3a1fe2a 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,11 +1,12 @@ +import { parseKnowledgeCitationReference } from './citation-resolution' import type { KnowledgeGraph, KnowledgeGraphEdge, KnowledgeGraphNode, KnowledgePage } from './types' import { normalizeLinkTarget } from './wikilinks' export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { - const byId = new Map() const bySlug = new Map() + const byId = new Map() for (const page of pages) { - byId.set(page.id, page) + byId.set(page.id, [...(byId.get(page.id) ?? []), page]) bySlug.set(normalizeLinkTarget(page.id), page) bySlug.set(normalizeLinkTarget(page.title), page) bySlug.set(normalizeLinkTarget(page.path.split('/').pop()!.replace(/\.md$/, '')), page) @@ -23,18 +24,12 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { for (const raw of page.outLinks) { const target = bySlug.get(normalizeLinkTarget(raw)) if (!target || target.id === page.id) continue - const key = `${page.id}->${target.id}` - const edge = edgesByKey.get(key) - if (edge) edge.weight += 1 - else - edgesByKey.set(key, { - source: page.id, - target: target.id, - weight: 1, - reasons: ['wikilink'], - }) - outgoing.set(page.id, (outgoing.get(page.id) ?? 0) + 1) - incoming.set(target.id, (incoming.get(target.id) ?? 0) + 1) + addDirectedEdge(page, target, 'wikilink', edgesByKey, incoming, outgoing) + } + 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) } } @@ -52,6 +47,45 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { return { nodes, edges: [...edgesByKey.values()].sort((a, b) => b.weight - a.weight) } } +/** + * An origin-qualified citation (`here::x`, `inherited:::x`, `shared::x`) + * must keep its graph edge: qualification is the documented remedy for an + * ambiguous id, so it cannot cost the citation signal. A value the parser + * rejects stays a literal page id so index builds never fail on stored data. + */ +function citedPageId(persisted: string): string { + try { + return parseKnowledgeCitationReference(persisted).pageId + } catch { + 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, @@ -66,7 +100,7 @@ function addSourceOverlapEdges( const edge = edges.get(key) if (edge) { edge.weight += overlap.length * 0.5 - edge.reasons.push('shared-source') + if (!edge.reasons.includes('shared-source')) edge.reasons.push('shared-source') } else { edges.set(key, { source: a.id, diff --git a/src/index.ts b/src/index.ts index f483522..921c532 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ export * from './agent-candidate' export * from './benchmarks/index' export * from './changes' export * from './chunking' +export * from './citation-lint' +export * from './citation-resolution' export * from './claim-evidence' export * from './claim-grounding' export * from './claim-ledger' diff --git a/src/run-scoped.test.ts b/src/run-scoped.test.ts index 88e46c3..ec88c1a 100644 --- a/src/run-scoped.test.ts +++ b/src/run-scoped.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createRunScopedStores } from './run-scoped' +import { createRunScopedStores, type RunLineageAuthority } from './run-scoped' let root: string let shared: string @@ -67,10 +67,99 @@ describe('createRunScopedStores', () => { await expect(stores.loadChain('child')).resolves.toEqual([]) }) + it('reopening a run is idempotent and refuses a different parent', async () => { + const stores = createRunScopedStores({ root }) + await stores.init('child', { parentRunId: 'parent-a' }) + await expect(stores.init('child', { parentRunId: 'parent-a' })).resolves.toBeDefined() + await expect(stores.init('child', { parentRunId: 'parent-b' })).rejects.toThrow( + /lineage conflict/, + ) + }) + + it('reads ancestry from an external product-owned authority', async () => { + const parents = new Map([ + ['parent', null], + ['child', 'parent'], + ]) + const authority: RunLineageAuthority = { + async parentOf(runId) { + return parents.get(runId) ?? null + }, + } + const stores = createRunScopedStores({ root, lineageAuthority: authority }) + await stores.init('parent') + await addPage(join(root, 'parent', 'knowledge-base'), 'parent-page.md', 'owned by parent') + await stores.init('child', { parentRunId: 'parent' }) + await addPage(join(root, 'child', 'knowledge-base'), 'child-page.md', 'owned by child') + + await expect(stores.lineage('child')).resolves.toEqual(['parent']) + const chain = await stores.loadChain('child') + expect(chain.map((entry) => [entry.page.title, entry.origin])).toEqual([ + ['child-page.md', 'here'], + ['parent-page.md', 'inherited:parent'], + ]) + }) + + it('fails closed without creating files when a read-only authority disagrees', async () => { + const authority: RunLineageAuthority = { + async parentOf() { + return 'registered-parent' + }, + } + const stores = createRunScopedStores({ root, lineageAuthority: authority }) + const rejectedStore = join(root, 'child', 'knowledge-base') + + await expect(stores.init('child', { parentRunId: 'different-parent' })).rejects.toThrow( + /external lineage authority disagrees/, + ) + await expect(access(rejectedStore)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('refuses a lineage cycle loudly', async () => { const stores = createRunScopedStores({ root }) await stores.init('a', { parentRunId: 'b' }) await stores.init('b', { parentRunId: 'a' }) await expect(stores.lineage('a')).rejects.toThrow(/cycle/) }) + + it('rejects a run id that would escape the store root', async () => { + const stores = createRunScopedStores({ root }) + await expect(stores.init('../escaped-run')).rejects.toThrow(/path separators or dot segments/) + await expect(stores.init('..')).rejects.toThrow(/path separators or dot segments/) + await expect(access(join(root, '..', 'escaped-run'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('resolves a chain of exactly the declared safety bound', async () => { + const parents = new Map() + for (let index = 0; index < 65; index++) { + parents.set(`run-${index}`, index === 64 ? null : `run-${index + 1}`) + } + const stores = createRunScopedStores({ + root, + lineageAuthority: { + async parentOf(runId) { + return parents.get(runId) ?? null + }, + }, + }) + + await expect(stores.lineage('run-0')).resolves.toHaveLength(64) + }) + + it('refuses an ancestry chain beyond the declared safety bound', async () => { + const parents = new Map() + for (let index = 0; index < 66; index++) { + parents.set(`run-${index}`, index === 65 ? null : `run-${index + 1}`) + } + const stores = createRunScopedStores({ + root, + lineageAuthority: { + async parentOf(runId) { + return parents.get(runId) ?? null + }, + }, + }) + + await expect(stores.lineage('run-0')).rejects.toThrow(/exceeds 64 ancestors/) + }) }) diff --git a/src/run-scoped.ts b/src/run-scoped.ts index f6b0e2d..af832fa 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -1,20 +1,13 @@ /** * Run-scoped knowledge stores with lineage-chain reads. * - * One store per run, physically isolated, with inheritance only by declared ancestry. The design - * answers a failure mode a discovery campaign paid for twice: when every run writes one shared - * store, a false "measured" claim from one arm becomes the next arm's settled provenance — twice - * in that campaign a wrong claim crossed run boundaries and became standing instruction before - * anyone re-derived it. Isolation makes "what did THIS run learn" a directory listing, and the - * knowledge delta between two runs a diff of two directories. - * - * A branch still inherits: reads walk the run's own store, then each ancestor up a recorded - * parent chain, then an optional shared store — every page labeled with where it came from, - * because "I established this", "an earlier attempt believed this", and "the lab curates this" - * are three different provenance claims and a reader must be able to weigh them differently. + * One store exists per run, physically isolated, with inheritance only through + * declared ancestry. Reads expose the current store, every declared ancestor, + * and an optional curated shared store with an explicit origin label. */ import { join } from 'node:path' import { isMissingFile, readRegularFileWithinRoot } from './durable-fs' +import { withKnowledgeMutation } from './mutation-lock' import type { KnowledgeLayout } from './store' import { initKnowledgeBase, loadKnowledgePages, writeJson } from './store' import type { KnowledgePage } from './types' @@ -33,6 +26,19 @@ export interface RunLineageRecord { createdAt: string } +/** + * Durable authority for run ancestry. + * + * A product whose run manifest already owns lineage can supply an authority + * backed by that manifest. `record` is optional for read-only authorities; in + * that case `init` verifies that the existing authority agrees with the parent + * requested by the caller before creating any store files. + */ +export interface RunLineageAuthority { + parentOf(runId: string): Promise + record?(record: RunLineageRecord): Promise +} + export const RUN_LINEAGE_BASENAME = 'lineage.json' export interface RunScopedStoresOptions { @@ -41,73 +47,103 @@ export interface RunScopedStoresOptions { /** Store path for a run; defaults to `//knowledge-base`. */ runStorePath?: (runId: string) => string /** - * The curated store every run may read but no run writes — promotion into it is a deliberate - * act outside this module. Searched last, labeled `shared`. + * The curated store every run may read but no run writes. Searched last and + * labeled `shared`. */ sharedRoot?: string + /** External owner for run ancestry. Defaults to a record inside each run store. */ + lineageAuthority?: RunLineageAuthority } -/** Ancestry chains longer than this indicate a cycle or a runaway, not a real lineage. */ +/** Ancestry beyond this bound is invalid durable state. */ const MAX_LINEAGE_HOPS = 64 export interface RunScopedStores { - /** Create (or open) a run's store, recording its parent at creation time. */ + /** Create or open a run store and bind it to one exact parent identity. */ init(runId: string, options?: { parentRunId?: string | null }): Promise - /** The ancestor chain of a run, nearest first, resolved from records written at init. */ + /** The ancestor chain of a run, nearest first. */ lineage(runId: string): Promise /** - * Every page visible to a run: its own, then each ancestor's, then the shared store's, each - * labeled with its origin. Later stores never shadow earlier ones — a reader sees both copies - * of a twice-recorded claim and the labels that distinguish them. + * Every page visible to a run: current, ancestors, then shared. A repeated + * page id is retained at every origin so citation resolution can report the + * ambiguity instead of silently shadowing one page. */ loadChain(runId: string): Promise } export function createRunScopedStores(options: RunScopedStoresOptions): RunScopedStores { + if (!options || typeof options !== 'object') { + throw new TypeError('createRunScopedStores options are required') + } + if (typeof options.root !== 'string' || options.root.trim().length === 0) { + throw new TypeError('createRunScopedStores root must be a non-empty string') + } + if (options.runStorePath !== undefined && typeof options.runStorePath !== 'function') { + throw new TypeError('createRunScopedStores runStorePath must be a function when present') + } + if (options.lineageAuthority !== undefined) validateLineageAuthority(options.lineageAuthority) + const storePath = options.runStorePath ?? ((runId: string) => join(options.root, runId, 'knowledge-base')) + const internalAuthority = createFileRunLineageAuthority(storePath) + const authority = options.lineageAuthority ?? internalAuthority - async function parentOf(runId: string): Promise { - try { - const snapshot = await readRegularFileWithinRoot(storePath(runId), RUN_LINEAGE_BASENAME) - return (JSON.parse(snapshot.bytes.toString('utf8')) as RunLineageRecord).parentRunId - } catch (error) { - // A run created before lineage recording (or outside it) simply ends the chain: absent - // ancestry is absent, not an error — the chain is only as deep as what was declared. - if (isMissingFile(error)) return null - throw error + const resolveLineage = async (runId: string): Promise => { + assertRunId(runId) + const chain: string[] = [] + const seen = new Set([runId]) + let current = runId + // One query more than the bound: a chain of exactly MAX_LINEAGE_HOPS + // ancestors needs the terminating null query to prove it ends. + for (let hop = 0; hop <= MAX_LINEAGE_HOPS; hop += 1) { + const parent = await authority.parentOf(current) + if (parent === null) return chain + assertRunId(parent, `parent of '${current}'`) + if (seen.has(parent)) { + throw new Error(`run lineage cycle: ${parent} is its own ancestor (via ${runId})`) + } + seen.add(parent) + chain.push(parent) + current = parent } + throw new Error(`run lineage for '${runId}' exceeds ${MAX_LINEAGE_HOPS} ancestors`) } return { async init(runId, initOptions = {}) { + assertRunId(runId) + const parentRunId = initOptions.parentRunId ?? null + if (parentRunId !== null) assertRunId(parentRunId, `parent of '${runId}'`) + + // A read-only product authority already owns the identity. Verify it + // before `initKnowledgeBase` creates directories or scaffold files, so a + // rejected lineage request leaves no partial knowledge store behind. + if (!authority.record) { + const existingParent = await authority.parentOf(runId) + if (existingParent !== parentRunId) { + throw new Error( + `external lineage authority disagrees for '${runId}': expected ${renderParent(parentRunId)}, observed ${renderParent(existingParent)}`, + ) + } + } + const layout = await initKnowledgeBase(storePath(runId)) - const record: RunLineageRecord = { - runId, - parentRunId: initOptions.parentRunId ?? null, - createdAt: new Date().toISOString(), + if (authority.record) { + await authority.record( + Object.freeze({ + runId, + parentRunId, + createdAt: new Date().toISOString(), + }), + ) } - await writeJson(join(storePath(runId), RUN_LINEAGE_BASENAME), record) return layout }, - async lineage(runId) { - const chain: string[] = [] - const seen = new Set([runId]) - let current: string | null = runId - for (let hop = 0; hop < MAX_LINEAGE_HOPS && current !== null; hop += 1) { - current = await parentOf(current) - if (current === null) break - if (seen.has(current)) { - throw new Error(`run lineage cycle: ${current} is its own ancestor (via ${runId})`) - } - seen.add(current) - chain.push(current) - } - return chain - }, + lineage: resolveLineage, async loadChain(runId) { + assertRunId(runId) const out: OriginatedPage[] = [] const readInto = async (root: string, origin: PageOrigin) => { let pages: KnowledgePage[] @@ -120,7 +156,7 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope for (const page of pages) out.push({ page, origin }) } await readInto(storePath(runId), 'here') - for (const ancestor of await this.lineage(runId)) { + for (const ancestor of await resolveLineage(runId)) { await readInto(storePath(ancestor), `inherited:${ancestor}`) } if (options.sharedRoot) await readInto(options.sharedRoot, 'shared') @@ -128,3 +164,114 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope }, } } + +/** File-backed authority used when the product has no separate run manifest. */ +export function createFileRunLineageAuthority( + runStorePath: (runId: string) => string, +): RunLineageAuthority { + if (typeof runStorePath !== 'function') { + throw new TypeError('createFileRunLineageAuthority requires a runStorePath function') + } + + const readRecord = async (runId: string): Promise => { + assertRunId(runId) + const root = runStorePath(runId) + try { + const snapshot = await readRegularFileWithinRoot(root, RUN_LINEAGE_BASENAME) + return parseLineageRecord(snapshot.bytes.toString('utf8'), runId) + } catch (error) { + if (isMissingFile(error)) return null + throw error + } + } + + return { + async parentOf(runId) { + return (await readRecord(runId))?.parentRunId ?? null + }, + + async record(record) { + const validated = validateLineageRecord(record) + const root = runStorePath(validated.runId) + await withKnowledgeMutation(root, async () => { + const existing = await readRecord(validated.runId) + if (existing) { + if (existing.parentRunId !== validated.parentRunId) { + throw new Error( + `run lineage conflict for '${validated.runId}': existing parent ${renderParent(existing.parentRunId)}, requested ${renderParent(validated.parentRunId)}`, + ) + } + return + } + await writeJson(join(root, RUN_LINEAGE_BASENAME), validated) + }) + }, + } +} + +function validateLineageAuthority(authority: RunLineageAuthority): void { + if (!authority || typeof authority !== 'object') { + throw new TypeError('lineageAuthority must be an object') + } + if (typeof authority.parentOf !== 'function') { + throw new TypeError('lineageAuthority.parentOf must be a function') + } + if (authority.record !== undefined && typeof authority.record !== 'function') { + throw new TypeError('lineageAuthority.record must be a function when present') + } +} + +function parseLineageRecord(text: string, expectedRunId: string): RunLineageRecord { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (cause) { + throw new Error(`invalid lineage JSON for '${expectedRunId}'`, { cause }) + } + const record = validateLineageRecord(parsed) + if (record.runId !== expectedRunId) { + throw new Error( + `lineage record identity mismatch: expected '${expectedRunId}', found '${record.runId}'`, + ) + } + return record +} + +function validateLineageRecord(value: unknown): RunLineageRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('run lineage record must be an object') + } + const record = value as Record + assertRunId(record.runId, 'lineage record runId') + if (record.parentRunId !== null) { + assertRunId(record.parentRunId, `parent of '${record.runId}'`) + } + if ( + typeof record.createdAt !== 'string' || + record.createdAt.trim().length === 0 || + !Number.isFinite(Date.parse(record.createdAt)) + ) { + throw new TypeError('run lineage record createdAt must be an ISO timestamp') + } + return { + runId: record.runId, + parentRunId: record.parentRunId, + createdAt: record.createdAt, + } +} + +function assertRunId(value: unknown, label = 'runId'): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`${label} must be a non-empty string`) + } + // A run id becomes a path segment under the store root. A separator or dot + // segment would escape the root and break the physical-isolation promise. + const trimmed = value.trim() + if (trimmed === '.' || trimmed === '..' || /[/\\\0]/.test(value)) { + throw new TypeError(`${label} must not contain path separators or dot segments`) + } +} + +function renderParent(parentRunId: string | null): string { + return parentRunId === null ? 'none' : `'${parentRunId}'` +} diff --git a/src/schemas.ts b/src/schemas.ts index 28334bb..e428c30 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -54,6 +54,7 @@ export const KnowledgePageSchema = z.object({ sourceIds: z.array(z.string()), tags: z.array(z.string()), outLinks: z.array(z.string()), + cites: z.array(z.string().min(1)).optional(), contradicts: z.array(z.string().min(1)).optional(), invalidation: KnowledgePageInvalidationSchema.optional(), }) diff --git a/src/store.ts b/src/store.ts index 8959a33..a46a02b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -121,6 +121,7 @@ async function loadKnowledgePagesUnlocked( rel.split('/').pop()!.replace(/\.md$/, '') const sourceIds = arrayField(frontmatter.sources) const tags = arrayField(frontmatter.tags) + const cites = idListField(frontmatter.cites) const contradicts = idListField(frontmatter.contradicts) const invalidation = KnowledgePageInvalidationSchema.safeParse(frontmatter.invalidation) const pageRelativePath = rel.startsWith(pagesPrefix) ? rel.slice(pagesPrefix.length) : rel @@ -133,6 +134,7 @@ async function loadKnowledgePagesUnlocked( sourceIds, tags, outLinks: extractWikilinks(body).map(normalizeLinkTarget), + ...(cites.length > 0 ? { cites } : {}), ...(contradicts.length > 0 ? { contradicts } : {}), ...(invalidation.success ? { invalidation: invalidation.data } : {}), }) diff --git a/src/types.ts b/src/types.ts index 79b9ae7..fcffab3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,8 @@ export interface KnowledgePage { sourceIds: string[] tags: string[] outLinks: string[] + /** Stable page ids this page explicitly builds on. */ + cites?: KnowledgeId[] /** Page ids this page explicitly refutes. */ contradicts?: KnowledgeId[] /** Present only when this page's own evidence has refuted the page. */ @@ -158,6 +160,8 @@ export interface KnowledgeSearchResult { export interface KnowledgeLintFinding { type: | 'broken-link' + | 'broken-citation' + | 'ambiguous-citation' | 'orphan' | 'no-outlinks' | 'uncited-claim'