experiment: facilitator view from reasoning graph
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* 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,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 description if it adds beyond label
|
||||
let text = description || label;
|
||||
if (!text || typeof text !== "string") return { included: false, reason: "empty" };
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { included: false, reason: "empty" };
|
||||
|
||||
// Depriorise items that are purely technical summaries
|
||||
for (var i = 0; i < TECHNICAL_SUMMARY_PATTERNS.length; i++) {
|
||||
if (TECHNICAL_SUMMARY_PATTERNS[i].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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 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 ───────────────────────────
|
||||
var known = [];
|
||||
var stillInvestigating = [];
|
||||
var possibleExplanations = [];
|
||||
|
||||
for (var _i = 0; _i < nodes.length; _i++) {
|
||||
var node = nodes[_i];
|
||||
var resolved = isResolved(node, resolvedIds);
|
||||
var displayResult = filterItem(node);
|
||||
|
||||
if (!displayResult.included) continue;
|
||||
|
||||
var entry = {
|
||||
text: displayResult.displayText,
|
||||
normalised: normaliseText(displayResult.displayText),
|
||||
kind: node.kind,
|
||||
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,
|
||||
};
|
||||
|
||||
if (resolved) {
|
||||
known.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unresolved content routing by kind
|
||||
switch (node.kind) {
|
||||
case "unknown":
|
||||
stillInvestigating.push(entry);
|
||||
break;
|
||||
case "assumption":
|
||||
possibleExplanations.push(entry);
|
||||
break;
|
||||
case "state":
|
||||
case "metric":
|
||||
// Unresolved states/metrics with extra content go to investigating.
|
||||
if (node.description && node.description !== node.label) {
|
||||
stillInvestigating.push(entry);
|
||||
} else {
|
||||
known.push(entry);
|
||||
}
|
||||
break;
|
||||
case "observation":
|
||||
// Unresolved observations are uncertain — put in stillInvestigating.
|
||||
stillInvestigating.push(entry);
|
||||
break;
|
||||
case "conclusion":
|
||||
possibleExplanations.push(entry);
|
||||
break;
|
||||
default:
|
||||
// Unknown kind — treat as unresolved unknown for safety.
|
||||
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: supported/resolved > high-confidence > concise > connected to active > last added.
|
||||
*/
|
||||
function rankKnown(items) {
|
||||
var confidenceRank = {};
|
||||
confidenceRank["high"] = 0;
|
||||
confidenceRank["medium"] = 1;
|
||||
confidenceRank["low"] = 2;
|
||||
confidenceRank["null"] = 3;
|
||||
|
||||
return items.slice().sort(function (a, b) {
|
||||
// Known items are all resolved. Prefer observations first.
|
||||
if (a.kind === "observation" && b.kind !== "observation") return -1;
|
||||
if (b.kind === "observation" && a.kind !== "observation") return 1;
|
||||
// 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 > structurally eligible/high-priority > 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 > 0 && be === 0) return -1;
|
||||
if (be > 0 && ae === 0) 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;
|
||||
Reference in New Issue
Block a user