From f397ecdfb09a0f542a5877ff58c5bf218167ae84 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:14:40 -0700 Subject: [PATCH 1/2] feat(hypothesis-map): pure model + assembler (26 H.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 26 slice H.1 (docs/HYPOTHESIS_MAP_DESIGN.md §2–§3): - src/shared/hypothesis-model.js — two chrome.storage.local maps (case_hypotheses, hypothesis_edges) on the EvidenceLinker pattern: canonical claim refs with drift-tolerant matching, deterministic content-hashed ids (label is hypothesis identity), idempotent create, immutable structural fields, suggested_by provenance (user/llm/nostr), supports|undermines role vocabulary local to the module (undermines deliberately NOT in CLAIM_RELATIONSHIPS), no wire-publish fields until H.5 is a decision. - src/shared/hypothesis-map.js — the case-dossier split: storage-aware collector + pure buildHypothesisMap, generatedAt injected. Seeds hypotheses from brief positions (article-hash holders carried as provenance), merges persisted records by normalized label, groups edges by role with per-section counts, detects shared/opposing-edge crux claims (never netted), stamps verdict chain-head chips as context, and discloses dangling edges (P6). Scope question reads authored_fields.scope_question directly (dossier.scope is never populated — substrate note C1). - Reader claim-delete cascades hypothesis edges beside the EvidenceLinker hook, with blast-radius disclosure. - 27 tests incl. the §6 grep guard (no weight/score/probability/ confidence/strength key anywhere in the assembled map, no allowlist) and a determinism deepEqual. Co-Authored-By: Claude Fable 5 --- docs/JOURNAL.md | 26 +++ src/reader/index.js | 6 + src/shared/hypothesis-map.js | 315 ++++++++++++++++++++++++++ src/shared/hypothesis-model.js | 386 ++++++++++++++++++++++++++++++++ tests/hypothesis-map.test.mjs | 357 +++++++++++++++++++++++++++++ tests/hypothesis-model.test.mjs | 257 +++++++++++++++++++++ 6 files changed, 1347 insertions(+) create mode 100644 src/shared/hypothesis-map.js create mode 100644 src/shared/hypothesis-model.js create mode 100644 tests/hypothesis-map.test.mjs create mode 100644 tests/hypothesis-model.test.mjs diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 77f4b73..001527c 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -19,6 +19,32 @@ or files, and the "so-what" for future readers. --- +## 2026-07-16 — Hypothesis identity is the label; case delete doesn't cascade the map (26 H.1) + +Tags: `design`. + +Two H.1 choices a contributor might second-guess +(`src/shared/hypothesis-model.js`, `hypothesis-map.js`): + +**Label-derived ids + normalized-label merge.** A hypothesis id hashes +`(case_id | normalized label)` — not the statement — and the map +builder merges brief-seeded positions with persisted records by +normalized label. Position labels are already the join key inside the +brief itself (`cruxes[].sides[].position_label`), and label-as-identity +lets a seed and its later human promotion converge on one record +without persisting anything at seed time. Cost: labels are immutable +(rename = delete + re-create), stated in the module header. + +**Case delete leaves the map in place.** The sidepanel's entity delete +preserves dependents everywhere else (claims keep `about` refs, briefs +stay in `xray-audits`), and case entity ids are name-derived — so a +deleted-then-recreated case reattaches its hypotheses. Claim delete +DOES cascade edges (the reader flow calls +`HypothesisEdgeModel.deleteForClaim` beside the EvidenceLinker hook); +`deleteForCase` exists as the explicit clear-the-map seam for H.3 UI. + +--- + ## 2026-07-16 — xray-network stays out of WORKSPACE_DATABASES (25.2b) Tags: `design`. diff --git a/src/reader/index.js b/src/reader/index.js index 005e9e5..963f957 100644 --- a/src/reader/index.js +++ b/src/reader/index.js @@ -20,6 +20,7 @@ import { recordAccount, extractPostAuthor } from '../shared/identity/account-reg import { selectAccountsToPublish } from '../shared/identity/account-publish.js'; import { ClaimModel, exactFromAnchor } from '../shared/claim-model.js'; import { EvidenceLinker } from '../shared/evidence-linker.js'; +import { HypothesisEdgeModel } from '../shared/hypothesis-model.js'; import * as ArchiveCache from '../shared/archive-cache.js'; import { recordAlias, resolveAlias } from '../shared/url-aliases.js'; import { installEntityTagger, rehydrateEntityMarks, renderEntitiesBar, extractParagraphContext } from './entity-tagger.js'; @@ -2605,6 +2606,7 @@ async function confirmDeleteClaim(id) { // user sees the blast radius before confirming. const links = await EvidenceLinker.getForClaim(id); const assessment = await AssessmentModel.getByClaimRef(id); + const hypothesisEdges = await HypothesisEdgeModel.getForClaim(id); const lines = []; if (claim.publishedAt) { lines.push('Already-published kind-30040 stays on relays until NIP-09 delete (later phase).'); @@ -2615,6 +2617,9 @@ async function confirmDeleteClaim(id) { if (assessment) { lines.push('Your assessment of it will also be removed.'); } + if (hypothesisEdges.length > 0) { + lines.push(`${hypothesisEdges.length} hypothesis attachment${hypothesisEdges.length === 1 ? '' : 's'} will also be removed.`); + } const msg = lines.length > 0 ? `Delete claim? ${lines.join(' ')}` : 'Delete claim?'; @@ -2623,6 +2628,7 @@ async function confirmDeleteClaim(id) { // registry, so it must still see the claim while matching. if (links.length > 0) await EvidenceLinker.deleteForClaim(id); if (assessment) await AssessmentModel.delete(assessment.id); + if (hypothesisEdges.length > 0) await HypothesisEdgeModel.deleteForClaim(id); // 13.6's dependent: a prediction promoted into this claim holds a // claim_ref — left dangling it defers the 30058 at every publish // forever and the audit panel shows a permanent "claim ✓". diff --git a/src/shared/hypothesis-map.js b/src/shared/hypothesis-map.js new file mode 100644 index 0000000..006604d --- /dev/null +++ b/src/shared/hypothesis-map.js @@ -0,0 +1,315 @@ +// Hypothesis map — Phase 26 H.1, the map assembler +// (docs/HYPOTHESIS_MAP_DESIGN.md §2–§3). +// +// DERIVED, COMPUTED ON READ — no new wire kind, nothing persisted +// here, no publish path, no relay fetch. The case-dossier split: a +// storage-aware collector (`collectHypothesisMapData`) and a pure +// builder (`buildHypothesisMap`), `generatedAt` injected, no clock +// read in this module. +// +// The map is structure, not judgment (§6): +// - hypotheses render side by side; ORDER IS NOT RANK (brief +// position order, then creation order); +// - every number is a section size whose derivation is the sibling +// list it counts; nothing is weighted, fused, or cross-compared — +// the no-scoreboard rule lives in the RENDER guard (H.2), and no +// weight/score/probability/confidence/strength key exists in this +// output (grep-tested); +// - a claim supporting one hypothesis and undermining another is +// surfaced as a shared/crux claim, never netted; +// - a verdict-state chip beside an edge is CONTEXT: the maintainer +// decision (§8) is show-with-note — a verdict never weights or +// filters an edge; +// - dangling references (an edge whose hypothesis vanished, a ref +// with no local claim and no snapshot) are disclosed, never +// silently dropped (P6). +// +// Seeding (§2 "Where the pieces map on"): synthesis brief positions +// become seed hypotheses — label + core_argument as the statement, +// article-hash `holders` carried as provenance (holders are ARTICLE +// level; claim edges are only ever human-drawn or human-accepted). +// A persisted hypothesis whose normalized label matches a position +// merges with it (the persisted statement wins; the seed contributes +// holders). Seed-only hypotheses get a `seed:` id — edges can only +// reference PERSISTED hypotheses, so the two id spaces never collide. +// +// The scope question reads from the case entity's +// `authored_fields.scope_question.value` — deliberately NOT +// `dossier.scope` (never populated; see substrate note C1). + +import { getCaseBrief } from './audit/audit-cache.js'; +import { collectCaseDossierData } from './case-dossier.js'; +import { HypothesisModel, HypothesisEdgeModel, normalizeHypothesisLabel } from './hypothesis-model.js'; +import { makeClaimRefCanonicalizer } from './claim-ref.js'; + +// ------------------------------------------------------------------ +// Gather (storage-aware) +// ------------------------------------------------------------------ + +/** + * Collect everything the pure builder needs for one case's map. + * + * options: + * data — an already-collected `collectCaseDossierData` envelope + * (the case view collects it once; `?? ` live read) + * brief — the stored case-brief record ({ caseId, brief, ... } + * or null); defaults to the live IDB read + * hypotheses — injected hypothesis records (`??` model read) + * edges — injected edge records (`??` model read); each edge's + * stored ref is re-canonicalized here (canonicality is + * time-dependent) and stamped as `ref` + */ +export async function collectHypothesisMapData(caseEntityId, options = {}) { + const data = options.data ?? await collectCaseDossierData(caseEntityId, options.dossierOptions || {}); + const briefRecord = options.brief !== undefined + ? options.brief + : (await getCaseBrief(caseEntityId) || null); + const hypotheses = options.hypotheses ?? await HypothesisModel.getForCase(caseEntityId); + const rawEdges = options.edges ?? await HypothesisEdgeModel.getForCase(caseEntityId); + const canon = await makeClaimRefCanonicalizer(); + const edges = rawEdges.map((e) => ({ ...e, ref: canon(e.claim_ref) })); + return { data, brief: briefRecord, hypotheses, edges }; +} + +// ------------------------------------------------------------------ +// Pure builders +// ------------------------------------------------------------------ + +/** The case's scope question, from the entity's authored fields. */ +function readScopeQuestion(data) { + const entity = (data.entitiesById || {})[data.case && data.case.id] || null; + const field = entity && entity.authored_fields && entity.authored_fields.scope_question; + const text = field && field.value ? String(field.value).trim() : ''; + return { text, provenance: text ? 'authored' : null }; +} + +/** article_hash → { url, title } over the archive records that ride the envelope. */ +function articleIndex(data) { + const byHash = new Map(); + for (const rec of data.articles || []) { + const hash = rec.articleHash || (rec.article && rec.article.canonicalHash) || null; + if (!hash || byHash.has(hash)) continue; + byHash.set(hash, { + url: rec.url || null, + title: (rec.article && rec.article.title) || null + }); + } + return byHash; +} + +/** + * Verdict-state chips for one local claim: its propositions joined to + * each chain's ACTIVE verdict (`find !superseded_by` — the + * getActiveForProposition idiom). `state: null` = unruled. Chips are + * CONTEXT beside an edge; nothing downstream may weight or filter on + * them (§8 decision). + */ +function verdictChipsForClaim(data, claimId) { + const out = []; + const all = (data.propositions && data.propositions.all) || {}; + const byProposition = (data.verdicts && data.verdicts.byProposition) || {}; + const props = Object.values(all) + .filter((p) => p.claim_id === claimId) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + for (const p of props) { + const chain = byProposition[p.id] || []; + const head = chain.find((v) => !v.superseded_by) || null; + out.push({ + proposition_id: p.id, + proposition_class: p.proposition_class, + state: head ? head.verdict : null + }); + } + return out; +} + +/** Resolve an edge's claim for display: local registry first, snapshot fallback. */ +function resolveEdgeClaim(data, edge) { + const claim = (data.claimsById || {})[edge.ref] || null; + if (claim) { + return { + local: true, + text: claim.text || '', + url: claim.source_url || null, + is_key: !!claim.is_key + }; + } + if (edge.claim_snapshot && (edge.claim_snapshot.text || edge.claim_snapshot.url)) { + return { + local: false, + text: edge.claim_snapshot.text || '', + url: edge.claim_snapshot.url_raw || edge.claim_snapshot.url || null, + is_key: false + }; + } + return null; +} + +function edgeView(data, edge, orbitClaimIds) { + return { + edge_id: edge.id, + ref: edge.ref, + role: edge.role, + note: edge.note || '', + suggested_by: edge.suggested_by || 'user', + quote: edge.quote || null, + article_hash: edge.article_hash || null, + claim: resolveEdgeClaim(data, edge), + in_orbit: orbitClaimIds.has(edge.ref), + verdicts: verdictChipsForClaim(data, edge.ref) + }; +} + +/** + * Build the hypothesis map. `input` is the collector envelope + * ({ data, brief, hypotheses, edges }); `generatedAt` is injected. + * + * Output shape (§2, all sizes are section counts whose derivation is + * the sibling list): + * + * { case, generated_at, question, + * hypotheses: [{ id, label, statement, note, suggested_by, + * persisted, seeded, core_argument, holders, + * edges: { supports: [...], undermines: [...] }, + * coverage: { supports, undermines } }], + * shared_claims: [{ ref, claim, entries: [{hypothesis_id, role}], + * opposing }], + * dangling: { edges: [...] }, + * coverage: { hypotheses, seeded, persisted, edges, supports, + * undermines, claims, shared_claims, opposing_claims, + * dangling_edges } } + */ +export function buildHypothesisMap(input, generatedAt = null) { + const { data } = input; + const brief = (input.brief && input.brief.brief) || null; + const persisted = input.hypotheses || []; + const edges = input.edges || []; + + const articles = articleIndex(data); + const orbitClaimIds = new Set(((data.orbit && data.orbit.claims) || []).map((c) => c.id)); + + // ---- hypotheses: seeds (brief position order) merged with + // persisted records (normalized-label join), then + // persisted-only rows in creation order. Order is NOT rank. + const persistedByLabel = new Map(); + for (const h of persisted) persistedByLabel.set(normalizeHypothesisLabel(h.label), h); + + const rows = []; + const consumed = new Set(); + for (const pos of (brief && brief.positions) || []) { + const label = String(pos.label || '').trim(); + if (!label) continue; + const norm = normalizeHypothesisLabel(label); + if (rows.some((r) => normalizeHypothesisLabel(r.label) === norm)) continue; + const match = persistedByLabel.get(norm) || null; + if (match) consumed.add(match.id); + rows.push({ + id: match ? match.id : `seed:${norm}`, + label: match ? match.label : label, + statement: match && match.statement + ? match.statement + : String(pos.core_argument || '').trim() || label, + note: match ? (match.note || '') : '', + suggested_by: match ? match.suggested_by : 'seed:brief', + persisted: !!match, + seeded: true, + core_argument: String(pos.core_argument || '').trim() || null, + holders: (pos.holders || []).map((h) => ({ + article_hash: h.article_hash, + url: (articles.get(h.article_hash) || {}).url || null, + title: (articles.get(h.article_hash) || {}).title || null + })) + }); + } + for (const h of persisted) { + if (consumed.has(h.id)) continue; + rows.push({ + id: h.id, + label: h.label, + statement: h.statement || h.label, + note: h.note || '', + suggested_by: h.suggested_by || 'user', + persisted: true, + seeded: false, + core_argument: null, + holders: [] + }); + } + + // ---- edges grouped under their hypothesis by role; an edge whose + // hypothesis is not in the map is DISCLOSED, never dropped. + const rowById = new Map(rows.map((r) => [r.id, r])); + for (const r of rows) r.edges = { supports: [], undermines: [] }; + const danglingEdges = []; + for (const e of edges) { + const view = edgeView(data, e, orbitClaimIds); + const row = rowById.get(e.hypothesis_id); + if (row && (e.role === 'supports' || e.role === 'undermines')) { + row.edges[e.role].push(view); + } else { + danglingEdges.push({ ...view, hypothesis_id: e.hypothesis_id }); + } + } + for (const r of rows) { + r.coverage = { + supports: r.edges.supports.length, + undermines: r.edges.undermines.length + }; + } + + // ---- shared claims: one claim edged under 2+ hypotheses. When + // the roles diverge (supports somewhere, undermines + // elsewhere) it is a CRUX made legible — flagged, not netted. + const byRef = new Map(); + for (const r of rows) { + for (const role of ['supports', 'undermines']) { + for (const v of r.edges[role]) { + if (!byRef.has(v.ref)) byRef.set(v.ref, { claim: v.claim, entries: [] }); + byRef.get(v.ref).entries.push({ hypothesis_id: r.id, role }); + } + } + } + const sharedClaims = []; + for (const [ref, agg] of byRef) { + const hypIds = new Set(agg.entries.map((x) => x.hypothesis_id)); + if (hypIds.size < 2) continue; + const roles = new Set(agg.entries.map((x) => x.role)); + sharedClaims.push({ + ref, + claim: agg.claim, + entries: agg.entries, + opposing: roles.has('supports') && roles.has('undermines') + }); + } + sharedClaims.sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0)); + + const edgeCount = rows.reduce( + (n, r) => n + r.edges.supports.length + r.edges.undermines.length, 0); + + return { + case: { id: data.case.id, name: data.case.name }, + generated_at: generatedAt, + question: readScopeQuestion(data), + hypotheses: rows, + shared_claims: sharedClaims, + dangling: { edges: danglingEdges }, + coverage: { + hypotheses: rows.length, + seeded: rows.filter((r) => r.seeded).length, + persisted: rows.filter((r) => r.persisted).length, + edges: edgeCount, + supports: rows.reduce((n, r) => n + r.edges.supports.length, 0), + undermines: rows.reduce((n, r) => n + r.edges.undermines.length, 0), + claims: byRef.size, + shared_claims: sharedClaims.length, + opposing_claims: sharedClaims.filter((s) => s.opposing).length, + dangling_edges: danglingEdges.length + } + }; +} + +/** Collect + build. `options.generatedAt` injected for determinism. */ +export async function assembleHypothesisMap(caseEntityId, options = {}) { + const input = await collectHypothesisMapData(caseEntityId, options); + return buildHypothesisMap(input, options.generatedAt ?? null); +} diff --git a/src/shared/hypothesis-model.js b/src/shared/hypothesis-model.js new file mode 100644 index 0000000..b3006e0 --- /dev/null +++ b/src/shared/hypothesis-model.js @@ -0,0 +1,386 @@ +// Hypothesis map storage — Phase 26 H.1 (docs/HYPOTHESIS_MAP_DESIGN.md +// §2). Two chrome.storage.local maps: +// +// - `case_hypotheses` — competing answers to a case's scope question +// - `hypothesis_edges` — claim→hypothesis attachments, each carrying +// a role: `supports` or `undermines` +// +// The EvidenceLinker pattern, deliberately in a SEPARATE store: +// `undermines` is not in CLAIM_RELATIONSHIPS and must not leak into +// the claim↔claim link vocabulary. Edges take CANONICAL claim refs +// (claim-ref.js); matchers canonicalize both the stored side and the +// query side at read time. Provenance rides in `suggested_by` +// ('user' | 'llm:' | 'nostr:', the repo-wide seam) — +// the design doc's `provenance` field, under the established name. +// +// Constitution guards (§2, §6): NO weight / score / probability / +// confidence / strength field exists on a hypothesis or an edge — a +// grep test pins this. A claim may support one hypothesis and +// undermine another; nothing here nets that out. NO wire kind: no +// publishedAt / publishedEventId fields until H.5 is a decision. +// +// A hypothesis label is IDENTITY: the id hashes (case_id | normalized +// label), mirroring claim-id derivation, so a brief-seeded hypothesis +// and its later persisted promotion converge on the same record. The +// statement and note are editable; the label and case are not — to +// reframe an answer, delete and re-create. +// +// Cascade posture: deleting a CLAIM removes its edges (the reader's +// claim-delete flow calls deleteForClaim, alongside +// EvidenceLinker.deleteForClaim). Deleting a CASE entity deliberately +// does NOT cascade — the sidepanel's entity delete preserves dependent +// data (claims keep their `about` refs, briefs stay), and case ids are +// name-derived, so re-creating the case reattaches its map. +// `deleteForCase` is the explicit clear-the-map seam for the H.3 UI. + +import { Storage } from './storage.js'; +import { Crypto } from './crypto.js'; +import { Utils } from './utils.js'; +import { ClaimModel } from './claim-model.js'; +import { normalize as normalizeUrl } from './metadata/url-normalizer.js'; +import { isValidSuggestedBy } from './assessment-taxonomy.js'; +import { + isLocalClaimId, parseClaimCoord, assertValidClaimRef, + canonicalizeClaimRef, makeClaimRefCanonicalizer +} from './claim-ref.js'; + +// ------------------------------------------------------------------ +// Enums +// ------------------------------------------------------------------ + +export const HYPOTHESIS_EDGE_ROLES = Object.freeze(['supports', 'undermines']); + +export const HYPOTHESIS_EDGE_ROLE_LABELS = { + supports: 'Supports', + undermines: 'Undermines' +}; + +export const HYPOTHESIS_EDGE_ROLE_ICONS = { + supports: '↗', + undermines: '↯' +}; + +// ------------------------------------------------------------------ +// ID derivation +// ------------------------------------------------------------------ + +/** Label normalization for identity: trim, collapse spaces, lowercase. */ +export function normalizeHypothesisLabel(label) { + return String(label || '').trim().replace(/\s+/g, ' ').toLowerCase(); +} + +export async function generateHypothesisId(caseId, label) { + const key = `${String(caseId || '').trim()}|hypothesis|${normalizeHypothesisLabel(label)}`; + const hash = await Crypto.sha256(key); + return `hyp_${hash.slice(0, 16)}`; +} + +/** + * Deterministic edge id from (hypothesis, canonical ref, role) — the + * role is in the hash, so a supports and an undermines attachment of + * the same claim to the same hypothesis are distinct records (the map + * renders that tension; it never resolves it). + */ +export async function generateHypothesisEdgeId(hypothesisId, claimRef, role) { + const key = `${String(hypothesisId || '')}|${String(claimRef || '')}|${String(role || '')}`; + const hash = await Crypto.sha256(key); + return `hedge_${hash.slice(0, 16)}`; +} + +// ------------------------------------------------------------------ +// Validation +// ------------------------------------------------------------------ + +function assertValidRole(role) { + if (!HYPOTHESIS_EDGE_ROLES.includes(role)) { + throw new Error(`Invalid edge role: ${role} (expected one of ${HYPOTHESIS_EDGE_ROLES.join(', ')})`); + } +} + +function assertValidSuggestedBy(value) { + const v = value === undefined || value === null ? 'user' : value; + if (!isValidSuggestedBy(v)) { + throw new Error(`Invalid suggested_by: ${v} (expected 'user', 'llm:' or 'nostr:')`); + } + return v; +} + +function assertCaseId(caseId) { + const trimmed = String(caseId || '').trim(); + if (!trimmed) throw new Error('case_id is required'); + return trimmed; +} + +/** + * Endpoint snapshot for the edged claim — caller-supplied for foreign + * refs, auto-filled from the claim registry for local ones (the + * evidence-linker resolveSnapshot pattern). Null when neither source + * has it; renderers must tolerate that. + */ +async function resolveClaimSnapshot(ref, given) { + if (given && (given.url || given.text)) { + const coord = parseClaimCoord(ref); + const rawUrl = given.url ? String(given.url) : ''; + return { + url: rawUrl ? normalizeUrl(rawUrl) : '', + url_raw: given.url_raw || rawUrl, + text: String(given.text || ''), + author_pubkey: given.author_pubkey || (coord ? coord.pubkey : null) + }; + } + if (isLocalClaimId(ref)) { + const claim = await ClaimModel.get(ref); + if (claim) { + return { + url: normalizeUrl(claim.source_url), + url_raw: claim.source_url || '', + text: claim.text, + author_pubkey: claim.publishedPubkey || null + }; + } + } + return null; +} + +// ------------------------------------------------------------------ +// CRUD — hypotheses +// ------------------------------------------------------------------ + +export const HypothesisModel = { + get: async (id) => { + if (!id) return null; + const all = await Storage.get('case_hypotheses', {}); + return all[id] || null; + }, + + getAll: async () => Storage.get('case_hypotheses', {}), + + /** Hypotheses for one case, oldest-first then id (presentation order is NOT rank). */ + getForCase: async (caseId) => { + const all = await Storage.get('case_hypotheses', {}); + return Object.values(all) + .filter((h) => h.case_id === caseId) + .sort((a, b) => (a.created || 0) - (b.created || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + }, + + /** + * Create a hypothesis. Idempotent on (case_id, normalized label) — + * the id derivation — so promoting a brief-seeded hypothesis twice, + * or re-accepting an LLM suggestion, lands on one record. + */ + create: async ({ case_id, label, statement, note, suggested_by }) => { + const caseId = assertCaseId(case_id); + const cleanLabel = String(label || '').trim(); + if (!cleanLabel) throw new Error('hypothesis label is required'); + + const id = await generateHypothesisId(caseId, cleanLabel); + const all = await Storage.get('case_hypotheses', {}); + if (all[id]) return all[id]; + + const now = Math.floor(Date.now() / 1000); + const record = { + id, + case_id: caseId, + label: cleanLabel, + statement: String(statement || '').trim(), + note: note || '', + suggested_by: assertValidSuggestedBy(suggested_by), + created: now, + updated: now + }; + all[id] = record; + await Storage.set('case_hypotheses', all); + Utils.log('Created hypothesis:', id, cleanLabel); + return record; + }, + + /** + * Patch a hypothesis. `label` and `case_id` are IMMUTABLE — they + * derive the id. Editable in place: `statement`, `note`. + */ + update: async (id, updates) => { + const all = await Storage.get('case_hypotheses', {}); + const record = all[id]; + if (!record) throw new Error(`Hypothesis not found: ${id}`); + const patched = { ...record }; + if ('statement' in updates) patched.statement = String(updates.statement || '').trim(); + if ('note' in updates) patched.note = updates.note || ''; + patched.updated = Math.floor(Date.now() / 1000); + all[id] = patched; + await Storage.set('case_hypotheses', all); + return patched; + }, + + /** Delete a hypothesis AND its edges (edges never orphaned). */ + delete: async (id) => { + const all = await Storage.get('case_hypotheses', {}); + if (!all[id]) return false; + delete all[id]; + await Storage.set('case_hypotheses', all); + const edges = await Storage.get('hypothesis_edges', {}); + let touched = false; + for (const [eid, edge] of Object.entries(edges)) { + if (edge.hypothesis_id === id) { delete edges[eid]; touched = true; } + } + if (touched) await Storage.set('hypothesis_edges', edges); + return true; + }, + + /** Remove every hypothesis (and edge) for a case — the case-delete hook. */ + deleteForCase: async (caseId) => { + const all = await Storage.get('case_hypotheses', {}); + const doomed = new Set( + Object.values(all).filter((h) => h.case_id === caseId).map((h) => h.id)); + if (doomed.size === 0) return 0; + for (const id of doomed) delete all[id]; + await Storage.set('case_hypotheses', all); + const edges = await Storage.get('hypothesis_edges', {}); + let touched = false; + for (const [eid, edge] of Object.entries(edges)) { + if (doomed.has(edge.hypothesis_id)) { delete edges[eid]; touched = true; } + } + if (touched) await Storage.set('hypothesis_edges', edges); + return doomed.size; + } +}; + +// ------------------------------------------------------------------ +// CRUD — edges +// ------------------------------------------------------------------ + +export const HypothesisEdgeModel = { + get: async (id) => { + if (!id) return null; + const all = await Storage.get('hypothesis_edges', {}); + return all[id] || null; + }, + + getAll: async () => Storage.get('hypothesis_edges', {}), + + /** Edges on one hypothesis, oldest-first then id. */ + getForHypothesis: async (hypothesisId) => { + const all = await Storage.get('hypothesis_edges', {}); + return Object.values(all) + .filter((e) => e.hypothesis_id === hypothesisId) + .sort((a, b) => (a.created || 0) - (b.created || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + }, + + /** Edges for one case, via its hypotheses. */ + getForCase: async (caseId) => { + const hyps = await HypothesisModel.getForCase(caseId); + const ids = new Set(hyps.map((h) => h.id)); + const all = await Storage.get('hypothesis_edges', {}); + return Object.values(all) + .filter((e) => ids.has(e.hypothesis_id)) + .sort((a, b) => (a.created || 0) - (b.created || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + }, + + /** + * Every edge referencing the claim, by either representation — + * stored refs are canonicalized too, so drifted refs still match. + */ + getForClaim: async (ref) => { + if (!ref) return []; + const canonical = await canonicalizeClaimRef(ref); + const canon = await makeClaimRefCanonicalizer(); + const all = await Storage.get('hypothesis_edges', {}); + return Object.values(all) + .filter((e) => canon(e.claim_ref) === canonical) + .sort((a, b) => (a.created || 0) - (b.created || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + }, + + /** + * Attach a claim to a hypothesis. Idempotent on the canonical + * (hypothesis, ref, role) triple, with the drift-tolerant dedupe + * pass. `quote` / `article_hash` carry the grounded span when the + * edge came from synthesis (design §2) — optional, verbatim. + */ + create: async ({ hypothesis_id, claim_ref, role, note, suggested_by, + quote, article_hash, claim_snapshot }) => { + const hyp = await HypothesisModel.get(hypothesis_id); + if (!hyp) throw new Error(`Hypothesis not found: ${hypothesis_id}`); + assertValidClaimRef(claim_ref, 'claim_ref'); + assertValidRole(role); + + const ref = await canonicalizeClaimRef(claim_ref, 'claim_ref'); + const id = await generateHypothesisEdgeId(hypothesis_id, ref, role); + const all = await Storage.get('hypothesis_edges', {}); + if (all[id]) return all[id]; + // Drift dedupe: a stored ref whose canonicality has since + // changed derives a different id for the same logical edge. + { + const canon = await makeClaimRefCanonicalizer(); + for (const edge of Object.values(all)) { + if (edge.hypothesis_id === hypothesis_id + && edge.role === role + && canon(edge.claim_ref) === ref) { + return edge; + } + } + } + + const now = Math.floor(Date.now() / 1000); + const record = { + id, + hypothesis_id, + claim_ref: ref, + role, + note: note || '', + suggested_by: assertValidSuggestedBy(suggested_by), + quote: quote ? String(quote) : null, + article_hash: article_hash || null, + claim_snapshot: await resolveClaimSnapshot(ref, claim_snapshot), + created: now, + updated: now + }; + all[id] = record; + await Storage.set('hypothesis_edges', all); + Utils.log('Created hypothesis edge:', id, role); + return record; + }, + + /** + * Patch an edge. Hypothesis / ref / role are IMMUTABLE — they + * derive the id. Editable in place: `note`. + */ + update: async (id, updates) => { + const all = await Storage.get('hypothesis_edges', {}); + const record = all[id]; + if (!record) throw new Error(`Hypothesis edge not found: ${id}`); + const patched = { ...record }; + if ('note' in updates) patched.note = updates.note || ''; + patched.updated = Math.floor(Date.now() / 1000); + all[id] = patched; + await Storage.set('hypothesis_edges', all); + return patched; + }, + + delete: async (id) => { + const all = await Storage.get('hypothesis_edges', {}); + if (!all[id]) return false; + delete all[id]; + await Storage.set('hypothesis_edges', all); + return true; + }, + + /** + * Delete every edge referencing the claim (either representation) — + * for the claim-delete flow, alongside EvidenceLinker.deleteForClaim. + * Returns the number removed. + */ + deleteForClaim: async (ref) => { + if (!ref) return 0; + const canonical = await canonicalizeClaimRef(ref); + const canon = await makeClaimRefCanonicalizer(); + const all = await Storage.get('hypothesis_edges', {}); + let removed = 0; + for (const [id, edge] of Object.entries(all)) { + if (canon(edge.claim_ref) === canonical) { + delete all[id]; + removed++; + } + } + if (removed > 0) await Storage.set('hypothesis_edges', all); + return removed; + } +}; diff --git a/tests/hypothesis-map.test.mjs b/tests/hypothesis-map.test.mjs new file mode 100644 index 0000000..f298d68 --- /dev/null +++ b/tests/hypothesis-map.test.mjs @@ -0,0 +1,357 @@ +// Hypothesis map assembler tests — Phase 26 H.1 +// (docs/HYPOTHESIS_MAP_DESIGN.md §2–§3, §6). Pure builder over +// hand-built `collectCaseDossierData`-shaped data (the case-graph +// fixture pattern) plus injected brief/hypotheses/edges; the collector +// is exercised with injected data so no fake-indexeddb is needed. +// Load-bearing invariants: seed↔persisted merge on normalized label, +// order is presentation not rank, roles never netted, opposing-edge +// crux detection, dangling disclosure (P6), verdict chips are +// chain-head context only, determinism, and the §6 grep guard — +// NO weight/score/probability/confidence/strength key anywhere, +// no allowlist. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const _stateStore = new Map(); +globalThis.chrome = { + storage: { + local: { + get(keys, cb) { + const out = {}; + for (const k of Array.isArray(keys) ? keys : [keys]) { + if (_stateStore.has(k)) out[k] = _stateStore.get(k); + } + cb(out); + }, + set(obj, cb) { + for (const [k, v] of Object.entries(obj)) _stateStore.set(k, v); + cb && cb(); + }, + remove(keys, cb) { + for (const k of Array.isArray(keys) ? keys : [keys]) _stateStore.delete(k); + cb && cb(); + } + } + } +}; + +const { + buildHypothesisMap, collectHypothesisMapData +} = await import('../src/shared/hypothesis-map.js'); +const { HypothesisModel, HypothesisEdgeModel } = await import('../src/shared/hypothesis-model.js'); +const { ClaimModel } = await import('../src/shared/claim-model.js'); + +function resetState() { _stateStore.clear(); } + +const GENERATED = '2026-07-16T12:00:00.000Z'; +const HASH_A = 'a'.repeat(64); +const HASH_B = 'b'.repeat(64); +const PUBKEY_F = 'f'.repeat(64); +const CASE_ID = 'entity_00000000000000aa'; + +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 + }; +} + +// Minimal collectCaseDossierData-shaped envelope (the case-graph +// fixture pattern), extended with the authored scope question. +function makeData(over = {}) { + const c1 = makeClaim('claim_00000000000000c1', { is_key: true }); + 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 the outbreak 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: {}, orbit: [] }, + verdicts: { byProposition: {} }, + integrity: [], integrityAll: [], forensic: [], + links: { contradicts: [], attestations: [] }, + wire: { verdicts: [], findings: [], articles: [] }, + ...over + }; +} + +function makeBrief(positions, over = {}) { + return { caseId: CASE_ID, brief: { summary: 's', positions, ...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 + }; +} + +const build = (input, at = GENERATED) => buildHypothesisMap({ + data: makeData(), brief: null, hypotheses: [], edges: [], ...input +}, at); + +// ------------------------------------------------------------------ + +test('hypothesis-map: empty case — zero-count sections and the authored question, not errors', () => { + const map = build({}); + assert.equal(map.question.text, 'Where did the outbreak begin?'); + assert.equal(map.question.provenance, 'authored'); + assert.deepEqual(map.hypotheses, []); + assert.deepEqual(map.shared_claims, []); + assert.deepEqual(map.dangling.edges, []); + assert.equal(map.coverage.hypotheses, 0); + assert.equal(map.coverage.edges, 0); + assert.equal(map.generated_at, GENERATED); +}); + +test('hypothesis-map: missing scope question is empty with null provenance — never fabricated', () => { + const data = makeData(); + delete data.entitiesById[CASE_ID].authored_fields; + const map = build({ data }); + assert.deepEqual(map.question, { text: '', provenance: null }); +}); + +test('hypothesis-map: brief positions seed hypotheses in brief order; holders join the archive honestly', () => { + const map = build({ + brief: makeBrief([ + { label: 'Zoonotic', core_argument: 'Spillover at the market.', + holders: [{ article_hash: HASH_A }, { article_hash: HASH_B }] }, + { label: 'Lab origin', holders: [] } + ]) + }); + assert.equal(map.hypotheses.length, 2); + const [z, l] = map.hypotheses; + assert.equal(z.id, 'seed:zoonotic'); + assert.equal(z.statement, 'Spillover at the market.'); + assert.equal(z.suggested_by, 'seed:brief'); + assert.equal(z.persisted, false); + assert.equal(z.seeded, true); + // Resolved holder carries url+title; the unresolved hash is KEPT + // with nulls (P6/P4: never dropped, never fabricated). + assert.deepEqual(z.holders, [ + { article_hash: HASH_A, url: 'https://x/a', title: 'Article A' }, + { article_hash: HASH_B, url: null, title: null } + ]); + assert.equal(l.statement, 'Lab origin', 'statement falls back to the label'); + assert.equal(map.coverage.seeded, 2); + assert.equal(map.coverage.persisted, 0); +}); + +test('hypothesis-map: a persisted hypothesis merges with its seed on normalized label — statement wins, holders ride', () => { + const persisted = hyp('hyp_00000000000000ab', 'ZOONOTIC ', { + statement: 'My sharper framing.', suggested_by: 'user' + }); + const later = hyp('hyp_00000000000000cd', 'Cold chain', { created: 70 }); + const map = build({ + brief: makeBrief([ + { label: 'Zoonotic', core_argument: 'Spillover.', holders: [{ article_hash: HASH_A }] } + ]), + hypotheses: [persisted, later] + }); + assert.equal(map.hypotheses.length, 2); + const [merged, tail] = map.hypotheses; + assert.equal(merged.id, 'hyp_00000000000000ab'); + assert.equal(merged.statement, 'My sharper framing.'); + assert.equal(merged.persisted, true); + assert.equal(merged.seeded, true); + assert.equal(merged.core_argument, 'Spillover.'); + assert.equal(merged.holders.length, 1); + assert.equal(tail.id, 'hyp_00000000000000cd', 'persisted-only rows follow the seeds in creation order'); + assert.equal(tail.seeded, false); + assert.equal(map.coverage.persisted, 2); +}); + +test('hypothesis-map: edges group under their hypothesis by role; section sizes match their lists', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const map = build({ + hypotheses: [h1], + edges: [ + edge('hedge_1', h1.id, 'claim_00000000000000c1', 'supports'), + edge('hedge_2', h1.id, 'claim_00000000000000c2', 'supports'), + edge('hedge_3', h1.id, 'claim_00000000000000c2', 'undermines') + ] + }); + const row = map.hypotheses[0]; + assert.equal(row.edges.supports.length, 2); + assert.equal(row.edges.undermines.length, 1); + assert.deepEqual(row.coverage, { supports: 2, undermines: 1 }); + assert.equal(map.coverage.edges, 3); + assert.equal(map.coverage.supports, 2); + assert.equal(map.coverage.undermines, 1); + assert.equal(map.coverage.claims, 2); + const sup = row.edges.supports[0]; + assert.equal(sup.claim.local, true); + assert.equal(sup.claim.is_key, true); + assert.equal(sup.in_orbit, true); +}); + +test('hypothesis-map: an edge to a vanished hypothesis is disclosed as dangling, never dropped', () => { + const map = build({ + hypotheses: [hyp('hyp_00000000000000ab', 'Zoonotic')], + edges: [edge('hedge_9', 'hyp_gone', 'claim_00000000000000c1', 'supports')] + }); + assert.equal(map.dangling.edges.length, 1); + assert.equal(map.dangling.edges[0].hypothesis_id, 'hyp_gone'); + assert.equal(map.coverage.dangling_edges, 1); + assert.equal(map.coverage.edges, 0, 'dangling edges are not counted as attached'); +}); + +test('hypothesis-map: foreign edge renders from its snapshot; unknown claim with no snapshot is null', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const coord = `30040:${PUBKEY_F}:their-claim`; + const map = build({ + hypotheses: [h1], + edges: [ + edge('hedge_1', h1.id, coord, 'supports', { + claim_snapshot: { url: 'https://foreign.example/x', url_raw: 'https://foreign.example/x', text: 'their claim', author_pubkey: PUBKEY_F } + }), + edge('hedge_2', h1.id, `30040:${PUBKEY_F}:bare`, 'undermines') + ] + }); + const [sup] = map.hypotheses[0].edges.supports; + assert.equal(sup.claim.local, false); + assert.equal(sup.claim.text, 'their claim'); + assert.equal(sup.in_orbit, false); + assert.equal(map.hypotheses[0].edges.undermines[0].claim, null, 'renderers must tolerate null'); +}); + +test('hypothesis-map: verdict chips are the chain head (find !superseded_by), unruled is null — context only', () => { + const c1 = 'claim_00000000000000c1'; + const data = makeData({ + propositions: { all: { + prop_1: { id: 'prop_1', claim_id: c1, proposition_class: 'event-fact' }, + prop_2: { id: 'prop_2', claim_id: c1, proposition_class: 'causal' } + }, orbit: [] }, + verdicts: { byProposition: { + prop_1: [ + { id: 'v1', proposition_id: 'prop_1', verdict: 'contested', superseded_by: 'v2' }, + { id: 'v2', proposition_id: 'prop_1', verdict: 'established-true', superseded_by: null } + ] + } } + }); + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const map = build({ data, hypotheses: [h1], edges: [edge('hedge_1', h1.id, c1, 'supports')] }); + const chips = map.hypotheses[0].edges.supports[0].verdicts; + assert.deepEqual(chips, [ + { proposition_id: 'prop_1', proposition_class: 'event-fact', state: 'established-true' }, + { proposition_id: 'prop_2', proposition_class: 'causal', state: null } + ]); +}); + +test('hypothesis-map: a claim under opposing roles across hypotheses is a crux; same-role sharing is not', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const h2 = hyp('hyp_00000000000000cd', 'Lab origin', { created: 55 }); + const map = build({ + hypotheses: [h1, h2], + edges: [ + edge('hedge_1', h1.id, 'claim_00000000000000c1', 'supports'), + edge('hedge_2', h2.id, 'claim_00000000000000c1', 'undermines'), + edge('hedge_3', h1.id, 'claim_00000000000000c2', 'supports'), + edge('hedge_4', h2.id, 'claim_00000000000000c2', 'supports') + ] + }); + assert.equal(map.shared_claims.length, 2); + const opposing = map.shared_claims.find((s) => s.ref === 'claim_00000000000000c1'); + assert.equal(opposing.opposing, true); + assert.deepEqual(opposing.entries, [ + { hypothesis_id: h1.id, role: 'supports' }, + { hypothesis_id: h2.id, role: 'undermines' } + ]); + assert.equal(map.shared_claims.find((s) => s.ref === 'claim_00000000000000c2').opposing, false); + assert.equal(map.coverage.shared_claims, 2); + assert.equal(map.coverage.opposing_claims, 1); +}); + +test('hypothesis-map: both roles on ONE hypothesis stay visible in its own sections — not a shared claim', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const map = build({ + hypotheses: [h1], + edges: [ + edge('hedge_1', h1.id, 'claim_00000000000000c1', 'supports'), + edge('hedge_2', h1.id, 'claim_00000000000000c1', 'undermines') + ] + }); + assert.deepEqual(map.hypotheses[0].coverage, { supports: 1, undermines: 1 }); + assert.deepEqual(map.shared_claims, [], 'sharing means 2+ hypotheses'); +}); + +test('hypothesis-map: deterministic — same inputs deepEqual', () => { + const input = { + brief: makeBrief([{ label: 'Zoonotic', holders: [{ article_hash: HASH_A }] }]), + hypotheses: [hyp('hyp_00000000000000cd', 'Lab origin')], + edges: [edge('hedge_1', 'hyp_00000000000000cd', 'claim_00000000000000c1', 'supports')] + }; + assert.deepEqual(build(input), build(input)); +}); + +test('hypothesis-map: §6 grep guard — no fused-number key anywhere, no allowlist', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const h2 = hyp('hyp_00000000000000cd', 'Lab origin'); + const map = build({ + brief: makeBrief([{ label: 'Zoonotic', core_argument: 'x', holders: [{ article_hash: HASH_A }] }]), + hypotheses: [h1, h2], + edges: [ + edge('hedge_1', h1.id, 'claim_00000000000000c1', 'supports'), + edge('hedge_2', h2.id, 'claim_00000000000000c1', 'undermines'), + edge('hedge_3', 'hyp_gone', 'claim_00000000000000c2', 'supports') + ] + }); + const banned = /weight|score|probabilit|confidence|strength|rating|grade|likelihood|mean/i; + const walk = (node, path) => { + if (Array.isArray(node)) { node.forEach((v, i) => walk(v, `${path}[${i}]`)); return; } + if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) { + if (banned.test(k)) assert.fail(`forbidden fused-number key at ${path}.${k}`); + walk(v, `${path}.${k}`); + } + } + }; + walk(map, '$'); +}); + +// ------------------------------------------------------------------ +// Collector (storage-aware; data + brief injected, models live) +// ------------------------------------------------------------------ + +test('hypothesis-map: collector reads the models and re-canonicalizes stored edge refs at read time', async () => { + resetState(); + const h = await HypothesisModel.create({ case_id: CASE_ID, label: 'Zoonotic' }); + const claim = await ClaimModel.create({ + text: 'The first cluster centered on the market.', + source_url: 'https://example.com/report', about: [CASE_ID] + }); + // Edge stored under a coordinate that only later becomes collapsible. + const coord = `30040:${PUBKEY_F}:${claim.id}`; + await HypothesisEdgeModel.create({ + hypothesis_id: h.id, claim_ref: coord, role: 'supports', + claim_snapshot: { url: 'https://example.com/report', text: 'their view' } + }); + await ClaimModel.markPublished(claim.id, 'e'.repeat(64), PUBKEY_F); + + const data = makeData({ claimsById: { [claim.id]: claim }, orbit: { entity_ids: [], entities: [], dangling_entity_ids: [], claims: [claim] } }); + const input = await collectHypothesisMapData(CASE_ID, { data, brief: null }); + assert.equal(input.edges.length, 1); + assert.equal(input.edges[0].claim_ref, coord, 'stored ref untouched'); + assert.equal(input.edges[0].ref, claim.id, 'canonicalized at read time'); + + const map = buildHypothesisMap(input, GENERATED); + assert.equal(map.hypotheses[0].edges.supports[0].claim.local, true, 'drifted ref reaches the local claim'); +}); diff --git a/tests/hypothesis-model.test.mjs b/tests/hypothesis-model.test.mjs new file mode 100644 index 0000000..753e55b --- /dev/null +++ b/tests/hypothesis-model.test.mjs @@ -0,0 +1,257 @@ +// Hypothesis map storage tests — Phase 26 H.1 +// (docs/HYPOTHESIS_MAP_DESIGN.md §2). Same chrome.storage.local shim +// pattern as case-dossier.test.mjs. The load-bearing invariants: +// deterministic label-derived ids (idempotent create), immutable +// structural fields, canonical-ref matching with drift tolerance, +// cascade deletes (edges never orphaned), and NO score-bearing or +// wire-publish field on either record. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const _stateStore = new Map(); +globalThis.chrome = { + storage: { + local: { + get(keys, cb) { + const out = {}; + for (const k of Array.isArray(keys) ? keys : [keys]) { + if (_stateStore.has(k)) out[k] = _stateStore.get(k); + } + cb(out); + }, + set(obj, cb) { + for (const [k, v] of Object.entries(obj)) _stateStore.set(k, v); + cb && cb(); + }, + remove(keys, cb) { + for (const k of Array.isArray(keys) ? keys : [keys]) _stateStore.delete(k); + cb && cb(); + } + } + } +}; + +const { + HypothesisModel, HypothesisEdgeModel, + HYPOTHESIS_EDGE_ROLES, generateHypothesisId, normalizeHypothesisLabel +} = await import('../src/shared/hypothesis-model.js'); +const { ClaimModel } = await import('../src/shared/claim-model.js'); +const { buildClaimCoord } = await import('../src/shared/claim-ref.js'); + +function resetState() { _stateStore.clear(); } + +const CASE_ID = 'entity_00000000000000aa'; +const PUBKEY_F = 'f'.repeat(64); + +async function seedHypothesis(over = {}) { + return HypothesisModel.create({ + case_id: CASE_ID, label: 'Zoonotic origin', + statement: 'The outbreak began with an animal spillover.', + ...over + }); +} + +async function seedClaim(over = {}) { + return ClaimModel.create({ + text: 'The first cluster centered on the market.', + source_url: 'https://example.com/report', + about: [CASE_ID], + ...over + }); +} + +// ------------------------------------------------------------------ +// Hypotheses +// ------------------------------------------------------------------ + +test('hypothesis-model: create derives a deterministic label id; idempotent across label spacing/case', async () => { + resetState(); + const first = await seedHypothesis(); + assert.match(first.id, /^hyp_[0-9a-f]{16}$/); + assert.equal(first.id, await generateHypothesisId(CASE_ID, 'Zoonotic origin')); + const again = await seedHypothesis({ label: ' zoonotic ORIGIN ', statement: 'Different text' }); + assert.equal(again.id, first.id); + assert.equal(again.statement, first.statement, 'idempotent create returns the existing record'); + assert.equal(Object.keys(await HypothesisModel.getAll()).length, 1); +}); + +test('hypothesis-model: case_id and label are required; suggested_by is validated', async () => { + resetState(); + await assert.rejects(() => HypothesisModel.create({ label: 'X' }), /case_id is required/); + await assert.rejects(() => HypothesisModel.create({ case_id: CASE_ID, label: ' ' }), /label is required/); + await assert.rejects(() => seedHypothesis({ suggested_by: 'bogus' }), /Invalid suggested_by/); + assert.equal((await seedHypothesis()).suggested_by, 'user'); + assert.equal((await seedHypothesis({ label: 'B', suggested_by: 'llm:claude-x' })).suggested_by, 'llm:claude-x'); + assert.equal((await seedHypothesis({ label: 'C', suggested_by: `nostr:${PUBKEY_F}` })).suggested_by, `nostr:${PUBKEY_F}`); +}); + +test('hypothesis-model: update patches statement/note only — label and case are identity', async () => { + resetState(); + const h = await seedHypothesis(); + const patched = await HypothesisModel.update(h.id, { + statement: 'Sharper phrasing.', note: 'why', label: 'Renamed', case_id: 'entity_00000000000000bb' + }); + assert.equal(patched.statement, 'Sharper phrasing.'); + assert.equal(patched.note, 'why'); + assert.equal(patched.label, 'Zoonotic origin'); + assert.equal(patched.case_id, CASE_ID); + await assert.rejects(() => HypothesisModel.update('hyp_missing', {}), /not found/); +}); + +test('hypothesis-model: getForCase filters and orders (created, id) — presentation, not rank', async () => { + resetState(); + const a = await seedHypothesis({ label: 'A' }); + const b = await seedHypothesis({ label: 'B' }); + await seedHypothesis({ label: 'Other case', case_id: 'entity_00000000000000bb' }); + const list = await HypothesisModel.getForCase(CASE_ID); + assert.deepEqual(list.map((h) => h.id).sort(), [a.id, b.id].sort()); + assert.equal(list.length, 2); +}); + +test('hypothesis-model: no score-bearing and no wire-publish field on either record', async () => { + resetState(); + const h = await seedHypothesis(); + const c = await seedClaim(); + const e = await HypothesisEdgeModel.create({ + hypothesis_id: h.id, claim_ref: c.id, role: 'supports' + }); + const banned = /weight|score|probabilit|confidence|strength|rating|grade|likelihood/i; + for (const rec of [h, e]) { + for (const key of Object.keys(rec)) { + assert.doesNotMatch(key, banned, `forbidden key ${key}`); + } + assert.equal('publishedAt' in rec, false, 'no wire fields until H.5 is a decision'); + assert.equal('publishedEventId' in rec, false); + } +}); + +// ------------------------------------------------------------------ +// Edges +// ------------------------------------------------------------------ + +test('hypothesis-edge: roles pinned; create validates hypothesis, ref and role', async () => { + resetState(); + assert.deepEqual([...HYPOTHESIS_EDGE_ROLES], ['supports', 'undermines']); + const h = await seedHypothesis(); + const c = await seedClaim(); + await assert.rejects( + () => HypothesisEdgeModel.create({ hypothesis_id: 'hyp_missing', claim_ref: c.id, role: 'supports' }), + /Hypothesis not found/); + await assert.rejects( + () => HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: 'nonsense', role: 'supports' }), + /claim id or a 30040 coordinate/); + await assert.rejects( + () => HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'contradicts' }), + /Invalid edge role/); +}); + +test('hypothesis-edge: idempotent on (hypothesis, ref, role); roles stay distinct — never netted', async () => { + resetState(); + const h = await seedHypothesis(); + const c = await seedClaim(); + const sup = await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + const dup = await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports', note: 'x' }); + assert.equal(dup.id, sup.id); + assert.equal(dup.note, '', 'idempotent create returns the existing record'); + const und = await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'undermines' }); + assert.notEqual(und.id, sup.id, 'a supports and an undermines attachment coexist'); + assert.equal((await HypothesisEdgeModel.getForHypothesis(h.id)).length, 2); +}); + +test('hypothesis-edge: snapshot auto-fills from the local claim registry', async () => { + resetState(); + const h = await seedHypothesis(); + const c = await seedClaim(); + const e = await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + assert.equal(e.claim_snapshot.text, c.text); + assert.equal(e.claim_snapshot.url_raw, 'https://example.com/report'); +}); + +test('hypothesis-edge: foreign coordinate — caller snapshot kept, author pubkey backfilled', async () => { + resetState(); + const h = await seedHypothesis(); + const coord = buildClaimCoord(PUBKEY_F, 'claim_00000000000000ff'); + const e = await HypothesisEdgeModel.create({ + hypothesis_id: h.id, claim_ref: coord, role: 'undermines', + quote: 'verbatim span', article_hash: 'a'.repeat(64), + claim_snapshot: { url: 'https://foreign.example/x', text: 'their claim' } + }); + assert.equal(e.claim_ref, coord, 'unpublished-elsewhere coordinate stays canonical'); + assert.equal(e.claim_snapshot.author_pubkey, PUBKEY_F); + assert.equal(e.quote, 'verbatim span'); +}); + +test('hypothesis-edge: drift dedupe — a stored coordinate that later collapses still dedupes and matches', async () => { + resetState(); + const h = await seedHypothesis(); + const c = await seedClaim(); + const coord = buildClaimCoord(PUBKEY_F, c.id); + // Stored while the coordinate is still foreign-shaped (claim not + // yet published under that pubkey) — kept verbatim. + const before = await HypothesisEdgeModel.create({ + hypothesis_id: h.id, claim_ref: coord, role: 'supports', + claim_snapshot: { url: 'https://example.com/report', text: 'their view of it' } + }); + assert.equal(before.claim_ref, coord); + await ClaimModel.markPublished(c.id, 'e'.repeat(64), PUBKEY_F); + // Same logical edge via the now-canonical local id: the drift pass + // must find the stored record instead of minting a sibling. + const after = await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + assert.equal(after.id, before.id); + // And canonical-ref matching reaches it from either representation. + assert.equal((await HypothesisEdgeModel.getForClaim(c.id)).length, 1); + assert.equal((await HypothesisEdgeModel.getForClaim(coord)).length, 1); +}); + +test('hypothesis-edge: update patches note only; structural fields immutable', async () => { + resetState(); + const h = await seedHypothesis(); + const c = await seedClaim(); + const e = await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + const patched = await HypothesisEdgeModel.update(e.id, { note: 'because', role: 'undermines' }); + assert.equal(patched.note, 'because'); + assert.equal(patched.role, 'supports'); +}); + +// ------------------------------------------------------------------ +// Cascades +// ------------------------------------------------------------------ + +test('hypothesis-model: deleting a hypothesis cascades its edges', async () => { + resetState(); + const h = await seedHypothesis(); + const keep = await seedHypothesis({ label: 'Lab origin' }); + const c = await seedClaim(); + await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + const kept = await HypothesisEdgeModel.create({ hypothesis_id: keep.id, claim_ref: c.id, role: 'undermines' }); + assert.equal(await HypothesisModel.delete(h.id), true); + assert.equal(await HypothesisModel.get(h.id), null); + const remaining = Object.values(await HypothesisEdgeModel.getAll()); + assert.deepEqual(remaining.map((e) => e.id), [kept.id]); +}); + +test('hypothesis-model: deleteForCase removes the case\'s hypotheses and edges, nothing else', async () => { + resetState(); + const h = await seedHypothesis(); + const other = await seedHypothesis({ label: 'Elsewhere', case_id: 'entity_00000000000000bb' }); + const c = await seedClaim(); + await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + const otherEdge = await HypothesisEdgeModel.create({ hypothesis_id: other.id, claim_ref: c.id, role: 'supports' }); + assert.equal(await HypothesisModel.deleteForCase(CASE_ID), 1); + assert.equal(await HypothesisModel.get(h.id), null); + assert.ok(await HypothesisModel.get(other.id)); + assert.deepEqual(Object.keys(await HypothesisEdgeModel.getAll()), [otherEdge.id]); +}); + +test('hypothesis-edge: deleteForClaim removes edges by either representation', async () => { + resetState(); + const h = await seedHypothesis(); + const c = await seedClaim(); + await ClaimModel.markPublished(c.id, 'e'.repeat(64), PUBKEY_F); + await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); + await HypothesisEdgeModel.create({ hypothesis_id: h.id, claim_ref: c.id, role: 'undermines' }); + const removed = await HypothesisEdgeModel.deleteForClaim(buildClaimCoord(PUBKEY_F, c.id)); + assert.equal(removed, 2); + assert.deepEqual(await HypothesisEdgeModel.getForHypothesis(h.id), []); +}); From db87531a0c83420dfcd9960e7d229266f2ecf4ef Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:40:15 -0700 Subject: [PATCH 2/2] fix(hypothesis-map): apply adversarial-review findings (26 H.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed findings from the multi-agent review of the H.1 diff: - WORKSPACE_CLEAR_KEYS gains case_hypotheses + hypothesis_edges — authored structure, same class as evidence_links (the list's own contract: extend it when a new content store ships). Pin test updated. Backups need no change (backup.js carries all of storage.local minus exclusions). - Seed loop: two brief positions whose labels normalize equal now UNION into one hypothesis (holders deduped by hash, first core_argument kept) instead of silently dropping the later one; blank-label positions are disclosed via coverage.unlabeled_positions (P6). - Cascade deletes write dependents FIRST (edges, then the hypothesis record — the confirmDeleteClaim order), so a death between writes leaves a visible re-deletable state, never invisible orphan edges. - shared_claims keeps the first RESOLVABLE claim view — a null view from a snapshot-less edge no longer shadows a sibling's snapshot. - Tests: model grep guard now walks nested keys (claim_snapshot); getForCase order actually asserted (distinct created stamps); HypothesisEdgeModel.getForCase cross-case exclusion covered; duplicate/blank position labels covered; null-shadow covered; assembleHypothesisMap composition covered; the live stored-brief read covered via fake-indexeddb. Co-Authored-By: Claude Fable 5 --- src/shared/hypothesis-map.js | 73 ++++++++++++++++-------- src/shared/hypothesis-model.js | 21 +++++-- src/shared/identity-profiles.js | 7 ++- tests/hypothesis-map.test.mjs | 96 +++++++++++++++++++++++++++++--- tests/hypothesis-model.test.mjs | 35 ++++++++++-- tests/identity-profiles.test.mjs | 3 +- 6 files changed, 192 insertions(+), 43 deletions(-) diff --git a/src/shared/hypothesis-map.js b/src/shared/hypothesis-map.js index 006604d..4efc7c6 100644 --- a/src/shared/hypothesis-map.js +++ b/src/shared/hypothesis-map.js @@ -195,31 +195,54 @@ export function buildHypothesisMap(input, generatedAt = null) { for (const h of persisted) persistedByLabel.set(normalizeHypothesisLabel(h.label), h); const rows = []; + const rowByNorm = new Map(); const consumed = new Set(); + let unlabeledPositions = 0; for (const pos of (brief && brief.positions) || []) { const label = String(pos.label || '').trim(); - if (!label) continue; + const coreArgument = String(pos.core_argument || '').trim() || null; + const holders = (pos.holders || []).map((h) => ({ + article_hash: h.article_hash, + url: (articles.get(h.article_hash) || {}).url || null, + title: (articles.get(h.article_hash) || {}).title || null + })); + if (!label) { + // A position the brief emitted without a label cannot seed + // a hypothesis — disclosed as a count, never silently + // dropped (P6). Its holders have nothing to attach to. + unlabeledPositions++; + continue; + } const norm = normalizeHypothesisLabel(label); - if (rows.some((r) => normalizeHypothesisLabel(r.label) === norm)) continue; + // Two positions normalizing to one label (an LLM emitting + // 'Lab origin' / 'Lab Origin') are ONE hypothesis: union the + // holders, keep the first core_argument — nothing dropped. + const existing = rowByNorm.get(norm); + if (existing) { + const seen = new Set(existing.holders.map((h) => h.article_hash)); + for (const h of holders) { + if (!seen.has(h.article_hash)) { existing.holders.push(h); seen.add(h.article_hash); } + } + if (!existing.core_argument && coreArgument) existing.core_argument = coreArgument; + continue; + } const match = persistedByLabel.get(norm) || null; if (match) consumed.add(match.id); - rows.push({ + const row = { id: match ? match.id : `seed:${norm}`, label: match ? match.label : label, statement: match && match.statement ? match.statement - : String(pos.core_argument || '').trim() || label, + : coreArgument || label, note: match ? (match.note || '') : '', suggested_by: match ? match.suggested_by : 'seed:brief', persisted: !!match, seeded: true, - core_argument: String(pos.core_argument || '').trim() || null, - holders: (pos.holders || []).map((h) => ({ - article_hash: h.article_hash, - url: (articles.get(h.article_hash) || {}).url || null, - title: (articles.get(h.article_hash) || {}).title || null - })) - }); + core_argument: coreArgument, + holders + }; + rows.push(row); + rowByNorm.set(norm, row); } for (const h of persisted) { if (consumed.has(h.id)) continue; @@ -265,7 +288,12 @@ export function buildHypothesisMap(input, generatedAt = null) { for (const role of ['supports', 'undermines']) { for (const v of r.edges[role]) { if (!byRef.has(v.ref)) byRef.set(v.ref, { claim: v.claim, entries: [] }); - byRef.get(v.ref).entries.push({ hypothesis_id: r.id, role }); + const agg = byRef.get(v.ref); + // Sibling edges to one ref may differ in resolvability + // (one carries a snapshot, one doesn't) — keep the + // first resolvable view rather than a first-seen null. + if (!agg.claim && v.claim) agg.claim = v.claim; + agg.entries.push({ hypothesis_id: r.id, role }); } } } @@ -294,16 +322,17 @@ export function buildHypothesisMap(input, generatedAt = null) { shared_claims: sharedClaims, dangling: { edges: danglingEdges }, coverage: { - hypotheses: rows.length, - seeded: rows.filter((r) => r.seeded).length, - persisted: rows.filter((r) => r.persisted).length, - edges: edgeCount, - supports: rows.reduce((n, r) => n + r.edges.supports.length, 0), - undermines: rows.reduce((n, r) => n + r.edges.undermines.length, 0), - claims: byRef.size, - shared_claims: sharedClaims.length, - opposing_claims: sharedClaims.filter((s) => s.opposing).length, - dangling_edges: danglingEdges.length + hypotheses: rows.length, + seeded: rows.filter((r) => r.seeded).length, + persisted: rows.filter((r) => r.persisted).length, + edges: edgeCount, + supports: rows.reduce((n, r) => n + r.edges.supports.length, 0), + undermines: rows.reduce((n, r) => n + r.edges.undermines.length, 0), + claims: byRef.size, + shared_claims: sharedClaims.length, + opposing_claims: sharedClaims.filter((s) => s.opposing).length, + dangling_edges: danglingEdges.length, + unlabeled_positions: unlabeledPositions } }; } diff --git a/src/shared/hypothesis-model.js b/src/shared/hypothesis-model.js index b3006e0..637dbe1 100644 --- a/src/shared/hypothesis-model.js +++ b/src/shared/hypothesis-model.js @@ -211,35 +211,44 @@ export const HypothesisModel = { return patched; }, - /** Delete a hypothesis AND its edges (edges never orphaned). */ + /** + * Delete a hypothesis AND its edges. Dependents FIRST (the + * confirmDeleteClaim order): a death between the two writes then + * leaves a hypothesis with no edges — visible and re-deletable — + * never invisible orphaned edges. + */ delete: async (id) => { const all = await Storage.get('case_hypotheses', {}); if (!all[id]) return false; - delete all[id]; - await Storage.set('case_hypotheses', all); const edges = await Storage.get('hypothesis_edges', {}); let touched = false; for (const [eid, edge] of Object.entries(edges)) { if (edge.hypothesis_id === id) { delete edges[eid]; touched = true; } } if (touched) await Storage.set('hypothesis_edges', edges); + delete all[id]; + await Storage.set('case_hypotheses', all); return true; }, - /** Remove every hypothesis (and edge) for a case — the case-delete hook. */ + /** + * Remove every hypothesis (and edge) for a case — the explicit + * clear-the-map seam (H.3 UI). Edges first, same rationale as + * delete. + */ deleteForCase: async (caseId) => { const all = await Storage.get('case_hypotheses', {}); const doomed = new Set( Object.values(all).filter((h) => h.case_id === caseId).map((h) => h.id)); if (doomed.size === 0) return 0; - for (const id of doomed) delete all[id]; - await Storage.set('case_hypotheses', all); const edges = await Storage.get('hypothesis_edges', {}); let touched = false; for (const [eid, edge] of Object.entries(edges)) { if (doomed.has(edge.hypothesis_id)) { delete edges[eid]; touched = true; } } if (touched) await Storage.set('hypothesis_edges', edges); + for (const id of doomed) delete all[id]; + await Storage.set('case_hypotheses', all); return doomed.size; } }; diff --git a/src/shared/identity-profiles.js b/src/shared/identity-profiles.js index 8f8b691..27be722 100644 --- a/src/shared/identity-profiles.js +++ b/src/shared/identity-profiles.js @@ -58,9 +58,14 @@ export const WORKSPACE_CLEAR_KEYS = Object.freeze([ // workspace content explicitly 'incorporated_artifacts', // Phase 25.3 reviewed-in foreign // artifacts (incorporation.js) - 'incorporation_dismissals' // Phase 25.3 declined proposals — + 'incorporation_dismissals', // Phase 25.3 declined proposals — // judgments about network content, // same class as fact dismissals + 'case_hypotheses', // Phase 26 hypothesis records + // (hypothesis-model.js) + 'hypothesis_edges' // Phase 26 claim→hypothesis edges — + // authored structure, same class as + // evidence_links ]); // What reset deliberately KEEPS — configuration and identity. Exported diff --git a/tests/hypothesis-map.test.mjs b/tests/hypothesis-map.test.mjs index f298d68..fcfb4b9 100644 --- a/tests/hypothesis-map.test.mjs +++ b/tests/hypothesis-map.test.mjs @@ -1,15 +1,17 @@ // Hypothesis map assembler tests — Phase 26 H.1 // (docs/HYPOTHESIS_MAP_DESIGN.md §2–§3, §6). Pure builder over // hand-built `collectCaseDossierData`-shaped data (the case-graph -// fixture pattern) plus injected brief/hypotheses/edges; the collector -// is exercised with injected data so no fake-indexeddb is needed. -// Load-bearing invariants: seed↔persisted merge on normalized label, -// order is presentation not rank, roles never netted, opposing-edge -// crux detection, dangling disclosure (P6), verdict chips are -// chain-head context only, determinism, and the §6 grep guard — -// NO weight/score/probability/confidence/strength key anywhere, -// no allowlist. +// fixture pattern) plus injected brief/hypotheses/edges; the dossier +// data is always injected, and fake-indexeddb backs only the one +// live-brief collector test. Load-bearing invariants: seed↔persisted +// merge on normalized label (duplicate positions union, blank ones +// disclosed), order is presentation not rank, roles never netted, +// opposing-edge crux detection, dangling disclosure (P6), verdict +// chips are chain-head context only, determinism, and the §6 grep +// guard — NO weight/score/probability/confidence/strength key +// anywhere, no allowlist. +import 'fake-indexeddb/auto'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -155,6 +157,35 @@ test('hypothesis-map: brief positions seed hypotheses in brief order; holders jo assert.equal(map.coverage.persisted, 0); }); +test('hypothesis-map: duplicate-normalizing position labels union into ONE hypothesis — nothing dropped', () => { + const map = build({ + brief: makeBrief([ + { label: 'Lab Origin', holders: [{ article_hash: HASH_A }] }, + { label: ' lab origin ', core_argument: 'Second framing.', + holders: [{ article_hash: HASH_A }, { article_hash: HASH_B }] } + ]) + }); + assert.equal(map.hypotheses.length, 1); + const row = map.hypotheses[0]; + assert.equal(row.label, 'Lab Origin', 'first spelling wins'); + assert.deepEqual(row.holders.map((h) => h.article_hash), [HASH_A, HASH_B], + 'holders union, deduped by hash'); + assert.equal(row.core_argument, 'Second framing.', + 'a later core_argument fills an empty one'); + assert.equal(map.coverage.seeded, 1); +}); + +test('hypothesis-map: a blank-label position cannot seed — disclosed as a count, never silent (P6)', () => { + const map = build({ + brief: makeBrief([ + { label: 'Zoonotic', holders: [] }, + { label: ' ', core_argument: 'orphaned framing', holders: [{ article_hash: HASH_A }] } + ]) + }); + assert.equal(map.hypotheses.length, 1); + assert.equal(map.coverage.unlabeled_positions, 1); +}); + test('hypothesis-map: a persisted hypothesis merges with its seed on normalized label — statement wins, holders ride', () => { const persisted = hyp('hyp_00000000000000ab', 'ZOONOTIC ', { statement: 'My sharper framing.', suggested_by: 'user' @@ -293,6 +324,26 @@ test('hypothesis-map: both roles on ONE hypothesis stay visible in its own secti assert.deepEqual(map.shared_claims, [], 'sharing means 2+ hypotheses'); }); +test('hypothesis-map: a shared claim keeps the first RESOLVABLE view — a null never shadows a sibling snapshot', () => { + const h1 = hyp('hyp_00000000000000ab', 'Zoonotic'); + const h2 = hyp('hyp_00000000000000cd', 'Lab origin', { created: 55 }); + const coord = `30040:${PUBKEY_F}:their-claim`; + const map = build({ + hypotheses: [h1, h2], + edges: [ + // First-seen edge has NO snapshot (claim view null)… + edge('hedge_1', h1.id, coord, 'supports'), + // …the sibling on the other hypothesis carries one. + edge('hedge_2', h2.id, coord, 'undermines', { + claim_snapshot: { url: 'https://foreign.example/x', url_raw: 'https://foreign.example/x', text: 'their claim', author_pubkey: PUBKEY_F } + }) + ] + }); + const crux = map.shared_claims.find((s) => s.ref === coord); + assert.equal(crux.opposing, true); + assert.equal(crux.claim.text, 'their claim', 'the resolvable sibling view wins'); +}); + test('hypothesis-map: deterministic — same inputs deepEqual', () => { const input = { brief: makeBrief([{ label: 'Zoonotic', holders: [{ article_hash: HASH_A }] }]), @@ -355,3 +406,32 @@ test('hypothesis-map: collector reads the models and re-canonicalizes stored edg const map = buildHypothesisMap(input, GENERATED); assert.equal(map.hypotheses[0].edges.supports[0].claim.local, true, 'drifted ref reaches the local claim'); }); + +test('hypothesis-map: assembleHypothesisMap composes collect+build and forwards generatedAt', async () => { + resetState(); + await HypothesisModel.create({ case_id: CASE_ID, label: 'Zoonotic' }); + const { assembleHypothesisMap } = await import('../src/shared/hypothesis-map.js'); + const map = await assembleHypothesisMap(CASE_ID, { + data: makeData(), brief: null, generatedAt: GENERATED + }); + assert.equal(map.generated_at, GENERATED); + assert.equal(map.hypotheses.length, 1); + assert.equal(map.hypotheses[0].label, 'Zoonotic'); +}); + +test('hypothesis-map: collector reads a LIVE stored brief when none is injected (fake-indexeddb)', async () => { + resetState(); + const { saveCaseBrief } = await import('../src/shared/audit/audit-cache.js'); + // The exact record shape synthesis-block.js persists. + await saveCaseBrief({ + caseId: CASE_ID, + brief: { summary: 's', positions: [{ label: 'From storage', holders: [{ article_hash: HASH_A }] }] }, + grounding: { checked: 1, dropped: 0 }, inputHash: 'x', model: 'm', + promptVersion: 'corpus-v1', members: [], analyzed: 1, failed: 0, usage: {} + }); + const input = await collectHypothesisMapData(CASE_ID, { data: makeData() }); + const map = buildHypothesisMap(input, GENERATED); + assert.equal(map.hypotheses.length, 1); + assert.equal(map.hypotheses[0].label, 'From storage'); + assert.equal(map.hypotheses[0].holders[0].url, 'https://x/a'); +}); diff --git a/tests/hypothesis-model.test.mjs b/tests/hypothesis-model.test.mjs index 753e55b..e156152 100644 --- a/tests/hypothesis-model.test.mjs +++ b/tests/hypothesis-model.test.mjs @@ -104,9 +104,25 @@ test('hypothesis-model: getForCase filters and orders (created, id) — presenta const a = await seedHypothesis({ label: 'A' }); const b = await seedHypothesis({ label: 'B' }); await seedHypothesis({ label: 'Other case', case_id: 'entity_00000000000000bb' }); + // Same-second creations tie on `created` — set distinct stamps + // directly so the (created, id) comparator is actually asserted. + const all = await HypothesisModel.getAll(); + all[a.id].created = 200; + all[b.id].created = 100; + _stateStore.set('case_hypotheses', JSON.stringify(all)); const list = await HypothesisModel.getForCase(CASE_ID); - assert.deepEqual(list.map((h) => h.id).sort(), [a.id, b.id].sort()); - assert.equal(list.length, 2); + assert.deepEqual(list.map((h) => h.id), [b.id, a.id], 'oldest-first, regardless of insertion order'); +}); + +test('hypothesis-edge: getForCase joins via the case\'s hypotheses and excludes other cases', async () => { + resetState(); + const mine = await seedHypothesis({ label: 'Mine' }); + const theirs = await seedHypothesis({ label: 'Theirs', case_id: 'entity_00000000000000bb' }); + const c = await seedClaim(); + const kept = await HypothesisEdgeModel.create({ hypothesis_id: mine.id, claim_ref: c.id, role: 'supports' }); + await HypothesisEdgeModel.create({ hypothesis_id: theirs.id, claim_ref: c.id, role: 'supports' }); + const list = await HypothesisEdgeModel.getForCase(CASE_ID); + assert.deepEqual(list.map((e) => e.id), [kept.id]); }); test('hypothesis-model: no score-bearing and no wire-publish field on either record', async () => { @@ -117,10 +133,19 @@ test('hypothesis-model: no score-bearing and no wire-publish field on either rec hypothesis_id: h.id, claim_ref: c.id, role: 'supports' }); const banned = /weight|score|probabilit|confidence|strength|rating|grade|likelihood/i; - for (const rec of [h, e]) { - for (const key of Object.keys(rec)) { - assert.doesNotMatch(key, banned, `forbidden key ${key}`); + // Recursive: nested objects (claim_snapshot) must not smuggle a + // banned slot past a top-level-only scan. + const walkKeys = (node, path) => { + if (Array.isArray(node)) { node.forEach((v, i) => walkKeys(v, `${path}[${i}]`)); return; } + if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) { + assert.doesNotMatch(k, banned, `forbidden key at ${path}.${k}`); + walkKeys(v, `${path}.${k}`); + } } + }; + for (const rec of [h, e]) { + walkKeys(rec, '$'); assert.equal('publishedAt' in rec, false, 'no wire fields until H.5 is a decision'); assert.equal('publishedEventId' in rec, false); } diff --git a/tests/identity-profiles.test.mjs b/tests/identity-profiles.test.mjs index ea60e81..b97ac01 100644 --- a/tests/identity-profiles.test.mjs +++ b/tests/identity-profiles.test.mjs @@ -52,7 +52,8 @@ test('WORKSPACE_CLEAR_KEYS is pinned exactly', () => { 'integrity_findings', 'platform_accounts', 'portal_identities', 'lens_jurisdictions', 'url_aliases', 'entity_fact_dismissals', 'entity_dedupe_dismissals', 'follow_sets', - 'incorporated_artifacts', 'incorporation_dismissals' + 'incorporated_artifacts', 'incorporation_dismissals', + 'case_hypotheses', 'hypothesis_edges' ]); });