From 0f77cce58331d94b363e59ba800d5e6c00517864 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:11:28 -0700 Subject: [PATCH 1/2] feat(portal): inline Trace-dependencies expander per claim (26 CF.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 26 slice CF.2 (docs/COUNTERFACTUAL_DESIGN.md §3; resolved §7: inline per-claim expander) — completes the approved Phase-26 ladder. - shared/case-counterfactual.js traceLines(delta, {claimsById}) — the pure plain-list source the DOM projects 1:1: every line a count with its derivation on the face (knot dissolve/split/shrink, only-support losses with claim texts, attestation origin deltas with surviving origins, falling propositions with chain lengths, entity/timeline losses, hypothesis-edge removals or role-flips). Negate wording says flips/rests-on and recomputes no verdict; the honest zero states that nothing rests on the claim. - shared/hypothesis-map.js collectHypothesisEdgeJoins — one read of both models, canonicalized refs + labels, the CF.1 hypothesisEdges join input. - portal/trace-block.js mountTraceExpander — a native details element per claim row in the evidence block; computes LAZILY on first open over the already-collected shared data envelope; If removed / If negated mode toggle; explainer states "measured over the local graph. Not a probability of anything." - evidence-block accepts {data, hypothesisEdges}; case-view collects the joins once beside the shared assembly and passes both through. - Tests: traceLines content for both modes, the honest zero, and the §4 copy guard — no likelihood/strength/percent phrasing in any line or derivation, either mode, across four claims. Co-Authored-By: Claude Fable 5 --- src/portal/case-view.js | 6 +++ src/portal/evidence-block.js | 12 ++++- src/portal/index.css | 7 +++ src/portal/trace-block.js | 70 +++++++++++++++++++++++++++ src/shared/case-counterfactual.js | 77 ++++++++++++++++++++++++++++++ src/shared/hypothesis-map.js | 19 ++++++++ tests/case-counterfactual.test.mjs | 55 ++++++++++++++++++++- 7 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 src/portal/trace-block.js diff --git a/src/portal/case-view.js b/src/portal/case-view.js index 36ae476..7f22882 100644 --- a/src/portal/case-view.js +++ b/src/portal/case-view.js @@ -24,6 +24,7 @@ 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 { collectHypothesisEdgeJoins } from '../shared/hypothesis-map.js'; import { Utils } from '../shared/utils.js'; // Open a LOCAL archived record in the reader to extract claims from a @@ -234,6 +235,10 @@ export function renderCaseView(host, params) { // that only the collector output carries). const data = await collectCaseDossierData(caseEnt.entityId); const dossier = buildCaseDossier(data, null); + // 26 CF.2 — the hypothesis-edge joins the per-claim trace + // expander folds into its deltas (one read, shared below). + const hypothesisEdges = await collectHypothesisEdgeJoins(caseEnt.entityId) + .catch(() => []); const c = dossier.coverage; localCountsHost.textContent = `Local corpus: ${c.articles} source${c.articles === 1 ? '' : 's'} · ` @@ -242,6 +247,7 @@ export function renderCaseView(host, params) { if (c.articles === 0) localCountsHost.remove(); renderShapeBlock(shapeHost, dossier); renderEvidenceBlock(evidenceHost, dossier, { + data, hypothesisEdges, onExtractClaims: openArchivedInReader, onRemoveFromCase: async (url) => { try { diff --git a/src/portal/evidence-block.js b/src/portal/evidence-block.js index f453649..39836f5 100644 --- a/src/portal/evidence-block.js +++ b/src/portal/evidence-block.js @@ -11,6 +11,7 @@ import { el, truncate } from './dom.js'; import { assembleCaseDossier } from '../shared/case-dossier.js'; import { auditCardChipData } from '../shared/audit/display.js'; +import { mountTraceExpander } from './trace-block.js'; import { Utils } from '../shared/utils.js'; const MAX_ROWS = 40; @@ -30,9 +31,12 @@ function captureChips(row) { // `dossierOrId`: a pre-built case dossier (shared assembly, 20.1) or a // case entity id. `opts.onExtractClaims(url)` opens a claimless member // row's archived article in the reader to extract claims (20.1). +// `opts.data` + `opts.hypothesisEdges` (26 CF.2): when the shared +// collector envelope rides along, each claim row gains the +// "Trace dependencies" expander (trace-block.js). export function renderEvidenceBlock(host, dossierOrId, opts = {}) { if (!dossierOrId) return; - const { onExtractClaims, onRemoveFromCase } = opts; + const { onExtractClaims, onRemoveFromCase, data, hypothesisEdges } = opts; const block = el('div', 'xr-view__dossier'); host.appendChild(block); @@ -124,6 +128,12 @@ export function renderEvidenceBlock(host, dossierOrId, opts = {}) { } else { li.appendChild(el('div', 'xr-ev__claim', truncate(c.text || '', 140))); } + // 26 CF.2 — the structural counterfactual, per claim. + if (data && c.claim_id) { + mountTraceExpander(li, { + data, claimId: c.claim_id, hypothesisEdges: hypothesisEdges || [] + }); + } } if (row.claims.length > MAX_CLAIMS_PER_ROW) { li.appendChild(el('div', 'xr-inspector__mono', `… +${row.claims.length - MAX_CLAIMS_PER_ROW} more claim(s)`)); diff --git a/src/portal/index.css b/src/portal/index.css index 62d145b..823e424 100644 --- a/src/portal/index.css +++ b/src/portal/index.css @@ -1111,3 +1111,10 @@ button.xr-badge--case { cursor: pointer; background: transparent; } font-size: 12.5px; min-width: 180px; } + +/* --- Trace dependencies (26 CF.2) — the per-claim structural + counterfactual expander. Plain lines; nothing here encodes a + judgment. */ +.xr-trace { margin: 4px 0 8px; } +.xr-trace__modes { display: flex; gap: 6px; margin: 6px 0; } +.xr-trace__line { margin: 6px 0; font-size: 12.5px; } diff --git a/src/portal/trace-block.js b/src/portal/trace-block.js new file mode 100644 index 0000000..4f738bb --- /dev/null +++ b/src/portal/trace-block.js @@ -0,0 +1,70 @@ +// Trace-dependencies expander — Phase 26 CF.2 +// (docs/COUNTERFACTUAL_DESIGN.md §3, resolved §7: inline in the case +// view as a per-claim expander). A native
that, on first +// open, runs the PURE traceClaimDependencies over the already- +// collected case data and renders `traceLines` 1:1 — plain counts +// with their derivations on the face. A Remove/Negate mode toggle +// recomputes; nothing is persisted, nothing is estimated, and the +// copy is guard-tested in shared/case-counterfactual.js's traceLines +// (no "more/less likely", no "stronger/weaker", no "% chance"). + +import { el } from './dom.js'; +import { traceClaimDependencies, traceLines, COUNTERFACTUAL_MODES } from '../shared/case-counterfactual.js'; +import { Utils } from '../shared/utils.js'; + +export const TRACE_SUMMARY = 'Trace dependencies'; +export const TRACE_EXPLAINER = + 'What in this case\'s graph structurally rests on this claim — counts with their ' + + 'derivations, measured over the local graph. Not a probability of anything.'; +export const TRACE_MODE_LABELS = { remove: 'If removed', negate: 'If negated' }; + +/** + * Mount the expander for one claim. `data` is the shared collector + * envelope; `hypothesisEdges` the optional pre-collected join rows + * (collectHypothesisEdgeJoins). Computation is lazy — nothing runs + * until the reader opens the expander. + */ +export function mountTraceExpander(host, { data, claimId, hypothesisEdges = [] }) { + if (!data || !claimId) return; + const sec = el('details', 'xr-synth__sec xr-trace'); + sec.appendChild(el('summary', null, TRACE_SUMMARY)); + const body = el('div'); + sec.appendChild(body); + host.appendChild(sec); + + let mode = 'remove'; + let opened = false; + + const render = () => { + body.replaceChildren(); + body.appendChild(el('div', 'xr-case__explainer', TRACE_EXPLAINER)); + const toggles = el('div', 'xr-trace__modes'); + for (const m of COUNTERFACTUAL_MODES) { + const b = el('button', + `xr-portal__btn${m === mode ? '' : ' xr-portal__btn--ghost'}`, + TRACE_MODE_LABELS[m]); + b.type = 'button'; + b.addEventListener('click', () => { if (m !== mode) { mode = m; render(); } }); + toggles.appendChild(b); + } + body.appendChild(toggles); + try { + const delta = traceClaimDependencies(data, claimId, { mode, hypothesisEdges }); + for (const line of traceLines(delta, { claimsById: data.claimsById || {} })) { + const row = el('div', 'xr-trace__line'); + row.appendChild(el('div', null, line.text)); + if (line.derivation) { + row.appendChild(el('div', 'xr-inspector__mono', line.derivation)); + } + body.appendChild(row); + } + } catch (err) { + Utils.error('Trace dependencies failed', err); + body.appendChild(el('div', 'xr-inspector__mono', 'Trace failed — see console.')); + } + }; + + sec.addEventListener('toggle', () => { + if (sec.open && !opened) { opened = true; render(); } + }); +} diff --git a/src/shared/case-counterfactual.js b/src/shared/case-counterfactual.js index 995f650..7e52f4d 100644 --- a/src/shared/case-counterfactual.js +++ b/src/shared/case-counterfactual.js @@ -228,6 +228,83 @@ function diffHypotheses(hypothesisEdges, ref, mode) { return { count: deltas.length, derivation: deltas }; } +/** + * CF.2 — the plain-list rendering of a delta, as pure strings (the + * portal DOM layer projects them 1:1, and the copy-review guard test + * walks them: no "more/less likely", "stronger/weaker", "% chance" — + * §4). Each line: { text, derivation } — the derivation string names + * the specific edges/claims/events, always on the face. + * `claimsById` (optional) resolves loser refs to their text. + */ +export function traceLines(delta, { claimsById = {} } = {}) { + const lines = []; + const push = (text, derivation = null) => lines.push({ text, derivation }); + const negate = delta.mode === 'negate'; + const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`; + + for (const k of delta.knots.derivation) { + const fragText = k.fragments_after.length === 0 ? 'it dissolves' + : k.fragments_after.length > 1 + ? `it splits into fragments of ${k.fragments_after.map((f) => f.size).join(' + ')}` + : `it shrinks to ${k.fragments_after[0].size}`; + push(negate + ? `In a contradiction knot of ${k.size_before}: ${plural(k.derivation.length, 'edge flips', 'edges flip')} to concordance — ${fragText} as a knot` + : `A contradiction knot of ${k.size_before}: ${fragText}`, + `edges: ${k.derivation.join(', ')}`); + } + const losers = delta.support.claims_losing_only_support; + if (losers.count > 0) { + push(`Leaves ${plural(losers.count, 'claim', 'claims')} with no remaining support`, + losers.derivation.map((l) => { + const c = claimsById[l.ref]; + return c && c.text ? `${l.ref} — “${c.text.slice(0, 80)}”` : l.ref; + }).join(' · ')); + } + if (delta.support.links_removed.count > 0) { + push(negate + ? `${plural(delta.support.links_removed.count, 'typed link', 'typed links')} no longer carry support` + : `Removes ${plural(delta.support.links_removed.count, 'typed link', 'typed links')}`, + delta.support.links_removed.derivation.map((l) => `${l.link_id} (${l.relationship})`).join(' · ')); + } + const own = delta.propositions.own; + if (own.count > 0) { + push(negate + ? `Its ${plural(own.count, 'verdict chain now rests', 'verdict chains now rest')} on a negated claim — no verdict is recomputed; that call stays with the verdict layer` + : `Its ${plural(own.count, 'proposition falls', 'propositions fall')}, taking ${plural(own.count, 'its verdict chain', 'their verdict chains')}`, + own.derivation.map((p) => `${p.proposition_id} (${p.proposition_class}, chain length ${p.verdict_chain_length})`).join(' · ')); + } + for (const d of delta.propositions.attestation_deltas) { + push(`Proposition ${d.proposition_id}: attestation origins ${d.origin_count_before}→${d.origin_count_after}` + + ` (demonstrably independent ${d.independent_before}→${d.independent_after})`, + `removed: ${d.derivation.removed_link_ids.join(', ')}` + + (d.derivation.surviving_origin_groups.length + ? ` · surviving origins: ${d.derivation.surviving_origin_groups.map((g) => g.origin_key).join(', ')}` : '')); + } + const gone = delta.entities.losing_only_claim; + if (gone.count > 0) { + push(`${plural(gone.count, 'entity loses', 'entities lose')} their only claim in this case`, + gone.derivation.map((e) => e.name || e.entity_id).join(' · ')); + } + if (delta.timeline.events_removed.count > 0) { + push(`Removes ${plural(delta.timeline.events_removed.count, 'timeline event', 'timeline events')}`, + delta.timeline.events_removed.derivation.map((e) => `${e.axis}/${e.kind}`).join(' · ')); + } + if (delta.timeline.axes_emptied.count > 0) { + push(`Empties the ${delta.timeline.axes_emptied.derivation.join(' and ')} ${delta.timeline.axes_emptied.count === 1 ? 'axis' : 'axes'} entirely`, + null); + } + for (const h of delta.hypotheses.derivation) { + push(negate + ? `Hypothesis “${h.label}”: ${plural(h.supports_affected + h.undermines_affected, 'attachment role flips', 'attachment roles flip')} (supports ↔ undermines)` + : `Hypothesis “${h.label}”: loses ${h.supports_affected} supporting and ${h.undermines_affected} undermining ${h.supports_affected + h.undermines_affected === 1 ? 'attachment' : 'attachments'}`, + `edges: ${h.derivation.join(', ')}`); + } + if (lines.length === 0) { + push('No structural dependencies found — nothing else in the case graph rests on this claim.', null); + } + return lines; +} + /** * The structural delta for one claim. `data` is the * `collectCaseDossierData` envelope (with the CF.1 `links.related` diff --git a/src/shared/hypothesis-map.js b/src/shared/hypothesis-map.js index 4efc7c6..a0a97eb 100644 --- a/src/shared/hypothesis-map.js +++ b/src/shared/hypothesis-map.js @@ -342,3 +342,22 @@ export async function assembleHypothesisMap(caseEntityId, options = {}) { const input = await collectHypothesisMapData(caseEntityId, options); return buildHypothesisMap(input, options.generatedAt ?? null); } + +/** + * The hypothesis-edge join rows the structural counterfactual consumes + * (CF.1 `options.hypothesisEdges`): one storage read of both models, + * refs re-canonicalized, labels resolved. + */ +export async function collectHypothesisEdgeJoins(caseEntityId) { + const hypotheses = await HypothesisModel.getForCase(caseEntityId); + const labelById = new Map(hypotheses.map((h) => [h.id, h.label])); + const edges = await HypothesisEdgeModel.getForCase(caseEntityId); + const canon = await makeClaimRefCanonicalizer(); + return edges.map((e) => ({ + hypothesis_id: e.hypothesis_id, + label: labelById.get(e.hypothesis_id) || e.hypothesis_id, + ref: canon(e.claim_ref), + role: e.role, + edge_id: e.id + })); +} diff --git a/tests/case-counterfactual.test.mjs b/tests/case-counterfactual.test.mjs index bf3d629..081a8fa 100644 --- a/tests/case-counterfactual.test.mjs +++ b/tests/case-counterfactual.test.mjs @@ -16,7 +16,7 @@ globalThis.chrome = globalThis.chrome || { storage: { local: { get(_k, cb) { cb({}); }, set(_o, cb) { cb && cb(); }, remove(_k, cb) { cb && cb(); } } } }; -const { traceClaimDependencies, COUNTERFACTUAL_MODES } = +const { traceClaimDependencies, traceLines, COUNTERFACTUAL_MODES } = await import('../src/shared/case-counterfactual.js'); const CASE_ID = 'entity_00000000000000aa'; @@ -238,6 +238,59 @@ test('case-counterfactual: deterministic — same inputs deepEqual', () => { assert.deepEqual(a, b); }); +// ------------------------------------------------------------------ +// traceLines (CF.2 — the plain-list render source) +// ------------------------------------------------------------------ + +const RICH_EDGES = [ + { hypothesis_id: 'hyp_1', label: 'Zoonotic', ref: C1, role: 'supports', edge_id: 'hedge_1' }, + { hypothesis_id: 'hyp_2', label: 'Lab', ref: C1, role: 'undermines', edge_id: 'hedge_2' } +]; + +test('case-counterfactual: traceLines states every delta with its derivation on the face', () => { + const data = makeData(); + const delta = traceClaimDependencies(data, C1, { mode: 'remove', hypothesisEdges: RICH_EDGES }); + const lines = traceLines(delta, { claimsById: data.claimsById }); + const texts = lines.map((l) => l.text); + assert.ok(texts.some((t) => /contradiction knot of 3/.test(t))); + assert.ok(texts.some((t) => /Leaves 1 claim with no remaining support/.test(t))); + assert.ok(texts.some((t) => /attestation origins 2→1/.test(t))); + assert.ok(texts.some((t) => /Hypothesis “Zoonotic”: loses 1 supporting and 0 undermining/.test(t))); + const losers = lines.find((l) => /no remaining support/.test(l.text)); + assert.match(losers.derivation, /claim_00000000000000c3 — “Claim claim_00000000000000c3”/); + const knot = lines.find((l) => /contradiction knot/.test(l.text)); + assert.match(knot.derivation, /link_00000000000000k1/); +}); + +test('case-counterfactual: traceLines negate wording — flips, rests-on, no removal of the claim itself', () => { + const delta = traceClaimDependencies(makeData(), C2, { mode: 'negate', hypothesisEdges: RICH_EDGES }); + const texts = traceLines(delta).map((l) => l.text); + assert.ok(texts.some((t) => /edges flip to concordance/.test(t))); + assert.ok(!texts.some((t) => /Removes .* timeline/.test(t)), 'negate keeps the claim dated and placed'); +}); + +test('case-counterfactual: traceLines honest zero — an untouched claim says so', () => { + const lines = traceLines(traceClaimDependencies(makeData(), 'claim_00000000000000ff', { mode: 'remove' })); + assert.equal(lines.length, 1); + assert.match(lines[0].text, /No structural dependencies found/); +}); + +test('case-counterfactual: §4 copy guard — no likelihood/strength/percent phrasing in ANY line, either mode', () => { + const banned = /more likely|less likely|stronger|weaker|% chance|\d+\s*%|probabilit|confidence|score\b|winner/i; + for (const mode of COUNTERFACTUAL_MODES) { + const data = makeData(); + for (const ref of [C1, C2, C4, 'claim_00000000000000ff']) { + const lines = traceLines( + traceClaimDependencies(data, ref, { mode, hypothesisEdges: RICH_EDGES }), + { claimsById: data.claimsById }); + for (const l of lines) { + assert.doesNotMatch(l.text, banned, `forbidden phrasing in "${l.text}"`); + if (l.derivation) assert.doesNotMatch(l.derivation, banned); + } + } + } +}); + test('case-counterfactual: §4 guard — banned keys nowhere, and EVERY numeric sits beside a derivation', () => { const hypothesisEdges = [ { hypothesis_id: 'hyp_1', label: 'Zoonotic', ref: C1, role: 'supports', edge_id: 'hedge_1' } From 494d7c5c106a86fc5aa29117f7f5393cc87fe511 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:12:38 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(smoke):=20Phase-26=20hypothesis=20map?= =?UTF-8?q?=20+=20counterfactual=20walk=20(P26.g=E2=80=93t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual checklist for the H.1–H.4 / CF.1–CF.2 surfaces: authoring idempotency, seed promotion, crux badges, verdict-chip context, the no-scoreboard read, the gated LLM suggestion with its disclosures and the unopposed check, and the trace expander's remove/negate/zero paths. Co-Authored-By: Claude Fable 5 --- docs/SMOKE_TEST.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 757bb4b..7bc1b6c 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -1312,6 +1312,30 @@ stored brief (run "Analyze corpus…" once). --- +## Phase 26 — Hypothesis map + structural counterfactual (H.1–H.4, CF.1–CF.2) + +Needs a case with claims; the LLM steps (P26.n–q) additionally need +`caseSynthesis` + `llmAssist` flags on and an API key. + +| # | Step | Expected | +|---|---|---| +| P26.g | Case dashboard on a case WITH claims but no hypotheses | ✅ a "Hypotheses — competing answers, not a ranking" block renders with the explainer and an **Add hypothesis…** button; a claimless case shows no block at all | +| P26.h | **Add hypothesis…** → label + statement → Add | ✅ the card appears after reload; re-adding the same label (any casing/spacing) lands on the SAME record, never a duplicate | +| P26.i | **Attach claim…** on a card → pick claim + Supports → Attach; repeat with Undermines on another card | ✅ the claim appears under "Supporting evidence (N)" / "Undermining evidence (N)"; the SAME claim under two hypotheses gets a **crux** badge in both places | +| P26.j | Run corpus synthesis first (Phase 20.4), then reopen the case | ✅ the brief's positions appear as seeded cards ("from brief" badge, holders as source links); attaching a claim to a seed PROMOTES it (survives brief regeneration) | +| P26.k | A claim with a truth verdict (Phase 15) attached as an edge | ✅ a verdict chip ("Event fact: Contested" etc.) renders beside the claim with the tooltip "…does not weight the edge"; no chip for unruled propositions | +| P26.l | Read the whole block with 2+ hypotheses of very different edge counts | ✅ NO cross-hypothesis comparison anywhere — no "leading", no totals compared, no bars/meters; section counts appear only inside each hypothesis's own sections | +| P26.m | **✕ detach** an edge; **✕ delete hypothesis** on a persisted card | ✅ detach removes just the edge; delete confirms with the attachment count and removes the card + its edges | +| P26.n | **Suggest edges (LLM)…** (flags + key on) | ✅ spend-confirm names the claim + hypothesis counts and "one call"; without the key the button is disabled with an Options hint; with `caseSynthesis` off the button is absent | +| P26.o | Run the suggestion | ✅ a disclosure line reports "N quotes checked · M ungrounded (dropped) · K rejected · J proposals"; each proposal shows role, hypothesis ← claim, quote, rationale, **Accept** / **Dismiss** | +| P26.p | Accept one proposal, then **Refresh map** | ✅ the edge lands with an `llm:` badge; nothing applied without the click | +| P26.q | Read the unopposed disclosure (when present) | ✅ hypotheses with zero undermining scrutiny are named: "…treat their support as unexamined, not established" | +| P26.r | Evidence block ▸ any claim row ▸ **Trace dependencies** | ✅ expands to "If removed / If negated" with plain count lines, each carrying its derivation (link/edge/proposition ids); the explainer ends "Not a probability of anything." | +| P26.s | Trace a claim inside a contradiction knot | ✅ remove mode reports the knot dissolving/splitting/shrinking with the removed edge ids; negate mode says the edges "flip to concordance" and reports zero entity/timeline change | +| P26.t | Trace a claim nothing depends on | ✅ "No structural dependencies found — nothing else in the case graph rests on this claim." | + +--- + ## Reporting For each defect found: