559 lines
17 KiB
JavaScript
559 lines
17 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|