diff --git a/AGENTS.md b/AGENTS.md index e9e1b5b..92f180c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Otherwise, it stays in this package. - Raw evidence lives under `raw/sources/` and should not be edited. - Pass `intake` to `applyKnowledgeWriteBlocks` (CLI `--intake`) so a write that duplicates a visible page or cites a page id that exists nowhere is refused before any byte lands. - Run `agent-knowledge index` after page changes. +- Run `planInvalidationPropagation` + `formatKnowledgeInvalidationProposal` after grading, so every citer of a refuted page records `citesInvalidated`. - Run `agent-knowledge lint` before trusting or promoting knowledge. - Treat `missing-source` lint findings as blocking. - Use `--json` for automation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2738f5e..57a248e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 10.3.0 — 2026-08-21 + +### Added + +- Add `planInvalidationPropagation(visiblePages)` and `formatKnowledgeInvalidationProposal(plan)`. Every page authored in the target store that cites a page carrying an `invalidation` is stamped with `citesInvalidated: [ids]`; a citation whose target was revalidated has the stamp removed. The plan is a diff, so a second pass over an already stamped store produces no mutation. Only `here` pages are stamped, because a run does not write the stores it inherits or shares. +- Add the `cites-invalidated` lint finding, a warning naming every live citation from a page into a page its own evidence refuted. +- Add `SearchKnowledgeOptions.excludeInvalidated`, which drops refuted pages from a result set. It defaults to `false`, so what search returns does not change for an existing caller. +- Add `originatedPages(pages, origin?)`, which presents plain pages as a visibility chain of one origin, so citation resolution, the write intake gate, and invalidation propagation take one page shape whether or not the caller runs run-scoped stores. + ## 10.2.0 — 2026-08-21 ### Added diff --git a/README.md b/README.md index 8be6b2c..8d9d353 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,22 @@ support-kb/ index.json # generated search index ``` +## Propagate an invalidation + +A page whose own evidence refuted it carries an `invalidation`. A reader who arrives through a citation never meets that verdict, so run the propagation pass after grading: + +```ts +const plan = planInvalidationPropagation(originatedPages(await loadKnowledgePages(root))) +if (plan.stamps.length > 0) { + await applyKnowledgeWriteBlocks(root, formatKnowledgeInvalidationProposal(plan)) +} +``` + +Each stamped page records `citesInvalidated: [ids]` in its frontmatter, and nothing else changes. +The plan is a diff, so a second pass over an already stamped store produces no mutation, and a citation whose target was revalidated has its stamp removed. +`agent-knowledge lint` reports a `cites-invalidated` warning for every live citation into a refuted page, and `searchKnowledge(index, query, { excludeInvalidated: true })` drops the refuted pages from a result set. +The default stays `false`: a caller reading history needs them. + ## Improve a live knowledge base `improveKnowledgeBase` creates an isolated candidate, runs your update callback, measures the candidate, and returns an exact candidate reference. diff --git a/api-surface.json b/api-surface.json index f0e5a2c..c841966 100644 --- a/api-surface.json +++ b/api-surface.json @@ -77,6 +77,7 @@ "BuildRetrievalBenchmarkCasesFromQrelsOptions": "type", "BuildRetrievalEvalDispatchOptions": "value", "CHECKABLE_RUNG_THRESHOLD": "value", + "CITES_INVALIDATED_FIELD": "value", "CheckExecution": "value", "ChunkingOptions": "value", "ClaimEvidence": "value", @@ -237,6 +238,8 @@ "KnowledgeIndex": "value", "KnowledgeIndexSchema": "value", "KnowledgeInspection": "value", + "KnowledgeInvalidationPlan": "value", + "KnowledgeInvalidationStamp": "value", "KnowledgeLayout": "value", "KnowledgeLexicalFieldBoosts": "value", "KnowledgeLexicalIndex": "value", @@ -587,6 +590,7 @@ "forkAgentMemoryBranchSnapshot": "value", "formatFrontmatter": "value", "formatKnowledgeCitationReference": "value", + "formatKnowledgeInvalidationProposal": "value", "fromAgentCandidateKnowledgeRef": "value", "gradeCompanyAgainstText": "value", "gradeFactAgainstText": "value", @@ -651,12 +655,14 @@ "normalizePageText": "value", "normalizePagesDirectory": "value", "optimizeKnowledgeBasePolicy": "value", + "originatedPages": "value", "parseFrontmatter": "value", "parseKnowledgeBenchmarkJsonl": "value", "parseKnowledgeBenchmarkQrels": "value", "parseKnowledgeCitationReference": "value", "parseKnowledgeWriteBlocks": "value", "partitionRetrievalScenarios": "value", + "planInvalidationPropagation": "value", "politeFetch": "value", "promoteKnowledgeCandidate": "value", "proposeFromFinding": "value", diff --git a/package.json b/package.json index 75e8522..3d54da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "10.2.0", + "version": "10.3.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/index.ts b/src/index.ts index 9bfb464..b95fb50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export * from './graph' export * from './ids' export * from './indexer' export * from './inspect' +export * from './invalidation-propagation' export * from './investment-thesis-set' export * from './investment-thesis-task' export * from './kb-improvement' diff --git a/src/invalidation-propagation.test.ts b/src/invalidation-propagation.test.ts new file mode 100644 index 0000000..472e1c4 --- /dev/null +++ b/src/invalidation-propagation.test.ts @@ -0,0 +1,111 @@ +import { mkdtemp, readFile, realpath, 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 { + formatKnowledgeInvalidationProposal, + planInvalidationPropagation, +} from './invalidation-propagation' +import { applyKnowledgeWriteBlocks } from './proposals' +import { originatedPages } from './run-scoped' +import { initKnowledgeBase, loadKnowledgePages } from './store' +import type { KnowledgePage } from './types' + +const overturned = { + verdict: 'contradicted' as const, + observedAt: '2026-08-18T00:00:00.000Z', + reason: 'The replication measured the opposite direction.', +} + +function page(id: string, frontmatter: Record): KnowledgePage { + const cites = frontmatter.cites as string[] | undefined + return { + id, + path: `knowledge/${id}.md`, + title: id, + text: `Body of ${id}.`, + frontmatter: { id, ...frontmatter }, + sourceIds: [], + tags: [], + outLinks: [], + ...(cites ? { cites } : {}), + ...(frontmatter.invalidation ? { invalidation: overturned } : {}), + } +} + +describe('planInvalidationPropagation', () => { + it('stamps a citer of an invalidated page and clears the stamp when the verdict is gone', () => { + const refuted = page('refuted', { invalidation: overturned }) + const citer = page('citer', { cites: ['refuted'] }) + + const plan = planInvalidationPropagation(originatedPages([refuted, citer])) + + expect(plan.invalidatedPageIds).toEqual(['refuted']) + expect(plan.stamps.map((stamp) => [stamp.page.id, stamp.citesInvalidated])).toEqual([ + ['citer', ['refuted']], + ]) + + const stamped = page('citer', { cites: ['refuted'], citesInvalidated: ['refuted'] }) + const revalidated = page('refuted', {}) + expect( + planInvalidationPropagation(originatedPages([revalidated, stamped])).stamps.map((stamp) => [ + stamp.page.id, + stamp.citesInvalidated, + ]), + ).toEqual([['citer', []]]) + }) + + it('never stamps a page the store only inherits', () => { + const refuted = page('refuted', { invalidation: overturned }) + const inheritedCiter = page('inherited-citer', { cites: ['refuted'] }) + + const plan = planInvalidationPropagation([ + ...originatedPages([refuted]), + ...originatedPages([inheritedCiter], 'inherited:run-a'), + ]) + + expect(plan.stamps).toEqual([]) + }) +}) + +describe('applying an invalidation plan through the write path', () => { + let root: string + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'invalidation-'))) + await initKnowledgeBase(root) + }) + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('is a no-op on the second pass over a store it already stamped', async () => { + await writeFile( + join(root, 'knowledge', 'refuted.md'), + `---\nid: refuted\ninvalidation: ${JSON.stringify(overturned)}\n---\n\n# Refuted\n\nA claim its own replication overturned.\n`, + ) + await writeFile( + join(root, 'knowledge', 'citer.md'), + '---\nid: citer\ntags:\n - live\ncites:\n - refuted\n---\n\n# Citer\n\nBuilt on the refuted claim.\n', + ) + + const runPass = async () => { + const plan = planInvalidationPropagation(originatedPages(await loadKnowledgePages(root))) + if (plan.stamps.length === 0) return { stamped: [] as string[] } + const applied = await applyKnowledgeWriteBlocks( + root, + formatKnowledgeInvalidationProposal(plan), + ) + return { stamped: applied.written } + } + + expect((await runPass()).stamped).toEqual(['knowledge/citer.md']) + const afterFirst = await readFile(join(root, 'knowledge', 'citer.md'), 'utf8') + expect(afterFirst).toContain('citesInvalidated:\n - refuted') + expect(afterFirst).toContain('- live') + expect(afterFirst).toContain('Built on the refuted claim.') + + expect((await runPass()).stamped).toEqual([]) + expect(await readFile(join(root, 'knowledge', 'citer.md'), 'utf8')).toBe(afterFirst) + }) +}) diff --git a/src/invalidation-propagation.ts b/src/invalidation-propagation.ts new file mode 100644 index 0000000..27f37b7 --- /dev/null +++ b/src/invalidation-propagation.ts @@ -0,0 +1,104 @@ +/** + * Invalidation propagation. + * + * A page whose own evidence refuted it carries an `invalidation`. That verdict + * is invisible to a reader who arrives through a citation, so a page that cites + * a refuted page records which of its citations are refuted. The pass is + * planned as a diff, so a store already stamped produces no mutation and the + * pass can run after every grading round. + */ +import { parseKnowledgeCitationReference, resolveKnowledgeCitation } from './citation-resolution' +import { formatFrontmatter } from './frontmatter' +import type { OriginatedPage } from './run-scoped' +import type { KnowledgeId, KnowledgePage } from './types' + +/** Frontmatter field naming the cited pages whose evidence refuted them. */ +export const CITES_INVALIDATED_FIELD = 'citesInvalidated' + +export interface KnowledgeInvalidationStamp { + readonly page: KnowledgePage + /** The value the field must hold, sorted and deduplicated. Empty removes the field. */ + readonly citesInvalidated: readonly KnowledgeId[] + /** The value the page holds now, in the order it is stored. */ + readonly current: readonly KnowledgeId[] +} + +export interface KnowledgeInvalidationPlan { + /** Every visible page carrying an invalidation, sorted by id. */ + readonly invalidatedPageIds: readonly KnowledgeId[] + /** Only the pages whose stamp differs from what they hold, in path order. */ + readonly stamps: readonly KnowledgeInvalidationStamp[] +} + +/** + * Plan the `citesInvalidated` stamp for every page authored in the target + * store. + * + * Citations resolve over the whole chain, so a page here may be stamped for + * citing a refuted inherited or shared page. Only `here` pages are stamped: a + * run does not write the stores it inherits or shares. + */ +export function planInvalidationPropagation( + visiblePages: readonly OriginatedPage[], +): KnowledgeInvalidationPlan { + if (!Array.isArray(visiblePages)) { + throw new TypeError('knowledge invalidation propagation requires the visible pages') + } + const invalidatedPageIds = [ + ...new Set( + visiblePages + .filter((entry) => entry.page.invalidation !== undefined) + .map((entry) => entry.page.id), + ), + ].sort() + + const stamps: KnowledgeInvalidationStamp[] = [] + for (const entry of visiblePages) { + if (entry.origin !== 'here') continue + const page = entry.page + const refuted = new Set() + for (const persisted of page.cites ?? []) { + const resolution = resolveKnowledgeCitation( + visiblePages, + parseKnowledgeCitationReference(persisted), + ) + if (resolution.resolved?.page.invalidation !== undefined) { + refuted.add(resolution.resolved.page.id) + } + } + const citesInvalidated = [...refuted].sort() + const current = idList(page.frontmatter[CITES_INVALIDATED_FIELD]) + if (sameOrder(current, citesInvalidated)) continue + stamps.push({ page, citesInvalidated, current }) + } + stamps.sort((left, right) => left.page.path.localeCompare(right.page.path)) + return { invalidatedPageIds, stamps } +} + +/** + * Render one plan as a write-block proposal for `applyKnowledgeWriteBlocks`. + * + * Only the stamped field changes. The page is rendered through + * `formatFrontmatter`, so its frontmatter is written in that writer's + * normalized form. + */ +export function formatKnowledgeInvalidationProposal(plan: KnowledgeInvalidationPlan): string { + return plan.stamps.map((stamp) => renderStampedBlock(stamp)).join('\n') +} + +function renderStampedBlock(stamp: KnowledgeInvalidationStamp): string { + const frontmatter: Record = { ...stamp.page.frontmatter } + if (stamp.citesInvalidated.length === 0) delete frontmatter[CITES_INVALIDATED_FIELD] + else frontmatter[CITES_INVALIDATED_FIELD] = [...stamp.citesInvalidated] + const content = formatFrontmatter(frontmatter, stamp.page.text) + return `---FILE: ${stamp.page.path}---\n${content.replace(/\n+$/, '')}\n---END FILE---` +} + +function idList(value: unknown): KnowledgeId[] { + const values = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [] + return values.filter((item): item is string => typeof item === 'string' && item.trim() !== '') +} + +function sameOrder(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]) +} diff --git a/src/lint.ts b/src/lint.ts index 9c7410a..715ba27 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -1,3 +1,4 @@ +import { parseKnowledgeCitationReference } from './citation-resolution' import { assertGradeableEvidence, CHECKABLE_RUNG_THRESHOLD, @@ -24,9 +25,11 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[ ]), ) const pageIds = new Map() + const invalidatedIds = new Set() const sourceHashes = new Map() for (const page of index.pages) { pageIds.set(page.id, [...(pageIds.get(page.id) ?? []), page.path]) + if (page.invalidation !== undefined) invalidatedIds.add(page.id) byTarget.add(normalizeLinkTarget(page.id)) byTarget.add(normalizeLinkTarget(page.title)) byTarget.add(normalizeLinkTarget(page.path.split('/').pop()!.replace(/\.md$/, ''))) @@ -115,6 +118,7 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[ findings.push(...lintPageEvidence(page)) findings.push(...lintPageContradictions(page, pageIds)) findings.push(...lintPageInvalidation(page)) + findings.push(...lintPageInvalidatedCitations(page, pageIds, invalidatedIds)) } for (const [title, paths] of titles) { @@ -234,6 +238,40 @@ function lintPageContradictions( return findings } +/** + * A citation into a page its own evidence refuted. + * + * The verdict lives on the cited page, so a reader arriving through the + * citation never meets it. Ambiguous ids are left to the citation audit, which + * owns that verdict. + */ +function lintPageInvalidatedCitations( + page: KnowledgePage, + pageIds: ReadonlyMap, + invalidatedIds: ReadonlySet, +): KnowledgeLintFinding[] { + const targetIds = [ + ...new Set( + (page.cites ?? []).map((persisted) => parseKnowledgeCitationReference(persisted).pageId), + ), + ] + .filter( + (targetId) => + targetId !== page.id && pageIds.get(targetId)?.length === 1 && invalidatedIds.has(targetId), + ) + .sort() + if (targetIds.length === 0) return [] + return [ + { + type: 'cites-invalidated', + severity: 'warning', + page: page.path, + message: `Page cites invalidated ${targetIds.length === 1 ? 'page' : 'pages'} ${targetIds.join(', ')}.`, + metadata: { targetIds }, + }, + ] +} + function lintPageInvalidation(page: KnowledgePage): KnowledgeLintFinding[] { if (page.frontmatter.invalidation === undefined) return [] const parsed = KnowledgePageInvalidationSchema.safeParse(page.frontmatter.invalidation) diff --git a/src/proposals.ts b/src/proposals.ts index f09248a..3cc7619 100644 --- a/src/proposals.ts +++ b/src/proposals.ts @@ -3,7 +3,7 @@ import { contentHash } from '@tangle-network/agent-eval' import { commitKnowledgeFileMutations } from './file-transaction' import { withKnowledgeMutation } from './mutation-lock' import { type KnowledgePagesOptions, normalizePagesDirectory } from './pages-directory' -import type { OriginatedPage } from './run-scoped' +import { type OriginatedPage, originatedPages } from './run-scoped' import { isKnowledgePagePath, knowledgePageFromMarkdown, loadKnowledgePages } from './store' import { assertKnowledgeWriteIntake, type KnowledgeWriteIntakeOptions } from './write-intake' import { parseKnowledgeWriteBlocks } from './write-protocol' @@ -67,10 +67,7 @@ export async function applyKnowledgeWriteBlocks( ), { ...settings, - visiblePages: [ - ...here.map((page) => ({ page, origin: 'here' as const })), - ...inheritedPages, - ], + visiblePages: [...originatedPages(here), ...inheritedPages], }, ) } diff --git a/src/run-scoped.ts b/src/run-scoped.ts index c161869..18f8e12 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -21,6 +21,21 @@ export interface OriginatedPage { origin: PageOrigin } +/** + * Present plain pages as a visibility chain of one origin. + * + * A store read directly is the `here` origin of a chain with no ancestry, so + * the chain-shaped APIs — citation resolution, the write intake gate, + * invalidation propagation — take one page shape whether or not the caller + * runs run-scoped stores. + */ +export function originatedPages( + pages: readonly KnowledgePage[], + origin: PageOrigin = 'here', +): OriginatedPage[] { + return pages.map((page) => ({ page, origin })) +} + export interface RunLineageRecord { runId: string parentRunId: string | null diff --git a/src/search.ts b/src/search.ts index 3e165a7..c9a586e 100644 --- a/src/search.ts +++ b/src/search.ts @@ -20,6 +20,12 @@ export interface SearchKnowledgeOptions { tags?: readonly string[] /** Match the exact string stored in `frontmatter.kind`. */ kinds?: readonly string[] + /** + * Drop pages whose own evidence refuted them. Defaults to false: a caller + * that reads history needs them, and a silent change of what search returns + * is worse than an explicit option. + */ + excludeInvalidated?: boolean /** Additional caller-owned filter, applied before either ranking stage. */ predicate?: (page: KnowledgePage) => boolean /** @@ -116,6 +122,7 @@ function filterPages(pages: KnowledgePage[], options: SearchKnowledgeOptions): K const kinds = options.kinds ? new Set(options.kinds) : null return pages.filter((page) => { + if (options.excludeInvalidated && page.invalidation !== undefined) return false if (pageIds && !pageIds.has(page.id)) return false if (tags && !page.tags.some((tag) => tags.has(tag))) return false if (kinds) { diff --git a/src/types.ts b/src/types.ts index e2964dc..4f7acd4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -191,6 +191,7 @@ export interface KnowledgeLintFinding { | 'nonportable-evidence' | 'broken-contradiction' | 'invalid-invalidation' + | 'cites-invalidated' severity: 'info' | 'warning' | 'error' page?: string message: string diff --git a/src/write-intake.test.ts b/src/write-intake.test.ts index e5a18c3..79fddbc 100644 --- a/src/write-intake.test.ts +++ b/src/write-intake.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { KnowledgeCitationResolutionError } from './citation-resolution' import { applyKnowledgeWriteBlocks } from './proposals' -import type { OriginatedPage } from './run-scoped' +import { originatedPages } from './run-scoped' import { initKnowledgeBase } from './store' import type { KnowledgePage } from './types' import { assertKnowledgeWriteIntake, KnowledgeDuplicateIntakeError } from './write-intake' @@ -26,16 +26,12 @@ function page(id: string, overrides: Partial = {}): KnowledgePage } } -function visible(pages: KnowledgePage[]): OriginatedPage[] { - return pages.map((entry) => ({ page: entry, origin: 'here' as const })) -} - describe('assertKnowledgeWriteIntake', () => { const settled = page('settled') it('refuses a candidate that restates a visible page without relating to it', () => { const call = () => - assertKnowledgeWriteIntake([page('restated')], { visiblePages: visible([settled]) }) + assertKnowledgeWriteIntake([page('restated')], { visiblePages: originatedPages([settled]) }) expect(call).toThrow(KnowledgeDuplicateIntakeError) try { @@ -61,7 +57,7 @@ describe('assertKnowledgeWriteIntake', () => { }) expect( - assertKnowledgeWriteIntake([candidate], { visiblePages: visible([settled]) }).map( + assertKnowledgeWriteIntake([candidate], { visiblePages: originatedPages([settled]) }).map( (resolved) => resolved.pageId, ), ).toEqual(['settled']) @@ -70,25 +66,25 @@ describe('assertKnowledgeWriteIntake', () => { it('accepts the same candidate once it names the page in contradicts', () => { const candidate = page('restated', { contradicts: ['settled'] }) - expect(assertKnowledgeWriteIntake([candidate], { visiblePages: visible([settled]) })).toEqual( - [], - ) + expect( + assertKnowledgeWriteIntake([candidate], { visiblePages: originatedPages([settled]) }), + ).toEqual([]) }) it('accepts a same-id update of the page it restates', () => { const candidate = page('settled', { path: 'knowledge/settled-v2.md' }) - expect(assertKnowledgeWriteIntake([candidate], { visiblePages: visible([settled]) })).toEqual( - [], - ) + expect( + assertKnowledgeWriteIntake([candidate], { visiblePages: originatedPages([settled]) }), + ).toEqual([]) }) it('accepts a rewrite of the page at the same path, which replaces it', () => { const candidate = page('renamed', { path: 'knowledge/settled.md' }) - expect(assertKnowledgeWriteIntake([candidate], { visiblePages: visible([settled]) })).toEqual( - [], - ) + expect( + assertKnowledgeWriteIntake([candidate], { visiblePages: originatedPages([settled]) }), + ).toEqual([]) }) it('resolves a citation into the same batch and refuses one that exists nowhere', () => {