feat: explain deterministic unknown selection
This commit is contained in:
@@ -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 || [],
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+227
-24
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user