fix: commit scope-aware condition status integration

This commit is contained in:
2026-08-06 13:14:03 +01:00
parent 6cde220974
commit 0ba927230b
+118 -61
View File
@@ -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);
}