Feature/product platform foundation v0.62 #1
@@ -73,6 +73,16 @@ The orchestrator now reports:
|
||||
- `rejectedChildren`
|
||||
- `selectedChildNodeId`
|
||||
- `childQualitySummary`
|
||||
- `propagationPerformed`
|
||||
- `resolvedChildNodeId`
|
||||
- `parentNodeId`
|
||||
- `parentStatusBefore`
|
||||
- `parentStatusAfter`
|
||||
- `parentConfidenceBefore`
|
||||
- `parentConfidenceAfter`
|
||||
- `affectedAncestorIds`
|
||||
- `nextSelectedSibling`
|
||||
- `parentResolved`
|
||||
- `decompositionPerformed`
|
||||
- `childUnknownCount`
|
||||
- `childNodeIds`
|
||||
@@ -94,6 +104,10 @@ After this change:
|
||||
- repeated updates reuse the same decomposition children deterministically
|
||||
- child-quality checks reject compound or duplicate children before they enter the graph
|
||||
- decomposition stops deterministically once a selected child is directly answerable
|
||||
- resolving one child does not resolve the parent immediately
|
||||
- resolved child evidence now propagates upward to the parent and ancestor chain deterministically
|
||||
- parent status and confidence change conservatively after child resolution
|
||||
- the next sibling becomes eligible for normal deterministic selection without recreating the resolved child
|
||||
|
||||
In the revenue-versus-cash case, the selected next question becomes:
|
||||
|
||||
@@ -101,11 +115,34 @@ In the revenue-versus-cash case, the selected next question becomes:
|
||||
|
||||
rather than asking the full broad explanation node directly.
|
||||
|
||||
## Upward propagation and reconstruction
|
||||
|
||||
Recursive reasoning is complete only when decomposition and reconstruction are both deterministic.
|
||||
|
||||
For this experiment, reconstruction now behaves as follows:
|
||||
|
||||
- when a child unknown resolves, that child keeps its own resolved status and answer evidence
|
||||
- the parent is updated, but remains unresolved unless the deterministic completion rule is satisfied
|
||||
- only the ancestor chain connected to that child is updated
|
||||
- unrelated branches remain unchanged
|
||||
- the deterministic selector then chooses the next justified unresolved sibling or related follow-up
|
||||
|
||||
For the current conservative completion rule:
|
||||
|
||||
- **one resolved child** → parent becomes `provisional` with higher confidence, but remains unresolved
|
||||
- **all direct child unknowns resolved** → parent resolves deterministically with `high` confidence
|
||||
|
||||
Example progression:
|
||||
|
||||
- parent before: `unknown`, `medium`
|
||||
- after resolving `How the two observations were measured`: parent becomes `provisional`, `high`
|
||||
- next sibling becomes selectable and the engine moves on without recreating the resolved child
|
||||
|
||||
## Interpretation
|
||||
|
||||
This supports the idea that recursive decomposition is a fundamental part of graph-backed questioning, not just a prompt refinement.
|
||||
|
||||
The main remaining limitation is that some decompositions can still produce equally justified child candidates. The current implementation handles that deterministically by exposing the ambiguity in diagnostics and, when possible, reusing the existing selector to pick a specific atomic child. That is still preferable to hiding the ambiguity inside one broad parent question.
|
||||
The main remaining limitation is that sibling selection still inherits the existing deterministic scorer. That means some domains may advance to a justified sibling that is not the intuitively expected next child, even though the propagation itself remains deterministic and graph-valid.
|
||||
|
||||
## Validation run
|
||||
|
||||
@@ -113,6 +150,7 @@ Covered by:
|
||||
|
||||
- `tests/graph/atomicity-assessment.test.js`
|
||||
- `tests/graph/decomposition-quality.test.js`
|
||||
- `tests/graph/upward-propagation.test.js`
|
||||
- `tests/graph/apply-proposal.test.js`
|
||||
- `tests/graph/orchestrator.test.js`
|
||||
- `tests/graph/question-formulator.test.js`
|
||||
|
||||
+348
-7
@@ -433,6 +433,300 @@ function buildChangesApplied(proposal, affectedNodeIds) {
|
||||
};
|
||||
}
|
||||
|
||||
function appendUniqueValue(values = [], nextValue) {
|
||||
return nextValue && !values.includes(nextValue)
|
||||
? [...values, nextValue]
|
||||
: values;
|
||||
}
|
||||
|
||||
function upsertProposalNodeUpdate(proposalSnapshot, update) {
|
||||
const existing = proposalSnapshot.updatedNodes.find(
|
||||
(candidate) => candidate.nodeId === update.nodeId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (update.newStatus != null) existing.newStatus = update.newStatus;
|
||||
if (update.newValue !== undefined) existing.newValue = update.newValue;
|
||||
if (existing.previousStatus == null) {
|
||||
existing.previousStatus = update.previousStatus ?? null;
|
||||
}
|
||||
if (existing.previousValue === undefined) {
|
||||
existing.previousValue = update.previousValue ?? null;
|
||||
}
|
||||
existing.reason = update.reason;
|
||||
return existing;
|
||||
}
|
||||
|
||||
proposalSnapshot.updatedNodes.push(update);
|
||||
return update;
|
||||
}
|
||||
|
||||
function ensureResolvedUnknownId(proposalSnapshot, nodeId) {
|
||||
if (!proposalSnapshot.resolvedUnknownNodeIds.includes(nodeId)) {
|
||||
proposalSnapshot.resolvedUnknownNodeIds.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPropagationEvidenceId(nodeId) {
|
||||
return `answer:${nodeId}`;
|
||||
}
|
||||
|
||||
function findDirectChildUnknowns(graph, parentNodeId) {
|
||||
const parentNode = (graph.nodes || []).find(
|
||||
(node) => node.id === parentNodeId,
|
||||
);
|
||||
const childIds = new Set(parentNode?.childIds || []);
|
||||
|
||||
for (const edge of graph.edges || []) {
|
||||
if (edge.toNodeId === parentNodeId && edge.relationship === "depends_on") {
|
||||
childIds.add(edge.fromNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return (graph.nodes || []).filter(
|
||||
(node) =>
|
||||
node.kind === "unknown" &&
|
||||
(node.parentId === parentNodeId || childIds.has(node.id)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasExistingDecompositionChildren(graph, parentNodeId) {
|
||||
return findDirectChildUnknowns(graph, parentNodeId).length > 0;
|
||||
}
|
||||
|
||||
function buildAncestorChain(graph, node) {
|
||||
const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item]));
|
||||
const chain = [];
|
||||
const queue = [node?.parentId ?? null].filter(Boolean);
|
||||
const seen = new Set();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentParentId = queue.shift();
|
||||
if (!currentParentId || seen.has(currentParentId)) continue;
|
||||
seen.add(currentParentId);
|
||||
|
||||
const parentNode = nodesById.get(currentParentId);
|
||||
if (!parentNode) continue;
|
||||
chain.push(parentNode);
|
||||
|
||||
if (parentNode.parentId) {
|
||||
queue.push(parentNode.parentId);
|
||||
}
|
||||
|
||||
for (const candidate of graph.nodes || []) {
|
||||
if (
|
||||
candidate.id !== parentNode.id &&
|
||||
(candidate.childIds || []).includes(parentNode.id)
|
||||
) {
|
||||
queue.push(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of graph.edges || []) {
|
||||
if (
|
||||
edge.fromNodeId !== parentNode.id &&
|
||||
edge.toNodeId === parentNode.id &&
|
||||
edge.relationship === "depends_on"
|
||||
) {
|
||||
queue.push(edge.fromNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
function syncParentChildReferences(graph) {
|
||||
const nodesById = new Map((graph.nodes || []).map((node) => [node.id, node]));
|
||||
|
||||
for (const node of graph.nodes || []) {
|
||||
if (!node.parentId) continue;
|
||||
const parentNode = nodesById.get(node.parentId);
|
||||
if (!parentNode) continue;
|
||||
|
||||
parentNode.childIds = appendUniqueValue(parentNode.childIds || [], node.id);
|
||||
parentNode.dependsOn = appendUniqueValue(
|
||||
parentNode.dependsOn || [],
|
||||
node.id,
|
||||
);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function computeParentProgressState(graph, parentNode) {
|
||||
const childUnknowns = findDirectChildUnknowns(graph, parentNode.id);
|
||||
const resolvedChildren = childUnknowns.filter(
|
||||
(child) => child.status === "resolved",
|
||||
);
|
||||
const progressedChildren = childUnknowns.filter((child) =>
|
||||
["resolved", "provisional"].includes(child.status),
|
||||
);
|
||||
const totalChildren = childUnknowns.length;
|
||||
|
||||
if (totalChildren === 0) {
|
||||
return {
|
||||
totalChildren,
|
||||
resolvedChildren,
|
||||
progressedChildren,
|
||||
nextStatus: parentNode.status,
|
||||
nextConfidence: parentNode.confidence,
|
||||
parentResolved: parentNode.status === "resolved",
|
||||
reason: "Parent has no child unknowns to aggregate.",
|
||||
};
|
||||
}
|
||||
|
||||
if (resolvedChildren.length === totalChildren) {
|
||||
return {
|
||||
totalChildren,
|
||||
resolvedChildren,
|
||||
progressedChildren,
|
||||
nextStatus: "resolved",
|
||||
nextConfidence: "high",
|
||||
parentResolved: true,
|
||||
reason:
|
||||
"All direct child unknowns are resolved, so the parent can now resolve deterministically.",
|
||||
};
|
||||
}
|
||||
|
||||
if (progressedChildren.length > 0) {
|
||||
return {
|
||||
totalChildren,
|
||||
resolvedChildren,
|
||||
progressedChildren,
|
||||
nextStatus: "provisional",
|
||||
nextConfidence: "high",
|
||||
parentResolved: false,
|
||||
reason:
|
||||
"At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalChildren,
|
||||
resolvedChildren,
|
||||
progressedChildren,
|
||||
nextStatus: parentNode.status,
|
||||
nextConfidence: parentNode.confidence,
|
||||
parentResolved: parentNode.status === "resolved",
|
||||
reason: "No direct child progress exists yet for the parent.",
|
||||
};
|
||||
}
|
||||
|
||||
export function propagateResolvedChildEvidence({
|
||||
updatedSituationGraph,
|
||||
proposalSnapshot,
|
||||
}) {
|
||||
const resolvedChildNodes = (updatedSituationGraph.nodes || []).filter(
|
||||
(node) =>
|
||||
node.kind === "unknown" &&
|
||||
node.parentId &&
|
||||
proposalSnapshot.resolvedUnknownNodeIds.includes(node.id),
|
||||
);
|
||||
|
||||
if (resolvedChildNodes.length === 0) {
|
||||
return {
|
||||
graph: updatedSituationGraph,
|
||||
proposalSnapshot,
|
||||
propagationPerformed: false,
|
||||
resolvedChildNodeId: null,
|
||||
parentNodeId: null,
|
||||
parentStatusBefore: null,
|
||||
parentStatusAfter: null,
|
||||
parentConfidenceBefore: null,
|
||||
parentConfidenceAfter: null,
|
||||
affectedAncestorIds: [],
|
||||
nextSelectedSibling: null,
|
||||
parentResolved: false,
|
||||
reason: "No resolved decomposition child required upward propagation.",
|
||||
};
|
||||
}
|
||||
|
||||
const graph = cloneJsonSafe(updatedSituationGraph);
|
||||
syncParentChildReferences(graph);
|
||||
const propagationEvents = [];
|
||||
const affectedAncestorIds = new Set();
|
||||
|
||||
for (const resolvedChildNode of resolvedChildNodes) {
|
||||
const liveChildNode = graph.nodes.find(
|
||||
(node) => node.id === resolvedChildNode.id,
|
||||
);
|
||||
if (!liveChildNode) continue;
|
||||
|
||||
liveChildNode.evidenceIds = appendUniqueValue(
|
||||
liveChildNode.evidenceIds || [],
|
||||
buildPropagationEvidenceId(liveChildNode.id),
|
||||
);
|
||||
|
||||
const ancestorChain = buildAncestorChain(graph, liveChildNode);
|
||||
for (const ancestorNode of ancestorChain) {
|
||||
const beforeStatus = ancestorNode.status;
|
||||
const beforeConfidence = ancestorNode.confidence;
|
||||
const progressState = computeParentProgressState(graph, ancestorNode);
|
||||
|
||||
ancestorNode.status = progressState.nextStatus;
|
||||
ancestorNode.confidence = progressState.nextConfidence;
|
||||
|
||||
if (progressState.parentResolved) {
|
||||
ensureResolvedUnknownId(proposalSnapshot, ancestorNode.id);
|
||||
}
|
||||
|
||||
upsertProposalNodeUpdate(proposalSnapshot, {
|
||||
nodeId: ancestorNode.id,
|
||||
previousStatus: beforeStatus,
|
||||
newStatus: progressState.nextStatus,
|
||||
previousValue: ancestorNode.value ?? null,
|
||||
newValue: ancestorNode.value ?? null,
|
||||
reason: progressState.reason,
|
||||
});
|
||||
|
||||
affectedAncestorIds.add(ancestorNode.id);
|
||||
propagationEvents.push({
|
||||
resolvedChildNodeId: liveChildNode.id,
|
||||
parentNodeId: ancestorNode.id,
|
||||
parentStatusBefore: beforeStatus,
|
||||
parentStatusAfter: progressState.nextStatus,
|
||||
parentConfidenceBefore: beforeConfidence,
|
||||
parentConfidenceAfter: progressState.nextConfidence,
|
||||
parentResolved: progressState.parentResolved,
|
||||
reason: progressState.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
graph.resolvedNodeIds = [
|
||||
...new Set([
|
||||
...graph.resolvedNodeIds,
|
||||
...proposalSnapshot.resolvedUnknownNodeIds,
|
||||
]),
|
||||
];
|
||||
|
||||
const siblingSelection = selectActiveUnknownCandidate(
|
||||
graph,
|
||||
graph.resolvedNodeIds,
|
||||
);
|
||||
const firstEvent = propagationEvents[0] ?? null;
|
||||
|
||||
return {
|
||||
graph,
|
||||
proposalSnapshot,
|
||||
propagationPerformed: propagationEvents.length > 0,
|
||||
resolvedChildNodeId: firstEvent?.resolvedChildNodeId ?? null,
|
||||
parentNodeId: firstEvent?.parentNodeId ?? null,
|
||||
parentStatusBefore: firstEvent?.parentStatusBefore ?? null,
|
||||
parentStatusAfter: firstEvent?.parentStatusAfter ?? null,
|
||||
parentConfidenceBefore: firstEvent?.parentConfidenceBefore ?? null,
|
||||
parentConfidenceAfter: firstEvent?.parentConfidenceAfter ?? null,
|
||||
affectedAncestorIds: [...affectedAncestorIds],
|
||||
nextSelectedSibling:
|
||||
siblingSelection?.status === "selected" ? siblingSelection.nodeId : null,
|
||||
parentResolved: firstEvent?.parentResolved ?? false,
|
||||
reason:
|
||||
firstEvent?.reason ??
|
||||
"Resolved child evidence propagated upward through the decomposition chain.",
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmergentReasoningUnknownLabel(graph) {
|
||||
const central = String(graph?.centralStatement || "these observations")
|
||||
.trim()
|
||||
@@ -822,17 +1116,17 @@ function buildDecompositionTemplates(parentNode, graph, depth = 0) {
|
||||
description: `Need evidence about the measure used for each observation, because that could help explain ${context.centralStatement}.`,
|
||||
},
|
||||
{
|
||||
label: `Change mainly affecting ${firstFocus}`,
|
||||
description: `Need to know whether a change mainly affected ${firstFocus}, because that could help explain ${context.centralStatement}.`,
|
||||
label: `Possible change mainly affecting ${firstFocus}`,
|
||||
description: `Need to know whether a possible change mainly affected ${firstFocus}, because that could help explain ${context.centralStatement}.`,
|
||||
},
|
||||
{
|
||||
label: `Change mainly affecting ${secondFocus}`,
|
||||
description: `Need to know whether a change mainly affected ${secondFocus}, because that could help explain ${context.centralStatement}.`,
|
||||
label: `Possible change mainly affecting ${secondFocus}`,
|
||||
description: `Need to know whether a possible change mainly affected ${secondFocus}, because that could help explain ${context.centralStatement}.`,
|
||||
},
|
||||
depth === 0
|
||||
? {
|
||||
label: "One-off event during the period",
|
||||
description: `Need to know whether a one-off event happened during the period, because that could help explain ${context.centralStatement}.`,
|
||||
label: "Possible one-off event during the period",
|
||||
description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${context.centralStatement}.`,
|
||||
}
|
||||
: {
|
||||
label: "Mix shift during the period",
|
||||
@@ -1006,6 +1300,12 @@ function runDeterministicDecomposition({
|
||||
break;
|
||||
}
|
||||
|
||||
if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) {
|
||||
decompositionStoppedReason =
|
||||
"Selected composite parent already has decomposition children, so they should be reused instead of regenerated.";
|
||||
break;
|
||||
}
|
||||
|
||||
if (decompositionDepth >= MAX_DECOMPOSITION_DEPTH) {
|
||||
decompositionStoppedReason =
|
||||
"Maximum decomposition depth reached before finding a smaller atomic child.";
|
||||
@@ -1423,6 +1723,22 @@ export function applyValidatedProposal({
|
||||
nextReasoningState = decompositionResult.reasoningState;
|
||||
deterministicSelection = decompositionResult.deterministicSelection;
|
||||
|
||||
const propagationResult = propagateResolvedChildEvidence({
|
||||
updatedSituationGraph,
|
||||
proposalSnapshot: decompositionResult.proposalSnapshot,
|
||||
});
|
||||
|
||||
updatedSituationGraph = propagationResult.graph;
|
||||
nextReasoningState = buildReasoningState(
|
||||
updatedSituationGraph,
|
||||
reasoningResolution.reasoningStateOverride,
|
||||
);
|
||||
updatedSituationGraph.reasoningState = nextReasoningState;
|
||||
deterministicSelection = selectActiveUnknownCandidate(
|
||||
updatedSituationGraph,
|
||||
updatedSituationGraph.resolvedNodeIds,
|
||||
);
|
||||
|
||||
const atomicityAssessment = decompositionResult.atomicityAssessment;
|
||||
const decompositionDepth = decompositionResult.decompositionDepth;
|
||||
const decompositionAttempted = decompositionResult.decompositionAttempted;
|
||||
@@ -1444,6 +1760,17 @@ export function applyValidatedProposal({
|
||||
),
|
||||
];
|
||||
const decompositionReason = decompositionStoppedReason;
|
||||
const propagationPerformed = propagationResult.propagationPerformed;
|
||||
const resolvedChildNodeId = propagationResult.resolvedChildNodeId;
|
||||
const parentNodeId = propagationResult.parentNodeId;
|
||||
const parentStatusBefore = propagationResult.parentStatusBefore;
|
||||
const parentStatusAfter = propagationResult.parentStatusAfter;
|
||||
const parentConfidenceBefore = propagationResult.parentConfidenceBefore;
|
||||
const parentConfidenceAfter = propagationResult.parentConfidenceAfter;
|
||||
const affectedAncestorIds = propagationResult.affectedAncestorIds;
|
||||
const nextSelectedSibling = propagationResult.nextSelectedSibling;
|
||||
const parentResolved = propagationResult.parentResolved;
|
||||
const propagationReason = propagationResult.reason;
|
||||
|
||||
if (
|
||||
deterministicSelection?.status === "selected" &&
|
||||
@@ -1560,10 +1887,24 @@ export function applyValidatedProposal({
|
||||
rejectedChildren,
|
||||
selectedChildNodeId,
|
||||
childQualitySummary,
|
||||
propagationPerformed,
|
||||
resolvedChildNodeId,
|
||||
parentNodeId,
|
||||
parentStatusBefore,
|
||||
parentStatusAfter,
|
||||
parentConfidenceBefore,
|
||||
parentConfidenceAfter,
|
||||
affectedAncestorIds,
|
||||
nextSelectedSibling,
|
||||
parentResolved,
|
||||
decompositionPerformed,
|
||||
childUnknownCount: decompositionChildNodeIds.length,
|
||||
childNodeIds: decompositionChildNodeIds,
|
||||
atomicityReason: decompositionReason || atomicityAssessment?.reason || null,
|
||||
atomicityReason:
|
||||
propagationReason ||
|
||||
decompositionReason ||
|
||||
atomicityAssessment?.reason ||
|
||||
null,
|
||||
previousActiveUnknownNodeId,
|
||||
newActiveUnknownNodeId,
|
||||
selectedQuestion: finalSelectedQuestion,
|
||||
|
||||
@@ -103,6 +103,16 @@ function buildUpdateDiagnostics({
|
||||
rejectedChildren,
|
||||
selectedChildNodeId,
|
||||
childQualitySummary,
|
||||
propagationPerformed,
|
||||
resolvedChildNodeId,
|
||||
parentNodeId,
|
||||
parentStatusBefore,
|
||||
parentStatusAfter,
|
||||
parentConfidenceBefore,
|
||||
parentConfidenceAfter,
|
||||
affectedAncestorIds,
|
||||
nextSelectedSibling,
|
||||
parentResolved,
|
||||
decompositionPerformed,
|
||||
childUnknownCount,
|
||||
childNodeIds,
|
||||
@@ -146,6 +156,16 @@ function buildUpdateDiagnostics({
|
||||
rejectedChildren: rejectedChildren ?? [],
|
||||
selectedChildNodeId: selectedChildNodeId ?? null,
|
||||
childQualitySummary: childQualitySummary ?? [],
|
||||
propagationPerformed: propagationPerformed ?? false,
|
||||
resolvedChildNodeId: resolvedChildNodeId ?? null,
|
||||
parentNodeId: parentNodeId ?? null,
|
||||
parentStatusBefore: parentStatusBefore ?? null,
|
||||
parentStatusAfter: parentStatusAfter ?? null,
|
||||
parentConfidenceBefore: parentConfidenceBefore ?? null,
|
||||
parentConfidenceAfter: parentConfidenceAfter ?? null,
|
||||
affectedAncestorIds: affectedAncestorIds ?? [],
|
||||
nextSelectedSibling: nextSelectedSibling ?? null,
|
||||
parentResolved: parentResolved ?? false,
|
||||
decompositionPerformed: decompositionPerformed ?? false,
|
||||
childUnknownCount: childUnknownCount ?? 0,
|
||||
childNodeIds: childNodeIds ?? [],
|
||||
@@ -413,6 +433,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
||||
rejectedChildren: [],
|
||||
selectedChildNodeId: null,
|
||||
childQualitySummary: [],
|
||||
propagationPerformed: false,
|
||||
resolvedChildNodeId: null,
|
||||
parentNodeId: null,
|
||||
parentStatusBefore: null,
|
||||
parentStatusAfter: null,
|
||||
parentConfidenceBefore: null,
|
||||
parentConfidenceAfter: null,
|
||||
affectedAncestorIds: [],
|
||||
nextSelectedSibling: null,
|
||||
parentResolved: false,
|
||||
decompositionPerformed: false,
|
||||
childUnknownCount: 0,
|
||||
childNodeIds: [],
|
||||
@@ -471,6 +501,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
||||
rejectedChildren: applicationResult.rejectedChildren,
|
||||
selectedChildNodeId: applicationResult.selectedChildNodeId,
|
||||
childQualitySummary: applicationResult.childQualitySummary,
|
||||
propagationPerformed: applicationResult.propagationPerformed,
|
||||
resolvedChildNodeId: applicationResult.resolvedChildNodeId,
|
||||
parentNodeId: applicationResult.parentNodeId,
|
||||
parentStatusBefore: applicationResult.parentStatusBefore,
|
||||
parentStatusAfter: applicationResult.parentStatusAfter,
|
||||
parentConfidenceBefore: applicationResult.parentConfidenceBefore,
|
||||
parentConfidenceAfter: applicationResult.parentConfidenceAfter,
|
||||
affectedAncestorIds: applicationResult.affectedAncestorIds,
|
||||
nextSelectedSibling: applicationResult.nextSelectedSibling,
|
||||
parentResolved: applicationResult.parentResolved,
|
||||
decompositionPerformed: applicationResult.decompositionPerformed,
|
||||
childUnknownCount: applicationResult.childUnknownCount,
|
||||
childNodeIds: applicationResult.childNodeIds,
|
||||
@@ -513,6 +553,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
||||
rejectedChildren: [],
|
||||
selectedChildNodeId: null,
|
||||
childQualitySummary: [],
|
||||
propagationPerformed: false,
|
||||
resolvedChildNodeId: null,
|
||||
parentNodeId: null,
|
||||
parentStatusBefore: null,
|
||||
parentStatusAfter: null,
|
||||
parentConfidenceBefore: null,
|
||||
parentConfidenceAfter: null,
|
||||
affectedAncestorIds: [],
|
||||
nextSelectedSibling: null,
|
||||
parentResolved: false,
|
||||
decompositionPerformed: false,
|
||||
childUnknownCount: 0,
|
||||
childNodeIds: [],
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||
|
||||
function makePropagationFixture({
|
||||
key,
|
||||
centralStatement,
|
||||
firstObservationLabel,
|
||||
secondObservationLabel,
|
||||
}) {
|
||||
const parent = makeNode({
|
||||
id: `${key}-parent`,
|
||||
label: `Explanation for why ${centralStatement}`,
|
||||
description:
|
||||
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
const measurementChild = makeNode({
|
||||
id: `${key}-child-measurement`,
|
||||
label: "How the two observations were measured",
|
||||
description: `Need evidence about the measure used for each observation, because that could help explain ${centralStatement}.`,
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
parentId: parent.id,
|
||||
});
|
||||
const timingChild = makeNode({
|
||||
id: `${key}-child-timing`,
|
||||
label: "Whether the two observations reflect different timing",
|
||||
description: `Need to know whether the two observations reflect different timing, because that could help explain ${centralStatement}.`,
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
parentId: parent.id,
|
||||
});
|
||||
const cashMovementChild = makeNode({
|
||||
id: `${key}-child-cash-movement`,
|
||||
label: `Possible change mainly affecting ${secondObservationLabel}`,
|
||||
description: `Need to know whether a possible change mainly affected ${secondObservationLabel}, because that could help explain ${centralStatement}.`,
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
parentId: parent.id,
|
||||
});
|
||||
const oneOffChild = makeNode({
|
||||
id: `${key}-child-one-off`,
|
||||
label: "Possible one-off event during the period",
|
||||
description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${centralStatement}.`,
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
parentId: parent.id,
|
||||
});
|
||||
const ancestor = makeNode({
|
||||
id: `${key}-ancestor`,
|
||||
label: `Reasoning for ${centralStatement}`,
|
||||
description:
|
||||
"Higher-level reasoning node depending on the parent explanation.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
childIds: [parent.id],
|
||||
});
|
||||
const unrelated = makeNode({
|
||||
id: `${key}-unrelated`,
|
||||
label: "Unrelated branch",
|
||||
description: "Should remain unchanged.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "low",
|
||||
});
|
||||
const firstObservation = makeNode({
|
||||
id: `${key}-obs-1`,
|
||||
label: firstObservationLabel,
|
||||
description: firstObservationLabel,
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "high",
|
||||
});
|
||||
const secondObservation = makeNode({
|
||||
id: `${key}-obs-2`,
|
||||
label: secondObservationLabel,
|
||||
description: secondObservationLabel,
|
||||
kind: "observation",
|
||||
status: "supported",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement,
|
||||
nodes: [
|
||||
ancestor,
|
||||
parent,
|
||||
measurementChild,
|
||||
timingChild,
|
||||
cashMovementChild,
|
||||
oneOffChild,
|
||||
unrelated,
|
||||
firstObservation,
|
||||
secondObservation,
|
||||
],
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: `${key}-e-parent-ancestor`,
|
||||
fromNodeId: parent.id,
|
||||
toNodeId: ancestor.id,
|
||||
relationship: "depends_on",
|
||||
description: "Ancestor depends on the parent explanation.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: `${key}-e-child-measurement-parent`,
|
||||
fromNodeId: measurementChild.id,
|
||||
toNodeId: parent.id,
|
||||
relationship: "depends_on",
|
||||
description: "Measurement child depends into the parent explanation.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: `${key}-e-child-timing-parent`,
|
||||
fromNodeId: timingChild.id,
|
||||
toNodeId: parent.id,
|
||||
relationship: "depends_on",
|
||||
description: "Timing child depends into the parent explanation.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: `${key}-e-child-cash-parent`,
|
||||
fromNodeId: cashMovementChild.id,
|
||||
toNodeId: parent.id,
|
||||
relationship: "depends_on",
|
||||
description: "Cash-movement child depends into the parent explanation.",
|
||||
}),
|
||||
makeEdge({
|
||||
id: `${key}-e-child-one-off-parent`,
|
||||
fromNodeId: oneOffChild.id,
|
||||
toNodeId: parent.id,
|
||||
relationship: "depends_on",
|
||||
description: "One-off child depends into the parent explanation.",
|
||||
}),
|
||||
],
|
||||
activeUnknownNodeId: measurementChild.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: `Propagation fixture for ${key}`,
|
||||
});
|
||||
|
||||
return {
|
||||
graph,
|
||||
ids: {
|
||||
ancestor: ancestor.id,
|
||||
parent: parent.id,
|
||||
measurementChild: measurementChild.id,
|
||||
timingChild: timingChild.id,
|
||||
cashMovementChild: cashMovementChild.id,
|
||||
oneOffChild: oneOffChild.id,
|
||||
unrelated: unrelated.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
key: "revenue-cash",
|
||||
centralStatement: "revenue increased while cash fell",
|
||||
firstObservationLabel: "Revenue increased by 18%.",
|
||||
secondObservationLabel: "Cash in the bank fell over the same period.",
|
||||
},
|
||||
{
|
||||
key: "satisfaction-complaints",
|
||||
centralStatement:
|
||||
"customer satisfaction increased while complaints increased",
|
||||
firstObservationLabel: "Customer satisfaction increased.",
|
||||
secondObservationLabel: "Complaints increased.",
|
||||
},
|
||||
{
|
||||
key: "traffic-sales",
|
||||
centralStatement: "traffic increased while sales stayed flat",
|
||||
firstObservationLabel: "Website traffic increased.",
|
||||
secondObservationLabel: "Sales stayed flat.",
|
||||
},
|
||||
{
|
||||
key: "delivery-cancellations",
|
||||
centralStatement: "delivery time fell while cancellations increased",
|
||||
firstObservationLabel: "Average delivery time decreased.",
|
||||
secondObservationLabel: "Cancellations increased.",
|
||||
},
|
||||
{
|
||||
key: "production-defects",
|
||||
centralStatement: "production increased while defects increased",
|
||||
firstObservationLabel: "Production increased.",
|
||||
secondObservationLabel: "Defects increased.",
|
||||
},
|
||||
];
|
||||
|
||||
describe("upward propagation", () => {
|
||||
it.each(scenarios)(
|
||||
"propagates resolved measurement child upward for $key",
|
||||
({
|
||||
key,
|
||||
centralStatement,
|
||||
firstObservationLabel,
|
||||
secondObservationLabel,
|
||||
}) => {
|
||||
const { graph, ids } = makePropagationFixture({
|
||||
key,
|
||||
centralStatement,
|
||||
firstObservationLabel,
|
||||
secondObservationLabel,
|
||||
});
|
||||
const unrelatedBefore = JSON.stringify(
|
||||
graph.nodes.find((node) => node.id === ids.unrelated),
|
||||
);
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: `${key}-anchor`,
|
||||
label: "Update anchor",
|
||||
description:
|
||||
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||
kind: "state",
|
||||
status: "known",
|
||||
confidence: "low",
|
||||
}),
|
||||
],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.measurementChild,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue:
|
||||
"The figures were measured over the same accounting period using the same management accounts.",
|
||||
reason: "The answer resolves the measurement child.",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [ids.measurementChild],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: null,
|
||||
},
|
||||
previousQuestion:
|
||||
"What evidence would clarify how the two observations were measured?",
|
||||
answer:
|
||||
"The figures were measured over the same accounting period using the same management accounts.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.resolvedUnknownNodeIds).toContain(ids.measurementChild);
|
||||
expect(result.propagationPerformed).toBe(true);
|
||||
expect(result.resolvedChildNodeId).toBe(ids.measurementChild);
|
||||
expect(result.parentNodeId).toBe(ids.parent);
|
||||
expect(result.parentStatusBefore).toBe("unknown");
|
||||
expect(result.parentStatusAfter).toBe("provisional");
|
||||
expect(result.parentConfidenceBefore).toBe("medium");
|
||||
expect(result.parentConfidenceAfter).toBe("high");
|
||||
expect(result.parentResolved).toBe(false);
|
||||
expect(result.affectedAncestorIds).toContain(ids.parent);
|
||||
expect(result.affectedAncestorIds).toContain(ids.ancestor);
|
||||
expect(result.nextSelectedSibling).toBe(result.newActiveUnknownNodeId);
|
||||
expect(result.nextSelectedSibling).toBe(result.selectedQuestion?.nodeId);
|
||||
expect(result.nextSelectedSibling).not.toBe(ids.measurementChild);
|
||||
expect([
|
||||
ids.timingChild,
|
||||
ids.cashMovementChild,
|
||||
ids.oneOffChild,
|
||||
]).toContain(result.nextSelectedSibling);
|
||||
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||
"measured",
|
||||
);
|
||||
|
||||
const parentNode = result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === ids.parent,
|
||||
);
|
||||
expect(parentNode).toMatchObject({
|
||||
status: "provisional",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const ancestorNode = result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === ids.ancestor,
|
||||
);
|
||||
expect(ancestorNode).toMatchObject({
|
||||
status: "provisional",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const resolvedChild = result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === ids.measurementChild,
|
||||
);
|
||||
expect(resolvedChild.status).toBe("resolved");
|
||||
expect(resolvedChild.evidenceIds).toContain(
|
||||
`answer:${ids.measurementChild}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.updatedSituationGraph.nodes.filter(
|
||||
(node) => node.id === ids.measurementChild,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
JSON.stringify(
|
||||
result.updatedSituationGraph.nodes.find(
|
||||
(node) => node.id === ids.unrelated,
|
||||
),
|
||||
),
|
||||
).toBe(unrelatedBefore);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves the parent only after all direct children are resolved", () => {
|
||||
const { graph, ids } = makePropagationFixture({
|
||||
key: "completion-rule",
|
||||
centralStatement: "revenue increased while cash fell",
|
||||
firstObservationLabel: "Revenue increased by 18%.",
|
||||
secondObservationLabel: "Cash in the bank fell over the same period.",
|
||||
});
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
addedNodes: [
|
||||
makeNode({
|
||||
id: "completion-rule-anchor",
|
||||
label: "Update anchor",
|
||||
description:
|
||||
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||
kind: "state",
|
||||
status: "known",
|
||||
confidence: "low",
|
||||
}),
|
||||
],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: ids.measurementChild,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "same management accounts",
|
||||
reason: "resolved measurement child",
|
||||
},
|
||||
{
|
||||
nodeId: ids.timingChild,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "timing aligned",
|
||||
reason: "resolved timing child",
|
||||
},
|
||||
{
|
||||
nodeId: ids.cashMovementChild,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "cash left through operations",
|
||||
reason: "resolved movement child",
|
||||
},
|
||||
{
|
||||
nodeId: ids.oneOffChild,
|
||||
previousStatus: "unknown",
|
||||
newStatus: "resolved",
|
||||
previousValue: null,
|
||||
newValue: "no exceptional movement",
|
||||
reason: "resolved one-off child",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [
|
||||
ids.measurementChild,
|
||||
ids.timingChild,
|
||||
ids.cashMovementChild,
|
||||
ids.oneOffChild,
|
||||
],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: null,
|
||||
},
|
||||
previousQuestion:
|
||||
"What evidence would clarify how the two observations were measured?",
|
||||
answer: "All direct child questions are now answered.",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.parentResolved).toBe(true);
|
||||
expect(result.resolvedUnknownNodeIds).toContain(ids.parent);
|
||||
expect(
|
||||
result.updatedSituationGraph.nodes.find((node) => node.id === ids.parent),
|
||||
).toMatchObject({ status: "resolved", confidence: "high" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user