From 102ede3e96b0cc8e2df22a61907637a44cd9b14f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:09:36 +0000 Subject: [PATCH] refactor(frontend): remove the Evidence UI, keep the Evidence API (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #7 parked the Evidence surface because its semantics belong to the theory track and its browser implementation is being rewritten for the Vue/Marid client. The backend is complete and tested; the React surface is the part that has to go. Removed from the React frontend: - `frontend/src/evidence/` — claim editor, fibre/echo view, residual explorer, projection controls, `claims.ts`, `figures.ts`, `types.ts`, `ui.tsx`, and `evidence.test.ts` - `components/EvidenceModeToggle.tsx` and the `api.evidence` namespace - the `evidence` tab in `RunView` and its entry in the tab list The toggle also gated the placement CladeCumulus panel, which is exactly what issue #7 complained about: the panel now renders unconditionally in the Trees view instead of appearing only when Evidence Mode was switched on. Kept: `src/core/evidence.jl`, `src/server/routes/evidence.jl`, `test/unit/test_evidence.jl` and `test/unit/test_evidence_routes.jl` — the claim schema, residuals, verdicts and SQL are unchanged and still covered. The README section now documents the API and its validation rules rather than three UI views, so the Marid client has a written contract to build against. Verified: `tsc --noEmit` clean; the 10 remaining test files pass (169 assertions); `vite build` completes. The deleted `evidence.test.ts` covered UI claim helpers only — the server-side rules it mirrored stay tested in Julia. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 65 +-- frontend/src/api/client.ts | 35 -- .../src/components/EvidenceModeToggle.tsx | 64 --- frontend/src/evidence/ClaimEditor.tsx | 462 ------------------ frontend/src/evidence/EvidencePanel.tsx | 241 --------- frontend/src/evidence/FiberEcho.tsx | 300 ------------ frontend/src/evidence/ProjectionControls.tsx | 184 ------- frontend/src/evidence/ResidualExplorer.tsx | 204 -------- frontend/src/evidence/claims.ts | 251 ---------- frontend/src/evidence/evidence.test.ts | 308 ------------ frontend/src/evidence/figures.ts | 140 ------ frontend/src/evidence/types.ts | 239 --------- frontend/src/evidence/ui.tsx | 72 --- frontend/src/views/RunView.tsx | 7 +- frontend/src/views/StudyView.tsx | 5 +- 15 files changed, 37 insertions(+), 2540 deletions(-) delete mode 100644 frontend/src/components/EvidenceModeToggle.tsx delete mode 100644 frontend/src/evidence/ClaimEditor.tsx delete mode 100644 frontend/src/evidence/EvidencePanel.tsx delete mode 100644 frontend/src/evidence/FiberEcho.tsx delete mode 100644 frontend/src/evidence/ProjectionControls.tsx delete mode 100644 frontend/src/evidence/ResidualExplorer.tsx delete mode 100644 frontend/src/evidence/claims.ts delete mode 100644 frontend/src/evidence/evidence.test.ts delete mode 100644 frontend/src/evidence/figures.ts delete mode 100644 frontend/src/evidence/types.ts delete mode 100644 frontend/src/evidence/ui.tsx diff --git a/README.md b/README.md index 49f6d86d..951a8459 100644 --- a/README.md +++ b/README.md @@ -445,39 +445,42 @@ models), `src/analysis/differential.jl`, `src/server/routes/analysis.jl`, and independent implementation and are quoted to 12 digits; the special functions are cross-checked against mpmath at 40 digits. -### Evidence Mode - -The **Evidence** tab turns a run's outputs into a citable epistemic record. A -claim is a sentence a reader could disagree with, a **scope** naming the part of -the data it is about (its *fibre*: the rows whose value at a rank, or whose -category in a set, is one of the named labels), and a **warrant** saying why it -follows. Claims are stored in `projects/{study}/[/]/evidence/claims.yml` -and can be downloaded as that exact YAML. - -Three views share one record: - -- **Claims** — the epistemic editor (avec fibre). A claim cannot be saved - without a scope and at least one value, and cannot be marked `supported` - without a measurement, citation or inference warrant: a definition alone - supports nothing. -- **Fibre & echo** — the fibre's share of every sample, its largest members, and - the same fibre seen through a second projection: the other classifier at the - same rank, and the parent rank. Agreement is reported with a verdict; - disagreement is a limit on how strongly the claim may be stated, and an echo - that cannot be computed says so rather than vanishing. -- **Residuals** — where the fibre departs from a null you declare, either - `proportional` (the fibre distributed across samples as the library is) or a - stated `claim_share`, with Pearson residuals, a log2 ratio and a χ² statistic - printed without a p-value. The worst departure is shown first. - -The tab's Provenance card states whether `attestation.yml` is absent, degraded -or internally inconsistent before any claim is read. Longer notes on the -definitions, the schema and the limits are kept in `docs/evidence-mode.md` -(documentation under `docs/` is kept local by policy — see `.gitignore`). +### Evidence Mode (API) + +Evidence Mode turns a run's outputs into a citable epistemic record. A claim is +a sentence a reader could disagree with, a **scope** naming the part of the data +it is about (its *fibre*: the rows whose value at a rank, or whose category in a +set, is one of the named labels), and a **warrant** saying why it follows. +Claims are stored in `projects/{study}/[/]/evidence/claims.yml` and +can be downloaded as that exact YAML. + +The record is served entirely by the Julia backend; the browser surface is +delivered by the Vue/Marid client, so this repository ships no Evidence UI. + +| Endpoint | What it answers | +| --- | --- | +| `GET .../runs/{run}/evidence` | The record plus the context a claim may cite (tables, ranks, categories, provenance) | +| `POST .../evidence/claims` | Create, replace or retire a claim; ids are safe names of at most 64 characters | +| `DELETE .../evidence/claims/{id}` | Retire one claim | +| `GET .../evidence/claims.yml` | The record exactly as it sits on disk | +| `POST .../evidence/fiber` | A fibre's share per sample, its largest members, and an **echo**: the same scope through another projection (the other classifier at the same rank, the parent rank) | +| `POST .../evidence/residuals` | Where a fibre departs from a declared null — `proportional` (the fibre spread across samples as the library is) or a stated `claim_share` | + +Validation is enforced server-side, so every client sees the same rules. Scope +is one projection (`rank` or `category`) of 1 to 50 values of at most 200 +characters; claim text is at most 2000 characters; a claim carries at most 5 +warrants and 20 caveats; a blank status is stored as `draft`. A claim cannot be +marked `supported` without at least one measurement, citation or inference +warrant — a definition alone supports nothing. Echo verdicts are `concordant`, +`minor divergence`, `partial agreement` or `discordant`; containment is +`contained`, `inconsistent` or `disjoint`. Residuals report Pearson residuals, a +log2 ratio and a χ² statistic without a p-value, counting `|r| > 2`. Implementation: `src/core/evidence.jl` (schema, residuals, verdicts), -`src/server/routes/evidence.jl` (the SQL), `frontend/src/evidence/` -(editor, fibre visualiser, residual explorer) and `test/unit/test_evidence.jl`. +`src/server/routes/evidence.jl` (the SQL), `test/unit/test_evidence.jl` and +`test/unit/test_evidence_routes.jl`. Longer notes on the definitions, the schema +and the limits are kept in `docs/evidence-mode.md` (documentation under `docs/` +is kept local by policy — see `.gitignore`). ### Figures and report diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5b58aaa7..be36a201 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -27,10 +27,6 @@ import type { } from './types' import type { FigureDoc } from '../figure/types' -import type { - EvidenceClaim, EvidenceContext, FibreRequest, FibreResult, - ResidualRequest, ResidualResult, -} from '../evidence/types' import { saveBlob } from '../utils/download' // Base URL for the backend API. Empty string means same-origin. @@ -415,37 +411,6 @@ export const api = { post(`/api/v1/studies/${study}/placements/${id}/trim-preview`, trim), }, - /** - * Evidence Mode. A run's claims live beside its outputs; the fibre and - * residual queries read the results database without writing to it. - */ - evidence: { - context: (study: string, run: string, group?: string | null) => - get(`/api/v1/studies/${study}/runs/${run}/evidence${gq(group)}`), - saveClaim: (study: string, run: string, claim: EvidenceClaim, group?: string | null) => - post<{ claim: EvidenceClaim; claims: EvidenceClaim[]; updated: string }>( - `/api/v1/studies/${study}/runs/${run}/evidence/claims${gq(group)}`, { claim }), - deleteClaim: (study: string, run: string, id: string, group?: string | null) => - del<{ deleted: string; claims: EvidenceClaim[]; updated: string }>( - `/api/v1/studies/${study}/runs/${run}/evidence/claims/${encodeURIComponent(id)}${gq(group)}`), - fiber: (study: string, run: string, body: FibreRequest, group?: string | null) => - post(`/api/v1/studies/${study}/runs/${run}/evidence/fiber${gq(group)}`, body), - residuals: (study: string, run: string, body: ResidualRequest, group?: string | null) => - post(`/api/v1/studies/${study}/runs/${run}/evidence/residuals${gq(group)}`, body), - /** The record as the YAML that sits on disk, for download. */ - claimsYaml: async (study: string, run: string, group?: string | null): Promise => { - const res = await fetch(apiUrl(`/api/v1/studies/${study}/runs/${run}/evidence/claims.yml${gq(group)}`), - { cache: 'no-store' }) - if (!res.ok) { - const err: ApiError = await res.json().catch(() => ({ - error: 'network_error', message: res.statusText, - })) - throw Object.assign(new Error(err.message), { apiError: err, status: res.status }) - } - return res.blob() - }, - }, - trees: { list: (study: string) => get(`/api/v1/studies/${study}/trees`), get: (study: string, file: string) => diff --git a/frontend/src/components/EvidenceModeToggle.tsx b/frontend/src/components/EvidenceModeToggle.tsx deleted file mode 100644 index 1cc6cd1d..00000000 --- a/frontend/src/components/EvidenceModeToggle.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) - -export interface EvidenceModeToggleProps { - enabled: boolean - onChange: (next: boolean) => void -} - -/** - * Progressive-disclosure gate for CladeCumulus cladistic and epistemic - * explorer surfaces. Defaults to `false` so standard reference/placement tree - * management is never replaced silently. - */ -export function EvidenceModeToggle({ enabled, onChange }: EvidenceModeToggleProps) { - return ( -
-
-
- CladeCumulus Evidence Mode - - {enabled ? 'Active' : 'Standard view'} - -
- - {enabled - ? 'Showing cumulative cladistic abundances, epistemic warrant colours, Faith’s PD, UniFrac, and non-destructive move validation.' - : 'Enable to inspect phylogenetic + taxonomic clade clouds, Faith’s PD, UniFrac distances, and epistemic warrant.'} - -
- - -
- ) -} diff --git a/frontend/src/evidence/ClaimEditor.tsx b/frontend/src/evidence/ClaimEditor.tsx deleted file mode 100644 index 7fc8b1bd..00000000 --- a/frontend/src/evidence/ClaimEditor.tsx +++ /dev/null @@ -1,462 +0,0 @@ -// © 2026 Joshua Benjamin Jewell. All rights reserved. -// Licensed under the GNU Affero General Public License version 3 (AGPLv3). - -// The epistemic editor: claims, each with the fibre it is about and the warrant -// that is supposed to carry it. -// -// The editor is deliberately stricter than a free-text note. A claim cannot be -// saved without a scope and at least one value, and cannot be marked 'supported' -// without an evidential warrant. Those two rules are what stop the record from -// becoming a place to write down conclusions the data does not carry - the -// cherry-pick risk the issue names. - -import { useEffect, useMemo, useState } from 'react' -import { api } from '../api/client' -import { errorMessage } from '../api/errorMessage' -import { useToast } from '../components/Toast' -import { saveBlob } from '../utils/download' -import { - claimCountByStatus, draftClaim, emptyWarrant, evidentialWarrants, newClaim, - nextClaimId, parseValues, scopeSummary, statusTone, validateClaim, -} from './claims' -import { Badge, Notice, sectionTitleStyle } from './ui' -import { - CLAIM_STATUSES, WARRANT_KINDS, type ClaimScope, type ClaimStatus, type ClaimWarrant, - type EvidenceClaim, type EvidenceContext, type ProjectionTag, type WarrantKind, -} from './types' -import type { AnnotationSource } from '../api/types' - -interface Props { - study: string - run: string - group?: string | null - context: EvidenceContext - /** A scope sent over from the fibre visualiser. */ - draftScope?: ClaimScope | null - onSaved: () => void - onUseInFibre?: (scope: ClaimScope) => void - /** Called once a handed-over scope has become a draft, so it is not reapplied. */ - onDraftConsumed?: () => void -} - -const inputStyle: React.CSSProperties = { - width: '100%', padding: '5px 7px', borderRadius: 4, boxSizing: 'border-box', - border: '1px solid var(--color-border)', background: 'var(--color-bg)', - color: 'var(--color-fg)', fontSize: '.85rem', fontFamily: 'inherit', -} -const labelStyle: React.CSSProperties = { - fontSize: '.76rem', textTransform: 'uppercase', letterSpacing: '.03em', - color: 'var(--color-muted-fg)', display: 'block', marginBottom: 3, -} -const rowStyle: React.CSSProperties = { marginBottom: 12 } - -function ScopeEditor({ value, onChange, context }: { - value: ClaimScope - onChange: (next: ClaimScope) => void - context: EvidenceContext -}) { - const ranks = value.source - ? context.ranks.filter(r => r.source === value.source) - : context.ranks - const rankNames = [...new Set(ranks.flatMap(r => r.names))] - const setNames = context.category_sets.map(s => s.name) - const suggestions = value.tag === 'category' - ? (context.category_sets.find(s => s.name === value.value)?.categories.map(c => c.name) ?? []) - : [] - const [valueInput, setValueInput] = useState('') - - function setTag(tag: ProjectionTag) { - const projection = tag === 'rank' ? (rankNames[0] ?? '') : (setNames[0] ?? '') - onChange({ ...value, tag, value: projection, values: [] }) - } - - function removeValue(v: string) { - onChange({ ...value, values: value.values.filter(x => x !== v) }) - } - - function addValue() { - const next = [...value.values] - for (const part of parseValues(valueInput)) { - if (!next.includes(part)) next.push(part) - } - if (next.length !== value.values.length) onChange({ ...value, values: next }) - setValueInput('') - } - - return ( -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- {value.values.map(v => ( - - {v} - - - ))} - {!value.values.length && ( - - none yet — a claim is never about everything - - )} -
-
- setValueInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addValue() } }} /> - - {suggestions.map(s => - -
-
- ) -} - -export function ClaimEditor({ study, run, group, context, draftScope, onSaved, onUseInFibre, onDraftConsumed }: Props) { - const toast = useToast() - const [selectedId, setSelectedId] = useState(null) - const [draft, setDraft] = useState(null) - // Caveats are edited as text and parsed on save, so a blank line while typing - // is not a validation error. - const [caveatsText, setCaveatsText] = useState('') - const [saving, setSaving] = useState(false) - const [exporting, setExporting] = useState(false) - - const claims = context.claims - const counts = useMemo(() => claimCountByStatus(claims), [claims]) - - const draftKey = draftScope - ? JSON.stringify([draftScope.table, draftScope.tag, draftScope.value, draftScope.source, draftScope.values]) - : null - useEffect(() => { - if (!draftScope) return - setSelectedId(null) - setDraft(newClaim(draftScope)) - setCaveatsText('') - onDraftConsumed?.() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftKey]) - - const parsedCaveats = caveatsText.split('\n').map(c => c.trim()).filter(Boolean) - const effective: EvidenceClaim | null = draft - ? { - ...draft, - caveats: parsedCaveats, - id: draft.id || (draft.text.trim() ? nextClaimId(claims, draft.text) : ''), - } - : null - const errors = effective ? validateClaim(effective) : [] - - function select(claim: EvidenceClaim) { - setSelectedId(claim.id) - setDraft(draftClaim(claim)) - setCaveatsText(claim.caveats.join('\n')) - } - - function startNew() { - setSelectedId(null) - setCaveatsText('') - setDraft(newClaim({ - table: context.merged_table ?? context.tables[0]?.id ?? 'merged', - tag: context.ranks[0]?.names.length ? 'rank' : 'category', - value: context.ranks[0]?.names[0] ?? context.category_sets[0]?.name ?? '', - source: context.sources[0] ?? null, - })) - } - - async function save() { - if (!effective || errors.length) return - setSaving(true) - try { - const saved = await api.evidence.saveClaim(study, run, { - ...effective, - text: effective.text.trim(), - caveats: effective.caveats.map(c => c.trim()).filter(Boolean), - }, group) - toast.success(`Claim “${saved.claim.id}” saved`) - setSelectedId(saved.claim.id) - setDraft(draftClaim(saved.claim)) - setCaveatsText(saved.claim.caveats.join('\n')) - onSaved() - } catch (err) { - toast.error(errorMessage(err, 'Could not save the claim')) - } finally { - setSaving(false) - } - } - - async function remove(claim: EvidenceClaim) { - if (!window.confirm(`Delete claim “${claim.id}”? The record on disk is rewritten.`)) return - try { - await api.evidence.deleteClaim(study, run, claim.id, group) - toast.success(`Claim “${claim.id}” deleted`) - if (selectedId === claim.id) { setSelectedId(null); setDraft(null); setCaveatsText('') } - onSaved() - } catch (err) { - toast.error(errorMessage(err, 'Could not delete the claim')) - } - } - - async function exportYaml() { - setExporting(true) - try { - const blob = await api.evidence.claimsYaml(study, run, group) - saveBlob(blob, `${study}-${run}-claims.yml`) - } catch (err) { - toast.error(errorMessage(err, 'Could not download the record')) - } finally { - setExporting(false) - } - } - - function updateWarrant(i: number, patch: Partial) { - if (!draft) return - const warrant = draft.warrant.map((w, j) => (j === i ? { ...w, ...patch } : w)) - setDraft({ ...draft, warrant }) - } - - const supportedWithoutEvidence = draft?.status === 'supported' && - evidentialWarrants(draft).length === 0 - - return ( -
-
-
-
Claims ({claims.length})
- -
-
- {counts.supported} supported · {counts.contested} contested · {counts.refuted} refuted ·{' '} - {counts.draft} draft -
- - {claims.length === 0 && ( -

- No claims recorded for this run yet. -

- )} - - {claims.map(claim => ( - - ))} - - -
- Written to {context.file} -
-
- - {!draft && ( -
-

- Select a claim to edit it, or start a new one. A claim records the fibre it is - about, why it follows, and what would make it false. -

-
- )} - - {draft && effective && ( -
-
-
- {selectedId ? 'Edit claim' : 'New claim'} -
- - {draft.status} - -
- -
- -