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:
2026-08-12 19:39:58 +01:00
parent 6dd9afbf6b
commit 57c9f2205e
4 changed files with 446 additions and 2 deletions
+131 -2
View File
@@ -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();
});
});