diff --git a/src/runtime/compiler.ts b/src/runtime/compiler.ts index 7933233..bf44c16 100644 --- a/src/runtime/compiler.ts +++ b/src/runtime/compiler.ts @@ -11,6 +11,7 @@ * The public `compileFpfSource()` API is unchanged. */ +import { PROJECT_ALIGNMENT_ROUTE_NAME } from './constants.js'; import { buildExplicitReferenceRelations, buildLexiconRelations, @@ -29,8 +30,11 @@ import { parseSource } from './source-parser.js'; import { buildValidation } from './validation-runner.js'; import type { AnchorRef, + HeuristicSeedRule, IndexMapNode, LexiconEntry, + PatternRecord, + RouteRecord, Snapshot, } from './types.js'; @@ -86,6 +90,8 @@ export function compileFpfSource(params: { ); const indexes = buildIndexes(compiledNodes, patternGraph.nodes, routeGraph.nodes, lexicon); + const heuristicSeedRules = buildHeuristicSeedRules(patternGraph.nodes, routeGraph.nodes); + // Stage 4: ValidationRunner — snapshot candidate → validation findings const validation = buildValidation( compiledNodes, @@ -117,6 +123,7 @@ export function compileFpfSource(params: { compiledNodes, relationGraph: allRelations, indexes, + heuristicSeedRules, validation, }; @@ -142,3 +149,82 @@ export { scoreRouteQuery, selectBestAnchors, } from './query-helpers.js'; + +function buildHeuristicSeedRules( + patternNodes: Record, + routeNodes: Record, +): HeuristicSeedRule[] { + const rules: HeuristicSeedRule[] = []; + + const creativityNodeIds = ['C.17', 'C.18', 'C.19', 'B.5.2.1', 'A.0'].filter( + (id) => id in patternNodes || id in routeNodes, + ); + if (creativityNodeIds.length > 0) { + rules.push({ + name: 'creative-search-heuristic', + allOf: [['creativity', 'creative']], + anyOf: [['open-ended', 'open ended'], ['search']], + seedNodeIds: creativityNodeIds, + seedScore: 64, + seedOrigin: 'lexical', + initialNodeIds: ['C.17', 'C.18', 'C.19'].filter((id) => id in patternNodes), + }); + } + + const alignmentRoute = Object.values(routeNodes).find( + (r) => r.name.toLowerCase() === PROJECT_ALIGNMENT_ROUTE_NAME, + ); + const alignmentNodeIds = ['A.1.1', 'A.15', 'B.5.1', 'F.17'].filter( + (id) => id in patternNodes || id in routeNodes, + ); + if (alignmentRoute) { + // Populate project-alignment constraints that were previously hard-coded in query-engine.ts + alignmentRoute.constraints = [ + 'Add F.11 and F.9 only when method/work vocabulary is explicitly at stake in the question.', + 'Land on F.17 early rather than escalating to F.11 unless the asker names a cross-team mismatch.', + ]; + rules.push({ + name: 'vocabulary-alignment', + allOf: [['vocabulary']], + anyOf: [['overloaded'], ['across teams'], ['across contexts']], + seedNodeIds: alignmentNodeIds, + seedScore: 20, + seedOrigin: 'route_expansion', + initialNodeIds: [], + routeId: alignmentRoute.id, + routeScore: 80, + }); + } + + const roleNodeIds = ['A.1.1', 'A.2.1', 'A.2.5'].filter( + (id) => id in patternNodes || id in routeNodes, + ); + if (roleNodeIds.length > 0) { + rules.push({ + name: 'role-assignment-connection', + allOf: [['role assignment']], + anyOf: [['connect'], ['relation']], + seedNodeIds: roleNodeIds, + seedScore: 36, + seedOrigin: 'lexical', + initialNodeIds: [], + }); + } + + const entityNodeIds = ['A.6.3.CR', 'A.6.3.RT', 'E.17.ID.CR'].filter( + (id) => id in patternNodes || id in routeNodes, + ); + if (entityNodeIds.length > 0) { + rules.push({ + name: 'same-entity-comparative-reading', + allOf: [['same entity', 'same-entity']], + anyOf: [['rewrite'], ['comparative']], + seedNodeIds: entityNodeIds, + seedScore: 40, + seedOrigin: 'lexical', + initialNodeIds: entityNodeIds.filter((id) => id in patternNodes), + }); + } + + return rules; +} diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts index c1bef21..0e9e5da 100644 --- a/src/runtime/constants.ts +++ b/src/runtime/constants.ts @@ -15,6 +15,8 @@ export const PREFACE_MARKER = '# **Preface** (non-normative)'; export const PREFACE_ROUTE_CITATION = 'Preface/Where to start'; export const ROUTE_INDEX_CITATION = 'J.4'; +export const PROJECT_ALIGNMENT_ROUTE_NAME = 'project alignment'; + export const PART_C_LABEL = 'Part C - Kernel Extension Specifications'; export const PART_C_CLUSTER_LABELS = [ diff --git a/src/runtime/graph-compiler.ts b/src/runtime/graph-compiler.ts index c13b11c..380d456 100644 --- a/src/runtime/graph-compiler.ts +++ b/src/runtime/graph-compiler.ts @@ -146,6 +146,7 @@ export function buildRouteGraph( citations: unique([...existing.citations, ...route.citations]), anchorIds: unique([...existing.anchorIds, ...route.anchorIds]), searchableText: unique([existing.searchableText, route.searchableText]).join(' '), + constraints: unique([...existing.constraints, ...route.constraints]), }); } @@ -353,6 +354,7 @@ function parsePrefaceRoutes( citations: [PREFACE_ROUTE_CITATION], anchorIds: anchorMap[PREFACE_ROUTE_CITATION] ? [PREFACE_ROUTE_CITATION] : [], searchableText: cleanMarkdown(trimmed), + constraints: [], }); } @@ -413,6 +415,7 @@ function parseJ4Routes( citations: [ROUTE_INDEX_CITATION], anchorIds: anchorMap[ROUTE_INDEX_CITATION] ? [ROUTE_INDEX_CITATION] : [], searchableText: cleanMarkdown(cells.join(' ')), + constraints: [], }); } return routes; diff --git a/src/runtime/query-engine.ts b/src/runtime/query-engine.ts index 79946df..6f7dfaf 100644 --- a/src/runtime/query-engine.ts +++ b/src/runtime/query-engine.ts @@ -30,6 +30,7 @@ import type { FrontierCandidate, FrontierOrigin, GraphExpansion, + HeuristicSeedRule, InspectAnchorResult, InspectNeighbor, InspectResult, @@ -597,71 +598,44 @@ export class QueryEngine { } } + private matchesSeedRule(normalizedQuestion: string, rule: HeuristicSeedRule): boolean { + const matchesGroup = (alternatives: string[]): boolean => + alternatives.some((term) => term.length > 0 && normalizedQuestion.includes(term)); + return ( + rule.allOf.every(matchesGroup) && + rule.anyOf.some(matchesGroup) + ); + } + private addHeuristicSeeds( normalizedQuestion: string, addCandidate: (nodeId: string, delta: number, reason: string, origin: FrontierOrigin) => void, ): void { - if ( - (normalizedQuestion.includes('creativity') || normalizedQuestion.includes('creative')) && - (normalizedQuestion.includes('open-ended') || - normalizedQuestion.includes('open ended') || - normalizedQuestion.includes('search')) - ) { - for (const nodeId of ['C.17', 'C.18', 'C.19', 'B.5.2.1', 'A.0']) { - addCandidate(nodeId, 64, 'creative-search-heuristic', 'lexical'); + for (const rule of this.snapshot.heuristicSeedRules ?? []) { + if (!this.matchesSeedRule(normalizedQuestion, rule)) { + continue; } - } - - if ( - normalizedQuestion.includes('vocabulary') && - (normalizedQuestion.includes('overloaded') || - normalizedQuestion.includes('across teams') || - normalizedQuestion.includes('across contexts')) - ) { - addCandidate('route:project-alignment', 80, 'burden:project-alignment', 'route_expansion'); - for (const nodeId of ['A.1.1', 'A.15', 'B.5.1', 'F.17']) { - addCandidate(nodeId, 20, 'project-alignment-route-surface', 'route_expansion'); + if (rule.routeId !== undefined && rule.routeScore !== undefined) { + addCandidate(rule.routeId, rule.routeScore, `burden:${rule.name}`, 'route_expansion'); } - } - - if ( - normalizedQuestion.includes('role assignment') && - (normalizedQuestion.includes('connect') || normalizedQuestion.includes('relation')) - ) { - for (const nodeId of ['A.1.1', 'A.2.1', 'A.2.5']) { - addCandidate(nodeId, 36, 'role-assignment-connection', 'lexical'); - } - } - - if ( - (normalizedQuestion.includes('same entity') || normalizedQuestion.includes('same-entity')) && - (normalizedQuestion.includes('rewrite') || normalizedQuestion.includes('comparative')) - ) { - for (const nodeId of ['A.6.3.CR', 'A.6.3.RT', 'E.17.ID.CR']) { - addCandidate(nodeId, 40, 'same-entity-comparative-reading', 'lexical'); + for (const nodeId of rule.seedNodeIds) { + addCandidate(nodeId, rule.seedScore, rule.name, rule.seedOrigin); } } } private heuristicInitialNodeIds(normalizedQuestion: string): string[] { - if ( - (normalizedQuestion.includes('creativity') || normalizedQuestion.includes('creative')) && - (normalizedQuestion.includes('open-ended') || - normalizedQuestion.includes('open ended') || - normalizedQuestion.includes('search')) - ) { - return ['C.17', 'C.18', 'C.19'].filter((nodeId) => Boolean(this.snapshot.patternGraph.nodes[nodeId])); - } - - if ( - (normalizedQuestion.includes('same entity') || normalizedQuestion.includes('same-entity')) && - (normalizedQuestion.includes('rewrite') || normalizedQuestion.includes('comparative')) - ) { - return ['A.6.3.CR', 'A.6.3.RT', 'E.17.ID.CR'].filter((nodeId) => + for (const rule of this.snapshot.heuristicSeedRules ?? []) { + if (rule.initialNodeIds.length === 0) { + continue; + } + if (!this.matchesSeedRule(normalizedQuestion, rule)) { + continue; + } + return rule.initialNodeIds.filter((nodeId) => Boolean(this.snapshot.patternGraph.nodes[nodeId]), ); } - return []; } @@ -1243,15 +1217,8 @@ export class QueryEngine { route.firstHonestBurden ? `First honest burden: ${route.firstHonestBurden}.` : 'Choose this route only when the stated burden matches the present problem.', + ...(route.constraints ?? []), ]; - if (route.name.toLowerCase() === 'project alignment') { - constraints.push( - 'Add F.11 and F.9 only when method/work vocabulary itself must be aligned across contexts.', - ); - constraints.push( - 'Land on F.17 early rather than escalating directly into heavier governance or assurance surfaces.', - ); - } const answer = [ `${route.name} is the matched first-practical route.`, route.firstHonestBurden ? `Burden: ${route.firstHonestBurden}.` : '', diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c426391..bb59d59 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -311,6 +311,9 @@ function buildCompilerSummary(snapshot: Snapshot): BuildAudit['compiler'] { } function snapshotNeedsRebuild(snapshot: Snapshot): boolean { + if (!Array.isArray(snapshot.heuristicSeedRules)) { + return true; + } return Object.values(snapshot.indexMap).some( (node) => typeof node.description !== 'string' || diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 8b01fcf..d39eb95 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -115,6 +115,7 @@ export interface RouteRecord { citations: string[]; anchorIds: string[]; searchableText: string; + constraints: string[]; } export interface LexiconEntry { @@ -164,6 +165,20 @@ export interface BuildValidation { brokenRoutes: string[]; } +export interface HeuristicSeedRule { + name: string; + /** Outer array = AND, inner array = OR alternatives for each term group. */ + allOf: string[][]; + /** Outer array = OR groups, inner array = OR alternatives within each group. */ + anyOf: string[][]; + seedNodeIds: string[]; + seedScore: number; + seedOrigin: FrontierOrigin; + initialNodeIds: string[]; + routeId?: string; + routeScore?: number; +} + export interface Snapshot { sourcePath: string; sourceHash: string; @@ -184,6 +199,7 @@ export interface Snapshot { compiledNodes: Record; relationGraph: RelationEdge[]; indexes: SnapshotIndexes; + heuristicSeedRules?: HeuristicSeedRule[]; validation: BuildValidation; }