Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/SMOKE_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<model>` 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:
Expand Down
6 changes: 6 additions & 0 deletions src/portal/case-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'} · `
Expand All @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion src/portal/evidence-block.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -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)`));
Expand Down
7 changes: 7 additions & 0 deletions src/portal/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
70 changes: 70 additions & 0 deletions src/portal/trace-block.js
Original file line number Diff line number Diff line change
@@ -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 <details> 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(); }
});
}
77 changes: 77 additions & 0 deletions src/shared/case-counterfactual.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
19 changes: 19 additions & 0 deletions src/shared/hypothesis-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}));
}
55 changes: 54 additions & 1 deletion tests/case-counterfactual.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' }
Expand Down