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, }; }