experiment: facilitator view from reasoning graph

This commit is contained in:
2026-08-05 15:00:42 +01:00
parent a7b7dda91f
commit 1998b84ae1
6 changed files with 794 additions and 12 deletions
+42
View File
@@ -434,3 +434,45 @@ For end users, present the same data as:
- **Quiet reasoning summary** — raw counts (nodes, edges, etc.) visually secondary
Internal graph concepts should remain available for developers (Developer Details) but should not dominate the primary view. The panel should feel like a facilitator's notebook: someone looking at it should immediately understand where the investigation stands, what has been learned, and what remains uncertain — without needing to understand graph theory.
## Graph Projection
The reasoning engine produces a rich graph with structured concepts (observations, unknowns, assumptions, relationships, metrics, states). The UI increasingly becomes a translation layer over this graph rather than maintaining separate duplicated summaries.
This section records principles for projecting graph data into human-meaningful views.
### Translation over exposure
- The graph is internal structure; the UI communicates human meaning.
- User-facing panels should translate graph state rather than expose graph terminology.
- Display only the amount of graph information useful for the current task.
### Epistemic clarity
- Known information, uncertainty and assumptions must remain visibly distinct.
- Assumptions must never look like facts.
- Use explicit structural labels (e.g., "Possible explanation", "Not yet established") rather than relying on colour or implicit cues.
### Curation as explanation
- Prioritisation and omission are part of good explanation.
- Repeated scenario text should not dominate derived summaries.
- Complete technical detail remains available through Developer Details.
### Robustness constraints
- Meaning must remain understandable without relying on colour.
- Displayed content must be grounded in existing graph fields — never invent facts absent from the graph.
- When nothing useful is established, show calm fallback language rather than an empty panel or a fabricated summary.
### Label hygiene
- Prefer labels over descriptions when labels are clearer.
- Normalise text for deduplication (lowercase, trim, collapse whitespace).
- Omit items that are too verbose to scan; do not synthesise rewritten claims that change meaning.
- Avoid displaying graph identifiers, confidence values without context, or raw enum categories in user-facing views.
### State-aware framing
- The same panel must remain useful during early, active and terminal investigation states.
- Terminal state content should change its framing (e.g., "What the evidence supports" rather than "Still investigating") but not invent certainty.
@@ -0,0 +1,172 @@
/**
* InvestigationSummaryPanelV3 — Phase 4, Experiment 12
* A user-facing facilitator view that translates the reasoning graph into
* a concise, human-meaningful presentation.
*
* Design principles:
* - The panel shows up to four sections: what we know, still investigating,
* possible explanations, and a quiet summary.
* - All content is grounded in existing graph fields. No invented facts.
* - Epistemic labels are explicit (structural), not colour-dependent.
* - The same panel remains useful during early, active and terminal states.
*/
import { buildFacilitatorViewModel } from "@/lib/presentation/facilitator-view-adapter";
/* ── Item rendering ─────────────────────────────────────────────── */
/**
* Render a single item with its structural label where applicable.
*/
function renderItem(item, isExplanation) {
if (isExplanation && typeof item === "object") {
return (
<li key={item.text} className="flex items-start gap-2">
<span className="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400/50" />
<span className="text-sm text-gray-700">{item.text}</span>
<span className="ml-auto mt-[-2px] shrink-0 whitespace-nowrap text-[10px] font-medium tracking-wide text-gray-400">
{item.label}
</span>
</li>
);
}
return (
<li key={item} className="flex items-start gap-2">
<span className="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400/50" />
<span className="text-sm text-gray-700">{item}</span>
</li>
);
}
/* ── Section components ──────────────────────────────────────────── */
function KnownSection({ title, items }) {
if (!items || items.length === 0) return null;
return (
<div>
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-400">
{title}
</h3>
<ul className="space-y-1.5">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-500" />
<span className="text-sm text-gray-700">{item}</span>
</li>
))}
</ul>
</div>
);
}
function InvestigatingSection({ title, items }) {
if (!items || items.length === 0) return null;
return (
<div>
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-400">
{title}
</h3>
<ul className="space-y-1.5">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400/60" />
<span className="text-sm text-gray-700">{item}</span>
</li>
))}
</ul>
</div>
);
}
function ExplanationSection({ items }) {
if (!items || items.length === 0) return null;
return (
<div>
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-400">
Possible explanations
</h3>
<ul className="space-y-1.5">
{items.map((item, i) => renderItem(item, true))}
</ul>
</div>
);
}
function QuietSummary({ text }) {
if (!text) return null;
return (
<div className="pt-2 border-t border-gray-200/40">
<p className="text-[10px] font-medium tracking-widest uppercase text-gray-300 mb-1.5">
Investigation state
</p>
<p className="text-xs text-gray-400">{text}</p>
</div>
);
}
/* ── Empty-state fallback ──────────────────────────────────────── */
function EmptyState() {
return (
<div className="space-y-3">
<KnownSection title="What we know" items={[]} />
<InvestigatingSection title="Still investigating" items={[]} />
{/* Intentionally no Possible explanations section when empty */}
<QuietSummary text={null} />
<div className="flex items-start gap-2">
<span className="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400/50" />
<p className="text-sm text-gray-500 italic">We are still establishing the basic facts.</p>
</div>
</div>
);
}
/* ── Main component ────────────────────────────────────────────── */
function InvestigationSummaryPanelV3({ graph, selectedQuestion, result }) {
// Build the view model from the adapter
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
const viewModel = buildFacilitatorViewModel({
nodes: graph?.nodes || [],
resolvedIds,
activeUnknownNodeId: graph?.activeUnknownNodeId || null,
edges: graph?.edges || [],
selectedQuestion,
});
// Early state fallback
if (!viewModel.known.hasItems && !viewModel.investigating.hasItems) {
return <EmptyState />;
}
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 p-5 space-y-4">
{/* What we know */}
<KnownSection title={viewModel.known.title} items={viewModel.known.items} />
{/* Still investigating — or "Remaining cautions" in terminal state */}
{!viewModel.investigating.shouldOmit && (
<InvestigatingSection
title={viewModel.investigating.title}
items={viewModel.investigating.items}
/>
)}
{/* Possible explanations */}
{viewModel.explanations.hasItems && (
<ExplanationSection items={viewModel.explanations.items} />
)}
{/* Quiet reasoning summary */}
<QuietSummary text={viewModel.summary.text} />
</div>
);
}
export default InvestigationSummaryPanelV3;
+38 -10
View File
@@ -6,6 +6,7 @@ import GraphUpdateView from "@/components/graph-update-view";
import SituationGraphView from "@/components/situation-graph-view";
import InvestigationSummaryPanel from "@/components/investigation-summary-panel";
import InvestigationSummaryPanelV2 from "@/components/investigation-summary-panel-v2";
import InvestigationSummaryPanelV3 from "@/components/investigation-summary-panel-v3";
import InvestigationMap from "@/components/investigation-map";
// ── Technical summary detector (main view filters these) ───
@@ -524,8 +525,8 @@ export default function ReasoningWorkspace({
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
const pendingTurnRef = useRef(null);
// ── Experiment 11: toggle between progress panel versions ──
const [showVersionB, setShowVersionB] = useState(false);
// ── Experiment 12: toggle between progress panel versions (temporary experimental UI) ──
const [panelVariant, setPanelVariant] = useState("c");
const { saveSession, loadSession } = useSessionPersistence();
// Persist workspace state on every successful update (Phase 3)
@@ -718,28 +719,55 @@ export default function ReasoningWorkspace({
{hasCurrentSummaryCondition && (
<>
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding || null} />
{/* ── Experiment 11: progress panel A / B toggle ── */}
{/* ── Experiment 12: progress panel A / B / C toggle (temporary experimental UI) ── */}
{hasGraph && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" role="radiogroup" aria-label="Progress panel variant">
<button
onClick={() => setShowVersionB(false)}
className={`text-xs transition ${!showVersionB ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
role="radio"
aria-checked={panelVariant === "a"}
onClick={() => setPanelVariant("a")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("b");
if (e.key === "ArrowLeft") setPanelVariant("c");
}}
className={`text-xs transition ${panelVariant === "a" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
Panel A
</button>
<span className="text-gray-300">/</span>
<button
onClick={() => setShowVersionB(true)}
className={`text-xs transition ${showVersionB ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
role="radio"
aria-checked={panelVariant === "b"}
onClick={() => setPanelVariant("b")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("c");
if (e.key === "ArrowLeft") setPanelVariant("a");
}}
className={`text-xs transition ${panelVariant === "b" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
Panel B
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "c"}
onClick={() => setPanelVariant("c")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("a");
if (e.key === "ArrowLeft") setPanelVariant("b");
}}
className={`text-xs transition ${panelVariant === "c" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
Panel C
</button>
</div>
<div className="opacity-75">
{showVersionB
{panelVariant === "a"
? <InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: panelVariant === "b"
? <InvestigationSummaryPanelV2 graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: <InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: <InvestigationSummaryPanelV3 graph={graph} selectedQuestion={selectedQ} result={result} />
}
</div>
</div>
+65 -1
View File
@@ -502,6 +502,25 @@ The current "Investigation in progress" panel exposes developer-oriented statist
---
## Emerging Direction — Graph as Source of Truth
The reasoning graph is becoming the shared source of truth for multiple UI views.
Different interfaces may project the same graph for different audiences:
- Version A — compact technical progress;
- Version B — detailed graph inspection;
- Version C — user-facing facilitator view;
- Developer Details — complete diagnostics;
- Investigation Map — future spatial projection;
- Current Question — active uncertainty projection.
The UI should not maintain separate invented summaries where the graph already contains the underlying information.
This is an emerging direction, not a final architecture decision.
---
## Emerging Direction — Facilitator Translation Layer
> The UI should progressively become a translation layer over the reasoning graph rather than maintaining separate duplicated summaries. Internal graph concepts should remain available for developers, while end users see a facilitator-style explanation of what is currently understood and what remains uncertain.
@@ -531,14 +550,59 @@ A facilitator-style panel should communicate:
- Is a quiet reasoning summary sufficient, or does it need more context?
- Does the translation-layer principle hold — presenting the graph as a notebook rather than raw data?
#### Result
Partially confirmed.
#### What did we learn?
- Version B proved that the reasoning graph contains substantially more useful information than Version A exposes.
- The graph already contains observations, unknowns, assumptions, metrics, relationships and state.
- The graph is rich enough to support multiple UI projections.
- Exposing the graph almost verbatim overwhelms the user.
- Technical categories are useful for development but do not directly communicate investigation progress.
- The user needs a translation of the graph rather than a graph browser.
- Developer Details should remain the place for complete technical inspection.
- A user-facing view needs filtering, prioritisation, deduplication and clear epistemic labels.
#### Decision
Keep Version A and Version B available for comparison.
Proceed with a Version C facilitator view built from the same graph.
---
### Experiment 12 — Facilitator View (Version C)
#### Hypothesis
The existing reasoning graph can be deterministically translated into a concise facilitator view that helps the user understand:
- what is currently known;
- what remains uncertain;
- what may explain the situation;
- why the investigation is continuing.
#### Questions
- Can the graph produce a useful human-facing summary without another LLM call?
- Can observations, unknowns and assumptions be clearly distinguished?
- Can duplicate or low-value graph content be filtered reliably?
- Does a concise projection improve understanding without exposing implementation detail?
- Does the panel remain useful across mocks and live Ollama output?
- Can the same view work during early, middle and terminal investigation states?
#### Evaluation
Pending visual review.
Pending visual and live-data review.
#### Status
Experimental.
Do not record a conclusion yet.
---
## Emerging Direction
+35
View File
@@ -143,3 +143,38 @@ The current adapter (`lib/map/investigation-map-adapter.js`) uses generic placeh
7. **Investigation duration tracking**: The summary panel computes elapsed time from `Date.now() - updatedAt`. If the engine emits proper timestamps, the UI can show accurate elapsed duration and investigate stalls (>5 min between turns).
8. **Layout independence (v0.7 workspace layout phase)**: Reasoning outputs must remain entirely independent of presentation layout. The UI's responsive workspace layout — which progressively reveals simultaneous context on wide screens — is a pure presentation concern. No reasoning contract field should be added, removed, or modified to accommodate layout changes. Future reasoning outputs should carry data semantically; how that data arranges itself visually is the responsibility of the presentation layer alone.
---
## Facilitator View Projection (Experiment 12)
Version C derives its content from existing graph fields without requiring new backend data. The following fields are used as inputs:
| Input | Source |
|-------|--------|
| node type / kind | `node.kind` (observation, unknown, assumption, state, metric, conclusion) |
| node label or description | `node.label`, `node.description` |
| support / status | `node.status`, `resolvedNodeIds` |
| confidence where available | `node.confidence` |
| active unknown identity | `graph.activeUnknownNodeId` |
| resolution state | `node.status === "resolved"` or `resolvedNodeIds.includes(id)` |
| evidence references where available | `node.evidenceIds` (currently empty in mocks) |
| relationship relevance where available | `edge.relevance`, `node.relationships` |
### Current limitations (observations, not requests)
The following are observed constraints of the current graph output. They are documented here because they affect the adapter's filtering and ranking logic. They should NOT be treated as backend change requests during this experiment.
- Graph text may repeat the full original scenario verbatim in node labels or descriptions.
- Labels may be verbose relative to what a user can scan quickly.
- The selected question rationale and the selected question itself may diverge slightly in wording from the underlying unknown node.
- Ranking signals (relevance, priority) may not be sufficient for ideal user-facing ordering; the adapter uses deterministic fallbacks.
- Some assumptions may be too generic to be useful without context.
- Duplicate semantic content may occur across node types (e.g., an observation and an unknown restating the same scenario fragment).
The adapter handles these limitations through:
1. Length-based filtering of overly verbose items;
2. Normalised text deduplication across node kinds;
3. Deprioritisation of items matching known boilerplate patterns;
4. Deterministic ranking with explicit fallback ordering documented in code comments.
@@ -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;