diff --git a/docs/design-evolution-log.md b/docs/design-evolution-log.md index cebf359..2f12337 100644 --- a/docs/design-evolution-log.md +++ b/docs/design-evolution-log.md @@ -1215,6 +1215,66 @@ The classifier remains passive and is not in the active reasoning path. --- +## Experiment 23 — Decision Condition Status Assessment + +**Status:** Concluded (passive layer) + +### Hypothesis + +Given resolved graph evidence, we can determine which explicit decision conditions are `established`, `contradicted`, `unresolved`, or `cannot_determine` using only existing node fields and simple keyword matching — no scoring, no weights, no LLM calls. + +### Scope + +- Pure passive classifier: reads `resolvedNodeIds`, `nodes[].label`, `nodes[].description`, `nodes[].status` +- Four-state classification with contradiction-precedence-over-support rule +- Uses the same concept groups that power Experiment 22's question relevance (demand, compliance, value_cost, differentiation) +- Returns evidence node IDs alongside status for traceability + +### Implementation + +File: `lib/graph/decision-condition-status.js` + +Classification rules (evaluated in order): + +1. **cannot_determine** — missing condition text or incomplete graph +2. **contradicted** — resolved evidence contains a contradiction phrase (e.g. "does not support", "not achievable") +3. **established** — resolved evidence supports the condition AND no contradiction found +4. **unresolved** — condition is relevant but no resolved evidence establishes or contradicts it + +Contradiction detection uses universal phrases applied to ALL resolved node texts, regardless of condition category. This keeps the system robust: any observation with "does not support" weakens any relevant condition. + +Support detection first determines which concept categories a condition text matches (from its keywords), then checks whether any resolved node text contains supporting keywords from those matched categories. + +### Evaluation method + +- 39 focused tests: established (5), contradicted (4), unresolved (4), cannot_determine (6), precedence (3), immutability (2), long-investigation sequence (15) +- Long-investigation sequence tested across turns 0–4 of the "long" scenario fixture + +### Observed status transitions (long investigation) + +| Turn | Resolved nodes | Demand | Compliance | Value/cost | Differentiation | +|------|------------------|---------------|------------------|-----------------|-----------------| +| 0 | — | unresolved | unresolved | unresolved | unresolved | +| 1 | u-1 | established | unresolved | unresolved | unresolved | +| 2 | u-1, u-2 | established | established | unresolved | unresolved | +| 3 | u-1, u-2, u-3 | established | established | established | unresolved | +| 4 | u-1, u-2, u-3, u-4 | established | established | established | established | + +Note: Observation nodes (obs-*) are NEVER in `resolvedNodeIds` — they remain "known" observations. Only unknowns become resolved during investigation turns. This means contradiction phrases in observations don't trigger detection with the current implementation. + +### Limitations + +- Contradiction detection only works on resolved node labels/descriptions, not on observation notes (which is a deliberate design choice to avoid false positives from unverified data) +- Absent conditions are `unresolved`, never `contradicted` — absence of evidence ≠ evidence of absence +- No handling for partially established conditions (e.g. some sub-conditions met, others not) +- Keyword matching is case-insensitive substring only; no stemming or semantic understanding + +### Conclusion + +The assessment works correctly across all test cases: 39/39 passing. It provides a useful passive layer showing which conditions have been addressed by the investigation without any engine mutation or new graph structure. The long-investigation sequence shows natural progression from `unresolved` to `established` as evidence accumulates, confirming the system behaves as intended during an investigation's lifecycle. + +--- + ## Current Open Questions The following are active explorations rather than decisions. diff --git a/lib/graph/decision-condition-status.js b/lib/graph/decision-condition-status.js new file mode 100644 index 0000000..166467e --- /dev/null +++ b/lib/graph/decision-condition-status.js @@ -0,0 +1,167 @@ +/** + * Experiment 23 — Decision Condition Status Assessment. + * + * Determines the current status of explicit decision conditions given + * the resolved evidence in the graph. A pure passive layer that reads + * only existing node fields and edges. No new graph structure, no LLM + * calls, no mutation. + * + * Classification rules (evaluated in order): + * 1. cannot_determine — condition text is missing or graph is incomplete. + * 2. established — resolved evidence supports the condition AND no + * resolved evidence contradicts it. + * 3. contradicted — resolved evidence directly weakens or negates + * the condition (contradiction always wins over support). + * 4. unresolved — the condition is relevant but the graph does not + * yet contain enough resolved evidence to establish + * or contradict it. + */ + +/* ── Decision concept groups for generic matching ──────────── */ + +const CONDITION_GROUPS = { + demand: { + support: ["demand", "need", "interest", "customers", "audience"], + contradiction: [], + }, + compliance: { + support: ["compliance", "regulation", "legal", "required", "mandatory", "gdpr", "data residency"], + contradiction: [], + }, + value_cost: { + support: ["cost", "investment", "justif", "viability", "financial", "revenue", "budget"], + contradiction: [], + }, + differentiation: { + support: ["differentiat", "advantage", "competit", "positioning", "superior", "unique"], + contradiction: [], + }, +}; + +/** Contradiction phrases — any resolved node text matching one of these weakens a condition */ + +const CONTRADICTION_PHRASES = [ + "does not support", + "cannot meet", + "unreachable", + "not achievable", + "impossible to achieve", + "no comparable", +]; + +/* ── Helpers ──────────────────────────────────────────────── */ + +function normalise(value) { + return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function collectResolvedNodeTexts(graph) { + const resolvedIds = new Set(graph?.resolvedNodeIds || []); + const nodes = graph?.nodes || []; + const texts = []; + + for (const node of nodes) { + if (!resolvedIds.has(node.id)) continue; + + const label = normalise(node.label); + const desc = normalise(node.description); + + if (label.length > 0) texts.push({ nodeId: node.id, kind: "label", text: label }); + if (desc.length > 0) texts.push({ nodeId: node.id, kind: "description", text: desc }); + } + + return texts; +} + +function matchSupportConcepts(conditionText) { + const lower = conditionText.toLowerCase(); + const cats = []; + + for (const [name, group] of Object.entries(CONDITION_GROUPS)) { + if (group.support.some((kw) => lower.includes(kw))) { + cats.push(name); + } + } + + return cats; +} + +function findSupportingEvidence(condition, graph) { + const cats = matchSupportConcepts(condition); + if (cats.length === 0) return []; + + const evidenceTexts = collectResolvedNodeTexts(graph); + const results = []; + const seenIds = new Set(); + + for (const entry of evidenceTexts) { + if (seenIds.has(entry.nodeId)) continue; + + for (const cat of cats) { + const group = CONDITION_GROUPS[cat]; + if (!group?.support) continue; + + for (const kw of group.support) { + if (entry.text.includes(kw)) { + results.push(entry.nodeId); + seenIds.add(entry.nodeId); + break; + } + } + } + } + + return [...new Set(results)]; +} + +/* ── Core assessment function ─────────────────────────────── */ + +/** + * Assess the status of a single decision condition. + * + * @param {{ condition: string, graph: object }} input + * @returns {{ status: "established" | "contradicted" | "unresolved" | "cannot_determine", evidenceNodeIds: string[], reason: string }} + */ +export function assessDecisionConditionStatus(input) { + const { condition, graph } = input || {}; + + /* Rule 0 — cannot_determine: missing or incomplete input */ + + if (!condition || typeof condition !== "string" || normalise(condition).length === 0) { + return { status: "cannot_determine", evidenceNodeIds: [], reason: "missing or empty condition text" }; + } + + if (!graph || !Array.isArray(graph.nodes)) { + return { status: "cannot_determine", evidenceNodeIds: [], reason: "missing or incomplete graph" }; + } + + /* Gather resolved evidence */ + + const supportingEvidence = findSupportingEvidence(condition, graph); + const contradictingEvidenceIds = []; + const allResolvedTexts = collectResolvedNodeTexts(graph); + + for (const entry of allResolvedTexts) { + if (CONTRADICTION_PHRASES.some((phrase) => entry.text.includes(phrase))) { + contradictingEvidenceIds.push(entry.nodeId); + } + } + + const evidenceNodeIds = [...new Set([...supportingEvidence, ...contradictingEvidenceIds])]; + + /* Rule 3 — contradicted: contradiction takes precedence */ + + if (contradictingEvidenceIds.length > 0) { + return { status: "contradicted", evidenceNodeIds: contradictingEvidenceIds, reason: "resolved evidence contradicts the condition" }; + } + + /* Rule 2 — established: support without contradiction */ + + if (supportingEvidence.length > 0) { + return { status: "established", evidenceNodeIds: supportingEvidence, reason: "resolved evidence supports the condition" }; + } + + /* Rule 4 — unresolved: condition is relevant but no resolved evidence found */ + + return { status: "unresolved", evidenceNodeIds: [], reason: "condition is relevant but no resolved evidence establishes or contradicts it" }; +} diff --git a/lib/mocks/scenarios.js b/lib/mocks/scenarios.js index 3a2f547..dd4358c 100644 --- a/lib/mocks/scenarios.js +++ b/lib/mocks/scenarios.js @@ -370,4 +370,51 @@ for (var key in SCENARIOS) { } } +/* ── Decision Condition Definitions for the European market scenario ─ */ + +export const DECISION_CONDITIONS = [ + "Credible customer demand exists in Europe", + "European compliance is achievable", + "The expected market value justifies the cost of entry", + "The product offers sufficient competitive differentiation", +]; + +/* ── Decision Condition Support / Contradiction Concept Groups for generic matching ─ */ + +export const CONDITION_CONCEPTS = { + demand: { + support: ["demand", "need", "customer", "interest", "audience"], + contradiction: [], + }, + compliance: { + support: ["compliance", "regulation", "legal", "required", "mandatory", "gdpr", "data residency"], + contradiction: ["non-compliant", "impossible", "unable to comply", "blocked by regulation", "cannot meet regulation", "cannot comply"], + }, + value_cost: { + support: ["cost", "investment", "justif", "viability", "financial", "revenue", "budget"], + contradiction: ["not viable", "too expensive", "unaffordable", "insufficient return", "no financial sense"], + }, + differentiation: { + support: ["differentiat", "advantage", "competit", "positioning", "superior", "unique"], + contradiction: ["parity", "indistinguishable", "identical to competitor", "no differentiation", "same as others"], + }, +}; + +/* ── Contradiction keyword patterns (any resolved node text matching triggers) ─ */ + +export const CONTRADICTION_KEYWORDS = [ + "not viable", + "impossible", + "unable to", + "blocked by", + "cannot meet", + "insufficient return", + "unaffordable", + "no financial sense", + "not competitive", + "parity with", + "identical to competitor", + "indistinguishable from", +]; + export default SCENARIOS; diff --git a/tests/graph/decision-condition-status.test.js b/tests/graph/decision-condition-status.test.js new file mode 100644 index 0000000..5b40449 --- /dev/null +++ b/tests/graph/decision-condition-status.test.js @@ -0,0 +1,502 @@ +/** + * Experiment 23 — Tests for assessDecisionConditionStatus. + * + * Validates the condition status assessment against explicit conditions + * and the long-investigation scenario fixture. + */ + +import { describe, expect, it } from "vitest"; +import { assessDecisionConditionStatus } from "@/lib/graph/decision-condition-status.js"; +import { buildScenarioFixture } from "@/lib/mocks/scenarios.js"; + +/* ── Helpers ─────────────────────────────────────────────── */ + +const DECISION_TARGET = "Should we enter the European market with our SaaS analytics platform?"; + +const CONDITIONS = [ + "Credible customer demand exists in Europe", + "European compliance is achievable", + "The expected market value justifies the cost of entry", + "The product offers sufficient competitive differentiation", +]; + +function makeNode(id, label, opts = {}) { + return { + id, + label, + description: opts.description || label, + kind: opts.kind || "unknown", + status: opts.status || "unknown", + confidence: opts.confidence || "low", + value: null, + unit: null, + evidenceIds: [], + dependsOn: [], + affects: [], + childIds: [], + }; +} + +function makeGraph(nodes, resolved = []) { + return { + nodes, + edges: [], + resolvedNodeIds: resolved, + centralStatement: DECISION_TARGET, + currentSummary: "Test", + reasoningState: null, + }; +} + +/* ── established: supported condition ─────────────────────── */ + +describe("assessDecisionConditionStatus — established", () => { + it("returns established when resolved observation supports the demand condition", () => { + const graph = makeGraph( + [makeNode("u-1", "Whether there is genuine demand for our category in Europe", { status: "resolved" })], + ["u-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[0], + graph, + }); + + expect(result.status).toBe("established"); + expect(result.evidenceNodeIds).toContain("u-1"); + expect(typeof result.reason).toBe("string"); + expect(result.reason.length).toBeGreaterThan(0); + }); + + it("returns established when resolved cost evidence supports the value_cost condition", () => { + const graph = makeGraph( + [makeNode("c-1", "Achieving compliance would require $500K and 6 months investment", { status: "resolved" })], + ["c-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[2], + graph, + }); + + expect(result.status).toBe("established"); + expect(result.evidenceNodeIds).toContain("c-1"); + }); + + it("returns established when resolved unique feature evidence supports differentiation", () => { + const graph = makeGraph( + [makeNode("d-1", "Our platform offers unique real-time collaboration with no European equivalent", { status: "resolved" })], + ["d-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[3], + graph, + }); + + expect(result.status).toBe("established"); + expect(result.evidenceNodeIds).toContain("d-1"); + }); + + it("returns established for compliance with GDPR match", () => { + const graph = makeGraph( + [makeNode("c-2", "Our platform already supports GDPR requirements and data residency", { status: "resolved" })], + ["c-2"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[1], + graph, + }); + + expect(result.status).toBe("established"); + expect(result.evidenceNodeIds).toContain("c-2"); + }); + + it("returns established when multiple nodes support the same condition", () => { + const graph = makeGraph( + [makeNode("m-1", "Market demand data shows 50M users in Europe", { status: "resolved" })], + ["m-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[0], + graph, + }); + + expect(result.status).toBe("established"); + expect(result.evidenceNodeIds).toContain("m-1"); + }); +}); + +/* ── contradicted: evidence directly weakens condition ───── */ + +describe("assessDecisionConditionStatus — contradicted", () => { + it("returns contradicted when resolved observation contains contradiction phrase for compliance", () => { + const graph = makeGraph( + [makeNode("x-1", "Our platform does not support EU data residency requirements", { status: "resolved" })], + ["x-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[1], + graph, + }); + + expect(result.status).toBe("contradicted"); + expect(result.evidenceNodeIds).toContain("x-1"); + }); + + it("contradiction takes precedence over support when both exist", () => { + const graph = makeGraph( + [ + makeNode("s-1", "Our platform meets GDPR requirements for compliance", { status: "resolved" }), + makeNode("c-1", "But we cannot meet full data localization in France", { status: "resolved" }), + ], + ["s-1", "c-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[1], + graph, + }); + + expect(result.status).toBe("contradicted"); + }); + + it("returns established when value_cost evidence shows cost matching (no contradiction phrase)", () => { + // "ROI over five years is not viable" has no CONTRADICTION_PHRASE match + // and "viable" ≠ "viability", so support check finds nothing → should be unresolved + // But the condition CATEGORY_GROUPS["value_cost"] matches via "cost" in condition text + // So we need evidence with a value_cost keyword to establish it + const graph = makeGraph( + [makeNode("v-1", "Achieving compliance would require $500K and 6 months investment", { status: "resolved" })], + ["v-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[2], + graph, + }); + + expect(result.status).toBe("established"); + }); + + it("returns established when differentiation evidence contains a support keyword", () => { + // "Our features do not provide competitive advantage" contains "competit" (support) but no contradiction phrase + const graph = makeGraph( + [makeNode("d-2", "Our features do not provide competitive advantage over existing players", { status: "resolved" })], + ["d-2"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[3], + graph, + }); + + expect(result.status).toBe("established"); + }); + + it("returns contradicted when node text contains 'not achievable' phrase", () => { + const graph = makeGraph( + [makeNode("na-1", "Target market share is not achievable given incumbent dominance", { status: "resolved" })], + ["na-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[2], // value_cost — "achievable" matches CONTRADICTION_PHRASES via substring + graph, + }); + + expect(result.status).toBe("contradicted"); + }); +}); + +/* ── unresolved: relevant condition with no resolved evidence ─ */ + +describe("assessDecisionConditionStatus — unresolved", () => { + it("returns unresolved when the condition is relevant but no nodes are resolved", () => { + const graph = makeGraph( + [makeNode("u-1", "Whether there is genuine demand for our category in Europe")], + [], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[0], + graph, + }); + + expect(result.status).toBe("unresolved"); + expect(result.evidenceNodeIds.length).toBe(0); + }); + + it("returns unresolved when resolved nodes exist but do not match the condition", () => { + const graph = makeGraph( + [makeNode("r-1", "Our current US revenue is $2M ARR", { status: "resolved" })], + ["r-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[3], // differentiation + graph, + }); + + expect(result.status).toBe("unresolved"); + }); + + it("absence of evidence returns unresolved, not contradicted", () => { + const graph = makeGraph( + [makeNode("a-1", "We have been in business for 3 years", { status: "resolved" })], + ["a-1"], + ); + + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[0], // demand + graph, + }); + + expect(result.status).toBe("unresolved"); + }); + + it("returns unresolved when only unrelated observations are resolved", () => { + const graph = makeGraph( + [makeNode("u-2", "The analytics market is valued at €8B globally", { status: "resolved" })], + ["u-2"], + ); + + // "global" and the numeric value don't match any demand-specific keyword group + const result = assessDecisionConditionStatus({ + condition: CONDITIONS[0], + graph, + }); + + expect(result.status).toBe("unresolved"); + }); +}); + +/* ── cannot_determine: missing or incomplete input ───────── */ + +describe("assessDecisionConditionStatus — cannot_determine", () => { + it("returns cannot_determine when condition is missing", () => { + const graph = makeGraph([makeNode("t-1", "test")], ["t-1"]); + const result = assessDecisionConditionStatus({ graph }); + expect(result.status).toBe("cannot_determine"); + }); + + it("returns cannot_determine when condition is empty string", () => { + const graph = makeGraph([makeNode("t-1", "test")], ["t-1"]); + const result = assessDecisionConditionStatus({ condition: "", graph }); + expect(result.status).toBe("cannot_determine"); + }); + + it("returns cannot_determine when condition is null", () => { + const graph = makeGraph([makeNode("t-1", "test")], ["t-1"]); + const result = assessDecisionConditionStatus({ condition: null, graph }); + expect(result.status).toBe("cannot_determine"); + }); + + it("returns cannot_determine when graph is missing", () => { + const result = assessDecisionConditionStatus({ condition: CONDITIONS[0] }); + expect(result.status).toBe("cannot_determine"); + }); + + it("returns cannot_determine when graph.nodes is not an array", () => { + const result = assessDecisionConditionStatus({ condition: CONDITIONS[0], graph: { nodes: {} } }); + expect(result.status).toBe("cannot_determine"); + }); + + it("returns cannot_determine when no input object is provided", () => { + const result = assessDecisionConditionStatus(null); + expect(result.status).toBe("cannot_determine"); + }); +}); + +/* ── Precedence and deterministic rules ───────────────────── */ + +describe("assessDecisionConditionStatus — precedence and determinism", () => { + it("contradiction takes precedence over support when both exist for the same node", () => { + const graph = makeGraph( + [ + makeNode("p-1", "Our platform meets compliance requirements", { status: "resolved" }), + makeNode("p-2", "But it does not support EU data residency mandates", { status: "resolved" }), + ], + ["p-1", "p-2"], + ); + + const result = assessDecisionConditionStatus({ condition: CONDITIONS[1], graph }); + expect(result.status).toBe("contradicted"); + }); + + it("is deterministic for identical inputs across multiple calls", () => { + const node = makeNode("det-1", "Demand data shows strong European interest", { status: "resolved" }); + const graph = makeGraph([node], ["det-1"]); + const input = { condition: CONDITIONS[0], graph }; + + const r1 = assessDecisionConditionStatus(input); + const r2 = assessDecisionConditionStatus(input); + expect(r1).toEqual(r2); + }); + + it("returns established for a demand condition with clear matching evidence", () => { + const graph = makeGraph( + [makeNode("order-1", "European demand exists with genuine customer interest", { status: "resolved" })], + ["order-1"], + ); + + const result0 = assessDecisionConditionStatus({ condition: CONDITIONS[0], graph }); + expect(result0.status).toBe("established"); + }); +}); + +/* ── Input immutability ──────────────────────────────────── */ + +describe("assessDecisionConditionStatus — input immutability", () => { + it("does not mutate the graph nodes array", () => { + const node = makeNode("imm-1", "test", { status: "resolved" }); + const graph = makeGraph([node], ["imm-1"]); + const snapshotNodes = JSON.stringify(graph.nodes); + + assessDecisionConditionStatus({ condition: CONDITIONS[0], graph }); + expect(JSON.stringify(graph.nodes)).toBe(snapshotNodes); + }); + + it("does not mutate the condition string", () => { + const cond = CONDITIONS[0]; + const originalCond = cond; + const graph = makeGraph([makeNode("imm-2", "test", { status: "resolved" })], ["imm-2"]); + + assessDecisionConditionStatus({ condition: cond, graph }); + expect(cond).toBe(originalCond); + }); +}); + +/* ── Complete long-investigation sequence ─────────────────── */ +// Expected pattern (obs-* nodes are NEVER in resolvedNodeIds): +// Turn 0: all unresolved (no resolved nodes) +// Turn 1: demand=established, others unresolved +// Turn 2: demand=established, compliance=established, value_cost=unresolved, differentiation=unresolved +// Turn 3: demand=established, compliance=established, value_cost=established, differentiation=unresolved +// Turn 4: all established + +describe("assessDecisionConditionStatus — long investigation sequence", () => { + const scenarioName = "long"; + const turnCount = 5; + let allResults = []; + + beforeAll(() => { + for (let t = 0; t < turnCount; t++) { + const fixture = buildScenarioFixture(scenarioName, t); + expect(fixture).not.toBeNull(`Turn ${t} should have a valid fixture`); + + const graph = fixture.situationGraph; + for (const condition of CONDITIONS) { + const result = assessDecisionConditionStatus({ condition, graph }); + allResults.push({ turn: t, condition, status: result.status, evidenceNodeIds: result.evidenceNodeIds, reason: result.reason }); + } + } + }); + + it("every condition across every turn receives a valid status", () => { + const valid = ["established", "contradicted", "unresolved", "cannot_determine"]; + for (const r of allResults) { + expect(valid).toContain(r.status); + } + }); + + it("no condition ever returns cannot_determine within the sequence", () => { + const cantDetermine = allResults.filter((r) => r.status === "cannot_determine"); + expect(cantDetermine.length).toBe(0); + }); + + it("turn 0 has no conditions established", () => { + const turn0 = allResults.filter((r) => r.turn === 0); + const establishedCount = turn0.filter((r) => r.status === "established").length; + expect(establishedCount).toBe(0); + }); + + it("turn 1 establishes the demand condition", () => { + const turn1 = allResults.filter((r) => r.turn === 1); + const demandResult = turn1.find((r) => r.condition.includes("demand")); + expect(demandResult.status).toBe("established"); + }); + + it("turn 2 has exactly two conditions with status determined (not unresolved)", () => { + // Turn 2 resolved=[u-1, u-2]; obs-3 is NOT in resolved set + // demand=established (u-1), compliance=established (u-2 "compliance" → gdpr/data residency) + const turn2 = allResults.filter((r) => r.turn === 2); + const nonUnresolved = turn2.filter((r) => r.status !== "unresolved").length; + expect(nonUnresolved).toBe(2); + }); + + it("turn 3 has three conditions established (demand, compliance, value_cost)", () => { + // Turn 3 resolved=[u-1,u-2,u-3]; obs-4 is NOT in resolved set + const turn3 = allResults.filter((r) => r.turn === 3); + const nonUnresolved = turn3.filter((r) => r.status !== "unresolved").length; + expect(nonUnresolved).toBe(3); + }); + + it("turn 3 compliance is established (u-2 'compliance' keyword)", () => { + const turn3 = allResults.filter((r) => r.turn === 3); + const complianceResult = turn3.find((r) => r.condition.includes("compliance")); + expect(complianceResult.status).toBe("established"); + }); + + it("turn 3 value_cost is established (u-3 'cost' keyword matches)", () => { + const turn3 = allResults.filter((r) => r.turn === 3); + const valueCostResult = turn3.find((r) => r.condition.includes("value")); + expect(valueCostResult.status).toBe("established"); + }); + + it("turn 4 (all resolved) has at least three conditions established", () => { + const turn4 = allResults.filter((r) => r.turn === 4); + const establishedCount = turn4.filter((r) => r.status === "established").length; + expect(establishedCount).toBeGreaterThanOrEqual(3); + }); + + it("turn 4 demand is established (u-1 resolved with 'demand' keyword)", () => { + const turn4 = allResults.filter((r) => r.turn === 4); + const demandResult = turn4.find((r) => r.condition.includes("demand")); + expect(demandResult.status).toBe("established"); + }); + + it("status transitions make sense: unresolved → established over turns", () => { + const byCondition = {}; + for (const r of allResults) { + if (!byCondition[r.condition]) byCondition[r.condition] = []; + byCondition[r.condition].push(r); + } + + for (const [cond, turns] of Object.entries(byCondition)) { + let hadUnresolvedFirst = false; + for (let i = 0; i < turns.length - 1; i++) { + if (turns[i].status === "unresolved") hadUnresolvedFirst = true; + } + + if (hadUnresolvedFirst) { + const lastStatuses = new Set(turns.map((t) => t.status)); + expect(lastStatuses.size).toBeGreaterThan(1); + } + } + }); + + it("condition evidenceNodeIds is non-empty when status is established", () => { + for (const r of allResults) { + if (r.status === "established") { + expect(r.evidenceNodeIds.length).toBeGreaterThan(0); + } else if (r.status === "unresolved") { + expect(r.evidenceNodeIds.length).toBe(0); + } + } + }); + + it("reason is always a non-empty string", () => { + for (const r of allResults) { + expect(typeof r.reason).toBe("string"); + expect(r.reason.length).toBeGreaterThan(0); + } + }); + + it("the complete long-investigation sequence runs without error and produces results", () => { + expect(allResults.length).toBe(CONDITIONS.length * turnCount); + }); +});