experiment: test questions against decision conditions
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user