feat: add 'option' node kind and 'contained_in' edge — 60A.3
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.
This commit is contained in:
@@ -452,6 +452,7 @@ describe("enum values completeness", () => {
|
||||
"assumption",
|
||||
"unknown",
|
||||
"conclusion",
|
||||
"option",
|
||||
];
|
||||
const actual = Object.values(SituationKind);
|
||||
expect(actual).toEqual(expect.arrayContaining(expected));
|
||||
@@ -482,6 +483,7 @@ describe("enum values completeness", () => {
|
||||
"measures",
|
||||
"compares_with",
|
||||
"updates",
|
||||
"contained_in",
|
||||
"other",
|
||||
];
|
||||
const actual = Object.values(SituationRelationship);
|
||||
@@ -495,3 +497,301 @@ describe("enum values completeness", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user