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

530 lines
15 KiB
JavaScript

function normaliseText(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function sentenceCase(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return "this uncertainty";
return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
}
function stripTrailingPunctuation(value) {
return String(value || "")
.trim()
.replace(/[.?!:;]+$/g, "")
.trim();
}
function buildNodeMap(graph) {
return new Map((graph?.nodes || []).map((node) => [node.id, node]));
}
function collectRelatedNodes(node, graph) {
if (!node || !graph) return [];
const nodesById = buildNodeMap(graph);
const relatedIds = new Set([
...(node.dependsOn || []),
...(node.affects || []),
...(node.childIds || []),
]);
if (node.parentId) {
relatedIds.add(node.parentId);
}
for (const edge of graph.edges || []) {
if (edge.fromNodeId === node.id) {
relatedIds.add(edge.toNodeId);
}
if (edge.toNodeId === node.id) {
relatedIds.add(edge.fromNodeId);
}
}
return [...relatedIds].map((nodeId) => nodesById.get(nodeId)).filter(Boolean);
}
function collectResolvedContextValues(graph) {
const resolvedSet = new Set(graph?.resolvedNodeIds || []);
return (graph?.nodes || [])
.filter((node) => resolvedSet.has(node.id))
.map((node) => node.value)
.filter((value) => typeof value === "string" && value.trim().length > 0);
}
function extractMeaning(node) {
const raw = `${node?.label || ""} ${node?.description || ""}`.trim();
let meaning = stripTrailingPunctuation(
String(node?.label || node?.description || "this uncertainty"),
).trim();
const lowered = normaliseText(raw);
if (
/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(lowered)
) {
return "the relevant customer, user, or value recipient";
}
meaning = meaning
.replace(/^uncertainty regarding\s+/i, "")
.replace(/^uncertainty about\s+/i, "")
.replace(/^lack of\s+/i, "")
.replace(/^unknown\s+/i, "")
.replace(/^whether\s+/i, "")
.replace(/^the\s+/, "")
.trim();
if (!meaning) {
return "this uncertainty";
}
return sentenceCase(meaning);
}
function isDefinitionLikeUnknown(nodeText, text) {
return (
/\b(define|definition|meaning|term|terminology)\b/.test(nodeText) ||
(/\bdefinition\b/.test(text) && /\bdisagreement\b/.test(text)) ||
(/\b(define|definition|meaning|term|terminology)\b/.test(text) &&
/\b(unclear|ambiguous|inconsistent|undefined|used inconsistently)\b/.test(
text,
))
);
}
function isClaimLikeUnknown(node, text) {
return (
node?.kind === "reported_claim" ||
node?.kind === "conclusion" ||
/\b(claim|assertion|true|false|correct|incorrect|happened|happening)\b/.test(
text,
) ||
/^whether\b/i.test(String(node?.label || "").trim())
);
}
function sanitizeQuestionText(question) {
return String(question || "")
.replace(/\)\.\s+/g, ") ")
.replace(/\s+/g, " ")
.trim();
}
function buildNeutralClarificationQuestion(meaning) {
return `What would clarify ${stripTrailingPunctuation(meaning)} in this situation?`;
}
function buildEvidenceFallbackQuestion(meaning) {
return `What evidence would confirm or rule out ${stripTrailingPunctuation(meaning)}?`;
}
function detectContradictionContext(graph) {
const central = stripTrailingPunctuation(
graph?.centralStatement || "this situation",
);
const contradictionNode = (graph?.nodes || []).find((node) => {
const text = normaliseText(`${node.label} ${node.description}`);
return (
node.kind === "relationship" &&
/\b(contradiction|conflict|inconsistent|mismatch|divergent|opposing)\b/.test(
text,
)
);
});
return {
centralStatement: central,
contradictionLabel: stripTrailingPunctuation(
contradictionNode?.label || "",
),
};
}
export function formulateTieResolutionQuestion({ graph }) {
const { centralStatement, contradictionLabel } =
detectContradictionContext(graph);
const focus =
centralStatement || contradictionLabel || "these conflicting signals";
const question = sanitizeQuestionText(
`What changed during the period that could explain why ${focus}?`,
);
return {
question,
reason:
"Formulated to distinguish between tied unresolved explanations without prematurely choosing one branch.",
strategy: null,
investigationStrategy: null,
selectionStatus: "ambiguous",
};
}
function extractActionPhrase(texts) {
for (const text of texts) {
const value = String(text || "").trim();
if (!value) continue;
const matches = [
value.match(/\b(?:whether|deciding|decision) to\s+([^.,;:]+)/i),
value.match(/\b(?:justify|continuing|proceeding with)\s+([^.,;:]+)/i),
value.match(
/\b(build|launch|adopt|buy|continue|proceed|invest in|fund)\s+([^.,;:]+)/i,
),
].filter(Boolean);
const match = matches[0];
if (!match) continue;
const phrase = (match[1] || `${match[1] || ""} ${match[2] || ""}`)
.replace(/^to\s+/i, "")
.trim();
if (phrase) {
return phrase;
}
}
return null;
}
function toGerundPhrase(phrase) {
const trimmed = String(phrase || "").trim();
if (!trimmed) return "proceeding with this decision";
const [firstWord, ...rest] = trimmed.split(/\s+/);
const lower = firstWord.toLowerCase();
const irregular = {
be: "being",
build: "building",
continue: "continuing",
decide: "deciding",
proceed: "proceeding",
launch: "launching",
invest: "investing",
fund: "funding",
buy: "buying",
pay: "paying",
adopt: "adopting",
};
let gerund = irregular[lower];
if (!gerund) {
if (lower.endsWith("e") && !lower.endsWith("ee")) {
gerund = `${lower.slice(0, -1)}ing`;
} else {
gerund = `${lower}ing`;
}
}
return [gerund, ...rest].join(" ");
}
function buildInvestigationStrategy({
key,
reason,
node,
graph,
relatedNodes,
meaning,
actionPhrase,
}) {
return {
key,
reason,
nodeId: node?.id ?? null,
nodeLabel: node?.label ?? null,
meaning,
actionPhrase,
relatedNodeIds: relatedNodes.map((relatedNode) => relatedNode.id),
centralStatement: graph?.centralStatement ?? null,
};
}
export function selectInvestigationStrategy({ node, graph, context = {} }) {
const relatedNodes = collectRelatedNodes(node, graph);
const meaning = extractMeaning(node);
const combinedText = [
node?.label,
node?.description,
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
...(context.resolvedValues || []),
]
.filter(Boolean)
.join(" ");
const text = normaliseText(combinedText);
const nodeText = normaliseText(
`${node?.label || ""} ${node?.description || ""}`,
);
const relatedText = normaliseText(
relatedNodes
.map((relatedNode) => `${relatedNode.label} ${relatedNode.description}`)
.join(" "),
);
const resolvedValues = collectResolvedContextValues(graph);
const actionPhrase = extractActionPhrase([
...resolvedValues,
...relatedNodes.map((relatedNode) => relatedNode.value),
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
]);
const decisionContext =
/\b(decision|whether to|build|launch|continue|proceed|invest|allocate)\b/.test(
`${text} ${relatedText} ${resolvedValues.join(" ")}`,
) || Boolean(actionPhrase);
const hasConstraintLanguage =
/\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
text,
);
const hasPrimaryConstraintLanguage =
/\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
nodeText,
);
const hasBaselineLanguage =
/\b(before|previous|baseline|prior|comparable state)\b/.test(text);
const hasPrimaryBaselineLanguage =
/\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText);
if (hasBaselineLanguage && hasPrimaryBaselineLanguage) {
return buildInvestigationStrategy({
key: "baseline_reconstruction",
reason:
"Selected because the unknown explicitly references a missing previous or baseline state.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
}
const hasDefinitionLanguage = isDefinitionLikeUnknown(nodeText, text);
const hasPrimaryDefinitionLanguage =
/\b(define|definition|meaning|term|terminology)\b/.test(nodeText);
const hasCriteriaLanguage =
/\b(success criteria|success threshold|threshold|decision criteria|criterion|justify|sufficient)\b/.test(
nodeText,
);
const hasDecisionValueLanguage =
decisionContext &&
/\b(value|commercial value|commercial viability|viability|justify|sufficient|success|threshold|criterion)\b/.test(
text,
);
const hasMeasurementLanguage =
/\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
text,
);
const hasEvidenceLanguage =
/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text) ||
isClaimLikeUnknown(node, text);
const hasContradictionLanguage =
/\b(contradiction|contradict|conflict|inconsistent|inconsistency|disagree|mismatch)\b/.test(
`${text} ${relatedText}`,
) ||
relatedNodes.some(
(relatedNode) =>
relatedNode.status === "contradicted" ||
relatedNode.kind === "conclusion",
);
if (hasPrimaryDefinitionLanguage || hasDefinitionLanguage) {
return buildInvestigationStrategy({
key: "definition",
reason:
"Selected because the unknown is primarily about clarifying what a term means in this case.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
}
if (hasDecisionValueLanguage || hasCriteriaLanguage) {
return buildInvestigationStrategy({
key: "decision_threshold",
reason:
"Selected because the unknown determines the threshold for making or justifying a decision.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
}
if (hasPrimaryBaselineLanguage || hasBaselineLanguage) {
return buildInvestigationStrategy({
key: "baseline_reconstruction",
reason:
"Selected because reconstructing the prior state is the most direct way to resolve the unknown.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
}
if (hasContradictionLanguage) {
return buildInvestigationStrategy({
key: "contradiction_resolution",
reason:
"Selected because the graph context indicates conflicting claims or inconsistent states that must be reconciled.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
}
if (hasEvidenceLanguage || hasMeasurementLanguage || hasConstraintLanguage) {
return buildInvestigationStrategy({
key: "evidence_gathering",
reason:
hasConstraintLanguage && hasPrimaryConstraintLanguage
? "Selected because evidence about the practical limiting factor is needed before the unknown can be resolved."
: "Selected because resolving the unknown requires evidence, signals, or measurable confirmation.",
node,
graph,
relatedNodes,
meaning,
actionPhrase,
});
}
return null;
}
function buildQuestionFromStrategy(strategy) {
switch (strategy.key) {
case "decision_threshold":
return strategy.actionPhrase
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(strategy.actionPhrase)}?`
: "What outcome would be sufficient to justify this decision?";
case "definition":
return `What does ${strategy.meaning} mean in this situation?`;
case "evidence_gathering":
return `What evidence would clarify ${stripTrailingPunctuation(strategy.meaning)}?`;
case "baseline_reconstruction":
return `What was the comparable state before ${strategy.meaning}?`;
case "contradiction_resolution":
return `What fact would resolve the contradiction about ${strategy.meaning}?`;
default:
return `What specific fact would resolve whether ${strategy.meaning} is true?`;
}
}
function isCompoundQuestion(question) {
const trimmed = String(question || "").trim();
const questionMarks = (trimmed.match(/\?/g) || []).length;
if (questionMarks !== 1) return true;
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
if (/\b(and|or)\b[^?]{0,80}\?/i.test(trimmed) && /,/.test(trimmed)) {
return true;
}
return false;
}
function validateFormulatedQuestion(question, meaning) {
const trimmed = String(question || "").trim();
const lower = trimmed.toLowerCase();
const meaningWords = normaliseText(meaning)
.split(" ")
.filter((word) => word.length > 3);
const overlappingWord = meaningWords.find((word) => lower.includes(word));
if (!trimmed) return false;
if ((trimmed.match(/\?/g) || []).length !== 1) return false;
if (isCompoundQuestion(trimmed)) return false;
if (/^what is\s+/i.test(trimmed)) return false;
if (/^how should uncertainty regarding\b/i.test(trimmed)) return false;
if (/^what would resolve uncertainty regarding\b/i.test(trimmed))
return false;
if (/\)\.\s+[A-Z]/.test(trimmed)) return false;
if (/\bis true\?$/i.test(trimmed) && !/^whether\b/i.test(meaning))
return false;
if (
/\bprice|pricing|price point\b/i.test(trimmed) &&
!/\bprice\b/i.test(meaning)
) {
return false;
}
if (
!overlappingWord &&
!/\b(decision|evidence|constraint|customer|value|outcome)\b/i.test(trimmed)
) {
return false;
}
return true;
}
export function formulateQuestion({ node, graph, context = {} }) {
if (context.selectionState?.status === "ambiguous") {
return formulateTieResolutionQuestion({ graph });
}
const investigationStrategy = selectInvestigationStrategy({
node,
graph,
context,
});
let question = investigationStrategy
? buildQuestionFromStrategy(investigationStrategy)
: buildNeutralClarificationQuestion(extractMeaning(node));
question = sanitizeQuestionText(question);
const fallbackMeaning = extractMeaning(node);
if (
!validateFormulatedQuestion(
question,
investigationStrategy?.meaning || fallbackMeaning,
)
) {
question = sanitizeQuestionText(
investigationStrategy &&
isClaimLikeUnknown(
node,
normaliseText(
collectRelatedNodes(node, graph)
.map(
(relatedNode) =>
`${relatedNode.label} ${relatedNode.description}`,
)
.concat([node?.label, node?.description])
.filter(Boolean)
.join(" "),
),
)
? buildEvidenceFallbackQuestion(fallbackMeaning)
: buildNeutralClarificationQuestion(fallbackMeaning),
);
}
return {
question,
reason: investigationStrategy
? `Formulated from graph context using the ${investigationStrategy.key} investigation strategy.`
: "Formulated as a neutral clarification question because no narrower investigation strategy clearly applied.",
strategy: investigationStrategy?.key ?? null,
investigationStrategy,
};
}