From 0ba927230bec4caed8eb0467ac19a34d0c68a9e4 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 6 Aug 2026 13:14:03 +0100 Subject: [PATCH] fix: commit scope-aware condition status integration --- lib/graph/decision-condition-status.js | 179 ++++++++++++++++--------- 1 file changed, 118 insertions(+), 61 deletions(-) diff --git a/lib/graph/decision-condition-status.js b/lib/graph/decision-condition-status.js index 43effb2..647c242 100644 --- a/lib/graph/decision-condition-status.js +++ b/lib/graph/decision-condition-status.js @@ -1,29 +1,38 @@ /** - * Experiment 23/24B — Decision Condition Status Assessment. + * 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` to classify each - * linked observation's relationship to the condition as supports / - * contradicts / informs / cannot_determine, then applies status rules: + * 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 linked evidence node returns supports + * 2. established — at least one direct-scope linked evidence node returns supports * AND none returns contradicts. - * 3. contradicted — at least one linked evidence node returns contradicts - * (contradiction always wins over support). - * 4. unresolved — evidence only returns informs, or no usable linked - * evidence exists. + * 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 ──────────────────────────────────────────────── */ @@ -63,6 +72,28 @@ function findLinkedObservations(graph, unknownId) { 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 ─────────────────────────────── */ /** @@ -120,7 +151,7 @@ export function assessDecisionConditionStatus(input) { if (!unknownNode) { /* Focused tests: single node serves as both evidence and unknown. - Accept any resolved unknown node as potential evidence target */ + 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]; @@ -143,8 +174,7 @@ export function assessDecisionConditionStatus(input) { const linkedObs = findLinkedObservations(graph, unknownId); if (linkedObs.length > 0) { - /* Use evidence direction classifier for each linked observation. */ - + /* Use scope-aware evidence direction assessment (Experiment 25B). */ return assessConditionViaEvidenceDirection(condition, firstCategory, linkedObs); } @@ -152,84 +182,95 @@ export function assessDecisionConditionStatus(input) { return assessConditionViaKeywords(condition, graph, firstCategory, unknownId, linkedObs); } -/** Determine which concept categories a condition text belongs to. */ +/* ── Scope-aware direction assessment (Experiment 25B) ─── */ -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; -} - -/* ── Evidence-direction based assessment (for graphs with edges) ─ */ +/** + * 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 directions = []; + const usableDirections = []; const evidenceNodeIds = []; for (const obs of linkedObs) { const text = normalise(obs.label || obs.description || ""); if (text.length === 0) continue; - const result = assessEvidenceDirection({ condition: { text: condition }, evidenceNode: obs }); - directions.push(result); + const directionResult = assessEvidenceDirection({ condition: { text: condition }, evidenceNode: obs }); + const scopeResult = assessEvidenceConditionScope({ + condition: { text: condition }, + evidenceNode: obs, + }); - if (result.direction !== "cannot_determine") { - evidenceNodeIds.push(obs.id); - } else { + /* 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 }); } - const usableDirections = directions.filter((d) => d.direction !== "cannot_determine"); + /* No direct-match evidence with a directional signal → unresolved. */ if (usableDirections.length === 0) { - return { status: "unresolved", evidenceNodeIds: [], reason: `${category} linked observations provide no directional signal` }; + return { status: "unresolved", evidenceNodeIds, reason: `${category} linked observations provide no direct-scope directional signal` }; } - const hasContradicts = directions.some((d) => d.direction === "contradicts"); - const hasSupports = directions.some((d) => d.direction === "supports"); - const anyInformsOrCanD = directions.some( - (d) => d.direction === "informs" || d.direction === "cannot_determine", - ); + const hasContradicts = usableDirections.some((d) => d.direction === "contradicts"); + const hasSupports = usableDirections.some((d) => d.direction === "supports"); if (hasContradicts) { - return { status: "contradicted", evidenceNodeIds, reason: `${category} linked evidence contradicts the condition` }; + return { status: "contradicted", evidenceNodeIds, reason: `${category} direct-scope linked evidence contradicts the condition` }; } - if (hasSupports && !hasContradicts) { - return { status: "established", evidenceNodeIds, reason: `${category} linked evidence supports the condition without contradiction` }; + if (hasSupports) { + return { status: "established", evidenceNodeIds, reason: `${category} direct-scope linked evidence supports the condition without contradiction` }; } - if (anyInformsOrCanD || usableDirections.every((d) => d.direction === "informs")) { - return { status: "unresolved", evidenceNodeIds, reason: `${category} linked evidence only provides contextual information` }; - } - - return { status: "unresolved", evidenceNodeIds: [], reason: `${category} condition assessed but no directional signal obtained` }; + 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. */ + /* 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` }; } @@ -248,21 +289,37 @@ function assessConditionViaKeywords(condition, graph, category, unknownId, linke const keywords = SUPPORT_KEYWORDS[category] || []; const supportingNodes = new Set(); - /* Inspect matched unknown node label for support keywords. */ + /* Inspect matched unknown node label for support keywords (scope-aware). */ if (unknownId) { const unkNode = graph.nodes.find((n) => n.id === unknownId); if (unkNode) { - const text = normalise(unkNode.label || unkNode.description || ""); - if (keywords.some((kw) => text.includes(kw))) { - supportingNodes.add(unkNode.id); + 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. */ + /* 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); }