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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-knowledge",
"version": "1.1.0",
"version": "1.1.1",
"description": "Source-grounded, eval-gated knowledge growth primitives for agents.",
"homepage": "https://github.com/tangle-network/agent-knowledge#readme",
"repository": {
Expand Down
52 changes: 50 additions & 2 deletions src/eval-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface BuildEvalKnowledgeBundleOptions {
userAnswers?: Record<string, string>
searchLimit?: number
metadata?: Record<string, unknown>
now?: Date
}

export interface EvalKnowledgeBundleBuildResult {
Expand All @@ -53,11 +54,12 @@ export interface EvalKnowledgeBundleBuildResult {

export function buildEvalKnowledgeBundle(options: BuildEvalKnowledgeBundleOptions): EvalKnowledgeBundleBuildResult {
const searchLimit = options.searchLimit ?? 5
const now = options.now ?? new Date()
const searchResultsByRequirement: Record<string, KnowledgeSearchResult[]> = {}
const requirements = options.specs.map((spec) => {
const results = searchKnowledge(options.index, spec.query, searchLimit)
searchResultsByRequirement[spec.id] = results
return requirementFromSearch(spec, results)
return requirementFromSearch(options.index, spec, results, now)
})
const report = scoreKnowledgeReadiness({
taskId: options.taskId,
Expand Down Expand Up @@ -85,15 +87,19 @@ export function buildEvalKnowledgeBundle(options: BuildEvalKnowledgeBundleOption
}

function requirementFromSearch(
index: KnowledgeIndex,
spec: KnowledgeReadinessSpec,
results: KnowledgeSearchResult[],
now: Date,
): KnowledgeRequirement {
const hitCount = results.length
const sourceIds = unique(results.flatMap((result) => result.page.sourceIds))
const sources = index.sources.filter((source) => sourceIds.includes(source.id))
const bestScore = results[0]?.normalizedScore ?? 0
const sourceCoverage = spec.minSources ? Math.min(1, sourceIds.length / spec.minSources) : (sourceIds.length > 0 ? 1 : 0)
const hitCoverage = spec.minHits ? Math.min(1, hitCount / spec.minHits) : (hitCount > 0 ? 1 : 0)
const currentConfidence = round(Math.min(bestScore, sourceCoverage, hitCoverage))
const freshness = sourceFreshness(sources, now)
const currentConfidence = round(Math.min(bestScore, sourceCoverage, hitCoverage, freshness.score))

return {
id: spec.id,
Expand All @@ -117,10 +123,52 @@ function requirementFromSearch(
hitCount,
sourceCount: sourceIds.length,
bestNormalizedScore: bestScore,
expiredSourceIds: freshness.expiredSourceIds,
freshnessScore: freshness.score,
validUntil: freshness.validUntil,
lastVerifiedAt: freshness.lastVerifiedAt,
},
}
}

function sourceFreshness(
sources: KnowledgeIndex['sources'],
now: Date,
): { score: number; validUntil?: string; lastVerifiedAt?: string; expiredSourceIds: string[] } {
if (sources.length === 0) return { score: 0, expiredSourceIds: [] }
const validUntilValues = sources.map((source) => source.validUntil ?? stringMetadata(source.metadata, 'validUntil') ?? stringMetadata(source.metadata, 'expiresAt')).filter(isIsoDate)
const lastVerifiedValues = sources.map((source) => source.lastVerifiedAt ?? stringMetadata(source.metadata, 'lastVerifiedAt')).filter(isIsoDate)
const expiredSourceIds = sources
.filter((source) => {
const validUntil = source.validUntil ?? stringMetadata(source.metadata, 'validUntil') ?? stringMetadata(source.metadata, 'expiresAt')
return validUntil ? Date.parse(validUntil) <= now.getTime() : false
})
.map((source) => source.id)
return {
score: expiredSourceIds.length > 0 ? 0 : 1,
validUntil: earliestIso(validUntilValues),
lastVerifiedAt: latestIso(lastVerifiedValues),
expiredSourceIds,
}
}

function stringMetadata(metadata: Record<string, unknown> | undefined, key: string): string | undefined {
const value = metadata?.[key]
return typeof value === 'string' ? value : undefined
}

function isIsoDate(value: string | undefined): value is string {
return Boolean(value && Number.isFinite(Date.parse(value)))
}

function earliestIso(values: string[]): string | undefined {
return values.sort((a, b) => Date.parse(a) - Date.parse(b))[0]
}

function latestIso(values: string[]): string | undefined {
return values.sort((a, b) => Date.parse(b) - Date.parse(a))[0]
}

function pageIdsFromResults(results: KnowledgeSearchResult[]): string[] {
return results.map((result) => result.page.id)
}
Expand Down
33 changes: 32 additions & 1 deletion src/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,61 @@ import { searchKnowledge } from './search'
export interface KnowledgeInspection {
pageCount: number
sourceCount: number
expiredSourceCount: number
staleSourceCount: number
edgeCount: number
findingCount: number
blockingFindingCount: number
topPages: Array<{ path: string; title: string; degree: number; sources: number }>
sourceFreshness: SourceFreshnessInspection[]
findings: KnowledgeLintFinding[]
}

export function inspectKnowledgeIndex(index: KnowledgeIndex): KnowledgeInspection {
export interface SourceFreshnessInspection {
id: string
title?: string
uri: string
status: 'fresh' | 'expired' | 'unknown'
validUntil?: string
lastVerifiedAt?: string
}

export function inspectKnowledgeIndex(index: KnowledgeIndex, options: { now?: Date } = {}): KnowledgeInspection {
const now = options.now ?? new Date()
const findings = lintKnowledgeIndex(index)
const degree = new Map(index.graph.nodes.map((node) => [node.id, node.inDegree + node.outDegree]))
const sourceFreshness = index.sources.map((source) => inspectSourceFreshness(source, now))
return {
pageCount: index.pages.length,
sourceCount: index.sources.length,
expiredSourceCount: sourceFreshness.filter((source) => source.status === 'expired').length,
staleSourceCount: sourceFreshness.filter((source) => source.status !== 'fresh').length,
edgeCount: index.graph.edges.length,
findingCount: findings.length,
blockingFindingCount: findings.filter((finding) => finding.severity === 'error').length,
topPages: [...index.pages]
.sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0))
.slice(0, 10)
.map((page) => ({ path: page.path, title: page.title, degree: degree.get(page.id) ?? 0, sources: page.sourceIds.length })),
sourceFreshness,
findings,
}
}

function inspectSourceFreshness(source: KnowledgeIndex['sources'][number], now: Date): SourceFreshnessInspection {
const validUntil = source.validUntil ?? stringMetadata(source.metadata, 'validUntil') ?? stringMetadata(source.metadata, 'expiresAt')
const lastVerifiedAt = source.lastVerifiedAt ?? stringMetadata(source.metadata, 'lastVerifiedAt')
const status = validUntil && Number.isFinite(Date.parse(validUntil))
? Date.parse(validUntil) <= now.getTime() ? 'expired' : 'fresh'
: 'unknown'
return { id: source.id, title: source.title, uri: source.uri, status, validUntil, lastVerifiedAt }
}

function stringMetadata(metadata: Record<string, unknown> | undefined, key: string): string | undefined {
const value = metadata?.[key]
return typeof value === 'string' ? value : undefined
}

export interface KnowledgeExplanation {
target: string
page?: KnowledgePage
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface SourceRecord {
contentHash: string
text?: string
anchors?: SourceAnchor[]
/** ISO timestamp after which consumers should treat this source as stale. */
validUntil?: string
/** ISO timestamp for the last successful source freshness verification. */
lastVerifiedAt?: string
metadata?: Record<string, unknown>
createdAt: string
}
Expand Down
35 changes: 34 additions & 1 deletion tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
reciprocalRankFusion,
searchKnowledge,
validateKnowledgeIndex,
writeSourceRegistry,
} from '../src/index'
import { detectKnowledgeGaps, findSurprisingConnections, toKnowledgeVizGraph } from '../src/viz/index'

Expand Down Expand Up @@ -75,6 +76,14 @@ describe('index/search/lint/viz', () => {
const sourcePath = join(root, 'seed.md')
await writeFile(sourcePath, '# Seed\n\nEvidence about attention.')
const [source] = await addSourcePath(root, sourcePath, { now: () => new Date('2026-01-01T00:00:00.000Z') })
await writeSourceRegistry(root, {
generatedAt: new Date('2026-01-01T00:00:00.000Z').toISOString(),
sources: [{
...source!,
validUntil: '2026-05-04T00:00:00.000Z',
lastVerifiedAt: '2026-04-01T00:00:00.000Z',
}],
})
await writeFile(join(root, 'knowledge', 'concepts', 'attention.md'), [
'---',
'id: attention',
Expand Down Expand Up @@ -133,6 +142,7 @@ describe('index/search/lint/viz', () => {
const readiness = buildEvalKnowledgeBundle({
taskId: 'coding-task',
index,
now: new Date('2026-05-03T00:00:00.000Z'),
specs: [{
id: 'attention-doc',
description: 'Attention implementation note',
Expand Down Expand Up @@ -163,12 +173,35 @@ describe('index/search/lint/viz', () => {
expect(readiness.acquisitionPlans.some((plan) => plan.mode === 'ask_user')).toBe(true)
expect(readiness.bundle.wikiPageIds).toContain('flash-attention')

const staleReadiness = buildEvalKnowledgeBundle({
taskId: 'stale-tax-task',
index,
now: new Date('2026-05-05T00:00:00.000Z'),
specs: [{
id: 'current-source',
description: 'Current source-backed page',
query: 'memory bandwidth',
requiredFor: ['stale-tax-task'],
category: 'regulatory',
acquisitionMode: 'search_web',
importance: 'blocking',
freshness: 'daily',
sensitivity: 'public',
confidenceNeeded: 0.8,
minSources: 1,
}],
})
expect(staleReadiness.report.blockingMissingRequirements.map((requirement) => requirement.id)).toEqual(['current-source'])
expect(staleReadiness.requirements[0]?.metadata?.expiredSourceIds).toEqual([source!.id])

const findings = lintKnowledgeIndex(index)
expect(findings.some((finding) => finding.type === 'orphan')).toBe(true)
expect(findings.some((finding) => finding.type === 'missing-source')).toBe(false)

const inspection = inspectKnowledgeIndex(index)
const inspection = inspectKnowledgeIndex(index, { now: new Date('2026-05-05T00:00:00.000Z') })
expect(inspection.sourceCount).toBe(1)
expect(inspection.expiredSourceCount).toBe(1)
expect(inspection.sourceFreshness[0]).toMatchObject({ id: source!.id, status: 'expired' })
expect(KnowledgeIndexSchema.parse(index).pages.length).toBe(index.pages.length)
const explanation = explainKnowledgeTarget(index, 'attention')
expect(explanation.sources[0]?.id).toBe(source!.id)
Expand Down