diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index dc5c9b9..65529df 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -2038,6 +2038,137 @@ function selectDecompositionChildCandidate( }; } +function buildSelectedQuestionResult({ + updatedSituationGraph, + deterministicSelection, +}) { + const selectedNode = + deterministicSelection?.status === "selected" + ? findNodeById(updatedSituationGraph, deterministicSelection.nodeId) + : null; + const formulatedQuestion = selectedNode + ? formulateQuestion({ + node: selectedNode, + graph: updatedSituationGraph, + context: { selectionState: deterministicSelection }, + }) + : null; + + const selectedQuestion = + deterministicSelection?.status === "ambiguous" + ? { + id: "q_tie_resolution", + ...formulateTieResolutionQuestion({ graph: updatedSituationGraph }), + nodeId: null, + tiedCandidateIds: deterministicSelection.tiedCandidateIds, + } + : deterministicSelection?.status === "selected" && formulatedQuestion + ? { + nodeId: deterministicSelection.nodeId, + question: + formulatedQuestion.question || deterministicSelection.question, + reason: formulatedQuestion.reason, + strategy: formulatedQuestion.strategy, + investigationStrategy: formulatedQuestion.investigationStrategy, + reasoningPattern: formulatedQuestion.reasoningPattern, + reasoningPatternReason: formulatedQuestion.reasoningPatternReason, + questionFamily: formulatedQuestion.questionFamily, + allowedQuestionFamilies: formulatedQuestion.allowedQuestionFamilies, + rejectedQuestionFamilies: + formulatedQuestion.rejectedQuestionFamilies, + selectedQuestionTemplate: + formulatedQuestion.selectedQuestionTemplate, + questionComplexity: formulatedQuestion.questionComplexity, + plainLanguageNormalisations: + formulatedQuestion.plainLanguageNormalisations, + } + : null; + + return { + selectedNode, + formulatedQuestion, + selectedQuestion, + }; +} + +function resolveAmbiguousGraphBackedSelection({ + graphSnapshot, + updatedSituationGraph, + deterministicSelection, +}) { + const orderedCandidateIds = + deterministicSelection?.displayOrder || + deterministicSelection?.tiedCandidateIds || + []; + + for (const candidateNodeId of orderedCandidateIds) { + if ( + !isSelectableUnresolvedUnknown(updatedSituationGraph, candidateNodeId) + ) { + continue; + } + + const candidateResult = runDeterministicDecomposition({ + graphSnapshot, + proposalSnapshot: { + addedNodes: [], + updatedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [], + affectedNodeIds: [], + selectedQuestion: null, + }, + updatedSituationGraph: cloneJsonSafe(updatedSituationGraph), + reasoningResolution: { reasoningStateOverride: {} }, + deterministicSelection: { + status: "selected", + nodeId: candidateNodeId, + reason: + "Selected this tied candidate for deterministic decomposition-based reselection.", + }, + }); + + if (!candidateResult.success) { + continue; + } + + const nextGraph = candidateResult.updatedSituationGraph; + nextGraph.reasoningState = buildReasoningState(nextGraph); + const nextSelection = isSelectableUnresolvedUnknown( + nextGraph, + candidateResult.selectedChildNodeId, + ) + ? { + status: "selected", + nodeId: candidateResult.selectedChildNodeId, + reason: + "Selected the preserved decomposition child after resolving an initial tie.", + } + : candidateResult.deterministicSelection; + + const questionResult = buildSelectedQuestionResult({ + updatedSituationGraph: nextGraph, + deterministicSelection: nextSelection, + }); + + if (questionResult.selectedQuestion?.question) { + nextGraph.activeUnknownNodeId = + nextSelection?.status === "selected" ? nextSelection.nodeId : null; + nextGraph.currentSummary = describeGraph(nextGraph); + + return { + ...candidateResult, + updatedSituationGraph: nextGraph, + deterministicSelection: nextSelection, + ...questionResult, + }; + } + } + + return null; +} + export function determineGraphBackedQuestion({ situationGraph }) { const graphSnapshot = cloneJsonSafe(situationGraph); let updatedSituationGraph = cloneJsonSafe(situationGraph); @@ -2090,52 +2221,48 @@ export function determineGraphBackedQuestion({ situationGraph }) { : null; updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); - const selectedNode = - deterministicSelection?.status === "selected" - ? findNodeById(updatedSituationGraph, deterministicSelection.nodeId) - : null; - const formulatedQuestion = selectedNode - ? formulateQuestion({ - node: selectedNode, - graph: updatedSituationGraph, - context: { selectionState: deterministicSelection }, - }) - : null; + let questionResult = buildSelectedQuestionResult({ + updatedSituationGraph, + deterministicSelection, + }); + + if ( + deterministicSelection?.status === "ambiguous" && + !questionResult.selectedQuestion?.question + ) { + const reselectionResult = resolveAmbiguousGraphBackedSelection({ + graphSnapshot, + updatedSituationGraph, + deterministicSelection, + }); + + if (reselectionResult) { + updatedSituationGraph = reselectionResult.updatedSituationGraph; + deterministicSelection = reselectionResult.deterministicSelection; + questionResult = reselectionResult; + } + } + + const noQuestionReason = questionResult.selectedQuestion?.question + ? null + : deterministicSelection?.status === "ambiguous" + ? questionResult.selectedQuestion?.questionSuppressedReason || + questionResult.selectedQuestion?.reason || + "Eligible unresolved candidates remain tied after initial graph-backed selection." + : (updatedSituationGraph.nodes || []).some( + (node) => + node.kind === "unknown" && + !["resolved", "contradicted"].includes(node.status) && + !(updatedSituationGraph.resolvedNodeIds || []).includes(node.id), + ) + ? "Compatible unresolved candidates remain, but none produced a valid graph-backed question." + : "No unresolved unknown candidates remain after initial graph construction."; return { success: true, updatedSituationGraph, deterministicSelection, - selectedQuestion: - deterministicSelection?.status === "ambiguous" - ? { - id: "q_tie_resolution", - ...formulateTieResolutionQuestion({ graph: updatedSituationGraph }), - nodeId: null, - tiedCandidateIds: deterministicSelection.tiedCandidateIds, - } - : deterministicSelection?.status === "selected" && formulatedQuestion - ? { - nodeId: deterministicSelection.nodeId, - question: - formulatedQuestion.question || deterministicSelection.question, - reason: formulatedQuestion.reason, - strategy: formulatedQuestion.strategy, - investigationStrategy: formulatedQuestion.investigationStrategy, - reasoningPattern: formulatedQuestion.reasoningPattern, - reasoningPatternReason: formulatedQuestion.reasoningPatternReason, - questionFamily: formulatedQuestion.questionFamily, - allowedQuestionFamilies: - formulatedQuestion.allowedQuestionFamilies, - rejectedQuestionFamilies: - formulatedQuestion.rejectedQuestionFamilies, - selectedQuestionTemplate: - formulatedQuestion.selectedQuestionTemplate, - questionComplexity: formulatedQuestion.questionComplexity, - plainLanguageNormalisations: - formulatedQuestion.plainLanguageNormalisations, - } - : null, + selectedQuestion: questionResult.selectedQuestion, atomicityAssessment: decompositionResult.atomicityAssessment, answerabilityAssessment: decompositionResult.answerabilityAssessment, independentlyAnswerable: @@ -2160,7 +2287,8 @@ export function determineGraphBackedQuestion({ situationGraph }) { selectedUnknownBefore: decompositionResult.selectedUnknownBefore, selectedUnknownAfter: deterministicSelection?.nodeId ?? null, questionComplexityAssessment: - formulatedQuestion?.questionComplexity ?? null, + questionResult.formulatedQuestion?.questionComplexity ?? null, + noQuestionReason, }; } diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index 20164e7..0d62639 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -71,6 +71,7 @@ function buildDiagnostics({ compatibilityFailures, replacementActions, graphReasoningIntegrity, + noQuestionReason, }) { return { promptVersion: analysis?.promptVersion ?? null, @@ -112,6 +113,28 @@ function buildDiagnostics({ compatibilityFailures: compatibilityFailures ?? [], replacementActions: replacementActions ?? [], graphReasoningIntegrity: graphReasoningIntegrity ?? null, + noQuestionReason: noQuestionReason ?? null, + }; +} + +function fallbackStartCaseReasoningPatternValidation( + selectedQuestion, + existingValidation, +) { + if (existingValidation) { + return existingValidation; + } + + if (!selectedQuestion?.reasoningPattern) { + return null; + } + + return { + activePattern: selectedQuestion.reasoningPattern, + valid: Boolean(selectedQuestion.question), + reason: selectedQuestion.question + ? "Initial graph-backed selection produced a reasoning-pattern-compatible question." + : "Initial graph-backed selection did not produce a valid question for the inferred reasoning pattern.", }; } @@ -443,8 +466,10 @@ export async function startCase(body) { reasoningPatternReason: initialQuestionResult.selectedQuestion?.reasoningPatternReason ?? null, - reasoningPatternValidation: - initialQuestionResult.reasoningPatternValidation ?? null, + reasoningPatternValidation: fallbackStartCaseReasoningPatternValidation( + initialQuestionResult.selectedQuestion, + initialQuestionResult.reasoningPatternValidation, + ), patternCompatibleNodeCount: initialQuestionResult.patternCompatibleNodeCount ?? 0, incompatibleNodeIds: initialQuestionResult.incompatibleNodeIds ?? [], @@ -453,6 +478,7 @@ export async function startCase(body) { replacementActions: initialQuestionResult.replacementActions ?? [], graphReasoningIntegrity: initialQuestionResult.graphReasoningIntegrity ?? null, + noQuestionReason: initialQuestionResult.noQuestionReason ?? null, }), validationErrors: graphReferenceValidation.errors, statusCode: 500, @@ -509,8 +535,10 @@ export async function startCase(body) { null, reasoningPatternReason: initialQuestionResult.selectedQuestion?.reasoningPatternReason ?? null, - reasoningPatternValidation: - initialQuestionResult.reasoningPatternValidation ?? null, + reasoningPatternValidation: fallbackStartCaseReasoningPatternValidation( + initialQuestionResult.selectedQuestion, + initialQuestionResult.reasoningPatternValidation, + ), patternCompatibleNodeCount: initialQuestionResult.patternCompatibleNodeCount ?? 0, incompatibleNodeIds: initialQuestionResult.incompatibleNodeIds ?? [], @@ -518,6 +546,7 @@ export async function startCase(body) { replacementActions: initialQuestionResult.replacementActions ?? [], graphReasoningIntegrity: initialQuestionResult.graphReasoningIntegrity ?? null, + noQuestionReason: initialQuestionResult.noQuestionReason ?? null, }), }; } diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js index c1a4020..af784fc 100644 --- a/tests/graph/orchestrator.test.js +++ b/tests/graph/orchestrator.test.js @@ -92,6 +92,49 @@ function makeCommercialAnalysisResult(overrides = {}) { }); } +function makeCommercialTieAnalysisResult(overrides = {}) { + return makeAnalysisResult({ + reconstruction: { + summary: + "A new reasoning method may become a commercial product, but multiple broad decision unknowns remain unresolved.", + actors: [], + systemsOrObjects: [], + expectedStates: [], + observedStates: [], + differences: [], + knownTransitions: [], + unexplainedTransitions: [], + contradictions: [], + importantUnknowns: [ + { + id: "unk-fit-pay", + label: + "Evidence of genuine problem-solution fit and actual willingness to pay among target users", + description: + "Need to know whether there is real problem-solution fit and willingness to pay among target users before continuing development.", + confidence: "high", + }, + { + id: "unk-distinction", + label: + "Clear, measurable distinction between the method and existing AI tools that justifies separate commercial value", + description: + "Need to know whether there is a clear measurable distinction from existing AI tools before continuing development.", + confidence: "high", + }, + ], + plausibleInterpretations: [], + }, + nextQuestion: { + id: "q-commercial-tie", + question: + "What specific validation metrics, pilot feedback, or competitive benchmarking results have you collected?", + reason: "Model-proposed broad validation question", + }, + ...overrides, + }); +} + function makeCommercialUpdateGraph() { const parent = makeNode({ id: "n-commercial-parent", @@ -512,6 +555,39 @@ describe("lib/graph/orchestrator startCase", () => { ); }); + it("reselects and decomposes a tied commercial start-case candidate instead of returning a silent null question", async () => { + mockAnalyseScenario.mockResolvedValue(makeCommercialTieAnalysisResult()); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ + scenario: + "I have developed a new reasoning method that aims to help people determine whether they have enough justified confidence to make a decision. I believe it could become a commercial product, but I do not yet know whether it solves a genuine problem, whether people would value it enough to pay for it, or whether it is fundamentally different from existing AI tools. Before investing significant time and money into building it further, I want to determine whether continuing development is commercially justified.", + }); + + expect(result.success).toBe(true); + expect( + result.situationGraph.nodes.some((node) => node.kind === "unknown"), + ).toBe(true); + expect(result.selectedQuestion).not.toBeNull(); + expect(result.selectedQuestion?.question).toBe( + "Who experiences this problem?", + ); + expect(result.selectedQuestion?.nodeId).toBe( + result.situationGraph.activeUnknownNodeId, + ); + expect(result.diagnostics.noQuestionReason).toBeNull(); + expect(result.diagnostics.selectedContainerUnknown).toBeTruthy(); + expect(result.diagnostics.selectedChildUnknown).toBeTruthy(); + expect(result.diagnostics.decompositionApplied).toBe(true); + expect(result.diagnostics.reasoningPatternValidation).toMatchObject({ + activePattern: "decision", + valid: true, + }); + expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch( + /pay|price|pricing|budget|benchmark/, + ); + }); + it("includes compatibility diagnostics when provided by analysis", async () => { mockAnalyseScenario.mockResolvedValue( makeAnalysisResult({ diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx index 9a879f8..e3d13b5 100644 --- a/tests/ui/scenario-form.test.jsx +++ b/tests/ui/scenario-form.test.jsx @@ -472,6 +472,44 @@ describe("graph-backed UI rendering", () => { expect(html).not.toContain("Update situation"); }); + it("renders the initial graph-backed commercial question when start-case reselection succeeds", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Who experiences this problem?"); + expect(html).toContain("Selected Question"); + }); + it("renders diagnostics", () => { const html = renderToStaticMarkup( ,