Files
confidence-engine/lib/graph/question-decision-conditions.js
T

246 lines
8.5 KiB
JavaScript

/**
* Experiment 22 — Question Relevance Against Decision Conditions.
*
* Classifies an unresolved unknown against explicit decision conditions
* that define what must be true for a specific decision to be sensible.
*
* Classification categories:
* tests_deciding_condition — The question directly tests something
* required for the decision's justification.
* adds_supporting_evidence — The answer would strengthen confidence
* but doesn't test a required condition.
* outside_decision_conditions — Not meaningfully connected to any condition.
* cannot_determine — Inputs are missing, empty, or too unclear.
*
* No LLM calls. No new graph fields. Pure function. No engine mutation.
*/
function normalise(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
}
/* ── Primary concept groups (substring-based detection) ─*/
const DEMAND_CONCEPTS = ["demand","need","interest","customers","audience"];
const COMPLIANCE_CONCEPTS = ["compliance","regulation","legal","required","mandatory","gdpr","data residency"];
const VALUE_COST_CONCEPTS = ["cost","investment","justif","return","viability","financial"];
const DIFFERENTIATION_CONCEPTS = ["differentiat","advantage","competit","positioning","superior","unique"];
/* ── Supporting evidence concept groups (broader, indirect terms) ─*/
const DEMAND_SUPPORT_CONCEPTS = ["geography","region","country","territory","area","locale","segment","target","entry","expansion","penetration"];
const COMPLIANCE_SUPPORT_CONCEPTS = ["privacy","certification","standards"];
const VALUE_COST_SUPPORT_CONCEPTS = ["budget","price","revenue","pricing","resource","structure","subsidiary"];
const DIFFERENTIATION_SUPPORT_CONCEPTS = ["edge","distinct","feature","benefit"];
const CATEGORY_GROUPS = {
demand: DEMAND_CONCEPTS,
compliance: COMPLIANCE_CONCEPTS,
value_cost: VALUE_COST_CONCEPTS,
differentiation: DIFFERENTIATION_CONCEPTS,
};
const SUPPORT_MAP = {
demand: DEMAND_SUPPORT_CONCEPTS,
compliance: COMPLIANCE_SUPPORT_CONCEPTS,
value_cost: VALUE_COST_SUPPORT_CONCEPTS,
differentiation: DIFFERENTIATION_SUPPORT_CONCEPTS,
};
/* ── Which primary concept categories does a condition mention? ─*/
function getConditionCategories(condition) {
const lower = condition.toLowerCase();
const cats = [];
for (const [name, concepts] of Object.entries(CATEGORY_GROUPS)) {
if (concepts.some((kw) => lower.includes(normalise(kw)))) cats.push(name);
}
return cats;
}
/* ── Primary categories a question draws from (substring matching) ─*/
function getPrimaryCategories(text) {
const lower = text.toLowerCase();
const cats = [];
for (const [name, concepts] of Object.entries(CATEGORY_GROUPS)) {
let hasMatch = false;
for (const concept of concepts) {
if (lower.includes(normalise(concept))) { hasMatch = true; break; }
}
if (hasMatch) cats.push(name);
}
return cats;
}
/* ── Support categories a question draws from ─*/
function getSupportCategories(text) {
const lower = text.toLowerCase();
const cats = [];
for (const [name, concepts] of Object.entries(SUPPORT_MAP)) {
let hasMatch = false;
for (const concept of concepts) {
if (lower.includes(normalise(concept))) { hasMatch = true; break; }
}
if (hasMatch) cats.push(name);
}
return cats;
}
/* ── Rule 1: Test a deciding condition directly ─*/
function testsCondition(conditions, primaryCategories, text) {
let bestMatch = null;
let bestScore = -1;
let bestCoverage = 0;
for (const cond of conditions) {
const condCats = getConditionCategories(cond);
if (condCats.length === 0) continue;
let score = 0;
let coverage = 0;
for (const pCat of primaryCategories) {
if (!condCats.includes(pCat)) continue;
const group = CATEGORY_GROUPS[pCat];
const qLower = text.toLowerCase();
const cLower = cond.toLowerCase();
// Count distinct concepts from this category that appear in question or condition
let distinctConcepts = 0;
for (const c of group) {
const normC = normalise(c);
if (qLower.includes(normC) || cLower.includes(normC)) {
distinctConcepts++;
}
}
score += Math.min(distinctConcepts, group.length);
if (distinctConcepts > coverage) coverage = distinctConcepts;
}
// Update: strict better score wins. Tied score: more concept coverage wins.
if (score > bestScore || (score === bestScore && coverage > bestCoverage)) {
bestMatch = cond;
bestScore = score;
bestCoverage = coverage;
}
}
return bestMatch;
}
/* ── Rule 2: Add supporting evidence to a condition ─*/
function supportsCondition(conditions, supportCategories, primaryCategories, text) {
let bestMatch = null;
let bestScore = -1;
for (const cond of conditions) {
const condCats = getConditionCategories(cond);
let score = 0;
for (const sCat of supportCategories) {
// Direct primary concept hits in this category's group
const directHits = countPrimaryHits(text, sCat);
// Support-only concept hits
let supportOnlyHits = 0;
for (const c of SUPPORT_MAP[sCat] || []) {
if (text.toLowerCase().includes(normalise(c))) supportOnlyHits++;
}
score += directHits * 0.5 + supportOnlyHits * 0.3;
}
if (score > bestScore) {
bestMatch = cond;
bestScore = score;
}
}
return bestMatch;
}
function countPrimaryHits(text, categoryName) {
const group = CATEGORY_GROUPS[categoryName];
if (!group) return 0;
const qLower = text.toLowerCase();
let count = 0;
for (const c of group) {
if (qLower.includes(normalise(c))) count++;
}
return count;
}
/* ── Core classification function ─────────────────────────── */
export function assessQuestionAgainstDecisionConditions(input) {
const { decisionTarget, decisionConditions, unknown: node, graph } = input || {};
if (!decisionTarget || !node || typeof node.kind !== "string") {
return { relevance: "cannot_determine", reason: "missing_input" };
}
if (!decisionConditions || !Array.isArray(decisionConditions) || decisionConditions.length === 0) {
return { relevance: "cannot_determine", reason: "missing_or_empty_conditions" };
}
const text = `${node?.label || ""} ${node?.description || ""}`.trim();
if (!text) {
return { relevance: "cannot_determine", reason: "empty_node_text" };
}
const decisionText = normalise(decisionTarget);
if (!decisionText) {
return { relevance: "cannot_determine", reason: "empty_decision_target" };
}
const validConditions = decisionConditions.filter((c) => c && normalise(c).length > 0);
if (validConditions.length === 0) {
return { relevance: "cannot_determine", reason: "all_conditions_empty" };
}
const unknownNormalized = normalise(text);
const dtWords = new Set(decisionText.split(/\s+/));
if (dtWords.size < 3) {
return { relevance: "cannot_determine", reason: "decision_target_too_short" };
}
const primaryCategories = getPrimaryCategories(unknownNormalized);
const supportCategories = getSupportCategories(unknownNormalized);
// No connection to any condition
if (primaryCategories.length === 0 && supportCategories.length === 0) {
return { relevance: "outside_decision_conditions", reason: "question does not relate to any stated decision condition" };
}
// Rule 1: Direct test of a deciding condition — requires primary category matches
if (primaryCategories.length > 0) {
const matchedDirect = testsCondition(validConditions, primaryCategories, unknownNormalized);
if (matchedDirect) {
return {
relevance: "tests_deciding_condition",
matchedCondition: matchedDirect,
reason: `question directly tests a condition required for the decision: "${matchedDirect}"`,
};
}
}
// Rule 2: Supports a condition via indirect match
const matchedSupport = supportsCondition(validConditions, supportCategories, primaryCategories, unknownNormalized);
if (matchedSupport) {
return {
relevance: "adds_supporting_evidence",
matchedCondition: matchedSupport,
reason: `question provides evidence related to a decision condition rather than testing it directly: "${matchedSupport}"`,
};
}
// Rule 3: Outside (safety net)
return { relevance: "outside_decision_conditions", reason: "question does not clearly connect to any stated decision condition" };
}