Implementation of Candidate B (unknown+option) from decision architecture design in 60A.2. Adds two new primitives to the situation graph: Schema (lib/graph/schema.js): - SituationKind.option — a choice available within a decision context - SituationRelationship.contained_in — links option → its parent unknown context Prompt rules (lib/graph/prompt-builder.js): - Section added: Decision Option Structure Rules with 5 numbered instructions governing when/how to create option nodes, link them via contained_in, attach consequences to specific options, and handle do-nothing alternatives. Explicitly forbids alternative_to edges and is_baseline/is_default flags. Tests (446 new lines): - schema.test.js: +300 — enum completeness updates, option kind validation, contained_in edge validation, native two-option graph fixture (~25 new tests) - prompt-builder.test.js: +133 — focused rules verification for all 5 rule points, negative checks (no relocation/savings/example-specific wording, no alternative_to requirement, baseline flag prohibition context) No production code paths affected beyond the two enum additions; existing node and edge kinds remain unchanged. No Ollama calls, no live API calls.
798 lines
22 KiB
JavaScript
798 lines
22 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
SituationKind,
|
|
SituationStatus,
|
|
ConfidenceLevel,
|
|
SituationRelationship,
|
|
answerResolutionGuidance,
|
|
answerSupportCategory,
|
|
situationNodeSchema,
|
|
situationEdgeSchema,
|
|
situationGraphSchema,
|
|
graphUpdateSchema,
|
|
startCaseRequestSchema,
|
|
updateCaseRequestSchema,
|
|
makeNodeId,
|
|
makeNode,
|
|
makeEdge,
|
|
makeGraph,
|
|
} from "@/lib/graph/schema.js";
|
|
|
|
describe("situationNodeSchema", () => {
|
|
const validNode = {
|
|
id: "n1",
|
|
label: "Test Node",
|
|
description: "A test node",
|
|
kind: "observation",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
};
|
|
|
|
it("validates a complete valid node", () => {
|
|
const result = situationNodeSchema.safeParse(validNode);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("requires id", () => {
|
|
const invalid = { ...validNode, id: "" };
|
|
const result = situationNodeSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("requires label", () => {
|
|
const invalid = { ...validNode, label: "" };
|
|
const result = situationNodeSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects invalid kind", () => {
|
|
const invalid = { ...validNode, kind: "nonexistent" };
|
|
const result = situationNodeSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects invalid status", () => {
|
|
const invalid = { ...validNode, status: "unknown_status" };
|
|
const result = situationNodeSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects invalid confidence", () => {
|
|
const invalid = { ...validNode, confidence: "extreme" };
|
|
const result = situationNodeSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("allows numeric value", () => {
|
|
const node = { ...validNode, value: 42 };
|
|
const result = situationNodeSchema.safeParse(node);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("allows string value", () => {
|
|
const node = { ...validNode, value: "active" };
|
|
const result = situationNodeSchema.safeParse(node);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("situationEdgeSchema", () => {
|
|
const validEdge = {
|
|
id: "e1",
|
|
fromNodeId: "n1",
|
|
toNodeId: "n2",
|
|
relationship: "supports",
|
|
confidence: "medium",
|
|
description: "Edge between nodes",
|
|
};
|
|
|
|
it("validates a complete valid edge", () => {
|
|
const result = situationEdgeSchema.safeParse(validEdge);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects invalid relationship type", () => {
|
|
const invalid = { ...validEdge, relationship: "invalid_rel" };
|
|
const result = situationEdgeSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("validates all relationship types", () => {
|
|
for (const rel of Object.values(SituationRelationship)) {
|
|
const edge = { ...validEdge, relationship: rel };
|
|
const result = situationEdgeSchema.safeParse(edge);
|
|
expect(result.success).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects self-referencing edges", () => {
|
|
// Self-refs are structurally valid but semantically questionable
|
|
const edge = { ...validEdge, fromNodeId: "n1", toNodeId: "n1" };
|
|
const result = situationEdgeSchema.safeParse(edge);
|
|
expect(result.success).toBe(true); // Structure is valid; semantics checked elsewhere
|
|
});
|
|
});
|
|
|
|
describe("situationGraphSchema", () => {
|
|
const validGraph = {
|
|
centralStatement: "Test graph summary",
|
|
nodes: [makeNode({ id: "n1", label: "Node 1" })],
|
|
edges: [],
|
|
activeUnknownNodeId: null,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "Initial summary",
|
|
};
|
|
|
|
it("validates a complete valid graph", () => {
|
|
const result = situationGraphSchema.safeParse(validGraph);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("requires at least one node", () => {
|
|
const invalid = { ...validGraph, nodes: [] };
|
|
const result = situationGraphSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("allows empty edges array", () => {
|
|
const graph = { ...validGraph, edges: [] };
|
|
const result = situationGraphSchema.safeParse(graph);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects missing centralStatement", () => {
|
|
const invalid = { ...validGraph, centralStatement: "" };
|
|
const result = situationGraphSchema.safeParse(invalid);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("graphUpdateSchema", () => {
|
|
it("validates empty update (no-op proposal)", () => {
|
|
const result = graphUpdateSchema.safeParse({});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("validates a complete update", () => {
|
|
const node = makeNode({ id: "n2", label: "New Node" });
|
|
const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" });
|
|
|
|
const result = graphUpdateSchema.safeParse({
|
|
addedNodes: [node],
|
|
updatedNodes: [
|
|
{
|
|
nodeId: "n1",
|
|
newStatus: "resolved",
|
|
previousStatus: "unknown",
|
|
reason: "Question answered",
|
|
},
|
|
],
|
|
addedEdges: [edge],
|
|
removedEdgeIds: ["e-old"],
|
|
resolvedUnknownNodeIds: ["n2"],
|
|
affectedNodeIds: ["n3"],
|
|
selectedQuestion: {
|
|
nodeId: "n2",
|
|
question: "What does this new node mean?",
|
|
reason: "A follow-up unknown remains.",
|
|
},
|
|
answerMeaning: {
|
|
userSupportedMeaning:
|
|
"The user directly established a concrete answer.",
|
|
possibleInference: null,
|
|
supportCategory: "other",
|
|
resolutionGuidance: "may_resolve",
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("allows null selectedQuestion", () => {
|
|
const result = graphUpdateSchema.safeParse({
|
|
selectedQuestion: null,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("allows null answerMeaning", () => {
|
|
const result = graphUpdateSchema.safeParse({
|
|
answerMeaning: null,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("validates structured answerMeaning when present", () => {
|
|
const result = graphUpdateSchema.safeParse({
|
|
answerMeaning: {
|
|
userSupportedMeaning: "Risk matters more to me.",
|
|
possibleInference:
|
|
"This may imply caution, but does not establish a hard constraint.",
|
|
supportCategory: answerSupportCategory.relative_priority_only,
|
|
resolutionGuidance: answerResolutionGuidance.must_remain_unresolved,
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects invalid supportCategory values", () => {
|
|
const result = graphUpdateSchema.safeParse({
|
|
answerMeaning: {
|
|
userSupportedMeaning: "Risk matters more to me.",
|
|
possibleInference: null,
|
|
supportCategory: "relative priority only",
|
|
resolutionGuidance: answerResolutionGuidance.must_remain_unresolved,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects invalid resolutionGuidance values", () => {
|
|
const result = graphUpdateSchema.safeParse({
|
|
answerMeaning: {
|
|
userSupportedMeaning: "Risk matters more to me.",
|
|
possibleInference: null,
|
|
supportCategory: answerSupportCategory.relative_priority_only,
|
|
resolutionGuidance: "leave unresolved",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects update with invalid node kind in addedNodes", () => {
|
|
const invalid = graphUpdateSchema.safeParse({
|
|
addedNodes: [
|
|
{
|
|
id: "x",
|
|
label: "Test",
|
|
kind: "invalid_kind",
|
|
description: "test",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
},
|
|
],
|
|
});
|
|
expect(invalid.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("API request schemas", () => {
|
|
describe("startCaseRequestSchema", () => {
|
|
it("validates scenario field", () => {
|
|
const result = startCaseRequestSchema.safeParse({
|
|
scenario: "Test scenario",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects empty scenario", () => {
|
|
const result = startCaseRequestSchema.safeParse({ scenario: "" });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects scenario over 10000 chars", () => {
|
|
const longScenario = "a".repeat(10001);
|
|
const result = startCaseRequestSchema.safeParse({
|
|
scenario: longScenario,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("accepts optional promptVersion", () => {
|
|
const result = startCaseRequestSchema.safeParse({
|
|
scenario: "Test",
|
|
promptVersion: "v0.3",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("updateCaseRequestSchema", () => {
|
|
it("validates complete update request", () => {
|
|
const graph = makeGraph({
|
|
centralStatement: "Test scenario",
|
|
nodes: [makeNode({ id: "n1", label: "N" })],
|
|
currentSummary: "Current state of situation",
|
|
});
|
|
const result = updateCaseRequestSchema.safeParse({
|
|
situationGraph: graph,
|
|
previousQuestion: "What happened?",
|
|
answer: "This is the answer",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects missing situationGraph", () => {
|
|
const result = updateCaseRequestSchema.safeParse({
|
|
previousQuestion: "Q?",
|
|
answer: "A",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects answer over 5000 chars", () => {
|
|
const graph = makeGraph({
|
|
centralStatement: "Test",
|
|
nodes: [makeNode({ id: "n1", label: "N" })],
|
|
currentSummary: "Test summary",
|
|
});
|
|
const result = updateCaseRequestSchema.safeParse({
|
|
situationGraph: graph,
|
|
previousQuestion: "Q?",
|
|
answer: "x".repeat(5001),
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("deterministic ID generation", () => {
|
|
it("generate consistent IDs for same label", () => {
|
|
const id1 = makeNodeId("Same Label");
|
|
const id2 = makeNodeId("Same Label");
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
it("generates different IDs for different labels", () => {
|
|
const id1 = makeNodeId("Label A");
|
|
const id2 = makeNodeId("Label B");
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
|
|
it("IDs are prefixed with 'n' and short", () => {
|
|
const id = makeNodeId(
|
|
"A very long label that would produce a longer hash if not truncated",
|
|
);
|
|
expect(id.startsWith("n")).toBe(true);
|
|
expect(id.length).toBeLessThan(15);
|
|
});
|
|
|
|
it("same kind of nodes get deterministic IDs", () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(makeNodeId("Test Node")).toBe(makeNodeId("Test Node"));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("helper functions", () => {
|
|
describe("makeNode", () => {
|
|
it("creates a minimal node with defaults", () => {
|
|
const node = makeNode({ label: "Minimal" });
|
|
const result = situationNodeSchema.safeParse(node);
|
|
expect(result.success).toBe(true);
|
|
expect(node.kind).toBe("observation");
|
|
expect(node.status).toBe("unknown");
|
|
expect(node.confidence).toBe("medium");
|
|
});
|
|
|
|
it("creates a node with custom kind/status", () => {
|
|
const node = makeNode({
|
|
label: "Custom",
|
|
kind: "metric",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: 42,
|
|
unit: "count",
|
|
});
|
|
expect(node.kind).toBe("metric");
|
|
expect(node.status).toBe("known");
|
|
expect(node.confidence).toBe("high");
|
|
expect(node.value).toBe(42);
|
|
expect(node.unit).toBe("count");
|
|
});
|
|
|
|
it("generates ID from label if none provided", () => {
|
|
const node = makeNode({ label: "Auto-ID" });
|
|
expect(node.id.startsWith("n")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("makeEdge", () => {
|
|
it("creates a minimal edge with defaults", () => {
|
|
const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" });
|
|
const result = situationEdgeSchema.safeParse(edge);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("generates description from node ids if not provided", () => {
|
|
const edge = makeEdge({ fromNodeId: "n-alpha", toNodeId: "n-beta" });
|
|
expect(edge.description).toContain("alpha");
|
|
expect(edge.description).toContain("beta");
|
|
});
|
|
});
|
|
|
|
describe("makeGraph", () => {
|
|
it("creates a minimal graph with defaults", () => {
|
|
const graph = makeGraph({
|
|
centralStatement: "Test",
|
|
currentSummary: "Default summary",
|
|
nodes: [makeNode({ id: "n1", label: "Placeholder" })],
|
|
});
|
|
const result = situationGraphSchema.safeParse(graph);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("allows specifying nodes and edges", () => {
|
|
const graph = makeGraph({
|
|
centralStatement: "Full Graph",
|
|
currentSummary: "Full summary",
|
|
nodes: [makeNode({ id: "n1", label: "N1" })],
|
|
edges: [makeEdge({ fromNodeId: "n1", toNodeId: "n2" })],
|
|
});
|
|
expect(graph.nodes.length).toBe(1);
|
|
expect(graph.edges.length).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("enum values completeness", () => {
|
|
it("SituationKind has all expected values", () => {
|
|
const expected = [
|
|
"observation",
|
|
"reported_claim",
|
|
"metric",
|
|
"state",
|
|
"transition",
|
|
"relationship",
|
|
"assumption",
|
|
"unknown",
|
|
"conclusion",
|
|
"option",
|
|
];
|
|
const actual = Object.values(SituationKind);
|
|
expect(actual).toEqual(expect.arrayContaining(expected));
|
|
});
|
|
|
|
it("SituationStatus has all expected values", () => {
|
|
const expected = [
|
|
"known",
|
|
"unknown",
|
|
"provisional",
|
|
"supported",
|
|
"weakened",
|
|
"contradicted",
|
|
"resolved",
|
|
];
|
|
const actual = Object.values(SituationStatus);
|
|
expect(actual).toEqual(expect.arrayContaining(expected));
|
|
});
|
|
|
|
it("SituationRelationship has all expected values", () => {
|
|
const expected = [
|
|
"supports",
|
|
"weakens",
|
|
"contradicts",
|
|
"depends_on",
|
|
"causes",
|
|
"may_cause",
|
|
"measures",
|
|
"compares_with",
|
|
"updates",
|
|
"contained_in",
|
|
"other",
|
|
];
|
|
const actual = Object.values(SituationRelationship);
|
|
expect(actual).toEqual(expect.arrayContaining(expected));
|
|
});
|
|
|
|
it("ConfidenceLevel has all expected values", () => {
|
|
const actual = Object.values(ConfidenceLevel);
|
|
expect(actual).toContain("low");
|
|
expect(actual).toContain("medium");
|
|
expect(actual).toContain("high");
|
|
});
|
|
});
|
|
|
|
// ── Experiment 60A.3 — Decision Option Vocabulary Boundary ──────────
|
|
|
|
describe("60A.3 option node kind", () => {
|
|
it("accepts 'option' as a valid SituationKind on a node", () => {
|
|
const result = situationNodeSchema.safeParse({
|
|
id: "n-opt-a",
|
|
label: "Option A",
|
|
description: "Candidate path A",
|
|
kind: "option",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("existing node kinds are still accepted", () => {
|
|
for (const kind of Object.values(SituationKind)) {
|
|
const result = situationNodeSchema.safeParse({
|
|
id: "n-x",
|
|
label: "Test",
|
|
description: "Test",
|
|
kind,
|
|
status: "known",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("still rejects invalid node kind", () => {
|
|
const result = situationNodeSchema.safeParse({
|
|
id: "n-x",
|
|
label: "Test",
|
|
description: "Test",
|
|
kind: "decision",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects 'alternative' as a node kind", () => {
|
|
const result = situationNodeSchema.safeParse({
|
|
id: "n-x",
|
|
label: "Test",
|
|
description: "Test",
|
|
kind: "alternative",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects 'baseline' as a node kind", () => {
|
|
const result = situationNodeSchema.safeParse({
|
|
id: "n-x",
|
|
label: "Test",
|
|
description: "Test",
|
|
kind: "baseline",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects 'outcome' as a node kind", () => {
|
|
const result = situationNodeSchema.safeParse({
|
|
id: "n-x",
|
|
label: "Test",
|
|
description: "Test",
|
|
kind: "outcome",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: null,
|
|
unit: null,
|
|
evidenceIds: [],
|
|
dependsOn: [],
|
|
affects: [],
|
|
parentId: null,
|
|
childIds: [],
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("60A.3 contained_in relationship", () => {
|
|
it("accepts 'contained_in' as a valid SituationRelationship on an edge", () => {
|
|
const result = situationEdgeSchema.safeParse({
|
|
id: "e-opt-a-to-ctx",
|
|
fromNodeId: "n-opt-a",
|
|
toNodeId: "n-decision-ctx",
|
|
relationship: "contained_in",
|
|
confidence: "medium",
|
|
description: "Option A belongs to decision context",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("existing relationships are still accepted", () => {
|
|
for (const rel of Object.values(SituationRelationship)) {
|
|
const result = situationEdgeSchema.safeParse({
|
|
id: "e-x",
|
|
fromNodeId: "n-a",
|
|
toNodeId: "n-b",
|
|
relationship: rel,
|
|
confidence: "high",
|
|
description: "test",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects invalid relationship type", () => {
|
|
const result = situationEdgeSchema.safeParse({
|
|
id: "e-x",
|
|
fromNodeId: "n-a",
|
|
toNodeId: "n-b",
|
|
relationship: "contains_option",
|
|
confidence: "high",
|
|
description: "test",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects 'alternative_to' as a SituationRelationship", () => {
|
|
const result = situationEdgeSchema.safeParse({
|
|
id: "e-x",
|
|
fromNodeId: "n-a",
|
|
toNodeId: "n-b",
|
|
relationship: "alternative_to",
|
|
confidence: "high",
|
|
description: "test",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("rejects 'option_for' as a SituationRelationship", () => {
|
|
const result = situationEdgeSchema.safeParse({
|
|
id: "e-x",
|
|
fromNodeId: "n-a",
|
|
toNodeId: "n-b",
|
|
relationship: "option_for",
|
|
confidence: "high",
|
|
description: "test",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("60A.3 native two-option graph fixture", () => {
|
|
it("constructs and validates a complete two-option decision graph", () => {
|
|
const unknownCtx = makeNode({
|
|
id: "n-decision-ctx",
|
|
label: "Which path leaves us better off?",
|
|
description: "Unresolved decision context for the current choice.",
|
|
kind: "unknown",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
});
|
|
|
|
const optionA = makeNode({
|
|
id: "n-option-a",
|
|
label: "Path A — Act",
|
|
description: "Candidate action A with its own consequences.",
|
|
kind: "option",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
});
|
|
|
|
const optionB = makeNode({
|
|
id: "n-option-b",
|
|
label: "Path B — Do nothing",
|
|
description: "Candidate action B (do-nothing / stay-put).",
|
|
kind: "option",
|
|
status: "unknown",
|
|
confidence: "medium",
|
|
});
|
|
|
|
const consequenceA = makeNode({
|
|
id: "n-cons-a",
|
|
label: "Benefit of Path A",
|
|
description: "Consequence that applies to option A.",
|
|
kind: "metric",
|
|
status: "known",
|
|
confidence: "high",
|
|
value: 100,
|
|
});
|
|
|
|
const consequenceB = makeNode({
|
|
id: "n-cons-b",
|
|
label: "Cost of Path B",
|
|
description: "Consequence that applies to option B.",
|
|
kind: "observation",
|
|
status: "known",
|
|
confidence: "high",
|
|
});
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "Test two-option decision.",
|
|
nodes: [unknownCtx, optionA, optionB, consequenceA, consequenceB],
|
|
edges: [
|
|
makeEdge({
|
|
id: "e-opt-a-contained",
|
|
fromNodeId: optionA.id,
|
|
toNodeId: unknownCtx.id,
|
|
relationship: "contained_in",
|
|
confidence: "high",
|
|
description: "Option A belongs to decision context",
|
|
}),
|
|
makeEdge({
|
|
id: "e-opt-b-contained",
|
|
fromNodeId: optionB.id,
|
|
toNodeId: unknownCtx.id,
|
|
relationship: "contained_in",
|
|
confidence: "high",
|
|
description: "Option B belongs to decision context",
|
|
}),
|
|
makeEdge({
|
|
id: "e-cons-a-to-opt-a",
|
|
fromNodeId: consequenceA.id,
|
|
toNodeId: optionA.id,
|
|
relationship: "supports",
|
|
confidence: "high",
|
|
description: "Consequence supports option A",
|
|
}),
|
|
makeEdge({
|
|
id: "e-cons-b-to-opt-b",
|
|
fromNodeId: consequenceB.id,
|
|
toNodeId: optionB.id,
|
|
relationship: "weakens",
|
|
confidence: "high",
|
|
description: "Consequence weakens option B",
|
|
}),
|
|
],
|
|
activeUnknownNodeId: unknownCtx.id,
|
|
resolvedNodeIds: [],
|
|
currentSummary: "2 options under 1 decision context.",
|
|
});
|
|
|
|
expect(graph.nodes.length).toBe(5);
|
|
expect(graph.edges.length).toBe(4);
|
|
|
|
// Both options share the same decision-context unknown
|
|
const optionEdges = graph.edges.filter((e) => e.relationship === "contained_in");
|
|
expect(optionEdges.length).toBe(2);
|
|
expect(optionEdges[0].toNodeId).toBe(optionEdges[1].toNodeId);
|
|
|
|
// Each option has its own consequence attached
|
|
const aConsequences = graph.edges.filter(
|
|
(e) => e.toNodeId === optionA.id,
|
|
);
|
|
const bConsequences = graph.edges.filter(
|
|
(e) => e.toNodeId === optionB.id,
|
|
);
|
|
expect(aConsequences.length).toBeGreaterThan(0);
|
|
expect(bConsequences.length).toBeGreaterThan(0);
|
|
|
|
// No decision node kind exists
|
|
expect(graph.nodes.some((n) => n.kind === "option")).toBe(true);
|
|
expect(graph.nodes.some((n) => n.kind === "unknown")).toBe(true);
|
|
});
|
|
});
|