checkpoint: preserve semantic decomposition investigation state

This commit is contained in:
2026-08-22 08:21:29 +01:00
parent 68be2344c6
commit 517d780e2c
4 changed files with 319 additions and 24 deletions
+176 -11
View File
@@ -34,11 +34,123 @@ import {
validateGraphReferences, validateGraphReferences,
validateGraphUpdate, validateGraphUpdate,
} from "./utils.js"; } from "./utils.js";
import { getProvider } from "@/lib/llm/provider";
function cloneJsonSafe(value) { function cloneJsonSafe(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function buildSemanticDecompositionPrompt({ parentNode, graph }) {
return `You are proposing narrower child unknowns for one unresolved compound parent unknown.
Return exactly one JSON object. Return JSON only.
This is NOT a graph update task.
Do NOT mutate graph state.
Do NOT select a child.
Do NOT rank children.
Do NOT resolve the parent.
Do NOT create conclusions, actions, recommendations, or unrelated graph content.
Required top-level fields:
- parentNodeId
- proposedChildren
Field rules:
- parentNodeId must exactly equal the supplied parent node id.
- proposedChildren must be an array of 0-5 objects.
- each child object must contain only: label, description.
- each child must preserve the parent's meaning while making it narrower and directly answerable.
- each child must express only one uncertainty.
Parent unknown:
- id: ${parentNode.id}
- label: ${parentNode.label}
- description: ${parentNode.description || ""}
Consider breaking into dimensions like: customer type, team capability, responsibility distribution, pricing structure, time factors — whatever creates the dependency.
Each candidate child should:
- be a genuine uncertainty (not an action or conclusion)
- be narrow enough to address in one analytical step
- focus on one specific dimension
- stay grounded in the parent's core meaning`;
}
function validateSemanticDecompositionResult(result, parentNodeId) {
const errors = [];
if (!result || typeof result !== "object") {
return ["Semantic decomposition result must be an object."];
}
if (result.parentNodeId !== parentNodeId) {
errors.push(
"Semantic decomposition result parentNodeId did not match the requested parent node id.",
);
}
if (!Array.isArray(result.proposedChildren)) {
errors.push(
"Semantic decomposition result must include a proposedChildren array.",
);
return errors;
}
if (result.proposedChildren.length > 5) {
errors.push("Semantic decomposition result proposed too many children.");
}
for (const child of result.proposedChildren) {
if (!child || typeof child !== "object") {
errors.push("Each proposed child must be an object.");
continue;
}
const keys = Object.keys(child);
if (!keys.includes("label") || !keys.includes("description")) {
errors.push("Each proposed child must include label and description.");
}
const unexpectedKeys = keys.filter(
(key) => !["label", "description"].includes(key),
);
if (unexpectedKeys.length > 0) {
errors.push(
`Proposed child contained unexpected fields: ${unexpectedKeys.join(", ")}`,
);
}
if (typeof child.label !== "string" || child.label.trim().length === 0) {
errors.push("Each proposed child label must be a non-empty string.");
}
if (
typeof child.description !== "string" ||
child.description.trim().length === 0
) {
errors.push("Each proposed child description must be a non-empty string.");
}
}
return errors;
}
async function proposeSemanticDecompositionChildren({
parentNode,
graph,
provider,
modelName,
}) {
const llmProvider = provider ?? getProvider();
const raw = await llmProvider.generateReconstruction(
buildSemanticDecompositionPrompt({ parentNode, graph }),
modelName ?? process.env.OLLAMA_MODEL,
);
const validationErrors = validateSemanticDecompositionResult(raw, parentNode.id);
if (validationErrors.length > 0) {
return { success: false, proposedChildren: [], errors: validationErrors };
}
return {
success: true,
proposedChildren: raw.proposedChildren.map((child) => ({
label: child.label.trim(),
description: child.description.trim(),
})),
errors: [],
};
}
function zodIssuesToErrors(error) { function zodIssuesToErrors(error) {
return ( return (
error?.issues?.map((issue) => { error?.issues?.map((issue) => {
@@ -1820,8 +1932,32 @@ function buildDecompositionTemplates(parentNode, graph, depth = 0) {
return []; return [];
} }
function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { async function buildCompositeUnknownChildren(
const templates = buildDecompositionTemplates(parentNode, graph, depth); parentNode,
graph,
depth = 0,
activeReasoningPattern,
options = {},
) {
let templates = buildDecompositionTemplates(parentNode, graph, depth);
let usedSemanticFallback = false;
let semanticFallbackErrors = [];
if (templates.length === 0 && options.allowSemanticFallback) {
const semanticFallback = await proposeSemanticDecompositionChildren({
parentNode,
graph,
provider: options.provider,
modelName: options.modelName,
});
if (semanticFallback.success) {
templates = semanticFallback.proposedChildren;
usedSemanticFallback = true;
} else {
semanticFallbackErrors = semanticFallback.errors;
}
}
if (templates.length === 0) { if (templates.length === 0) {
return { return {
@@ -1833,6 +1969,8 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
acceptedChildCount: 0, acceptedChildCount: 0,
rejectedChildren: [], rejectedChildren: [],
childQualitySummary: [], childQualitySummary: [],
usedSemanticFallback,
semanticFallbackErrors,
reason: reason:
"Decomposition stopped because no meaning-preserving child family was justified for this parent.", "Decomposition stopped because no meaning-preserving child family was justified for this parent.",
}; };
@@ -1936,6 +2074,8 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
acceptedChildCount, acceptedChildCount,
rejectedChildren, rejectedChildren,
childQualitySummary, childQualitySummary,
usedSemanticFallback,
semanticFallbackErrors,
reason: reason:
acceptedChildCount === 0 acceptedChildCount === 0
? "Decomposition stopped because all proposed children failed quality checks." ? "Decomposition stopped because all proposed children failed quality checks."
@@ -1952,6 +2092,8 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
acceptedChildCount, acceptedChildCount,
rejectedChildren, rejectedChildren,
childQualitySummary, childQualitySummary,
usedSemanticFallback,
semanticFallbackErrors,
reason: reason:
childNodes.length > 0 childNodes.length > 0
? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question." ? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question."
@@ -2768,10 +2910,12 @@ function buildSelectedQuestionResult({
}; };
} }
function resolveAmbiguousGraphBackedSelection({ async function resolveAmbiguousGraphBackedSelection({
graphSnapshot, graphSnapshot,
updatedSituationGraph, updatedSituationGraph,
deterministicSelection, deterministicSelection,
provider = null,
modelName = null,
}) { }) {
const orderedCandidateIds = const orderedCandidateIds =
deterministicSelection?.displayOrder || deterministicSelection?.displayOrder ||
@@ -2785,7 +2929,7 @@ function resolveAmbiguousGraphBackedSelection({
continue; continue;
} }
const candidateResult = runDeterministicDecomposition({ const candidateResult = await runDeterministicDecomposition({
graphSnapshot, graphSnapshot,
proposalSnapshot: { proposalSnapshot: {
addedNodes: [], addedNodes: [],
@@ -2804,6 +2948,8 @@ function resolveAmbiguousGraphBackedSelection({
reason: reason:
"Selected this tied candidate for deterministic decomposition-based reselection.", "Selected this tied candidate for deterministic decomposition-based reselection.",
}, },
provider,
modelName,
}); });
if (!candidateResult.success) { if (!candidateResult.success) {
@@ -2874,7 +3020,11 @@ function reseatSelectionAfterQuestionRejection({
const QUESTION_FORMULATION_REJECTION_NO_QUESTION_REASON = const QUESTION_FORMULATION_REJECTION_NO_QUESTION_REASON =
"The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex."; "The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex.";
export function determineGraphBackedQuestion({ situationGraph }) { export async function determineGraphBackedQuestion({
situationGraph,
provider = null,
modelName = null,
}) {
const graphSnapshot = cloneJsonSafe(situationGraph); const graphSnapshot = cloneJsonSafe(situationGraph);
let updatedSituationGraph = cloneJsonSafe(situationGraph); let updatedSituationGraph = cloneJsonSafe(situationGraph);
let deterministicSelection = selectActiveUnknownCandidate( let deterministicSelection = selectActiveUnknownCandidate(
@@ -2882,7 +3032,7 @@ export function determineGraphBackedQuestion({ situationGraph }) {
updatedSituationGraph.resolvedNodeIds || [], updatedSituationGraph.resolvedNodeIds || [],
); );
const decompositionResult = runDeterministicDecomposition({ const decompositionResult = await runDeterministicDecomposition({
graphSnapshot, graphSnapshot,
proposalSnapshot: { proposalSnapshot: {
addedNodes: [], addedNodes: [],
@@ -2896,6 +3046,8 @@ export function determineGraphBackedQuestion({ situationGraph }) {
updatedSituationGraph, updatedSituationGraph,
reasoningResolution: { reasoningStateOverride: {} }, reasoningResolution: { reasoningStateOverride: {} },
deterministicSelection, deterministicSelection,
provider,
modelName,
}); });
if (!decompositionResult.success) { if (!decompositionResult.success) {
@@ -2952,10 +3104,12 @@ export function determineGraphBackedQuestion({ situationGraph }) {
deterministicSelection?.status === "ambiguous" && deterministicSelection?.status === "ambiguous" &&
!questionResult.selectedQuestion?.question !questionResult.selectedQuestion?.question
) { ) {
const reselectionResult = resolveAmbiguousGraphBackedSelection({ const reselectionResult = await resolveAmbiguousGraphBackedSelection({
graphSnapshot, graphSnapshot,
updatedSituationGraph, updatedSituationGraph,
deterministicSelection, deterministicSelection,
provider,
modelName,
}); });
if (reselectionResult) { if (reselectionResult) {
@@ -3015,13 +3169,15 @@ export function determineGraphBackedQuestion({ situationGraph }) {
}; };
} }
function runDeterministicDecomposition({ async function runDeterministicDecomposition({
graphSnapshot, graphSnapshot,
proposalSnapshot, proposalSnapshot,
updatedSituationGraph, updatedSituationGraph,
reasoningResolution, reasoningResolution,
deterministicSelection, deterministicSelection,
structurallyAdmittedNodeIds = new Set(), structurallyAdmittedNodeIds = new Set(),
provider = null,
modelName = null,
}) { }) {
let workingGraph = updatedSituationGraph; let workingGraph = updatedSituationGraph;
let workingSelection = deterministicSelection; let workingSelection = deterministicSelection;
@@ -3189,11 +3345,16 @@ function runDeterministicDecomposition({
} }
decompositionAttempted = true; decompositionAttempted = true;
const decomposition = buildCompositeUnknownChildren( const decomposition = await buildCompositeUnknownChildren(
selectedNode, selectedNode,
workingGraph, workingGraph,
decompositionDepth, decompositionDepth,
activeReasoningPattern, activeReasoningPattern,
{
allowSemanticFallback: Boolean(provider),
provider,
modelName,
},
); );
proposedChildCount = decomposition.proposedChildCount; proposedChildCount = decomposition.proposedChildCount;
@@ -3805,11 +3966,13 @@ function deriveReasoningStateOverride({
}; };
} }
export function applyValidatedProposal({ export async function applyValidatedProposal({
situationGraph, situationGraph,
proposal, proposal,
previousQuestion = null, previousQuestion = null,
answer = null, answer = null,
provider = null,
modelName = null,
}) { }) {
const graphValidation = situationGraphSchema.safeParse(situationGraph); const graphValidation = situationGraphSchema.safeParse(situationGraph);
const proposalValidation = graphUpdateSchema.safeParse(proposal); const proposalValidation = graphUpdateSchema.safeParse(proposal);
@@ -4093,13 +4256,15 @@ export function applyValidatedProposal({
updatedSituationGraph.resolvedNodeIds, updatedSituationGraph.resolvedNodeIds,
); );
const decompositionResult = runDeterministicDecomposition({ const decompositionResult = await runDeterministicDecomposition({
graphSnapshot, graphSnapshot,
proposalSnapshot, proposalSnapshot,
updatedSituationGraph, updatedSituationGraph,
reasoningResolution, reasoningResolution,
deterministicSelection, deterministicSelection,
structurallyAdmittedNodeIds, structurallyAdmittedNodeIds,
provider,
modelName,
}); });
if (!decompositionResult.success) { if (!decompositionResult.success) {
+6 -2
View File
@@ -343,7 +343,7 @@ function buildUpdateDiagnostics({
}; };
} }
export async function startCase(body) { export async function startCase(body, dependencies = {}) {
const parsedRequest = startCaseRequestSchema.safeParse(body); const parsedRequest = startCaseRequestSchema.safeParse(body);
if (!parsedRequest.success) { if (!parsedRequest.success) {
@@ -399,8 +399,12 @@ export async function startCase(body) {
const graphReferenceValidation = validateGraphReferences( const graphReferenceValidation = validateGraphReferences(
initialSituationGraph, initialSituationGraph,
); );
const initialQuestionResult = determineGraphBackedQuestion({ const provider = dependencies.provider ?? getProvider();
const modelName = dependencies.modelName ?? analysis?.modelName ?? null;
const initialQuestionResult = await determineGraphBackedQuestion({
situationGraph: initialSituationGraph, situationGraph: initialSituationGraph,
provider,
modelName,
}); });
const situationGraph = initialQuestionResult.success const situationGraph = initialQuestionResult.success
? initialQuestionResult.updatedSituationGraph ? initialQuestionResult.updatedSituationGraph
+48 -6
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
assessChildUnknownQuality, assessChildUnknownQuality,
applyValidatedProposal, applyValidatedProposal,
determineGraphBackedQuestion,
MAX_DECOMPOSITION_DEPTH, MAX_DECOMPOSITION_DEPTH,
} from "@/lib/graph/apply-proposal.js"; } from "@/lib/graph/apply-proposal.js";
import { makeGraph, makeNode } from "@/lib/graph/schema.js"; import { makeGraph, makeNode } from "@/lib/graph/schema.js";
@@ -193,7 +194,7 @@ describe("decomposition stopping conditions", () => {
}; };
} }
it("does not decompose an atomic selected unknown", () => { it("does not decompose an atomic selected unknown", async () => {
const atomic = makeNode({ const atomic = makeNode({
id: "n-atomic", id: "n-atomic",
label: "Were both figures measured over the same accounting period?", label: "Were both figures measured over the same accounting period?",
@@ -213,7 +214,7 @@ describe("decomposition stopping conditions", () => {
currentSummary: "Atomic selected node graph", currentSummary: "Atomic selected node graph",
}); });
const result = applyValidatedProposal({ const result = await applyValidatedProposal({
situationGraph: graph, situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(), proposal: makeMeaningfulNoOpProposal(),
}); });
@@ -225,7 +226,7 @@ describe("decomposition stopping conditions", () => {
); );
}); });
it("does not manufacture comparison children for a generic explanatory parent", () => { it("does not manufacture comparison children for a generic explanatory parent", async () => {
const { parent, graph } = makeParentGraph({ const { parent, graph } = makeParentGraph({
centralStatement: "Traffic increased, but sales stayed flat.", centralStatement: "Traffic increased, but sales stayed flat.",
parentLabel: parentLabel:
@@ -252,7 +253,7 @@ describe("decomposition stopping conditions", () => {
], ],
}); });
const result = applyValidatedProposal({ const result = await applyValidatedProposal({
situationGraph: graph, situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(), proposal: makeMeaningfulNoOpProposal(),
}); });
@@ -286,7 +287,7 @@ describe("decomposition stopping conditions", () => {
); );
}); });
it("still decomposes a genuine comparison/measurement parent into valid comparison children", () => { it("still decomposes a genuine comparison/measurement parent into valid comparison children", async () => {
const { graph } = makeParentGraph({ const { graph } = makeParentGraph({
centralStatement: centralStatement:
"Revenue increased by 18%, but cash in the bank fell over the same period.", "Revenue increased by 18%, but cash in the bank fell over the same period.",
@@ -313,7 +314,7 @@ describe("decomposition stopping conditions", () => {
], ],
}); });
const result = applyValidatedProposal({ const result = await applyValidatedProposal({
situationGraph: graph, situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(), proposal: makeMeaningfulNoOpProposal(),
}); });
@@ -342,4 +343,45 @@ describe("decomposition stopping conditions", () => {
expect(MAX_DECOMPOSITION_DEPTH).toBeGreaterThanOrEqual(2); expect(MAX_DECOMPOSITION_DEPTH).toBeGreaterThanOrEqual(2);
expect(MAX_DECOMPOSITION_DEPTH).toBeLessThanOrEqual(3); expect(MAX_DECOMPOSITION_DEPTH).toBeLessThanOrEqual(3);
}); });
it("rejects invalid semantic fallback children through existing quality validation", async () => {
const { parent, graph } = makeParentGraph({
centralStatement:
"A dependency persists, but the cause is unclear.",
parentLabel: "Which factor is causing the dependency",
parentDescription:
"Need to know whether team capability, customer dependency, responsibility distribution, or insufficient deliberate delegation is causing the dependency.",
});
const provider = {
generateReconstruction: async () => ({
parentNodeId: parent.id,
proposedChildren: [
{
label: "Team capability or customer dependency",
description:
"Need to know whether team capability or customer dependency is causing the dependency.",
},
{
label: "Responsibility distribution or insufficient deliberate delegation",
description:
"Need to know whether responsibility distribution or insufficient deliberate delegation is causing the dependency.",
},
],
}),
};
const result = await determineGraphBackedQuestion({
situationGraph: graph,
provider,
modelName: "mock-ollama",
});
expect(result.success).toBe(true);
expect(result.decompositionAttempted).toBe(true);
expect(result.decompositionAccepted).toBe(false);
expect(result.proposedChildCount).toBe(2);
expect(result.acceptedChildCount).toBe(0);
expect(result.childUnknownCount).toBe(0);
expect(result.rejectedChildren).toHaveLength(2);
});
}); });
+89 -5
View File
@@ -3,7 +3,10 @@ import {
assessUnknownAnswerability, assessUnknownAnswerability,
assessUnknownAtomicity, assessUnknownAtomicity,
} from "@/lib/graph/question-formulator.js"; } from "@/lib/graph/question-formulator.js";
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import {
applyValidatedProposal,
determineGraphBackedQuestion,
} from "@/lib/graph/apply-proposal.js";
import { makeGraph, makeNode } from "@/lib/graph/schema.js"; import { makeGraph, makeNode } from "@/lib/graph/schema.js";
const COMMERCIAL_SCENARIO = const COMMERCIAL_SCENARIO =
@@ -95,10 +98,10 @@ describe("assessUnknownAnswerability", () => {
}); });
describe("answerability-triggered decomposition", () => { describe("answerability-triggered decomposition", () => {
it("decomposes a non-answerable parent into independently answerable child investigations", () => { it("decomposes a non-answerable parent into independently answerable child investigations", async () => {
const graph = makeCommercialContainerGraph(); const graph = makeCommercialContainerGraph();
const result = applyValidatedProposal({ const result = await applyValidatedProposal({
situationGraph: graph, situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(), proposal: makeMeaningfulNoOpProposal(),
}); });
@@ -116,10 +119,10 @@ describe("answerability-triggered decomposition", () => {
); );
}); });
it("keeps the parent unresolved while selecting a child unknown", () => { it("keeps the parent unresolved while selecting a child unknown", async () => {
const graph = makeCommercialContainerGraph(); const graph = makeCommercialContainerGraph();
const result = applyValidatedProposal({ const result = await applyValidatedProposal({
situationGraph: graph, situationGraph: graph,
proposal: makeMeaningfulNoOpProposal(), proposal: makeMeaningfulNoOpProposal(),
}); });
@@ -135,4 +138,85 @@ describe("answerability-triggered decomposition", () => {
expect(selectedChild.parentId).toBe(parentNode.id); expect(selectedChild.parentId).toBe(parentNode.id);
expect(selectedChild.status).toBe("unknown"); expect(selectedChild.status).toBe("unknown");
}); });
it("invokes bounded semantic fallback when no deterministic family exists and accepts valid children without direct LLM graph mutation", async () => {
const parent = makeNode({
id: "n-dependency-parent",
label: "Which factor is causing the dependency",
description:
"Need to know whether team capability, customer or client dependency, responsibility distribution, or insufficient deliberate delegation or stepping away is causing the dependency.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const graph = makeGraph({
centralStatement:
"A dependency persists, but it is unclear whether the cause is team capability, customer dependency, responsibility distribution, or insufficient deliberate delegation.",
nodes: [parent],
edges: [],
activeUnknownNodeId: parent.id,
resolvedNodeIds: [],
currentSummary: "Dependency-factor decomposition fixture",
});
const atomicity = assessUnknownAtomicity({ node: parent, graph });
const answerability = assessUnknownAnswerability({ node: parent, graph });
const provider = {
generateReconstruction: async () => ({
parentNodeId: parent.id,
proposedChildren: [
{
label: "Whether team capability is causing the dependency",
description:
"Need to know whether team capability is causing the dependency, because that would narrow the source of the dependency.",
},
{
label: "Whether customer dependency is causing the dependency",
description:
"Need to know whether customer dependency is causing the dependency, because that would narrow the source of the dependency.",
},
],
}),
};
const result = await determineGraphBackedQuestion({
situationGraph: graph,
provider,
modelName: "mock-ollama",
});
expect(atomicity.atomicity).toBe("atomic");
expect(answerability.independentlyAnswerable).toBe(false);
expect(answerability.decompositionRequired).toBe(true);
expect(result.success).toBe(true);
expect(result.decompositionAttempted).toBe(true);
expect(result.decompositionTriggeredByAnswerability).toBe(true);
expect(result.decompositionAccepted).toBe(true);
expect(result.proposedChildCount).toBe(2);
expect(result.acceptedChildCount).toBe(2);
expect(result.childUnknownCount).toBe(2);
expect(result.selectedContainerUnknown).toBe(parent.id);
expect(result.selectedChildUnknown).not.toBe(parent.id);
expect(result.selectedQuestion).toBeTruthy();
});
it("preserves deterministic family path without invoking semantic fallback", async () => {
const graph = makeCommercialContainerGraph();
let providerCalled = false;
const provider = {
generateReconstruction: async () => {
providerCalled = true;
return { parentNodeId: "n-commercial-parent", proposedChildren: [] };
},
};
const result = await determineGraphBackedQuestion({
situationGraph: graph,
provider,
modelName: "mock-ollama",
});
expect(result.success).toBe(true);
expect(result.decompositionPerformed).toBe(true);
expect(providerCalled).toBe(false);
});
}); });