diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index cbbc894..2f561ca 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -17,6 +17,7 @@ import { applyValidatedProposal } from "./apply-proposal.js"; import { buildGraphUpdatePrompt } from "./prompt-builder.js"; import { parseGraphUpdateProposal } from "./update-proposal.js"; import { + explainUnknownSelection, selectActiveUnknownCandidate, validateGraphReferences, } from "./utils.js"; @@ -31,7 +32,12 @@ function toValidationErrors(error) { ); } -function buildDiagnostics({ analysis, graph, graphReferenceValidation }) { +function buildDiagnostics({ + analysis, + graph, + graphReferenceValidation, + unknownSelectionExplanation, +}) { return { promptVersion: analysis?.promptVersion ?? null, modelName: analysis?.modelName ?? null, @@ -43,6 +49,7 @@ function buildDiagnostics({ analysis, graph, graphReferenceValidation }) { compatibilityApplied: analysis?.compatibilityApplied ?? false, compatibilityChanges: analysis?.compatibilityChanges ?? [], compatibilityWarnings: analysis?.compatibilityWarnings ?? [], + unknownSelectionExplanation: unknownSelectionExplanation ?? null, }; } @@ -54,6 +61,7 @@ function buildUpdateDiagnostics({ graph, graphReferenceValidation, selectedQuestion, + unknownSelectionExplanation, }) { return { promptVersion: promptVersion ?? "v0.4", @@ -71,6 +79,7 @@ function buildUpdateDiagnostics({ selectedQuestion?.investigationStrategy ?? selectedQuestion?.strategy ?? null, + unknownSelectionExplanation: unknownSelectionExplanation ?? null, }; } @@ -131,6 +140,10 @@ export async function startCase(body) { situationGraphSchema.parse(situationGraph); const graphReferenceValidation = validateGraphReferences(situationGraph); + const unknownSelectionExplanation = explainUnknownSelection( + situationGraph, + [], + ); if (!graphReferenceValidation.valid) { return { success: false, @@ -139,6 +152,7 @@ export async function startCase(body) { analysis, graph: situationGraph, graphReferenceValidation, + unknownSelectionExplanation, }), validationErrors: graphReferenceValidation.errors, statusCode: 500, @@ -153,6 +167,7 @@ export async function startCase(body) { analysis, graph: situationGraph, graphReferenceValidation, + unknownSelectionExplanation, }), }; } @@ -290,6 +305,10 @@ async function updateCaseWithDependencies(body, dependencies = {}) { graph: situationGraph, graphReferenceValidation: graphReferenceValidation, selectedQuestion: null, + unknownSelectionExplanation: explainUnknownSelection( + situationGraph, + situationGraph.resolvedNodeIds || [], + ), }), }, statusCode: @@ -320,6 +339,10 @@ async function updateCaseWithDependencies(body, dependencies = {}) { graph: applicationResult.updatedSituationGraph, graphReferenceValidation: applicationResult.graphReferenceValidation, selectedQuestion: applicationResult.selectedQuestion, + unknownSelectionExplanation: explainUnknownSelection( + applicationResult.updatedSituationGraph, + applicationResult.updatedSituationGraph.resolvedNodeIds || [], + ), }), }; } @@ -336,6 +359,10 @@ async function updateCaseWithDependencies(body, dependencies = {}) { graph: situationGraph, graphReferenceValidation, selectedQuestion: null, + unknownSelectionExplanation: explainUnknownSelection( + situationGraph, + situationGraph.resolvedNodeIds || [], + ), }), }; } diff --git a/lib/graph/utils.js b/lib/graph/utils.js index c1fc6cc..cd2cebf 100644 --- a/lib/graph/utils.js +++ b/lib/graph/utils.js @@ -95,6 +95,127 @@ function classifyUnknownPriority(text) { return matches; } +function buildScoreContributions( + matches, + downstreamCount, + unresolvedParentUnknownCount, +) { + const contributions = [ + { + rule: "downstream_dependencies", + value: downstreamCount, + weight: 4, + delta: downstreamCount * 4, + }, + ]; + + if (matches.objective) { + contributions.push({ + rule: "objective_match", + value: true, + weight: 12, + delta: 12, + }); + } + if (matches.actor) { + contributions.push({ + rule: "actor_match", + value: true, + weight: 10, + delta: 10, + }); + } + if (matches.criteria) { + contributions.push({ + rule: "criteria_match", + value: true, + weight: 11, + delta: 11, + }); + } + if (matches.measure) { + contributions.push({ + rule: "measure_match", + value: true, + weight: 8, + delta: 8, + }); + } + if (matches.terminology) { + contributions.push({ + rule: "terminology_match", + value: true, + weight: 7, + delta: 7, + }); + } + if (matches.constraint) { + contributions.push({ + rule: "constraint_match", + value: true, + weight: 9, + delta: 9, + }); + } + if (matches.pricing) { + contributions.push({ + rule: "pricing_penalty", + value: true, + weight: -8, + delta: -8, + }); + } + if (matches.implementation) { + contributions.push({ + rule: "implementation_penalty", + value: true, + weight: -10, + delta: -10, + }); + } + if (matches.optimisation) { + contributions.push({ + rule: "optimisation_penalty", + value: true, + weight: -9, + delta: -9, + }); + } + if (matches.speculative) { + contributions.push({ + rule: "speculative_penalty", + value: true, + weight: -12, + delta: -12, + }); + } + + if ( + matches.pricing && + !matches.objective && + !matches.criteria && + !matches.actor + ) { + contributions.push({ + rule: "isolated_pricing_penalty", + value: true, + weight: -6, + delta: -6, + }); + } + + if (unresolvedParentUnknownCount > 0) { + contributions.push({ + rule: "unresolved_prerequisite_penalty", + value: unresolvedParentUnknownCount, + weight: -7, + delta: unresolvedParentUnknownCount * -7, + }); + } + + return contributions; +} + export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) { const text = collectNodeText(node); const matches = classifyUnknownPriority(text); @@ -105,30 +226,15 @@ export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) { resolvedNodeIds, ); - let score = downstreamCount * 4; - - if (matches.objective) score += 12; - if (matches.actor) score += 10; - if (matches.criteria) score += 11; - if (matches.measure) score += 8; - if (matches.terminology) score += 7; - if (matches.constraint) score += 9; - - if (matches.pricing) score -= 8; - if (matches.implementation) score -= 10; - if (matches.optimisation) score -= 9; - if (matches.speculative) score -= 12; - - if ( - matches.pricing && - !matches.objective && - !matches.criteria && - !matches.actor - ) { - score -= 6; - } - - score -= unresolvedParentUnknownCount * 7; + const contributions = buildScoreContributions( + matches, + downstreamCount, + unresolvedParentUnknownCount, + ); + const score = contributions.reduce( + (total, contribution) => total + contribution.delta, + 0, + ); return { nodeId: node.id, @@ -137,6 +243,7 @@ export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) { downstreamCount, unresolvedParentUnknownCount, matches, + contributions, }; } @@ -406,6 +513,102 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) { }; } +export function explainUnknownSelection(graph, resolvedNodeIds = []) { + const unresolved = graph.nodes.filter( + (n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id), + ); + + if (unresolved.length === 0) { + return { + selectedNodeId: null, + selectedNodeLabel: null, + resolvedNodeIds: [...resolvedNodeIds], + candidates: [], + competitors: [], + tieBreakOrder: [ + "score_desc", + "downstreamCount_desc", + "unresolvedParentUnknownCount_asc", + "label_asc", + ], + summary: { + candidateCount: 0, + }, + }; + } + + const candidates = unresolved.map((node) => ({ + nodeId: node.id, + label: node.label, + ...scoreUnknownCandidate(graph, node, resolvedNodeIds), + })); + + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.downstreamCount !== a.downstreamCount) { + return b.downstreamCount - a.downstreamCount; + } + if (a.unresolvedParentUnknownCount !== b.unresolvedParentUnknownCount) { + return a.unresolvedParentUnknownCount - b.unresolvedParentUnknownCount; + } + return a.label.localeCompare(b.label); + }); + + const selected = candidates[0]; + const competitors = candidates.slice(1).map((candidate) => ({ + nodeId: candidate.nodeId, + label: candidate.label, + score: candidate.score, + downstreamCount: candidate.downstreamCount, + unresolvedParentUnknownCount: candidate.unresolvedParentUnknownCount, + matches: candidate.matches, + contributions: candidate.contributions, + outrankedBy: { + scoreDelta: selected.score - candidate.score, + downstreamDelta: selected.downstreamCount - candidate.downstreamCount, + unresolvedPrerequisiteDelta: + candidate.unresolvedParentUnknownCount - + selected.unresolvedParentUnknownCount, + labelOrderWinner: + selected.score === candidate.score && + selected.downstreamCount === candidate.downstreamCount && + selected.unresolvedParentUnknownCount === + candidate.unresolvedParentUnknownCount + ? selected.label.localeCompare(candidate.label) <= 0 + ? selected.label + : candidate.label + : null, + }, + })); + + return { + selectedNodeId: selected.nodeId, + selectedNodeLabel: selected.label, + resolvedNodeIds: [...resolvedNodeIds], + tieBreakOrder: [ + "score_desc", + "downstreamCount_desc", + "unresolvedParentUnknownCount_asc", + "label_asc", + ], + candidates, + selected: { + nodeId: selected.nodeId, + label: selected.label, + score: selected.score, + downstreamCount: selected.downstreamCount, + unresolvedParentUnknownCount: selected.unresolvedParentUnknownCount, + matches: selected.matches, + contributions: selected.contributions, + }, + competitors, + summary: { + candidateCount: candidates.length, + selectedReason: `highest_score=${selected.score}; downstream=${selected.downstreamCount}; unresolved_prerequisites=${selected.unresolvedParentUnknownCount}`, + }, + }; +} + // ── Apply a graph update deterministically ── export function applyGraphUpdate(graph, update) { diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js index 3a61642..2c241fa 100644 --- a/tests/graph/orchestrator.test.js +++ b/tests/graph/orchestrator.test.js @@ -178,6 +178,9 @@ describe("lib/graph/orchestrator startCase", () => { expect(result.success).toBe(true); expect(result.situationGraph.activeUnknownNodeId).toBeTruthy(); + expect(result.diagnostics.unknownSelectionExplanation?.selectedNodeId).toBe( + result.situationGraph.activeUnknownNodeId, + ); }); it("returns structured failure when graph reference validation fails", async () => { @@ -262,6 +265,69 @@ describe("lib/graph/orchestrator startCase", () => { expect(result.diagnostics.compatibilityChanges).toHaveLength(1); }); + it("preserves selected question bytes while adding selection explanation diagnostics", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue( + makeProposal({ + addedNodes: [ + makeNode({ + id: "n-build-decision", + label: "Build Confidence Engine decision", + description: "Decision introduced by the answer.", + kind: "state", + status: "supported", + confidence: "medium", + }), + makeNode({ + id: "n-commercial-value", + label: "Commercial value definition", + description: + "Need a concrete definition because the decision depends on it.", + kind: "unknown", + status: "unknown", + confidence: "high", + }), + ], + addedEdges: [ + { + id: "e-build-commercial-value", + fromNodeId: "n-build-decision", + toNodeId: "n-commercial-value", + relationship: "depends_on", + confidence: "medium", + description: + "The decision depends on commercial value definition.", + }, + ], + selectedQuestion: { + nodeId: "n-commercial-value", + question: + "How should commercial value be defined for this decision?", + reason: "Consequential unresolved uncertainty remains.", + }, + }), + ), + }; + + const first = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + applyProposal: true, + }); + const second = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + applyProposal: true, + }); + + expect(first.selectedQuestion.question).toBe( + second.selectedQuestion.question, + ); + expect(first.selectedQuestion.reason).toBe(second.selectedQuestion.reason); + expect(first.diagnostics.unknownSelectionExplanation).toBeTruthy(); + }); + it("produces a validated update proposal for a valid request", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); const provider = {