fix: handle unjustified unknown selection ties
This commit is contained in:
@@ -183,6 +183,87 @@ describe("lib/graph/orchestrator startCase", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an ambiguous tie result instead of choosing by label order", async () => {
|
||||
mockAnalyseScenario.mockResolvedValue(
|
||||
makeAnalysisResult({
|
||||
reconstruction: {
|
||||
summary: "Revenue up while cash falls",
|
||||
actors: [],
|
||||
systemsOrObjects: [],
|
||||
expectedStates: [],
|
||||
observedStates: [
|
||||
{
|
||||
id: "obs-1",
|
||||
label: "Revenue increased by 18%.",
|
||||
description: "Revenue increased by 18%.",
|
||||
confidence: "high",
|
||||
},
|
||||
{
|
||||
id: "obs-2",
|
||||
label: "Cash in the bank decreased over the same period.",
|
||||
description: "Cash in the bank decreased over the same period.",
|
||||
confidence: "high",
|
||||
},
|
||||
],
|
||||
differences: [],
|
||||
knownTransitions: [],
|
||||
unexplainedTransitions: [],
|
||||
contradictions: [
|
||||
{
|
||||
id: "c-1",
|
||||
label:
|
||||
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
||||
description:
|
||||
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
||||
confidence: "medium",
|
||||
},
|
||||
],
|
||||
importantUnknowns: [
|
||||
{
|
||||
id: "unk-1",
|
||||
label:
|
||||
"Whether revenue recognition timing differs from cash collection timing.",
|
||||
description:
|
||||
"Whether revenue recognition timing differs from cash collection timing.",
|
||||
confidence: "high",
|
||||
},
|
||||
{
|
||||
id: "unk-2",
|
||||
label:
|
||||
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||
description:
|
||||
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||
confidence: "high",
|
||||
},
|
||||
],
|
||||
plausibleInterpretations: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
||||
|
||||
const result = await startCase({
|
||||
scenario:
|
||||
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.situationGraph.activeUnknownNodeId).toBeNull();
|
||||
expect(result.selectedQuestion).toMatchObject({
|
||||
id: "q_tie_resolution",
|
||||
selectionStatus: "ambiguous",
|
||||
question:
|
||||
"What changed during the period that could explain why Revenue increased by 18%, but cash in the bank fell over the same period?",
|
||||
tiedCandidateIds: expect.arrayContaining([expect.any(String)]),
|
||||
});
|
||||
expect(result.diagnostics.unknownSelectionExplanation).toMatchObject({
|
||||
status: "ambiguous",
|
||||
tieType: "complete_unresolved_tie",
|
||||
selectedNodeId: null,
|
||||
alphabeticalUsedAsReasoning: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns structured failure when graph reference validation fails", async () => {
|
||||
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
||||
const utils = await import("@/lib/graph/utils.js");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formulateQuestion,
|
||||
formulateTieResolutionQuestion,
|
||||
selectInvestigationStrategy,
|
||||
} from "@/lib/graph/question-formulator.js";
|
||||
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||
@@ -178,16 +179,24 @@ describe("formulateQuestion", () => {
|
||||
});
|
||||
|
||||
it("the same unknown can produce different questions when paired with different strategies", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-same-unknown",
|
||||
const thresholdUnknown = makeNode({
|
||||
id: "n-threshold-unknown",
|
||||
label: "Value threshold",
|
||||
description: "Need to resolve the value threshold.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const definitionUnknown = makeNode({
|
||||
id: "n-definition-unknown",
|
||||
label: "Value term",
|
||||
description: "Need to resolve what value term refers to in this context.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const decisionGraph = makeGraphFor(unknown, {
|
||||
const decisionGraph = makeGraphFor(thresholdUnknown, {
|
||||
centralStatement: "We are deciding whether to launch this product.",
|
||||
nodes: [
|
||||
makeNode({
|
||||
@@ -197,35 +206,35 @@ describe("formulateQuestion", () => {
|
||||
kind: "state",
|
||||
status: "known",
|
||||
confidence: "medium",
|
||||
childIds: [unknown.id],
|
||||
childIds: [thresholdUnknown.id],
|
||||
value: "Deciding whether to launch the product",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const definitionGraph = makeGraphFor(unknown, {
|
||||
const definitionGraph = makeGraphFor(definitionUnknown, {
|
||||
centralStatement:
|
||||
"The team uses the term value threshold inconsistently.",
|
||||
nodes: [
|
||||
makeNode({
|
||||
id: "n-definition",
|
||||
label: "Definition disagreement",
|
||||
label: "Definition disagreement about value threshold",
|
||||
description:
|
||||
"Need a definition of value threshold before comparing options.",
|
||||
"Need a definition of value threshold because the term is used inconsistently before comparing options.",
|
||||
kind: "state",
|
||||
status: "known",
|
||||
confidence: "medium",
|
||||
childIds: [unknown.id],
|
||||
childIds: [definitionUnknown.id],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const decisionResult = formulateQuestion({
|
||||
node: unknown,
|
||||
node: thresholdUnknown,
|
||||
graph: decisionGraph,
|
||||
});
|
||||
const definitionResult = formulateQuestion({
|
||||
node: unknown,
|
||||
node: definitionUnknown,
|
||||
graph: definitionGraph,
|
||||
});
|
||||
|
||||
@@ -296,4 +305,95 @@ describe("formulateQuestion", () => {
|
||||
"What would resolve uncertainty regarding",
|
||||
);
|
||||
});
|
||||
|
||||
it("ambiguous contradiction produces a broad distinguishing question without accounting jargon", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-cause-a",
|
||||
label: "Cash outflow cause",
|
||||
description: "Unclear explanation for the contradiction.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const contradiction = makeNode({
|
||||
id: "n-contradiction",
|
||||
label: "Divergent movement between revenue and cash",
|
||||
description: "Two signals moved in opposite directions.",
|
||||
kind: "relationship",
|
||||
status: "supported",
|
||||
confidence: "medium",
|
||||
});
|
||||
const graph = makeGraphFor(unknown, {
|
||||
centralStatement:
|
||||
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||
nodes: [contradiction],
|
||||
});
|
||||
|
||||
const result = formulateTieResolutionQuestion({ graph });
|
||||
|
||||
expect(result.question).toBe(
|
||||
"What changed during the period that could explain why Revenue increased by 18%, but cash in the bank fell over the same period?",
|
||||
);
|
||||
expect(result.question.toLowerCase()).not.toMatch(
|
||||
/accounts receivable|capex|debt repayments|working capital/,
|
||||
);
|
||||
});
|
||||
|
||||
it("definition is selected only for genuine definition unknowns", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-definition-only",
|
||||
label: "Definition of success criteria",
|
||||
description: "The term is used inconsistently and needs a definition.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: unknown,
|
||||
graph: makeGraphFor(unknown),
|
||||
});
|
||||
expect(result.strategy).toBe("definition");
|
||||
});
|
||||
|
||||
it("an unknown about possible causes does not become a definition question", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-causes",
|
||||
label: "Possible causes of the divergence",
|
||||
description: "Several causes may explain the divergence.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: unknown,
|
||||
graph: makeGraphFor(unknown),
|
||||
});
|
||||
expect(result.strategy).toBeNull();
|
||||
expect(result.question).toBe(
|
||||
"What would clarify possible causes of the divergence in this situation?",
|
||||
);
|
||||
});
|
||||
|
||||
it("malformed punctuation is rejected", () => {
|
||||
const unknown = makeNode({
|
||||
id: "n-punct",
|
||||
label: "Magnitude and nature of cash outflows (operating expenses).",
|
||||
description:
|
||||
"Magnitude and nature of cash outflows (operating expenses).",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: unknown,
|
||||
graph: makeGraphFor(unknown),
|
||||
});
|
||||
expect(result.question).not.toContain("). is true?");
|
||||
expect(result.question).toBe(
|
||||
"What would clarify magnitude and nature of cash outflows (operating expenses) in this situation?",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formulateQuestion } from "@/lib/graph/question-formulator.js";
|
||||
import {
|
||||
formulateQuestion,
|
||||
formulateTieResolutionQuestion,
|
||||
} from "@/lib/graph/question-formulator.js";
|
||||
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||
import { explainUnknownSelection } from "@/lib/graph/utils.js";
|
||||
import {
|
||||
explainUnknownSelection,
|
||||
selectActiveUnknownCandidate,
|
||||
} from "@/lib/graph/utils.js";
|
||||
|
||||
function buildLiveShapedGraph() {
|
||||
const summary = makeNode({
|
||||
@@ -184,37 +190,57 @@ function neutraliseUnknownWording(graph) {
|
||||
}
|
||||
|
||||
describe("selection influence diagnostic", () => {
|
||||
it("records ordering changes for live-shaped, structure-only, and wording-neutralised fixtures", () => {
|
||||
it("records ambiguous ordering changes for live-shaped, structure-only, and wording-neutralised fixtures", () => {
|
||||
const liveGraph = buildLiveShapedGraph();
|
||||
const liveExplanation = explainUnknownSelection(liveGraph, []);
|
||||
const liveWinner = liveGraph.nodes.find(
|
||||
(node) => node.id === liveExplanation.selectedNodeId,
|
||||
);
|
||||
const liveQuestion = formulateQuestion({
|
||||
node: liveWinner,
|
||||
graph: liveGraph,
|
||||
});
|
||||
const liveSelection = selectActiveUnknownCandidate(liveGraph, []);
|
||||
const tieQuestion = formulateTieResolutionQuestion({ graph: liveGraph });
|
||||
|
||||
const noLinksExplanation = explainUnknownSelection(
|
||||
removeDependencyLinks(liveGraph),
|
||||
[],
|
||||
);
|
||||
const noLinksSelection = selectActiveUnknownCandidate(
|
||||
removeDependencyLinks(liveGraph),
|
||||
[],
|
||||
);
|
||||
const neutralWordingExplanation = explainUnknownSelection(
|
||||
neutraliseUnknownWording(liveGraph),
|
||||
[],
|
||||
);
|
||||
const neutralSelection = selectActiveUnknownCandidate(
|
||||
neutraliseUnknownWording(liveGraph),
|
||||
[],
|
||||
);
|
||||
|
||||
const fallbackQuestion = formulateQuestion({
|
||||
node: liveGraph.nodes.find((node) => node.id === "nqdzobz"),
|
||||
graph: liveGraph,
|
||||
});
|
||||
|
||||
const diagnosticRecord = {
|
||||
liveStatus: liveExplanation.status,
|
||||
liveShapedCandidateOrdering: orderCandidates(liveExplanation),
|
||||
liveTiedCandidateIds: liveExplanation.tiedCandidateIds,
|
||||
noLinksCandidateOrdering: orderCandidates(noLinksExplanation),
|
||||
noLinksStatus: noLinksExplanation.status,
|
||||
neutralWordingCandidateOrdering: orderCandidates(
|
||||
neutralWordingExplanation,
|
||||
),
|
||||
selectedExplanationContributions:
|
||||
liveExplanation.selected?.contributions ?? [],
|
||||
selectedInvestigationStrategy: liveQuestion.strategy,
|
||||
neutralStatus: neutralWordingExplanation.status,
|
||||
selectedExplanationContributions: liveExplanation.selected?.contributions,
|
||||
tieQuestion: tieQuestion.question,
|
||||
liveSelection,
|
||||
noLinksSelection,
|
||||
neutralSelection,
|
||||
fallbackQuestion,
|
||||
};
|
||||
|
||||
expect(diagnosticRecord.liveStatus).toBe("ambiguous");
|
||||
expect(diagnosticRecord.liveTiedCandidateIds).toEqual([
|
||||
"nqdzobz",
|
||||
"niewza",
|
||||
]);
|
||||
expect(diagnosticRecord.liveShapedCandidateOrdering).toEqual([
|
||||
{
|
||||
nodeId: "nqdzobz",
|
||||
@@ -233,9 +259,17 @@ describe("selection influence diagnostic", () => {
|
||||
unresolvedParentUnknownCount: 0,
|
||||
},
|
||||
]);
|
||||
expect(diagnosticRecord.liveSelection).toMatchObject({
|
||||
selectedNode: null,
|
||||
status: "ambiguous",
|
||||
tieType: "complete_unresolved_tie",
|
||||
tiedCandidateIds: ["nqdzobz", "niewza"],
|
||||
});
|
||||
expect(diagnosticRecord.noLinksCandidateOrdering).toEqual(
|
||||
diagnosticRecord.liveShapedCandidateOrdering,
|
||||
);
|
||||
expect(diagnosticRecord.noLinksStatus).toBe("ambiguous");
|
||||
expect(diagnosticRecord.noLinksSelection.status).toBe("ambiguous");
|
||||
expect(diagnosticRecord.neutralWordingCandidateOrdering).toEqual([
|
||||
{
|
||||
nodeId: "niewza",
|
||||
@@ -252,14 +286,18 @@ describe("selection influence diagnostic", () => {
|
||||
unresolvedParentUnknownCount: 0,
|
||||
},
|
||||
]);
|
||||
expect(diagnosticRecord.selectedExplanationContributions).toEqual([
|
||||
{
|
||||
rule: "downstream_dependencies",
|
||||
value: 0,
|
||||
weight: 4,
|
||||
delta: 0,
|
||||
},
|
||||
]);
|
||||
expect(diagnosticRecord.selectedInvestigationStrategy).toBe("definition");
|
||||
expect(diagnosticRecord.neutralStatus).toBe("ambiguous");
|
||||
expect(diagnosticRecord.neutralSelection.status).toBe("ambiguous");
|
||||
expect(diagnosticRecord.selectedExplanationContributions).toBeUndefined();
|
||||
expect(diagnosticRecord.tieQuestion).toBe(
|
||||
"What changed during the period that could explain why Revenue increased by 18%, but cash in the bank fell over the same period?",
|
||||
);
|
||||
expect(diagnosticRecord.tieQuestion.toLowerCase()).not.toMatch(
|
||||
/accounts receivable|capex|debt repayments|working capital/,
|
||||
);
|
||||
expect(diagnosticRecord.fallbackQuestion.strategy).toBeNull();
|
||||
expect(diagnosticRecord.fallbackQuestion.question).toBe(
|
||||
"What would clarify magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts) in this situation?",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,6 +456,7 @@ describe("selectActiveUnknownCandidate", () => {
|
||||
|
||||
const result = selectActiveUnknownCandidate(graph, []);
|
||||
expect(result.nodeId).toBe("unknown-a"); // Has more dependents (score 2 vs 0)
|
||||
expect(result.status).toBe("selected");
|
||||
});
|
||||
|
||||
it("returns one candidate (not array)", () => {
|
||||
@@ -619,6 +620,72 @@ describe("selectActiveUnknownCandidate", () => {
|
||||
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
||||
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
||||
});
|
||||
|
||||
it("returns ambiguous for a complete unresolved tie instead of label-based winner", () => {
|
||||
const unknownA = makeNode({
|
||||
id: "tie-a",
|
||||
label: "Magnitude and nature of cash outflows",
|
||||
description: "Magnitude and nature of cash outflows.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const unknownB = makeNode({
|
||||
id: "tie-b",
|
||||
label:
|
||||
"Whether revenue recognition timing differs from cash collection timing",
|
||||
description:
|
||||
"Whether revenue recognition timing differs from cash collection timing.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
const graph = makeGraph({
|
||||
centralStatement:
|
||||
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||
nodes: [unknownA, unknownB],
|
||||
edges: [],
|
||||
activeUnknownNodeId: null,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Tie case",
|
||||
});
|
||||
|
||||
const result = selectActiveUnknownCandidate(graph, []);
|
||||
expect(result).toMatchObject({
|
||||
selectedNode: null,
|
||||
status: "ambiguous",
|
||||
tieType: "complete_unresolved_tie",
|
||||
tiedCandidateIds: ["tie-a", "tie-b"],
|
||||
});
|
||||
expect(result.nodeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("alphabetical renaming does not resolve a complete tie", () => {
|
||||
const unknownA = makeNode({
|
||||
id: "tie-a",
|
||||
label: "Unknown B",
|
||||
description: "Unknown factor one.",
|
||||
kind: "unknown",
|
||||
});
|
||||
const unknownB = makeNode({
|
||||
id: "tie-b",
|
||||
label: "Unknown A",
|
||||
description: "Unknown factor two.",
|
||||
kind: "unknown",
|
||||
});
|
||||
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("ambiguous");
|
||||
expect(result.tiedCandidateIds.sort()).toEqual(["tie-a", "tie-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyGraphUpdate", () => {
|
||||
|
||||
Reference in New Issue
Block a user