feat(confidence-engine): accept episode evidence at graph application

This commit is contained in:
2026-09-01 08:30:26 +01:00
parent 6b77e32771
commit 0752c53a25
2 changed files with 585 additions and 13 deletions
+27 -13
View File
@@ -3971,9 +3971,11 @@ export async function applyValidatedProposal({
proposal,
previousQuestion = null,
answer = null,
evidenceContext = null,
provider = null,
modelName = null,
}) {
const isLegacySingleTurn = !evidenceContext;
const graphValidation = situationGraphSchema.safeParse(situationGraph);
const proposalValidation = graphUpdateSchema.safeParse(proposal);
@@ -4030,11 +4032,15 @@ export async function applyValidatedProposal({
);
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
const ownershipChanges = reconcileDecisionClosureOwnership(
situationGraph,
reconciledProposal.proposal,
answer,
);
// legacy only: episode evidence does not supply a single-answer confirmation string;
// Done and Finding agree are NOT closure authority (settled architecture rule).
const ownershipChanges = isLegacySingleTurn
? reconcileDecisionClosureOwnership(
situationGraph,
reconciledProposal.proposal,
answer,
)
: { strippedUpdates: [], strippedResolvedIds: [] };
if (ownershipChanges.strippedResolvedIds.length > 0) {
// Reconciler may have added selectedQuestion = null because the parent
// appeared resolved during reconciliation. If we stripped it, restore
@@ -4130,12 +4136,16 @@ export async function applyValidatedProposal({
validatedProposal,
);
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
proposalCompatibilityErrors.push(
...validateAnswerMeaningCompatibilityWithRawAnswer({
answer,
proposal: validatedProposal,
}),
);
// legacy only: raw-answer fidelity is a single-turn safeguard.
// Episode evidence carries structured turns — no synthetic answer is constructed.
if (isLegacySingleTurn) {
proposalCompatibilityErrors.push(
...validateAnswerMeaningCompatibilityWithRawAnswer({
answer,
proposal: validatedProposal,
}),
);
}
proposalCompatibilityErrors.push(
...validateAnswerMeaningAlignment(validatedProposal),
);
@@ -4165,10 +4175,14 @@ export async function applyValidatedProposal({
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
: null;
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
// For episode evidence: extract turn text from the ordered Q/A sequence.
const _epTurns = evidenceContext?.isCompletedEpisode ? evidenceContext.episodeEvidence.turns : null;
const _derivedQuestion = isLegacySingleTurn ? previousQuestion : (_epTurns?.[0]?.question ?? "");
const _derivedAnswer = isLegacySingleTurn ? answer : (_epTurns?.[0]?.answer ?? "");
const reasoningResolution = deriveReasoningStateOverride({
graph: graphSnapshot,
previousQuestion,
answer,
previousQuestion: _derivedQuestion,
answer: _derivedAnswer,
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
});
@@ -0,0 +1,558 @@
/**
* Tests for episode evidence at the graph application boundary.
*
* Scope: applyValidatedProposal accepts structured completed-episode evidence
* without collapsing it into a synthetic single answer, and preserves existing
* single-turn behaviour unchanged.
*/
import { describe, expect, it } from "vitest";
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
// ── production-style helpers ───────────────────────────────────────
function makeSituationGraph() {
const parent = makeNode({
id: "n-parent",
label: "Commercial justification for continuing development",
description:
"Need to know whether this solves a genuine problem and is commercially justified.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const child1 = makeNode({
id: "n-child-1",
label: "Whether the idea solves a genuine problem",
description: "Need evidence about whether there is real user demand.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const child2 = makeNode({
id: "n-child-2",
label: "Whether people would value it enough to pay",
description: "Need evidence about willingness to pay.",
kind: "unknown",
status: "unknown",
confidence: "medium",
parentId: parent.id,
});
const anchor = makeNode({
id: "n-anchor",
label: "Market observation anchor",
description: "Anchor state introduced by the answer.",
kind: "state",
status: "known",
confidence: "low",
});
return makeGraph({
centralStatement:
"I have developed a new reasoning method and want to determine whether continuing development is commercially justified.",
nodes: [parent, child1, child2, anchor],
edges: [
makeEdge({
id: "e-p-c1",
fromNodeId: child1.id,
toNodeId: parent.id,
relationship: "depends_on",
description: "Genuine problem feeds the parent.",
}),
makeEdge({
id: "e-p-c2",
fromNodeId: child2.id,
toNodeId: parent.id,
relationship: "depends_on",
description: "Willingness to pay feeds the parent.",
}),
],
activeUnknownNodeId: "n-child-1",
resolvedNodeIds: [],
currentSummary: "Episode evidence boundary fixture",
});
}
function makeResolvedProposal(resolvedIds = ["n-child-1"]) {
return {
addedNodes: [
makeNode({
id: "n-anchor2",
label: "Update anchor 2",
description: "Anchor state.",
kind: "state",
status: "known",
confidence: "low",
}),
],
updatedNodes: resolvedIds.map((id) => ({
nodeId: id,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: `answer:${id}`,
reason: "resolved child",
})),
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: resolvedIds,
affectedNodeIds: [],
selectedQuestion: null,
};
}
function makeLegacyAnswer() {
return "Yes, I confirm there is no further material uncertainty.";
}
// ── Legacy single-turn regression ──────────────────────────────────
describe("legacy single-turn path", () => {
it("continues to work without evidenceContext (backward-compatible signature)", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
answer: makeLegacyAnswer(),
});
expect(result.success).toBe(true);
});
it("legacy raw-answer compatibility check still runs (Regression A)", async () => {
const graph = makeSituationGraph();
// Use unstructured answerMeaning (no structured supportCategory/resolutionGuidance)
// so that validateAnswerMeaningCompatibilityWithRawAnswer actually executes.
// Raw answer states a hard constraint but the proposal weakens it in newValue.
const proposalWithWeakening = {
addedNodes: [
makeNode({
id: "n-anchor-lr",
label: "Update anchor",
description: "Anchor state.",
kind: "state",
status: "known",
confidence: "low",
}),
],
updatedNodes: [
{
nodeId: "n-child-1",
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "It would be good to proceed.", // weaker than raw constraint
reason: "Evidence supports proceeding.",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-child-1"],
affectedNodeIds: [],
selectedQuestion: null,
answerMeaning: {
userSupportedMeaning: "It would be good to proceed.",
supportCategory: null,
resolutionGuidance: null,
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: proposalWithWeakening,
answer: "We MUST proceed — no exceptions.", // explicit hard constraint in raw answer
});
// legacy path: raw-answer compatibility check rejects weakening
expect(result.success).toBe(false);
expect(result.stage).toBe("proposal_compatibility");
});
});
// ── Episode evidence path ──────────────────────────────────────────
describe("episode evidence at application boundary", () => {
it("accepts structured completed-episode evidence without collapsing to synthetic answer", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{
contributionId: "c-1",
sequence: 1,
question: "Should we proceed with the investment?",
answer: "Yes, the risk assessment is complete.",
},
{
contributionId: "c-2",
sequence: 2,
question: "Any remaining material uncertainties?",
answer: "No, all findings are aligned.",
},
],
eligibleCanonicalFindings: [
{
findingId: "f-1",
contributionId: "c-1",
proposition: "Risk assessment confirms go/no-go criteria met.",
sourceObservation: "Risk analysis document v3.",
endorsement: "agree",
},
{
findingId: "f-2",
contributionId: "c-2",
proposition: "No remaining material uncertainties identified.",
sourceObservation: "User confirmation response.",
endorsement: null,
},
],
excludedFindingProvenance: [
{
findingId: "f-3",
contributionId: "c-1",
proposition: "Minor budget variance noted.",
sourceObservation: "Budget report.",
disposition: "not_relevant",
},
],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
});
it("ordered turns remain distinct (no aggregation)", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{
contributionId: "c-10",
sequence: 1,
question: "Q1 distinct text?",
answer: "A1 distinct text.",
},
{
contributionId: "c-11",
sequence: 2,
question: "Q2 distinct text?",
answer: "A2 distinct text.",
},
{
contributionId: "c-12",
sequence: 3,
question: "Q3 distinct text?",
answer: "A3 distinct text.",
},
],
eligibleCanonicalFindings: [],
excludedFindingProvenance: [],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
});
it("eligible Findings remain structured evidence", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const eligibleFindings = [
{
findingId: "f-aggree",
contributionId: "c-a",
proposition: "Confirmed proposition A.",
sourceObservation: "Source A.",
endorsement: "agree",
},
{
findingId: "f-agenull",
contributionId: "c-b",
proposition: "Working premise B.",
sourceObservation: "Source B.",
endorsement: null,
},
];
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{ contributionId: "c-a", sequence: 1, question: "Q?", answer: "A." },
{ contributionId: "c-b", sequence: 2, question: "Q?", answer: "A." },
],
eligibleCanonicalFindings: eligibleFindings,
excludedFindingProvenance: [],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
});
it("no synthetic answer is constructed for episode path", async () => {
// In episode mode, the `answer` parameter is omitted and structured turns
// are provided via evidenceContext. Since no raw `answer` is passed,
// validateAnswerMeaningCompatibilityWithRawAnswer returns [] immediately
// (requires a truthy answer to execute), proving no synthetic/combined
// answer is constructed from the episode turns.
const graph = makeSituationGraph();
// Use structured meaning (bypasses raw-answer compatibility) with valid alignment
const proposalWithMeaning = {
addedNodes: [
makeNode({
id: "n-anchor-na",
label: "Update anchor",
description: "Anchor state.",
kind: "state",
status: "known",
confidence: "low",
}),
],
updatedNodes: [
{
nodeId: "n-child-1",
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Proceed with full commitment.",
reason: "Evidence supports it.",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-child-1"],
affectedNodeIds: [],
selectedQuestion: null,
structuralActionRequired: true,
answerMeaning: {
supportCategory: "explicit_hard_constraint",
resolutionGuidance: "must_resolve",
userSupportedMeaning: "Proceed with full commitment.",
},
};
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{ contributionId: "c-1", sequence: 1, question: "Go?", answer: "Absolutely yes." },
{ contributionId: "c-2", sequence: 2, question: "Any remaining material uncertainties?", answer: "No." },
],
eligibleCanonicalFindings: [],
excludedFindingProvenance: [],
},
};
// No `answer` parameter — evidenceContext drives the episode branch.
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: proposalWithMeaning,
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
});
it("proposal-internal validation still runs in episode mode", async () => {
const graph = makeSituationGraph();
// Duplicate node ID — structural violation that proposal-internal validation catches.
const proposalWithDuplicateNodes = {
addedNodes: [
{
id: "n-child-1", // duplicates existing node ID
kind: "unknown",
status: "unknown",
confidence: "medium",
label: "Duplicate unknown",
value: null,
dependsOn: [],
},
],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
};
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{ contributionId: "c-1", sequence: 1, question: "Q?", answer: "A." },
],
eligibleCanonicalFindings: [],
excludedFindingProvenance: [],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: proposalWithDuplicateNodes,
evidenceContext: episodeEvidence,
});
// Proposal-internal validation (structural) still runs and rejects.
expect(result.success).toBe(false);
expect(result.stage).toBe("proposal_compatibility");
});
it("structured episode is available for subsequent safeguard adaptation", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const turns = [
{ contributionId: "c-1", sequence: 1, question: "First turn?", answer: "First answer." },
{ contributionId: "c-2", sequence: 2, question: "Second turn?", answer: "Second answer." },
];
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns,
eligibleCanonicalFindings: [],
excludedFindingProvenance: [],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
expect(result.updatedSituationGraph.nodes.find((n) => n.id === "n-child-1")?.status).toBe("resolved");
});
it("preserves existing graph mutation behaviour in episode mode", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const originalParentStatus = graph.nodes.find((n) => n.id === "n-parent")?.status;
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{ contributionId: "c-1", sequence: 1, question: "Q?", answer: "A." },
],
eligibleCanonicalFindings: [],
excludedFindingProvenance: [],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
// Graph was mutated (expected)
expect(result.updatedSituationGraph.nodes.find((n) => n.id === "n-child-1")?.status).toBe("resolved");
// Original graph not mutated (atomicity test)
expect(graph.nodes.find((n) => n.id === "n-parent")?.status).toBe(originalParentStatus);
});
});
// ── Signature compatibility ────────────────────────────────────────
describe("applyValidatedProposal signature", () => {
it("works with minimal legacy call (situationGraph + proposal only)", async () => {
const graph = makeSituationGraph();
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: makeResolvedProposal(["n-child-1"]),
});
expect(result.success).toBe(true);
});
it("works with legacy answer parameter still passed", async () => {
const graph = makeSituationGraph();
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: makeResolvedProposal(["n-child-1"]),
answer: "Yes.",
});
expect(result.success).toBe(true);
});
it("works with both evidenceContext and legacy parameters (evidenceContext takes precedence)", async () => {
const graph = makeSituationGraph();
const proposal = makeResolvedProposal(["n-child-1"]);
const episodeEvidence = {
isCompletedEpisode: true,
episodeEvidence: {
situationGraph: graph,
targetNodeId: "n-parent",
turns: [
{ contributionId: "c-1", sequence: 1, question: "Q?", answer: "A." },
],
eligibleCanonicalFindings: [],
excludedFindingProvenance: [],
},
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal,
previousQuestion: "Legacy question?",
answer: "Legacy answer.", // should be ignored — evidenceContext present
evidenceContext: episodeEvidence,
});
expect(result.success).toBe(true);
});
});