diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index 3cbe35f..3120122 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -118,6 +118,33 @@ export const snapshotWithRebuildSchema = z }) .strict(); +export const changeFamilySchema = z.enum([ + 'no_change', + 'viewing_change', + 'slot_explicitness_change', + 'editioned_semantic_change', + 'entity_addition', + 'described_entity_retargeting', +]); + +export const refreshSentinelSchema = z + .object({ + name: z.string(), + passed: z.boolean(), + detail: z.string().optional(), + }) + .strict(); + +export const refreshClassificationSchema = z + .object({ + changeFamily: changeFamilySchema, + sentinels: z.array(refreshSentinelSchema), + addedIds: z.array(z.string()), + removedIds: z.array(z.string()), + changedIds: z.array(z.string()), + }) + .strict(); + export const buildAuditSchema = z .object({ sourcePath: z.string(), @@ -139,6 +166,7 @@ export const buildAuditSchema = z brokenRoutes: z.array(z.string()), }) .strict(), + refreshClassification: refreshClassificationSchema.optional(), compiler: z .object({ mode: z.literal('local_vectorless'), diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts index c1bef21..33931b1 100644 --- a/src/runtime/constants.ts +++ b/src/runtime/constants.ts @@ -9,6 +9,7 @@ export const ARTIFACT_FILENAMES = { routeGraph: 'route-graph.json', lexicon: 'lexicon.json', anchorMap: 'anchor-map.json', + indexingView: 'indexing-view.json', } as const; export const PREFACE_MARKER = '# **Preface** (non-normative)'; diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts new file mode 100644 index 0000000..5099833 --- /dev/null +++ b/src/runtime/indexing-view.ts @@ -0,0 +1,343 @@ +import { createHash } from 'node:crypto'; +import type { + ChangeFamily, + IndexingView, + IndexingViewEntry, + IndexingViewRoute, + RefreshClassification, + RefreshSentinel, + Snapshot, +} from './types.js'; + +export function buildIndexingView(snapshot: Snapshot): IndexingView { + const patterns: Record = {}; + for (const [id, pattern] of Object.entries(snapshot.patternGraph.nodes)) { + patterns[id] = { + id, + kind: 'pattern', + title: pattern.title, + status: pattern.status, + type: pattern.type, + normativity: pattern.normativity, + part: pattern.part, + cluster: pattern.cluster, + aliases: [...pattern.aliases].sort(), + anchorIds: [...pattern.sectionIds].sort(), + relationEdges: pattern.relations + .map((r) => ({ from: r.from, relation: r.relation, to: r.to })) + .sort((a, b) => `${a.from}:${a.relation}:${a.to}`.localeCompare(`${b.from}:${b.relation}:${b.to}`)), + }; + } + + const routes: Record = {}; + for (const [id, route] of Object.entries(snapshot.routeGraph.nodes)) { + routes[id] = { + id, + name: route.name, + orderedIds: [...route.orderedIds], + optionalIds: [...route.optionalIds], + landingIds: [...route.landingIds], + routeSurfaces: [...route.routeSurfaces], + constraints: route.firstHonestBurden ? [route.firstHonestBurden] : [], + anchorIds: [...route.anchorIds].sort(), + citations: [...route.citations].sort(), + nextOwners: [...route.nextOwners].sort(), + reroutes: [...route.reroutes].sort(), + }; + } + + const anchorIds = Object.keys(snapshot.anchorMap).sort(); + const lexiconCanonicals = Object.keys(snapshot.lexicon).sort(); + const lexiconFingerprints: Record = {}; + for (const [id, entry] of Object.entries(snapshot.lexicon)) { + lexiconFingerprints[id] = { + normalizedKeys: [...entry.normalizedKeys].sort(), + linkedNodeIds: [...entry.linkedNodeIds].sort(), + }; + } + + const sortedPatterns = Object.fromEntries(Object.entries(patterns).sort(([a], [b]) => a.localeCompare(b))); + const sortedRoutes = Object.fromEntries(Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))); + const sortedLexiconFingerprints = Object.fromEntries( + Object.entries(lexiconFingerprints).sort(([a], [b]) => a.localeCompare(b)), + ); + const spineContent = JSON.stringify({ patterns: sortedPatterns, routes: sortedRoutes, anchorIds, lexiconCanonicals, lexiconFingerprints: sortedLexiconFingerprints }); + const edition = `sha256:${createHash('sha256').update(spineContent).digest('hex').slice(0, 16)}`; + + return { + edition, + sourceHash: snapshot.sourceHash, + builtAt: snapshot.builtAt, + patterns, + routes, + anchorIds, + lexiconCanonicals, + }; +} + +export function classifyChange( + previous: IndexingView, + current: IndexingView, +): RefreshClassification { + const sentinels = runRefreshSentinels(previous, current); + + const prevPatternIds = new Set(Object.keys(previous.patterns)); + const currPatternIds = new Set(Object.keys(current.patterns)); + const prevRouteIds = new Set(Object.keys(previous.routes)); + const currRouteIds = new Set(Object.keys(current.routes)); + + const prevAllIds = new Set([...prevPatternIds, ...prevRouteIds]); + const currAllIds = new Set([...currPatternIds, ...currRouteIds]); + + const addedIds = [...currAllIds].filter((id) => !prevAllIds.has(id)); + const removedIds = [...prevAllIds].filter((id) => !currAllIds.has(id)); + const changedIds: string[] = []; + + for (const id of currPatternIds) { + if (prevPatternIds.has(id) && !entryEqual(previous.patterns[id], current.patterns[id])) { + changedIds.push(id); + } + } + for (const id of currRouteIds) { + if (prevRouteIds.has(id) && !routeEqual(previous.routes[id], current.routes[id])) { + changedIds.push(id); + } + } + + if (previous.edition === current.edition) { + return { changeFamily: 'no_change', sentinels, addedIds, removedIds, changedIds }; + } + + const changeFamily = inferChangeFamily(previous, current, addedIds, removedIds, changedIds); + return { changeFamily, sentinels, addedIds, removedIds, changedIds }; +} + +function inferChangeFamily( + previous: IndexingView, + current: IndexingView, + addedIds: string[], + removedIds: string[], + changedIds: string[], +): ChangeFamily { + // Distinguish pure additions (benign) from removals/renames (real retarget risk) + if (removedIds.length > 0) { + return 'described_entity_retargeting'; + } + if (addedIds.length > 0 && changedIds.length === 0) { + return 'entity_addition'; + } + + if (changedIds.length === 0) { + // No pattern/route ID changes but edition differs — check anchors and lexicon explicitly + const anchorsChanged = + previous.anchorIds.length !== current.anchorIds.length || + previous.anchorIds.some((id, i) => id !== current.anchorIds[i]); + const lexiconChanged = + previous.lexiconCanonicals.length !== current.lexiconCanonicals.length || + previous.lexiconCanonicals.some((id, i) => id !== current.lexiconCanonicals[i]); + if (anchorsChanged || lexiconChanged) { + return 'viewing_change'; + } + // Edition differs but no detectable field change — defensive fallback + return 'viewing_change'; + } + + const hasSemanticChange = changedIds.some((id) => { + const prev = previous.patterns[id]; + const curr = current.patterns[id]; + if (!prev || !curr) { + const prevRoute = previous.routes[id]; + const currRoute = current.routes[id]; + if (prevRoute && currRoute) { + return ( + prevRoute.name !== currRoute.name || + !arraysEqual(prevRoute.orderedIds, currRoute.orderedIds) || + !arraysEqual(prevRoute.landingIds, currRoute.landingIds) || + !arraysEqual(prevRoute.optionalIds, currRoute.optionalIds) || + !arraysEqual(prevRoute.routeSurfaces, currRoute.routeSurfaces) || + !arraysEqual(prevRoute.constraints, currRoute.constraints) || + !arraysEqual(prevRoute.anchorIds, currRoute.anchorIds) || + !arraysEqual(prevRoute.citations, currRoute.citations) || + !arraysEqual(prevRoute.nextOwners, currRoute.nextOwners) || + !arraysEqual(prevRoute.reroutes, currRoute.reroutes) + ); + } + return true; + } + return ( + prev.title !== curr.title || + prev.status !== curr.status || + prev.type !== curr.type || + prev.normativity !== curr.normativity || + !relationEdgesEqual(prev.relationEdges, curr.relationEdges) + ); + }); + + if (hasSemanticChange) { + return 'editioned_semantic_change'; + } + + const hasSlotChange = changedIds.some((id) => { + const prev = previous.patterns[id]; + const curr = current.patterns[id]; + if (!prev || !curr) { + return false; + } + return ( + prev.part !== curr.part || + prev.cluster !== curr.cluster || + !arraysEqual(prev.aliases, curr.aliases) + ); + }); + + if (hasSlotChange) { + return 'slot_explicitness_change'; + } + + return 'viewing_change'; +} + +/** Cap sentinel detail to first N items for uniform truncation. */ +const MAX_DETAIL_ITEMS = 10; +function formatDetail(label: string, items: string[]): string { + const capped = items.slice(0, MAX_DETAIL_ITEMS); + const suffix = items.length > MAX_DETAIL_ITEMS ? `, … (${items.length - MAX_DETAIL_ITEMS} more)` : ''; + return `${label}: ${capped.join(', ')}${suffix}`; +} + +function runRefreshSentinels( + previous: IndexingView, + current: IndexingView, +): RefreshSentinel[] { + const sentinels: RefreshSentinel[] = []; + + const prevPatternIds = new Set(Object.keys(previous.patterns)); + const currPatternIds = new Set(Object.keys(current.patterns)); + const missingIds = [...prevPatternIds].filter((id) => !currPatternIds.has(id)); + sentinels.push({ + name: 'id_continuity', + passed: missingIds.length === 0, + detail: missingIds.length > 0 ? formatDetail('Removed pattern IDs', missingIds) : undefined, + }); + + const prevAliases = new Set( + Object.values(previous.patterns).flatMap((p) => p.aliases), + ); + const currAliases = new Set( + Object.values(current.patterns).flatMap((p) => p.aliases), + ); + const droppedAliases = [...prevAliases].filter((a) => !currAliases.has(a)); + sentinels.push({ + name: 'alias_coverage', + passed: droppedAliases.length === 0, + detail: droppedAliases.length > 0 ? formatDetail('Dropped aliases', droppedAliases) : undefined, + }); + + const prevAnchorSet = new Set(previous.anchorIds); + const currAnchorSet = new Set(current.anchorIds); + const missingAnchors = [...prevAnchorSet].filter((a) => !currAnchorSet.has(a)); + sentinels.push({ + name: 'anchor_continuity', + passed: missingAnchors.length === 0, + detail: missingAnchors.length > 0 ? formatDetail('Missing anchors', missingAnchors) : undefined, + }); + + const prevRouteIds = new Set(Object.keys(previous.routes)); + const currRouteIds = new Set(Object.keys(current.routes)); + const missingRoutes = [...prevRouteIds].filter((id) => !currRouteIds.has(id)); + sentinels.push({ + name: 'route_closure', + passed: missingRoutes.length === 0, + detail: missingRoutes.length > 0 ? formatDetail('Missing routes', missingRoutes) : undefined, + }); + + const allRelationTargets = new Set(); + for (const pattern of Object.values(current.patterns)) { + for (const edge of pattern.relationEdges) { + allRelationTargets.add(edge.to); + } + } + const danglingRefs = [...allRelationTargets].filter( + (id) => !currPatternIds.has(id) && !currRouteIds.has(id), + ); + sentinels.push({ + name: 'no_dangling_references', + passed: danglingRefs.length === 0, + detail: danglingRefs.length > 0 ? formatDetail('Dangling references', danglingRefs) : undefined, + }); + + return sentinels; +} + +/** Field-by-field equality for IndexingViewEntry — avoids JSON.stringify allocation. */ +function entryEqual( + a: IndexingViewEntry | undefined, + b: IndexingViewEntry | undefined, +): boolean { + if (!a || !b) { + return a === b; + } + return ( + a.id === b.id && + a.kind === b.kind && + a.title === b.title && + a.status === b.status && + a.type === b.type && + a.normativity === b.normativity && + a.part === b.part && + a.cluster === b.cluster && + arraysEqual(a.aliases, b.aliases) && + arraysEqual(a.anchorIds, b.anchorIds) && + relationEdgesEqual(a.relationEdges, b.relationEdges) + ); +} + +/** Field-by-field equality for IndexingViewRoute — avoids JSON.stringify allocation. */ +function routeEqual( + a: IndexingViewRoute | undefined, + b: IndexingViewRoute | undefined, +): boolean { + if (!a || !b) { + return a === b; + } + return ( + a.id === b.id && + a.name === b.name && + arraysEqual(a.orderedIds, b.orderedIds) && + arraysEqual(a.optionalIds, b.optionalIds) && + arraysEqual(a.landingIds, b.landingIds) && + arraysEqual(a.routeSurfaces, b.routeSurfaces) && + arraysEqual(a.constraints, b.constraints) && + arraysEqual(a.anchorIds, b.anchorIds) && + arraysEqual(a.citations, b.citations) && + arraysEqual(a.nextOwners, b.nextOwners) && + arraysEqual(a.reroutes, b.reroutes) + ); +} + +function arraysEqual(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +function relationEdgesEqual( + a: ReadonlyArray<{ from: string; relation: string; to: string }>, + b: ReadonlyArray<{ from: string; relation: string; to: string }>, +): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].from !== b[i].from || a[i].relation !== b[i].relation || a[i].to !== b[i].to) { + return false; + } + } + return true; +} diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c426391..90c9720 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -8,6 +8,7 @@ import { DEFAULT_SOURCE_PATH, } from './constants.js'; import { compileFpfSource } from './compiler.js'; +import { buildIndexingView, classifyChange } from './indexing-view.js'; import { createSynthesizerFromEnv } from './lm-studio-synthesizer.js'; import { QueryEngine } from './query-engine.js'; import { @@ -18,6 +19,7 @@ import type { AnswerMode, BuildAudit, ExpandCitationsResult, + IndexingView, InspectAnchorResult, InspectResult, LocalAnswerSynthesizer, @@ -81,10 +83,17 @@ export class FpfRuntime { artifacts: this.artifactPaths, }; await this.writeArtifacts(existingSnapshot); + const existingView = await this.loadIndexingView(); + if (!existingView) { + const view = buildIndexingView(existingSnapshot); + await this.writeJson(this.artifactPaths.indexingView, view); + } await this.writeAudit(audit); return audit; } + const previousIndexingView = await this.loadIndexingView() + ?? (existingSnapshot ? buildIndexingView(existingSnapshot) : undefined); const sourceText = await readFile(this.sourcePath, 'utf8'); const builtAt = new Date().toISOString(); const { snapshot } = compileFpfSource({ @@ -94,22 +103,29 @@ export class FpfRuntime { sourceText, }); + const currentIndexingView = buildIndexingView(snapshot); + const refreshClassification = previousIndexingView + ? classifyChange(previousIndexingView, currentIndexingView) + : undefined; + await this.writeArtifacts(snapshot); + await this.writeJson(this.artifactPaths.indexingView, currentIndexingView); const audit: BuildAudit = { sourcePath: this.sourcePath, sourceHash: currentSourceHash, previousSourceHash: existingSnapshot?.sourceHash, builtAt, - rebuilt: true, - reason: force - ? 'forced' - : existingSnapshot - ? compatibleSnapshot - ? 'source_hash_changed' - : 'missing_snapshot' - : 'missing_snapshot', + rebuilt: true, + reason: force + ? 'forced' + : existingSnapshot + ? compatibleSnapshot + ? 'source_hash_changed' + : 'missing_snapshot' + : 'missing_snapshot', validation: snapshot.validation, + refreshClassification, compiler: buildCompilerSummary(snapshot), artifacts: this.artifactPaths, }; @@ -240,6 +256,15 @@ export class FpfRuntime { } } + private async loadIndexingView(): Promise { + try { + const content = await readFile(this.artifactPaths.indexingView, 'utf8'); + return JSON.parse(content) as IndexingView; + } catch { + return undefined; + } + } + private async requireSnapshot(): Promise { const snapshot = await this.loadSnapshot(); if (!snapshot) { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 8b01fcf..2c3cd14 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -187,6 +187,66 @@ export interface Snapshot { validation: BuildValidation; } +export type ChangeFamily = + | 'no_change' + | 'viewing_change' + | 'slot_explicitness_change' + | 'editioned_semantic_change' + | 'entity_addition' + | 'described_entity_retargeting'; + +export interface IndexingViewEntry { + id: string; + kind: NodeKind; + title: string; + status?: string; + type?: string; + normativity?: string; + part?: string; + cluster?: string; + aliases: string[]; + anchorIds: string[]; + relationEdges: Array<{ from: string; relation: RelationKind; to: string }>; +} + +export interface IndexingViewRoute { + id: string; + name: string; + orderedIds: string[]; + optionalIds: string[]; + landingIds: string[]; + routeSurfaces: string[]; + constraints: string[]; + anchorIds: string[]; + citations: string[]; + nextOwners: string[]; + reroutes: string[]; +} + +export interface IndexingView { + edition: string; + sourceHash: string; + builtAt: string; + patterns: Record; + routes: Record; + anchorIds: string[]; + lexiconCanonicals: string[]; +} + +export interface RefreshSentinel { + name: string; + passed: boolean; + detail?: string; +} + +export interface RefreshClassification { + changeFamily: ChangeFamily; + sentinels: RefreshSentinel[]; + addedIds: string[]; + removedIds: string[]; + changedIds: string[]; +} + export interface BuildAudit { sourcePath: string; sourceHash: string; @@ -199,6 +259,7 @@ export interface BuildAudit { | 'source_hash_changed' | 'snapshot_current'; validation: BuildValidation; + refreshClassification?: RefreshClassification; compiler: { mode: 'local_vectorless'; compiledNodes: number;