Merge branch 'feature/emergent-unknowns-v0.5'
This commit is contained in:
@@ -23,12 +23,17 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
affectedNodeIds,
|
affectedNodeIds,
|
||||||
previousActiveUnknownNodeId,
|
previousActiveUnknownNodeId,
|
||||||
newActiveUnknownNodeId,
|
newActiveUnknownNodeId,
|
||||||
|
selectedQuestion,
|
||||||
changesApplied,
|
changesApplied,
|
||||||
proposal,
|
proposal,
|
||||||
previousSituationGraph,
|
previousSituationGraph,
|
||||||
updatedSituationGraph,
|
updatedSituationGraph,
|
||||||
} = updateResult;
|
} = updateResult;
|
||||||
|
|
||||||
|
const newlySurfacedUnknownNodeIds = (proposal.addedNodes || [])
|
||||||
|
.filter((node) => node.kind === "unknown")
|
||||||
|
.map((node) => node.id);
|
||||||
|
|
||||||
const previousNodesById = new Map(
|
const previousNodesById = new Map(
|
||||||
(previousSituationGraph?.nodes || []).map((node) => [node.id, node]),
|
(previousSituationGraph?.nodes || []).map((node) => [node.id, node]),
|
||||||
);
|
);
|
||||||
@@ -123,10 +128,15 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
{resolveActiveUnknown(newActiveUnknownNodeId)}
|
{resolveActiveUnknown(newActiveUnknownNodeId)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!newActiveUnknownNodeId && previousActiveUnknownNodeId && (
|
{selectedQuestion?.question && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Next question status:</span> No next
|
<span className="font-medium">Next question:</span>{" "}
|
||||||
question selected yet.
|
{selectedQuestion.question}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!selectedQuestion?.question && !newActiveUnknownNodeId && previousActiveUnknownNodeId && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Next question status:</span> No next question selected yet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +147,11 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
items={resolvedUnknownNodeIds}
|
items={resolvedUnknownNodeIds}
|
||||||
renderItem={resolveNodePresentation}
|
renderItem={resolveNodePresentation}
|
||||||
/>
|
/>
|
||||||
|
<ListSection
|
||||||
|
title="Newly surfaced unknowns"
|
||||||
|
items={newlySurfacedUnknownNodeIds}
|
||||||
|
renderItem={resolveNodePresentation}
|
||||||
|
/>
|
||||||
<ListSection
|
<ListSection
|
||||||
title="Affected nodes"
|
title="Affected nodes"
|
||||||
items={affectedNodeIds}
|
items={affectedNodeIds}
|
||||||
|
|||||||
@@ -52,9 +52,16 @@ function normaliseStartResult(data) {
|
|||||||
typeof data?.selectedQuestion === "string"
|
typeof data?.selectedQuestion === "string"
|
||||||
? data.selectedQuestion
|
? data.selectedQuestion
|
||||||
: data?.selectedQuestion?.question ?? null,
|
: data?.selectedQuestion?.question ?? null,
|
||||||
|
newlySurfacedNodeIds: data?.newlySurfacedNodeIds ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normaliseUpdateSelectedQuestion(selectedQuestion) {
|
||||||
|
if (!selectedQuestion) return null;
|
||||||
|
if (typeof selectedQuestion === "string") return selectedQuestion;
|
||||||
|
return selectedQuestion.question ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ScenarioResultPanels({ status, result }) {
|
export function ScenarioResultPanels({ status, result }) {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
@@ -83,6 +90,7 @@ export function ScenarioResultPanels({ status, result }) {
|
|||||||
<SituationGraphView
|
<SituationGraphView
|
||||||
situationGraph={result.situationGraph}
|
situationGraph={result.situationGraph}
|
||||||
selectedQuestion={result.selectedQuestion}
|
selectedQuestion={result.selectedQuestion}
|
||||||
|
newlySurfacedNodeIds={result.newlySurfacedNodeIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -192,7 +200,12 @@ export default function ScenarioForm() {
|
|||||||
setResult((current) => ({
|
setResult((current) => ({
|
||||||
...current,
|
...current,
|
||||||
situationGraph: outcome.updatedSituationGraph,
|
situationGraph: outcome.updatedSituationGraph,
|
||||||
selectedQuestion: null,
|
selectedQuestion: normaliseUpdateSelectedQuestion(
|
||||||
|
outcome.selectedQuestion,
|
||||||
|
),
|
||||||
|
newlySurfacedNodeIds: (outcome.proposal?.addedNodes || [])
|
||||||
|
.filter((node) => node.kind === "unknown")
|
||||||
|
.map((node) => node.id),
|
||||||
diagnostics: outcome.diagnostics,
|
diagnostics: outcome.diagnostics,
|
||||||
}));
|
}));
|
||||||
setAnswer("");
|
setAnswer("");
|
||||||
@@ -208,9 +221,14 @@ export default function ScenarioForm() {
|
|||||||
|
|
||||||
const canRenderAnswerForm =
|
const canRenderAnswerForm =
|
||||||
status === "success" &&
|
status === "success" &&
|
||||||
|
updateStatus === "idle" &&
|
||||||
Boolean(result?.situationGraph) &&
|
Boolean(result?.situationGraph) &&
|
||||||
Boolean(result?.selectedQuestion);
|
Boolean(result?.selectedQuestion);
|
||||||
|
|
||||||
|
const canRenderDisabledFollowUpForm =
|
||||||
|
updateStatus === "success" &&
|
||||||
|
Boolean(updateResult?.selectedQuestion?.question || result?.selectedQuestion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -277,8 +295,44 @@ export default function ScenarioForm() {
|
|||||||
{updateStatus === "success" && updateResult && (
|
{updateStatus === "success" && updateResult && (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
|
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
|
||||||
No next question selected yet.
|
{updateResult.selectedQuestion?.question
|
||||||
|
? updateResult.selectedQuestion.question
|
||||||
|
: "No next question selected yet."}
|
||||||
</div>
|
</div>
|
||||||
|
{canRenderDisabledFollowUpForm && (
|
||||||
|
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 opacity-70">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">Selected Question</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-700">
|
||||||
|
{updateResult.selectedQuestion?.question || result?.selectedQuestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="follow-up-disabled-textarea" className="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Your answer
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="follow-up-disabled-textarea"
|
||||||
|
rows={4}
|
||||||
|
disabled
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm opacity-70"
|
||||||
|
placeholder="Additional submission is disabled in this one-update prototype."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Additional submission is disabled in this one-update prototype.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Update situation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
<GraphUpdateView updateResult={updateResult} />
|
<GraphUpdateView updateResult={updateResult} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ function NodeBadge({ children, tone = "gray" }) {
|
|||||||
blue: "border-blue-200 bg-blue-50 text-blue-700",
|
blue: "border-blue-200 bg-blue-50 text-blue-700",
|
||||||
green: "border-green-200 bg-green-50 text-green-700",
|
green: "border-green-200 bg-green-50 text-green-700",
|
||||||
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700",
|
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700",
|
||||||
|
red: "border-red-200 bg-red-50 text-red-700",
|
||||||
|
purple: "border-purple-200 bg-purple-50 text-purple-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +19,13 @@ function NodeBadge({ children, tone = "gray" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeGroup({ title, nodes }) {
|
function NodeGroup({
|
||||||
|
title,
|
||||||
|
nodes,
|
||||||
|
resolvedNodeIds = new Set(),
|
||||||
|
newlySurfacedNodeIds = new Set(),
|
||||||
|
activeUnknownNodeId = null,
|
||||||
|
}) {
|
||||||
if (!nodes?.length) return null;
|
if (!nodes?.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,6 +40,15 @@ function NodeGroup({ title, nodes }) {
|
|||||||
<span className="font-medium text-gray-900">{node.label}</span>
|
<span className="font-medium text-gray-900">{node.label}</span>
|
||||||
<NodeBadge tone="blue">{node.status}</NodeBadge>
|
<NodeBadge tone="blue">{node.status}</NodeBadge>
|
||||||
<NodeBadge tone="green">{node.confidence}</NodeBadge>
|
<NodeBadge tone="green">{node.confidence}</NodeBadge>
|
||||||
|
{resolvedNodeIds.has(node.id) && (
|
||||||
|
<NodeBadge tone="red">resolved unknown</NodeBadge>
|
||||||
|
)}
|
||||||
|
{newlySurfacedNodeIds.has(node.id) && (
|
||||||
|
<NodeBadge tone="purple">newly surfaced unknown</NodeBadge>
|
||||||
|
)}
|
||||||
|
{activeUnknownNodeId === node.id && (
|
||||||
|
<NodeBadge tone="yellow">active unknown</NodeBadge>
|
||||||
|
)}
|
||||||
{node.value != null && (
|
{node.value != null && (
|
||||||
<NodeBadge tone="yellow">
|
<NodeBadge tone="yellow">
|
||||||
{node.value}
|
{node.value}
|
||||||
@@ -52,6 +69,7 @@ function NodeGroup({ title, nodes }) {
|
|||||||
export default function SituationGraphView({
|
export default function SituationGraphView({
|
||||||
situationGraph,
|
situationGraph,
|
||||||
selectedQuestion,
|
selectedQuestion,
|
||||||
|
newlySurfacedNodeIds = [],
|
||||||
}) {
|
}) {
|
||||||
if (!situationGraph) return null;
|
if (!situationGraph) return null;
|
||||||
|
|
||||||
@@ -70,6 +88,9 @@ export default function SituationGraphView({
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
const resolvedNodeIdSet = new Set(situationGraph.resolvedNodeIds || []);
|
||||||
|
const newlySurfacedNodeIdSet = new Set(newlySurfacedNodeIds || []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedQuestionText && (
|
{selectedQuestionText && (
|
||||||
@@ -110,6 +131,9 @@ export default function SituationGraphView({
|
|||||||
key={kind}
|
key={kind}
|
||||||
title={kind.replace(/_/g, " ")}
|
title={kind.replace(/_/g, " ")}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
resolvedNodeIds={resolvedNodeIdSet}
|
||||||
|
newlySurfacedNodeIds={newlySurfacedNodeIdSet}
|
||||||
|
activeUnknownNodeId={situationGraph.activeUnknownNodeId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# v0.5 Question Priority Generalisation
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
The current deterministic unknown selector and graph-context question formulator should generalise across several decision types by selecting a foundational unknown before downstream implementation or pricing leaves.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
1. Should we hire another engineer?
|
||||||
|
2. Should we replace the delivery vans?
|
||||||
|
3. Should we launch in another country?
|
||||||
|
4. Should we continue a project that is over budget?
|
||||||
|
5. Should we introduce a paid support tier?
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
| Scenario | Selected unknown | Strategy | Pass/Fail |
|
||||||
|
| ---------------------------- | --------------------------- | -------------------- | --------- |
|
||||||
|
| Hire another engineer | `hire-success-criteria` | `decision criterion` | Pass |
|
||||||
|
| Replace the delivery vans | `van-reliability-threshold` | `decision criterion` | Pass |
|
||||||
|
| Launch in another country | `country-value-threshold` | `actor/customer` | Pass |
|
||||||
|
| Continue over-budget project | `project-benefit-threshold` | `decision criterion` | Pass |
|
||||||
|
| Introduce paid support tier | `support-value-threshold` | `actor/customer` | Pass |
|
||||||
|
|
||||||
|
## Repeated failure patterns
|
||||||
|
|
||||||
|
Two repeated structural formulation failures appeared before the final pass:
|
||||||
|
|
||||||
|
1. **Constraint language in surrounding graph context outranked node-local decision-threshold language** in more than one case.
|
||||||
|
2. **Baseline language in surrounding graph context outranked node-local threshold language** in more than one case.
|
||||||
|
|
||||||
|
Both failures affected formulation strategy, not deterministic unknown selection.
|
||||||
|
|
||||||
|
## Code change made
|
||||||
|
|
||||||
|
A small deterministic change was made in `lib/graph/question-formulator.js`:
|
||||||
|
|
||||||
|
- prefer node-local `definition` language before broader criterion inference
|
||||||
|
- prefer node-local `decision criterion` language before context-only `constraint` inference
|
||||||
|
- only treat `baseline` or `constraint` as primary when the selected node itself carries that language, otherwise allow them as fallback strategies later
|
||||||
|
|
||||||
|
No architecture, UI, persistence, prompt, scoring, additional model turns, or provider calls were added.
|
||||||
|
|
||||||
|
## Remaining limitations
|
||||||
|
|
||||||
|
- In two passing cases, the selector chose a threshold-style foundational node while the formulator still used an `actor/customer` strategy because related context strongly referenced customers or recipients.
|
||||||
|
- This experiment is fixture-driven and deterministic; it is useful for regression protection, not scientific validation.
|
||||||
|
- The suite exercises the production path without model calls, but it does not prove behaviour over arbitrary real-world graph structures.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# v0.5 Release Notes
|
||||||
|
|
||||||
|
## Purpose of v0.5
|
||||||
|
|
||||||
|
v0.5 stabilises the graph-backed one-turn update flow so the engine can resolve an answered unknown, surface consequential new unknowns, prioritise the next unknown deterministically, and formulate a deterministic follow-up question without changing the UI or adding more model turns.
|
||||||
|
|
||||||
|
## Capabilities proven
|
||||||
|
|
||||||
|
v0.5 includes:
|
||||||
|
|
||||||
|
- resolving an existing unknown
|
||||||
|
- surfacing consequential new unknowns
|
||||||
|
- limiting emergent unknowns
|
||||||
|
- deterministic information-value prioritisation
|
||||||
|
- deterministic question formulation
|
||||||
|
- generalisation across five decision types
|
||||||
|
- graph-backed one-turn UI update
|
||||||
|
|
||||||
|
## Five-case generalisation result
|
||||||
|
|
||||||
|
All five deterministic fixture scenarios passed:
|
||||||
|
|
||||||
|
1. Should we hire another engineer?
|
||||||
|
2. Should we replace the delivery vans?
|
||||||
|
3. Should we launch in another country?
|
||||||
|
4. Should we continue a project that is over budget?
|
||||||
|
5. Should we introduce a paid support tier?
|
||||||
|
|
||||||
|
The selector chose a foundational unknown first in each case, avoided the downstream leaf first, required no model call, and preserved graph immutability during question formulation.
|
||||||
|
|
||||||
|
## Key deterministic safeguards
|
||||||
|
|
||||||
|
- proposal application re-selects the active unknown deterministically after validation
|
||||||
|
- information-value scoring penalises downstream or prerequisite-blocked unknowns
|
||||||
|
- emergent unknown validation limits additions and requires explicit answer-derived linkage
|
||||||
|
- final question wording is reformulated from graph context without an extra model turn
|
||||||
|
- question validation rejects compound, awkward, or pricing-led fallback phrasing
|
||||||
|
|
||||||
|
## Known limitation
|
||||||
|
|
||||||
|
A correctly selected threshold node can still be phrased using an actor/customer strategy when surrounding graph context strongly references customers or value recipients.
|
||||||
|
|
||||||
|
This limitation is recorded for the next experiment and is not being fixed in the v0.5 release-prep task.
|
||||||
|
|
||||||
|
## Deliberately excluded work
|
||||||
|
|
||||||
|
- no reasoning-logic expansion beyond the small deterministic formulation fixes already landed on the branch
|
||||||
|
- no new features
|
||||||
|
- no UI changes
|
||||||
|
- no persistence
|
||||||
|
- no additional model turn
|
||||||
|
- no Ollama calls for validation
|
||||||
|
- no evaluator-suite runs
|
||||||
|
- no Playwright runs
|
||||||
|
|
||||||
|
## Next experimental question
|
||||||
|
|
||||||
|
Can the question formulation strategy remain aligned with the selected node's role when surrounding graph context contains competing signals?
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describeGraph } from "./builder.js";
|
import { describeGraph } from "./builder.js";
|
||||||
|
import { formulateQuestion } from "./question-formulator.js";
|
||||||
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
|
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
|
||||||
import {
|
import {
|
||||||
applyGraphUpdate,
|
applyGraphUpdate,
|
||||||
detectDuplicateNodeIds,
|
detectDuplicateNodeIds,
|
||||||
findAffectedNodes,
|
findAffectedNodes,
|
||||||
|
scoreUnknownCandidate,
|
||||||
selectActiveUnknownCandidate,
|
selectActiveUnknownCandidate,
|
||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
validateGraphUpdate,
|
validateGraphUpdate,
|
||||||
@@ -41,6 +43,225 @@ function normaliseText(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildNodeById(graph, addedNodes = []) {
|
||||||
|
return new Map(
|
||||||
|
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompoundQuestion(question) {
|
||||||
|
if (typeof question !== "string") return false;
|
||||||
|
const trimmed = question.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
|
||||||
|
const questionMarks = (trimmed.match(/\?/g) || []).length;
|
||||||
|
if (questionMarks > 1) return true;
|
||||||
|
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
|
||||||
|
if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAddedUnknowns(graph, proposal) {
|
||||||
|
const errors = [];
|
||||||
|
const addedUnknowns = proposal.addedNodes.filter(
|
||||||
|
(node) => node.kind === "unknown",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addedUnknowns.length > 3) {
|
||||||
|
errors.push(
|
||||||
|
`Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedExistingUnknowns = graph.nodes.filter(
|
||||||
|
(node) =>
|
||||||
|
node.kind === "unknown" &&
|
||||||
|
!proposal.resolvedUnknownNodeIds.includes(node.id),
|
||||||
|
);
|
||||||
|
const seenAddedUnknownMeanings = new Map();
|
||||||
|
const answerDerivedNodeIds = new Set([
|
||||||
|
...proposal.updatedNodes.map((update) => update.nodeId),
|
||||||
|
...proposal.resolvedUnknownNodeIds,
|
||||||
|
...proposal.addedNodes
|
||||||
|
.filter((node) => node.kind !== "unknown")
|
||||||
|
.map((node) => node.id),
|
||||||
|
]);
|
||||||
|
const proposalNodeById = buildNodeById(graph, proposal.addedNodes);
|
||||||
|
|
||||||
|
function hasExplicitNodeReference(fromNode, toNodeId) {
|
||||||
|
if (!fromNode || !toNodeId) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
fromNode.parentId === toNodeId ||
|
||||||
|
fromNode.dependsOn.includes(toNodeId) ||
|
||||||
|
fromNode.affects.includes(toNodeId) ||
|
||||||
|
fromNode.childIds.includes(toNodeId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitAnswerDerivedRelationship(unknownNode) {
|
||||||
|
const connectedEdge = proposal.addedEdges.find(
|
||||||
|
(edge) =>
|
||||||
|
(edge.fromNodeId === unknownNode.id &&
|
||||||
|
answerDerivedNodeIds.has(edge.toNodeId)) ||
|
||||||
|
(edge.toNodeId === unknownNode.id &&
|
||||||
|
answerDerivedNodeIds.has(edge.fromNodeId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectedEdge) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const answerDerivedNodeId of answerDerivedNodeIds) {
|
||||||
|
const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasExplicitNodeReference(unknownNode, answerDerivedNodeId) ||
|
||||||
|
hasExplicitNodeReference(answerDerivedNode, unknownNode.id)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unknownNode of addedUnknowns) {
|
||||||
|
const meaningKeys = [
|
||||||
|
normaliseText(unknownNode.label),
|
||||||
|
normaliseText(unknownNode.description),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const meaningKey of meaningKeys) {
|
||||||
|
if (seenAddedUnknownMeanings.has(meaningKey)) {
|
||||||
|
errors.push(
|
||||||
|
`Proposal adds duplicate unknown meaning: "${unknownNode.label}"`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seenAddedUnknownMeanings.set(meaningKey, unknownNode.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingUnknown of unresolvedExistingUnknowns) {
|
||||||
|
const existingMeaningKeys = [
|
||||||
|
normaliseText(existingUnknown.label),
|
||||||
|
normaliseText(existingUnknown.description),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) {
|
||||||
|
errors.push(
|
||||||
|
`Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
unknownNode.description.trim() === unknownNode.label.trim() ||
|
||||||
|
!/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test(
|
||||||
|
unknownNode.description,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`New unknown must include why it matters in its description: "${unknownNode.id}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExplicitAnswerDerivedRelationship(unknownNode)) {
|
||||||
|
errors.push(
|
||||||
|
`New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSelectedQuestion(graph, proposal) {
|
||||||
|
const errors = [];
|
||||||
|
const selectedQuestion = proposal.selectedQuestion;
|
||||||
|
const nodeById = buildNodeById(graph, proposal.addedNodes);
|
||||||
|
|
||||||
|
if (selectedQuestion == null) {
|
||||||
|
return { errors, selectedQuestionNodeId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodeById.get(selectedQuestion.nodeId);
|
||||||
|
if (!node) {
|
||||||
|
errors.push(
|
||||||
|
`selectedQuestion references missing node: "${selectedQuestion.nodeId}"`,
|
||||||
|
);
|
||||||
|
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.kind !== "unknown") {
|
||||||
|
errors.push(
|
||||||
|
`selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvesNode = proposal.resolvedUnknownNodeIds.includes(
|
||||||
|
selectedQuestion.nodeId,
|
||||||
|
);
|
||||||
|
const updatedStatus = proposal.updatedNodes.find(
|
||||||
|
(update) => update.nodeId === selectedQuestion.nodeId,
|
||||||
|
)?.newStatus;
|
||||||
|
const effectiveStatus = updatedStatus ?? node.status;
|
||||||
|
|
||||||
|
if (resolvesNode || effectiveStatus === "resolved") {
|
||||||
|
errors.push(
|
||||||
|
`selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
graph.activeUnknownNodeId &&
|
||||||
|
proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) &&
|
||||||
|
selectedQuestion.nodeId === graph.activeUnknownNodeId
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompoundQuestion(selectedQuestion.question)) {
|
||||||
|
errors.push("selectedQuestion must be a single non-compound question");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedNodeIds = [
|
||||||
|
...(graph.resolvedNodeIds || []),
|
||||||
|
...(proposal.resolvedUnknownNodeIds || []),
|
||||||
|
];
|
||||||
|
const candidateScore = scoreUnknownCandidate(
|
||||||
|
{
|
||||||
|
...graph,
|
||||||
|
nodes: [...graph.nodes, ...(proposal.addedNodes || [])],
|
||||||
|
edges: [...graph.edges, ...(proposal.addedEdges || [])],
|
||||||
|
},
|
||||||
|
node,
|
||||||
|
resolvedNodeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQuestionSelectionRequirement(graph, proposal) {
|
||||||
|
const addedConsequentialUnknowns = proposal.addedNodes.filter(
|
||||||
|
(node) => node.kind === "unknown" && node.status !== "resolved",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
proposal.selectedQuestion == null &&
|
||||||
|
addedConsequentialUnknowns.length > 0
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function buildResolvedUnknownUpdate(node) {
|
function buildResolvedUnknownUpdate(node) {
|
||||||
return {
|
return {
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
@@ -191,6 +412,9 @@ function buildAffectedNodeIds(graph, proposal) {
|
|||||||
function buildChangesApplied(proposal, affectedNodeIds) {
|
function buildChangesApplied(proposal, affectedNodeIds) {
|
||||||
return {
|
return {
|
||||||
addedNodeCount: proposal.addedNodes.length,
|
addedNodeCount: proposal.addedNodes.length,
|
||||||
|
addedUnknownCount: proposal.addedNodes.filter(
|
||||||
|
(node) => node.kind === "unknown",
|
||||||
|
).length,
|
||||||
updatedNodeCount: proposal.updatedNodes.length,
|
updatedNodeCount: proposal.updatedNodes.length,
|
||||||
addedEdgeCount: proposal.addedEdges.length,
|
addedEdgeCount: proposal.addedEdges.length,
|
||||||
removedEdgeCount: proposal.removedEdgeIds.length,
|
removedEdgeCount: proposal.removedEdgeIds.length,
|
||||||
@@ -322,6 +546,18 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
|||||||
proposalCompatibilityErrors.push(
|
proposalCompatibilityErrors.push(
|
||||||
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
|
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
|
||||||
);
|
);
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
...validateAddedUnknowns(situationGraph, validatedProposal),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedQuestionValidation = validateSelectedQuestion(
|
||||||
|
situationGraph,
|
||||||
|
validatedProposal,
|
||||||
|
);
|
||||||
|
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
|
||||||
|
);
|
||||||
|
|
||||||
if (proposalCompatibilityErrors.length > 0) {
|
if (proposalCompatibilityErrors.length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -361,6 +597,10 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
|||||||
newActiveUnknownNodeId = null;
|
newActiveUnknownNodeId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (validatedProposal.selectedQuestion?.nodeId) {
|
||||||
|
newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
const remainingUnknownExists =
|
const remainingUnknownExists =
|
||||||
newActiveUnknownNodeId != null &&
|
newActiveUnknownNodeId != null &&
|
||||||
updatedSituationGraph.nodes.some(
|
updatedSituationGraph.nodes.some(
|
||||||
@@ -378,9 +618,47 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
|||||||
)?.nodeId ?? null;
|
)?.nodeId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deterministicSelection = selectActiveUnknownCandidate(
|
||||||
|
updatedSituationGraph,
|
||||||
|
updatedSituationGraph.resolvedNodeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deterministicSelection?.nodeId) {
|
||||||
|
newActiveUnknownNodeId = deterministicSelection.nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
||||||
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
||||||
|
|
||||||
|
const selectedNode = deterministicSelection?.nodeId
|
||||||
|
? updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === deterministicSelection.nodeId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const formulatedQuestion = selectedNode
|
||||||
|
? formulateQuestion({
|
||||||
|
node: selectedNode,
|
||||||
|
graph: updatedSituationGraph,
|
||||||
|
context: {
|
||||||
|
resolvedValues: validatedProposal.updatedNodes
|
||||||
|
.map((update) => update.newValue)
|
||||||
|
.filter(
|
||||||
|
(value) => typeof value === "string" && value.trim().length > 0,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const finalSelectedQuestion = deterministicSelection
|
||||||
|
? {
|
||||||
|
nodeId: deterministicSelection.nodeId,
|
||||||
|
question:
|
||||||
|
formulatedQuestion?.question || deterministicSelection.question,
|
||||||
|
reason: formulatedQuestion?.reason || deterministicSelection.reason,
|
||||||
|
strategy: formulatedQuestion?.strategy,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const resultGraphValidation = situationGraphSchema.safeParse(
|
const resultGraphValidation = situationGraphSchema.safeParse(
|
||||||
updatedSituationGraph,
|
updatedSituationGraph,
|
||||||
);
|
);
|
||||||
@@ -430,6 +708,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
|||||||
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
||||||
previousActiveUnknownNodeId,
|
previousActiveUnknownNodeId,
|
||||||
newActiveUnknownNodeId,
|
newActiveUnknownNodeId,
|
||||||
|
selectedQuestion: finalSelectedQuestion,
|
||||||
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
||||||
graphReferenceValidation: resultReferenceValidation,
|
graphReferenceValidation: resultReferenceValidation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
stage: "update_applied",
|
stage: "update_applied",
|
||||||
updatedSituationGraph: applicationResult.updatedSituationGraph,
|
updatedSituationGraph: applicationResult.updatedSituationGraph,
|
||||||
proposal: applicationResult.graphUpdate,
|
proposal: applicationResult.graphUpdate,
|
||||||
|
selectedQuestion: applicationResult.selectedQuestion,
|
||||||
affectedNodeIds: applicationResult.affectedNodeIds,
|
affectedNodeIds: applicationResult.affectedNodeIds,
|
||||||
resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds,
|
resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds,
|
||||||
previousActiveUnknownNodeId:
|
previousActiveUnknownNodeId:
|
||||||
|
|||||||
+31
-12
@@ -68,6 +68,7 @@ The JSON object must contain exactly these top-level fields:
|
|||||||
- removedEdgeIds
|
- removedEdgeIds
|
||||||
- resolvedUnknownNodeIds
|
- resolvedUnknownNodeIds
|
||||||
- affectedNodeIds
|
- affectedNodeIds
|
||||||
|
- selectedQuestion
|
||||||
|
|
||||||
## Required Shapes
|
## Required Shapes
|
||||||
- addedNodes: array of nodes using these exact keys:
|
- addedNodes: array of nodes using these exact keys:
|
||||||
@@ -79,27 +80,45 @@ The JSON object must contain exactly these top-level fields:
|
|||||||
- removedEdgeIds: array of strings
|
- removedEdgeIds: array of strings
|
||||||
- resolvedUnknownNodeIds: array of strings
|
- resolvedUnknownNodeIds: array of strings
|
||||||
- affectedNodeIds: array of strings
|
- affectedNodeIds: array of strings
|
||||||
|
- selectedQuestion: either null or an object using these exact keys:
|
||||||
|
nodeId, question, reason
|
||||||
|
|
||||||
## Proposal Rules
|
## Proposal Rules
|
||||||
1. Propose changes only. Never return a replacement graph.
|
1. Propose changes only. Never return a replacement graph.
|
||||||
2. Preserve unrelated nodes and edges by omitting them from the proposal.
|
2. Preserve unrelated nodes and edges by omitting them from the proposal.
|
||||||
3. Reference existing node IDs when updating an existing concept.
|
3. Reference existing node IDs when updating an existing concept.
|
||||||
4. Use addedNodes only for genuinely new concepts.
|
4. Use addedNodes only for genuinely new concepts.
|
||||||
5. Resolve the active unknown when the answer supports it.
|
5. Resolve the answered unknown first when the answer supports it.
|
||||||
6. Propagate only through explicit dependencies or relationships already present in the graph.
|
6. Then inspect the answer for newly introduced consequential uncertainty.
|
||||||
7. Do not invent evidence.
|
7. Add new unknown nodes only when the answer introduces a new decision, claim, object, measure, dependency, or unresolved term directly relevant to the case.
|
||||||
8. Do not create unsupported causal edges.
|
8. Add at most 3 new unknown nodes.
|
||||||
9. Do not ask more than one next question. In this contract you are not returning any next-question field at all.
|
9. Every new unknown must be directly traceable to the user's answer and its description must state why that uncertainty matters.
|
||||||
10. Use empty arrays when there are no changes in a category.
|
9a. In the description of every new unknown, explicitly include a short why-it-matters clause using wording such as because, so that, needed to decide, or matters because.
|
||||||
11. Never return null array entries.
|
10. Do not add broad generic discovery questions.
|
||||||
12. Never use unknown enum values.
|
11. Do not add duplicate unknowns.
|
||||||
13. Do not change existing IDs.
|
12. Do not expand unrelated branches.
|
||||||
14. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
|
13. Propagate only through explicit dependencies or relationships already present in the graph, except for the minimal new edges needed to connect validated new unknowns to the relevant answer-derived decision or context node.
|
||||||
|
13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer.
|
||||||
|
14. Do not invent evidence.
|
||||||
|
15. Do not create unsupported causal edges.
|
||||||
|
16. If consequential unresolved unknowns exist, selectedQuestion may identify one valid candidate unknown, but the engine will deterministically choose final priority after validation.
|
||||||
|
17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes.
|
||||||
|
18. selectedQuestion.question must be one narrow non-compound question about that one unknown.
|
||||||
|
19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology.
|
||||||
|
20. Return selectedQuestion as null only when no consequential unresolved unknown remains.
|
||||||
|
21. Use empty arrays when there are no changes in a category.
|
||||||
|
22. Never return null array entries.
|
||||||
|
23. Never use unknown enum values.
|
||||||
|
24. Do not change existing IDs.
|
||||||
|
25. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
|
||||||
|
|
||||||
## Additional Guidance
|
## Additional Guidance
|
||||||
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
|
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
|
||||||
- When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node rather than creating only a parallel observation.
|
- When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node rather than creating only a parallel observation.
|
||||||
- If a new metric or observation is necessary, add the smallest set of nodes and edges needed.
|
- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns.
|
||||||
|
- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer.
|
||||||
|
- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case.
|
||||||
|
- Treat selectedQuestion as a candidate only; the engine will apply deterministic information-value scoring after validation.
|
||||||
- If the answer does not justify a change, return empty arrays for every category.
|
- If the answer does not justify a change, return empty arrays for every category.
|
||||||
|
|
||||||
## Example Constraint Reminder
|
## Example Constraint Reminder
|
||||||
@@ -108,7 +127,7 @@ ${formatExampleAnswerBlock()}
|
|||||||
## Output Contract Reminder
|
## Output Contract Reminder
|
||||||
Return one JSON object only, with exact field names and exact enum values.
|
Return one JSON object only, with exact field names and exact enum values.
|
||||||
Never include a full graph.
|
Never include a full graph.
|
||||||
Never include a nextQuestion field.
|
Never include any field other than the contract fields above.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
function normaliseText(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentenceCase(value) {
|
||||||
|
const trimmed = String(value || "").trim();
|
||||||
|
if (!trimmed) return "this uncertainty";
|
||||||
|
return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeMap(graph) {
|
||||||
|
return new Map((graph?.nodes || []).map((node) => [node.id, node]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRelatedNodes(node, graph) {
|
||||||
|
if (!node || !graph) return [];
|
||||||
|
|
||||||
|
const nodesById = buildNodeMap(graph);
|
||||||
|
const relatedIds = new Set([
|
||||||
|
...(node.dependsOn || []),
|
||||||
|
...(node.affects || []),
|
||||||
|
...(node.childIds || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (node.parentId) {
|
||||||
|
relatedIds.add(node.parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges || []) {
|
||||||
|
if (edge.fromNodeId === node.id) {
|
||||||
|
relatedIds.add(edge.toNodeId);
|
||||||
|
}
|
||||||
|
if (edge.toNodeId === node.id) {
|
||||||
|
relatedIds.add(edge.fromNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...relatedIds].map((nodeId) => nodesById.get(nodeId)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectResolvedContextValues(graph) {
|
||||||
|
const resolvedSet = new Set(graph?.resolvedNodeIds || []);
|
||||||
|
|
||||||
|
return (graph?.nodes || [])
|
||||||
|
.filter((node) => resolvedSet.has(node.id))
|
||||||
|
.map((node) => node.value)
|
||||||
|
.filter((value) => typeof value === "string" && value.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMeaning(node) {
|
||||||
|
const raw = `${node?.label || ""} ${node?.description || ""}`.trim();
|
||||||
|
let meaning = String(
|
||||||
|
node?.label || node?.description || "this uncertainty",
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const lowered = normaliseText(raw);
|
||||||
|
if (
|
||||||
|
/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(lowered)
|
||||||
|
) {
|
||||||
|
return "the relevant customer, user, or value recipient";
|
||||||
|
}
|
||||||
|
|
||||||
|
meaning = meaning
|
||||||
|
.replace(/^uncertainty regarding\s+/i, "")
|
||||||
|
.replace(/^uncertainty about\s+/i, "")
|
||||||
|
.replace(/^lack of\s+/i, "")
|
||||||
|
.replace(/^unknown\s+/i, "")
|
||||||
|
.replace(/^whether\s+/i, "")
|
||||||
|
.replace(/^the\s+/, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!meaning) {
|
||||||
|
return "this uncertainty";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentenceCase(meaning);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractActionPhrase(texts) {
|
||||||
|
for (const text of texts) {
|
||||||
|
const value = String(text || "").trim();
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
const matches = [
|
||||||
|
value.match(/\b(?:whether|deciding|decision) to\s+([^.,;:]+)/i),
|
||||||
|
value.match(/\b(?:justify|continuing|proceeding with)\s+([^.,;:]+)/i),
|
||||||
|
value.match(
|
||||||
|
/\b(build|launch|adopt|buy|continue|proceed|invest in|fund)\s+([^.,;:]+)/i,
|
||||||
|
),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const match = matches[0];
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const phrase = (match[1] || `${match[1] || ""} ${match[2] || ""}`)
|
||||||
|
.replace(/^to\s+/i, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (phrase) {
|
||||||
|
return phrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGerundPhrase(phrase) {
|
||||||
|
const trimmed = String(phrase || "").trim();
|
||||||
|
if (!trimmed) return "proceeding with this decision";
|
||||||
|
|
||||||
|
const [firstWord, ...rest] = trimmed.split(/\s+/);
|
||||||
|
const lower = firstWord.toLowerCase();
|
||||||
|
const irregular = {
|
||||||
|
be: "being",
|
||||||
|
build: "building",
|
||||||
|
continue: "continuing",
|
||||||
|
decide: "deciding",
|
||||||
|
proceed: "proceeding",
|
||||||
|
launch: "launching",
|
||||||
|
invest: "investing",
|
||||||
|
fund: "funding",
|
||||||
|
buy: "buying",
|
||||||
|
pay: "paying",
|
||||||
|
adopt: "adopting",
|
||||||
|
};
|
||||||
|
|
||||||
|
let gerund = irregular[lower];
|
||||||
|
if (!gerund) {
|
||||||
|
if (lower.endsWith("e") && !lower.endsWith("ee")) {
|
||||||
|
gerund = `${lower.slice(0, -1)}ing`;
|
||||||
|
} else {
|
||||||
|
gerund = `${lower}ing`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [gerund, ...rest].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
|
||||||
|
const text = normaliseText(combinedText);
|
||||||
|
const nodeText = normaliseText(
|
||||||
|
`${node?.label || ""} ${node?.description || ""}`,
|
||||||
|
);
|
||||||
|
const relatedText = normaliseText(
|
||||||
|
relatedNodes
|
||||||
|
.map((relatedNode) => `${relatedNode.label} ${relatedNode.description}`)
|
||||||
|
.join(" "),
|
||||||
|
);
|
||||||
|
const resolvedValues = collectResolvedContextValues(graph);
|
||||||
|
const actionPhrase = extractActionPhrase([
|
||||||
|
...resolvedValues,
|
||||||
|
...relatedNodes.map((relatedNode) => relatedNode.value),
|
||||||
|
...relatedNodes.map((relatedNode) => relatedNode.label),
|
||||||
|
...relatedNodes.map((relatedNode) => relatedNode.description),
|
||||||
|
graph?.centralStatement,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const decisionContext =
|
||||||
|
/\b(decision|whether to|build|launch|continue|proceed|invest|allocate)\b/.test(
|
||||||
|
`${text} ${relatedText} ${resolvedValues.join(" ")}`,
|
||||||
|
) || Boolean(actionPhrase);
|
||||||
|
|
||||||
|
const hasConstraintLanguage =
|
||||||
|
/\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const hasPrimaryConstraintLanguage =
|
||||||
|
/\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
|
||||||
|
nodeText,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(text)) {
|
||||||
|
return { strategy: "actor/customer", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBaselineLanguage =
|
||||||
|
/\b(before|previous|baseline|prior|comparable state)\b/.test(text);
|
||||||
|
const hasPrimaryBaselineLanguage =
|
||||||
|
/\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText);
|
||||||
|
|
||||||
|
if (hasBaselineLanguage && hasPrimaryBaselineLanguage) {
|
||||||
|
return { strategy: "baseline", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\b(when|timing|timeline|duration|sequence|milestone)\b/.test(text)) {
|
||||||
|
return { strategy: "transition/timing", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDefinitionLanguage =
|
||||||
|
/\b(define|definition|meaning|term|terminology)\b/.test(text);
|
||||||
|
const hasPrimaryDefinitionLanguage =
|
||||||
|
/\b(define|definition|meaning|term|terminology)\b/.test(nodeText);
|
||||||
|
const hasCriteriaLanguage =
|
||||||
|
/\b(success criteria|success threshold|threshold|decision criteria|criterion|justify|sufficient)\b/.test(
|
||||||
|
nodeText,
|
||||||
|
);
|
||||||
|
const hasDecisionValueLanguage =
|
||||||
|
decisionContext &&
|
||||||
|
/\b(value|commercial value|commercial viability|viability|justify|sufficient|success|threshold|criterion)\b/.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const hasMeasurementLanguage =
|
||||||
|
/\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDecisionValueLanguage && hasMeasurementLanguage) {
|
||||||
|
return { strategy: "measurement", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPrimaryDefinitionLanguage) {
|
||||||
|
return { strategy: "definition", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDecisionValueLanguage || hasCriteriaLanguage) {
|
||||||
|
return { strategy: "decision criterion", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConstraintLanguage && hasPrimaryConstraintLanguage) {
|
||||||
|
return { strategy: "constraint", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDefinitionLanguage) {
|
||||||
|
return { strategy: "definition", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBaselineLanguage) {
|
||||||
|
return { strategy: "baseline", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConstraintLanguage) {
|
||||||
|
return { strategy: "constraint", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text)) {
|
||||||
|
return { strategy: "evidence", meaning, actionPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMeasurementLanguage) {
|
||||||
|
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 }) {
|
||||||
|
switch (strategy) {
|
||||||
|
case "decision criterion":
|
||||||
|
return actionPhrase
|
||||||
|
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(actionPhrase)}?`
|
||||||
|
: "What outcome would be sufficient to justify this decision?";
|
||||||
|
case "definition":
|
||||||
|
return `What does ${meaning} mean in this situation?`;
|
||||||
|
case "evidence":
|
||||||
|
return `What evidence would show whether ${meaning} is true?`;
|
||||||
|
case "baseline":
|
||||||
|
return `What was the comparable state before ${meaning}?`;
|
||||||
|
case "actor/customer":
|
||||||
|
return "Who experiences the problem or receives the value in this situation?";
|
||||||
|
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:
|
||||||
|
return `What specific fact would resolve whether ${meaning} is true?`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompoundQuestion(question) {
|
||||||
|
const trimmed = String(question || "").trim();
|
||||||
|
const questionMarks = (trimmed.match(/\?/g) || []).length;
|
||||||
|
|
||||||
|
if (questionMarks !== 1) return true;
|
||||||
|
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
|
||||||
|
if (/\b(and|or)\b[^?]{0,80}\?/i.test(trimmed) && /,/.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFormulatedQuestion(question, meaning) {
|
||||||
|
const trimmed = String(question || "").trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
const meaningWords = normaliseText(meaning)
|
||||||
|
.split(" ")
|
||||||
|
.filter((word) => word.length > 3);
|
||||||
|
const overlappingWord = meaningWords.find((word) => lower.includes(word));
|
||||||
|
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if ((trimmed.match(/\?/g) || []).length !== 1) return false;
|
||||||
|
if (isCompoundQuestion(trimmed)) return false;
|
||||||
|
if (/^what is\s+/i.test(trimmed)) return false;
|
||||||
|
if (/^how should uncertainty regarding\b/i.test(trimmed)) return false;
|
||||||
|
if (/^what would resolve uncertainty regarding\b/i.test(trimmed))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
/\bprice|pricing|price point\b/i.test(trimmed) &&
|
||||||
|
!/\bprice\b/i.test(meaning)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!overlappingWord &&
|
||||||
|
!/\b(decision|evidence|constraint|customer|value|outcome)\b/i.test(trimmed)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formulateQuestion({ 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 detected = detectStrategy({
|
||||||
|
node,
|
||||||
|
graph,
|
||||||
|
relatedNodes,
|
||||||
|
combinedText,
|
||||||
|
meaning,
|
||||||
|
});
|
||||||
|
|
||||||
|
let question = buildQuestion(detected);
|
||||||
|
|
||||||
|
if (!validateFormulatedQuestion(question, meaning)) {
|
||||||
|
question = `What evidence would resolve whether ${meaning} is true?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
question,
|
||||||
|
reason: `Formulated from graph context using the ${detected.strategy} strategy.`,
|
||||||
|
strategy: detected.strategy,
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-2
@@ -101,11 +101,22 @@ const graphUpdateNodeChangeSchema = z.object({
|
|||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
||||||
newStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
newStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
||||||
previousValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
previousValue: z
|
||||||
|
.union([z.string(), z.number(), z.null()])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||||
reason: z.string().min(1),
|
reason: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const selectedQuestionSchema = z
|
||||||
|
.object({
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
question: z.string().min(1),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const graphUpdateSchema = z.object({
|
export const graphUpdateSchema = z.object({
|
||||||
addedNodes: z.array(situationNodeSchema).default([]),
|
addedNodes: z.array(situationNodeSchema).default([]),
|
||||||
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
|
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
|
||||||
@@ -113,6 +124,7 @@ export const graphUpdateSchema = z.object({
|
|||||||
removedEdgeIds: z.array(z.string()).default([]),
|
removedEdgeIds: z.array(z.string()).default([]),
|
||||||
resolvedUnknownNodeIds: z.array(z.string()).default([]),
|
resolvedUnknownNodeIds: z.array(z.string()).default([]),
|
||||||
affectedNodeIds: z.array(z.string()).default([]),
|
affectedNodeIds: z.array(z.string()).default([]),
|
||||||
|
selectedQuestion: selectedQuestionSchema.nullable().default(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
|
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
|
||||||
@@ -169,7 +181,9 @@ export function makeNode(opts) {
|
|||||||
/** Create a minimal valid edge — used in tests and fixtures */
|
/** Create a minimal valid edge — used in tests and fixtures */
|
||||||
export function makeEdge(opts) {
|
export function makeEdge(opts) {
|
||||||
return situationEdgeSchema.parse({
|
return situationEdgeSchema.parse({
|
||||||
id: opts.id || "e" + opts.fromNodeId.slice(0,3) + "-" + opts.toNodeId.slice(0,3),
|
id:
|
||||||
|
opts.id ||
|
||||||
|
"e" + opts.fromNodeId.slice(0, 3) + "-" + opts.toNodeId.slice(0, 3),
|
||||||
fromNodeId: opts.fromNodeId,
|
fromNodeId: opts.fromNodeId,
|
||||||
toNodeId: opts.toNodeId,
|
toNodeId: opts.toNodeId,
|
||||||
relationship: opts.relationship ?? "supports",
|
relationship: opts.relationship ?? "supports",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const TOP_LEVEL_ARRAY_FIELDS = [
|
|||||||
"affectedNodeIds",
|
"affectedNodeIds",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TOP_LEVEL_NULLABLE_FIELDS = ["selectedQuestion"];
|
||||||
|
|
||||||
function cloneJsonSafe(value) {
|
function cloneJsonSafe(value) {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
return JSON.parse(JSON.stringify(value));
|
return JSON.parse(JSON.stringify(value));
|
||||||
@@ -79,6 +81,22 @@ function fillMissingOptionalArrays(proposal, normalisationsApplied) {
|
|||||||
return proposal;
|
return proposal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillMissingNullableFields(proposal, normalisationsApplied) {
|
||||||
|
if (!proposal || typeof proposal !== "object") return proposal;
|
||||||
|
|
||||||
|
for (const field of TOP_LEVEL_NULLABLE_FIELDS) {
|
||||||
|
if (!(field in proposal)) {
|
||||||
|
proposal[field] = null;
|
||||||
|
normalisationsApplied.push({
|
||||||
|
path: [field],
|
||||||
|
change: "Filled missing optional nullable field with null",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseGraphUpdateProposal(rawResponse) {
|
export function parseGraphUpdateProposal(rawResponse) {
|
||||||
const raw = rawResponse;
|
const raw = rawResponse;
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -111,6 +129,7 @@ export function parseGraphUpdateProposal(rawResponse) {
|
|||||||
let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied);
|
let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied);
|
||||||
normalised = applyKnownEnumAliases(normalised, normalisationsApplied);
|
normalised = applyKnownEnumAliases(normalised, normalisationsApplied);
|
||||||
normalised = fillMissingOptionalArrays(normalised, normalisationsApplied);
|
normalised = fillMissingOptionalArrays(normalised, normalisationsApplied);
|
||||||
|
normalised = fillMissingNullableFields(normalised, normalisationsApplied);
|
||||||
|
|
||||||
const parsedProposal = graphUpdateSchema.safeParse(normalised);
|
const parsedProposal = graphUpdateSchema.safeParse(normalised);
|
||||||
|
|
||||||
|
|||||||
+234
-29
@@ -5,7 +5,171 @@
|
|||||||
* and these utilities apply them safely.
|
* and these utilities apply them safely.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { situationNodeSchema, situationEdgeSchema, situationGraphSchema } from "./schema.js";
|
import {
|
||||||
|
situationNodeSchema,
|
||||||
|
situationEdgeSchema,
|
||||||
|
situationGraphSchema,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
function normaliseText(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNodeText(node) {
|
||||||
|
return `${node?.label || ""} ${node?.description || ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function countIncomingUnknownDependencies(graph, nodeId, resolvedNodeIds) {
|
||||||
|
const resolvedSet = new Set(resolvedNodeIds || []);
|
||||||
|
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||||
|
const incoming = new Set();
|
||||||
|
|
||||||
|
for (const dependencyId of nodesById.get(nodeId)?.dependsOn || []) {
|
||||||
|
const dependencyNode = nodesById.get(dependencyId);
|
||||||
|
if (dependencyNode?.kind === "unknown" && !resolvedSet.has(dependencyId)) {
|
||||||
|
incoming.add(dependencyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.toNodeId !== nodeId) continue;
|
||||||
|
const dependencyNode = nodesById.get(edge.fromNodeId);
|
||||||
|
if (
|
||||||
|
dependencyNode?.kind === "unknown" &&
|
||||||
|
!resolvedSet.has(edge.fromNodeId)
|
||||||
|
) {
|
||||||
|
incoming.add(edge.fromNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyUnknownPriority(text) {
|
||||||
|
const normalised = normaliseText(text);
|
||||||
|
|
||||||
|
const matches = {
|
||||||
|
objective:
|
||||||
|
/\b(objective|goal|outcome|value|problem|job to be done|benefit|commercial value)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
actor:
|
||||||
|
/\b(customer|user|buyer|actor|stakeholder|audience|recipient)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
criteria:
|
||||||
|
/\b(success criteria|success threshold|threshold|decision criteria|criterion|justify|sufficient)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
measure:
|
||||||
|
/\b(metric|measure|measurable|roi|demand|evidence|signal|proof)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
terminology: /\b(define|definition|meaning|means|term|terminology)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
constraint:
|
||||||
|
/\b(constraint|limit|budget|deadline|requirement|regulation)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
pricing: /\b(price|pricing|price point|subscription|charge|pay for)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
implementation:
|
||||||
|
/\b(implementation|build approach|architecture|stack|feature|technical design)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
optimisation:
|
||||||
|
/\b(optimisation|optimi[sz]ation|improve|efficiency|performance|scale)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
speculative:
|
||||||
|
/\b(maybe|possible|optional|future branch|nice to have|slogan|colour|color|ui)\b/.test(
|
||||||
|
normalised,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
||||||
|
const text = collectNodeText(node);
|
||||||
|
const matches = classifyUnknownPriority(text);
|
||||||
|
const downstreamCount = findDependentNodes(graph, node.id).length;
|
||||||
|
const unresolvedParentUnknownCount = countIncomingUnknownDependencies(
|
||||||
|
graph,
|
||||||
|
node.id,
|
||||||
|
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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeId: node.id,
|
||||||
|
label: node.label,
|
||||||
|
score,
|
||||||
|
downstreamCount,
|
||||||
|
unresolvedParentUnknownCount,
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeterministicQuestionForUnknown(node) {
|
||||||
|
const text = normaliseText(collectNodeText(node));
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\b(success criteria|success threshold|threshold|decision criteria|criterion)\b/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `What outcome would define success for ${node.label}?`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\b(customer|user|buyer|actor|stakeholder|audience|recipient)\b/.test(text)
|
||||||
|
) {
|
||||||
|
return `Who is the key actor or customer for ${node.label}?`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\b(define|definition|meaning|means|term|terminology|value)\b/.test(text)
|
||||||
|
) {
|
||||||
|
return `How should ${node.label} be defined for this decision?`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\b(metric|measure|measurable|roi|demand|evidence|signal|proof)\b/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `What evidence or measure would resolve ${node.label}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `What would resolve ${node.label}?`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Validate that all edge references point to existing nodes ──
|
// ── Validate that all edge references point to existing nodes ──
|
||||||
|
|
||||||
@@ -15,16 +179,22 @@ export function validateGraphReferences(graph) {
|
|||||||
|
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
if (node.parentId !== null && !nodeIds.has(node.parentId)) {
|
if (node.parentId !== null && !nodeIds.has(node.parentId)) {
|
||||||
errors.push(`Node "${node.id}" references parentId "${node.parentId}" which does not exist`);
|
errors.push(
|
||||||
|
`Node "${node.id}" references parentId "${node.parentId}" which does not exist`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const cid of node.childIds) {
|
for (const cid of node.childIds) {
|
||||||
if (!nodeIds.has(cid)) {
|
if (!nodeIds.has(cid)) {
|
||||||
errors.push(`Node "${node.id}" references childIds "${cid}" which does not exist`);
|
errors.push(
|
||||||
|
`Node "${node.id}" references childIds "${cid}" which does not exist`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const dep of node.dependsOn) {
|
for (const dep of node.dependsOn) {
|
||||||
if (!nodeIds.has(dep)) {
|
if (!nodeIds.has(dep)) {
|
||||||
errors.push(`Node "${node.id}" depends on "${dep}" which does not exist`);
|
errors.push(
|
||||||
|
`Node "${node.id}" depends on "${dep}" which does not exist`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const aff of node.affects) {
|
for (const aff of node.affects) {
|
||||||
@@ -36,10 +206,14 @@ export function validateGraphReferences(graph) {
|
|||||||
|
|
||||||
for (const edge of graph.edges) {
|
for (const edge of graph.edges) {
|
||||||
if (!nodeIds.has(edge.fromNodeId)) {
|
if (!nodeIds.has(edge.fromNodeId)) {
|
||||||
errors.push(`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`);
|
errors.push(
|
||||||
|
`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!nodeIds.has(edge.toNodeId)) {
|
if (!nodeIds.has(edge.toNodeId)) {
|
||||||
errors.push(`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`);
|
errors.push(
|
||||||
|
`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +254,12 @@ export function detectDuplicateEdges(edges) {
|
|||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
|
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
duplicates.push({ edgeId: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, relationship: edge.relationship });
|
duplicates.push({
|
||||||
|
edgeId: edge.id,
|
||||||
|
fromNodeId: edge.fromNodeId,
|
||||||
|
toNodeId: edge.toNodeId,
|
||||||
|
relationship: edge.relationship,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
@@ -91,7 +270,9 @@ export function detectDuplicateEdges(edges) {
|
|||||||
// ── Find all nodes that depend on a given node (transitive) ──
|
// ── Find all nodes that depend on a given node (transitive) ──
|
||||||
|
|
||||||
export function findDependentNodes(graph, nodeId) {
|
export function findDependentNodes(graph, nodeId) {
|
||||||
const direct = graph.nodes.filter((n) => n.dependsOn.includes(nodeId)).map((n) => n.id);
|
const direct = graph.nodes
|
||||||
|
.filter((n) => n.dependsOn.includes(nodeId))
|
||||||
|
.map((n) => n.id);
|
||||||
const affected = new Set(direct);
|
const affected = new Set(direct);
|
||||||
|
|
||||||
// Also propagate through edges where the relationship is depends_on
|
// Also propagate through edges where the relationship is depends_on
|
||||||
@@ -124,10 +305,14 @@ export function findDependentNodes(graph, nodeId) {
|
|||||||
export function findAffectedNodes(graph, nodeId) {
|
export function findAffectedNodes(graph, nodeId) {
|
||||||
// Direct effects: two sources
|
// Direct effects: two sources
|
||||||
// 1. Nodes that depend on this node (they list it in their dependsOn)
|
// 1. Nodes that depend on this node (they list it in their dependsOn)
|
||||||
const directFromDepends = graph.nodes.filter((n) => n.id !== nodeId && n.dependsOn.includes(nodeId)).map((n) => n.id);
|
const directFromDepends = graph.nodes
|
||||||
|
.filter((n) => n.id !== nodeId && n.dependsOn.includes(nodeId))
|
||||||
|
.map((n) => n.id);
|
||||||
|
|
||||||
// 2. Targets of the node's affects relationships (this node directly affects them)
|
// 2. Targets of the node's affects relationships (this node directly affects them)
|
||||||
const myAffectedTargets = new Set(graph.nodes.find((n) => n.id === nodeId)?.affects || []);
|
const myAffectedTargets = new Set(
|
||||||
|
graph.nodes.find((n) => n.id === nodeId)?.affects || [],
|
||||||
|
);
|
||||||
|
|
||||||
// Merge: also add edge targets where this node is the source
|
// Merge: also add edge targets where this node is the source
|
||||||
for (const edge of graph.edges) {
|
for (const edge of graph.edges) {
|
||||||
@@ -147,7 +332,11 @@ export function findAffectedNodes(graph, nodeId) {
|
|||||||
if (!current || !affected.has(current)) continue;
|
if (!current || !affected.has(current)) continue;
|
||||||
|
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
if (node.id !== nodeId && !affected.has(node.id) && (node.dependsOn.includes(current) || node.affects.includes(current))) {
|
if (
|
||||||
|
node.id !== nodeId &&
|
||||||
|
!affected.has(node.id) &&
|
||||||
|
(node.dependsOn.includes(current) || node.affects.includes(current))
|
||||||
|
) {
|
||||||
affected.add(node.id);
|
affected.add(node.id);
|
||||||
queue.push(node.id);
|
queue.push(node.id);
|
||||||
}
|
}
|
||||||
@@ -184,26 +373,37 @@ export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
|
|||||||
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
||||||
// Skip already resolved nodes
|
// Skip already resolved nodes
|
||||||
const unresolved = graph.nodes.filter(
|
const unresolved = graph.nodes.filter(
|
||||||
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
|
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unresolved.length === 0) return null;
|
if (unresolved.length === 0) return null;
|
||||||
|
|
||||||
// Prioritise: critical unknowns first, then those that are depended upon most
|
const scoredCandidates = unresolved.map((node) => ({
|
||||||
const dependencyCount = unresolved.map((n) => {
|
node,
|
||||||
const deps = findDependentNodes(graph, n.id).length;
|
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
|
||||||
const importanceOrder = { critical: 3, important: 2, supporting: 1, incidental: 0 };
|
}));
|
||||||
const impScore = importanceOrder[n.confidence] || 0;
|
|
||||||
return { node: n, score: deps * 2 + impScore };
|
scoredCandidates.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.node.label.localeCompare(b.node.label);
|
||||||
});
|
});
|
||||||
|
|
||||||
dependencyCount.sort((a, b) => b.score - a.score);
|
const best = scoredCandidates[0];
|
||||||
|
|
||||||
// Return the highest-scoring unresolved unknown
|
|
||||||
const best = dependencyCount[0];
|
|
||||||
if (!best) return null;
|
if (!best) return null;
|
||||||
|
|
||||||
return { nodeId: best.node.id, label: best.node.label, score: best.score };
|
return {
|
||||||
|
nodeId: best.node.id,
|
||||||
|
label: best.node.label,
|
||||||
|
score: best.score,
|
||||||
|
question: buildDeterministicQuestionForUnknown(best.node),
|
||||||
|
reason: `Selected for highest information value (score ${best.score}) with ${best.downstreamCount} downstream dependency node(s) and ${best.unresolvedParentUnknownCount} unresolved prerequisite unknown(s).`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Apply a graph update deterministically ──
|
// ── Apply a graph update deterministically ──
|
||||||
@@ -232,10 +432,14 @@ export function applyGraphUpdate(graph, update) {
|
|||||||
// Validate added edges reference existing or new nodes
|
// Validate added edges reference existing or new nodes
|
||||||
for (const edge of update.addedEdges) {
|
for (const edge of update.addedEdges) {
|
||||||
if (!allNodeIds.has(edge.fromNodeId)) {
|
if (!allNodeIds.has(edge.fromNodeId)) {
|
||||||
errors.push(`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`);
|
errors.push(
|
||||||
|
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!allNodeIds.has(edge.toNodeId)) {
|
if (!allNodeIds.has(edge.toNodeId)) {
|
||||||
errors.push(`Added edge references non-existent toNodeId: "${edge.toNodeId}"`);
|
errors.push(
|
||||||
|
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +489,9 @@ export function applyGraphUpdate(graph, update) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add resolved node IDs
|
// Add resolved node IDs
|
||||||
const newResolved = [...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds])];
|
const newResolved = [
|
||||||
|
...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds]),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -320,10 +526,10 @@ export function validateGraphUpdate(graph, update) {
|
|||||||
|
|
||||||
// Reject updates with no meaningful change
|
// Reject updates with no meaningful change
|
||||||
const statusChanged = update.updatedNodes.some(
|
const statusChanged = update.updatedNodes.some(
|
||||||
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus
|
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus,
|
||||||
);
|
);
|
||||||
const valueChanged = update.updatedNodes.some(
|
const valueChanged = update.updatedNodes.some(
|
||||||
(u) => u.previousValue !== null && u.newValue !== u.previousValue
|
(u) => u.previousValue !== null && u.newValue !== u.previousValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasMeaningfulChange =
|
const hasMeaningfulChange =
|
||||||
@@ -345,4 +551,3 @@ export function validateGraphUpdate(graph, update) {
|
|||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const BASE_URL =
|
||||||
|
process.env.CONFIDENCE_ENGINE_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const OUTPUT_DIR = "tests-results/commercial-value-update";
|
||||||
|
|
||||||
|
const scenario = "I think therefore I am";
|
||||||
|
const answer =
|
||||||
|
"Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.";
|
||||||
|
|
||||||
|
async function postJson(path, body) {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return { status: response.status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printLine(label, value) {
|
||||||
|
const rendered = value === undefined ? null : value;
|
||||||
|
console.log(`${label}: ${JSON.stringify(rendered)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const startResult = await postJson("/api/cases/start", { scenario });
|
||||||
|
await writeFile(
|
||||||
|
`${OUTPUT_DIR}/start-response.json`,
|
||||||
|
JSON.stringify(startResult, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedQuestion = startResult.json?.selectedQuestion?.question || null;
|
||||||
|
|
||||||
|
let updateResult = {
|
||||||
|
status: null,
|
||||||
|
json: {
|
||||||
|
success: false,
|
||||||
|
stage: "request_construction",
|
||||||
|
errors: ["Missing selected question from start response"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startResult.json?.success && selectedQuestion) {
|
||||||
|
updateResult = await postJson("/api/cases/update", {
|
||||||
|
situationGraph: startResult.json.situationGraph,
|
||||||
|
previousQuestion: selectedQuestion,
|
||||||
|
answer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
`${OUTPUT_DIR}/update-response.json`,
|
||||||
|
JSON.stringify(updateResult, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
printLine("start success", startResult.json?.success ?? false);
|
||||||
|
printLine("update success", updateResult.json?.success ?? false);
|
||||||
|
printLine("update stage", updateResult.json?.stage ?? null);
|
||||||
|
printLine(
|
||||||
|
"proposal added nodes",
|
||||||
|
updateResult.json?.proposal?.addedNodes?.map((node) => node.id) ?? null,
|
||||||
|
);
|
||||||
|
printLine(
|
||||||
|
"proposal added edges",
|
||||||
|
updateResult.json?.proposal?.addedEdges?.map((edge) => ({
|
||||||
|
id: edge.id,
|
||||||
|
fromNodeId: edge.fromNodeId,
|
||||||
|
toNodeId: edge.toNodeId,
|
||||||
|
relationship: edge.relationship,
|
||||||
|
})) ?? null,
|
||||||
|
);
|
||||||
|
printLine(
|
||||||
|
"proposal resolved unknown IDs",
|
||||||
|
updateResult.json?.proposal?.resolvedUnknownNodeIds ??
|
||||||
|
updateResult.json?.resolvedUnknownNodeIds ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
printLine(
|
||||||
|
"errors",
|
||||||
|
updateResult.json?.errors ??
|
||||||
|
updateResult.json?.proposalErrors ??
|
||||||
|
updateResult.json?.graphValidationErrors ??
|
||||||
|
updateResult.json?.validationErrors ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -25,7 +25,9 @@ function makeSuccessResult() {
|
|||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n1"],
|
resolvedUnknownNodeIds: ["n1"],
|
||||||
affectedNodeIds: ["n1"],
|
affectedNodeIds: ["n1"],
|
||||||
|
selectedQuestion: null,
|
||||||
},
|
},
|
||||||
|
selectedQuestion: null,
|
||||||
affectedNodeIds: ["n1"],
|
affectedNodeIds: ["n1"],
|
||||||
resolvedUnknownNodeIds: ["n1"],
|
resolvedUnknownNodeIds: ["n1"],
|
||||||
previousActiveUnknownNodeId: "n0",
|
previousActiveUnknownNodeId: "n0",
|
||||||
@@ -268,6 +270,7 @@ describe("app/api/cases/update route", () => {
|
|||||||
resolvedUnknownNodeIds: success.resolvedUnknownNodeIds,
|
resolvedUnknownNodeIds: success.resolvedUnknownNodeIds,
|
||||||
previousActiveUnknownNodeId: success.previousActiveUnknownNodeId,
|
previousActiveUnknownNodeId: success.previousActiveUnknownNodeId,
|
||||||
newActiveUnknownNodeId: success.newActiveUnknownNodeId,
|
newActiveUnknownNodeId: success.newActiveUnknownNodeId,
|
||||||
|
selectedQuestion: success.selectedQuestion,
|
||||||
changesApplied: success.changesApplied,
|
changesApplied: success.changesApplied,
|
||||||
diagnostics: success.diagnostics,
|
diagnostics: success.diagnostics,
|
||||||
});
|
});
|
||||||
|
|||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeScenarioGraph({
|
||||||
|
scenario,
|
||||||
|
decisionNode,
|
||||||
|
answeredContextUnknown,
|
||||||
|
foundationalUnknown,
|
||||||
|
consequentialUnknown,
|
||||||
|
downstreamLeaf,
|
||||||
|
}) {
|
||||||
|
const nodes = [
|
||||||
|
decisionNode,
|
||||||
|
answeredContextUnknown,
|
||||||
|
foundationalUnknown,
|
||||||
|
consequentialUnknown,
|
||||||
|
downstreamLeaf,
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
makeEdge({
|
||||||
|
id: `${decisionNode.id}-to-${foundationalUnknown.id}`,
|
||||||
|
fromNodeId: decisionNode.id,
|
||||||
|
toNodeId: foundationalUnknown.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${decisionNode.label} depends on ${foundationalUnknown.label}.`,
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${answeredContextUnknown.id}-to-${consequentialUnknown.id}`,
|
||||||
|
fromNodeId: answeredContextUnknown.id,
|
||||||
|
toNodeId: consequentialUnknown.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${consequentialUnknown.label} was surfaced from resolved context.`,
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${foundationalUnknown.id}-to-${consequentialUnknown.id}`,
|
||||||
|
fromNodeId: foundationalUnknown.id,
|
||||||
|
toNodeId: consequentialUnknown.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${consequentialUnknown.label} depends on ${foundationalUnknown.label}.`,
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${consequentialUnknown.id}-to-${downstreamLeaf.id}`,
|
||||||
|
fromNodeId: consequentialUnknown.id,
|
||||||
|
toNodeId: downstreamLeaf.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${downstreamLeaf.label} depends on ${consequentialUnknown.label}.`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement: scenario,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
activeUnknownNodeId: answeredContextUnknown.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Generalisation fixture graph",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const questionPriorityGeneralisationFixtures = [
|
||||||
|
{
|
||||||
|
key: "hire-engineer",
|
||||||
|
scenario: "Should we hire another engineer?",
|
||||||
|
decisionType: "resourcing decision",
|
||||||
|
acceptableFoundationalUnknownNodeIds: [
|
||||||
|
"hire-success-criteria",
|
||||||
|
"hire-bottleneck",
|
||||||
|
],
|
||||||
|
prohibitedFirstTopics: ["salary", "job advert", "programming language"],
|
||||||
|
acceptableQuestionStrategies: ["decision criterion", "constraint"],
|
||||||
|
notes:
|
||||||
|
"The first question should establish whether more engineering capacity is justified before compensation or implementation details.",
|
||||||
|
graph: makeScenarioGraph({
|
||||||
|
scenario: "Should we hire another engineer?",
|
||||||
|
decisionNode: makeNode({
|
||||||
|
id: "hire-decision",
|
||||||
|
label: "Hiring another engineer decision",
|
||||||
|
description: "Decision about increasing engineering capacity.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "Deciding whether to hire another engineer",
|
||||||
|
childIds: ["hire-success-criteria"],
|
||||||
|
}),
|
||||||
|
answeredContextUnknown: makeNode({
|
||||||
|
id: "hire-delays-known",
|
||||||
|
label: "Delivery delays established",
|
||||||
|
description:
|
||||||
|
"Need to confirm whether recent delivery delays are real because this context determines whether a capacity decision is even relevant.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
value:
|
||||||
|
"The roadmap is slipping because the current team cannot clear the queue.",
|
||||||
|
childIds: ["hire-bottleneck"],
|
||||||
|
}),
|
||||||
|
foundationalUnknown: makeNode({
|
||||||
|
id: "hire-success-criteria",
|
||||||
|
label: "Hiring success threshold",
|
||||||
|
description:
|
||||||
|
"Need the success threshold because the hiring decision depends on what improvement would justify adding headcount.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
parentId: "hire-decision",
|
||||||
|
childIds: ["hire-bottleneck"],
|
||||||
|
}),
|
||||||
|
consequentialUnknown: makeNode({
|
||||||
|
id: "hire-bottleneck",
|
||||||
|
label: "Primary delivery bottleneck",
|
||||||
|
description:
|
||||||
|
"Need the main bottleneck because the team must know whether another engineer would relieve the limiting constraint.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: ["hire-success-criteria"],
|
||||||
|
parentId: "hire-success-criteria",
|
||||||
|
childIds: ["hire-salary"],
|
||||||
|
}),
|
||||||
|
downstreamLeaf: makeNode({
|
||||||
|
id: "hire-salary",
|
||||||
|
label: "Engineer salary budget",
|
||||||
|
description:
|
||||||
|
"Need the salary range because compensation planning comes after the hiring case is established.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["hire-bottleneck"],
|
||||||
|
parentId: "hire-bottleneck",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "replace-vans",
|
||||||
|
scenario: "Should we replace the delivery vans?",
|
||||||
|
decisionType: "asset replacement decision",
|
||||||
|
acceptableFoundationalUnknownNodeIds: [
|
||||||
|
"van-reliability-threshold",
|
||||||
|
"van-service-constraint",
|
||||||
|
],
|
||||||
|
prohibitedFirstTopics: [
|
||||||
|
"purchase price",
|
||||||
|
"paint colour",
|
||||||
|
"finance provider",
|
||||||
|
],
|
||||||
|
acceptableQuestionStrategies: ["decision criterion", "constraint"],
|
||||||
|
notes:
|
||||||
|
"The first question should establish whether the fleet is failing a threshold that justifies replacement.",
|
||||||
|
graph: makeScenarioGraph({
|
||||||
|
scenario: "Should we replace the delivery vans?",
|
||||||
|
decisionNode: makeNode({
|
||||||
|
id: "van-decision",
|
||||||
|
label: "Replace delivery vans decision",
|
||||||
|
description: "Decision about replacing the current delivery fleet.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "Deciding whether to replace the delivery vans",
|
||||||
|
childIds: ["van-reliability-threshold"],
|
||||||
|
}),
|
||||||
|
answeredContextUnknown: makeNode({
|
||||||
|
id: "van-breakdowns-known",
|
||||||
|
label: "Breakdown trend confirmed",
|
||||||
|
description:
|
||||||
|
"Need to confirm whether the recent rise in breakdowns is real because that context determines whether fleet replacement is relevant.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
value:
|
||||||
|
"Breakdowns and missed deliveries have increased over the last quarter.",
|
||||||
|
childIds: ["van-service-constraint"],
|
||||||
|
}),
|
||||||
|
foundationalUnknown: makeNode({
|
||||||
|
id: "van-reliability-threshold",
|
||||||
|
label: "Replacement justification threshold",
|
||||||
|
description:
|
||||||
|
"Need the threshold because the replacement decision depends on what level of reliability loss is enough to justify replacing the fleet.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
parentId: "van-decision",
|
||||||
|
childIds: ["van-service-constraint"],
|
||||||
|
}),
|
||||||
|
consequentialUnknown: makeNode({
|
||||||
|
id: "van-service-constraint",
|
||||||
|
label: "Operational service constraint",
|
||||||
|
description:
|
||||||
|
"Need the limiting service constraint because the team must know how vehicle unreliability is affecting deliveries before comparing purchasing options.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: ["van-reliability-threshold"],
|
||||||
|
parentId: "van-reliability-threshold",
|
||||||
|
childIds: ["van-price"],
|
||||||
|
}),
|
||||||
|
downstreamLeaf: makeNode({
|
||||||
|
id: "van-price",
|
||||||
|
label: "Exact replacement purchase price",
|
||||||
|
description:
|
||||||
|
"Need the exact purchase price because financing analysis comes after replacement is justified.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["van-service-constraint"],
|
||||||
|
parentId: "van-service-constraint",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "launch-country",
|
||||||
|
scenario: "Should we launch in another country?",
|
||||||
|
decisionType: "market expansion decision",
|
||||||
|
acceptableFoundationalUnknownNodeIds: [
|
||||||
|
"country-customer",
|
||||||
|
"country-value-threshold",
|
||||||
|
],
|
||||||
|
prohibitedFirstTopics: [
|
||||||
|
"launch date",
|
||||||
|
"office location",
|
||||||
|
"advertising channel",
|
||||||
|
],
|
||||||
|
acceptableQuestionStrategies: ["actor/customer", "decision criterion"],
|
||||||
|
notes:
|
||||||
|
"The first question should clarify the customer or value case for expansion before rollout logistics.",
|
||||||
|
graph: makeScenarioGraph({
|
||||||
|
scenario: "Should we launch in another country?",
|
||||||
|
decisionNode: makeNode({
|
||||||
|
id: "country-decision",
|
||||||
|
label: "Launch in another country decision",
|
||||||
|
description: "Decision about entering a new national market.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "Deciding whether to launch in another country",
|
||||||
|
childIds: ["country-customer"],
|
||||||
|
}),
|
||||||
|
answeredContextUnknown: makeNode({
|
||||||
|
id: "country-interest-known",
|
||||||
|
label: "Inbound interest confirmed",
|
||||||
|
description:
|
||||||
|
"Need to confirm whether inbound interest from another country is real because that context determines whether expansion is relevant.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
value:
|
||||||
|
"Prospective customers from another country are asking for access.",
|
||||||
|
childIds: ["country-value-threshold"],
|
||||||
|
}),
|
||||||
|
foundationalUnknown: makeNode({
|
||||||
|
id: "country-customer",
|
||||||
|
label: "Relevant customer in the new country",
|
||||||
|
description:
|
||||||
|
"Need the relevant customer because the expansion decision depends on who experiences the problem or receives the value in that market.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
parentId: "country-decision",
|
||||||
|
childIds: ["country-value-threshold"],
|
||||||
|
}),
|
||||||
|
consequentialUnknown: makeNode({
|
||||||
|
id: "country-value-threshold",
|
||||||
|
label: "Expansion value threshold",
|
||||||
|
description:
|
||||||
|
"Need the value threshold because the team must know what evidence of demand or value would justify entering the new country.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: ["country-customer"],
|
||||||
|
parentId: "country-customer",
|
||||||
|
childIds: ["country-launch-date"],
|
||||||
|
}),
|
||||||
|
downstreamLeaf: makeNode({
|
||||||
|
id: "country-launch-date",
|
||||||
|
label: "Country launch date",
|
||||||
|
description:
|
||||||
|
"Need the launch date because rollout planning follows once the expansion case is established.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["country-value-threshold"],
|
||||||
|
parentId: "country-value-threshold",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "over-budget-project",
|
||||||
|
scenario: "Should we continue a project that is over budget?",
|
||||||
|
decisionType: "continuation decision",
|
||||||
|
acceptableFoundationalUnknownNodeIds: [
|
||||||
|
"project-benefit-threshold",
|
||||||
|
"project-remaining-benefit",
|
||||||
|
],
|
||||||
|
prohibitedFirstTopics: ["sunk cost", "project logo", "final launch date"],
|
||||||
|
acceptableQuestionStrategies: ["decision criterion", "objective"],
|
||||||
|
notes:
|
||||||
|
"The first question should establish remaining value or success threshold before sunk-cost framing or launch timing.",
|
||||||
|
graph: makeScenarioGraph({
|
||||||
|
scenario: "Should we continue a project that is over budget?",
|
||||||
|
decisionNode: makeNode({
|
||||||
|
id: "project-decision",
|
||||||
|
label: "Continue over-budget project decision",
|
||||||
|
description:
|
||||||
|
"Decision about continuing a project that has exceeded budget.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "Deciding whether to continue the over-budget project",
|
||||||
|
childIds: ["project-benefit-threshold"],
|
||||||
|
}),
|
||||||
|
answeredContextUnknown: makeNode({
|
||||||
|
id: "project-overrun-known",
|
||||||
|
label: "Budget overrun confirmed",
|
||||||
|
description:
|
||||||
|
"Need to confirm whether the project is materially over budget because that context determines whether a continuation decision is relevant.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
value: "The project has exceeded its approved budget by 35 percent.",
|
||||||
|
childIds: ["project-remaining-benefit"],
|
||||||
|
}),
|
||||||
|
foundationalUnknown: makeNode({
|
||||||
|
id: "project-benefit-threshold",
|
||||||
|
label: "Continuation success threshold",
|
||||||
|
description:
|
||||||
|
"Need the threshold because the continuation decision depends on what remaining benefit would still justify completing the project.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
parentId: "project-decision",
|
||||||
|
childIds: ["project-remaining-benefit"],
|
||||||
|
}),
|
||||||
|
consequentialUnknown: makeNode({
|
||||||
|
id: "project-remaining-benefit",
|
||||||
|
label: "Remaining project benefit",
|
||||||
|
description:
|
||||||
|
"Need the remaining benefit because the team must know what value is still achievable before deciding whether to continue.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: ["project-benefit-threshold"],
|
||||||
|
parentId: "project-benefit-threshold",
|
||||||
|
childIds: ["project-launch-date"],
|
||||||
|
}),
|
||||||
|
downstreamLeaf: makeNode({
|
||||||
|
id: "project-launch-date",
|
||||||
|
label: "Final launch date",
|
||||||
|
description:
|
||||||
|
"Need the final launch date because scheduling details only matter after remaining value is established.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["project-remaining-benefit"],
|
||||||
|
parentId: "project-remaining-benefit",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paid-support-tier",
|
||||||
|
scenario: "Should we introduce a paid support tier?",
|
||||||
|
decisionType: "commercial packaging decision",
|
||||||
|
acceptableFoundationalUnknownNodeIds: [
|
||||||
|
"support-customer",
|
||||||
|
"support-value-threshold",
|
||||||
|
],
|
||||||
|
prohibitedFirstTopics: [
|
||||||
|
"subscription price",
|
||||||
|
"payment provider",
|
||||||
|
"tier name",
|
||||||
|
],
|
||||||
|
acceptableQuestionStrategies: ["actor/customer", "decision criterion"],
|
||||||
|
notes:
|
||||||
|
"The first question should establish who values paid support or what outcome would justify offering it before pricing details.",
|
||||||
|
graph: makeScenarioGraph({
|
||||||
|
scenario: "Should we introduce a paid support tier?",
|
||||||
|
decisionNode: makeNode({
|
||||||
|
id: "support-decision",
|
||||||
|
label: "Introduce paid support tier decision",
|
||||||
|
description: "Decision about adding a paid support offering.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "Deciding whether to introduce a paid support tier",
|
||||||
|
childIds: ["support-customer"],
|
||||||
|
}),
|
||||||
|
answeredContextUnknown: makeNode({
|
||||||
|
id: "support-requests-known",
|
||||||
|
label: "Support request pattern confirmed",
|
||||||
|
description:
|
||||||
|
"Need to confirm whether repeated requests for faster support responses are real because that context determines whether a paid tier is relevant.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
value:
|
||||||
|
"Some users are asking for guaranteed response times and escalation help.",
|
||||||
|
childIds: ["support-value-threshold"],
|
||||||
|
}),
|
||||||
|
foundationalUnknown: makeNode({
|
||||||
|
id: "support-customer",
|
||||||
|
label: "Customer willing to pay for support",
|
||||||
|
description:
|
||||||
|
"Need the customer because the decision depends on who experiences enough support pain or receives enough value to pay for a support tier.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
parentId: "support-decision",
|
||||||
|
childIds: ["support-value-threshold"],
|
||||||
|
}),
|
||||||
|
consequentialUnknown: makeNode({
|
||||||
|
id: "support-value-threshold",
|
||||||
|
label: "Paid support value threshold",
|
||||||
|
description:
|
||||||
|
"Need the value threshold because the team must know what outcome would justify introducing paid support before setting packaging details.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: ["support-customer"],
|
||||||
|
parentId: "support-customer",
|
||||||
|
childIds: ["support-price"],
|
||||||
|
}),
|
||||||
|
downstreamLeaf: makeNode({
|
||||||
|
id: "support-price",
|
||||||
|
label: "Support subscription price",
|
||||||
|
description:
|
||||||
|
"Need the subscription price because pricing and payment setup come after the support value case is established.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["support-value-threshold"],
|
||||||
|
parentId: "support-value-threshold",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -99,6 +99,7 @@ function makeApplicationFixture() {
|
|||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [complaintRateUnknown.id],
|
resolvedUnknownNodeIds: [complaintRateUnknown.id],
|
||||||
affectedNodeIds: [qualityDeterioration.id],
|
affectedNodeIds: [qualityDeterioration.id],
|
||||||
|
selectedQuestion: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -444,6 +445,7 @@ describe("applyValidatedProposal", () => {
|
|||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
affectedNodeIds: [],
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,4 +457,544 @@ describe("applyValidatedProposal", () => {
|
|||||||
expect.arrayContaining([expect.stringContaining("no meaningful change")]),
|
expect.arrayContaining([expect.stringContaining("no meaningful change")]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves one unknown and adds consequential unknowns with one selected question", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need a concrete definition of commercial value because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-demand-evidence",
|
||||||
|
label: "Evidence of demand",
|
||||||
|
description:
|
||||||
|
"Need evidence of demand because it matters to the build decision.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-build-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision situation introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "Decision whether to build Confidence Engine",
|
||||||
|
reason: "The answer resolves the original context unknown.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-commercial-value",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-commercial-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on defining commercial value.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-demand-evidence",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-demand-evidence",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on evidence of demand.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-commercial-value",
|
||||||
|
question: "How should commercial value be defined for this decision?",
|
||||||
|
reason:
|
||||||
|
"This is the most consequential unresolved unknown introduced by the answer.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
|
||||||
|
ids.complaintRateUnknown,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.some(
|
||||||
|
(node) => node.id === "n-commercial-value",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.some(
|
||||||
|
(node) => node.id === "n-demand-evidence",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.selectedQuestion?.question).toMatch(/\?$/);
|
||||||
|
expect(result.selectedQuestion?.question.length).toBeGreaterThan(20);
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||||
|
"price",
|
||||||
|
);
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||||
|
"how should uncertainty regarding",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects more than 3 added unknowns", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
addedNodes: [1, 2, 3, 4].map((index) =>
|
||||||
|
makeNode({
|
||||||
|
id: `n-unknown-${index}`,
|
||||||
|
label: `Unknown ${index}`,
|
||||||
|
description: `Need unknown ${index} because it matters to the decision.`,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
addedEdges: [1, 2, 3, 4].map((index) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `e-unknown-${index}`,
|
||||||
|
fromNodeId: ids.complaintRateUnknown,
|
||||||
|
toNodeId: `n-unknown-${index}`,
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: `Links unknown ${index}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-unknown-1",
|
||||||
|
question: "What is unknown 1?",
|
||||||
|
reason: "Follow-up required.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors.join(" ")).toContain("too many unknown nodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unrelated added unknowns", () => {
|
||||||
|
const { graph, proposal } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-unrelated",
|
||||||
|
label: "Office rent",
|
||||||
|
description:
|
||||||
|
"Need office rent because it matters to a different branch.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-unrelated",
|
||||||
|
question: "What is the office rent?",
|
||||||
|
reason: "Unrelated test.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors.join(" ")).toContain(
|
||||||
|
"explicitly related to an answer-derived node",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a newly added unknown explicitly linked through answer-derived node fields", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-answer-context",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision context introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
childIds: ["n-commercial-value"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need commercial value definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: ["n-answer-context"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "Decision whether to build Confidence Engine",
|
||||||
|
reason: "The answer resolves the original context unknown.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-commercial-value",
|
||||||
|
question: "How should commercial value be defined for this decision?",
|
||||||
|
reason: "A consequential unknown remains unresolved.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a newly added unknown linked only to the original unresolved node when that node is not answer-derived", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need commercial value definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: [ids.complaintRateUnknown],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [],
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-legacy-unknown-commercial-value",
|
||||||
|
fromNodeId: ids.complaintRateUnknown,
|
||||||
|
toNodeId: "n-commercial-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Links only to the original unresolved unknown.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-commercial-value",
|
||||||
|
question: "How should commercial value be defined for this decision?",
|
||||||
|
reason: "A consequential unknown remains unresolved.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors.join(" ")).toContain(
|
||||||
|
"explicitly related to an answer-derived node",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a floating emergent unknown with no explicit relationship", () => {
|
||||||
|
const { graph } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-floating",
|
||||||
|
label: "Floating unknown",
|
||||||
|
description: "Need this because it matters to the decision.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-floating",
|
||||||
|
question: "What would resolve Floating unknown?",
|
||||||
|
reason: "Test case for floating unknown rejection.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors.join(" ")).toContain(
|
||||||
|
"explicitly related to an answer-derived node",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the reported live-shaped commercial-value proposal when the linkage is explicit in node references", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "answer_context_build",
|
||||||
|
label: "Build Confidence Engine decision context",
|
||||||
|
description:
|
||||||
|
"The answer introduces a concrete decision about whether to build Confidence Engine.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "high",
|
||||||
|
dependsOn: [ids.complaintRateUnknown, "nu_commercial_val"],
|
||||||
|
childIds: ["nu_commercial_val"],
|
||||||
|
affects: ["nu_commercial_val"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "nu_commercial_val",
|
||||||
|
label: "Commercial viability assessment of Confidence Engine",
|
||||||
|
description:
|
||||||
|
"The commercial viability of Confidence Engine remains unknown because resolving it is needed to decide whether building it is justified.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["answer_context_build"],
|
||||||
|
childIds: ["answer_context_build"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue:
|
||||||
|
"Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.",
|
||||||
|
reason: "The answer resolves the original context unknown.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
affectedNodeIds: [
|
||||||
|
ids.complaintRateUnknown,
|
||||||
|
"answer_context_build",
|
||||||
|
"nu_commercial_val",
|
||||||
|
],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "nu_commercial_val",
|
||||||
|
question:
|
||||||
|
"How should commercial viability be defined for this decision?",
|
||||||
|
reason: "A foundational commercial-value unknown remains unresolved.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
|
||||||
|
ids.complaintRateUnknown,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.some(
|
||||||
|
(node) => node.id === "nu_commercial_val",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects selected question referencing resolved node", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
question: "What is the complaint rate?",
|
||||||
|
reason: "Invalid reselection.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errors.join(" ")).toContain(
|
||||||
|
"selectedQuestion must reference an unresolved node",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active unknown matches selected question node", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-success-threshold",
|
||||||
|
label: "Success threshold",
|
||||||
|
description:
|
||||||
|
"Need a success threshold because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-build-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "Decision whether to build Confidence Engine",
|
||||||
|
reason: "The answer resolves the original unknown.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-success-threshold",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-success-threshold",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on a success threshold.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-success-threshold",
|
||||||
|
question: "What success threshold would justify building it?",
|
||||||
|
reason: "One consequential unknown remains.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces downstream pricing question with higher-value commercial-value question", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need commercial value definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-pricing",
|
||||||
|
label: "Target price point",
|
||||||
|
description:
|
||||||
|
"Need a price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-commercial-value"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-build-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.complaintRateUnknown,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "Decision whether to build Confidence Engine",
|
||||||
|
reason: "The answer resolves the original context unknown.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-commercial-value",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-commercial-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on defining commercial value.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-commercial-value-pricing",
|
||||||
|
fromNodeId: "n-commercial-value",
|
||||||
|
toNodeId: "n-pricing",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Pricing depends on commercial value definition.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-build-pricing",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-pricing",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "low",
|
||||||
|
description: "The decision also references pricing assumptions.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-pricing",
|
||||||
|
question: "What is the target price point?",
|
||||||
|
reason: "Model chose a downstream leaf.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({ situationGraph: graph, proposal });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||||
|
"price",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function makeProposal(overrides = {}) {
|
|||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n-unknown"],
|
resolvedUnknownNodeIds: ["n-unknown"],
|
||||||
affectedNodeIds: [],
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -529,6 +530,147 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
expect(result.proposal.nextQuestion).toBeUndefined();
|
expect(result.proposal.nextQuestion).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns selectedQuestion from applied update proposal", 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 result = await updateCase(makeUpdateRequest(), {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
|
||||||
|
expect(result.selectedQuestion?.question).not.toBe(
|
||||||
|
"How should commercial value be defined for this decision?",
|
||||||
|
);
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||||
|
"how should uncertainty regarding",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deterministically prioritises customer value over pricing follow-up", async () => {
|
||||||
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi.fn().mockResolvedValue(
|
||||||
|
makeProposal({
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-value",
|
||||||
|
label: "Customer value",
|
||||||
|
description:
|
||||||
|
"Need customer value because purchase decisions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-price",
|
||||||
|
label: "Target price point",
|
||||||
|
description:
|
||||||
|
"Need a target price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-value"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
{
|
||||||
|
id: "e-decision-value",
|
||||||
|
fromNodeId: "n-decision",
|
||||||
|
toNodeId: "n-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The decision depends on customer value.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-value-price",
|
||||||
|
fromNodeId: "n-value",
|
||||||
|
toNodeId: "n-price",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Pricing depends on customer value.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-decision-price",
|
||||||
|
fromNodeId: "n-decision",
|
||||||
|
toNodeId: "n-price",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "low",
|
||||||
|
description: "The decision references pricing assumptions.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-price",
|
||||||
|
question: "What is the price point?",
|
||||||
|
reason: "Model chose pricing.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateCase(makeUpdateRequest(), {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe("n-value");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to proposal-only mode", async () => {
|
it("defaults to proposal-only mode", async () => {
|
||||||
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
const applyValidatedProposal = vi.fn();
|
const applyValidatedProposal = vi.fn();
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe("buildGraphUpdatePrompt", () => {
|
|||||||
expect(prompt).toContain("removedEdgeIds");
|
expect(prompt).toContain("removedEdgeIds");
|
||||||
expect(prompt).toContain("resolvedUnknownNodeIds");
|
expect(prompt).toContain("resolvedUnknownNodeIds");
|
||||||
expect(prompt).toContain("affectedNodeIds");
|
expect(prompt).toContain("affectedNodeIds");
|
||||||
|
expect(prompt).toContain("selectedQuestion");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists enum values", () => {
|
it("lists enum values", () => {
|
||||||
@@ -98,4 +99,16 @@ describe("buildGraphUpdatePrompt", () => {
|
|||||||
expect(prompt).toContain("Return JSON only");
|
expect(prompt).toContain("Return JSON only");
|
||||||
expect(prompt).toContain("Return one JSON object only");
|
expect(prompt).toContain("Return one JSON object only");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("describes controlled emergent unknown rules", () => {
|
||||||
|
const prompt = buildGraphUpdatePrompt(makeContext());
|
||||||
|
expect(prompt).toContain("Add at most 3 new unknown nodes");
|
||||||
|
expect(prompt).toContain("Resolve the answered unknown first");
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"selectedQuestion.question must be one narrow non-compound question",
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"the engine will deterministically choose final priority after validation",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formulateQuestion } from "@/lib/graph/question-formulator.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeGraphFor(node, extra = {}) {
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement: extra.centralStatement || "Decision context",
|
||||||
|
nodes: [node, ...(extra.nodes || [])],
|
||||||
|
edges: extra.edges || [],
|
||||||
|
activeUnknownNodeId: node.id,
|
||||||
|
resolvedNodeIds: extra.resolvedNodeIds || [],
|
||||||
|
currentSummary: "Test summary",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formulateQuestion", () => {
|
||||||
|
it("commercial viability plus build decision produces a decision-criterion question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-commercial",
|
||||||
|
label: "Uncertainty regarding the commercial value of the product",
|
||||||
|
description:
|
||||||
|
"Commercial justification remains unclear because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
parentId: "n-decision",
|
||||||
|
});
|
||||||
|
const decision = makeNode({
|
||||||
|
id: "n-decision",
|
||||||
|
label: "Build decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
childIds: [unknown.id],
|
||||||
|
value: "Deciding whether to build the product",
|
||||||
|
});
|
||||||
|
const graph = makeGraphFor(unknown, {
|
||||||
|
nodes: [decision],
|
||||||
|
resolvedNodeIds: [decision.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({ node: unknown, graph });
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("decision criterion");
|
||||||
|
expect(result.question).toContain("What outcome");
|
||||||
|
expect(result.question.toLowerCase()).toContain("justify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commercial viability does not produce a pricing-first question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-commercial",
|
||||||
|
label: "Commercial viability",
|
||||||
|
description:
|
||||||
|
"Commercial viability remains unresolved because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const graph = makeGraphFor(unknown);
|
||||||
|
|
||||||
|
const result = formulateQuestion({ node: unknown, graph });
|
||||||
|
|
||||||
|
expect(result.question.toLowerCase()).not.toContain("price");
|
||||||
|
expect(result.question.toLowerCase()).not.toContain("pricing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undefined term produces a definition question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-term",
|
||||||
|
label: "Success criteria definition",
|
||||||
|
description:
|
||||||
|
"Need a definition of the term because the team uses it inconsistently.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("definition");
|
||||||
|
expect(result.question).toMatch(/^What does /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsupported claim produces an evidence question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-claim",
|
||||||
|
label: "Demand claim",
|
||||||
|
description: "Need evidence because the claim has not been validated.",
|
||||||
|
kind: "reported_claim",
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("evidence");
|
||||||
|
expect(result.question).toContain("What evidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing previous state produces a baseline question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-baseline",
|
||||||
|
label: "Baseline conversion rate",
|
||||||
|
description:
|
||||||
|
"Need the previous baseline because the change cannot be assessed without it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("baseline");
|
||||||
|
expect(result.question).toContain("What was the comparable state before");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown customer produces an actor/customer question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-customer",
|
||||||
|
label: "Target customer",
|
||||||
|
description:
|
||||||
|
"Need to know the customer because value depends on who receives it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("actor/customer");
|
||||||
|
expect(result.question).toContain(
|
||||||
|
"Who experiences the problem or receives the value",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constraint unknown produces a constraint question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-constraint",
|
||||||
|
label: "Budget constraint",
|
||||||
|
description:
|
||||||
|
"Need the main budget constraint because it limits the available options.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("constraint");
|
||||||
|
expect(result.question).toContain("What constraint most limits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("question is singular and answerable", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-evidence",
|
||||||
|
label: "Evidence of demand",
|
||||||
|
description:
|
||||||
|
"Need evidence of demand because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.question.match(/\?/g) || []).toHaveLength(1);
|
||||||
|
expect(result.question.toLowerCase()).not.toContain(" and ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awkward uncertainty phrasing is rejected via fallback", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-weird",
|
||||||
|
label: "Uncertainty regarding service reliability",
|
||||||
|
description: "Unknown service reliability.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.question).not.toContain("How should uncertainty regarding");
|
||||||
|
expect(result.question).not.toContain(
|
||||||
|
"What would resolve uncertainty regarding",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { formulateQuestion } from "@/lib/graph/question-formulator.js";
|
||||||
|
import { selectActiveUnknownCandidate } from "@/lib/graph/utils.js";
|
||||||
|
import { questionPriorityGeneralisationFixtures } from "@/tests/fixtures/question-priority-generalisation.js";
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResolutionProposal(graph) {
|
||||||
|
const activeNode = graph.nodes.find(
|
||||||
|
(node) => node.id === graph.activeUnknownNodeId,
|
||||||
|
);
|
||||||
|
const placeholderCandidate = graph.nodes.find(
|
||||||
|
(node) => node.kind === "unknown" && node.id !== activeNode.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: activeNode.id,
|
||||||
|
previousStatus: activeNode.status,
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: activeNode.value ?? null,
|
||||||
|
newValue: activeNode.value ?? "Resolved context answer",
|
||||||
|
reason:
|
||||||
|
"The resolved context unknown is treated as answered for fixture progression.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [activeNode.id],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: placeholderCandidate?.id,
|
||||||
|
question: "Placeholder candidate question?",
|
||||||
|
reason: "Candidate only; deterministic selector should override it.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertQuestionStructure(question) {
|
||||||
|
expect(question.match(/\?/g) || []).toHaveLength(1);
|
||||||
|
expect(question).not.toMatch(/\?\s*(and|or)\b/i);
|
||||||
|
expect(question).not.toMatch(/^What is\s+/i);
|
||||||
|
expect(question).toMatch(/^(What|Who|When)\b/);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("question priority generalisation", () => {
|
||||||
|
for (const fixture of questionPriorityGeneralisationFixtures) {
|
||||||
|
it(`${fixture.scenario} selects a foundational unknown and singular answerable strategy`, () => {
|
||||||
|
const originalGraph = clone(fixture.graph);
|
||||||
|
const deterministicSelection = selectActiveUnknownCandidate(
|
||||||
|
fixture.graph,
|
||||||
|
[fixture.graph.activeUnknownNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: fixture.graph,
|
||||||
|
proposal: buildResolutionProposal(fixture.graph),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(fixture.graph).toEqual(originalGraph);
|
||||||
|
expect(result.graphUpdate.selectedQuestion?.question).toBe(
|
||||||
|
"Placeholder candidate question?",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deterministicSelection.nodeId).toBe(
|
||||||
|
result.selectedQuestion.nodeId,
|
||||||
|
);
|
||||||
|
expect(fixture.acceptableFoundationalUnknownNodeIds).toContain(
|
||||||
|
result.selectedQuestion.nodeId,
|
||||||
|
);
|
||||||
|
expect(result.selectedQuestion.nodeId).not.toBe(
|
||||||
|
fixture.graph.nodes[fixture.graph.nodes.length - 1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fixture.acceptableQuestionStrategies).toContain(
|
||||||
|
result.selectedQuestion.strategy,
|
||||||
|
);
|
||||||
|
assertQuestionStructure(result.selectedQuestion.question);
|
||||||
|
|
||||||
|
const lowerQuestion = result.selectedQuestion.question.toLowerCase();
|
||||||
|
for (const topic of fixture.prohibitedFirstTopics) {
|
||||||
|
expect(lowerQuestion).not.toContain(topic.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNode = result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === result.selectedQuestion.nodeId,
|
||||||
|
);
|
||||||
|
const reformulated = formulateQuestion({
|
||||||
|
node: selectedNode,
|
||||||
|
graph: result.updatedSituationGraph,
|
||||||
|
context: {
|
||||||
|
resolvedValues: ["Resolved context answer"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reformulated.question).toBe(result.selectedQuestion.question);
|
||||||
|
expect(clone(result.updatedSituationGraph)).toEqual(
|
||||||
|
result.updatedSituationGraph,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports all five selected unknowns and strategies", () => {
|
||||||
|
const summary = questionPriorityGeneralisationFixtures.map((fixture) => {
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: fixture.graph,
|
||||||
|
proposal: buildResolutionProposal(fixture.graph),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenario: fixture.scenario,
|
||||||
|
nodeId: result.selectedQuestion.nodeId,
|
||||||
|
strategy: result.selectedQuestion.strategy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"nodeId": "hire-success-criteria",
|
||||||
|
"scenario": "Should we hire another engineer?",
|
||||||
|
"strategy": "decision criterion",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nodeId": "van-reliability-threshold",
|
||||||
|
"scenario": "Should we replace the delivery vans?",
|
||||||
|
"strategy": "decision criterion",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nodeId": "country-value-threshold",
|
||||||
|
"scenario": "Should we launch in another country?",
|
||||||
|
"strategy": "actor/customer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nodeId": "project-benefit-threshold",
|
||||||
|
"scenario": "Should we continue a project that is over budget?",
|
||||||
|
"strategy": "decision criterion",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nodeId": "support-value-threshold",
|
||||||
|
"scenario": "Should we introduce a paid support tier?",
|
||||||
|
"strategy": "actor/customer",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
-12
@@ -164,18 +164,53 @@ describe("graphUpdateSchema", () => {
|
|||||||
|
|
||||||
const result = graphUpdateSchema.safeParse({
|
const result = graphUpdateSchema.safeParse({
|
||||||
addedNodes: [node],
|
addedNodes: [node],
|
||||||
updatedNodes: [{ nodeId: "n1", newStatus: "resolved", previousStatus: "unknown", reason: "Question answered" }],
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: "n1",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousStatus: "unknown",
|
||||||
|
reason: "Question answered",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [edge],
|
addedEdges: [edge],
|
||||||
removedEdgeIds: ["e-old"],
|
removedEdgeIds: ["e-old"],
|
||||||
resolvedUnknownNodeIds: ["n2"],
|
resolvedUnknownNodeIds: ["n2"],
|
||||||
affectedNodeIds: ["n3"],
|
affectedNodeIds: ["n3"],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n2",
|
||||||
|
question: "What does this new node mean?",
|
||||||
|
reason: "A follow-up unknown remains.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows null selectedQuestion", () => {
|
||||||
|
const result = graphUpdateSchema.safeParse({
|
||||||
|
selectedQuestion: null,
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects update with invalid node kind in addedNodes", () => {
|
it("rejects update with invalid node kind in addedNodes", () => {
|
||||||
const invalid = graphUpdateSchema.safeParse({
|
const invalid = graphUpdateSchema.safeParse({
|
||||||
addedNodes: [{ id: "x", label: "Test", kind: "invalid_kind", description: "test", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
|
addedNodes: [
|
||||||
|
{
|
||||||
|
id: "x",
|
||||||
|
label: "Test",
|
||||||
|
kind: "invalid_kind",
|
||||||
|
description: "test",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(invalid.success).toBe(false);
|
expect(invalid.success).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -184,7 +219,9 @@ describe("graphUpdateSchema", () => {
|
|||||||
describe("API request schemas", () => {
|
describe("API request schemas", () => {
|
||||||
describe("startCaseRequestSchema", () => {
|
describe("startCaseRequestSchema", () => {
|
||||||
it("validates scenario field", () => {
|
it("validates scenario field", () => {
|
||||||
const result = startCaseRequestSchema.safeParse({ scenario: "Test scenario" });
|
const result = startCaseRequestSchema.safeParse({
|
||||||
|
scenario: "Test scenario",
|
||||||
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,14 +232,16 @@ describe("API request schemas", () => {
|
|||||||
|
|
||||||
it("rejects scenario over 10000 chars", () => {
|
it("rejects scenario over 10000 chars", () => {
|
||||||
const longScenario = "a".repeat(10001);
|
const longScenario = "a".repeat(10001);
|
||||||
const result = startCaseRequestSchema.safeParse({ scenario: longScenario });
|
const result = startCaseRequestSchema.safeParse({
|
||||||
|
scenario: longScenario,
|
||||||
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts optional promptVersion", () => {
|
it("accepts optional promptVersion", () => {
|
||||||
const result = startCaseRequestSchema.safeParse({
|
const result = startCaseRequestSchema.safeParse({
|
||||||
scenario: "Test",
|
scenario: "Test",
|
||||||
promptVersion: "v0.3"
|
promptVersion: "v0.3",
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -213,7 +252,7 @@ describe("API request schemas", () => {
|
|||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test scenario",
|
centralStatement: "Test scenario",
|
||||||
nodes: [makeNode({ id: "n1", label: "N" })],
|
nodes: [makeNode({ id: "n1", label: "N" })],
|
||||||
currentSummary: "Current state of situation"
|
currentSummary: "Current state of situation",
|
||||||
});
|
});
|
||||||
const result = updateCaseRequestSchema.safeParse({
|
const result = updateCaseRequestSchema.safeParse({
|
||||||
situationGraph: graph,
|
situationGraph: graph,
|
||||||
@@ -235,7 +274,7 @@ describe("API request schemas", () => {
|
|||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test",
|
centralStatement: "Test",
|
||||||
nodes: [makeNode({ id: "n1", label: "N" })],
|
nodes: [makeNode({ id: "n1", label: "N" })],
|
||||||
currentSummary: "Test summary"
|
currentSummary: "Test summary",
|
||||||
});
|
});
|
||||||
const result = updateCaseRequestSchema.safeParse({
|
const result = updateCaseRequestSchema.safeParse({
|
||||||
situationGraph: graph,
|
situationGraph: graph,
|
||||||
@@ -261,7 +300,9 @@ describe("deterministic ID generation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("IDs are prefixed with 'n' and short", () => {
|
it("IDs are prefixed with 'n' and short", () => {
|
||||||
const id = makeNodeId("A very long label that would produce a longer hash if not truncated");
|
const id = makeNodeId(
|
||||||
|
"A very long label that would produce a longer hash if not truncated",
|
||||||
|
);
|
||||||
expect(id.startsWith("n")).toBe(true);
|
expect(id.startsWith("n")).toBe(true);
|
||||||
expect(id.length).toBeLessThan(15);
|
expect(id.length).toBeLessThan(15);
|
||||||
});
|
});
|
||||||
@@ -325,7 +366,7 @@ describe("helper functions", () => {
|
|||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test",
|
centralStatement: "Test",
|
||||||
currentSummary: "Default summary",
|
currentSummary: "Default summary",
|
||||||
nodes: [makeNode({ id: "n1", label: "Placeholder" })]
|
nodes: [makeNode({ id: "n1", label: "Placeholder" })],
|
||||||
});
|
});
|
||||||
const result = situationGraphSchema.safeParse(graph);
|
const result = situationGraphSchema.safeParse(graph);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
@@ -346,19 +387,48 @@ describe("helper functions", () => {
|
|||||||
|
|
||||||
describe("enum values completeness", () => {
|
describe("enum values completeness", () => {
|
||||||
it("SituationKind has all expected values", () => {
|
it("SituationKind has all expected values", () => {
|
||||||
const expected = ["observation", "reported_claim", "metric", "state", "transition", "relationship", "assumption", "unknown", "conclusion"];
|
const expected = [
|
||||||
|
"observation",
|
||||||
|
"reported_claim",
|
||||||
|
"metric",
|
||||||
|
"state",
|
||||||
|
"transition",
|
||||||
|
"relationship",
|
||||||
|
"assumption",
|
||||||
|
"unknown",
|
||||||
|
"conclusion",
|
||||||
|
];
|
||||||
const actual = Object.values(SituationKind);
|
const actual = Object.values(SituationKind);
|
||||||
expect(actual).toEqual(expect.arrayContaining(expected));
|
expect(actual).toEqual(expect.arrayContaining(expected));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("SituationStatus has all expected values", () => {
|
it("SituationStatus has all expected values", () => {
|
||||||
const expected = ["known", "unknown", "provisional", "supported", "weakened", "contradicted", "resolved"];
|
const expected = [
|
||||||
|
"known",
|
||||||
|
"unknown",
|
||||||
|
"provisional",
|
||||||
|
"supported",
|
||||||
|
"weakened",
|
||||||
|
"contradicted",
|
||||||
|
"resolved",
|
||||||
|
];
|
||||||
const actual = Object.values(SituationStatus);
|
const actual = Object.values(SituationStatus);
|
||||||
expect(actual).toEqual(expect.arrayContaining(expected));
|
expect(actual).toEqual(expect.arrayContaining(expected));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("SituationRelationship has all expected values", () => {
|
it("SituationRelationship has all expected values", () => {
|
||||||
const expected = ["supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "other"];
|
const expected = [
|
||||||
|
"supports",
|
||||||
|
"weakens",
|
||||||
|
"contradicts",
|
||||||
|
"depends_on",
|
||||||
|
"causes",
|
||||||
|
"may_cause",
|
||||||
|
"measures",
|
||||||
|
"compares_with",
|
||||||
|
"updates",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
const actual = Object.values(SituationRelationship);
|
const actual = Object.values(SituationRelationship);
|
||||||
expect(actual).toEqual(expect.arrayContaining(expected));
|
expect(actual).toEqual(expect.arrayContaining(expected));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function makeValidProposal(overrides = {}) {
|
|||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n-unknown"],
|
resolvedUnknownNodeIds: ["n-unknown"],
|
||||||
affectedNodeIds: [],
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,34 @@ describe("parseGraphUpdateProposal", () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not invent a next question", () => {
|
it("defaults missing selectedQuestion to null", () => {
|
||||||
|
const result = parseGraphUpdateProposal({
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.proposal.selectedQuestion).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a valid selectedQuestion", () => {
|
||||||
|
const result = parseGraphUpdateProposal(
|
||||||
|
makeValidProposal({
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-follow-up",
|
||||||
|
question: "How should commercial value be defined for this decision?",
|
||||||
|
reason: "A consequential unknown remains unresolved.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.proposal.selectedQuestion?.nodeId).toBe("n-follow-up");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent a next question field outside the contract", () => {
|
||||||
const result = parseGraphUpdateProposal(makeValidProposal());
|
const result = parseGraphUpdateProposal(makeValidProposal());
|
||||||
expect(result.proposal.nextQuestion).toBeUndefined();
|
expect(result.proposal.nextQuestion).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
+363
-98
@@ -3,6 +3,7 @@ import {
|
|||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
detectDuplicateNodeIds,
|
detectDuplicateNodeIds,
|
||||||
detectDuplicateEdges,
|
detectDuplicateEdges,
|
||||||
|
scoreUnknownCandidate,
|
||||||
findDependentNodes,
|
findDependentNodes,
|
||||||
findAffectedNodes,
|
findAffectedNodes,
|
||||||
resolveUnknownNode,
|
resolveUnknownNode,
|
||||||
@@ -28,8 +29,18 @@ function makeTestGraph() {
|
|||||||
// n4 is an unknown not depended on
|
// n4 is an unknown not depended on
|
||||||
// n5 is an unknown depended upon by n3 indirectly
|
// n5 is an unknown depended upon by n3 indirectly
|
||||||
|
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n3.id,
|
||||||
|
toNodeId: n1.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
return makeGraph({
|
return makeGraph({
|
||||||
centralStatement: "Test graph",
|
centralStatement: "Test graph",
|
||||||
@@ -55,7 +66,9 @@ describe("validateGraphReferences", () => {
|
|||||||
graph.nodes[0].parentId = "nonexistent-parent";
|
graph.nodes[0].parentId = "nonexistent-parent";
|
||||||
const result = validateGraphReferences(graph);
|
const result = validateGraphReferences(graph);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("nonexistent-parent"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("nonexistent-parent"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects invalid childIds reference", () => {
|
it("detects invalid childIds reference", () => {
|
||||||
@@ -84,7 +97,7 @@ describe("validateGraphReferences", () => {
|
|||||||
graph.edges[0].fromNodeId = "ghost-node";
|
graph.edges[0].fromNodeId = "ghost-node";
|
||||||
const result = validateGraphReferences(graph);
|
const result = validateGraphReferences(graph);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects edge referencing non-existent toNodeId", () => {
|
it("detects edge referencing non-existent toNodeId", () => {
|
||||||
@@ -154,8 +167,18 @@ describe("detectDuplicateEdges", () => {
|
|||||||
it("detects duplicate edge (same from, to, relationship)", () => {
|
it("detects duplicate edge (same from, to, relationship)", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A" });
|
const n1 = makeNode({ id: "n1", label: "A" });
|
||||||
const n2 = makeNode({ id: "n2", label: "B" });
|
const n2 = makeNode({ id: "n2", label: "B" });
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
const dups = detectDuplicateEdges([e1, e2]);
|
const dups = detectDuplicateEdges([e1, e2]);
|
||||||
expect(dups.length).toBe(1);
|
expect(dups.length).toBe(1);
|
||||||
@@ -164,8 +187,18 @@ describe("detectDuplicateEdges", () => {
|
|||||||
it("allows same nodes with different relationship types", () => {
|
it("allows same nodes with different relationship types", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A" });
|
const n1 = makeNode({ id: "n1", label: "A" });
|
||||||
const n2 = makeNode({ id: "n2", label: "B" });
|
const n2 = makeNode({ id: "n2", label: "B" });
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "weakens",
|
||||||
|
});
|
||||||
|
|
||||||
const dups = detectDuplicateEdges([e1, e2]);
|
const dups = detectDuplicateEdges([e1, e2]);
|
||||||
expect(dups.length).toBe(0);
|
expect(dups.length).toBe(0);
|
||||||
@@ -174,8 +207,18 @@ describe("detectDuplicateEdges", () => {
|
|||||||
it("detects reversed direction as different edge", () => {
|
it("detects reversed direction as different edge", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A" });
|
const n1 = makeNode({ id: "n1", label: "A" });
|
||||||
const n2 = makeNode({ id: "n2", label: "B" });
|
const n2 = makeNode({ id: "n2", label: "B" });
|
||||||
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
|
const e1 = makeEdge({
|
||||||
const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports" });
|
id: "e1",
|
||||||
|
fromNodeId: n1.id,
|
||||||
|
toNodeId: n2.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
const e2 = makeEdge({
|
||||||
|
id: "e2",
|
||||||
|
fromNodeId: n2.id,
|
||||||
|
toNodeId: n1.id,
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
const dups = detectDuplicateEdges([e1, e2]);
|
const dups = detectDuplicateEdges([e1, e2]);
|
||||||
expect(dups.length).toBe(0);
|
expect(dups.length).toBe(0);
|
||||||
@@ -270,7 +313,14 @@ describe("findAffectedNodes (transitive)", () => {
|
|||||||
|
|
||||||
it("handles empty graph", () => {
|
it("handles empty graph", () => {
|
||||||
// build a minimal graph without triggering schema validation for this edge case
|
// build a minimal graph without triggering schema validation for this edge case
|
||||||
const graph = { centralStatement: "Empty", nodes: [], edges: [], resolvedNodeIds: [], currentSummary: "", activeUnknownNodeId: null };
|
const graph = {
|
||||||
|
centralStatement: "Empty",
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "",
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
};
|
||||||
const affected = findAffectedNodes(graph, "any-node");
|
const affected = findAffectedNodes(graph, "any-node");
|
||||||
expect(affected.length).toBe(0);
|
expect(affected.length).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -279,7 +329,13 @@ describe("findAffectedNodes (transitive)", () => {
|
|||||||
describe("resolveUnknownNode", () => {
|
describe("resolveUnknownNode", () => {
|
||||||
it("returns success for valid node id", () => {
|
it("returns success for valid node id", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const result = resolveUnknownNode(graph, "n4", "resolved", "Confirmed", "User confirmed");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"n4",
|
||||||
|
"resolved",
|
||||||
|
"Confirmed",
|
||||||
|
"User confirmed",
|
||||||
|
);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.newStatus).toBe("resolved");
|
expect(result.newStatus).toBe("resolved");
|
||||||
expect(result.reason).toBe("User confirmed");
|
expect(result.reason).toBe("User confirmed");
|
||||||
@@ -287,7 +343,13 @@ describe("resolveUnknownNode", () => {
|
|||||||
|
|
||||||
it("returns error for non-existent node", () => {
|
it("returns error for non-existent node", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const result = resolveUnknownNode(graph, "ghost-node", "resolved", null, "reason");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"ghost-node",
|
||||||
|
"resolved",
|
||||||
|
null,
|
||||||
|
"reason",
|
||||||
|
);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain("not found");
|
expect(result.error).toContain("not found");
|
||||||
});
|
});
|
||||||
@@ -297,13 +359,25 @@ describe("resolveUnknownNode", () => {
|
|||||||
// n5 depends on... actually let's set up properly
|
// n5 depends on... actually let's set up properly
|
||||||
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
|
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
|
||||||
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
|
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
|
||||||
const result = resolveUnknownNode(graph, "n4", "resolved", "Yes", "Clarified");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"n4",
|
||||||
|
"resolved",
|
||||||
|
"Yes",
|
||||||
|
"Clarified",
|
||||||
|
);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks previous status and value", () => {
|
it("tracks previous status and value", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const result = resolveUnknownNode(graph, "n4", "known", "confirmed_value", "Evidence found");
|
const result = resolveUnknownNode(
|
||||||
|
graph,
|
||||||
|
"n4",
|
||||||
|
"known",
|
||||||
|
"confirmed_value",
|
||||||
|
"Evidence found",
|
||||||
|
);
|
||||||
expect(result.previousStatus).toBe("unknown");
|
expect(result.previousStatus).toBe("unknown");
|
||||||
expect(result.newValue).toBe("confirmed_value");
|
expect(result.newValue).toBe("confirmed_value");
|
||||||
});
|
});
|
||||||
@@ -313,7 +387,11 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
it("returns null when no unresolved unknowns", () => {
|
it("returns null when no unresolved unknowns", () => {
|
||||||
// makeTestGraph nodes default to kind "observation", not "unknown"
|
// makeTestGraph nodes default to kind "observation", not "unknown"
|
||||||
// Create explicit unknown-kind nodes for this test
|
// Create explicit unknown-kind nodes for this test
|
||||||
const nUnknown = makeNode({ id: "n-unk-x", label: "Unknown X", kind: "unknown" });
|
const nUnknown = makeNode({
|
||||||
|
id: "n-unk-x",
|
||||||
|
label: "Unknown X",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test",
|
centralStatement: "Test",
|
||||||
nodes: [nUnknown],
|
nodes: [nUnknown],
|
||||||
@@ -349,9 +427,21 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prioritises nodes with more dependents", () => {
|
it("prioritises nodes with more dependents", () => {
|
||||||
const unknownA = makeNode({ id: "unknown-a", label: "Unknown A", kind: "unknown" });
|
const unknownA = makeNode({
|
||||||
const unknownB = makeNode({ id: "unknown-b", label: "Unknown B", kind: "unknown" });
|
id: "unknown-a",
|
||||||
const dependent = makeNode({ id: "dep", label: "Dependent", kind: "state" });
|
label: "Unknown A",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
const unknownB = makeNode({
|
||||||
|
id: "unknown-b",
|
||||||
|
label: "Unknown B",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
const dependent = makeNode({
|
||||||
|
id: "dep",
|
||||||
|
label: "Dependent",
|
||||||
|
kind: "state",
|
||||||
|
});
|
||||||
|
|
||||||
dependent.dependsOn.push("unknown-a");
|
dependent.dependsOn.push("unknown-a");
|
||||||
|
|
||||||
@@ -370,7 +460,11 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
|
|
||||||
it("returns one candidate (not array)", () => {
|
it("returns one candidate (not array)", () => {
|
||||||
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
|
||||||
const nUnknown = makeNode({ id: "n-unk", label: "Pending", kind: "unknown" });
|
const nUnknown = makeNode({
|
||||||
|
id: "n-unk",
|
||||||
|
label: "Pending",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
const graph = makeGraph({
|
const graph = makeGraph({
|
||||||
centralStatement: "Test",
|
centralStatement: "Test",
|
||||||
nodes: [n1, nUnknown],
|
nodes: [n1, nUnknown],
|
||||||
@@ -386,6 +480,145 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
expect(result.label).toBeDefined();
|
expect(result.label).toBeDefined();
|
||||||
expect(result.score).toBeDefined();
|
expect(result.score).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("commercial value wins over pricing", () => {
|
||||||
|
const commercialValue = makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need to define commercial value because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const pricing = makeNode({
|
||||||
|
id: "n-pricing",
|
||||||
|
label: "Target price point",
|
||||||
|
description:
|
||||||
|
"Need a target price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-commercial-value"],
|
||||||
|
});
|
||||||
|
const decision = makeNode({
|
||||||
|
id: "n-decision",
|
||||||
|
label: "Build decision",
|
||||||
|
description: "Decision context",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-commercial-value", "n-pricing"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Build decision",
|
||||||
|
nodes: [commercialValue, pricing, decision],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: pricing.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.nodeId).toBe("n-commercial-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customer value wins over UI colour", () => {
|
||||||
|
const customerValue = makeNode({
|
||||||
|
id: "n-customer-value",
|
||||||
|
label: "Customer value",
|
||||||
|
description:
|
||||||
|
"Need to know customer value because adoption depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const uiColour = makeNode({
|
||||||
|
id: "n-ui-colour",
|
||||||
|
label: "UI colour",
|
||||||
|
description: "Need a UI colour because presentation choices remain open.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Value question",
|
||||||
|
nodes: [customerValue, uiColour],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.nodeId).toBe("n-customer-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("success criteria wins over marketing slogan", () => {
|
||||||
|
const successCriteria = makeNode({
|
||||||
|
id: "n-success-criteria",
|
||||||
|
label: "Success criteria",
|
||||||
|
description:
|
||||||
|
"Need success criteria because the decision requires a threshold.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const slogan = makeNode({
|
||||||
|
id: "n-slogan",
|
||||||
|
label: "Marketing slogan",
|
||||||
|
description: "Need a slogan because messaging is undecided.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Threshold question",
|
||||||
|
nodes: [successCriteria, slogan],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.nodeId).toBe("n-success-criteria");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("penalises unknowns with unresolved parent unknowns", () => {
|
||||||
|
const parentUnknown = makeNode({
|
||||||
|
id: "n-parent",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need commercial value definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const childUnknown = makeNode({
|
||||||
|
id: "n-child",
|
||||||
|
label: "Target price point",
|
||||||
|
description: "Need price point because revenue assumptions depend on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-parent"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Dependency ordering",
|
||||||
|
nodes: [parentUnknown, childUnknown],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentScore = scoreUnknownCandidate(graph, parentUnknown, []);
|
||||||
|
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
||||||
|
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyGraphUpdate", () => {
|
describe("applyGraphUpdate", () => {
|
||||||
@@ -405,7 +638,7 @@ describe("applyGraphUpdate", () => {
|
|||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.nodes.length).toBe(graph.nodes.length + 1);
|
expect(result.nodes.length).toBe(graph.nodes.length + 1);
|
||||||
expect(result.nodes.some(n => n.id === "n-new")).toBe(true);
|
expect(result.nodes.some((n) => n.id === "n-new")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies status updates correctly", () => {
|
it("applies status updates correctly", () => {
|
||||||
@@ -413,14 +646,16 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
previousValue: null,
|
newStatus: "resolved",
|
||||||
newValue: "confirmed",
|
previousValue: null,
|
||||||
reason: "Answered by user",
|
newValue: "confirmed",
|
||||||
}],
|
reason: "Answered by user",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -430,7 +665,7 @@ describe("applyGraphUpdate", () => {
|
|||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
const updatedNode = result.nodes.find(n => n.id === "n4");
|
const updatedNode = result.nodes.find((n) => n.id === "n4");
|
||||||
expect(updatedNode.status).toBe("resolved");
|
expect(updatedNode.status).toBe("resolved");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,12 +674,14 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "ghost-node",
|
{
|
||||||
previousStatus: null,
|
nodeId: "ghost-node",
|
||||||
newStatus: "known",
|
previousStatus: null,
|
||||||
reason: "test",
|
newStatus: "known",
|
||||||
}],
|
reason: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -453,7 +690,7 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes requested edges", () => {
|
it("removes requested edges", () => {
|
||||||
@@ -472,12 +709,16 @@ describe("applyGraphUpdate", () => {
|
|||||||
const result = applyGraphUpdate(graph, update);
|
const result = applyGraphUpdate(graph, update);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.edges.length).toBe(graph.edges.length - 1);
|
expect(result.edges.length).toBe(graph.edges.length - 1);
|
||||||
expect(result.edges.some(e => e.id === edgeIdToRemove)).toBe(false);
|
expect(result.edges.some((e) => e.id === edgeIdToRemove)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds edges and updates node dependsOn/affects", () => {
|
it("adds edges and updates node dependsOn/affects", () => {
|
||||||
const graph = makeTestGraph();
|
const graph = makeTestGraph();
|
||||||
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports" });
|
const newEdge = makeEdge({
|
||||||
|
fromNodeId: "n1",
|
||||||
|
toNodeId: "n4",
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
@@ -492,11 +733,11 @@ describe("applyGraphUpdate", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
// Check the edge was added
|
// Check the edge was added
|
||||||
expect(result.edges.some(e => e.id === newEdge.id)).toBe(true);
|
expect(result.edges.some((e) => e.id === newEdge.id)).toBe(true);
|
||||||
|
|
||||||
// Check node relationship arrays updated
|
// Check node relationship arrays updated
|
||||||
const fromNode = result.nodes.find(n => n.id === "n1");
|
const fromNode = result.nodes.find((n) => n.id === "n1");
|
||||||
const toNode = result.nodes.find(n => n.id === "n4");
|
const toNode = result.nodes.find((n) => n.id === "n4");
|
||||||
expect(fromNode.childIds).toContain("n4");
|
expect(fromNode.childIds).toContain("n4");
|
||||||
expect(toNode.dependsOn).toContain("n1");
|
expect(toNode.dependsOn).toContain("n1");
|
||||||
});
|
});
|
||||||
@@ -542,14 +783,16 @@ describe("applyGraphUpdate", () => {
|
|||||||
const update = {
|
const update = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [],
|
updatedNodes: [],
|
||||||
addedEdges: [{
|
addedEdges: [
|
||||||
id: "e-new",
|
{
|
||||||
fromNodeId: "missing-node",
|
id: "e-new",
|
||||||
toNodeId: "n1",
|
fromNodeId: "missing-node",
|
||||||
relationship: "supports",
|
toNodeId: "n1",
|
||||||
confidence: "medium",
|
relationship: "supports",
|
||||||
description: "bad edge",
|
confidence: "medium",
|
||||||
}],
|
description: "bad edge",
|
||||||
|
},
|
||||||
|
],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
affectedNodeIds: [],
|
affectedNodeIds: [],
|
||||||
@@ -583,12 +826,14 @@ describe("applyGraphUpdate", () => {
|
|||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
addedNodes: [newNode],
|
addedNodes: [newNode],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Multiple ops test",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Multiple ops test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
|
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
|
||||||
removedEdgeIds: [graph.edges[0]?.id || ""],
|
removedEdgeIds: [graph.edges[0]?.id || ""],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -622,14 +867,16 @@ describe("validateGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = validateGraphUpdate(graph, {
|
const result = validateGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n1",
|
{
|
||||||
previousStatus: null,
|
nodeId: "n1",
|
||||||
newStatus: null,
|
previousStatus: null,
|
||||||
previousValue: null,
|
newStatus: null,
|
||||||
newValue: null,
|
previousValue: null,
|
||||||
reason: "No change test",
|
newValue: null,
|
||||||
}],
|
reason: "No change test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -637,7 +884,7 @@ describe("validateGraphUpdate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("no meaningful"))).toBe(true);
|
expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate node IDs in additions", () => {
|
it("rejects duplicate node IDs in additions", () => {
|
||||||
@@ -661,12 +908,14 @@ describe("validateGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = validateGraphUpdate(graph, {
|
const result = validateGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "ghost-node",
|
{
|
||||||
previousStatus: null,
|
nodeId: "ghost-node",
|
||||||
newStatus: "known",
|
previousStatus: null,
|
||||||
reason: "test",
|
newStatus: "known",
|
||||||
}],
|
reason: "test",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -681,12 +930,14 @@ describe("validateGraphUpdate", () => {
|
|||||||
|
|
||||||
const result = validateGraphUpdate(graph, {
|
const result = validateGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "known",
|
previousStatus: "unknown",
|
||||||
reason: "Confirmed",
|
newStatus: "known",
|
||||||
}],
|
reason: "Confirmed",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -710,7 +961,9 @@ describe("validateGraphUpdate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes("100KB") || e.includes("exceeds"))).toBe(true);
|
expect(
|
||||||
|
result.errors.some((e) => e.includes("100KB") || e.includes("exceeds")),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty errors array for valid update", () => {
|
it("returns empty errors array for valid update", () => {
|
||||||
@@ -738,17 +991,23 @@ describe("update lifecycle integration", () => {
|
|||||||
|
|
||||||
// Create a meaningful update
|
// Create a meaningful update
|
||||||
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
|
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
|
||||||
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n-new", relationship: "supports" });
|
const newEdge = makeEdge({
|
||||||
|
fromNodeId: "n1",
|
||||||
|
toNodeId: "n-new",
|
||||||
|
relationship: "supports",
|
||||||
|
});
|
||||||
|
|
||||||
// Validate first
|
// Validate first
|
||||||
const validationResult = validateGraphUpdate(graph, {
|
const validationResult = validateGraphUpdate(graph, {
|
||||||
addedNodes: [newNode],
|
addedNodes: [newNode],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Answered via follow-up question",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Answered via follow-up question",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [newEdge],
|
addedEdges: [newEdge],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -759,12 +1018,14 @@ describe("update lifecycle integration", () => {
|
|||||||
// Apply
|
// Apply
|
||||||
const applyResult = applyGraphUpdate(graph, {
|
const applyResult = applyGraphUpdate(graph, {
|
||||||
addedNodes: [newNode],
|
addedNodes: [newNode],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Answered via follow-up question",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Answered via follow-up question",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [newEdge],
|
addedEdges: [newEdge],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
@@ -786,7 +1047,9 @@ describe("update lifecycle integration", () => {
|
|||||||
|
|
||||||
const invalidUpdate = {
|
const invalidUpdate = {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{ nodeId: "ghost-node", newStatus: "known", reason: "test" }],
|
updatedNodes: [
|
||||||
|
{ nodeId: "ghost-node", newStatus: "known", reason: "test" },
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: [],
|
resolvedUnknownNodeIds: [],
|
||||||
@@ -806,12 +1069,14 @@ describe("update lifecycle integration", () => {
|
|||||||
|
|
||||||
applyGraphUpdate(graph, {
|
applyGraphUpdate(graph, {
|
||||||
addedNodes: [],
|
addedNodes: [],
|
||||||
updatedNodes: [{
|
updatedNodes: [
|
||||||
nodeId: "n4",
|
{
|
||||||
previousStatus: "unknown",
|
nodeId: "n4",
|
||||||
newStatus: "resolved",
|
previousStatus: "unknown",
|
||||||
reason: "Test preserve",
|
newStatus: "resolved",
|
||||||
}],
|
reason: "Test preserve",
|
||||||
|
},
|
||||||
|
],
|
||||||
addedEdges: [],
|
addedEdges: [],
|
||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n4"],
|
resolvedUnknownNodeIds: ["n4"],
|
||||||
|
|||||||
+7
-1
@@ -64,9 +64,15 @@ test("graph-backed one-turn update smoke test", async ({ page }) => {
|
|||||||
timeout: 240000,
|
timeout: 240000,
|
||||||
});
|
});
|
||||||
await expect(page.getByText(/Resolved unknowns/i)).toBeVisible();
|
await expect(page.getByText(/Resolved unknowns/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/Newly surfaced unknowns/i)).toBeVisible();
|
||||||
await expect(page.getByText(/Affected nodes/i)).toBeVisible();
|
await expect(page.getByText(/Affected nodes/i)).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/No next question selected yet\./i),
|
page.getByText(/Selected Question|Next question:/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
/Additional submission is disabled in this one-update prototype\./i,
|
||||||
|
),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText(/Error:/i)).toHaveCount(0);
|
await expect(page.getByText(/Error:/i)).toHaveCount(0);
|
||||||
await expect(page.getByText(/Update error:/i)).toHaveCount(0);
|
await expect(page.getByText(/Update error:/i)).toHaveCount(0);
|
||||||
|
|||||||
@@ -113,11 +113,37 @@ function makeUpdateSuccess(overrides = {}) {
|
|||||||
value: "1.9 complaints per 100 units",
|
value: "1.9 complaints per 100 units",
|
||||||
unit: null,
|
unit: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "n-next-unknown",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description: "Need a definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
},
|
},
|
||||||
proposal: {
|
proposal: {
|
||||||
addedNodes: [],
|
addedNodes: [
|
||||||
|
{
|
||||||
|
id: "n-next-unknown",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description: "Need a definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
updatedNodes: [
|
updatedNodes: [
|
||||||
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
|
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
|
||||||
],
|
],
|
||||||
@@ -125,6 +151,16 @@ function makeUpdateSuccess(overrides = {}) {
|
|||||||
removedEdgeIds: [],
|
removedEdgeIds: [],
|
||||||
resolvedUnknownNodeIds: ["n-unknown"],
|
resolvedUnknownNodeIds: ["n-unknown"],
|
||||||
affectedNodeIds: ["n-conclusion"],
|
affectedNodeIds: ["n-conclusion"],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-next-unknown",
|
||||||
|
question: "How should commercial value be defined for this decision?",
|
||||||
|
reason: "A narrower consequential uncertainty remains.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-next-unknown",
|
||||||
|
question: "How should commercial value be defined for this decision?",
|
||||||
|
reason: "A narrower consequential uncertainty remains.",
|
||||||
},
|
},
|
||||||
affectedNodeIds: ["n-conclusion"],
|
affectedNodeIds: ["n-conclusion"],
|
||||||
resolvedUnknownNodeIds: ["n-unknown"],
|
resolvedUnknownNodeIds: ["n-unknown"],
|
||||||
@@ -323,6 +359,20 @@ describe("graph-backed UI rendering", () => {
|
|||||||
expect(html).toContain("Complaint rate denominator");
|
expect(html).toContain("Complaint rate denominator");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("newly surfaced unknowns render", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphUpdateView
|
||||||
|
updateResult={{
|
||||||
|
...makeUpdateSuccess(),
|
||||||
|
previousSituationGraph: makeGraphResult().situationGraph,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Newly surfaced unknowns");
|
||||||
|
expect(html).toContain("Commercial value definition");
|
||||||
|
});
|
||||||
|
|
||||||
it("affected nodes render", () => {
|
it("affected nodes render", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<GraphUpdateView
|
<GraphUpdateView
|
||||||
@@ -337,11 +387,33 @@ describe("graph-backed UI rendering", () => {
|
|||||||
expect(html).toContain("Quality deterioration");
|
expect(html).toContain("Quality deterioration");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no fake next question appears", () => {
|
it("renders validated next question when present", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<GraphUpdateView
|
<GraphUpdateView
|
||||||
updateResult={{
|
updateResult={{
|
||||||
...makeUpdateSuccess({ newActiveUnknownNodeId: null }),
|
...makeUpdateSuccess(),
|
||||||
|
previousSituationGraph: makeGraphResult().situationGraph,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain(
|
||||||
|
"How should commercial value be defined for this decision?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no fake next question appears when there is none", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphUpdateView
|
||||||
|
updateResult={{
|
||||||
|
...makeUpdateSuccess({
|
||||||
|
newActiveUnknownNodeId: null,
|
||||||
|
selectedQuestion: null,
|
||||||
|
proposal: {
|
||||||
|
...makeUpdateSuccess().proposal,
|
||||||
|
selectedQuestion: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
previousSituationGraph: makeGraphResult().situationGraph,
|
previousSituationGraph: makeGraphResult().situationGraph,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
@@ -363,7 +435,51 @@ describe("graph-backed UI rendering", () => {
|
|||||||
expect(html).toContain("Previous active unknown");
|
expect(html).toContain("Previous active unknown");
|
||||||
expect(html).toContain("Complaint rate denominator");
|
expect(html).toContain("Complaint rate denominator");
|
||||||
expect(html).toContain("New active unknown");
|
expect(html).toContain("New active unknown");
|
||||||
expect(html).toContain("Unknown node (ID: n-next-unknown)");
|
expect(html).toContain("Commercial value definition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("successful update renders prior and new state together", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphUpdateView
|
||||||
|
updateResult={{
|
||||||
|
...makeUpdateSuccess(),
|
||||||
|
previousSituationGraph: makeGraphResult().situationGraph,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Previous active unknown");
|
||||||
|
expect(html).toContain("Resolved unknowns");
|
||||||
|
expect(html).toContain("Newly surfaced unknowns");
|
||||||
|
expect(html).toContain("New active unknown");
|
||||||
|
expect(html).toContain("Next question");
|
||||||
|
expect(html).toContain(
|
||||||
|
"How should commercial value be defined for this decision?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("situation graph marks newly surfaced and active unknowns", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<SituationGraphView
|
||||||
|
situationGraph={makeUpdateSuccess().updatedSituationGraph}
|
||||||
|
selectedQuestion={makeUpdateSuccess().selectedQuestion}
|
||||||
|
newlySurfacedNodeIds={["n-next-unknown"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("newly surfaced unknown");
|
||||||
|
expect(html).toContain("active unknown");
|
||||||
|
expect(html).toContain("resolved unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disabled follow-up form is shown only as prototype limitation", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphUpdateView
|
||||||
|
updateResult={makeUpdateSuccess()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("How should commercial value be defined for this decision?");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("raw ids remain only in collapsed proposal details", () => {
|
it("raw ids remain only in collapsed proposal details", () => {
|
||||||
@@ -418,6 +534,30 @@ describe("graph-backed UI rendering", () => {
|
|||||||
expect(html).toContain("bad proposal");
|
expect(html).toContain("bad proposal");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("failed update does not fabricate history", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<>
|
||||||
|
<UpdateErrorPanel
|
||||||
|
updateError={{
|
||||||
|
error: "Update case failed",
|
||||||
|
errors: [
|
||||||
|
'New unknown must be explicitly related to an answer-derived node: "nu_commercial_val"',
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<GraphUpdateView updateResult={null} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Update error: Update case failed");
|
||||||
|
expect(html).toContain("nu_commercial_val");
|
||||||
|
expect(html).not.toContain("Previous active unknown");
|
||||||
|
expect(html).not.toContain("Resolved unknowns");
|
||||||
|
expect(html).not.toContain("Newly surfaced unknowns");
|
||||||
|
expect(html).not.toContain("New active unknown");
|
||||||
|
expect(html).not.toContain("Proposal details");
|
||||||
|
});
|
||||||
|
|
||||||
it("proposal details remain collapsible", () => {
|
it("proposal details remain collapsible", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<GraphUpdateView updateResult={makeUpdateSuccess()} />,
|
<GraphUpdateView updateResult={makeUpdateSuccess()} />,
|
||||||
|
|||||||
Reference in New Issue
Block a user