Feature/product platform foundation v0.62 #1
@@ -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.
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user