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} - -
- -
- -