Implemented Investigation Strategy

This commit is contained in:
2026-08-02 15:10:49 +01:00
parent a79a7bd524
commit 5ef9710293
7 changed files with 293 additions and 121 deletions
+8
View File
@@ -80,6 +80,14 @@ export default function DiagnosticsView({ result }) {
? `${validationIcons.valid} valid` ? `${validationIcons.valid} valid`
: `${validationIcons.invalid} invalid`, : `${validationIcons.invalid} invalid`,
}, },
{
label: "Investigation strategy",
value:
diagnostics.investigationStrategy?.key ||
diagnostics.investigationStrategy ||
result.selectedQuestion?.strategy ||
"?",
},
]; ];
const errors = [ const errors = [
+1
View File
@@ -656,6 +656,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
formulatedQuestion?.question || deterministicSelection.question, formulatedQuestion?.question || deterministicSelection.question,
reason: formulatedQuestion?.reason || deterministicSelection.reason, reason: formulatedQuestion?.reason || deterministicSelection.reason,
strategy: formulatedQuestion?.strategy, strategy: formulatedQuestion?.strategy,
investigationStrategy: formulatedQuestion?.investigationStrategy,
} }
: null; : null;
+8
View File
@@ -53,6 +53,7 @@ function buildUpdateDiagnostics({
normalisationsApplied, normalisationsApplied,
graph, graph,
graphReferenceValidation, graphReferenceValidation,
selectedQuestion,
}) { }) {
return { return {
promptVersion: promptVersion ?? "v0.4", promptVersion: promptVersion ?? "v0.4",
@@ -66,6 +67,10 @@ function buildUpdateDiagnostics({
errors: [], errors: [],
}, },
normalisationsApplied: normalisationsApplied ?? [], normalisationsApplied: normalisationsApplied ?? [],
investigationStrategy:
selectedQuestion?.investigationStrategy ??
selectedQuestion?.strategy ??
null,
}; };
} }
@@ -284,6 +289,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
normalisationsApplied: parsedProposal.normalisationsApplied, normalisationsApplied: parsedProposal.normalisationsApplied,
graph: situationGraph, graph: situationGraph,
graphReferenceValidation: graphReferenceValidation, graphReferenceValidation: graphReferenceValidation,
selectedQuestion: null,
}), }),
}, },
statusCode: statusCode:
@@ -313,6 +319,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
normalisationsApplied: parsedProposal.normalisationsApplied, normalisationsApplied: parsedProposal.normalisationsApplied,
graph: applicationResult.updatedSituationGraph, graph: applicationResult.updatedSituationGraph,
graphReferenceValidation: applicationResult.graphReferenceValidation, graphReferenceValidation: applicationResult.graphReferenceValidation,
selectedQuestion: applicationResult.selectedQuestion,
}), }),
}; };
} }
@@ -328,6 +335,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
normalisationsApplied: parsedProposal.normalisationsApplied, normalisationsApplied: parsedProposal.normalisationsApplied,
graph: situationGraph, graph: situationGraph,
graphReferenceValidation, graphReferenceValidation,
selectedQuestion: null,
}), }),
}; };
} }
+146 -93
View File
@@ -139,7 +139,41 @@ function toGerundPhrase(phrase) {
return [gerund, ...rest].join(" "); return [gerund, ...rest].join(" ");
} }
function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) { function buildInvestigationStrategy({
key,
reason,
node,
graph,
relatedNodes,
meaning,
actionPhrase,
}) {
return {
key,
reason,
nodeId: node?.id ?? null,
nodeLabel: node?.label ?? null,
meaning,
actionPhrase,
relatedNodeIds: relatedNodes.map((relatedNode) => relatedNode.id),
centralStatement: graph?.centralStatement ?? null,
};
}
export function selectInvestigationStrategy({ node, graph, context = {} }) {
const relatedNodes = collectRelatedNodes(node, graph);
const meaning = extractMeaning(node);
const combinedText = [
node?.label,
node?.description,
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
...(context.resolvedValues || []),
]
.filter(Boolean)
.join(" ");
const text = normaliseText(combinedText); const text = normaliseText(combinedText);
const nodeText = normaliseText( const nodeText = normaliseText(
`${node?.label || ""} ${node?.description || ""}`, `${node?.label || ""} ${node?.description || ""}`,
@@ -172,21 +206,22 @@ function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
nodeText, nodeText,
); );
if (/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(text)) {
return { strategy: "actor/customer", meaning, actionPhrase };
}
const hasBaselineLanguage = const hasBaselineLanguage =
/\b(before|previous|baseline|prior|comparable state)\b/.test(text); /\b(before|previous|baseline|prior|comparable state)\b/.test(text);
const hasPrimaryBaselineLanguage = const hasPrimaryBaselineLanguage =
/\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText); /\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText);
if (hasBaselineLanguage && hasPrimaryBaselineLanguage) { if (hasBaselineLanguage && hasPrimaryBaselineLanguage) {
return { strategy: "baseline", meaning, actionPhrase }; return buildInvestigationStrategy({
} key: "baseline_reconstruction",
reason:
if (/\b(when|timing|timeline|duration|sequence|milestone)\b/.test(text)) { "Selected because the unknown explicitly references a missing previous or baseline state.",
return { strategy: "transition/timing", meaning, actionPhrase }; node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
} }
const hasDefinitionLanguage = const hasDefinitionLanguage =
@@ -206,84 +241,116 @@ function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
/\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test( /\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
text, text,
); );
const hasEvidenceLanguage =
/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text) ||
node?.kind === "reported_claim" ||
node?.kind === "conclusion" ||
/\b(claim|assertion|true|false)\b/.test(text);
const hasContradictionLanguage =
/\b(contradiction|contradict|conflict|inconsistent|inconsistency|disagree|mismatch)\b/.test(
`${text} ${relatedText}`,
) ||
relatedNodes.some(
(relatedNode) =>
relatedNode.status === "contradicted" ||
relatedNode.kind === "conclusion",
);
if (hasDecisionValueLanguage && hasMeasurementLanguage) { if (hasPrimaryDefinitionLanguage || hasDefinitionLanguage) {
return { strategy: "measurement", meaning, actionPhrase }; return buildInvestigationStrategy({
} key: "definition",
reason:
if (hasPrimaryDefinitionLanguage) { "Selected because the unknown is primarily about clarifying what a term means in this case.",
return { strategy: "definition", meaning, actionPhrase }; node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
} }
if (hasDecisionValueLanguage || hasCriteriaLanguage) { if (hasDecisionValueLanguage || hasCriteriaLanguage) {
return { strategy: "decision criterion", meaning, actionPhrase }; return buildInvestigationStrategy({
key: "decision_threshold",
reason:
"Selected because the unknown determines the threshold for making or justifying a decision.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
} }
if (hasConstraintLanguage && hasPrimaryConstraintLanguage) { if (hasPrimaryBaselineLanguage || hasBaselineLanguage) {
return { strategy: "constraint", meaning, actionPhrase }; return buildInvestigationStrategy({
key: "baseline_reconstruction",
reason:
"Selected because reconstructing the prior state is the most direct way to resolve the unknown.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
} }
if (hasDefinitionLanguage) { if (hasContradictionLanguage) {
return { strategy: "definition", meaning, actionPhrase }; return buildInvestigationStrategy({
key: "contradiction_resolution",
reason:
"Selected because the graph context indicates conflicting claims or inconsistent states that must be reconciled.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
} }
if (hasBaselineLanguage) { if (hasEvidenceLanguage || hasMeasurementLanguage || hasConstraintLanguage) {
return { strategy: "baseline", meaning, actionPhrase }; return buildInvestigationStrategy({
key: "evidence_gathering",
reason:
hasConstraintLanguage && hasPrimaryConstraintLanguage
? "Selected because evidence about the practical limiting factor is needed before the unknown can be resolved."
: "Selected because resolving the unknown requires evidence, signals, or measurable confirmation.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
} }
if (hasConstraintLanguage) { return buildInvestigationStrategy({
return { strategy: "constraint", meaning, actionPhrase }; key: "definition",
} reason:
"Selected as the deterministic fallback because clarifying the exact meaning of the unknown is the narrowest first step.",
if (/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text)) { node,
return { strategy: "evidence", meaning, actionPhrase }; graph,
} relatedNodes,
meaning,
if (hasMeasurementLanguage) { actionPhrase,
return { strategy: "measurement", meaning, actionPhrase }; });
}
if (
/\b(objective|goal|outcome|problem|job to be done|benefit)\b/.test(text)
) {
return { strategy: "objective", meaning, actionPhrase };
}
if (
node?.kind === "reported_claim" ||
node?.kind === "conclusion" ||
/\b(claim|assertion|true|false)\b/.test(text)
) {
return { strategy: "evidence", meaning, actionPhrase };
}
return { strategy: "generic clarification", meaning, actionPhrase };
} }
function buildQuestion({ strategy, meaning, actionPhrase }) { function buildQuestionFromStrategy(strategy) {
switch (strategy) { switch (strategy.key) {
case "decision criterion": case "decision_threshold":
return actionPhrase return strategy.actionPhrase
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(actionPhrase)}?` ? `What outcome would demonstrate enough value to justify ${toGerundPhrase(strategy.actionPhrase)}?`
: "What outcome would be sufficient to justify this decision?"; : "What outcome would be sufficient to justify this decision?";
case "definition": case "definition":
return `What does ${meaning} mean in this situation?`; return `What does ${strategy.meaning} mean in this situation?`;
case "evidence": case "evidence_gathering":
return `What evidence would show whether ${meaning} is true?`; return `What evidence would show whether ${strategy.meaning} is true?`;
case "baseline": case "baseline_reconstruction":
return `What was the comparable state before ${meaning}?`; return `What was the comparable state before ${strategy.meaning}?`;
case "actor/customer": case "contradiction_resolution":
return "Who experiences the problem or receives the value in this situation?"; return `What fact would resolve the contradiction about ${strategy.meaning}?`;
case "objective":
return "What outcome is this decision or effort meant to achieve?";
case "constraint":
return "What constraint most limits the available options in this situation?";
case "measurement":
return `What measure would determine whether ${meaning} is sufficient?`;
case "transition/timing":
return `When does ${meaning} become relevant in the decision or change?`;
default: default:
return `What specific fact would resolve whether ${meaning} is true?`; return `What specific fact would resolve whether ${strategy.meaning} is true?`;
} }
} }
@@ -332,36 +399,22 @@ function validateFormulatedQuestion(question, meaning) {
} }
export function formulateQuestion({ node, graph, context = {} }) { export function formulateQuestion({ node, graph, context = {} }) {
const relatedNodes = collectRelatedNodes(node, graph); const investigationStrategy = selectInvestigationStrategy({
const meaning = extractMeaning(node);
const combinedText = [
node?.label,
node?.description,
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
...(context.resolvedValues || []),
]
.filter(Boolean)
.join(" ");
const detected = detectStrategy({
node, node,
graph, graph,
relatedNodes, context,
combinedText,
meaning,
}); });
let question = buildQuestion(detected); let question = buildQuestionFromStrategy(investigationStrategy);
if (!validateFormulatedQuestion(question, meaning)) { if (!validateFormulatedQuestion(question, investigationStrategy.meaning)) {
question = `What evidence would resolve whether ${meaning} is true?`; question = `What evidence would resolve whether ${investigationStrategy.meaning} is true?`;
} }
return { return {
question, question,
reason: `Formulated from graph context using the ${detected.strategy} strategy.`, reason: `Formulated from graph context using the ${investigationStrategy.key} investigation strategy.`,
strategy: detected.strategy, strategy: investigationStrategy.key,
investigationStrategy,
}; };
} }
+17 -5
View File
@@ -67,7 +67,11 @@ export const questionPriorityGeneralisationFixtures = [
"hire-bottleneck", "hire-bottleneck",
], ],
prohibitedFirstTopics: ["salary", "job advert", "programming language"], prohibitedFirstTopics: ["salary", "job advert", "programming language"],
acceptableQuestionStrategies: ["decision criterion", "constraint"], acceptableQuestionStrategies: [
"decision_threshold",
"evidence_gathering",
"definition",
],
notes: notes:
"The first question should establish whether more engineering capacity is justified before compensation or implementation details.", "The first question should establish whether more engineering capacity is justified before compensation or implementation details.",
graph: makeScenarioGraph({ graph: makeScenarioGraph({
@@ -143,7 +147,11 @@ export const questionPriorityGeneralisationFixtures = [
"paint colour", "paint colour",
"finance provider", "finance provider",
], ],
acceptableQuestionStrategies: ["decision criterion", "constraint"], acceptableQuestionStrategies: [
"decision_threshold",
"evidence_gathering",
"definition",
],
notes: notes:
"The first question should establish whether the fleet is failing a threshold that justifies replacement.", "The first question should establish whether the fleet is failing a threshold that justifies replacement.",
graph: makeScenarioGraph({ graph: makeScenarioGraph({
@@ -219,7 +227,7 @@ export const questionPriorityGeneralisationFixtures = [
"office location", "office location",
"advertising channel", "advertising channel",
], ],
acceptableQuestionStrategies: ["actor/customer", "decision criterion"], acceptableQuestionStrategies: ["definition", "decision_threshold"],
notes: notes:
"The first question should clarify the customer or value case for expansion before rollout logistics.", "The first question should clarify the customer or value case for expansion before rollout logistics.",
graph: makeScenarioGraph({ graph: makeScenarioGraph({
@@ -291,7 +299,7 @@ export const questionPriorityGeneralisationFixtures = [
"project-remaining-benefit", "project-remaining-benefit",
], ],
prohibitedFirstTopics: ["sunk cost", "project logo", "final launch date"], prohibitedFirstTopics: ["sunk cost", "project logo", "final launch date"],
acceptableQuestionStrategies: ["decision criterion", "objective"], acceptableQuestionStrategies: ["decision_threshold", "definition"],
notes: notes:
"The first question should establish remaining value or success threshold before sunk-cost framing or launch timing.", "The first question should establish remaining value or success threshold before sunk-cost framing or launch timing.",
graph: makeScenarioGraph({ graph: makeScenarioGraph({
@@ -367,7 +375,11 @@ export const questionPriorityGeneralisationFixtures = [
"payment provider", "payment provider",
"tier name", "tier name",
], ],
acceptableQuestionStrategies: ["actor/customer", "decision criterion"], acceptableQuestionStrategies: [
"definition",
"decision_threshold",
"baseline_reconstruction",
],
notes: notes:
"The first question should establish who values paid support or what outcome would justify offering it before pricing details.", "The first question should establish who values paid support or what outcome would justify offering it before pricing details.",
graph: makeScenarioGraph({ graph: makeScenarioGraph({
+108 -18
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formulateQuestion } from "@/lib/graph/question-formulator.js"; import {
formulateQuestion,
selectInvestigationStrategy,
} from "@/lib/graph/question-formulator.js";
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
function makeGraphFor(node, extra = {}) { function makeGraphFor(node, extra = {}) {
@@ -14,7 +17,7 @@ function makeGraphFor(node, extra = {}) {
} }
describe("formulateQuestion", () => { describe("formulateQuestion", () => {
it("commercial viability plus build decision produces a decision-criterion question", () => { it("commercial viability plus build decision produces a decision-threshold question", () => {
const unknown = makeNode({ const unknown = makeNode({
id: "n-commercial", id: "n-commercial",
label: "Uncertainty regarding the commercial value of the product", label: "Uncertainty regarding the commercial value of the product",
@@ -42,7 +45,7 @@ describe("formulateQuestion", () => {
const result = formulateQuestion({ node: unknown, graph }); const result = formulateQuestion({ node: unknown, graph });
expect(result.strategy).toBe("decision criterion"); expect(result.strategy).toBe("decision_threshold");
expect(result.question).toContain("What outcome"); expect(result.question).toContain("What outcome");
expect(result.question.toLowerCase()).toContain("justify"); expect(result.question.toLowerCase()).toContain("justify");
}); });
@@ -100,7 +103,7 @@ describe("formulateQuestion", () => {
graph: makeGraphFor(unknown), graph: makeGraphFor(unknown),
}); });
expect(result.strategy).toBe("evidence"); expect(result.strategy).toBe("evidence_gathering");
expect(result.question).toContain("What evidence"); expect(result.question).toContain("What evidence");
}); });
@@ -120,33 +123,41 @@ describe("formulateQuestion", () => {
graph: makeGraphFor(unknown), graph: makeGraphFor(unknown),
}); });
expect(result.strategy).toBe("baseline"); expect(result.strategy).toBe("baseline_reconstruction");
expect(result.question).toContain("What was the comparable state before"); expect(result.question).toContain("What was the comparable state before");
}); });
it("unknown customer produces an actor/customer question", () => { it("conflicting claim produces a contradiction-resolution question", () => {
const unknown = makeNode({ const unknown = makeNode({
id: "n-customer", id: "n-conflict",
label: "Target customer", label: "Conflicting churn claim",
description: description:
"Need to know the customer because value depends on who receives it.", "Need to resolve the inconsistency because the current figures contradict each other.",
kind: "unknown", kind: "unknown",
status: "unknown", status: "unknown",
confidence: "high", confidence: "medium",
});
const contradiction = makeNode({
id: "n-contradiction",
label: "Contradicted report",
description: "Two sources disagree about churn.",
kind: "conclusion",
status: "contradicted",
confidence: "low",
childIds: [unknown.id],
}); });
const result = formulateQuestion({ const result = formulateQuestion({
node: unknown, node: unknown,
graph: makeGraphFor(unknown), graph: makeGraphFor(unknown, { nodes: [contradiction] }),
}); });
expect(result.strategy).toBe("actor/customer"); expect(result.strategy).toBe("contradiction_resolution");
expect(result.question).toContain( expect(result.question).toContain("resolve the contradiction");
"Who experiences the problem or receives the value",
);
}); });
it("constraint unknown produces a constraint question", () => { it("constraint unknown uses evidence-gathering within the fixed strategy set", () => {
const unknown = makeNode({ const unknown = makeNode({
id: "n-constraint", id: "n-constraint",
label: "Budget constraint", label: "Budget constraint",
@@ -162,8 +173,87 @@ describe("formulateQuestion", () => {
graph: makeGraphFor(unknown), graph: makeGraphFor(unknown),
}); });
expect(result.strategy).toBe("constraint"); expect(result.strategy).toBe("evidence_gathering");
expect(result.question).toContain("What constraint most limits"); expect(result.question).toContain("What evidence");
});
it("the same unknown can produce different questions when paired with different strategies", () => {
const unknown = makeNode({
id: "n-same-unknown",
label: "Value threshold",
description: "Need to resolve the value threshold.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const decisionGraph = makeGraphFor(unknown, {
centralStatement: "We are deciding whether to launch this product.",
nodes: [
makeNode({
id: "n-decision",
label: "Launch decision",
description: "Decision depends on the value threshold.",
kind: "state",
status: "known",
confidence: "medium",
childIds: [unknown.id],
value: "Deciding whether to launch the product",
}),
],
});
const definitionGraph = makeGraphFor(unknown, {
centralStatement:
"The team uses the term value threshold inconsistently.",
nodes: [
makeNode({
id: "n-definition",
label: "Definition disagreement",
description:
"Need a definition of value threshold before comparing options.",
kind: "state",
status: "known",
confidence: "medium",
childIds: [unknown.id],
}),
],
});
const decisionResult = formulateQuestion({
node: unknown,
graph: decisionGraph,
});
const definitionResult = formulateQuestion({
node: unknown,
graph: definitionGraph,
});
expect(decisionResult.strategy).toBe("decision_threshold");
expect(definitionResult.strategy).toBe("definition");
expect(decisionResult.question).not.toBe(definitionResult.question);
});
it("strategy selection is deterministic and explainable", () => {
const unknown = makeNode({
id: "n-threshold",
label: "Success threshold",
description:
"Need the success threshold because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const graph = makeGraphFor(unknown, {
centralStatement: "We need to decide whether to continue investing.",
});
const first = selectInvestigationStrategy({ node: unknown, graph });
const second = selectInvestigationStrategy({ node: unknown, graph });
expect(first).toEqual(second);
expect(first.key).toBe("decision_threshold");
expect(first.reason).toContain("threshold");
}); });
it("question is singular and answerable", () => { it("question is singular and answerable", () => {
@@ -127,27 +127,27 @@ describe("question priority generalisation", () => {
{ {
"nodeId": "hire-success-criteria", "nodeId": "hire-success-criteria",
"scenario": "Should we hire another engineer?", "scenario": "Should we hire another engineer?",
"strategy": "decision criterion", "strategy": "decision_threshold",
}, },
{ {
"nodeId": "van-reliability-threshold", "nodeId": "van-reliability-threshold",
"scenario": "Should we replace the delivery vans?", "scenario": "Should we replace the delivery vans?",
"strategy": "decision criterion", "strategy": "decision_threshold",
}, },
{ {
"nodeId": "country-value-threshold", "nodeId": "country-value-threshold",
"scenario": "Should we launch in another country?", "scenario": "Should we launch in another country?",
"strategy": "actor/customer", "strategy": "decision_threshold",
}, },
{ {
"nodeId": "project-benefit-threshold", "nodeId": "project-benefit-threshold",
"scenario": "Should we continue a project that is over budget?", "scenario": "Should we continue a project that is over budget?",
"strategy": "decision criterion", "strategy": "decision_threshold",
}, },
{ {
"nodeId": "support-value-threshold", "nodeId": "support-value-threshold",
"scenario": "Should we introduce a paid support tier?", "scenario": "Should we introduce a paid support tier?",
"strategy": "actor/customer", "strategy": "baseline_reconstruction",
}, },
] ]
`); `);