feat: decompose non-answerable unknowns

This commit is contained in:
2026-08-03 09:53:28 +01:00
parent db994d7764
commit 42d4da3496
5 changed files with 477 additions and 12 deletions
+188 -12
View File
@@ -1,5 +1,6 @@
import { describeGraph } from "./builder.js";
import {
assessUnknownAnswerability,
assessUnknownAtomicity,
buildReasoningState,
classifyObservationRelationship,
@@ -1624,6 +1625,63 @@ function findNodeById(graph, nodeId) {
return (graph.nodes || []).find((node) => node.id === nodeId) || null;
}
function isSelectableUnresolvedUnknown(graph, nodeId) {
const node = findNodeById(graph, nodeId);
return Boolean(
node &&
node.kind === "unknown" &&
!["resolved", "contradicted"].includes(node.status) &&
!(graph.resolvedNodeIds || []).includes(node.id),
);
}
function selectedQuestionBelongsToChild(graph, selectedQuestion) {
if (!selectedQuestion?.nodeId) return false;
return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId);
}
function selectDecompositionChildCandidate(graph, parentNodeId) {
const childCandidates = findDirectChildUnknowns(graph, parentNodeId).filter(
(node) =>
node.kind === "unknown" &&
!["resolved", "contradicted"].includes(node.status),
);
if (childCandidates.length === 0) {
return { status: "none", nodeId: null, tiedCandidateIds: [] };
}
const scored = childCandidates.map((node) => ({
node,
score:
scoreUnknownCandidate(graph, node, graph.resolvedNodeIds || []).score ??
Number.NEGATIVE_INFINITY,
}));
const topScore = Math.max(...scored.map((item) => item.score));
const top = scored.filter((item) => item.score === topScore);
if (top.length === 0) {
return { status: "none", nodeId: null, tiedCandidateIds: [] };
}
if (top.length > 1) {
return {
status: "ambiguous",
nodeId: null,
tiedCandidateIds: top.map((item) => item.node.id),
reason:
"Multiple decomposition children remain equally good next investigations.",
};
}
return {
status: "selected",
nodeId: top[0].node.id,
reason:
"Selected the strongest direct child investigation for a non-answerable parent unknown.",
};
}
export function determineGraphBackedQuestion({ situationGraph }) {
const graphSnapshot = cloneJsonSafe(situationGraph);
let updatedSituationGraph = cloneJsonSafe(situationGraph);
@@ -1656,10 +1714,20 @@ export function determineGraphBackedQuestion({ situationGraph }) {
updatedSituationGraph.reasoningState = buildReasoningState(
updatedSituationGraph,
);
deterministicSelection = selectActiveUnknownCandidate(
deterministicSelection = isSelectableUnresolvedUnknown(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds || [],
);
decompositionResult.selectedChildNodeId,
)
? {
status: "selected",
nodeId: decompositionResult.selectedChildNodeId,
reason:
"Selected the preserved decomposition child because it remains the strongest independently answerable investigation.",
}
: selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds || [],
);
updatedSituationGraph.activeUnknownNodeId =
deterministicSelection?.status === "selected"
? deterministicSelection.nodeId
@@ -1704,10 +1772,26 @@ export function determineGraphBackedQuestion({ situationGraph }) {
}
: null,
atomicityAssessment: decompositionResult.atomicityAssessment,
answerabilityAssessment: decompositionResult.answerabilityAssessment,
independentlyAnswerable:
decompositionResult.answerabilityAssessment?.independentlyAnswerable ??
null,
prerequisiteConceptCount:
decompositionResult.answerabilityAssessment?.prerequisiteConceptCount ??
null,
decompositionPerformed:
decompositionResult.decompositionAttempted &&
decompositionResult.decompositionAccepted,
decompositionAttempted: decompositionResult.decompositionAttempted,
decompositionTriggeredByAnswerability:
decompositionResult.decompositionTriggeredByAnswerability ?? false,
selectedContainerUnknown:
decompositionResult.selectedContainerUnknown ?? null,
selectedChildUnknown:
decompositionResult.selectedChildNodeId ??
(deterministicSelection?.status === "selected"
? deterministicSelection.nodeId
: null),
selectedUnknownBefore: decompositionResult.selectedUnknownBefore,
selectedUnknownAfter: deterministicSelection?.nodeId ?? null,
questionComplexityAssessment:
@@ -1727,7 +1811,9 @@ function runDeterministicDecomposition({
let workingProposal = proposalSnapshot;
let nextReasoningState = workingGraph.reasoningState;
let lastAtomicityAssessment = null;
let lastAnswerabilityAssessment = null;
let rootAtomicityAssessment = null;
let rootAnswerabilityAssessment = null;
let decompositionDepth = 0;
let decompositionAttempted = false;
let decompositionAccepted = false;
@@ -1742,6 +1828,8 @@ function runDeterministicDecomposition({
let rejectedChildren = [];
let childQualitySummary = [];
let decompositionTriggeredByQuestionComplexity = false;
let decompositionTriggeredByAnswerability = false;
let selectedContainerUnknown = null;
while (workingSelection?.status === "selected" && workingSelection?.nodeId) {
const selectedNode = findNodeById(workingGraph, workingSelection.nodeId);
@@ -1755,13 +1843,28 @@ function runDeterministicDecomposition({
node: selectedNode,
graph: workingGraph,
});
const answerabilityAssessment = assessUnknownAnswerability({
node: selectedNode,
graph: workingGraph,
});
lastAtomicityAssessment = atomicityAssessment;
lastAnswerabilityAssessment = answerabilityAssessment;
if (!rootAtomicityAssessment) {
rootAtomicityAssessment = atomicityAssessment;
}
if (!rootAnswerabilityAssessment) {
rootAnswerabilityAssessment = answerabilityAssessment;
}
if (atomicityAssessment.atomicity === "atomic") {
selectedChildNodeId = decompositionDepth > 0 ? selectedNode.id : null;
const decompositionRequired =
atomicityAssessment.atomicity !== "atomic" ||
!answerabilityAssessment.independentlyAnswerable;
if (!decompositionRequired) {
selectedChildNodeId =
decompositionDepth > 0 || selectedNode.parentId
? selectedNode.id
: null;
decompositionStoppedReason =
decompositionDepth > 0
? "Selected child is atomic and directly answerable."
@@ -1769,9 +1872,37 @@ function runDeterministicDecomposition({
break;
}
if (!selectedContainerUnknown) {
selectedContainerUnknown = selectedNode.id;
}
if (!answerabilityAssessment.independentlyAnswerable) {
decompositionTriggeredByAnswerability = true;
}
if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) {
const childSelection = selectDecompositionChildCandidate(
workingGraph,
selectedNode.id,
);
if (childSelection.status === "selected") {
workingSelection = childSelection;
decompositionStoppedReason =
"Selected child is atomic and directly answerable.";
selectedChildNodeId =
findNodeById(workingGraph, childSelection.nodeId)?.parentId ===
selectedNode.id
? childSelection.nodeId
: null;
break;
}
if (childSelection.status === "ambiguous") {
workingSelection = childSelection;
decompositionStoppedReason =
"Selected parent is not independently answerable and its existing child investigations are tied.";
break;
}
decompositionStoppedReason =
"Selected composite parent already has decomposition children, so they should be reused instead of regenerated.";
"Selected parent is not independently answerable, but no unresolved child investigation remained available.";
break;
}
@@ -1831,9 +1962,9 @@ function runDeterministicDecomposition({
reasoningResolution.reasoningStateOverride,
);
workingGraph.reasoningState = nextReasoningState;
workingSelection = selectActiveUnknownCandidate(
workingSelection = selectDecompositionChildCandidate(
workingGraph,
workingGraph.resolvedNodeIds,
selectedNode.id,
);
if (workingSelection?.status !== "selected") {
@@ -1851,6 +1982,13 @@ function runDeterministicDecomposition({
break;
}
if (
findNodeById(workingGraph, workingSelection.nodeId)?.parentId ===
selectedNode.id
) {
selectedChildNodeId = workingSelection.nodeId;
}
decompositionAccepted = true;
decompositionDepth += 1;
}
@@ -1863,6 +2001,8 @@ function runDeterministicDecomposition({
deterministicSelection: workingSelection,
atomicityAssessment:
rootAtomicityAssessment ?? lastAtomicityAssessment ?? null,
answerabilityAssessment:
rootAnswerabilityAssessment ?? lastAnswerabilityAssessment ?? null,
decompositionDepth,
decompositionAttempted,
decompositionAccepted,
@@ -1874,6 +2014,8 @@ function runDeterministicDecomposition({
selectedChildNodeId,
selectedUnknownBefore,
decompositionTriggeredByQuestionComplexity,
decompositionTriggeredByAnswerability,
selectedContainerUnknown,
};
}
@@ -2205,12 +2347,23 @@ export function applyValidatedProposal({
reasoningResolution.reasoningStateOverride,
);
updatedSituationGraph.reasoningState = nextReasoningState;
deterministicSelection = selectActiveUnknownCandidate(
deterministicSelection = isSelectableUnresolvedUnknown(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
decompositionResult.selectedChildNodeId,
)
? {
status: "selected",
nodeId: decompositionResult.selectedChildNodeId,
reason:
"Preserved the selected decomposition child because it remains unresolved after propagation.",
}
: selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
const atomicityAssessment = decompositionResult.atomicityAssessment;
const answerabilityAssessment = decompositionResult.answerabilityAssessment;
const decompositionDepth = decompositionResult.decompositionDepth;
const decompositionAttempted = decompositionResult.decompositionAttempted;
const decompositionAccepted = decompositionResult.decompositionAccepted;
@@ -2321,6 +2474,15 @@ export function applyValidatedProposal({
}
: null;
const finalSelectedChildNodeId =
selectedChildNodeId ??
(selectedQuestionBelongsToChild(
updatedSituationGraph,
finalSelectedQuestion,
)
? finalSelectedQuestion?.nodeId
: null);
const resultGraphValidation = situationGraphSchema.safeParse(
updatedSituationGraph,
);
@@ -2374,6 +2536,11 @@ export function applyValidatedProposal({
emergentReasoningNodeReason: emergentReasoningUnknown?.reason ?? null,
atomicityAssessment: atomicityAssessment?.atomicity ?? null,
atomicityDecisionReason: atomicityAssessment?.reason ?? null,
answerabilityAssessment,
independentlyAnswerable:
answerabilityAssessment?.independentlyAnswerable ?? null,
prerequisiteConceptCount:
answerabilityAssessment?.prerequisiteConceptCount ?? null,
decompositionDepth,
decompositionAttempted,
decompositionAccepted,
@@ -2381,7 +2548,7 @@ export function applyValidatedProposal({
proposedChildCount,
acceptedChildCount,
rejectedChildren,
selectedChildNodeId,
selectedChildNodeId: finalSelectedChildNodeId,
childQualitySummary,
selectedUnknownBefore: decompositionResult.selectedUnknownBefore,
selectedUnknownAfter: deterministicSelection?.nodeId ?? null,
@@ -2391,6 +2558,8 @@ export function applyValidatedProposal({
complexityReasons: questionComplexity?.reasons ?? [],
decompositionTriggeredByQuestionComplexity:
decompositionResult.decompositionTriggeredByQuestionComplexity ?? false,
decompositionTriggeredByAnswerability:
decompositionResult.decompositionTriggeredByAnswerability ?? false,
previousQuestion,
finalQuestion: finalSelectedQuestion?.question ?? null,
plainLanguageNormalisations,
@@ -2423,6 +2592,13 @@ export function applyValidatedProposal({
decompositionPerformed,
childUnknownCount: decompositionChildNodeIds.length,
childNodeIds: decompositionChildNodeIds,
selectedContainerUnknown:
decompositionResult.selectedContainerUnknown ?? null,
selectedChildUnknown:
finalSelectedChildNodeId ??
(deterministicSelection?.status === "selected"
? deterministicSelection.nodeId
: null),
atomicityReason:
propagationReason ||
decompositionReason ||
+42
View File
@@ -52,6 +52,13 @@ function buildDiagnostics({
selectedUnknownNodeId,
decompositionApplied,
questionComplexityAssessment,
answerabilityAssessment,
independentlyAnswerable,
prerequisiteConceptCount,
decompositionTriggeredByAnswerability,
decompositionReason,
selectedContainerUnknown,
selectedChildUnknown,
}) {
return {
promptVersion: analysis?.promptVersion ?? null,
@@ -73,6 +80,14 @@ function buildDiagnostics({
selectedUnknownNodeId: selectedUnknownNodeId ?? null,
decompositionApplied: decompositionApplied ?? false,
questionComplexityAssessment: questionComplexityAssessment ?? null,
answerabilityAssessment: answerabilityAssessment ?? null,
independentlyAnswerable: independentlyAnswerable ?? null,
prerequisiteConceptCount: prerequisiteConceptCount ?? null,
decompositionTriggeredByAnswerability:
decompositionTriggeredByAnswerability ?? false,
decompositionReason: decompositionReason ?? null,
selectedContainerUnknown: selectedContainerUnknown ?? null,
selectedChildUnknown: selectedChildUnknown ?? null,
};
}
@@ -341,6 +356,20 @@ export async function startCase(body) {
initialQuestionResult.decompositionPerformed ?? false,
questionComplexityAssessment:
initialQuestionResult.questionComplexityAssessment ?? null,
answerabilityAssessment:
initialQuestionResult.answerabilityAssessment ?? null,
independentlyAnswerable:
initialQuestionResult.independentlyAnswerable ?? null,
prerequisiteConceptCount:
initialQuestionResult.prerequisiteConceptCount ?? null,
decompositionTriggeredByAnswerability:
initialQuestionResult.decompositionTriggeredByAnswerability ?? false,
decompositionReason:
initialQuestionResult.selectedQuestion?.reason ?? null,
selectedContainerUnknown:
initialQuestionResult.selectedContainerUnknown ?? null,
selectedChildUnknown:
initialQuestionResult.selectedChildUnknown ?? null,
}),
validationErrors: graphReferenceValidation.errors,
statusCode: 500,
@@ -371,6 +400,19 @@ export async function startCase(body) {
initialQuestionResult.decompositionPerformed ?? false,
questionComplexityAssessment:
initialQuestionResult.questionComplexityAssessment ?? null,
answerabilityAssessment:
initialQuestionResult.answerabilityAssessment ?? null,
independentlyAnswerable:
initialQuestionResult.independentlyAnswerable ?? null,
prerequisiteConceptCount:
initialQuestionResult.prerequisiteConceptCount ?? null,
decompositionTriggeredByAnswerability:
initialQuestionResult.decompositionTriggeredByAnswerability ?? false,
decompositionReason:
initialQuestionResult.selectedQuestion?.reason ?? null,
selectedContainerUnknown:
initialQuestionResult.selectedContainerUnknown ?? null,
selectedChildUnknown: initialQuestionResult.selectedChildUnknown ?? null,
}),
};
}
+87
View File
@@ -569,6 +569,93 @@ function isDirectlyAnswerableObservationChildText(text) {
);
}
function countPrerequisiteConceptSignals(text) {
let count = 0;
if (/\bproblem\b/.test(text)) count += 1;
if (/\b(audience|customer|user|buyer|stakeholder|recipient)\b/.test(text))
count += 1;
if (/\b(demand|seek help|actively look for help)\b/.test(text)) count += 1;
if (/\b(pay|willingness to pay|price|pricing)\b/.test(text)) count += 1;
if (
/\b(compare|comparison|different from|alternatives|alternative|existing alternatives|existing tools|better than)\b/.test(
text,
)
)
count += 1;
if (/\b(value|viability|justified|business case|commercial)\b/.test(text))
count += 1;
if (/\b(feasibility|technical)\b/.test(text)) count += 1;
return count;
}
function countIndependentAnswerDimensions(node, graph) {
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
const relatedNodes = collectRelatedNodes(node, graph);
const unresolvedDependencies = relatedNodes.filter(
(relatedNode) =>
relatedNode.kind === "unknown" && relatedNode.status !== "resolved",
).length;
const conjunctionCount = (text.match(/\b(and|or)\b/g) || []).length;
const prerequisiteConceptCount = countPrerequisiteConceptSignals(text);
const implicitConclusion =
/\b(commercially justified|commercial justification|commercial viability|business case|customer value|market demand|product validation|technical feasibility)\b/.test(
text,
) ||
(/\bevidence that\b/.test(text) &&
/\b(problem|need|demand|audience|customer|user|alternatives|better than)\b/.test(
text,
)) ||
(/\bwhether\b/.test(text) &&
/\b(addresses|solve|solves|justifies|supports|demonstrates)\b/.test(
text,
) &&
/\b(problem|value|need|demand|audience|customer|user)\b/.test(text));
return {
prerequisiteConceptCount,
unresolvedDependencies,
conjunctionCount,
implicitConclusion,
multipleEvidenceDimensions:
prerequisiteConceptCount >= 2 || conjunctionCount >= 2,
};
}
export function assessUnknownAnswerability({ node, graph }) {
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
const dimensionSummary = countIndependentAnswerDimensions(node, graph);
const independentlyAnswerable =
!dimensionSummary.implicitConclusion &&
!dimensionSummary.multipleEvidenceDimensions &&
dimensionSummary.unresolvedDependencies === 0 &&
dimensionSummary.prerequisiteConceptCount <= 1;
if (independentlyAnswerable) {
return {
independentlyAnswerable: true,
reason:
"This unknown can be answered directly without first resolving several prerequisite investigations.",
prerequisiteConceptCount: dimensionSummary.prerequisiteConceptCount,
decompositionRequired: false,
};
}
return {
independentlyAnswerable: false,
reason: dimensionSummary.implicitConclusion
? "This unknown asks for a higher-level conclusion that depends on several smaller investigations."
: "This unknown still bundles multiple prerequisite evidence dimensions, so it should be decomposed before it becomes the selected question.",
prerequisiteConceptCount: Math.max(
dimensionSummary.prerequisiteConceptCount,
dimensionSummary.unresolvedDependencies,
dimensionSummary.conjunctionCount + 1,
),
decompositionRequired: true,
};
}
export function assessUnknownAtomicity({ node, graph }) {
const nodeText = normaliseText(
`${node?.label || ""} ${node?.description || ""}`,