Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"BuildRetrievalBenchmarkCasesFromQrelsOptions": "type",
"BuildRetrievalEvalDispatchOptions": "value",
"CHECKABLE_RUNG_THRESHOLD": "value",
"CITES_INVALIDATED_FIELD": "value",
"CheckExecution": "value",
"ChunkingOptions": "value",
"ClaimEvidence": "value",
Expand Down Expand Up @@ -237,6 +238,8 @@
"KnowledgeIndex": "value",
"KnowledgeIndexSchema": "value",
"KnowledgeInspection": "value",
"KnowledgeInvalidationPlan": "value",
"KnowledgeInvalidationStamp": "value",
"KnowledgeLayout": "value",
"KnowledgeLexicalFieldBoosts": "value",
"KnowledgeLexicalIndex": "value",
Expand Down Expand Up @@ -587,6 +590,7 @@
"forkAgentMemoryBranchSnapshot": "value",
"formatFrontmatter": "value",
"formatKnowledgeCitationReference": "value",
"formatKnowledgeInvalidationProposal": "value",
"fromAgentCandidateKnowledgeRef": "value",
"gradeCompanyAgainstText": "value",
"gradeFactAgainstText": "value",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
111 changes: 111 additions & 0 deletions src/invalidation-propagation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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)
})
})
104 changes: 104 additions & 0 deletions src/invalidation-propagation.ts
Original file line number Diff line number Diff line change
@@ -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<KnowledgeId>()
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<string, unknown> = { ...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])
}
38 changes: 38 additions & 0 deletions src/lint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseKnowledgeCitationReference } from './citation-resolution'
import {
assertGradeableEvidence,
CHECKABLE_RUNG_THRESHOLD,
Expand All @@ -24,9 +25,11 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[
]),
)
const pageIds = new Map<string, string[]>()
const invalidatedIds = new Set<string>()
const sourceHashes = new Map<string, string[]>()
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$/, '')))
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, string[]>,
invalidatedIds: ReadonlySet<string>,
): 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)
Expand Down
Loading