Files
confidence-engine/lib/presentation/facilitator-view-adapter.js
T
robbond 6eaf0fc246 experiment: improve semantic graph projection
Experiment 13 — Semantic Facilitator Translation

- Classify nodes by semantic role (observation, question, explanation,
  scaffolding, relationship) rather than graph kind. Scaffolding suppressed
  entirely before section routing.
- Three-tier filtering: scaffolding patterns > internal vocabulary > technical
  summary patterns. Prevents structural noise from contaminating user-facing
  sections.
- Deduplicate by normalised text — merge duplicate observations expressing the
  same finding.
- Route resolved unknowns and assumptions to known section with epistemic
  labels instead of treating them as unresolved questions.
- Prefer concrete observations (numbers, change language, temporal refs) over
  abstract labels in ranking.
- Closed Experiment 12 as confirmed. Added Experiment 13 documentation.
- Updated UX guidelines with Semantic Projection principles.
- 37 tests: filtering, classification, deduplication, ranking, framing, mock
  data integration, edge cases.
2026-08-05 15:26:29 +01:00

575 lines
22 KiB
JavaScript

/**
* FacilitatorViewAdapter — deterministic projection of the reasoning graph
* into a concise, human-facing facilitator view (Version C).
*
* This adapter is pure and testable. It receives a prepared view model from
* ReasoningWorkspace and returns a structured display model with up to four
* primary sections:
*
* 1. What we know — supported observations, resolved state nodes
* 2. Still investigating — unresolved unknowns, active unknown context
* 3. Possible explanations — assumptions and tentative causal claims
* 4. Quiet reasoning summary — secondary counts from the same graph
*
* Key design: this adapter classifies nodes by *semantic role* rather than
* simply projecting graph kinds. Internal graph concepts (metrics, systems,
* scaffolding, technical summaries) are suppressed from the user-facing view.
* Translation quality matters more than layout completeness.
*
* All filtering, deduplication and ranking is deterministic and uses only
* existing graph fields. No new backend data or API contracts are required.
*/
/* ── Normalisation helpers ─────────────────────────────────────── */
/**
* Normalise a string for deduplication comparison.
* Lowercase, trim, remove punctuation, collapse whitespace.
*/
function normaliseText(text) {
if (!text || typeof text !== "string") return "";
return text
.toLowerCase()
.replace(/[^\w\s]/g, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Remove repeated boilerplate prefixes that add no meaning.
*/
function stripBoilerplate(text) {
if (!text || typeof text !== "string") return text;
const result = text.replace(/^need evidence about\s*/i, "").trim();
return result || null;
}
/**
* Determine whether text is too long to scan usefully.
*/
function isTooLong(text, maxChars) {
if (!text) return false;
if (maxChars === undefined) maxChars = 280;
return text.length > maxChars;
}
/* ── Filtering helpers ─────────────────────────────────────────── */
// Patterns that flag content as likely technical or boilerplate summary text.
const TECHNICAL_SUMMARY_PATTERNS = [
/\bnodes?\s*[:\d]/i,
/\bedges?\s*[:\d]/i,
/\bsorted\s*/i,
/by_kind/i,
/\b(?:node|edge|unknown|state)\s+count/i,
];
// Patterns that flag content as scaffolding — structural glue the user does not need to see.
const SCAFFOLDING_PATTERNS = [
// Scenario summaries and setup descriptions
/\bsummary\s*of\s*(?:scenario|situation|problem|context|background)\b/i,
/(?:^|\s)summary\s*[:\.]?\s*/i,
// Process labels — the user cares about findings, not processes
/\b(?:process|approach|workflow|methodology|procedure)\s+describes?\b/i,
// System/tool references that are implementation details
/\b(?:system|tool|platform|interface|framework|engine|library|component)\s+(?:for|that|which|used|providing|supporting)\b/i,
/(?:logging|measurement|reporting|tracking|monitoring)\s+(?:system|tool|mechanism|framework|approach)\b/i,
// Metric object descriptions (the metric itself is fine; describing the *object* is not)
/\b(?:metric|measure|indicator|KPI)\s+describes?\b/i,
/\b(?:metric|measure|indicator)\s+(?:captures?|tracks?|quantifies?|represents?)\b/i,
// Graph artefacts — nodes describing themselves or other graph elements
/(?:graph|diagram|visualization)\s+(?:showing|depicting|illustrating|displaying)\b/i,
/\bnodes?\s*representing?\b/i,
// "Current situation" type labels that are pure scaffolding
/\b(?:current\s+)?(?:situation|state|scenario|context)\b.*\b(describes?|is|represents?|shows)\b/i,
// Vague state-of-play descriptions
/\b(?:is\s+(?:a\s+)?(?:situation|case|scenario|context|problem))\b/i,
];
// Patterns that flag content as implementation/technical vocabulary the user should not see.
const INTERNAL_VOCAB_PATTERNS = [
// Technical summary language
/\b(?:total|overall)\s+(?:count|number|figure)\s+of\b/i,
/(?:complaint|incident|issue)\s+logging\s+(?:system|tool|mechanism|process)\b/i,
/(?:performance|quality|production)\s+(?:measurement|monitoring)\s+(?:tools?|systems?)\b/i,
// "Scaffolding" kind of description masquerading as content
/\b(?:summary|overview|background\s+context)\s+of\b/i,
];
/**
* Decide whether a raw graph text item should be included in the panel.
* Returns { included, displayText, reason } where reason is null when accepted.
*/
function filterItem(raw) {
const label = raw.label;
const description = raw.description;
const kind = raw.kind;
// Extract display text — prefer whichever sounds most natural for human reading.
let text;
if (description && typeof description === "string" && description.trim()) {
// Prefer the longer, more informative text.
if (description !== label) {
text = description;
} else {
text = label;
}
} else {
text = label || "";
}
if (!text || typeof text !== "string") return { included: false, reason: "empty" };
const trimmed = text.trim();
if (!trimmed) return { included: false, reason: "empty" };
// ── Scaffolding suppression (priority over technical summary) ──
for (var i = 0; i < SCAFFOLDING_PATTERNS.length; i++) {
if (SCAFFOLDING_PATTERNS[i].test(trimmed)) return { included: false, reason: "scaffolding" };
}
// ── Internal vocabulary suppression ──
for (var j = 0; j < INTERNAL_VOCAB_PATTERNS.length; j++) {
if (INTERNAL_VOCAB_PATTERNS[j].test(trimmed)) return { included: false, reason: "internal-vocab" };
}
// ── Technical summary suppression (existing) ──
for (var k = 0; k < TECHNICAL_SUMMARY_PATTERNS.length; k++) {
if (TECHNICAL_SUMMARY_PATTERNS[k].test(trimmed)) return { included: false, reason: "technical" };
}
// Skip internal IDs — items whose text is just an ID or contains only one
if (/^[a-z0-9-]{1,40}$/i.test(trimmed) && trimmed.length < 60) {
return { included: false, reason: "internal-id" };
}
// Depriorise items that are too long to scan usefully.
// The adapter does not synthesise rewritten claims from verbose text.
if (isTooLong(trimmed)) return { included: false, reason: "too-long" };
return { included: true, displayText: trimmed, sourceKind: kind };
}
/* ── Node collection helpers ───────────────────────────────────── */
/**
* Determine whether a node is resolved (explicitly closed).
*/
function isResolved(node, resolvedIds) {
return resolvedIds.has(node.id) || node.status === "resolved";
}
/**
* Determine whether this node is the active unknown target.
*/
function isActiveUnknown(node, activeUnknownNodeId) {
return node.id === activeUnknownNodeId;
}
/**
* Determine whether a node's content represents established knowledge
* (regardless of explicit resolution). Used for routing in Phase 1.
* A kind=observation with status known is always an established observation.
*/
function isEstablished(node, resolvedIds) {
if (node.kind === "observation" && node.status === "known") return true;
// Already resolved nodes are also established (by ID or by status)
if (resolvedIds.has(node.id)) return true;
if (node.status === "resolved") return true;
return false;
}
/* ── Semantic role classification ──────────────────────────────── */
/**
* Classify a node by its semantic role in the investigation rather than its graph kind.
* Returns one of: "observation", "question", "explanation", "scaffolding", "relationship".
*
* This allows the adapter to route content based on *meaning* rather than *type*.
* A node that says "Complaints increased by 35%" is an observation regardless of kind.
* A node whose only value is describing a process or summarising the scenario is scaffolding.
*/
function classifySemanticRole(node, resolvedIds) {
var text = (node.description || node.label || "").trim().toLowerCase();
var kind = node.kind;
// If filterItem rejected it as scaffolding/internal-vocab, treat it as scaffolding here too
var filtered = filterItem(node);
if (!filtered.included) {
return "scaffolding";
}
// Check resolved/established first for semantic routing.
// A resolved unknown or assumption is still a question/explanation in origin,
// but its content is now known. We return "observation" here so that Phase 1
// routing places it in the known section rather than investigating.
if (resolvedIds && isEstablished(node, resolvedIds)) {
return kind === "relationship" ? "relationship" : "observation";
}
if (kind === "observation") return "observation";
if (kind === "unknown") return "question";
if (kind === "assumption") return "explanation";
if (kind === "relationship") return "relationship";
// kind === "state" or "metric" — need to look at content
var isConcrete = !!(
/\b\d+/.test(text) || // contains numbers
/\b(?:increased|decreased|rose|fell|changed|improved|worsened)\b/i.test(text) || // contains change language
/\b(?:per|from |to |across|during|over\s+\d)/i.test(text) || // temporal/quantitative
/\b(?:about|approximately|around|roughly|exactly)\b/i.test(text) ||
/^\d/.test(text) // starts with a digit
);
var isProcess = /describes?\s+(?:the\s+)?(?:current\s+)?(?:situation|state|scenario|problem|context)/i.test(text);
var isSummary = /^summary/i.test(text) || /^(is\s+a\s+)?(situation|case|scenario|context)\b/i.test(text);
if (isConcrete) return "observation";
if (isProcess || isSummary) return "scaffolding";
// Default: if it looks like a question or explanation from context, honour that.
if (/\?$/.test(node.label || "")) return "question";
return "scaffolding";
}
/* ── Core adapter function ─────────────────────────────────────── */
/**
* Build a Version C facilitator view model from graph data.
*
* @param {Object} params
* @param {Array<Object>} params.nodes — graph nodes
* @param {Set<string>} params.resolvedIds — resolved node IDs
* @param {string|null} params.activeUnknownNodeId — ID of the active unknown
* @param {Array<Object>} [params.edges=[]] — graph edges
* @param {Object|null} [params.selectedQuestion=null] — current question object
* @returns {Object} viewModel with sections: known, stillInvestigating, possibleExplanations, summaryCounts
*/
export function buildFacilitatorViewModel(_ref) {
var nodes = _ref.nodes;
var resolvedIds = _ref.resolvedIds;
var activeUnknownNodeId = _ref.activeUnknownNodeId;
var edges = _ref.edges;
var selectedQuestion = _ref.selectedQuestion;
if (!nodes) nodes = [];
if (!resolvedIds) resolvedIds = new Set();
if (activeUnknownNodeId === undefined || activeUnknownNodeId === null) activeUnknownNodeId = null;
if (!edges) edges = [];
if (!selectedQuestion) selectedQuestion = null;
// ── Phase 1: Categorise all nodes by semantic role ──────────
var known = [];
var stillInvestigating = [];
var possibleExplanations = [];
for (var _i = 0; _i < nodes.length; _i++) {
var node = nodes[_i];
var resolved = isResolved(node, resolvedIds);
var established = isEstablished(node, resolvedIds);
var semanticRole = classifySemanticRole(node, resolvedIds);
var displayResult = filterItem(node);
if (!displayResult.included) continue;
// Scaffold items are entirely suppressed from user-facing sections.
if (semanticRole === "scaffolding") continue;
var entry = {
text: displayResult.displayText,
normalised: normaliseText(displayResult.displayText),
kind: node.kind,
semanticRole: semanticRole,
confidence: node.confidence || null,
isResolved: resolved,
isActiveUnknown: isActiveUnknown(node, activeUnknownNodeId),
evidenceIds: node.evidenceIds || [],
relevance: node.relevance != null ? node.relevance : null,
priority: node.priority != null ? node.priority : null,
};
// ── Established content goes to "known" ──
if (established) {
known.push(entry);
continue;
}
// ── Unresolved content routing by semantic role ──
switch (semanticRole) {
case "question":
stillInvestigating.push(entry);
break;
case "explanation":
possibleExplanations.push(entry);
break;
case "observation":
// Unresolved observations are uncertain — put in investigating.
stillInvestigating.push(entry);
break;
case "relationship":
known.push(entry);
break;
default:
stillInvestigating.push(entry);
}
}
// ── Phase 2: Deduplicate by normalised text ─────────────────
/**
* Deduplicate entries within a single list.
* First occurrence wins; if a later entry has a higher-priority kind, replace it.
*/
function deduplicate(entries) {
var seen = {}; // normalised → first entry
return entries.filter(function (entry) {
var key = entry.normalised;
if (!key || !seen.hasOwnProperty(key)) {
seen[key] = entry;
return true;
}
// If already seen, prefer the one with a more specific kind order:
// observation > unknown > assumption > state > metric
var priorityOrder = ["observation", "unknown", "assumption", "state", "metric"];
var existingKindIdx = priorityOrder.indexOf(seen[key].kind);
var newKindIdx = priorityOrder.indexOf(entry.kind);
if (newKindIdx < existingKindIdx) {
seen[key] = entry;
return true; // replace with this one
}
return false; // skip — earlier winner stays
});
}
// Apply deduplication within each section independently
var knownDedup = deduplicate(known);
var unknownDedup = deduplicate(stillInvestigating);
var assumptionDedup = deduplicate(possibleExplanations);
// Cross-deduplicate: if "known" and "stillInvestigating" share normalised text,
// move the item to stillInvestigating (uncertainty wins).
var knownFinal = knownDedup;
var stillInvestigatingFinal = unknownDedup;
if (knownDedup.length > 0 && unknownDedup.length > 0) {
var knownTexts = {};
for (var _j = 0; _j < unknownDedup.length; _j++) {
knownTexts[unknownDedup[_j].normalised] = true;
}
knownFinal = knownDedup.filter(function (e) { return !knownTexts[e.normalised]; });
}
// ── Phase 3: Rank items within each section ─────────────────
/**
* Rank known items.
* Order: observations > questions/resolved unknowns > explanations/resolved assumptions > relationships > other.
* Within each group: high-confidence > medium > low > concise.
*/
function rankKnown(items) {
var confidenceRank = {};
confidenceRank["high"] = 0;
confidenceRank["medium"] = 1;
confidenceRank["low"] = 2;
confidenceRank["null"] = 3;
var rolePriority = {};
rolePriority["observation"] = 0;
rolePriority["question"] = 1;
rolePriority["explanation"] = 2;
rolePriority["relationship"] = 3;
rolePriority["scaffolding"] = 4;
return items.slice().sort(function (a, b) {
// Prefer semantic observations first
var ra = rolePriority[a.semanticRole] != null ? rolePriority[a.semanticRole] : 4;
var rb = rolePriority[b.semanticRole] != null ? rolePriority[b.semanticRole] : 4;
if (ra !== rb) return ra - rb;
// Then by confidence
var ca = confidenceRank[a.confidence] != null ? confidenceRank[a.confidence] : 3;
var cb = confidenceRank[b.confidence] != null ? confidenceRank[b.confidence] : 3;
if (ca !== cb) return ca - cb;
// Prefer concise items
if (a.text.length !== b.text.length) return a.text.length - b.text.length;
return 0;
});
}
/**
* Rank still-investigating items.
* Order: active unknown > selected-question target > explicit priority > evidence-linked > concise > remaining.
*/
function rankUnknowns(items) {
var sqText = selectedQuestion && selectedQuestion.question ? normaliseText(selectedQuestion.question) : null;
return items.slice().sort(function (a, b) {
// Active unknown always first
if (a.isActiveUnknown && !b.isActiveUnknown) return -1;
if (!a.isActiveUnknown && b.isActiveUnknown) return 1;
// Selected-question target: match by normalised text
if (sqText && a.normalised === sqText && b.normalised !== sqText) return -1;
if (sqText && a.normalised !== sqText && b.normalised === sqText) return 1;
// Explicit priority fields where available in the graph
var pa = a.priority != null ? a.priority : null;
var pb = b.priority != null ? b.priority : null;
if (pa != null && pb != null && pa !== pb) return pa - pb;
// Prefer items linked to supported observations (has evidenceIds)
var ae = a.evidenceIds ? a.evidenceIds.length : 0;
var be = b.evidenceIds ? b.evidenceIds.length : 0;
if (ae > be) return -1;
if (be > ae) return 1;
// Prefer concise items (shorter labels are more scannable)
if (a.text.length !== b.text.length) return a.text.length - b.text.length;
return 0;
});
}
/**
* Rank possible explanation items.
* Order: assumptions linked to supported observations > related to active unknown > concise > remaining.
* Fallback ordering is by length (concise first), then kind preference.
*/
function rankAssumptions(items) {
return items.slice().sort(function (a, b) {
// Prefer assumptions with evidence linkage
var ae = a.evidenceIds ? a.evidenceIds.length : 0;
var be = b.evidenceIds ? b.evidenceIds.length : 0;
if (ae > be) return -1;
if (be > ae) return 1;
// Then by length (concise first)
if (a.text.length !== b.text.length) return a.text.length - b.text.length;
return 0;
});
}
var rankedKnown = rankKnown(knownFinal);
var rankedUnknowns = rankUnknowns(stillInvestigatingFinal);
var rankedAssumptions = rankAssumptions(assumptionDedup);
// ── Phase 4: Apply display limits ────────────────────────────
var MAX_KNOWN = 4;
var MAX_INVESTIGATING = 4;
var MAX_EXPLANATIONS = 3;
var knownDisplay = rankedKnown.slice(0, MAX_KNOWN);
var investigatingDisplay = rankedUnknowns.slice(0, MAX_INVESTIGATING);
var explanationsDisplay = rankedAssumptions.slice(0, MAX_EXPLANATIONS);
// ── Phase 5: Build display model ─────────────────────────────
function toItemDisplay(entry) {
return entry.text;
}
// Determine section titles based on investigation state
var hasUnresolvedUnknowns = investigatingDisplay.some(function (e) { return e.kind === "unknown"; });
var knownSectionTitle = "What we know";
var isTerminal = !selectedQuestion && nodes.length > 0;
if (isTerminal) {
knownSectionTitle = "What the evidence supports";
}
var investigatingSectionTitle = "Still investigating";
if (isTerminal && hasUnresolvedUnknowns) {
investigatingSectionTitle = "Remaining cautions";
}
// ── Phase 6: Compute quiet summary counts ────────────────────
// Count all items from the graph (including resolved), displayed as plain-language labels.
var totalObservations = 0;
for (var _k = 0; _k < nodes.length; _k++) {
if (nodes[_k].kind === "observation" && filterItem(nodes[_k]).included) {
totalObservations++;
}
}
var unresolvedUnknownsCount = 0;
for (var _l = 0; _l < nodes.length; _l++) {
if (nodes[_l].kind === "unknown" && !isResolved(nodes[_l], resolvedIds)) {
unresolvedUnknownsCount++;
}
}
var unresolvedAssumptionsCount = 0;
for (var _m = 0; _m < nodes.length; _m++) {
if (nodes[_m].kind === "assumption" && !isResolved(nodes[_m], resolvedIds)) {
unresolvedAssumptionsCount++;
}
}
var relationshipsCount = edges ? edges.length : 0;
// Build plain-language label string — only include non-zero counts.
var summaryParts = [];
if (totalObservations > 0) {
summaryParts.push(totalObservations + " observation" + (totalObservations !== 1 ? "s" : ""));
}
if (unresolvedUnknownsCount > 0) {
summaryParts.push(unresolvedUnknownsCount + " open question" + (unresolvedUnknownsCount !== 1 ? "s" : ""));
}
if (unresolvedAssumptionsCount > 0) {
summaryParts.push(unresolvedAssumptionsCount + " assumption" + (unresolvedAssumptionsCount !== 1 ? "s" : ""));
}
// ── Phase 7: Determine terminal framing for investigating section ──
var investigatingSectionHasItems = false;
if (!isTerminal) {
investigatingSectionHasItems = investigatingDisplay.length > 0;
} else {
investigatingSectionHasItems = hasUnresolvedUnknowns || explanationsDisplay.length > 0;
}
return {
known: {
title: knownSectionTitle,
items: knownDisplay.map(toItemDisplay),
hasItems: knownDisplay.length > 0,
},
investigating: {
title: investigatingSectionTitle,
items: investigatingDisplay.map(toItemDisplay),
hasItems: investigatingSectionHasItems,
// Flag for the component to know whether to omit this section entirely.
shouldOmit: isTerminal && !hasUnresolvedUnknowns && explanationsDisplay.length === 0,
},
explanations: {
title: "Possible explanations",
items: explanationsDisplay.map(function (entry) {
return {
text: entry.text,
// Structural uncertainty label — never depends on colour.
label: entry.evidenceIds && entry.evidenceIds.length > 0 ? "To be tested" : "Not yet established",
};
}),
hasItems: explanationsDisplay.length > 0,
},
summary: {
text: summaryParts.length > 0 ? summaryParts.join(" · ") : null,
},
_meta: {
isTerminal: isTerminal,
hasUnresolvedUnknowns: hasUnresolvedUnknowns,
totalObservations: totalObservations,
unresolvedUnknownsCount: unresolvedUnknownsCount,
unresolvedAssumptionsCount: unresolvedAssumptionsCount,
},
};
}
export default buildFacilitatorViewModel;