From 4affadab4b9f629aa188c1f56883d235247d9212 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 12:17:49 +0100 Subject: [PATCH] fix: link emergent unknowns to answer-derived graph nodes --- lib/graph/apply-proposal.js | 49 ++++- scripts/reproduce-commercial-value-update.mjs | 97 +++++++++ tests/graph/apply-proposal.test.js | 202 ++++++++++++++++++ tests/ui/scenario-form.test.jsx | 44 ++++ 4 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 scripts/reproduce-commercial-value-update.mjs diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index 1d0a233..c92ca83 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -87,6 +87,45 @@ function validateAddedUnknowns(graph, proposal) { .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 = [ @@ -128,15 +167,7 @@ function validateAddedUnknowns(graph, proposal) { ); } - const connectedEdge = proposal.addedEdges.find( - (edge) => - (edge.fromNodeId === unknownNode.id && - answerDerivedNodeIds.has(edge.toNodeId)) || - (edge.toNodeId === unknownNode.id && - answerDerivedNodeIds.has(edge.fromNodeId)), - ); - - if (!connectedEdge) { + if (!hasExplicitAnswerDerivedRelationship(unknownNode)) { errors.push( `New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`, ); diff --git a/scripts/reproduce-commercial-value-update.mjs b/scripts/reproduce-commercial-value-update.mjs new file mode 100644 index 0000000..d789f05 --- /dev/null +++ b/scripts/reproduce-commercial-value-update.mjs @@ -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; +}); diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index bc68e5f..f43fe1a 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -623,6 +623,208 @@ describe("applyValidatedProposal", () => { ); }); + 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(); diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx index f0f39fe..0a29573 100644 --- a/tests/ui/scenario-form.test.jsx +++ b/tests/ui/scenario-form.test.jsx @@ -438,6 +438,26 @@ describe("graph-backed UI rendering", () => { expect(html).toContain("Commercial value definition"); }); + it("successful update renders prior and new state together", () => { + const html = renderToStaticMarkup( + , + ); + + 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( { expect(html).toContain("bad proposal"); }); + it("failed update does not fabricate history", () => { + const html = renderToStaticMarkup( + <> + + + , + ); + + 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", () => { const html = renderToStaticMarkup( ,