From 685c6f17926bc9ff8c8238ea58fff39a2aea1cf0 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:45:52 -0700 Subject: [PATCH] feat(portal): side-by-side hypothesis map on the case dashboard (26 H.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 26 slice H.2 (docs/HYPOTHESIS_MAP_DESIGN.md §4): - src/portal/hypothesis-block.js — renderHypothesesBlock over the shared case-view data envelope. A pure, node-testable buildHypothesisBlockModel derives every user-facing string; the DOM layer projects it 1:1 (el()/textContent, no innerHTML). Hypotheses render as equal-width side-by-side cards (grid; layout encodes nothing), each with statement, brief holders as followable source links, and default-open Supporting/Undermining sections whose headings carry ONLY their own size. Crux badges on claims attached to more than one hypothesis; verdict chain-head chips as context with the does-not-weight note; llm/nostr/seed provenance badges; dangling edges and unlabeled brief positions disclosed. Self-removes when the case has no map. - case-view.js mounts the block in the shared-assembly IIFE between the graph/synthesis blocks and the timeline. - tests/hypothesis-block.test.mjs — the §6.2 no-scoreboard guard over the model's strings (negative: no comparison phrasing or judgment number anywhere; positive: the maps-not-picks / not-a-ranking / does-not-weight disclaimers present — the corpus-publish pairing), cross-hypothesis reference check per card, honest degradation for unresolved claims/holders. Co-Authored-By: Claude Fable 5 --- src/portal/case-view.js | 6 + src/portal/hypothesis-block.js | 223 ++++++++++++++++++++++++++++++++ src/portal/index.css | 27 ++++ tests/hypothesis-block.test.mjs | 188 +++++++++++++++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 src/portal/hypothesis-block.js create mode 100644 tests/hypothesis-block.test.mjs diff --git a/src/portal/case-view.js b/src/portal/case-view.js index ddc1d26..31ef450 100644 --- a/src/portal/case-view.js +++ b/src/portal/case-view.js @@ -23,6 +23,7 @@ import { mountAddSources } from './add-sources.js'; import { mountTranscriptImport } from './import-transcript.js'; import { renderCaseGraph } from './case-graph-view.js'; import { renderSynthesisBlock } from './synthesis-block.js'; +import { renderHypothesesBlock } from './hypothesis-block.js'; import { Utils } from '../shared/utils.js'; // Open a LOCAL archived record in the reader to extract claims from a @@ -219,12 +220,14 @@ export function renderCaseView(host, params) { const shapeHost = el('div'); const evidenceHost = el('div'); const graphHost = el('div'); + const hypothesesHost = el('div'); const timelineHost = el('div'); if (caseEnt && caseEnt.entityId) { host.appendChild(localCountsHost); host.appendChild(shapeHost); host.appendChild(evidenceHost); host.appendChild(graphHost); + host.appendChild(hypothesesHost); (async () => { // Collect once, then build the dossier AND the local graph // from the same `data` (the graph needs entitiesById/articles @@ -260,6 +263,9 @@ export function renderCaseView(host, params) { data, dossier, callbacks: { onReloadCase: callbacks.onReloadCase } }); + // 26 H.2 — the hypothesis map, assembled from the same + // shared `data`. Self-removes when the case has no map. + renderHypothesesBlock(hypothesesHost, { data }); renderCaseTimeline(timelineHost, dossier); })().catch((err) => { Utils.error('Case dossier assembly failed', err); diff --git a/src/portal/hypothesis-block.js b/src/portal/hypothesis-block.js new file mode 100644 index 0000000..222083c --- /dev/null +++ b/src/portal/hypothesis-block.js @@ -0,0 +1,223 @@ +// Hypothesis map block — Phase 26 H.2 (docs/HYPOTHESIS_MAP_DESIGN.md +// §4). Side-by-side competing answers with their supporting and +// undermining claim attachments. Thin projection of the pure map +// (`buildHypothesisMap`); all text goes through `el()`/textContent +// (no innerHTML). +// +// The §6 render red lines, enforced by `buildHypothesisBlockModel` +// (pure, node-testable — the DOM layer maps it 1:1): +// - hypotheses render side by side; the visible copy says the order +// is not a ranking and X-Ray does not pick a winner; +// - the ONLY counts beside a role are each section's own size +// ("Supporting evidence (N)") — no cross-hypothesis comparison +// string exists anywhere in the model (guard-tested); +// - verdict chips are context; the copy says they do not weight the +// edge; +// - no progress bars, no meters, no per-hypothesis totals compared. + +import { el, truncate } from './dom.js'; +import { collectHypothesisMapData, buildHypothesisMap } from '../shared/hypothesis-map.js'; +import { VERDICT_STATE_LABELS, PROPOSITION_CLASS_LABELS } from '../shared/truth-taxonomy.js'; +import { Utils } from '../shared/utils.js'; + +const MAX_EDGES_PER_SECTION = 12; +const MAX_DANGLING = 6; + +export const HYPOTHESIS_BLOCK_HEADING = 'Hypotheses — competing answers, not a ranking'; +export const HYPOTHESIS_BLOCK_EXPLAINER = + 'Competing answers to the case question, with the specific claims each one rests on. ' + + 'X-Ray maps the answers side by side; it does not pick one, the order is not a ranking, ' + + 'and section sizes are never compared across hypotheses. A verdict chip is context for ' + + 'the claim it sits on — it does not weight the edge, and a contested claim can still be ' + + 'load-bearing.'; + +const ROLE_HEADINGS = { supports: 'Supporting evidence', undermines: 'Undermining evidence' }; + +function provenanceBadge(suggestedBy) { + if (!suggestedBy || suggestedBy === 'user') return null; + if (suggestedBy === 'seed:brief') return 'from brief'; + if (suggestedBy.startsWith('llm:')) return suggestedBy; + if (suggestedBy.startsWith('nostr:')) return `nostr:${suggestedBy.slice(6, 14)}…`; + return suggestedBy; +} + +/** + * Pure view model: every user-facing string the block will render, + * derived from a built hypothesis map. Exported so the no-scoreboard + * guard test can walk the strings without a DOM. + */ +export function buildHypothesisBlockModel(map) { + const empty = map.hypotheses.length === 0 && map.dangling.edges.length === 0; + const c = map.coverage; + const model = { + empty, + heading: HYPOTHESIS_BLOCK_HEADING, + explainer: HYPOTHESIS_BLOCK_EXPLAINER, + // Map-level totals only — never a per-hypothesis figure. + countsLine: `${c.hypotheses} hypothes${c.hypotheses === 1 ? 'is' : 'es'} · ` + + `${c.edges} claim attachment${c.edges === 1 ? '' : 's'} · ` + + `${c.claims} distinct claim${c.claims === 1 ? '' : 's'}`, + questionLine: map.question.text ? `Case question (author's framing): ${map.question.text}` : null, + cards: [], + danglingLine: null, + dangling: [], + unlabeledLine: null + }; + if (empty) return model; + + const cruxRefs = new Set(map.shared_claims.map((s) => s.ref)); + for (const h of map.hypotheses) { + const card = { + id: h.id, + title: h.label, + statement: h.statement, + provenance: provenanceBadge(h.suggested_by), + holders: h.holders.map((hold) => ({ + label: hold.title || hold.url || `${hold.article_hash.slice(0, 12)}… (not in local archive)`, + url: hold.url + })), + sections: [] + }; + for (const role of ['supports', 'undermines']) { + const edges = h.edges[role]; + card.sections.push({ + role, + // The one place a count is allowed: this section's own size. + heading: `${ROLE_HEADINGS[role]} (${edges.length})`, + edges: edges.slice(0, MAX_EDGES_PER_SECTION).map((e) => ({ + text: e.claim ? truncate(e.claim.text, 140) : `${e.ref} (unresolved claim)`, + url: e.claim ? e.claim.url : null, + quote: e.quote ? truncate(e.quote, 200) : null, + note: e.note || null, + crux: cruxRefs.has(e.ref), + provenance: provenanceBadge(e.suggested_by), + verdictChips: e.verdicts + .filter((v) => v.state !== null) + .map((v) => `${PROPOSITION_CLASS_LABELS[v.proposition_class] || v.proposition_class}: ` + + `${VERDICT_STATE_LABELS[v.state] || v.state}`) + })), + overflow: edges.length > MAX_EDGES_PER_SECTION + ? `… +${edges.length - MAX_EDGES_PER_SECTION} more` : null + }); + } + model.cards.push(card); + } + + if (map.dangling.edges.length > 0) { + model.danglingLine = `${map.dangling.edges.length} attachment${map.dangling.edges.length === 1 ? '' : 's'} ` + + 'reference a hypothesis that no longer exists — kept visible for disclosure.'; + model.dangling = map.dangling.edges.slice(0, MAX_DANGLING).map((e) => + `${e.role} · ${e.claim ? truncate(e.claim.text, 100) : e.ref}`); + } + if (c.unlabeled_positions > 0) { + model.unlabeledLine = `${c.unlabeled_positions} brief position${c.unlabeled_positions === 1 ? '' : 's'} ` + + 'had no label and could not seed a hypothesis.'; + } + return model; +} + +// ------------------------------------------------------------------ +// DOM layer — a 1:1 projection of the model. +// ------------------------------------------------------------------ + +function sourceLink(url, label) { + const a = el('a', 'xr-synth__src', label); + a.href = url; + a.target = '_blank'; + a.rel = 'noreferrer noopener'; + a.title = url; + return a; +} + +/** + * Render the hypothesis map for a case. `data` is the shared + * `collectCaseDossierData` envelope (assembled once by the case view); + * the brief and the hypothesis/edge models are read live. + */ +export function renderHypothesesBlock(host, { data }) { + if (!data || !data.case) return; + const block = el('div', 'xr-view__dossier xr-hyp'); + host.appendChild(block); + + (async () => { + const input = await collectHypothesisMapData(data.case.id, { data }); + const map = buildHypothesisMap(input, null); + const model = buildHypothesisBlockModel(map); + if (model.empty) { block.remove(); return; } + + block.appendChild(el('h3', 'xr-case__heading', model.heading)); + block.appendChild(el('div', 'xr-case__explainer', model.explainer)); + if (model.questionLine) block.appendChild(el('div', 'xr-view__dossier-line', model.questionLine)); + block.appendChild(el('div', 'xr-view__dossier-line', model.countsLine)); + + const grid = el('div', 'xr-hyp__grid'); + for (const card of model.cards) { + const cardEl = el('div', 'xr-hyp__card'); + const head = el('div', 'xr-row__head'); + head.appendChild(el('h4', 'xr-inspector__sub', card.title)); + if (card.provenance) head.appendChild(el('span', 'xr-badge xr-badge--muted', card.provenance)); + cardEl.appendChild(head); + if (card.statement && card.statement !== card.title) { + cardEl.appendChild(el('div', 'xr-hyp__statement', card.statement)); + } + if (card.holders.length > 0) { + const holders = el('div', 'xr-hyp__holders'); + holders.appendChild(el('span', 'xr-inspector__mono', 'held by: ')); + card.holders.forEach((hold, i) => { + if (i > 0) holders.appendChild(el('span', 'xr-inspector__mono', ' · ')); + holders.appendChild(hold.url + ? sourceLink(hold.url, hold.label) + : el('span', 'xr-inspector__mono', hold.label)); + }); + cardEl.appendChild(holders); + } + for (const section of card.sections) { + const sec = el('details', 'xr-synth__sec'); + sec.open = true; + sec.appendChild(el('summary', null, section.heading)); + for (const edge of section.edges) { + const row = el('div', 'xr-hyp__edge'); + const line = el('div', 'xr-hyp__edgeline'); + line.appendChild(edge.url + ? sourceLink(edge.url, edge.text) + : el('span', null, edge.text)); + if (edge.crux) { + const b = el('span', 'xr-badge xr-badge--warn', 'crux'); + b.title = 'This claim is attached to more than one hypothesis — the disagreement, made legible.'; + line.appendChild(b); + } + if (edge.provenance) line.appendChild(el('span', 'xr-badge xr-badge--muted', edge.provenance)); + for (const chip of edge.verdictChips) { + const v = el('span', 'xr-badge xr-badge--muted', chip); + v.title = 'Verdict context for this claim — it does not weight the edge.'; + line.appendChild(v); + } + row.appendChild(line); + if (edge.quote) row.appendChild(el('blockquote', 'xr-finding-row__quote', edge.quote)); + if (edge.note) row.appendChild(el('div', 'xr-inspector__mono', edge.note)); + sec.appendChild(row); + } + if (section.edges.length === 0) { + sec.appendChild(el('div', 'xr-inspector__mono', 'none attached yet')); + } + if (section.overflow) sec.appendChild(el('div', 'xr-inspector__mono', section.overflow)); + cardEl.appendChild(sec); + } + grid.appendChild(cardEl); + } + block.appendChild(grid); + + if (model.danglingLine) { + block.appendChild(el('div', 'xr-view__dossier-line', model.danglingLine)); + for (const line of model.dangling) { + block.appendChild(el('div', 'xr-inspector__mono', line)); + } + } + if (model.unlabeledLine) { + block.appendChild(el('div', 'xr-inspector__mono', model.unlabeledLine)); + } + })().catch((err) => { + Utils.error('Hypothesis block render failed', err); + block.remove(); + }); +} diff --git a/src/portal/index.css b/src/portal/index.css index 8baffe6..1a7a6cf 100644 --- a/src/portal/index.css +++ b/src/portal/index.css @@ -1068,3 +1068,30 @@ button.xr-badge--case { cursor: pointer; background: transparent; } flex-wrap: wrap; } .xr-dossier__evidence-src { color: inherit; } + +/* --- Hypothesis map (26 H.2) — side-by-side competing answers. + Deliberately no bars, meters, or width-encodes-anything: cards are + equal-width columns; the layout itself must not read as a ranking. */ +.xr-hyp__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 10px; + margin: 8px 0; + align-items: start; +} +.xr-hyp__card { + border: 1px solid var(--xr-border); + border-radius: 8px; + padding: 8px 12px; +} +.xr-hyp__card .xr-inspector__sub { margin: 2px 0 4px; } +.xr-hyp__statement { font-size: 13px; margin: 2px 0 6px; } +.xr-hyp__holders { font-size: 11.5px; margin: 2px 0 6px; } +.xr-hyp__edge { margin: 6px 0; } +.xr-hyp__edgeline { + display: flex; + gap: 6px; + align-items: baseline; + flex-wrap: wrap; + font-size: 12.5px; +} diff --git a/tests/hypothesis-block.test.mjs b/tests/hypothesis-block.test.mjs new file mode 100644 index 0000000..bf10229 --- /dev/null +++ b/tests/hypothesis-block.test.mjs @@ -0,0 +1,188 @@ +// Hypothesis block view-model tests — Phase 26 H.2 +// (docs/HYPOTHESIS_MAP_DESIGN.md §4, §6.2). The DOM layer is a 1:1 +// projection of `buildHypothesisBlockModel` (pure), so the +// no-scoreboard guard walks the model's strings: the ONLY count +// beside a role is each section's own size, no cross-hypothesis +// comparison phrasing exists anywhere, and the required +// maps-not-picks / verdict-does-not-weight disclaimers are present +// (the corpus-publish.test.mjs negative+positive pairing). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +globalThis.chrome = globalThis.chrome || { + storage: { local: { get(_k, cb) { cb({}); }, set(_o, cb) { cb && cb(); }, remove(_k, cb) { cb && cb(); } } } +}; + +const { buildHypothesisBlockModel } = await import('../src/portal/hypothesis-block.js'); +const { buildHypothesisMap } = await import('../src/shared/hypothesis-map.js'); + +const CASE_ID = 'entity_00000000000000aa'; +const HASH_A = 'a'.repeat(64); + +function makeClaim(id, over = {}) { + return { + id, text: `Claim ${id}`, about: [CASE_ID], source: null, is_key: false, + source_url: `https://example.com/${id}`, created: 100, ...over + }; +} + +function makeData(over = {}) { + const c1 = makeClaim('claim_00000000000000c1'); + const c2 = makeClaim('claim_00000000000000c2'); + return { + case: { id: CASE_ID, name: 'Origins', type: 'case', pubkey: null }, + membership_ids: [CASE_ID], + entitiesById: { + [CASE_ID]: { + id: CASE_ID, name: 'Origins', type: 'case', + authored_fields: { scope_question: { value: 'Where did it begin?' } } + } + }, + articles: [{ url: 'https://x/a', articleHash: HASH_A, cachedAt: 10, article: { title: 'Article A' } }], + orbit: { entity_ids: [CASE_ID], entities: [], dangling_entity_ids: [], claims: [c1, c2] }, + claimsById: { [c1.id]: c1, [c2.id]: c2 }, + propositions: { all: { + prop_1: { id: 'prop_1', claim_id: c1.id, proposition_class: 'event-fact' } + }, orbit: [] }, + verdicts: { byProposition: { + prop_1: [{ id: 'v1', proposition_id: 'prop_1', verdict: 'contested', superseded_by: null }] + } }, + integrity: [], integrityAll: [], forensic: [], + links: { contradicts: [], attestations: [] }, + wire: { verdicts: [], findings: [], articles: [] }, + ...over + }; +} + +function hyp(id, label, over = {}) { + return { + id, case_id: CASE_ID, label, statement: `${label} statement`, note: '', + suggested_by: 'user', created: 50, updated: 50, ...over + }; +} + +function edge(id, hypothesisId, ref, role, over = {}) { + return { + id, hypothesis_id: hypothesisId, claim_ref: ref, ref, role, + note: '', suggested_by: 'user', quote: null, article_hash: null, + claim_snapshot: null, created: 60, updated: 60, ...over + }; +} + +function richModel() { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const h2 = hyp('hyp_00000000000000cd', 'Lab origin', { created: 55, suggested_by: 'llm:claude-x' }); + const map = buildHypothesisMap({ + data: makeData(), + brief: { caseId: CASE_ID, brief: { summary: 's', positions: [ + { label: 'Zoonotic', core_argument: 'Spillover.', holders: [{ article_hash: HASH_A }] }, + { label: ' ', holders: [] } + ] } }, + hypotheses: [h1, h2], + edges: [ + edge('hedge_1', h1.id, 'claim_00000000000000c1', 'supports', { quote: 'verbatim', note: 'why it matters' }), + edge('hedge_2', h2.id, 'claim_00000000000000c1', 'undermines', { suggested_by: 'llm:claude-x' }), + edge('hedge_3', h1.id, 'claim_00000000000000c2', 'supports'), + edge('hedge_4', 'hyp_gone', 'claim_00000000000000c2', 'supports') + ] + }, null); + return buildHypothesisBlockModel(map); +} + +function allStrings(node, out = []) { + if (typeof node === 'string') { out.push(node); return out; } + if (Array.isArray(node)) { node.forEach((v) => allStrings(v, out)); return out; } + if (node && typeof node === 'object') { Object.values(node).forEach((v) => allStrings(v, out)); } + return out; +} + +// ------------------------------------------------------------------ + +test('hypothesis-block: no-scoreboard guard — no comparison phrasing or judgment number in ANY rendered string', () => { + const strings = allStrings(richModel()); + assert.ok(strings.length > 10); + const banned = /\d+\s*%|\d+\s*\/\s*100|more likely|less likely|stronger|weaker|winner|wins\b|leads\b|ahead of|best.supported|top hypothesis|score|probabilit|confidence|likelihood/i; + for (const s of strings) { + assert.doesNotMatch(s, banned, `forbidden phrasing in: "${s}"`); + } +}); + +test('hypothesis-block: the required disclaimers are present (positive half of the firewall pairing)', () => { + const model = richModel(); + assert.match(model.heading, /not a ranking/); + assert.match(model.explainer, /does not pick one/); + assert.match(model.explainer, /order is not a ranking/); + assert.match(model.explainer, /does not weight the edge/); + assert.match(model.explainer, /never compared across hypotheses/); +}); + +test('hypothesis-block: the only role-adjacent count is each section\'s own size', () => { + const model = richModel(); + for (const card of model.cards) { + assert.equal(card.sections.length, 2); + for (const s of card.sections) { + assert.match(s.heading, /^(Supporting|Undermining) evidence \(\d+\)$/); + } + // No card-level string carries another hypothesis's label. + const otherLabels = model.cards.filter((c) => c !== card).map((c) => c.title); + for (const s of allStrings(card)) { + for (const other of otherLabels) { + assert.ok(!s.includes(other), `cross-hypothesis reference in "${s}"`); + } + } + } + // The map-level counts line names totals, not any hypothesis. + for (const card of model.cards) { + assert.ok(!model.countsLine.includes(card.title)); + } +}); + +test('hypothesis-block: model carries the map faithfully — crux badge, verdict chip, quote, provenance, dangling, unlabeled', () => { + const model = richModel(); + assert.equal(model.empty, false); + assert.equal(model.cards.length, 2); + const [z, l] = model.cards; + assert.equal(z.title, 'Zoonotic'); + assert.equal(z.holders[0].label, 'Article A'); + assert.equal(z.holders[0].url, 'https://x/a'); + const zSup = z.sections.find((s) => s.role === 'supports'); + assert.equal(zSup.heading, 'Supporting evidence (2)'); + const cruxEdge = zSup.edges.find((e) => e.crux); + assert.ok(cruxEdge, 'the claim shared with Lab origin is badged as crux'); + assert.deepEqual(cruxEdge.verdictChips, ['Event fact: Contested']); + assert.equal(cruxEdge.quote, 'verbatim'); + assert.equal(cruxEdge.note, 'why it matters'); + assert.equal(l.provenance, 'llm:claude-x'); + const lUnd = l.sections.find((s) => s.role === 'undermines'); + assert.equal(lUnd.edges[0].crux, true); + assert.match(model.danglingLine, /1 attachment reference/); + assert.equal(model.dangling.length, 1); + assert.match(model.unlabeledLine, /1 brief position had no label/); +}); + +test('hypothesis-block: empty map → empty model (the block self-removes)', () => { + const map = buildHypothesisMap({ data: makeData(), brief: null, hypotheses: [], edges: [] }, null); + const model = buildHypothesisBlockModel(map); + assert.equal(model.empty, true); + assert.deepEqual(model.cards, []); +}); + +test('hypothesis-block: unresolved claim and unresolved holder degrade honestly, never fabricated', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const map = buildHypothesisMap({ + data: makeData({ articles: [] }), + brief: { caseId: CASE_ID, brief: { summary: 's', positions: [ + { label: 'Zoonotic', holders: [{ article_hash: HASH_A }] } + ] } }, + hypotheses: [h1], + edges: [edge('hedge_1', h1.id, `30040:${'f'.repeat(64)}:bare`, 'undermines')] + }, null); + const model = buildHypothesisBlockModel(map); + const card = model.cards[0]; + assert.match(card.holders[0].label, /not in local archive/); + assert.equal(card.holders[0].url, null); + const und = card.sections.find((s) => s.role === 'undermines'); + assert.match(und.edges[0].text, /unresolved claim/); + assert.equal(und.edges[0].url, null); +});