feat(confidence-engine): accept episode evidence at graph application
This commit is contained in:
+27
-13
@@ -3971,9 +3971,11 @@ export async function applyValidatedProposal({
|
|||||||
proposal,
|
proposal,
|
||||||
previousQuestion = null,
|
previousQuestion = null,
|
||||||
answer = null,
|
answer = null,
|
||||||
|
evidenceContext = null,
|
||||||
provider = null,
|
provider = null,
|
||||||
modelName = null,
|
modelName = null,
|
||||||
}) {
|
}) {
|
||||||
|
const isLegacySingleTurn = !evidenceContext;
|
||||||
const graphValidation = situationGraphSchema.safeParse(situationGraph);
|
const graphValidation = situationGraphSchema.safeParse(situationGraph);
|
||||||
const proposalValidation = graphUpdateSchema.safeParse(proposal);
|
const proposalValidation = graphUpdateSchema.safeParse(proposal);
|
||||||
|
|
||||||
@@ -4030,11 +4032,15 @@ export async function applyValidatedProposal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
|
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
|
||||||
const ownershipChanges = reconcileDecisionClosureOwnership(
|
// legacy only: episode evidence does not supply a single-answer confirmation string;
|
||||||
situationGraph,
|
// Done and Finding agree are NOT closure authority (settled architecture rule).
|
||||||
reconciledProposal.proposal,
|
const ownershipChanges = isLegacySingleTurn
|
||||||
answer,
|
? reconcileDecisionClosureOwnership(
|
||||||
);
|
situationGraph,
|
||||||
|
reconciledProposal.proposal,
|
||||||
|
answer,
|
||||||
|
)
|
||||||
|
: { strippedUpdates: [], strippedResolvedIds: [] };
|
||||||
if (ownershipChanges.strippedResolvedIds.length > 0) {
|
if (ownershipChanges.strippedResolvedIds.length > 0) {
|
||||||
// Reconciler may have added selectedQuestion = null because the parent
|
// Reconciler may have added selectedQuestion = null because the parent
|
||||||
// appeared resolved during reconciliation. If we stripped it, restore
|
// appeared resolved during reconciliation. If we stripped it, restore
|
||||||
@@ -4130,12 +4136,16 @@ export async function applyValidatedProposal({
|
|||||||
validatedProposal,
|
validatedProposal,
|
||||||
);
|
);
|
||||||
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
|
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
|
||||||
proposalCompatibilityErrors.push(
|
// legacy only: raw-answer fidelity is a single-turn safeguard.
|
||||||
...validateAnswerMeaningCompatibilityWithRawAnswer({
|
// Episode evidence carries structured turns — no synthetic answer is constructed.
|
||||||
answer,
|
if (isLegacySingleTurn) {
|
||||||
proposal: validatedProposal,
|
proposalCompatibilityErrors.push(
|
||||||
}),
|
...validateAnswerMeaningCompatibilityWithRawAnswer({
|
||||||
);
|
answer,
|
||||||
|
proposal: validatedProposal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
proposalCompatibilityErrors.push(
|
proposalCompatibilityErrors.push(
|
||||||
...validateAnswerMeaningAlignment(validatedProposal),
|
...validateAnswerMeaningAlignment(validatedProposal),
|
||||||
);
|
);
|
||||||
@@ -4165,10 +4175,14 @@ export async function applyValidatedProposal({
|
|||||||
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
|
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
|
||||||
: null;
|
: null;
|
||||||
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
|
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({
|
const reasoningResolution = deriveReasoningStateOverride({
|
||||||
graph: graphSnapshot,
|
graph: graphSnapshot,
|
||||||
previousQuestion,
|
previousQuestion: _derivedQuestion,
|
||||||
answer,
|
answer: _derivedAnswer,
|
||||||
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user