fix(reasoning): preserve investigation ownership across selection and question rejection

This commit is contained in:
2026-08-17 17:58:45 +01:00
parent d908f3746d
commit 772ae495c6
7 changed files with 850 additions and 4429 deletions
+42
View File
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
import { determineGraphBackedQuestion } from "@/lib/graph/apply-proposal.js";
import { validateGraphReferences } from "@/lib/graph/utils.js";
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
import liveProductLaunchStartResponse from "@/tests/fixtures/live-product-launch-start-response.json";
const mockAnalyseScenario = vi.fn();
const MOCK_CONFIG = { OLLAMA_MODEL: "configured" };
@@ -588,6 +590,46 @@ describe("lib/graph/orchestrator startCase", () => {
);
});
it("retains ownership when the strongest target's formulated question is rejected", () => {
const situationGraph = structuredClone(
liveProductLaunchStartResponse.situationGraph,
);
const result = determineGraphBackedQuestion({ situationGraph });
expect(result.success).toBe(true);
expect(result.deterministicSelection?.nodeId ?? null).toBe("ntpt9ki");
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe("ntpt9ki");
expect(result.selectedQuestion).toBeNull();
expect(result.noQuestionReason).toBe(
"The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex.",
);
expect(result.selectedChildUnknown).toBe("ntpt9ki");
expect(result.selectedUnknownAfter).toBe("ntpt9ki");
expect(result.selectedQuestion?.nodeId ?? null).toBe(null);
});
it("replays the captured live product-launch start graph through deterministic graph-backed question selection", () => {
const situationGraph = liveProductLaunchStartResponse.situationGraph;
const result = determineGraphBackedQuestion({ situationGraph });
expect(result.success).toBe(true);
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe("ntpt9ki");
expect(result.deterministicSelection?.nodeId ?? null).toBe("ntpt9ki");
expect(result.answerabilityAssessment?.independentlyAnswerable).toBe(false);
expect(result.answerabilityAssessment?.decompositionRequired).toBe(true);
expect(result.decompositionAttempted).toBe(true);
expect(result.decompositionPerformed).toBe(false);
expect(result.selectedChildUnknown).toBe("ntpt9ki");
expect(result.selectedUnknownAfter).toBe("ntpt9ki");
expect(result.selectedQuestion).toBeNull();
expect(result.noQuestionReason).toBe(
"The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex.",
);
expect(result.deterministicSelection?.nodeId).not.toBe("nxmeiab");
});
it("includes compatibility diagnostics when provided by analysis", async () => {
mockAnalyseScenario.mockResolvedValue(
makeAnalysisResult({
+131 -11
View File
@@ -621,7 +621,7 @@ describe("selectActiveUnknownCandidate", () => {
expect(parentScore.score).toBeGreaterThan(childScore.score);
});
it("returns ambiguous for a complete unresolved tie instead of label-based winner", () => {
it("returns a deterministic winner for a complete unresolved tie", () => {
const unknownA = makeNode({
id: "tie-a",
label: "Magnitude and nature of cash outflows",
@@ -651,16 +651,12 @@ describe("selectActiveUnknownCandidate", () => {
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result).toMatchObject({
selectedNode: null,
status: "ambiguous",
tieType: "complete_unresolved_tie",
tiedCandidateIds: ["tie-a", "tie-b"],
});
expect(result.nodeId).toBeUndefined();
expect(result.status).toBe("selected");
expect(result.tieType).toBe("complete_unresolved_tie");
expect(result.nodeId).toBeTruthy();
});
it("alphabetical renaming does not resolve a complete tie", () => {
it("stable ordering resolves a complete tie deterministically", () => {
const unknownA = makeNode({
id: "tie-a",
label: "Unknown B",
@@ -683,8 +679,132 @@ describe("selectActiveUnknownCandidate", () => {
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.status).toBe("ambiguous");
expect(result.tiedCandidateIds.sort()).toEqual(["tie-a", "tie-b"]);
expect(result.status).toBe("selected");
expect(result.nodeId).toBeTruthy();
});
it("preserves the active candidate when it remains eligible and substantively tied", () => {
const unknownA = makeNode({
id: "tie-a",
label: "Unknown B",
description: "Unknown factor one.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const unknownB = makeNode({
id: "tie-b",
label: "Unknown A",
description: "Unknown factor two.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const graph = makeGraph({
centralStatement: "Two conflicting signals remain unresolved.",
nodes: [unknownA, unknownB],
edges: [],
activeUnknownNodeId: "tie-a",
resolvedNodeIds: [],
currentSummary: "Tie case",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.status).toBe("selected");
expect(result.nodeId).toBe("tie-a");
});
it("transfers ownership when the active candidate substantively loses on score", () => {
const loser = makeNode({
id: "active-loser",
label: "Unknown branch",
description: "Speculative future branch.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const winner = makeNode({
id: "clear-winner",
label: "Customer value definition",
description: "Need customer value definition.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const graph = makeGraph({
centralStatement: "Decision context",
nodes: [loser, winner],
edges: [],
activeUnknownNodeId: loser.id,
resolvedNodeIds: [],
currentSummary: "Score loss case",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.status).toBe("selected");
expect(result.nodeId).toBe(winner.id);
});
it("transfers ownership when the active candidate is resolved or ineligible", () => {
const resolvedActive = makeNode({
id: "resolved-active",
label: "Resolved unknown",
description: "Resolved unknown.",
kind: "unknown",
status: "resolved",
confidence: "high",
});
const remaining = makeNode({
id: "remaining-unknown",
label: "Customer value definition",
description: "Need customer value definition.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const graph = makeGraph({
centralStatement: "Decision context",
nodes: [resolvedActive, remaining],
edges: [],
activeUnknownNodeId: resolvedActive.id,
resolvedNodeIds: [resolvedActive.id],
currentSummary: "Resolved active case",
});
const result = selectActiveUnknownCandidate(graph, graph.resolvedNodeIds);
expect(result.status).toBe("selected");
expect(result.nodeId).toBe(remaining.id);
});
it("keeps existing deterministic fallback when there is no active candidate", () => {
const unknownA = makeNode({
id: "fallback-a",
label: "Unknown B",
description: "Unknown factor one.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const unknownB = makeNode({
id: "fallback-b",
label: "Unknown A",
description: "Unknown factor two.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const graph = makeGraph({
centralStatement: "Two conflicting signals remain unresolved.",
nodes: [unknownA, unknownB],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Tie case",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.status).toBe("selected");
expect(result.nodeId).toBeTruthy();
});
});