Files
confidence-engine/lib/graph/question-importance.js
T

138 lines
4.5 KiB
JavaScript

/**
* Question Importance — passive classifier for unresolved unknowns.
*
* A pure-function layer that classifies each unresolved unknown into one of
* four importance categories using only actual repository fields. No scoring,
* no weights, no new graph structure. Designed to be validated against mock
* scenarios without changing engine behaviour in any way.
*
* Classification rules (in order):
* 1. important — Other unresolved unknown(s) depend on this one being resolved first;
* OR text contains decision-context patterns ("whether to", "build",
* "launch", "continue", "proceed") AND has ≥1 graph connection.
* 2. helpful — Text contains evidence-related patterns (evidence, metric, measure,
* criteria, validation, proof); OR has ≥2 total connections in the graph.
* 3. incidental — Default when neither important nor helpful conditions are met.
* 4. cannot_determine — Node label and description are both empty/null.
*
* IMPORTANT: This module does NOT modify engine behaviour. It must never write to
* the graph, change unknown selection, or influence question generation. Validation
* is done by running this classifier passively against existing scenario fixtures.
*/
/* ── Decision-context text patterns (from utils.js classifyUnknownPriority) ── */
const DECISION_PATTERNS = [
/whether to/i,
/\bbuild\b/i,
/\blaunch\b/i,
/\bcontinue.*develop/i,
/\bproceed\b/i,
];
/* ── Evidence-related text patterns (from utils.js classifyUnknownPriority) ── */
const EVIDENCE_PATTERNS = [
/evidence|metric|measure|criteria|validation|proof/i,
];
/* ── Normalise node label + description for text matching ── */
function normaliseText(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function collectNodeText(node) {
return `${node?.label || ""} ${node?.description || ""}`.trim();
}
/* ── Collect connected node IDs (union of dependsOn, affects, childIds, and edges) ── */
function collectConnectedIds(node, graph) {
if (!node || !graph) return new Set();
const ids = new Set([
...(node.dependsOn || []),
...(node.affects || []),
...(node.childIds || []),
]);
if (node.parentId) ids.add(node.parentId);
for (const edge of graph.edges || []) {
if (edge.fromNodeId === node.id) ids.add(edge.toNodeId);
if (edge.toNodeId === node.id) ids.add(edge.fromNodeId);
}
return ids;
}
/* ── Check whether any other unresolved unknown depends on this node ── */
function hasDownstreamUnknownDependents(node, graph, resolvedNodeIds) {
if (!node || !graph) return false;
const resolvedSet = new Set(resolvedNodeIds || []);
const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
// Check explicit dependsOn links pointing back to this node
for (const otherNode of graph.nodes) {
if (otherNode.id === node.id) continue;
if (otherNode.kind !== "unknown") continue;
if (resolvedSet.has(otherNode.id)) continue;
if (otherNode.dependsOn.includes(node.id)) return true;
}
// Check edge links where other unknown is source and this is target
for (const edge of graph.edges || []) {
if (edge.toNodeId !== node.id) continue;
const source = nodesById.get(edge.fromNodeId);
if (!source) continue;
if (source.kind !== "unknown") continue;
if (resolvedSet.has(source.id)) continue;
return true;
}
return false;
}
/* ── Core classification function ── */
export function assessQuestionImportance(input) {
// Validate input contract
const { node, graph, resolvedNodeIds = [] } = input || {};
if (!node || !graph || typeof node.kind !== "string") {
return { category: "cannot_determine", reason: "missing_input" };
}
const text = collectNodeText(node);
const connectedIds = collectConnectedIds(node, graph);
const isImportant = [
hasDownstreamUnknownDependents(node, graph, resolvedNodeIds),
DECISION_PATTERNS.some((p) => p.test(text)),
].some(Boolean);
if (isImportant && connectedIds.size >= 1) {
return { category: "important" };
}
// Rule 2 — helpful
const isEvidenceText = EVIDENCE_PATTERNS.some((p) => p.test(text));
if (isEvidenceText || connectedIds.size >= 2) {
return { category: "helpful" };
}
// Rule 4 — cannot_determine for empty nodes
if (!text.trim()) {
return { category: "cannot_determine", reason: "empty_node_text" };
}
// Rule 3 — default to incidental
return { category: "incidental" };
}