diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f661b9..296d029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add content-addressed knowledge visibility, retrieval, and downstream-use receipts. Retrieval receipts bind the exact ordered current/ancestor/shared page snapshot, query, retriever configuration, ranked results, actor/profile/execution identities, and Eval evidence references. Use receipts bind one returned rank to a decision, artifact, experiment, candidate, message, or other consumer with an explicit `supports`, `contradicts`, `extends`, `rederives`, or `background` relation. Verification fails on changed page bytes, visibility, query, rank, consumer, relation, or retrieval identity; the receipts provide provenance without claiming correctness, novelty, or causal lift. + ## 8.0.6 — 2026-08-16 ### Changed diff --git a/README.md b/README.md index 3b4aa1a..06260a4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Supply application callbacks for those decisions, or use `@tangle-network/agent- ## Install ```bash -pnpm add @tangle-network/agent-knowledge@7.2.6 @tangle-network/agent-eval@0.145.11 @tangle-network/agent-interface@0.52.0 +pnpm add @tangle-network/agent-knowledge@8.0.8 @tangle-network/agent-eval@0.147.0 @tangle-network/agent-interface@1.0.0 ``` Requires Node.js 20.19 or later. @@ -20,6 +20,8 @@ Requires Node.js 20.19 or later. |---|---|---| | Create a file-backed knowledge base | `initKnowledgeBase`, `addSourceText`, `applyKnowledgeWriteBlocks` | package root | | Search an existing package knowledge base | `createFileSystemSearchProvider` | package root | +| Isolate knowledge per run and inherit only declared ancestry | `createRunScopedStores` | package root | +| Prove what knowledge was visible, retrieved, and selected for use | `createKnowledgeRetrievalReceipt`, `createKnowledgeUseReceipt` | package root | | Improve a live knowledge base without editing it in place | `improveKnowledgeBase` | package root | | Optimize retrieval or a complete RAG configuration | `runRetrievalImprovementLoop`, `runRagOptimization` | package root | | Optimize a KB maintenance policy | `optimizeKnowledgeBasePolicy` | package root | @@ -80,6 +82,42 @@ The provider uses the package's local text search. Pass `refresh: 'always'` to rebuild its index before every query, or call `invalidate()` after changing files. Use `asRetrievalEvalRetriever()` to send the same search path into retrieval tests. +## Prove what the agent saw and used + +A page existing in a knowledge base, a page appearing in retrieval results, and a page influencing a decision are three different facts. The receipt APIs preserve those joins without pretending they prove the page is true or that it improved the outcome. + +```ts +import { + createKnowledgeRetrievalReceipt, + createKnowledgeUseReceipt, + createKnowledgeVisibilitySnapshot, +} from '@tangle-network/agent-knowledge' + +const visiblePages = await runStores.loadChain(runId) +const visibility = createKnowledgeVisibilitySnapshot(visiblePages) + +const retrieval = createKnowledgeRetrievalReceipt({ + runId, + query: 'prior verifier obstruction', + retriever: { id: 'hybrid-search', version: '1.0.0', configDigest }, + visiblePages, + results, +}) + +const use = createKnowledgeUseReceipt({ + retrieval, + selectedRank: 1, + relation: 'extends', + consumer: { kind: 'artifact', uri: 'artifact://run/DECISION.md', digest }, +}) + +console.log(visibility.snapshotDigest, retrieval.receiptDigest, use.receiptDigest) +``` + +ELI5: the visibility snapshot is the bookshelf the agent was allowed to see, the retrieval receipt is the exact books search handed back, and the use receipt records which returned book the agent attached to a downstream decision or artifact. + +The receipts are content-addressed and mutation-sensitive. They do **not** establish correctness, novelty, compliance, or causal lift; Eval owns those later judgments. Read [knowledge retrieval and use receipts](docs/knowledge-use-receipts.md) for the complete proof boundary, trace attributes, and experiment design. + ## Use the CLI The CLI exposes the same file-backed workflow. @@ -300,6 +338,7 @@ Those choices stay in the application or in `@tangle-network/agent-runtime`. ## More detail - [Architecture and data model](docs/architecture.md) +- [Knowledge retrieval and use receipts](docs/knowledge-use-receipts.md) - [Verified research comparison](docs/verified-research-ab.md) - [Changelog](CHANGELOG.md) diff --git a/docs/knowledge-use-receipts.md b/docs/knowledge-use-receipts.md new file mode 100644 index 0000000..2eddd1b --- /dev/null +++ b/docs/knowledge-use-receipts.md @@ -0,0 +1,182 @@ +# Knowledge retrieval and use receipts + +A knowledge page appearing in a prompt, a final answer resembling a prior result, and a citation in a generated document are three different facts. None of them alone proves that a particular agent retrieved a particular page and used it in a downstream decision. + +This module records two immutable links: + +```text +exact visible knowledge snapshot + ↓ +retrieval receipt + ↓ +selected returned result + ↓ +knowledge-use receipt + ↓ +decision / artifact / experiment / candidate / message +``` + +The receipts establish provenance. They do not decide whether using the knowledge was wise, whether the downstream result is correct, or whether it is novel. Those are Eval questions over the retained evidence. + +## Visibility snapshot + +`createKnowledgeVisibilitySnapshot()` binds the ordered set of pages visible to one retrieval operation. Every entry records: + +- position; +- stable page id; +- origin (`here`, `inherited:`, or `shared`); +- path; +- canonical page digest; +- source ids; +- whether the page was invalidated. + +Order is identity-bearing because retrieval algorithms may use order as a tie break or candidate window. Page text, frontmatter, citations, contradictions, invalidation state, path, and source joins are included in each page digest. + +```ts +import { createKnowledgeVisibilitySnapshot } from '@tangle-network/agent-knowledge' + +const visibility = createKnowledgeVisibilitySnapshot(await stores.loadChain(runId)) +console.log(visibility.snapshotDigest) +``` + +A repeated path at the same origin is refused. The same stable page id may remain visible at different origins; ambiguity-safe citation resolution is handled separately. + +## Retrieval receipt + +`createKnowledgeRetrievalReceipt()` binds: + +- run id; +- optional actor, profile, and execution identities; +- exact query; +- retriever id, version, and configuration digest; +- the complete visibility snapshot; +- every ranked returned page, origin, path, digest, score, snippet, and reason; +- trace or artifact evidence references; +- bounded scalar attributes; +- creation timestamp. + +A result is accepted only when its exact page bytes, path, id, and origin occur in the visibility snapshot. Ranks must be unique and contiguous from one. Scores must be finite, and normalized scores must lie in `[0, 1]`. + +```ts +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { createKnowledgeRetrievalReceipt } from '@tangle-network/agent-knowledge' + +const receipt = createKnowledgeRetrievalReceipt({ + runId, + actorId: 'root:s0', + profileDigest, + executionRef, + query: 'prior obstruction calibrated verifier', + retriever: { + id: 'inspectable-token-overlap', + version: '1.0.0', + configDigest: canonicalCandidateDigest({ tokenizer: 'unicode-words', limit: 5 }), + }, + visiblePages, + results, + evidenceRefs: [{ kind: 'event', uri: `event://${runId}/retrieval-1` }], +}) +``` + +`verifyKnowledgeRetrievalReceipt()` recomputes the visibility and receipt digests and checks every result-to-visibility join. `assertKnowledgeRetrievalMatchesVisibility()` additionally proves that the receipt still describes a supplied page snapshot; a later page mutation fails this check. + +## Use receipt + +`createKnowledgeUseReceipt()` selects one exact rank returned by a verified retrieval and binds it to a downstream consumer: + +```ts +const use = createKnowledgeUseReceipt({ + retrieval: receipt, + selectedRank: 1, + relation: 'extends', + consumer: { + kind: 'artifact', + uri: `artifact://${runId}/DECISION.md`, + digest: decisionArtifactDigest, + }, + evidenceRefs: [{ kind: 'span', uri: `trace://${runId}/span/knowledge-use-1` }], +}) +``` + +Relations are descriptive: + +- `supports` +- `contradicts` +- `extends` +- `rederives` +- `background` + +Consumer kinds are: + +- `decision` +- `artifact` +- `experiment` +- `candidate` +- `message` +- `other` + +The use receipt copies the selected result's rank, page id, origin, path, and digest. It references the retrieval receipt digest. `verifyKnowledgeUseReceipt()` refuses verification against a different retrieval, a result that was not returned, a changed selected page, or any mutation of the relation or consumer identity. + +## Canonical serialization + +Optional actor, profile, execution, consumer-digest, and evidence-excerpt fields are omitted when absent. They are never emitted with a JavaScript `undefined` value. Empty evidence and attribute collections remain explicit empty arrays or objects because they are part of the receipt contract. + +Attribute values are deliberately limited to strings, finite numbers, booleans, and `null`. Nested arbitrary objects are refused rather than passed through a language-specific serializer. More structured evidence belongs in an artifact or a versioned contract referenced by digest. + +The receipt digest covers the complete canonical material except the digest field itself. A verifier recomputes both the visibility snapshot digest and the outer receipt digest; copying a digest onto modified content does not verify. + +## What a receipt proves + +A valid retrieval receipt proves: + +> Under this exact run/actor/profile/execution identity, this exact retriever configuration searched this exact ordered knowledge snapshot with this exact query and returned these exact ranked page versions. + +A valid use receipt additionally proves: + +> The consumer selected this exact returned page version and declared this exact relation while producing this exact downstream consumer identity. + +It does **not** prove: + +- that the page's claim is true; +- that the declared relation is semantically correct; +- that the consumer complied with the page; +- that the downstream artifact passed its verifier; +- that the result is novel rather than a re-derivation; +- that knowledge improved the outcome. + +Those claims require Eval findings, artifact checks, paired experiments, novelty/reuse adjudication, and downstream outcome evidence. + +## Trace integration + +Both receipts accept canonical Eval `EvidenceRef` values. A Runtime or product adapter should emit a trace event/span containing the receipt digest and retain the receipt itself as an artifact or durable record. The trace is an index into the receipt; it is not a second copy of its truth. + +Recommended event attributes: + +```text +knowledge.receipt.kind +knowledge.receipt.digest +knowledge.visibility.digest +knowledge.retriever.id +knowledge.retriever.version +knowledge.result.count +knowledge.used.page_id +knowledge.used.origin +knowledge.consumer.kind +knowledge.consumer.uri +knowledge.relation +``` + +Do not encode absent token, cost, model, status, or artifact state as zero or success while attaching these receipts. Knowledge provenance cannot repair incomplete execution evidence. + +## Experiment use + +For a knowledge-compounding comparison, record separately: + +1. knowledge available to the arm; +2. knowledge retrieved; +3. knowledge selected for use; +4. the downstream decision or artifact; +5. the verifier outcome; +6. whether the contribution duplicated, verified, extended, corrected, newly applied, or independently discovered the prior result. + +This separation prevents final-prose similarity or citation count from masquerading as causal evidence that accumulated knowledge improved research. diff --git a/src/index.ts b/src/index.ts index f483522..3a96704 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export * from './investment-thesis-set' export * from './investment-thesis-task' export * from './kb-improvement' export * from './kb-store' +export * from './knowledge-use-receipts' export * from './lint' export * from './material-facts-metric' export * from './memory/index' diff --git a/src/knowledge-use-receipts.test.ts b/src/knowledge-use-receipts.test.ts new file mode 100644 index 0000000..27e91c0 --- /dev/null +++ b/src/knowledge-use-receipts.test.ts @@ -0,0 +1,348 @@ +import { canonicalCandidateDigest } from '@tangle-network/agent-interface' +import { describe, expect, it } from 'vitest' +import { + assertKnowledgeRetrievalMatchesVisibility, + createKnowledgeRetrievalReceipt, + createKnowledgeUseReceipt, + createKnowledgeVisibilitySnapshot, + knowledgePageDigest, + type OriginatedKnowledgeSearchResult, + verifyKnowledgeRetrievalReceipt, + verifyKnowledgeUseReceipt, +} from './knowledge-use-receipts' +import type { OriginatedPage } from './run-scoped' +import type { KnowledgePage } from './types' + +const createdAt = '2026-08-17T12:00:00.000Z' + +function page(input: { + id: string + path?: string + text?: string + sourceIds?: string[] + cites?: string[] +}): KnowledgePage { + return { + id: input.id, + path: input.path ?? `knowledge/${input.id}.md`, + title: `Title ${input.id}`, + text: input.text ?? `Knowledge for ${input.id}`, + frontmatter: { id: input.id, title: `Title ${input.id}` }, + sourceIds: input.sourceIds ?? [], + tags: ['fixture'], + outLinks: [], + ...(input.cites ? { cites: input.cites } : {}), + } +} + +function fixture() { + const current = page({ id: 'current-result', sourceIds: ['source-current'] }) + const inherited = page({ id: 'parent-result', sourceIds: ['source-parent'] }) + const shared = page({ id: 'instrument-calibration', sourceIds: ['source-shared'] }) + const visiblePages: OriginatedPage[] = [ + { page: current, origin: 'here' }, + { page: inherited, origin: 'inherited:run-parent' }, + { page: shared, origin: 'shared' }, + ] + const results: OriginatedKnowledgeSearchResult[] = [ + { + page: inherited, + origin: 'inherited:run-parent', + score: 0.04, + rrfScore: 0.04, + normalizedScore: 1, + rank: 1, + snippet: 'The parent result contains the needed obstruction.', + reasons: ['title-match', 'body-token-match'], + }, + { + page: shared, + origin: 'shared', + score: 0.02, + rrfScore: 0.02, + normalizedScore: 0.5, + rank: 2, + snippet: 'The shared page documents the calibrated verifier.', + reasons: ['body-token-match'], + }, + ] + return { current, inherited, shared, visiblePages, results } +} + +function retrieval(overrides: Partial[0]> = {}) { + const data = fixture() + return createKnowledgeRetrievalReceipt({ + runId: 'run-child', + actorId: 'root:s0', + profileDigest: canonicalCandidateDigest({ profile: 'researcher-v1' }), + executionRef: canonicalCandidateDigest({ executor: 'runtime-v1' }), + query: 'prior obstruction calibrated verifier', + retriever: { + id: 'inspectable-token-overlap', + version: '1.0.0', + configDigest: canonicalCandidateDigest({ tokenizer: 'unicode-words', limit: 5 }), + }, + visiblePages: data.visiblePages, + results: data.results, + evidenceRefs: [{ kind: 'event', uri: 'event://run-child/retrieval-1' }], + attributes: { purpose: 'research', limit: 5, inheritedEnabled: true }, + createdAt, + ...overrides, + }) +} + +describe('knowledge visibility snapshots', () => { + it('binds ordered page bytes, origins, paths, sources, and invalidation state', () => { + const { visiblePages } = fixture() + const snapshot = createKnowledgeVisibilitySnapshot(visiblePages) + + expect(snapshot.entries.map((entry) => [entry.position, entry.pageId, entry.origin])).toEqual([ + [0, 'current-result', 'here'], + [1, 'parent-result', 'inherited:run-parent'], + [2, 'instrument-calibration', 'shared'], + ]) + expect(snapshot.entries[1]?.pageDigest).toBe(knowledgePageDigest(visiblePages[1]!.page)) + expect(snapshot.snapshotDigest).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(Object.isFrozen(snapshot)).toBe(true) + expect(Object.isFrozen(snapshot.entries)).toBe(true) + }) + + it('changes identity when page bytes, origin, or ordering changes', () => { + const { visiblePages } = fixture() + const baseline = createKnowledgeVisibilitySnapshot(visiblePages).snapshotDigest + const changedText = structuredClone(visiblePages) + changedText[1]!.page.text = 'Mutated parent result.' + const changedOrigin = structuredClone(visiblePages) + changedOrigin[1]!.origin = 'shared' + const changedOrder = [visiblePages[1]!, visiblePages[0]!, visiblePages[2]!] + + expect(createKnowledgeVisibilitySnapshot(changedText).snapshotDigest).not.toBe(baseline) + expect(createKnowledgeVisibilitySnapshot(changedOrigin).snapshotDigest).not.toBe(baseline) + expect(createKnowledgeVisibilitySnapshot(changedOrder).snapshotDigest).not.toBe(baseline) + }) + + it('refuses a repeated path at the same origin', () => { + const { current } = fixture() + expect(() => + createKnowledgeVisibilitySnapshot([ + { page: current, origin: 'here' }, + { page: { ...current, id: 'different-id' }, origin: 'here' }, + ]), + ).toThrow(/repeats path/) + }) +}) + +describe('knowledge retrieval receipts', () => { + it('binds exact visibility, ranked results, executor identity, and trace evidence', () => { + const receipt = retrieval() + + expect(verifyKnowledgeRetrievalReceipt(receipt)).toBe(receipt) + expect(receipt).toMatchObject({ + schemaVersion: '1.0.0', + kind: 'knowledge-retrieval', + digestAlgorithm: 'rfc8785-sha256', + runId: 'run-child', + actorId: 'root:s0', + query: 'prior obstruction calibrated verifier', + results: [ + { rank: 1, pageId: 'parent-result', origin: 'inherited:run-parent' }, + { rank: 2, pageId: 'instrument-calibration', origin: 'shared' }, + ], + }) + expect(receipt.receiptDigest).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(receipt.results[0]?.pageDigest).toBe(receipt.visibility.entries[1]?.pageDigest) + expect(Object.isFrozen(receipt.results)).toBe(true) + expect(Object.isFrozen(receipt.evidenceRefs)).toBe(true) + expect(Object.isFrozen(receipt.attributes)).toBe(true) + }) + + it('is deterministic for identical evidence and changes for identity-bearing inputs', () => { + const first = retrieval() + const second = retrieval() + const changedQuery = retrieval({ query: 'different query' }) + const changedExecutor = retrieval({ + executionRef: canonicalCandidateDigest({ executor: 'runtime-v2' }), + }) + + expect(second.receiptDigest).toBe(first.receiptDigest) + expect(changedQuery.receiptDigest).not.toBe(first.receiptDigest) + expect(changedExecutor.receiptDigest).not.toBe(first.receiptDigest) + }) + + it('omits absent optional identities instead of persisting undefined', () => { + const receipt = retrieval({ + actorId: undefined, + profileDigest: undefined, + executionRef: undefined, + evidenceRefs: [], + attributes: {}, + }) + + expect(Object.hasOwn(receipt, 'actorId')).toBe(false) + expect(Object.hasOwn(receipt, 'profileDigest')).toBe(false) + expect(Object.hasOwn(receipt, 'executionRef')).toBe(false) + expect(() => verifyKnowledgeRetrievalReceipt(receipt)).not.toThrow() + }) + + it('refuses a result absent from the visibility snapshot', () => { + const data = fixture() + const fabricated = page({ id: 'fabricated' }) + + expect(() => + retrieval({ + results: [ + { + ...data.results[0]!, + page: fabricated, + }, + ], + }), + ).toThrow(/was not visible/) + }) + + it('refuses page mutation between visibility and retrieval result materialization', () => { + const data = fixture() + const mutatedResult = { + ...data.results[0]!, + page: { ...data.inherited, text: 'Changed after the visible snapshot was captured.' }, + } + + expect(() => + createKnowledgeRetrievalReceipt({ + runId: 'run-child', + query: 'obstruction', + retriever: { + id: 'fixture', + version: '1', + configDigest: canonicalCandidateDigest({ fixture: true }), + }, + visiblePages: data.visiblePages, + results: [mutatedResult], + createdAt, + }), + ).toThrow(/does not match its visibility snapshot/) + }) + + it('refuses duplicate, gapped, non-finite, or out-of-range result rows', () => { + const data = fixture() + expect(() => + retrieval({ results: [data.results[0]!, { ...data.results[1]!, rank: 1 }] }), + ).toThrow(/repeats rank 1/) + expect(() => retrieval({ results: [{ ...data.results[0]!, rank: 2 }] })).toThrow( + /contiguous from 1/, + ) + expect(() => retrieval({ results: [{ ...data.results[0]!, rrfScore: Number.NaN }] })).toThrow( + /rrfScore must be a finite number/, + ) + expect(() => retrieval({ results: [{ ...data.results[0]!, normalizedScore: 1.1 }] })).toThrow( + /must be in \[0,1\]/, + ) + }) + + it('detects receipt and post-retrieval visibility mutations', () => { + const receipt = retrieval() + const changedQuery = { ...receipt, query: 'forged query' } + expect(() => verifyKnowledgeRetrievalReceipt(changedQuery)).toThrow(/receipt digest mismatch/) + + const { visiblePages } = fixture() + visiblePages[1]!.page.text = 'The cited page changed after retrieval.' + expect(() => assertKnowledgeRetrievalMatchesVisibility(receipt, visiblePages)).toThrow( + /does not match the supplied visibility snapshot/, + ) + }) + + it('refuses nested attribute values that cannot enter the canonical receipt', () => { + expect(() => retrieval({ attributes: { nested: { invalid: true } } as never })).toThrow( + /unsupported value/, + ) + }) +}) + +describe('knowledge use receipts', () => { + it('binds one returned rank to a downstream artifact and its evidence', () => { + const source = retrieval() + const use = createKnowledgeUseReceipt({ + retrieval: source, + selectedRank: 1, + relation: 'extends', + consumer: { + kind: 'artifact', + uri: 'artifact://run-child/decision.md', + digest: canonicalCandidateDigest({ artifact: 'decision-v1' }), + }, + evidenceRefs: [{ kind: 'span', uri: 'trace://run-child/span/use-1' }], + attributes: { statement: 'Used the parent obstruction to define the next experiment.' }, + createdAt: '2026-08-17T12:05:00.000Z', + }) + + expect(verifyKnowledgeUseReceipt(use, source)).toBe(use) + expect(use).toMatchObject({ + kind: 'knowledge-use', + runId: 'run-child', + retrievalReceiptDigest: source.receiptDigest, + relation: 'extends', + used: { + rank: 1, + pageId: 'parent-result', + origin: 'inherited:run-parent', + }, + consumer: { kind: 'artifact', uri: 'artifact://run-child/decision.md' }, + }) + expect(use.receiptDigest).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(Object.isFrozen(use)).toBe(true) + expect(Object.isFrozen(use.used)).toBe(true) + }) + + it('refuses a rank the retrieval never returned', () => { + expect(() => + createKnowledgeUseReceipt({ + retrieval: retrieval(), + selectedRank: 3, + relation: 'background', + consumer: { kind: 'decision', uri: 'decision://run-child/next' }, + createdAt, + }), + ).toThrow(/was not returned/) + }) + + it('refuses verification against a different retrieval receipt', () => { + const original = retrieval() + const use = createKnowledgeUseReceipt({ + retrieval: original, + selectedRank: 1, + relation: 'supports', + consumer: { kind: 'decision', uri: 'decision://run-child/next' }, + createdAt, + }) + const different = retrieval({ query: 'another query' }) + + expect(() => verifyKnowledgeUseReceipt(use, different)).toThrow(/different retrieval receipt/) + }) + + it('detects selected-page, relation, and consumer mutation', () => { + const source = retrieval() + const use = createKnowledgeUseReceipt({ + retrieval: source, + selectedRank: 1, + relation: 'supports', + consumer: { kind: 'candidate', uri: 'candidate://profile/1' }, + createdAt, + }) + + expect(() => + verifyKnowledgeUseReceipt( + { ...use, used: { ...use.used, pageDigest: canonicalCandidateDigest({ forged: true }) } }, + source, + ), + ).toThrow(/selected result does not match/) + expect(() => verifyKnowledgeUseReceipt({ ...use, relation: 'extends' }, source)).toThrow( + /receipt digest mismatch/, + ) + expect(() => + verifyKnowledgeUseReceipt( + { ...use, consumer: { ...use.consumer, uri: 'candidate://profile/forged' } }, + source, + ), + ).toThrow(/receipt digest mismatch/) + }) +}) diff --git a/src/knowledge-use-receipts.ts b/src/knowledge-use-receipts.ts new file mode 100644 index 0000000..8da70ec --- /dev/null +++ b/src/knowledge-use-receipts.ts @@ -0,0 +1,749 @@ +import type { EvidenceRef } from '@tangle-network/agent-eval/analyst' +import { + canonicalCandidateDigest, + type Sha256Digest, + sha256DigestSchema, +} from '@tangle-network/agent-interface' +import type { OriginatedPage, PageOrigin } from './run-scoped' +import type { KnowledgePage, KnowledgeSearchResult } from './types' + +export const KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION = '1.0.0' as const +export const KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM = 'rfc8785-sha256' as const + +export interface KnowledgeVisibilitySnapshotEntry { + readonly position: number + readonly pageId: string + readonly origin: PageOrigin + readonly path: string + readonly pageDigest: Sha256Digest + readonly sourceIds: readonly string[] + readonly invalidated: boolean +} + +/** Exact ordered page visibility presented to one retrieval operation. */ +export interface KnowledgeVisibilitySnapshot { + readonly schemaVersion: typeof KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION + readonly digestAlgorithm: typeof KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM + readonly snapshotDigest: Sha256Digest + readonly entries: readonly KnowledgeVisibilitySnapshotEntry[] +} + +export interface KnowledgeRetrieverIdentity { + /** Stable implementation name, for example `token-overlap-v1`. */ + readonly id: string + /** Published or application-defined implementation version. */ + readonly version: string + /** Exact identity of every retrieval setting not represented elsewhere. */ + readonly configDigest: Sha256Digest +} + +export interface OriginatedKnowledgeSearchResult extends KnowledgeSearchResult { + readonly origin: PageOrigin +} + +export interface KnowledgeRetrievalResultReceipt { + readonly rank: number + readonly pageId: string + readonly origin: PageOrigin + readonly path: string + readonly pageDigest: Sha256Digest + readonly rrfScore: number + readonly normalizedScore: number + readonly snippet: string + readonly reasons: readonly string[] +} + +export type KnowledgeReceiptAttributeValue = string | number | boolean | null + +/** + * Immutable proof of what one actor could see and what its retriever returned. + * Final prose is not evidence that retrieval happened; this receipt is. + */ +export interface KnowledgeRetrievalReceipt { + readonly schemaVersion: typeof KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION + readonly kind: 'knowledge-retrieval' + readonly digestAlgorithm: typeof KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM + readonly receiptDigest: Sha256Digest + readonly createdAt: string + readonly runId: string + readonly actorId?: string + readonly profileDigest?: Sha256Digest + readonly executionRef?: Sha256Digest + readonly query: string + readonly retriever: KnowledgeRetrieverIdentity + readonly visibility: KnowledgeVisibilitySnapshot + readonly results: readonly KnowledgeRetrievalResultReceipt[] + readonly evidenceRefs: readonly EvidenceRef[] + readonly attributes: Readonly> +} + +export interface CreateKnowledgeRetrievalReceiptInput { + readonly runId: string + readonly actorId?: string + readonly profileDigest?: Sha256Digest + readonly executionRef?: Sha256Digest + readonly query: string + readonly retriever: KnowledgeRetrieverIdentity + readonly visiblePages: readonly OriginatedPage[] + readonly results: readonly OriginatedKnowledgeSearchResult[] + readonly evidenceRefs?: readonly EvidenceRef[] + readonly attributes?: Readonly> + readonly createdAt?: Date | string +} + +export type KnowledgeUseRelation = + | 'supports' + | 'contradicts' + | 'extends' + | 'rederives' + | 'background' + +export type KnowledgeConsumerKind = + | 'decision' + | 'artifact' + | 'experiment' + | 'candidate' + | 'message' + | 'other' + +export interface KnowledgeConsumerRef { + readonly kind: KnowledgeConsumerKind + readonly uri: string + readonly digest?: Sha256Digest +} + +export interface KnowledgeUsedResult { + readonly rank: number + readonly pageId: string + readonly origin: PageOrigin + readonly path: string + readonly pageDigest: Sha256Digest +} + +/** + * Immutable proof that one retrieved page was selected for a downstream + * decision, artifact, experiment, candidate, or message. + */ +export interface KnowledgeUseReceipt { + readonly schemaVersion: typeof KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION + readonly kind: 'knowledge-use' + readonly digestAlgorithm: typeof KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM + readonly receiptDigest: Sha256Digest + readonly createdAt: string + readonly runId: string + readonly actorId?: string + readonly profileDigest?: Sha256Digest + readonly executionRef?: Sha256Digest + readonly retrievalReceiptDigest: Sha256Digest + readonly used: KnowledgeUsedResult + readonly relation: KnowledgeUseRelation + readonly consumer: KnowledgeConsumerRef + readonly evidenceRefs: readonly EvidenceRef[] + readonly attributes: Readonly> +} + +export interface CreateKnowledgeUseReceiptInput { + readonly retrieval: KnowledgeRetrievalReceipt + /** Exact one-based rank from the retrieval receipt. */ + readonly selectedRank: number + readonly relation: KnowledgeUseRelation + readonly consumer: KnowledgeConsumerRef + readonly evidenceRefs?: readonly EvidenceRef[] + readonly attributes?: Readonly> + readonly createdAt?: Date | string +} + +/** Stable content identity for one exact knowledge page. */ +export function knowledgePageDigest(page: KnowledgePage): Sha256Digest { + validateKnowledgePage(page) + return canonicalCandidateDigest({ + id: page.id, + path: page.path, + title: page.title, + text: page.text, + frontmatter: page.frontmatter, + sourceIds: [...page.sourceIds], + tags: [...page.tags], + outLinks: [...page.outLinks], + contradicts: [...(page.contradicts ?? [])], + invalidation: page.invalidation ?? null, + }) +} + +/** Snapshot the exact ordered current/ancestor/shared page view. */ +export function createKnowledgeVisibilitySnapshot( + visiblePages: readonly OriginatedPage[], +): KnowledgeVisibilitySnapshot { + if (!Array.isArray(visiblePages)) { + throw new TypeError('knowledge visibility must be an array') + } + const identities = new Set() + const entries = visiblePages.map((entry, position) => { + if (!entry || typeof entry !== 'object') { + throw new TypeError(`knowledge visibility[${position}] must be an originated page`) + } + const origin = validateOrigin(entry.origin, `knowledge visibility[${position}].origin`) + validateKnowledgePage(entry.page) + const identity = `${origin}\u0000${entry.page.path}` + if (identities.has(identity)) { + throw new Error( + `knowledge visibility repeats path '${entry.page.path}' at origin '${origin}'`, + ) + } + identities.add(identity) + return Object.freeze({ + position, + pageId: entry.page.id, + origin, + path: entry.page.path, + pageDigest: knowledgePageDigest(entry.page), + sourceIds: Object.freeze([...entry.page.sourceIds]), + invalidated: entry.page.invalidation !== undefined, + }) + }) + const material = visibilityMaterial(entries) + return Object.freeze({ + ...material, + snapshotDigest: canonicalCandidateDigest(material), + entries: Object.freeze(entries), + }) +} + +/** Create a retrieval receipt and refuse results that were not in the view. */ +export function createKnowledgeRetrievalReceipt( + input: CreateKnowledgeRetrievalReceiptInput, +): KnowledgeRetrievalReceipt { + if (!input || typeof input !== 'object') { + throw new TypeError('knowledge retrieval receipt input is required') + } + const runId = nonEmpty(input.runId, 'knowledge retrieval runId') + const actorId = optionalText(input.actorId, 'knowledge retrieval actorId') + const profileDigest = optionalDigest(input.profileDigest, 'knowledge retrieval profileDigest') + const executionRef = optionalDigest(input.executionRef, 'knowledge retrieval executionRef') + const query = nonEmpty(input.query, 'knowledge retrieval query') + const retriever = normalizeRetriever(input.retriever) + const visibility = createKnowledgeVisibilitySnapshot(input.visiblePages) + const results = normalizeRetrievalResults(input.results, visibility) + const evidenceRefs = normalizeEvidenceRefs(input.evidenceRefs ?? []) + const attributes = normalizeAttributes(input.attributes ?? {}) + const createdAt = isoTimestamp(input.createdAt, 'knowledge retrieval createdAt') + const material = retrievalMaterial({ + createdAt, + runId, + actorId, + profileDigest, + executionRef, + query, + retriever, + visibility, + results, + evidenceRefs, + attributes, + }) + return Object.freeze({ + ...material, + receiptDigest: canonicalCandidateDigest(material), + }) +} + +/** Verify the receipt's schema, internal joins, and canonical digest. */ +export function verifyKnowledgeRetrievalReceipt( + receipt: KnowledgeRetrievalReceipt, +): KnowledgeRetrievalReceipt { + if (!receipt || typeof receipt !== 'object') { + throw new TypeError('knowledge retrieval receipt is required') + } + if (receipt.schemaVersion !== KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION) { + throw new Error(`unsupported knowledge retrieval schemaVersion '${receipt.schemaVersion}'`) + } + if (receipt.kind !== 'knowledge-retrieval') { + throw new Error(`knowledge retrieval receipt kind must be 'knowledge-retrieval'`) + } + if (receipt.digestAlgorithm !== KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM) { + throw new Error(`unsupported knowledge retrieval digestAlgorithm '${receipt.digestAlgorithm}'`) + } + const expectedVisibility = canonicalCandidateDigest( + visibilityMaterial(receipt.visibility.entries), + ) + if (expectedVisibility !== receipt.visibility.snapshotDigest) { + throw new Error('knowledge retrieval visibility snapshot digest mismatch') + } + validateReceiptResults(receipt.results, receipt.visibility) + const material = retrievalMaterial({ + createdAt: isoTimestamp(receipt.createdAt, 'knowledge retrieval createdAt'), + runId: nonEmpty(receipt.runId, 'knowledge retrieval runId'), + actorId: optionalText(receipt.actorId, 'knowledge retrieval actorId'), + profileDigest: optionalDigest(receipt.profileDigest, 'knowledge retrieval profileDigest'), + executionRef: optionalDigest(receipt.executionRef, 'knowledge retrieval executionRef'), + query: nonEmpty(receipt.query, 'knowledge retrieval query'), + retriever: normalizeRetriever(receipt.retriever), + visibility: receipt.visibility, + results: receipt.results, + evidenceRefs: normalizeEvidenceRefs(receipt.evidenceRefs), + attributes: normalizeAttributes(receipt.attributes), + }) + const expected = canonicalCandidateDigest(material) + if (expected !== receipt.receiptDigest) { + throw new Error('knowledge retrieval receipt digest mismatch') + } + return receipt +} + +/** Prove that a receipt still describes the supplied visibility bytes. */ +export function assertKnowledgeRetrievalMatchesVisibility( + receipt: KnowledgeRetrievalReceipt, + visiblePages: readonly OriginatedPage[], +): void { + verifyKnowledgeRetrievalReceipt(receipt) + const observed = createKnowledgeVisibilitySnapshot(visiblePages) + if (observed.snapshotDigest !== receipt.visibility.snapshotDigest) { + throw new Error('knowledge retrieval receipt does not match the supplied visibility snapshot') + } +} + +/** Create a downstream-use receipt for one exact ranked result. */ +export function createKnowledgeUseReceipt( + input: CreateKnowledgeUseReceiptInput, +): KnowledgeUseReceipt { + if (!input || typeof input !== 'object') { + throw new TypeError('knowledge use receipt input is required') + } + const retrieval = verifyKnowledgeRetrievalReceipt(input.retrieval) + if (!Number.isSafeInteger(input.selectedRank) || input.selectedRank < 1) { + throw new TypeError('knowledge use selectedRank must be a positive safe integer') + } + const selected = retrieval.results.find((result) => result.rank === input.selectedRank) + if (!selected) { + throw new Error( + `knowledge use selectedRank ${input.selectedRank} was not returned by retrieval ${retrieval.receiptDigest}`, + ) + } + const relation = validateUseRelation(input.relation) + const consumer = normalizeConsumer(input.consumer) + const evidenceRefs = normalizeEvidenceRefs(input.evidenceRefs ?? []) + const attributes = normalizeAttributes(input.attributes ?? {}) + const createdAt = isoTimestamp(input.createdAt, 'knowledge use createdAt') + const used = Object.freeze({ + rank: selected.rank, + pageId: selected.pageId, + origin: selected.origin, + path: selected.path, + pageDigest: selected.pageDigest, + }) + const material = useMaterial({ + createdAt, + retrieval, + used, + relation, + consumer, + evidenceRefs, + attributes, + }) + return Object.freeze({ + ...material, + receiptDigest: canonicalCandidateDigest(material), + }) +} + +/** Verify one use receipt against the exact retrieval that authorized it. */ +export function verifyKnowledgeUseReceipt( + receipt: KnowledgeUseReceipt, + retrieval: KnowledgeRetrievalReceipt, +): KnowledgeUseReceipt { + if (!receipt || typeof receipt !== 'object') { + throw new TypeError('knowledge use receipt is required') + } + const verifiedRetrieval = verifyKnowledgeRetrievalReceipt(retrieval) + if (receipt.schemaVersion !== KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION) { + throw new Error(`unsupported knowledge use schemaVersion '${receipt.schemaVersion}'`) + } + if (receipt.kind !== 'knowledge-use') { + throw new Error(`knowledge use receipt kind must be 'knowledge-use'`) + } + if (receipt.digestAlgorithm !== KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM) { + throw new Error(`unsupported knowledge use digestAlgorithm '${receipt.digestAlgorithm}'`) + } + if (receipt.retrievalReceiptDigest !== verifiedRetrieval.receiptDigest) { + throw new Error('knowledge use receipt references a different retrieval receipt') + } + const selected = verifiedRetrieval.results.find((result) => result.rank === receipt.used.rank) + if ( + !selected || + selected.pageId !== receipt.used.pageId || + selected.origin !== receipt.used.origin || + selected.path !== receipt.used.path || + selected.pageDigest !== receipt.used.pageDigest + ) { + throw new Error('knowledge use receipt selected result does not match the retrieval receipt') + } + const material = useMaterial({ + createdAt: isoTimestamp(receipt.createdAt, 'knowledge use createdAt'), + retrieval: verifiedRetrieval, + used: receipt.used, + relation: validateUseRelation(receipt.relation), + consumer: normalizeConsumer(receipt.consumer), + evidenceRefs: normalizeEvidenceRefs(receipt.evidenceRefs), + attributes: normalizeAttributes(receipt.attributes), + }) + const expected = canonicalCandidateDigest(material) + if (expected !== receipt.receiptDigest) { + throw new Error('knowledge use receipt digest mismatch') + } + return receipt +} + +function visibilityMaterial(entries: readonly KnowledgeVisibilitySnapshotEntry[]) { + return { + schemaVersion: KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION, + digestAlgorithm: KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM, + entries: entries.map((entry, position) => { + if (entry.position !== position) { + throw new Error( + `knowledge visibility position mismatch: expected ${position}, observed ${entry.position}`, + ) + } + return { + position, + pageId: nonEmpty(entry.pageId, `knowledge visibility[${position}].pageId`), + origin: validateOrigin(entry.origin, `knowledge visibility[${position}].origin`), + path: nonEmpty(entry.path, `knowledge visibility[${position}].path`), + pageDigest: digest(entry.pageDigest, `knowledge visibility[${position}].pageDigest`), + sourceIds: entry.sourceIds.map((sourceId, sourceIndex) => + nonEmpty(sourceId, `knowledge visibility[${position}].sourceIds[${sourceIndex}]`), + ), + invalidated: Boolean(entry.invalidated), + } + }), + } as const +} + +function retrievalMaterial(input: { + createdAt: string + runId: string + actorId?: string + profileDigest?: Sha256Digest + executionRef?: Sha256Digest + query: string + retriever: KnowledgeRetrieverIdentity + visibility: KnowledgeVisibilitySnapshot + results: readonly KnowledgeRetrievalResultReceipt[] + evidenceRefs: readonly EvidenceRef[] + attributes: Readonly> +}) { + return { + schemaVersion: KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION, + kind: 'knowledge-retrieval' as const, + digestAlgorithm: KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM, + createdAt: input.createdAt, + runId: input.runId, + ...(input.actorId === undefined ? {} : { actorId: input.actorId }), + ...(input.profileDigest === undefined ? {} : { profileDigest: input.profileDigest }), + ...(input.executionRef === undefined ? {} : { executionRef: input.executionRef }), + query: input.query, + retriever: input.retriever, + visibility: input.visibility, + results: input.results, + evidenceRefs: input.evidenceRefs, + attributes: input.attributes, + } +} + +function useMaterial(input: { + createdAt: string + retrieval: KnowledgeRetrievalReceipt + used: KnowledgeUsedResult + relation: KnowledgeUseRelation + consumer: KnowledgeConsumerRef + evidenceRefs: readonly EvidenceRef[] + attributes: Readonly> +}) { + return { + schemaVersion: KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION, + kind: 'knowledge-use' as const, + digestAlgorithm: KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM, + createdAt: input.createdAt, + runId: input.retrieval.runId, + ...(input.retrieval.actorId === undefined ? {} : { actorId: input.retrieval.actorId }), + ...(input.retrieval.profileDigest === undefined + ? {} + : { profileDigest: input.retrieval.profileDigest }), + ...(input.retrieval.executionRef === undefined + ? {} + : { executionRef: input.retrieval.executionRef }), + retrievalReceiptDigest: input.retrieval.receiptDigest, + used: input.used, + relation: input.relation, + consumer: input.consumer, + evidenceRefs: input.evidenceRefs, + attributes: input.attributes, + } +} + +function normalizeRetrievalResults( + results: readonly OriginatedKnowledgeSearchResult[], + visibility: KnowledgeVisibilitySnapshot, +): readonly KnowledgeRetrievalResultReceipt[] { + if (!Array.isArray(results)) throw new TypeError('knowledge retrieval results must be an array') + const visible = new Map( + visibility.entries.map((entry) => [`${entry.origin}\u0000${entry.path}`, entry]), + ) + const seenRanks = new Set() + const seenPages = new Set() + const normalized = results.map((result, index) => { + if (!result || typeof result !== 'object') { + throw new TypeError(`knowledge retrieval results[${index}] must be an object`) + } + if (!Number.isSafeInteger(result.rank) || result.rank < 1) { + throw new TypeError(`knowledge retrieval results[${index}].rank must be positive`) + } + if (seenRanks.has(result.rank)) { + throw new Error(`knowledge retrieval repeats rank ${result.rank}`) + } + seenRanks.add(result.rank) + const origin = validateOrigin(result.origin, `knowledge retrieval results[${index}].origin`) + validateKnowledgePage(result.page) + const key = `${origin}\u0000${result.page.path}` + if (seenPages.has(key)) { + throw new Error( + `knowledge retrieval repeats page '${result.page.path}' at origin '${origin}'`, + ) + } + seenPages.add(key) + const visibleEntry = visible.get(key) + if (!visibleEntry) { + throw new Error( + `knowledge retrieval result '${result.page.path}' at origin '${origin}' was not visible`, + ) + } + const pageDigest = knowledgePageDigest(result.page) + if (visibleEntry.pageId !== result.page.id || visibleEntry.pageDigest !== pageDigest) { + throw new Error( + `knowledge retrieval result '${result.page.path}' does not match its visibility snapshot`, + ) + } + finite(result.rrfScore, `knowledge retrieval results[${index}].rrfScore`) + finite(result.normalizedScore, `knowledge retrieval results[${index}].normalizedScore`) + if (result.normalizedScore < 0 || result.normalizedScore > 1) { + throw new TypeError(`knowledge retrieval results[${index}].normalizedScore must be in [0,1]`) + } + return Object.freeze({ + rank: result.rank, + pageId: result.page.id, + origin, + path: result.page.path, + pageDigest, + rrfScore: result.rrfScore, + normalizedScore: result.normalizedScore, + snippet: typeof result.snippet === 'string' ? result.snippet : '', + reasons: Object.freeze( + result.reasons.map((reason: string, reasonIndex: number) => + nonEmpty(reason, `knowledge retrieval results[${index}].reasons[${reasonIndex}]`), + ), + ), + }) + }) + normalized.sort((left, right) => left.rank - right.rank) + normalized.forEach((result, index) => { + if (result.rank !== index + 1) { + throw new Error( + `knowledge retrieval ranks must be contiguous from 1; expected ${index + 1}, observed ${result.rank}`, + ) + } + }) + return Object.freeze(normalized) +} + +function validateReceiptResults( + results: readonly KnowledgeRetrievalResultReceipt[], + visibility: KnowledgeVisibilitySnapshot, +): void { + const visible = new Map( + visibility.entries.map((entry) => [`${entry.origin}\u0000${entry.path}`, entry]), + ) + const seen = new Set() + results.forEach((result, index) => { + if (result.rank !== index + 1) { + throw new Error( + `knowledge retrieval ranks must be contiguous from 1; expected ${index + 1}, observed ${result.rank}`, + ) + } + const origin = validateOrigin(result.origin, `knowledge retrieval results[${index}].origin`) + const key = `${origin}\u0000${nonEmpty(result.path, `knowledge retrieval results[${index}].path`)}` + if (seen.has(key)) throw new Error(`knowledge retrieval repeats visible page '${result.path}'`) + seen.add(key) + const entry = visible.get(key) + if (!entry || entry.pageId !== result.pageId || entry.pageDigest !== result.pageDigest) { + throw new Error( + `knowledge retrieval result rank ${result.rank} is not in the visibility snapshot`, + ) + } + finite(result.rrfScore, `knowledge retrieval results[${index}].rrfScore`) + finite(result.normalizedScore, `knowledge retrieval results[${index}].normalizedScore`) + if (result.normalizedScore < 0 || result.normalizedScore > 1) { + throw new TypeError(`knowledge retrieval results[${index}].normalizedScore must be in [0,1]`) + } + if (!Array.isArray(result.reasons)) { + throw new TypeError(`knowledge retrieval results[${index}].reasons must be an array`) + } + }) +} + +function normalizeRetriever(input: KnowledgeRetrieverIdentity): KnowledgeRetrieverIdentity { + if (!input || typeof input !== 'object') { + throw new TypeError('knowledge retriever identity is required') + } + return Object.freeze({ + id: nonEmpty(input.id, 'knowledge retriever id'), + version: nonEmpty(input.version, 'knowledge retriever version'), + configDigest: digest(input.configDigest, 'knowledge retriever configDigest'), + }) +} + +function normalizeConsumer(input: KnowledgeConsumerRef): KnowledgeConsumerRef { + if (!input || typeof input !== 'object') { + throw new TypeError('knowledge consumer reference is required') + } + const kinds: readonly KnowledgeConsumerKind[] = [ + 'decision', + 'artifact', + 'experiment', + 'candidate', + 'message', + 'other', + ] + if (!kinds.includes(input.kind)) { + throw new TypeError(`knowledge consumer kind is invalid: ${String(input.kind)}`) + } + return Object.freeze({ + kind: input.kind, + uri: nonEmpty(input.uri, 'knowledge consumer uri'), + ...(input.digest === undefined + ? {} + : { digest: digest(input.digest, 'knowledge consumer digest') }), + }) +} + +function normalizeEvidenceRefs(values: readonly EvidenceRef[]): readonly EvidenceRef[] { + if (!Array.isArray(values)) throw new TypeError('knowledge evidenceRefs must be an array') + const allowed: readonly EvidenceRef['kind'][] = ['span', 'event', 'artifact', 'finding', 'metric'] + return Object.freeze( + values.map((value, index) => { + if (!value || typeof value !== 'object' || !allowed.includes(value.kind)) { + throw new TypeError(`knowledge evidenceRefs[${index}].kind is invalid`) + } + return Object.freeze({ + kind: value.kind, + uri: nonEmpty(value.uri, `knowledge evidenceRefs[${index}].uri`), + ...(value.excerpt === undefined + ? {} + : { excerpt: nonEmpty(value.excerpt, `knowledge evidenceRefs[${index}].excerpt`) }), + }) + }), + ) +} + +function normalizeAttributes( + input: Readonly>, +): Readonly> { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('knowledge receipt attributes must be an object') + } + const normalized: Record = {} + for (const [key, value] of Object.entries(input)) { + const name = nonEmpty(key, 'knowledge receipt attribute key') + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new TypeError(`knowledge receipt attribute '${name}' has an unsupported value`) + } + if (typeof value === 'number') finite(value, `knowledge receipt attribute '${name}'`) + normalized[name] = value + } + return Object.freeze(normalized) +} + +function validateUseRelation(value: KnowledgeUseRelation): KnowledgeUseRelation { + const allowed: readonly KnowledgeUseRelation[] = [ + 'supports', + 'contradicts', + 'extends', + 'rederives', + 'background', + ] + if (!allowed.includes(value)) { + throw new TypeError(`knowledge use relation is invalid: ${String(value)}`) + } + return value +} + +function validateKnowledgePage(page: KnowledgePage): void { + if (!page || typeof page !== 'object') throw new TypeError('knowledge page must be an object') + nonEmpty(page.id, 'knowledge page id') + nonEmpty(page.path, 'knowledge page path') + nonEmpty(page.title, 'knowledge page title') + if (typeof page.text !== 'string') throw new TypeError('knowledge page text must be a string') + if ( + !page.frontmatter || + typeof page.frontmatter !== 'object' || + Array.isArray(page.frontmatter) + ) { + throw new TypeError('knowledge page frontmatter must be an object') + } + for (const [name, values] of [ + ['sourceIds', page.sourceIds], + ['tags', page.tags], + ['outLinks', page.outLinks], + ] as const) { + if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) { + throw new TypeError(`knowledge page ${name} must be a string array`) + } + } +} + +function validateOrigin(value: unknown, label: string): PageOrigin { + if (value === 'here' || value === 'shared') return value + if ( + typeof value === 'string' && + value.startsWith('inherited:') && + value.slice('inherited:'.length).trim().length > 0 + ) { + return value as PageOrigin + } + throw new TypeError(`${label} is invalid: ${String(value)}`) +} + +function digest(value: unknown, label: string): Sha256Digest { + const parsed = sha256DigestSchema.safeParse(value) + if (!parsed.success) throw new TypeError(`${label} must be a lowercase sha256 digest`) + return parsed.data +} + +function optionalDigest(value: unknown, label: string): Sha256Digest | undefined { + return value === undefined ? undefined : digest(value, label) +} + +function nonEmpty(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`${label} must be a non-empty string`) + } + return value.trim() +} + +function optionalText(value: unknown, label: string): string | undefined { + return value === undefined ? undefined : nonEmpty(value, label) +} + +function finite(value: unknown, label: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError(`${label} must be a finite number`) + } +} + +function isoTimestamp(value: Date | string | undefined, label: string): string { + const date = value === undefined ? new Date() : value instanceof Date ? value : new Date(value) + if (!Number.isFinite(date.getTime())) throw new TypeError(`${label} must be a valid timestamp`) + return date.toISOString() +}