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
2 changes: 1 addition & 1 deletion api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@
"RunRetrievalImprovementLoopOptions": "value f7fa1827ff47",
"RunRetrievalImprovementLoopResult": "value 21f0b2d53f0d",
"RunScopedStores": "value 88be8b4eed2e",
"RunScopedStoresOptions": "value 5c0af586c64d",
"RunScopedStoresOptions": "value b04713afaad9",
"RunSerializedKnowledgeOptimizationOptions": "value 648af65b3525",
"RunSerializedKnowledgeOptimizationResult": "value 6159e2cc058c",
"SCAFFOLD_PAGE_BASENAMES": "value 373728f5643d",
Expand Down
8 changes: 8 additions & 0 deletions docs/run-scoped-citations.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ A read-only authority must already contain the lineage before `init()` is called

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.

### Read budgets

A finite lineage is not invalid merely because it contains many runs. Ancestry reads have no
implicit depth cutoff. Supply `maxAncestors` to `createRunScopedStores` when the caller needs a
read budget, including `0` for a root-only view. A chain exactly at that bound is accepted; a
longer chain is refused explicitly rather than truncated. Cycles, invalid run identities,
conflicting parents, and path-containment checks remain enforced independently of that budget.

## 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.
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": "16.0.1",
"version": "17.0.0",
"description": "Build, search, evaluate, and improve source-backed knowledge bases.",
"homepage": "https://github.com/tangle-network/agent-knowledge#readme",
"repository": {
Expand Down
56 changes: 55 additions & 1 deletion src/run-scoped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ describe('createRunScopedStores', () => {
}
const stores = createRunScopedStores({
root,
maxAncestors: 64,
lineageAuthority: {
async parentOf(runId) {
return parents.get(runId) ?? null
Expand All @@ -183,13 +184,66 @@ describe('createRunScopedStores', () => {
}
const stores = createRunScopedStores({
root,
maxAncestors: 64,
lineageAuthority: {
async parentOf(runId) {
return parents.get(runId) ?? null
},
},
})

await expect(stores.lineage('run-0')).rejects.toThrow(/exceeds 64 ancestors/)
await expect(stores.lineage('run-0')).rejects.toThrow(/exceeds caller maxAncestors=64/)
})

it('reads a long finite lineage without a source-level ancestry ceiling', async () => {
const stores = createRunScopedStores({
root,
lineageAuthority: {
async parentOf(runId) {
const index = Number(runId.slice(4))
return index === 256 ? null : `run-${index + 1}`
},
},
})
const lineage = await stores.lineage('run-0')
expect(lineage).toHaveLength(256)
expect(lineage[255]).toBe('run-256')
})

it('reads an actual inherited page beyond the old 64-ancestor cutoff', async () => {
const stores = createRunScopedStores({ root })
await Promise.all(
Array.from({ length: 66 }, (_, index) =>
stores.init(`run-${index}`, { parentRunId: index === 65 ? null : `run-${index + 1}` }),
),
)
await addPage(stores.storePath('run-65'), 'retained.md', 'useful work from the first run')
const pages = await stores.loadChain('run-0')
expect(pages.find((entry) => entry.page.title === 'retained.md')).toMatchObject({
origin: 'inherited:run-65',
})
})

it.each([-1, 1.5, NaN, Infinity, null])(
'rejects malformed caller ancestry bounds (%s)',
(maxAncestors) => {
expect(() => createRunScopedStores({ root, maxAncestors: maxAncestors as number })).toThrow(
/non-negative safe integer/,
)
},
)

it('allows zero ancestors only for a root run', async () => {
const stores = createRunScopedStores({
root,
maxAncestors: 0,
lineageAuthority: {
async parentOf(id) {
return id === 'root' ? null : 'root'
},
},
})
await expect(stores.lineage('root')).resolves.toEqual([])
await expect(stores.lineage('child')).rejects.toThrow(/caller maxAncestors=0/)
})
})
19 changes: 12 additions & 7 deletions src/run-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ export interface RunScopedStoresOptions extends KnowledgePagesOptions {
sharedRoot?: string
/** External owner for run ancestry. Defaults to a record inside each run store. */
lineageAuthority?: RunLineageAuthority
/** Optional caller-owned bound on ancestor reads. Omit to read the complete finite chain.
* Cycles and invalid identities are always refused; long valid history is not corruption. */
maxAncestors?: number
}

/** Ancestry beyond this bound is invalid durable state. */
const MAX_LINEAGE_HOPS = 64

export interface RunScopedStores {
/** Create or open a run store and bind it to one exact parent identity. */
init(runId: string, options?: { parentRunId?: string | null }): Promise<KnowledgeLayout>
Expand Down Expand Up @@ -103,6 +103,10 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope
if (options.runStorePath !== undefined && typeof options.runStorePath !== 'function') {
throw new TypeError('createRunScopedStores runStorePath must be a function when present')
}
const maxAncestors = options.maxAncestors
if (maxAncestors !== undefined && (!Number.isSafeInteger(maxAncestors) || maxAncestors < 0)) {
throw new TypeError('createRunScopedStores maxAncestors must be a non-negative safe integer')
}
if (options.lineageAuthority !== undefined) validateLineageAuthority(options.lineageAuthority)
const pagesDirectory = normalizePagesDirectory(options.pagesDirectory)

Expand All @@ -116,20 +120,21 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope
const chain: string[] = []
const seen = new Set<string>([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) {
// The terminating null is read even at an explicit bound: an exactly-full chain is valid.
for (;;) {
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})`)
}
if (maxAncestors !== undefined && chain.length >= maxAncestors) {
throw new Error(`run lineage for '${runId}' exceeds caller maxAncestors=${maxAncestors}`)
}
seen.add(parent)
chain.push(parent)
current = parent
}
throw new Error(`run lineage for '${runId}' exceeds ${MAX_LINEAGE_HOPS} ancestors`)
}

return {
Expand Down