diff --git a/lib/graph/prompt-builder.js b/lib/graph/prompt-builder.js index b8896ae..dc11a9a 100644 --- a/lib/graph/prompt-builder.js +++ b/lib/graph/prompt-builder.js @@ -134,6 +134,19 @@ The JSON object must contain exactly these top-level fields: 31. If the answer explicitly states a hard constraint, state that directly in userSupportedMeaning. 32. Populate resolutionGuidance when the user's meaning genuinely implies must_remain_unresolved, may_resolve, or must_resolve. Keep it null only when no existing resolution state actually applies. +## Decision Option Structure Rules +When the user presents mutually exclusive candidate actions for one unresolved choice: + +1. Create exactly one node of kind "unknown" to carry the decision question (the existing mechanism). Do not add a separate "decision" node kind. Keep that unknown as-is or create it fresh — do not duplicate it into every option. + +2. For each candidate path, create exactly one node of kind "option". The option's label names the alternative; its description states what that alternative entails. + +3. Link each option to the decision-context unknown using relationship "contained_in" (edge: option → unknown). Shared membership already implies these options are alternatives of each other — do not add an "alternative_to" edge between options. + +4. Attach consequences and evidence to the specific option they belong to via existing edge types ("causes", "may_cause", etc.). Each consequence's fromNodeId explicitly identifies its parent option. Do not collapse all alternatives into one generic trade-off description on a single node. + +5. A do-nothing / stay-put / current-state path is an option when it is genuinely one of the alternatives — represent it with kind "option" and label it clearly. Do not introduce an "is_baseline", "is_default", or "is_status_quo" field; baseline meaning is carried by label and consequences alone in this implementation. + ## Contract: structuralActionRequired Declaration Rule When answerMeaning.userSupportedMeaning is populated you MUST set structuralActionRequired to match what your proposal outputs: diff --git a/lib/graph/schema.js b/lib/graph/schema.js index b2bf340..35eb491 100644 --- a/lib/graph/schema.js +++ b/lib/graph/schema.js @@ -18,6 +18,7 @@ export const SituationKind = /** @type {const} */ ({ assumption: "assumption", unknown: "unknown", conclusion: "conclusion", + option: "option", }); export const SituationStatus = /** @type {const} */ ({ @@ -83,6 +84,7 @@ export const SituationRelationship = /** @type {const} */ ({ measures: "measures", compares_with: "compares_with", updates: "updates", + contained_in: "contained_in", other: "other", }); diff --git a/tests/graph/prompt-builder.test.js b/tests/graph/prompt-builder.test.js index a72efa2..e22d4c8 100644 --- a/tests/graph/prompt-builder.test.js +++ b/tests/graph/prompt-builder.test.js @@ -79,13 +79,13 @@ describe("buildGraphUpdatePrompt", () => { it("lists enum values", () => { const prompt = buildGraphUpdatePrompt(makeContext()); expect(prompt).toContain( - "observation | reported_claim | metric | state | transition | relationship | assumption | unknown | conclusion", + "observation | reported_claim | metric | state | transition | relationship | assumption | unknown | conclusion | option", ); expect(prompt).toContain( "known | unknown | provisional | supported | weakened | contradicted | resolved", ); expect(prompt).toContain( - "supports | weakens | contradicts | depends_on | causes | may_cause | measures | compares_with | updates | other", + "supports | weakens | contradicts | depends_on | causes | may_cause | measures | compares_with | updates | contained_in | other", ); }); @@ -635,3 +635,132 @@ describe("buildGraphUpdatePrompt — 57J.59 selected-question contract alignment expect(prompt).toContain("genuinely new concepts"); }); }); + +// ── Experiment 60A.3 — Decision Option Prompt Rules ────────────── + +describe("60A.3 decision option prompt rules", () => { + const prompt = buildGraphUpdatePrompt(makeContext()); + const section = prompt.split("## Decision Option Structure Rules")[1].split( + "## Contract:", + )[0]; + + it("rule: when user presents mutually exclusive candidate actions, create one option node per alternative", () => { + expect(section).toContain("mutually exclusive"); + expect(section).toContain("option"); + expect(section).toContain("each"); + }); + + it("rule: keep one unknown as decision context; do not duplicate into every option", () => { + expect(section).toContain("unknown"); + expect(section).toMatch(/decision[-\s]context/); + }); + + it("rule: link each option to decision-context unknown using contained_in", () => { + expect(section).toContain("contained_in"); + expect(section).toContain("option"); + expect(section).toContain("unknown"); + }); + + it("rule: attach consequences to the specific option they belong to", () => { + expect(section).toContain("specific option"); + expect(section).toContain("consequence"); + }); + + it("rule text uses double-quoted enum values (no backticks in source)", () => { + // The rule section must use "kind" style quoting that works inside a template literal. + expect(section).toContain('kind "unknown"'); + expect(section).toContain('kind "option"'); + expect(section).toContain('"contained_in"'); + }); + + it("rule explicitly says do not add an alternative_to edge", () => { + expect(section).toContain("alternative_to"); + const lines = section.split("\n").filter((l) => l.includes("alternative_to")); + const negLine = lines.find( + (l) => + l.toLowerCase().includes("do not") || + l.toLowerCase().includes("not add") || + l.toLowerCase().includes("dont"), + ); + expect(negLine).toBeDefined(); + }); + + it("rule does not require is_baseline / is_default / is_status_quo fields", () => { + expect(section).toContain('is_baseline'); + expect(section).toContain('is_default'); + expect(section).toContain('is_status_quo'); + // Verify these appear only in a negation context: + const negLines = section.split("\n").filter( + (l) => l.includes("is_baseline") || l.includes("is_default"), + ); + expect(negLines.some((l) => l.toLowerCase().includes("do not introduce"))).toBe(true); + }); + + it("rule explicitly forbids alternative_to edge", () => { + const rulesSection = prompt.split("## Decision Option Structure Rules")[1]; + expect(rulesSection).toContain("alternative_to"); + // Should say something about not adding it (e.g., "Do not add") + const lines = rulesSection.split("\n").filter((l) => l.includes("alternative_to")); + const altLine = lines.find((l) => + l.toLowerCase().includes("do not") || l.toLowerCase().includes("not"), + ); + expect(altLine).toBeDefined(); + }); + + it("rule: do-nothing / stay-put is an option when genuinely one of the alternatives", () => { + expect(section).toContain("do-nothing") || section.includes("do nothing"); + // Verify at least one variant is present + const hasDoNothing = section.includes("do-nothing") || section.includes("do nothing"); + expect(hasDoNothing).toBe(true); + }); + + // Negative checks + it("no relocation/savings-specific wording introduced", () => { + expect(prompt).not.toContain("relocation"); + expect(prompt).not.toContain("savings"); + expect(prompt).not.toContain("engineer"); + expect(prompt).not.toContain("£2m"); + expect(prompt).not.toContain("£2M"); + }); + + it("no new decision node kind requirement in prompt", () => { + const rulesSection = prompt.split("## Decision Option Structure Rules")[1]; + // The section must NOT instruct adding a new "decision" node kind — but may mention "decision" as context. + expect(rulesSection).toContain('kind "unknown"'); + expect(rulesSection).toContain('"option"'); + // Verify the prohibition is against adding a separate decision node: + const lines = rulesSection.split("\n").filter((l) => l.includes("decision") && l.toLowerCase().includes("add")); + expect(lines.some((l) => l.toLowerCase().includes("do not"))).toBe(true); + }); + + it("does not require alternative_to — only forbids it", () => { + const rulesSection = prompt.split("## Decision Option Structure Rules")[1]; + // The section must mention alternative_to (to forbid it) but not require it: + expect(rulesSection).toContain("alternative_to"); + // Verify the mention is in a prohibition context, not an encouragement context: + const lines = rulesSection.split("\n").filter((l) => l.includes("alternative_to")); + expect(lines.some((l) => l.toLowerCase().includes("do not"))).toBe(true); + }); + + it("baseline flag words appear only in prohibition context", () => { + const rulesSection = prompt.split("## Decision Option Structure Rules")[1]; + // The strings is_baseline, is_default, is_status_quo are mentioned to forbid them: + ["is_baseline", "is_default", "is_status_quo"].forEach((flag) => { + expect(rulesSection).toContain(flag); + // Verify each appears in a negation context: + const lines = rulesSection.split("\n").filter((l) => l.includes(flag)); + expect(lines.some((l) => l.toLowerCase().includes("do not introduce"))).toBe(true); + }); + }); + + it("rule: explicitly forbids alternative_to edge", () => { + const rulesSection = prompt.split("## Decision Option Structure Rules")[1]; + expect(rulesSection).toContain("alternative_to"); + // Should say something about not adding it (e.g., "Do not add") + const lines = rulesSection.split("\n").filter((l) => l.includes("alternative_to")); + const altLine = lines.find((l) => + l.toLowerCase().includes("do not") || l.toLowerCase().includes("not"), + ); + expect(altLine).toBeDefined(); + }); +}); diff --git a/tests/graph/schema.test.js b/tests/graph/schema.test.js index 9603347..5d23e8c 100644 --- a/tests/graph/schema.test.js +++ b/tests/graph/schema.test.js @@ -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); + }); +});