335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
/**
|
|
* Experiment 23/24B/25B — 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.
|
|
*
|
|
* Uses Experiment 24A's assessEvidenceDirection and Experiment 25A's
|
|
* assessEvidenceConditionScope to classify each linked observation's
|
|
* relationship to the condition as supports / contradicts / informs /
|
|
* cannot_determine, then applies scope-aware status rules:
|
|
*
|
|
* Scope-aware classification (Experiment 25B):
|
|
* direct_match + supports → established
|
|
* direct_match + contradicts → contradicted
|
|
* partial_match → unresolved (even if direction = supports/contradicts)
|
|
* different_timeframe → unresolved (evidence does not directly answer the condition)
|
|
* unrelated → ignore for status purposes
|
|
* cannot_determine → do not establish or contradict
|
|
*
|
|
* Classification rules (evaluated in order):
|
|
* 1. cannot_determine — condition text is missing or graph is incomplete.
|
|
* 2. established — at least one direct-scope linked evidence node returns supports
|
|
* AND none returns contradicts.
|
|
* 3. contradicted — at least one direct-scope linked evidence node returns contradicts
|
|
* (contradiction always wins over support within direct scope).
|
|
* 4. unresolved — no direct-scope evidence with directional signal, or partial/different/unrelated scope only.
|
|
*
|
|
* IMPORTANT: Do not mark a condition established merely because its unknown is resolved.
|
|
* The actual evidence text from connected observations determines status.
|
|
*/
|
|
|
|
import { assessEvidenceDirection } from "./evidence-direction.js";
|
|
import { assessEvidenceConditionScope } from "./evidence-condition-scope.js";
|
|
|
|
/* ── Helpers ──────────────────────────────────────────────── */
|
|
|
|
function normalise(value) {
|
|
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
}
|
|
|
|
/** Build an adjacency map: nodeId → Set of connected nodeIds (via edges). */
|
|
|
|
function buildAdjacency(graph) {
|
|
const adj = new Map();
|
|
for (const node of graph.nodes || []) {
|
|
if (!adj.has(node.id)) adj.set(node.id, new Set());
|
|
}
|
|
for (const edge of graph.edges || []) {
|
|
adj.get(edge.fromNodeId)?.add(edge.toNodeId);
|
|
adj.get(edge.toNodeId)?.add(edge.fromNodeId);
|
|
}
|
|
return adj;
|
|
}
|
|
|
|
/** Find observations connected to a specific resolved unknown via edges. */
|
|
|
|
function findLinkedObservations(graph, unknownId) {
|
|
const adj = buildAdjacency(graph);
|
|
const linkedIds = adj.get(unknownId);
|
|
if (!linkedIds) return [];
|
|
|
|
const observations = [];
|
|
for (const nodeId of linkedIds) {
|
|
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
if (!node) continue;
|
|
// Only accept actual observation nodes (not state, relationship, or unknown types)
|
|
if (node.kind !== "observation") continue;
|
|
observations.push(node);
|
|
}
|
|
return observations;
|
|
}
|
|
|
|
/** Determine which concept categories a condition text belongs to. */
|
|
|
|
function matchSupportConcepts(conditionText) {
|
|
const lower = conditionText.toLowerCase();
|
|
const cats = [];
|
|
|
|
if (lower.includes("demand") || lower.includes("need") || lower.includes("interest") || lower.includes("audience")) {
|
|
cats.push("demand");
|
|
}
|
|
if (lower.includes("compliance") || lower.includes("gdpr") || lower.includes("regulation") || lower.includes("data residency")) {
|
|
cats.push("compliance");
|
|
}
|
|
if (lower.includes("cost") || lower.includes("investment") || lower.includes("justif") || lower.includes("viability") || lower.includes("market value")) {
|
|
cats.push("value_cost");
|
|
}
|
|
if (lower.includes("differentiat") || lower.includes("advantage") || lower.includes("competit") || lower.includes("positioning") || lower.includes("unique")) {
|
|
cats.push("differentiation");
|
|
}
|
|
|
|
return cats;
|
|
}
|
|
|
|
/* ── 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" };
|
|
}
|
|
|
|
/* Find concept categories for this condition and the corresponding unknown node. */
|
|
|
|
const conditionCategories = matchSupportConcepts(condition);
|
|
if (conditionCategories.length === 0) {
|
|
return { status: "unresolved", evidenceNodeIds: [], reason: "condition text contains no recognisable decision keywords" };
|
|
}
|
|
|
|
const firstCategory = conditionCategories[0];
|
|
|
|
/* Locate the relevant unknown node (by label matching or fixed IDs from long-turns fixture). */
|
|
|
|
const unknownPatterns = {
|
|
demand: ["demand", "need", "audience", "interest"],
|
|
compliance: ["compliance", "gdpr", "regulation", "data residency"],
|
|
value_cost: ["cost", "investment", "viability", "value.*justify"],
|
|
differentiation: ["differentiat", "advantage", "competit", "positioning", "unique"],
|
|
};
|
|
|
|
const patternKeywords = unknownPatterns[firstCategory] || [];
|
|
const unknownNodeCandidates = graph.nodes.filter(
|
|
(n) => n.kind === "unknown" && patternKeywords.some((kw) => (n.label || "").toLowerCase().includes(kw)),
|
|
);
|
|
|
|
/* Also accept by fixed IDs for the long-investigation fixture. */
|
|
const fallbackIds = ["u-1", "u-2", "u-3", "u-4"];
|
|
const fallbackCandidates = graph.nodes.filter((n) => n.kind === "unknown" && fallbackIds.includes(n.id));
|
|
|
|
let unknownNode;
|
|
if (unknownNodeCandidates.length > 0) {
|
|
unknownNode = unknownNodeCandidates[0];
|
|
} else if (fallbackCandidates.length > 0) {
|
|
unknownNode = fallbackCandidates[0];
|
|
}
|
|
|
|
if (!unknownNode) {
|
|
/* Focused tests: single node serves as both evidence and unknown.
|
|
Accept any resolved unknown node as potential evidence target. */
|
|
const allResolvedUnknowns = graph.nodes.filter((n) => n.kind === "unknown" && (graph.resolvedNodeIds || []).includes(n.id));
|
|
if (allResolvedUnknowns.length > 0) {
|
|
unknownNode = allResolvedUnknowns[0];
|
|
} else {
|
|
return { status: "unresolved", evidenceNodeIds: [], reason: `no ${firstCategory} unknown node found in graph` };
|
|
}
|
|
}
|
|
|
|
const unknownId = unknownNode.id;
|
|
|
|
/* Unknown must be resolved before its linked observations count as evidence. */
|
|
|
|
const resolvedIds = new Set(graph.resolvedNodeIds || []);
|
|
if (!resolvedIds.has(unknownId)) {
|
|
return { status: "unresolved", evidenceNodeIds: [], reason: `${firstCategory} unknown is not yet resolved` };
|
|
}
|
|
|
|
/* Find observations linked to this resolved unknown via edges. */
|
|
|
|
const linkedObs = findLinkedObservations(graph, unknownId);
|
|
|
|
if (linkedObs.length > 0) {
|
|
/* Use scope-aware evidence direction assessment (Experiment 25B). */
|
|
return assessConditionViaEvidenceDirection(condition, firstCategory, linkedObs);
|
|
}
|
|
|
|
/* Fallback: keyword-based assessment for tests/fixtures without edges. */
|
|
return assessConditionViaKeywords(condition, graph, firstCategory, unknownId, linkedObs);
|
|
}
|
|
|
|
/* ── Scope-aware direction assessment (Experiment 25B) ─── */
|
|
|
|
/**
|
|
* Assess all linked observations and derive condition status considering
|
|
* both evidence direction AND evidence-condition scope.
|
|
*
|
|
* Rule: only direct_match scope evidence can establish or contradict.
|
|
* partial_match, different_timeframe, unrelated, cannot_determine leave
|
|
* the condition unresolved even when direction points elsewhere.
|
|
*/
|
|
|
|
function assessConditionViaEvidenceDirection(condition, category, linkedObs) {
|
|
const usableDirections = [];
|
|
const evidenceNodeIds = [];
|
|
|
|
for (const obs of linkedObs) {
|
|
const text = normalise(obs.label || obs.description || "");
|
|
if (text.length === 0) continue;
|
|
|
|
const directionResult = assessEvidenceDirection({ condition: { text: condition }, evidenceNode: obs });
|
|
const scopeResult = assessEvidenceConditionScope({
|
|
condition: { text: condition },
|
|
evidenceNode: obs,
|
|
});
|
|
|
|
/* Record all directional signals for evidenceNodeIds. */
|
|
|
|
if (scopeResult.scope === "direct_match" || directionResult.direction !== "cannot_determine") {
|
|
evidenceNodeIds.push(obs.id);
|
|
}
|
|
|
|
/* Only direct_match scope contributes directional signal to status.
|
|
partial_match, different_timeframe, unrelated, cannot_determine
|
|
are relevant but do not directly answer the condition being assessed. */
|
|
|
|
if (scopeResult.scope !== "direct_match") continue;
|
|
|
|
if (directionResult.direction === "cannot_determine") continue;
|
|
|
|
usableDirections.push({ direction: directionResult.direction, evidenceId: obs.id });
|
|
}
|
|
|
|
/* No direct-match evidence with a directional signal → unresolved. */
|
|
|
|
if (usableDirections.length === 0) {
|
|
return { status: "unresolved", evidenceNodeIds, reason: `${category} linked observations provide no direct-scope directional signal` };
|
|
}
|
|
|
|
const hasContradicts = usableDirections.some((d) => d.direction === "contradicts");
|
|
const hasSupports = usableDirections.some((d) => d.direction === "supports");
|
|
|
|
if (hasContradicts) {
|
|
return { status: "contradicted", evidenceNodeIds, reason: `${category} direct-scope linked evidence contradicts the condition` };
|
|
}
|
|
|
|
if (hasSupports) {
|
|
return { status: "established", evidenceNodeIds, reason: `${category} direct-scope linked evidence supports the condition without contradiction` };
|
|
}
|
|
|
|
return { status: "unresolved", evidenceNodeIds, reason: `${category} linked evidence provides context only within direct scope` };
|
|
}
|
|
|
|
/* ── Keyword-based assessment (fallback for tests/fixtures without edges) ─ */
|
|
|
|
/**
|
|
* Fallback when no edge-linked observations exist.
|
|
* Inspects resolved nodes using keywords, but scope-aware: only direct_match
|
|
* nodes can establish or contradict; all other scopes leave unresolved.
|
|
*/
|
|
|
|
function assessConditionViaKeywords(condition, graph, category, unknownId, linkedObs = []) {
|
|
const allResolved = graph.nodes.filter((n) => n.status === "resolved");
|
|
|
|
/* Check contradiction phrases in ALL resolved evidence — but scope-aware. */
|
|
const CONTRADICTION_PHRASES = ["does not support", "cannot meet", "unreachable", "not achievable", "impossible to achieve", "no comparable"];
|
|
|
|
for (const node of allResolved) {
|
|
const text = normalise(node.label || node.description || "");
|
|
if (text.length === 0) continue;
|
|
|
|
/* Check scope before applying contradiction. */
|
|
|
|
const scopeResult = assessEvidenceConditionScope({
|
|
condition: { text: condition },
|
|
evidenceNode: node,
|
|
});
|
|
|
|
if (scopeResult.scope !== "direct_match") continue;
|
|
|
|
if (CONTRADICTION_PHRASES.some((phrase) => text.includes(phrase))) {
|
|
return { status: "contradicted", evidenceNodeIds: [node.id], reason: `${category} linked evidence contradicts the condition` };
|
|
}
|
|
}
|
|
|
|
/* Check support keywords in matched unknown's label only, plus any linked observations. */
|
|
/* Note: value_cost uses stronger phrases to avoid false positives from
|
|
contextual cost/compliance evidence that doesn't prove "value justifies cost." */
|
|
const SUPPORT_KEYWORDS = {
|
|
demand: ["demand", "need", "interest", "audience"],
|
|
compliance: ["compliance", "regulation", "gdpr", "data residency"],
|
|
value_cost: ["justified by market", "worth the cost", "sufficient return", "justifies entry", "financial viable", "value justifies"],
|
|
differentiation: ["differentiat", "advantage", "competit", "positioning", "unique"],
|
|
};
|
|
|
|
const keywords = SUPPORT_KEYWORDS[category] || [];
|
|
const supportingNodes = new Set();
|
|
|
|
/* Inspect matched unknown node label for support keywords (scope-aware). */
|
|
if (unknownId) {
|
|
const unkNode = graph.nodes.find((n) => n.id === unknownId);
|
|
if (unkNode) {
|
|
const scopeResult = assessEvidenceConditionScope({
|
|
condition: { text: condition },
|
|
evidenceNode: unkNode,
|
|
});
|
|
|
|
if (scopeResult.scope === "direct_match") {
|
|
const text = normalise(unkNode.label || unkNode.description || "");
|
|
if (keywords.some((kw) => text.includes(kw))) {
|
|
supportingNodes.add(unkNode.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Also inspect linked observations for support keywords (scope-aware). */
|
|
if (linkedObs && linkedObs.length > 0) {
|
|
for (const obs of linkedObs) {
|
|
const text = normalise(obs.label || obs.description || "");
|
|
if (text.length === 0) continue;
|
|
|
|
const scopeResult = assessEvidenceConditionScope({
|
|
condition: { text: condition },
|
|
evidenceNode: obs,
|
|
});
|
|
|
|
if (scopeResult.scope !== "direct_match") continue;
|
|
|
|
if (keywords.some((kw) => text.includes(kw))) {
|
|
supportingNodes.add(obs.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (supportingNodes.size > 0) {
|
|
return { status: "established", evidenceNodeIds: [...supportingNodes], reason: `${category} resolved evidence supports the condition` };
|
|
}
|
|
|
|
return { status: "unresolved", evidenceNodeIds: [], reason: `${category} condition is relevant but no resolved evidence establishes or contradicts it` };
|
|
}
|