From 72cdfbc1d956a67e3e38828fd3911334e4782b18 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:31:07 +0000 Subject: [PATCH 1/8] feat: add stable indexing view and semantic refresh classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IndexingView, ChangeFamily, RefreshClassification, RefreshSentinel types - Add buildIndexingView() to extract semantic spine from compiled snapshot - Add classifyChange() to diff two indexing views and classify the change family - Add runRefreshSentinels() for post-rebuild validation checks: id_continuity, alias_coverage, anchor_continuity, route_closure, no_dangling_references - Wire indexing view into runtime.refresh() — build after compile, compare with previous - Add indexingView to ARTIFACT_FILENAMES (persisted as indexing-view.json) - Add refreshClassification to BuildAudit type and MCP tool contract schema - Change families: no_change, viewing_change, slot_explicitness_change, editioned_semantic_change, described_entity_retargeting Closes #3 Co-Authored-By: Stanislau --- src/mcp/tool-contracts.ts | 27 ++++ src/runtime/constants.ts | 1 + src/runtime/indexing-view.ts | 254 +++++++++++++++++++++++++++++++++++ src/runtime/runtime.ts | 35 +++-- src/runtime/types.ts | 55 ++++++++ 5 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 src/runtime/indexing-view.ts diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index 3cbe35f..181f7a5 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -118,6 +118,32 @@ export const snapshotWithRebuildSchema = z }) .strict(); +export const changeFamilySchema = z.enum([ + 'no_change', + 'viewing_change', + 'slot_explicitness_change', + 'editioned_semantic_change', + '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 +165,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..2bf1392 --- /dev/null +++ b/src/runtime/indexing-view.ts @@ -0,0 +1,254 @@ +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 as { constraints?: string[] }).constraints ?? [])], + }; + } + + const anchorIds = Object.keys(snapshot.anchorMap).sort(); + const lexiconCanonicals = Object.keys(snapshot.lexicon).sort(); + + const spineContent = JSON.stringify({ patterns, routes, anchorIds, lexiconCanonicals }); + 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 { + if (addedIds.length > 0 || removedIds.length > 0) { + return 'described_entity_retargeting'; + } + + if (changedIds.length === 0) { + const anchorsDiffer = + previous.anchorIds.length !== current.anchorIds.length || + previous.anchorIds.some((id, i) => id !== current.anchorIds[i]); + const lexiconDiffers = + previous.lexiconCanonicals.length !== current.lexiconCanonicals.length || + previous.lexiconCanonicals.some((id, i) => id !== current.lexiconCanonicals[i]); + + if (anchorsDiffer || lexiconDiffers) { + return 'viewing_change'; + } + 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 || + JSON.stringify(prevRoute.orderedIds) !== JSON.stringify(currRoute.orderedIds) || + JSON.stringify(prevRoute.landingIds) !== JSON.stringify(currRoute.landingIds) + ); + } + return true; + } + return ( + prev.title !== curr.title || + prev.status !== curr.status || + prev.type !== curr.type || + prev.normativity !== curr.normativity || + JSON.stringify(prev.relationEdges) !== JSON.stringify(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 || + JSON.stringify(prev.aliases) !== JSON.stringify(curr.aliases) + ); + }); + + if (hasSlotChange) { + return 'slot_explicitness_change'; + } + + return 'viewing_change'; +} + +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 ? `Removed pattern IDs: ${missingIds.join(', ')}` : 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 ? `Dropped aliases: ${droppedAliases.join(', ')}` : 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 ? `Missing anchors: ${missingAnchors.slice(0, 10).join(', ')}` : 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 ? `Missing routes: ${missingRoutes.join(', ')}` : 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 ? `Dangling references: ${danglingRefs.slice(0, 10).join(', ')}` : undefined, + }); + + return sentinels; +} + +function entryEqual( + a: IndexingViewEntry | undefined, + b: IndexingViewEntry | undefined, +): boolean { + if (!a || !b) { + return a === b; + } + return JSON.stringify(a) === JSON.stringify(b); +} + +function routeEqual( + a: IndexingViewRoute | undefined, + b: IndexingViewRoute | undefined, +): boolean { + if (!a || !b) { + return a === b; + } + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c426391..b689934 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, @@ -85,6 +87,7 @@ export class FpfRuntime { return audit; } + const previousIndexingView = await this.loadIndexingView(); const sourceText = await readFile(this.sourcePath, 'utf8'); const builtAt = new Date().toISOString(); const { snapshot } = compileFpfSource({ @@ -94,22 +97,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 +250,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..2487c5d 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -187,6 +187,60 @@ export interface Snapshot { validation: BuildValidation; } +export type ChangeFamily = + | 'viewing_change' + | 'slot_explicitness_change' + | 'editioned_semantic_change' + | '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[]; +} + +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 | 'no_change'; + sentinels: RefreshSentinel[]; + addedIds: string[]; + removedIds: string[]; + changedIds: string[]; +} + export interface BuildAudit { sourcePath: string; sourceHash: string; @@ -199,6 +253,7 @@ export interface BuildAudit { | 'source_hash_changed' | 'snapshot_current'; validation: BuildValidation; + refreshClassification?: RefreshClassification; compiler: { mode: 'local_vectorless'; compiledNodes: number; From 07afa46c5eb8eed60053d63acf53f4a326884f58 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:35:02 +0000 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20address=20Gemini=20review=20?= =?UTF-8?q?=E2=80=94=20deterministic=20hash,=20route=20change=20detection,?= =?UTF-8?q?=20drop=20unsafe=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort pattern/route keys before JSON.stringify for deterministic edition hash - Remove unsafe RouteRecord constraints cast (field doesn't exist on main branch yet) - Expand route semantic change detection to include optionalIds, routeSurfaces, constraints Co-Authored-By: Stanislau --- src/runtime/indexing-view.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts index 2bf1392..dbd83da 100644 --- a/src/runtime/indexing-view.ts +++ b/src/runtime/indexing-view.ts @@ -38,14 +38,16 @@ export function buildIndexingView(snapshot: Snapshot): IndexingView { optionalIds: [...route.optionalIds], landingIds: [...route.landingIds], routeSurfaces: [...route.routeSurfaces], - constraints: [...((route as { constraints?: string[] }).constraints ?? [])], + constraints: [], }; } const anchorIds = Object.keys(snapshot.anchorMap).sort(); const lexiconCanonicals = Object.keys(snapshot.lexicon).sort(); - const spineContent = JSON.stringify({ patterns, routes, anchorIds, lexiconCanonicals }); + 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 spineContent = JSON.stringify({ patterns: sortedPatterns, routes: sortedRoutes, anchorIds, lexiconCanonicals }); const edition = `sha256:${createHash('sha256').update(spineContent).digest('hex').slice(0, 16)}`; return { @@ -131,7 +133,10 @@ function inferChangeFamily( return ( prevRoute.name !== currRoute.name || JSON.stringify(prevRoute.orderedIds) !== JSON.stringify(currRoute.orderedIds) || - JSON.stringify(prevRoute.landingIds) !== JSON.stringify(currRoute.landingIds) + JSON.stringify(prevRoute.landingIds) !== JSON.stringify(currRoute.landingIds) || + JSON.stringify(prevRoute.optionalIds) !== JSON.stringify(currRoute.optionalIds) || + JSON.stringify(prevRoute.routeSurfaces) !== JSON.stringify(currRoute.routeSurfaces) || + JSON.stringify(prevRoute.constraints) !== JSON.stringify(currRoute.constraints) ); } return true; From 4ef2d9e90ab3e73ec53495530a4e03373d20da6c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:37:30 +0000 Subject: [PATCH 3/8] fix: backfill indexing view on no-rebuild path, truncate sentinel details, simplify redundant conditional Co-Authored-By: Stanislau --- src/runtime/indexing-view.ts | 25 ++++++++++++------------- src/runtime/runtime.ts | 5 +++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts index dbd83da..24f19f9 100644 --- a/src/runtime/indexing-view.ts +++ b/src/runtime/indexing-view.ts @@ -110,16 +110,7 @@ function inferChangeFamily( } if (changedIds.length === 0) { - const anchorsDiffer = - previous.anchorIds.length !== current.anchorIds.length || - previous.anchorIds.some((id, i) => id !== current.anchorIds[i]); - const lexiconDiffers = - previous.lexiconCanonicals.length !== current.lexiconCanonicals.length || - previous.lexiconCanonicals.some((id, i) => id !== current.lexiconCanonicals[i]); - - if (anchorsDiffer || lexiconDiffers) { - return 'viewing_change'; - } + // No pattern/route ID changes but edition differs — must be anchor or lexicon change return 'viewing_change'; } @@ -186,7 +177,7 @@ function runRefreshSentinels( sentinels.push({ name: 'id_continuity', passed: missingIds.length === 0, - detail: missingIds.length > 0 ? `Removed pattern IDs: ${missingIds.join(', ')}` : undefined, + detail: missingIds.length > 0 ? truncateDetail(`Removed pattern IDs: ${missingIds.join(', ')}`) : undefined, }); const prevAliases = new Set( @@ -199,7 +190,7 @@ function runRefreshSentinels( sentinels.push({ name: 'alias_coverage', passed: droppedAliases.length === 0, - detail: droppedAliases.length > 0 ? `Dropped aliases: ${droppedAliases.join(', ')}` : undefined, + detail: droppedAliases.length > 0 ? truncateDetail(`Dropped aliases: ${droppedAliases.join(', ')}`) : undefined, }); const prevAnchorSet = new Set(previous.anchorIds); @@ -217,7 +208,7 @@ function runRefreshSentinels( sentinels.push({ name: 'route_closure', passed: missingRoutes.length === 0, - detail: missingRoutes.length > 0 ? `Missing routes: ${missingRoutes.join(', ')}` : undefined, + detail: missingRoutes.length > 0 ? truncateDetail(`Missing routes: ${missingRoutes.join(', ')}`) : undefined, }); const allRelationTargets = new Set(); @@ -238,6 +229,14 @@ function runRefreshSentinels( return sentinels; } +const MAX_DETAIL_LENGTH = 500; +function truncateDetail(detail: string): string { + if (detail.length <= MAX_DETAIL_LENGTH) { + return detail; + } + return `${detail.slice(0, MAX_DETAIL_LENGTH)}… (truncated)`; +} + function entryEqual( a: IndexingViewEntry | undefined, b: IndexingViewEntry | undefined, diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index b689934..d57c67b 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -83,6 +83,11 @@ 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; } From 0b2e388979a4ce68d33439b16a27fb2dda9c3f45 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:03:11 +0000 Subject: [PATCH 4/8] fix: include lexicon internals and route burden in edition hash - Include normalizedKeys and linkedNodeIds per lexicon entry in the edition hash so that changes to lexical retrieval inputs (used by query-engine for matching and node expansion) are detected by semantic refresh classification. - Populate IndexingViewRoute.constraints from route.firstHonestBurden so that burden changes (used by buildRouteAnswer for constraint generation) are captured in the edition hash. Co-Authored-By: Stanislau --- src/runtime/indexing-view.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts index 24f19f9..2d644fd 100644 --- a/src/runtime/indexing-view.ts +++ b/src/runtime/indexing-view.ts @@ -38,16 +38,26 @@ export function buildIndexingView(snapshot: Snapshot): IndexingView { optionalIds: [...route.optionalIds], landingIds: [...route.landingIds], routeSurfaces: [...route.routeSurfaces], - constraints: [], + constraints: route.firstHonestBurden ? [route.firstHonestBurden] : [], }; } 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 spineContent = JSON.stringify({ patterns: sortedPatterns, routes: sortedRoutes, anchorIds, lexiconCanonicals }); + 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 { From 5007b430ae881c8a4444f98e853e38b2e95e0fc3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:45:26 +0000 Subject: [PATCH 5/8] fix: unify ChangeFamily type, replace JSON.stringify with field-by-field equality, standardize truncation, refine entity retargeting - Include 'no_change' and 'entity_addition' in ChangeFamily type directly (eliminates ChangeFamily | 'no_change' union drift from Zod schema) - Replace JSON.stringify equality in entryEqual/routeEqual with field-by-field comparison (avoids allocation, removes key-order fragility) - Standardize all sentinel truncation to 'first N items' via formatDetail() (replaces mixed truncateDetail(chars) and .slice(0,10) strategies) - Distinguish pure entity_addition (benign) from described_entity_retargeting (removals/renames with real retarget risk) - Add explicit anchor/lexicon check in viewing_change fallthrough so future IndexingView fields don't silently get swept into viewing_change Co-Authored-By: Stanislau --- src/mcp/tool-contracts.ts | 1 + src/runtime/indexing-view.ts | 111 +++++++++++++++++++++++++++-------- src/runtime/types.ts | 4 +- 3 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index 181f7a5..3120122 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -123,6 +123,7 @@ export const changeFamilySchema = z.enum([ 'viewing_change', 'slot_explicitness_change', 'editioned_semantic_change', + 'entity_addition', 'described_entity_retargeting', ]); diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts index 2d644fd..9fe2b69 100644 --- a/src/runtime/indexing-view.ts +++ b/src/runtime/indexing-view.ts @@ -115,12 +115,26 @@ function inferChangeFamily( removedIds: string[], changedIds: string[], ): ChangeFamily { - if (addedIds.length > 0 || removedIds.length > 0) { + // Distinguish pure additions (benign) from removals/renames (real retarget risk) + if (removedIds.length > 0) { return 'described_entity_retargeting'; } + if (addedIds.length > 0) { + return 'entity_addition'; + } if (changedIds.length === 0) { - // No pattern/route ID changes but edition differs — must be anchor or lexicon change + // 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'; } @@ -133,11 +147,11 @@ function inferChangeFamily( if (prevRoute && currRoute) { return ( prevRoute.name !== currRoute.name || - JSON.stringify(prevRoute.orderedIds) !== JSON.stringify(currRoute.orderedIds) || - JSON.stringify(prevRoute.landingIds) !== JSON.stringify(currRoute.landingIds) || - JSON.stringify(prevRoute.optionalIds) !== JSON.stringify(currRoute.optionalIds) || - JSON.stringify(prevRoute.routeSurfaces) !== JSON.stringify(currRoute.routeSurfaces) || - JSON.stringify(prevRoute.constraints) !== JSON.stringify(currRoute.constraints) + !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) ); } return true; @@ -147,7 +161,7 @@ function inferChangeFamily( prev.status !== curr.status || prev.type !== curr.type || prev.normativity !== curr.normativity || - JSON.stringify(prev.relationEdges) !== JSON.stringify(curr.relationEdges) + !relationEdgesEqual(prev.relationEdges, curr.relationEdges) ); }); @@ -164,7 +178,7 @@ function inferChangeFamily( return ( prev.part !== curr.part || prev.cluster !== curr.cluster || - JSON.stringify(prev.aliases) !== JSON.stringify(curr.aliases) + !arraysEqual(prev.aliases, curr.aliases) ); }); @@ -175,6 +189,14 @@ function inferChangeFamily( 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, @@ -187,7 +209,7 @@ function runRefreshSentinels( sentinels.push({ name: 'id_continuity', passed: missingIds.length === 0, - detail: missingIds.length > 0 ? truncateDetail(`Removed pattern IDs: ${missingIds.join(', ')}`) : undefined, + detail: missingIds.length > 0 ? formatDetail('Removed pattern IDs', missingIds) : undefined, }); const prevAliases = new Set( @@ -200,7 +222,7 @@ function runRefreshSentinels( sentinels.push({ name: 'alias_coverage', passed: droppedAliases.length === 0, - detail: droppedAliases.length > 0 ? truncateDetail(`Dropped aliases: ${droppedAliases.join(', ')}`) : undefined, + detail: droppedAliases.length > 0 ? formatDetail('Dropped aliases', droppedAliases) : undefined, }); const prevAnchorSet = new Set(previous.anchorIds); @@ -209,7 +231,7 @@ function runRefreshSentinels( sentinels.push({ name: 'anchor_continuity', passed: missingAnchors.length === 0, - detail: missingAnchors.length > 0 ? `Missing anchors: ${missingAnchors.slice(0, 10).join(', ')}` : undefined, + detail: missingAnchors.length > 0 ? formatDetail('Missing anchors', missingAnchors) : undefined, }); const prevRouteIds = new Set(Object.keys(previous.routes)); @@ -218,7 +240,7 @@ function runRefreshSentinels( sentinels.push({ name: 'route_closure', passed: missingRoutes.length === 0, - detail: missingRoutes.length > 0 ? truncateDetail(`Missing routes: ${missingRoutes.join(', ')}`) : undefined, + detail: missingRoutes.length > 0 ? formatDetail('Missing routes', missingRoutes) : undefined, }); const allRelationTargets = new Set(); @@ -233,20 +255,13 @@ function runRefreshSentinels( sentinels.push({ name: 'no_dangling_references', passed: danglingRefs.length === 0, - detail: danglingRefs.length > 0 ? `Dangling references: ${danglingRefs.slice(0, 10).join(', ')}` : undefined, + detail: danglingRefs.length > 0 ? formatDetail('Dangling references', danglingRefs) : undefined, }); return sentinels; } -const MAX_DETAIL_LENGTH = 500; -function truncateDetail(detail: string): string { - if (detail.length <= MAX_DETAIL_LENGTH) { - return detail; - } - return `${detail.slice(0, MAX_DETAIL_LENGTH)}… (truncated)`; -} - +/** Field-by-field equality for IndexingViewEntry — avoids JSON.stringify allocation. */ function entryEqual( a: IndexingViewEntry | undefined, b: IndexingViewEntry | undefined, @@ -254,9 +269,22 @@ function entryEqual( if (!a || !b) { return a === b; } - return JSON.stringify(a) === JSON.stringify(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, @@ -264,5 +292,40 @@ function routeEqual( if (!a || !b) { return a === b; } - return JSON.stringify(a) === JSON.stringify(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) + ); +} + +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/types.ts b/src/runtime/types.ts index 2487c5d..c45f8fb 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -188,9 +188,11 @@ export interface Snapshot { } export type ChangeFamily = + | 'no_change' | 'viewing_change' | 'slot_explicitness_change' | 'editioned_semantic_change' + | 'entity_addition' | 'described_entity_retargeting'; export interface IndexingViewEntry { @@ -234,7 +236,7 @@ export interface RefreshSentinel { } export interface RefreshClassification { - changeFamily: ChangeFamily | 'no_change'; + changeFamily: ChangeFamily; sentinels: RefreshSentinel[]; addedIds: string[]; removedIds: string[]; From 3665969f5bafe5a8f350ed5384582a4fbc4890e7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:02:06 +0000 Subject: [PATCH 6/8] fix: gate entity_addition on pure-addition diffs and fall back to snapshot-derived indexing view - inferChangeFamily() now returns 'entity_addition' only when changedIds is empty, preventing mixed addition+change diffs from being masked as benign. - runtime.ts falls back to buildIndexingView(existingSnapshot) when the indexing-view file is missing but a compatible snapshot exists, ensuring refreshClassification is not silently dropped during artifact upgrades. Co-Authored-By: Stanislau --- src/runtime/indexing-view.ts | 2 +- src/runtime/runtime.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts index 9fe2b69..ef3e226 100644 --- a/src/runtime/indexing-view.ts +++ b/src/runtime/indexing-view.ts @@ -119,7 +119,7 @@ function inferChangeFamily( if (removedIds.length > 0) { return 'described_entity_retargeting'; } - if (addedIds.length > 0) { + if (addedIds.length > 0 && changedIds.length === 0) { return 'entity_addition'; } diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index d57c67b..d441599 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -92,7 +92,8 @@ export class FpfRuntime { return audit; } - const previousIndexingView = await this.loadIndexingView(); + const previousIndexingView = await this.loadIndexingView() + ?? (compatibleSnapshot && existingSnapshot ? buildIndexingView(existingSnapshot) : undefined); const sourceText = await readFile(this.sourcePath, 'utf8'); const builtAt = new Date().toISOString(); const { snapshot } = compileFpfSource({ From 5fb3bd0fdbe4966a104b4583040dfa188679a736 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:10:20 +0000 Subject: [PATCH 7/8] fix: remove compatibility gate from snapshot-based indexing view fallback buildIndexingView() only consumes patternGraph/routeGraph/anchorMap/lexicon, none of which are checked by snapshotNeedsRebuild() (which gates on indexMap metadata fields). Using existingSnapshot directly ensures refreshClassification is populated even when upgrading from snapshots with stale indexMap metadata. Co-Authored-By: Stanislau --- src/runtime/runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index d441599..90c9720 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -93,7 +93,7 @@ export class FpfRuntime { } const previousIndexingView = await this.loadIndexingView() - ?? (compatibleSnapshot && existingSnapshot ? buildIndexingView(existingSnapshot) : undefined); + ?? (existingSnapshot ? buildIndexingView(existingSnapshot) : undefined); const sourceText = await readFile(this.sourcePath, 'utf8'); const builtAt = new Date().toISOString(); const { snapshot } = compileFpfSource({ From b0b5eb1e7679e3683846bbba0f177bea98272164 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:19:00 +0000 Subject: [PATCH 8/8] fix: include anchorIds, citations, nextOwners, reroutes in route indexing view Codex P2: buildIndexingView omitted route fields that affect runtime behavior (anchor selection, graph traversal, answer projection). Changes to these fields would not be detected by classifyChange, causing no_change when behavior actually changed. Co-Authored-By: Stanislau --- src/runtime/indexing-view.ts | 16 ++++++++++++++-- src/runtime/types.ts | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/runtime/indexing-view.ts b/src/runtime/indexing-view.ts index ef3e226..5099833 100644 --- a/src/runtime/indexing-view.ts +++ b/src/runtime/indexing-view.ts @@ -39,6 +39,10 @@ export function buildIndexingView(snapshot: Snapshot): IndexingView { 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(), }; } @@ -151,7 +155,11 @@ function inferChangeFamily( !arraysEqual(prevRoute.landingIds, currRoute.landingIds) || !arraysEqual(prevRoute.optionalIds, currRoute.optionalIds) || !arraysEqual(prevRoute.routeSurfaces, currRoute.routeSurfaces) || - !arraysEqual(prevRoute.constraints, currRoute.constraints) + !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; @@ -299,7 +307,11 @@ function routeEqual( arraysEqual(a.optionalIds, b.optionalIds) && arraysEqual(a.landingIds, b.landingIds) && arraysEqual(a.routeSurfaces, b.routeSurfaces) && - arraysEqual(a.constraints, b.constraints) + 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) ); } diff --git a/src/runtime/types.ts b/src/runtime/types.ts index c45f8fb..2c3cd14 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -217,6 +217,10 @@ export interface IndexingViewRoute { landingIds: string[]; routeSurfaces: string[]; constraints: string[]; + anchorIds: string[]; + citations: string[]; + nextOwners: string[]; + reroutes: string[]; } export interface IndexingView {