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 ||