From 083a4acb57c899d038cdd4d92b3c5edf511dbc10 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Thu, 22 Oct 2026 22:21:14 +0000 Subject: [PATCH 1/5] feat(statistics): optional exact-arithmetic descriptive layer (refs #1) Adds an opt-in statistics layer that preserves integer counts, produces exact rationals where a statistic has one, and computes the rest at an explicitly requested precision. It is descriptive only: no test, interval or model fit is added, because issue #1 requires method-specific validation and independent review before those can ship. Numeric integrity (src/analysis/exact_numeric.jl) - NumericPolicy: validated, versioned, mode = float64 | bigfloat | exact. - Counts convert to BigInt exactly; fractional counts, NaN/Inf, Bool and float counts above 2^53-1 are refused rather than rounded. - Exact rationals with explicit zero-denominator and max_digits limits. - Approximations tagged with engine, precision and rounding mode; display values rounded half-to-even and marked display_rounded. - All MPFR work runs inside a lock-guarded precision block that restores the previous precision, so concurrent analyses cannot share a precision setting. Statistics (src/analysis/exact_statistics.jl) - Bounded catalogue of five descriptive methods, each publishing its response type, numeric modes, uncertainty statement and limitations. - Refusals are states with stable codes (invalid_input, zero_denominator, resource_limit, unsupported_numeric_mode, depth_exceeds_library), never plausible-looking numbers. - Results are immutable, write-once, content-addressed records with input fingerprint and full provenance (method, policy, software/MPFR versions). Surfaces and config - routes/statistics.jl: catalogue, run, list and fetch endpoints. - analysis.statistics.numeric defaults in config/defaults/pipeline.yml, with a README section; no pipeline stage reads them. Validation - Independent reference implementation in Python (fractions, Decimal at 80 digits) generating test/fixtures/statistics_known_answers.json, cross-checked against exhaustive subset enumeration. - Metamorphic, generative, adversarial and boundary-fuzz tests; 64 concurrent analyses at four precisions; record immutability and corruption handling; existing behaviour unchanged when the layer is unselected. - Eight negative controls (mutants) that the suite requires to be caught, and a simulation/precision/cost study script. - docs/statistics-layer.md publishes the scope, contract, evidence and the acceptance criteria still open (independent review, UI panel, cancellation, reviewed datasets). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 7 + config/defaults/pipeline.yml | 23 + docs/statistics-layer.md | 304 +++++ scripts/statistics-negative-controls.jl | 170 +++ scripts/statistics-reference.py | 232 ++++ scripts/statistics-simulation.jl | 168 +++ src/MetaManifold.jl | 13 +- src/analysis/exact_numeric.jl | 695 +++++++++++ src/analysis/exact_statistics.jl | 1179 +++++++++++++++++++ src/server/routes/analysis.jl | 7 +- src/server/routes/statistics.jl | 274 +++++ src/server/server.jl | 3 + test/fixtures/statistics_known_answers.json | 295 +++++ test/runtests.jl | 7 + test/unit/test_exact_numeric.jl | 352 ++++++ test/unit/test_exact_statistics.jl | 590 ++++++++++ 16 files changed, 4315 insertions(+), 4 deletions(-) create mode 100644 docs/statistics-layer.md create mode 100644 scripts/statistics-negative-controls.jl create mode 100644 scripts/statistics-reference.py create mode 100644 scripts/statistics-simulation.jl create mode 100644 src/analysis/exact_numeric.jl create mode 100644 src/analysis/exact_statistics.jl create mode 100644 src/server/routes/statistics.jl create mode 100644 test/fixtures/statistics_known_answers.json create mode 100644 test/unit/test_exact_numeric.jl create mode 100644 test/unit/test_exact_statistics.jl diff --git a/README.md b/README.md index 998fcc15..b703517f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Once a run completes, analysis is performed on request through the web UI, both - Pipeline stage read-count summaries - NMDS ordination (Bray-Curtis, via R/vegan) - PERMANOVA, with PERMDISP to check for differences in dispersion (via R/vegan) +- An optional exact-arithmetic statistics layer: descriptive summaries with exact integer counts and rationals, or approximations at a requested precision, each stored as an immutable record with its provenance. It performs no test and no inference; see [docs/statistics-layer.md](docs/statistics-layer.md) Counts may be normalised before analysis (none, rarefaction to a fixed or auto-resolved depth, or scaling with ranked subsampling (SRS), which reaches the depth by scaling counts and keeps more of the community structure), and contamination-flagged taxa may be included or excluded. Ordinations can also apply a Hellinger transform before the Bray-Curtis dissimilarity. Composition charts can be drawn as a grid faceted on any two of run, group and sub-group. All analysis charts are returned as Plotly JSON and rendered interactively in the browser. @@ -612,6 +613,12 @@ analysis: depth: 0 # rarefy/srs reads per sample; 0 = smallest non-empty sample nmds: max_stress: 0.2 # warn if NMDS stress exceeds this value + statistics: # the optional exact-arithmetic statistics layer + numeric: + mode: float64 # float64 | bigfloat | exact + precision_bits: 256 # MPFR precision for bigfloat mode (53 to 4096) + max_digits: 4096 # bound on any exact value (16 to 100000) + display_digits: 6 # rounding applied to display strings only (0 to 50) ``` ### Configuring taxonomic filtering (`filters:` in `config/composition.yml`) diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 44b2c882..cc927843 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -376,6 +376,29 @@ analysis: # its taxon's smallest observed proportion is lowered to this fraction of it. # A replaced zero is not a measurement. replacement_delta: 0.65 + # Exact-arithmetic statistics layer (issue #1), served at + # /api/v1/studies/{study}/runs/{run}/analysis/statistics. No pipeline stage + # reads this section and no existing analysis calls the layer, so a run that + # never asks for it is unaffected. Every stored record carries the policy it + # was computed under, so editing these values never rewrites earlier results. + statistics: + numeric: + # float64: IEEE-754 binary64, matching the rest of the application. + # bigfloat: MPFR at precision_bits - higher precision, still an approximation. + # exact: arbitrary-precision integers and exact rationals only. A statistic + # with no exact value (the Shannon index, for example) is refused outright + # with unsupported_numeric_mode rather than returned at another precision. + mode: float64 + # Working precision in bits for the bigfloat mode: 53 to 4096. Recorded but + # unused by float64 and exact. + precision_bits: 256 + # Largest decimal digits allowed in any exact numerator, denominator or + # integer: 16 to 100000. Exceeding it is an explicit resource_limit failure, + # never a quiet fallback to floating point. + max_digits: 4096 + # Decimal places in display strings only: 0 to 50. Display values are + # rounded and labelled; they never feed a computation. + display_digits: 6 ## Occupancy probe (MMW-01) # Measures each attested stage's memory: Julia's allocation total, and the diff --git a/docs/statistics-layer.md b/docs/statistics-layer.md new file mode 100644 index 00000000..b235fa02 --- /dev/null +++ b/docs/statistics-layer.md @@ -0,0 +1,304 @@ +# The exact-arithmetic statistics layer + +Status: implemented, additive, **descriptive only** — awaiting independent +scientific review before any inferential method is added (issue #1). + +This document is the layer's published scope. Where it says a method is +supported, that is the whole of what is claimed; everything it does not say is +not claimed. + +## 1. What this layer is + +MetaManifold's existing analyses — alpha diversity, taxonomic composition, +differential abundance, PERMANOVA — are unchanged and remain the default path. +The statistics layer is a separate, opt-in set of descriptive summaries that: + +* preserve read counts as exact arbitrary-precision integers; +* produce exact rational values where a statistic has one (Simpson's index, + proportions, means, rarefaction expectation); +* compute the statistics that have no exact value (Shannon's index, Pielou + evenness) at an explicitly requested precision, tagged as approximations; +* store every result as an immutable, content-addressed record with its inputs, + settings, versions and diagnostics; +* refuse, with a named code, whatever it cannot compute honestly. + +It is reachable only through its own endpoints (§5). No pipeline stage and no +existing analysis calls it, so a run that never asks for it is unaffected. + +### What it is not + +* **Not a test.** There is no hypothesis test, p-value, confidence interval or + model fit anywhere in this layer. Exact statistical tests are a later, + separately validated extension. +* **Not model-based.** No distribution is assumed, so no estimator is claimed to + be unbiased for a population quantity. +* **Not symbolic.** Symbolic mathematics is out of scope. +* **Not a fix for measurement error.** Exact arithmetic removes representation + error. Sampling uncertainty, model misspecification, PCR bias, primer bias, + extraction bias, copy-number variation and every other biological or technical + bias are untouched by anything here. + +## 2. Method catalogue + +The catalogue is data: `MetaManifold.ExactStatistics.METHOD_CATALOGUE`, served at +`GET /api/v1/studies/{study}/analysis/statistics/catalogue`. A method is only in +it if it can state its response type, numeric modes, uncertainty statement and +limitations. + +| Method | Computes | Exact in `exact` mode | Approximate otherwise | +|---|---|---|---| +| `summarise.counts` | per-sample and per-feature totals, richness, zero counts, means | totals, richness, means | means | +| `describe.alpha` | richness, Gini–Simpson, Shannon, Pielou evenness | richness, Simpson | Simpson, Shannon, evenness | +| `describe.proportions` | each feature's share of its sample and of the corpus | proportions | proportions | +| `rarefaction.expected_richness` | Hurlbert's (1971) expected richness at a chosen depth | the expectation | the expectation | +| `compare.taxon_ratio` | exact ratio of two named features' totals | the ratio | the ratio | + +Supported response: a **non-negative integer count matrix**, samples × features. +Anything else is refused (§4). Every method is `design_required = false`: none of +them needs study-design information, which is exactly why none of them infers +anything. + +**Uncertainty, effect size, multiple testing.** Every entry states these +explicitly: uncertainty is not estimated (no sampling model is assumed), there is +no effect size (no comparison between groups is made), and multiple testing is +not applicable (no test is performed). Those are "not applicable" statements with +a reason, not omissions. + +**Rarefaction specifics.** The expectation is computed in closed form as a +product of rational factors per feature, so no binomial coefficient is +materialised, no random number is drawn, and there is no Monte Carlo error. A +depth above a sample's library size is refused for that sample +(`depth_exceeds_library`) rather than clamped, and depths above 10,000 or +feature × depth products above 5,000,000 are refused for the request +(`resource_limit`). + +## 3. The numeric policy + +One immutable `NumericPolicy` per request, resolved from +`analysis.statistics.numeric` in the config cascade and optionally overridden per +request: + +```yaml +analysis: + statistics: + numeric: + mode: float64 # float64 | bigfloat | exact + precision_bits: 256 # 53 .. 4096, used by bigfloat + max_digits: 4096 # 16 .. 100000, bounds every exact value + display_digits: 6 # 0 .. 50, display strings only +``` + +* `float64` — binary64 (53-bit significand), matching the rest of the + application. +* `bigfloat` — MPFR at `precision_bits`. Higher precision, still an approximation. +* `exact` — arbitrary-precision integers and exact rationals only. A statistic + with no exact value is refused with `unsupported_numeric_mode`. It is never + computed at another precision and returned as though the request had been met. + +Every argument is validated strictly, and a mistyped key is an error rather than a +silently ignored setting. + +### Precision isolation + +Julia's `BigFloat` precision is process-global. All arithmetic that depends on it +runs inside `ExactNumeric.with_precision`, which takes a process-wide lock, sets +the precision, and restores it in a `finally` before releasing the lock. Two +analyses asking for different precisions therefore cannot observe each other's +setting, and nothing is left behind afterwards. The tests run 64 concurrent +analyses at four precisions and compare each with its sequential result. + +### The serialisation contract + +Published as data by `ExactNumeric.numeric_contract()`. + +| Kind | Representation | JSON | +|---|---|---| +| exact integer | arbitrary precision | `{"kind":"exact","type":"integer","value":"","display":"..."}` | +| exact rational | lowest terms, positive denominator | `{"kind":"exact","type":"rational","numerator":"...","denominator":"...","value":"n/d","display":"...","display_rounded":true}` | +| approximation | MPFR at a stated precision, or binary64 | `{"kind":"approximation","type":"mpfr" or "binary64","value":"","precision_bits":N,"rounding":"round-to-nearest-ties-to-even","display":"...","display_rounded":true}` | + +Rules: + +1. Exact values are **always decimal strings**, never JSON numbers. A count above + 2^53−1 does not survive a JSON round trip as a number, and emitting it as one + is how exact counts quietly stop being exact in a browser or an R session. +2. Rationals are normalised; a zero denominator is refused. +3. Approximations carry their engine, precision and rounding mode and are never + promoted to exact by a later step. +4. Display values are rounded separately, half to even, and marked + `display_rounded`. They never feed a computation. +5. Unsupported representations are rejected explicitly: NaN, infinities, + fractional counts, float counts above 2^53−1, `Bool`, and exact values beyond + `max_digits`. + +`parse_exact_json` reverses the round trip; the tests check +`parse_exact_json(exact_json(x)) == x` for integers and rationals. + +## 4. Failures are states, not fallbacks + +Each refusal carries a stable code, a scope, and a message: + +| Code | Meaning | +|---|---| +| `invalid_input` | input cannot be represented exactly: fractional count, NaN, ∞, float count > 2^53−1, `Bool` | +| `zero_denominator` | the requested ratio, index or proportion has no value | +| `resource_limit` | an exact value, work bound or depth bound was exceeded | +| `unsupported_numeric_mode` | the operation has no representation in `exact` mode | +| `depth_exceeds_library` | rarefaction depth above a sample's library size | + +A record's `status` is `complete`, `partial` (some entries explicitly +unsuccessful, each naming its own code, while the rest are usable) or +`unsuccessful` (nothing could be computed). A partial result never fills a failed +entry with a plausible-looking number: a zero-read sample has no proportions, a +sample with one observed feature has no Pielou evenness, and the record says so. + +## 5. API + +``` +GET /api/v1/studies/{study}/analysis/statistics/catalogue +POST /api/v1/studies/{study}/runs/{run}/analysis/statistics +GET /api/v1/studies/{study}/runs/{run}/analysis/statistics +GET /api/v1/studies/{study}/runs/{run}/analysis/statistics/{analysis_id} +``` + +POST body: + +```json +{ + "table": "merged", + "method": "rarefaction.expected_richness", + "options": {"depth": 1000}, + "numeric": {"mode": "exact"}, + "filter": "...", "colFilters": {}, "sampleReads": {"min": 100} +} +``` + +The POST answers `200` with the stored record whenever the analysis ran, even when +its `status` is `partial` or `unsuccessful`: the request succeeded, and the +outcome is the result. A client must read `status`. Only a malformed request, an +unknown method or option, or an invalid numeric policy is an HTTP error. + +The statistics surface obeys `analysis.exclude_categories` when a config names +`statistics` in an entry's `apply_to`. The factory default does not, so the layer +sees the unfiltered table until an exclusion says otherwise. + +## 6. Records + +`projects/{study}/[{group}/]{run}/analysis/statistics/{analysis_id}.json` + +* `analysis_id` is the SHA-256 of the record's canonical JSON (RFC 8785), + excluding `analysis_id` and `created_at`: the same analysis of the same input + under the same settings hashes identically wherever it is recomputed, while a + changed setting, input or method version produces a different id. +* Records are **write-once**. Re-running an unchanged analysis is idempotent and + keeps the original file; a differing body at the same id is a `RecordConflict`, + never an overwrite. New settings produce a new record beside the old one, and + each record names the input fingerprint it was computed from. +* The file is written atomically (temporary file, then rename) and is + byte-identical across machines for the same analysis and timestamp. +* `list_records` reports a damaged file as `unreadable` rather than hiding it: a + missing record and a broken one are different facts. + +The input fingerprint is the SHA-256 of canonical JSON over the ordered sample +names, ordered feature names and every count as a decimal string. It changes if a +count, a name, or the order of either changes. + +Provenance recorded with every result: method name and version, numeric policy, +parameters, seed (recorded even though no catalogue method draws a random number), +Julia version, MPFR version, package version and the input fingerprint. + +## 7. Validation + +The evidence this layer ships with: + +| Evidence | Where | Runs in CI | +|---|---|---| +| Independent reference values in another language (`fractions.Fraction`, `decimal.Decimal` at 80 digits) | `scripts/statistics-reference.py` → `test/fixtures/statistics_known_answers.json` | yes (the fixture is checked by the suite) | +| Exhaustive subset enumeration, sharing no algebra with the closed form | `test/unit/test_exact_statistics.jl` | yes | +| Metamorphic relations (permutation, uniform rescaling) and bounds | same | yes | +| Adversarial cases: huge counts, all-zero tables, tiny probabilities, non-finite input, counts above 2^53, depth above library, work bounds | same | yes | +| Generative tests with seeded random tables | same | yes | +| Parsing fuzz at the exact-value and policy boundaries | `test/unit/test_exact_numeric.jl` | yes | +| Negative controls (8 mutants, each expected to be caught) | `scripts/statistics-negative-controls.jl`, enforced by the suite | yes | +| Simulation study: simulated mean vs exact expectation, pre-set 4-SE tolerance | small version in the suite; full study in `scripts/statistics-simulation.jl` | small version yes | +| Precision isolation under concurrency | `test/unit/test_exact_statistics.jl` | yes | +| Record reproducibility, immutability, corruption handling | same | yes | +| Existing behaviour unchanged when the layer is not selected | same | yes | +| Cost and allocation of the exact path | `scripts/statistics-simulation.jl` | no (opt-in) | + +Reproduce everything locally: + +```console +$ python3 scripts/statistics-reference.py # regenerate the fixture, then +$ julia --project=. test/runtests.jl exact # run the two layer test files +$ julia --project=. scripts/statistics-negative-controls.jl +$ julia --project=. scripts/statistics-simulation.jl +``` + +The simulation study's tolerance is set in advance at four standard errors of the +simulated mean: a correct implementation fails roughly once in 30,000 such +studies, while a bias above about 1% is caught. Seeds are fixed, so the study is +deterministic rather than flaky. + +### What has *not* been established + +Stated plainly, because a green test run is not the same as a validated method: + +* **No independent scientific review has happened.** The acceptance criteria in + issue #1 require it, and no amount of passing tests substitutes for it. +* **No inferential method exists**, so no coverage, error-rate or power claim is + made anywhere. +* **The layer has no UI panel.** It is backend-first by design: the issue rules + out new application TypeScript, and accessibility and workflow testing of a + panel is its own piece of work. +* **Cost on realistic large tables is not benchmarked in CI.** The opt-in script + reports it; the budgets there are advisory unless run with + `--strict-benchmark`. +* **Cross-language storage round trips** are validated against this document's + contract and a Python reference for the mathematics, not yet against R, DuckDB + or a browser client. + +## 8. Residual risks and limitations + +* Counts reach the layer as binary64 from the results database. Values above + 2^53−1 are refused, so a table whose counts exceed that cannot be summarised + here even though DuckDB could hold them exactly. This is a deliberate + refusal over a silent loss of exactness. +* Exact rationals grow. `max_digits` bounds them, and exceeding it in `exact` mode + fails the entry rather than degrading to floating point. A user who wants a + number at any cost can select `bigfloat` explicitly. +* `describe.alpha` does not normalise for sequencing depth. Comparing raw index + values across samples of different depth is not valid, and the method's + limitations say so; `rarefaction.expected_richness` exists for the + depth-standardised question. +* Simpson's index here is the plug-in Gini–Simpson complement on observed + relative abundances. It is not an unbiased estimator of the population index. +* No statistic in this layer accounts for compositionality beyond the warning in + `describe.proportions`. +* The `float64` mode rounds as binary64 does, so its values can differ in the last + bit from the existing `DiversityMetrics` routines even where both are correct. + The `exact` mode is the authoritative path. + +## 9. Acceptance criteria in issue #1 + +| Criterion | Status | +|---|---| +| Approve a bounded method catalogue and publish supported/unsupported conditions | done (§2) — bounded to five descriptive methods | +| Publish numeric conversion and cross-language/storage contracts, refusing unsupported representations | done (§3, §4); round trips tested for integers and rationals | +| Known-answer cases and independent reference implementations | done (`scripts/statistics-reference.py`, enumeration) | +| Numerical stability across precision levels; adversarial cases | done in the suite | +| Property/generative tests and boundary fuzzing | done | +| Negative controls and mutation sensitivity, commands and evidence recorded | done (`scripts/statistics-negative-controls.jl`) | +| Simulation studies for bias and error rates with pre-set tolerances | bias study done; interval coverage is not applicable, because no interval is estimated | +| Representative biological datasets and null/alternative cases | **open** — needs reviewed datasets; no inferential claim exists to validate yet | +| Reproducibility, serialization, concurrent precision isolation, failure recovery | done except cancellation of a long request, which needs the job queue and is **open** | +| UI-to-backend-to-result workflows, accessibility, provenance display | **open** — backend endpoints and provenance exist; no panel | +| Existing behaviour unchanged when unselected | tested | +| Benchmarks and bounds on expensive operations | bounds enforced; timings opt-in | +| Repository/owner standards gates in CI | runs through the ordinary CI workflow; results must be reviewed on the PR | +| Independent statistical/scientific review and release approval | **open** — owner action | + +The remaining open items are exactly the ones that cannot be closed by writing +code: reviewed datasets, a UI panel, cancellation through the job queue, and +independent scientific sign-off. Until they are closed, no inferential method +should be added and the companion symbolic-engine work stays blocked. diff --git a/scripts/statistics-negative-controls.jl b/scripts/statistics-negative-controls.jl new file mode 100644 index 00000000..a973efcb --- /dev/null +++ b/scripts/statistics-negative-controls.jl @@ -0,0 +1,170 @@ +#!/usr/bin/env julia +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Negative controls for the exact statistics layer (issue #1). +# +# A test suite that only ever sees correct values cannot tell a working +# validation from one that accepts everything. Every check below is the same +# validation the unit tests apply to the real implementation; each is also +# applied to a deliberately wrong implementation (a mutant), and the record +# shows which mutants were caught. +# +# The mutations are chosen to be the mistakes that actually happen in this +# domain rather than arbitrary bit flips: +# +# * computing an exact statistic through binary64; +# * dividing by n-1 instead of n; +# * assuming sampling with replacement in a rarefaction expectation; +# * adding a pseudocount so a zero denominator never occurs; +# * rounding a fractional count instead of refusing it; +# * trusting a float count above 2^53; +# * ignoring the requested precision and using a fixed one. +# +# Run: +# +# julia --project=. scripts/statistics-negative-controls.jl +# +# It exits non-zero if any mutant escapes. The unit suite includes this file and +# asserts the same thing, so the evidence is enforced in CI rather than quoted. + +using MetaManifold.ExactNumeric +using MetaManifold.ExactStatistics + +## --------------------------------------------------------------------------- +## Mutants +## --------------------------------------------------------------------------- + +"""Simpson's index computed in binary64 instead of exactly.""" +mutant_simpson_binary64(counts) = + Float64(1 - sum((Float64(c) / Float64(sum(counts)))^2 for c in counts)) + +"""Simpson's index with a pseudocount, so a zero-read sample never divides by zero.""" +mutant_simpson_pseudocount(counts) = simpson_index(BigInt.(counts) .+ 1) + +"""A mean dividing by n-1 (a sample standard deviation habit applied to a mean).""" +mutant_mean_off_by_one(total, n) = + n <= 1 ? Float64(total) : Float64(total) / Float64(n - 1) + +"""Expected richness assuming reads are sampled *with* replacement.""" +function mutant_expected_richness_with_replacement(counts, depth) + total = sum(BigInt.(counts)) + s = zero(Rational{BigInt}) + for c in counts + c == 0 && continue + s += 1 - (one(Rational{BigInt}) - BigInt(c) // total)^depth + end + return s +end + +"""A proportion with a pseudocount, so a zero total becomes 1/1.""" +mutant_proportion_pseudocount(count, total) = BigInt(count + 1) // BigInt(total + 1) + +"""A count conversion that rounds instead of refusing a fractional value.""" +function mutant_count_rounds(x) + x isa AbstractFloat && return BigInt(round(Int, x)) + return BigInt(x) +end + +"""A count conversion that trusts a float above 2^53.""" +function mutant_count_trusts_large_floats(x) + x isa AbstractFloat && return BigInt(x) + return BigInt(x) +end + +"""Shannon's index computed at a fixed 256 bits whatever was requested.""" +mutant_shannon_fixed_precision(counts) = + shannon_index(counts, NumericPolicy(mode = "bigfloat", precision_bits = 256)) + +## --------------------------------------------------------------------------- +## The checks +## --------------------------------------------------------------------------- + +const _EXACT_FULL = NumericPolicy(mode = "exact", max_digits = 100_000) + +const NEGATIVE_CONTROL_CHECKS = [ + (name = "Simpson computed through binary64", + real = simpson_index, + mutant = mutant_simpson_binary64, + validate = f -> f(BigInt[1, 2]) isa Rational && f(BigInt[1, 2]) == 4 // 9), + + (name = "Simpson with a pseudocount", + real = simpson_index, + mutant = mutant_simpson_pseudocount, + validate = f -> f(BigInt[1, 2]) == 4 // 9), + + (name = "mean per feature dividing by n-1", + real = (total, n) -> exact_ratio(total, n; policy = _EXACT_FULL), + mutant = mutant_mean_off_by_one, + validate = f -> f(30, 3) == 10), + + (name = "expected richness assuming sampling with replacement", + real = (counts, depth) -> expected_richness(counts, depth; policy = _EXACT_FULL), + mutant = mutant_expected_richness_with_replacement, + validate = f -> f(BigInt[2, 3], 2) == 8 // 5), + + (name = "proportion with a pseudocount", + real = proportion_value, + mutant = mutant_proportion_pseudocount, + validate = f -> f(1, 3) == 1 // 3), + + (name = "fractional count rounded instead of refused", + real = exact_count, + mutant = mutant_count_rounds, + validate = f -> try + f(1.5) + false + catch e + e isa InvalidNumericInput + end), + + (name = "float count above 2^53 trusted", + real = exact_count, + mutant = mutant_count_trusts_large_floats, + validate = f -> try + f(Float64(2)^53) + false + catch e + e isa InvalidNumericInput + end), + + (name = "Shannon computed at a fixed precision", + real = counts -> shannon_index(counts, NumericPolicy(mode = "bigfloat", + precision_bits = 512)), + mutant = mutant_shannon_fixed_precision, + validate = f -> precision(f(BigInt[90, 10])) == 512), +] + +"""Run every negative control, printing the evidence, and return whether all passed.""" +function run_negative_controls(io::IO = stdout) + println(io, "statistics negative controls (issue #1)") + println(io, " ", rpad("check", 54), " real mutant") + ok = true + for control in NEGATIVE_CONTROL_CHECKS + real_passed = try + control.validate(control.real) === true + catch e + println(io, " real implementation threw in '", control.name, "': ", + sprint(showerror, e)) + false + end + mutant_rejected = try + control.validate(control.mutant) === false + catch e + # A mutant that throws is caught by a check that expects a value; it + # only counts as rejected if the check was itself an expectation of + # refusal, which `validate` returns false for on its own. + true + end + ok &= real_passed && mutant_rejected + println(io, " ", rpad(control.name, 54), " ", + rpad(real_passed ? "pass" : "FAIL", 7), " ", + mutant_rejected ? "caught" : "ESCAPED") + end + println(io, ok ? "all mutants caught" : "at least one mutant escaped") + return ok +end + +if abspath(PROGRAM_FILE) == abspath(@__FILE__) + exit(run_negative_controls() ? 0 : 1) +end diff --git a/scripts/statistics-reference.py b/scripts/statistics-reference.py new file mode 100644 index 00000000..325f5e9c --- /dev/null +++ b/scripts/statistics-reference.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +"""Independent reference values for the exact statistics layer (issue #1). + +Why this file exists +-------------------- +The Julia tests in `test/unit/test_exact_statistics.jl` assert known answers. +A known answer is only worth something if the number was produced somewhere +other than the implementation under test: comparing an implementation with +itself proves that it is consistently wrong just as well as that it is right. + +So the expected values here are computed by a second implementation, written +from the published definitions, in a different language and with different +arithmetic libraries: + + * exact rationals use Python's `fractions.Fraction`; + * Shannon values use `decimal.Decimal` at 80 significant digits, whose + `ln()` is a correctly-rounded-to-context implementation, not the same + algorithm Julia's MPFR call uses; + * every expected-richness value is checked twice - once with the closed form + written with `math.comb`, and once by brute-force enumeration over every + subset of the library (only for libraries small enough to enumerate). The + two are independent of each other, so a value that agrees with both is + pinned down rather than merely consistent. + +The output is `test/fixtures/statistics_known_answers.json`, which the Julia +suite reads. Regenerate with: + + python3 scripts/statistics-reference.py + +and the fixture is rewritten (the script prints nothing else, so it can be +diffed). If the Julia implementation and this reference ever disagree, one of +them has a bug; the fixture is the record of what was agreed at review time. + +Scope note: this validates arithmetic and the closed-form expectation. It says +nothing about biological validity, and it does not validate any statistical +method that does not yet exist in the catalogue. +""" + +from __future__ import annotations + +import json +import math +from decimal import Decimal, getcontext +from fractions import Fraction +from itertools import combinations +from pathlib import Path + +getcontext().prec = 80 + +FIXTURES = Path(__file__).resolve().parent.parent / "test" / "fixtures" / "statistics_known_answers.json" + + +def simpson(counts: list[int]) -> Fraction: + """Gini-Simpson complement: 1 - sum(p_i^2), exactly.""" + total = sum(counts) + if total == 0: + raise ZeroDivisionError("Simpson index of a sample with zero reads") + squares = sum(Fraction(c, total) ** 2 for c in counts) + return 1 - squares + + +def shannon(counts: list[int]) -> Decimal: + """-sum(p_i ln p_i) at the ambient Decimal precision.""" + total = Decimal(sum(counts)) + h = Decimal(0) + for c in counts: + if c == 0: + continue + p = Decimal(c) / total + h -= p * p.ln() + return +h + + +def expected_richness_closed_form(counts: list[int], depth: int) -> Fraction: + """Hurlbert (1971): sum_i (1 - C(N - n_i, m) / C(N, m)).""" + total = sum(counts) + if not 1 <= depth <= total: + raise ValueError("depth out of range") + denom = math.comb(total, depth) + s = Fraction(0) + for c in counts: + s += 1 - Fraction(math.comb(total - c, depth) if total - c >= depth else 0, denom) + return s + + +def expected_richness_brute_force(counts: list[int], depth: int) -> Fraction: + """Enumerate every subsample of `depth` reads and average the richness. + + Reads are treated as distinguishable individuals, which is exactly the + without-replacement model the closed form assumes. + """ + reads: list[int] = [] + for feature, c in enumerate(counts): + reads.extend([feature] * c) + total = len(reads) + if not 1 <= depth <= total or math.comb(total, depth) > 2_000_000: + raise ValueError("library too large to enumerate") + observed = Decimal(0) + n = 0 + for subset in combinations(reads, depth): + observed += len(set(subset)) + n += 1 + return Fraction(int(observed), n) + + +def ratio_string(value: Fraction) -> str: + return f"{value.numerator}/{value.denominator}" + + +def bytes_to_permutations(counts: list[int]) -> list[list[int]]: + """The reverse order, used to check that the expectation is permutation-invariant.""" + return list(reversed(counts)) + + +SIMPSON_CASES = [ + [1, 2], + [1, 1, 1, 1], + [50, 50], + [90, 10], + [1], + [2, 3, 5, 7, 11], + [10**6, 3 * 10**6, 0], + [0, 0, 7], +] + +EXPECTED_RICHNESS_CASES = [ + ([2, 3], 1), + ([2, 3], 2), + ([2, 3], 5), + ([1, 1, 1], 2), + ([5, 0, 0], 3), + ([10, 20, 30], 15), + ([1, 2, 3, 4], 5), + ([4, 4, 4, 4], 8), +] + +SHANNON_CASES = [ + [50, 50], + [90, 10], + [1, 1, 1], + [1, 10**9], + [25, 25, 25, 25], +] + +PROPORTION_CASES = [ + [1, 2], + [3, 3, 3], + [90, 10], + [0, 5, 5], +] + + +def main() -> None: + fixture: dict = { + "_provenance": { + "producer": "scripts/statistics-reference.py", + "language": "Python " + ".".join(str(v) for v in __import__("sys").version_info[:3]), + "arithmetic": "fractions.Fraction (exact rationals); decimal.Decimal at 80 digits for logarithms", + "independence": [ + "written from the published definitions, not ported from the Julia code", + "expected richness checked against both a closed form and exhaustive subset enumeration", + "Shannon values computed with Decimal.ln at 80 digits, not MPFR", + ], + "does_not_validated": "anything inferential: the layer makes no inference, and no statistical test is validated here", + "shannon_digits": 60, + }, + "simpson": [], + "expected_richness": [], + "shannon": [], + "proportions": [], + } + + for counts in SIMPSON_CASES: + value = simpson(counts) + fixture["simpson"].append({ + "counts": counts, + "value": ratio_string(value), + "permuted_value": ratio_string(simpson(bytes_to_permutations(counts))), + "scaled_by_three": ratio_string(simpson([3 * c for c in counts])), + "display_6": f"{float(value):.6f}", + }) + + for counts, depth in EXPECTED_RICHNESS_CASES: + closed = expected_richness_closed_form(counts, depth) + entry = { + "counts": counts, + "depth": depth, + "value": ratio_string(closed), + "permuted_value": ratio_string(expected_richness_closed_form(bytes_to_permutations(counts), depth)), + "display_6": f"{float(closed):.6f}", + } + try: + brute = expected_richness_brute_force(counts, depth) + except ValueError: + entry["brute_force"] = None + else: + if brute != closed: + raise SystemExit( + f"closed form and enumeration disagree for {counts} at depth {depth}: " + f"{closed} != {brute}" + ) + entry["brute_force"] = ratio_string(brute) + fixture["expected_richness"].append(entry) + + for counts in SHANNON_CASES: + value = shannon(counts) + fixture["shannon"].append({ + "counts": counts, + # 60 significant digits, truncated rather than rounded: the Julia test + # compares with a tolerance, and a truncated reference cannot be more + # precise than it claims. + "value_decimal": str(value.quantize(Decimal(1).scaleb(-60))), + "digits": 60, + "double": float(value), + }) + + for counts in PROPORTION_CASES: + total = sum(counts) + fixture["proportions"].append({ + "counts": counts, + "values": [ratio_string(Fraction(c, total)) for c in counts], + }) + + FIXTURES.parent.mkdir(parents=True, exist_ok=True) + FIXTURES.write_text(json.dumps(fixture, indent=2, sort_keys=False) + "\n") + print(f"wrote {FIXTURES}") + + +if __name__ == "__main__": + main() diff --git a/scripts/statistics-simulation.jl b/scripts/statistics-simulation.jl new file mode 100644 index 00000000..f9655753 --- /dev/null +++ b/scripts/statistics-simulation.jl @@ -0,0 +1,168 @@ +#!/usr/bin/env julia +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Simulation, stability and cost study for the exact statistics layer (issue #1). +# +# Three questions, each answered by running the layer rather than reasoning +# about it: +# +# 1. Bias. Is the closed-form expected richness the mean of the thing it +# claims to be the mean of? For each table and depth, subsamples are drawn +# without replacement and the simulated mean is compared with the exact +# value. The tolerance is fixed in advance at four standard errors of the +# simulated mean, so a correct implementation fails roughly once in 30,000 +# runs and a bias above ~1% is caught. Seeds are fixed, so the study is +# reproducible rather than flaky. +# +# 2. Precision stability. Does the Shannon index agree across 64, 256 and 1024 +# bits to the precision the narrowest of them can support? Disagreement +# beyond 53 bits' worth of agreement would mean the precision setting is not +# being honoured. +# +# 3. Cost. How long does the exact path take, and how much does it allocate, on +# a table of realistic width? This is reported, and only enforced with +# --strict-benchmark, because a wall-clock budget on shared CI hardware is a +# flaky assertion rather than a measurement. +# +# This script is NOT part of the unit suite: it runs for longer than a unit test +# should. The unit suite carries a small version of study 1 with the same +# pre-set tolerance. Run: +# +# julia --project=. scripts/statistics-simulation.jl +# julia --project=. scripts/statistics-simulation.jl --strict-benchmark +# +# It exits non-zero if a correctness check fails. + +using MetaManifold.ExactNumeric +using MetaManifold.ExactStatistics +using Random + +const STRICT = "--strict-benchmark" in ARGS +const EXACT = NumericPolicy(mode = "exact", max_digits = 100_000) +const BIG = NumericPolicy(mode = "bigfloat", precision_bits = 256) + +"""Draw `n` subsamples of `depth` reads without replacement; return mean and SE.""" +function simulate_richness(counts::Vector{Int}, depth::Int; n::Int = 4_000, seed::Int = 12345) + reads = Int[] + for (feature, c) in enumerate(counts) + for _ in 1:c + push!(reads, feature) + end + end + rng = MersenneTwister(seed) + values = Vector{Int}(undef, n) + for i in 1:n + subset = randperm(rng, length(reads))[1:depth] + values[i] = length(unique(reads[subset])) + end + μ = sum(values) / n + σ = sqrt(sum((v - μ)^2 for v in values) / (n - 1)) + return μ, σ / sqrt(n) +end + +function bias_study(io::IO) + println(io, "\n1. Expected richness: exact value vs simulated mean") + println(io, " tolerance: |simulated - exact| <= 4 standard errors (pre-set)\n") + println(io, " ", rpad("counts", 22), rpad("depth", 7), rpad("exact", 20), + rpad("simulated", 12), "SE result") + tables = [ + [4, 6, 2, 8], + [10, 10], + [20, 1, 1], + [30, 20, 10, 5], + [1, 1, 1, 1, 1, 1, 1, 1], + [50, 0, 0, 0], + [12, 7, 3], + ] + ok = true + for counts in tables, depth in unique([1, max(1, sum(counts) ÷ 4), max(1, sum(counts) ÷ 2)]) + exact = expected_richness(BigInt.(counts), depth; policy = EXACT) + μ, se = simulate_richness(counts, depth) + tolerance = 4 * se + passed = abs(μ - Float64(exact)) <= tolerance + 1e-12 + ok &= passed + println(io, " ", rpad(string(counts), 22), rpad(depth, 7), + rpad(display_string(exact; digits = 6), 20), + rpad(string(round(μ; digits = 4)), 12), + rpad(string(round(se; sigdigits = 3)), 10), + passed ? "pass" : "FAIL") + end + return ok +end + +function precision_study(io::IO) + println(io, "\n2. Shannon index across precisions") + tables = [[50, 50], [90, 10], [1, 10^9], [25, 25, 25, 25], [1, 1, 1]] + ok = true + for counts in tables + values = Dict(bits => shannon_index(BigInt.(counts), + NumericPolicy(mode = "bigfloat", + precision_bits = bits)) + for bits in (64, 256, 1024)) + narrow = values[64] + # 64 bits carries about 19 decimal digits; agreement to 1e-17 is what that + # width supports, and a wider computation that disagrees has lost precision. + agreement = maximum(abs(values[bits] - narrow) for bits in (256, 1024)) + passed = agreement < big"1e-17" + ok &= passed + println(io, " ", rpad(string(counts), 22), + " agreement across 64/256/1024 bits: ", + rpad(string(agreement), 12), passed ? "pass" : "FAIL") + end + return ok +end + +function cost_study(io::IO) + println(io, "\n3. Cost of the exact path (reported; enforced only with --strict-benchmark)") + rng = MersenneTwister(2026) + samples, features = 200, 50 + mat = [rand(rng, 0:500) for _ in 1:samples, _ in 1:features] + names_s = ["S$(i)" for i in 1:samples] + names_f = ["F$(j)" for j in 1:features] + + results = NamedTuple[] + for (label, method, policy, options) in ( + ("summarise.counts (exact)", "summarise.counts", EXACT, Dict{String,Any}()), + ("describe.alpha (bigfloat 256)", "describe.alpha", BIG, Dict{String,Any}()), + ("describe.proportions (exact)", "describe.proportions", EXACT, Dict{String,Any}()), + ("rarefaction.expected_richness (bigfloat 256)", + "rarefaction.expected_richness", BIG, Dict{String,Any}("depth" => 100))) + stats = @timed analyse(mat, names_s, names_f; method = method, policy = policy, + options = options) + record = stats.value + push!(results, (; label, seconds = stats.time, bytes = stats.bytes, + status = record["status"])) + end + + budgets = Dict("summarise.counts (exact)" => 5.0, + "describe.alpha (bigfloat 256)" => 10.0, + "describe.proportions (exact)" => 5.0, + "rarefaction.expected_richness (bigfloat 256)" => 60.0) + ok = true + for r in results + budget = get(budgets, r.label, 60.0) + within = r.seconds <= budget + ok &= within || !STRICT + println(io, " ", rpad(r.label, 46), + rpad(string(round(r.seconds; digits = 3)) * " s", 10), + rpad(string(round(r.bytes / 2^20; digits = 1)) * " MiB", 10), + rpad(r.status, 10), + within ? "within budget" : "OVER BUDGET ($(budget) s)") + end + return ok +end + +function main(io::IO = stdout) + println(io, "statistics simulation study (issue #1)") + println(io, "julia ", VERSION, ", ", Sys.CPU_THREADS, " threads") + ok = bias_study(io) + ok &= precision_study(io) + ok &= cost_study(io) + println(io, "\n", ok ? "all checks passed" : "at least one check failed") + return ok +end + +if abspath(PROGRAM_FILE) == abspath(@__FILE__) + exit(main() ? 0 : 1) +end diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl index 8b13c154..92b0c071 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -32,13 +32,22 @@ include("pipeline/dada2.jl") include("pipeline/swarm.jl") include("pipeline/phylogeny.jl") +# Canonical JSON: deterministic bytes for hashing and content-addressing. +# Included before the analysis layer because the statistics records are +# content-addressed with it (their analysis id is the SHA-256 of their canonical +# bytes). It depends on SHA only, so nothing else moves. +include("canonical.jl") + # Analysis include("analysis/diversity.jl") include("analysis/analysis.jl") include("analysis/differential.jl") -# Canonical JSON: deterministic bytes for hashing and content-addressing. -include("canonical.jl") +# Optional exact-arithmetic statistics layer (issue #1). Its own modules; the +# code above neither imports nor calls them, so every existing analysis and +# pipeline path is unchanged when the layer is not selected. +include("analysis/exact_numeric.jl") +include("analysis/exact_statistics.jl") # Web server include("server/server.jl") diff --git a/src/analysis/exact_numeric.jl b/src/analysis/exact_numeric.jl new file mode 100644 index 00000000..d6bd6413 --- /dev/null +++ b/src/analysis/exact_numeric.jl @@ -0,0 +1,695 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# The numeric policy of the optional statistics layer (issue #1). +# +# This file owns four things and nothing else: +# +# 1. What "exact" and "higher precision" mean here, as one validated, +# versioned `NumericPolicy` value. +# 2. Checked conversion of counts into arbitrary-precision integers, with an +# explicit refusal - never a rounding - at every boundary that cannot +# represent a count exactly. +# 3. Exact rational construction with explicit zero-denominator and resource +# failures. +# 4. The serialisation contract: every number leaves this layer tagged as an +# exact value, an approximation, or a rounded display of one of those, so a +# consumer cannot mistake a display value for a computed one. +# +# Scope, stated plainly. Exact arithmetic removes representation error. It does +# not remove measurement error, sampling uncertainty, model misspecification or +# biological bias, and nothing here estimates any of them. Higher precision is +# not exactness either: a 4096-bit approximation is still an approximation, and +# every value this file labels `"approximation"` is one. Symbolic mathematics is +# out of scope. +# +# Precision isolation. Julia's BigFloat precision is process-global state. Every +# computation that depends on it here runs inside `with_precision`, which takes +# a process-wide lock and restores the previous precision before releasing it, +# so two analyses asking for different precisions can run concurrently without +# either seeing the other's setting. Nothing in this file changes the ambient +# precision permanently, and no result carries an implicit dependence on it: a +# value is rounded to a stated number of bits when it is produced. + +module ExactNumeric + +export POLICY_VERSION, NUMERIC_MODES, DEFAULT_PRECISION_BITS, MIN_PRECISION_BITS, + MAX_PRECISION_BITS, DEFAULT_MAX_DIGITS, MAX_MAX_DIGITS, MAX_DISPLAY_DIGITS, + FLOAT_EXACT_INT_MAX, NumericPolicy, policy_from_config, policy_dict, + arithmetic_bits, with_precision, approximate_value, + NumericError, InvalidNumericInput, ZeroDenominator, NumericLimit, + ResourceLimit, UnsupportedNumericMode, error_code, + exact_count, exact_counts, exact_ratio, check_limit, + exact_json, approx_json, parse_exact_json, display_string, + decimal_digits, numeric_contract + +# Version of the numeric policy as a whole: the set of modes, the conversion +# rules, and the JSON tags below. A stored analysis records this string, so a +# later change to any of them is visible against an old result rather than +# silent. +const POLICY_VERSION = "1.0.0" + +# `float64` - IEEE-754 binary64 (53-bit significand), for compatibility +# with the values the rest of the application already produces. +# `bigfloat` - MPFR at `precision_bits`, an approximation computed and +# reported at that precision. +# `exact` - arbitrary-precision integers and exact rationals only. An +# operation with no exact representation is refused +# (`UnsupportedNumericMode`); it is never computed at some other +# precision and returned as if it were what was asked for. +const NUMERIC_MODES = ("float64", "bigfloat", "exact") + +const DEFAULT_PRECISION_BITS = 256 +# 53 is binary64's significand: below it, "higher precision" would be lower. +const MIN_PRECISION_BITS = 53 +# An upper bound rather than a suggestion: past it, an analysis is far more +# likely to be an accident than a requirement, and the cost is unbounded. +const MAX_PRECISION_BITS = 4096 + +# The largest number of decimal digits allowed in any exact numerator, +# denominator or integer this layer emits or stores. +const DEFAULT_MAX_DIGITS = 4096 +const MAX_MAX_DIGITS = 100_000 + +# Display rounding is presentation only; it never feeds a computation back. +const MAX_DISPLAY_DIGITS = 50 + +# Binary64 represents every integer in this range exactly, and no other. +const FLOAT_EXACT_INT_MAX = 2^53 - 1 + +## --------------------------------------------------------------------------- +## Failures +## +## Each of these is a refusal, not a fallback. The statistics layer turns them +## into an explicit unsuccessful state (with a stable machine-readable code) +## instead of a plausible-looking number. +## --------------------------------------------------------------------------- + +abstract type NumericError <: Exception end + +"An input cannot be represented exactly: a fractional count, NaN, or similar." +struct InvalidNumericInput <: NumericError + what :: String + reason :: String +end + +"A divisor was zero, so the requested ratio has no value." +struct ZeroDenominator <: NumericError + what :: String +end + +"An exact value exceeded the policy's `max_digits` bound." +struct NumericLimit <: NumericError + what :: String + limit :: Int +end + +"A computation exceeded a declared work bound (iterations or operations)." +struct ResourceLimit <: NumericError + what :: String + limit :: Int +end + +"An operation has no representation in the requested numeric mode." +struct UnsupportedNumericMode <: NumericError + what :: String + mode :: Symbol + reason :: String +end + +Base.showerror(io::IO, e::InvalidNumericInput) = + print(io, "invalid numeric input ($(e.what)): $(e.reason)") + +Base.showerror(io::IO, e::ZeroDenominator) = + print(io, "zero denominator: $(e.what) has no value when its divisor is zero") + +Base.showerror(io::IO, e::NumericLimit) = + print(io, "exact value limit: $(e.what) needs more than $(e.limit) decimal digits " * + "in a numerator, denominator or integer (raise numeric.max_digits, within " * + "its own bound, or select the bigfloat mode explicitly)") + +Base.showerror(io::IO, e::ResourceLimit) = + print(io, "resource limit: $(e.what) would exceed $(e.limit) units of work") + +Base.showerror(io::IO, e::UnsupportedNumericMode) = + print(io, "unsupported numeric mode: $(e.what) has no exact representation, and the " * + "policy is '$(e.mode)' (nothing was silently computed at another precision). " * + e.reason) + +""" + error_code(e::NumericError) -> String + +The stable code a failure is stored under, for clients that switch on it. +""" +error_code(::InvalidNumericInput) = "invalid_input" +error_code(::ZeroDenominator) = "zero_denominator" +error_code(::NumericLimit) = "resource_limit" +error_code(::ResourceLimit) = "resource_limit" +error_code(::UnsupportedNumericMode) = "unsupported_numeric_mode" + +## --------------------------------------------------------------------------- +## The policy +## --------------------------------------------------------------------------- + +""" + NumericPolicy(; mode="float64", precision_bits=256, max_digits=4096, + display_digits=6) + +The requested numeric behaviour of one analysis. Immutable, small, and carried +in every stored result's provenance, so a result states the rules it was +computed under rather than inheriting today's defaults. + +`mode` is one of `NUMERIC_MODES`. `precision_bits`, in [53, 4096], is MPFR's +working precision and applies to the `bigfloat` mode only; it is still recorded +in the other modes, because a policy is a request, not a report. `max_digits`, +in [16, 100000], bounds every exact numerator, denominator and integer the +analysis may produce. `display_digits`, in [0, 50], is the rounding applied to +*display* strings only. + +Every argument is validated strictly. A value of the wrong type is refused +rather than converted: `precision_bits=256.0` is an `ArgumentError`, not 256, +because silently accepting one spelling and not another is how two callers end +up meaning different things. `Bool` is refused for the integer fields. +""" +struct NumericPolicy + mode :: Symbol + precision_bits :: Int + max_digits :: Int + display_digits :: Int + version :: String +end + +function _strict_int(v, name::String, lo::Integer, hi::Integer)::Int + (v isa Integer && !(v isa Bool)) || throw(ArgumentError( + "numeric policy $name must be an integer, not $(typeof(v))")) + lo <= v <= hi || throw(ArgumentError( + "numeric policy $name must be between $lo and $hi, not $v")) + return Int(v) +end + +function NumericPolicy(; mode = "float64", + precision_bits = DEFAULT_PRECISION_BITS, + max_digits = DEFAULT_MAX_DIGITS, + display_digits = 6) + mode isa AbstractString || throw(ArgumentError( + "numeric policy mode must be a string, not $(typeof(mode))")) + m = lowercase(strip(String(mode))) + m in NUMERIC_MODES || throw(ArgumentError( + "numeric policy mode must be one of $(join(NUMERIC_MODES, ", ")), not '$mode'")) + NumericPolicy(Symbol(m), + _strict_int(precision_bits, "precision_bits", MIN_PRECISION_BITS, MAX_PRECISION_BITS), + _strict_int(max_digits, "max_digits", 16, MAX_MAX_DIGITS), + _strict_int(display_digits, "display_digits", 0, MAX_DISPLAY_DIGITS), + POLICY_VERSION) +end + +const POLICY_KEYS = ("mode", "precision_bits", "max_digits", "display_digits") + +""" + policy_from_config(section=nothing; overrides=nothing) -> NumericPolicy + +Build a policy from a config section (a `Dict` from the config cascade, or +`nothing`) and an optional request-level override, with the override applied +last. Keys are `"mode"`, `"precision_bits"`, `"max_digits"` and +`"display_digits"`; `nothing` values mean "not set" and are skipped, so a +cascade's explicit `~` does not shadow the default with a null. + +An unknown key is an `ArgumentError`. That is deliberate: a misspelt +`"precision"` must not leave the analysis quietly running at the default while +the caller believes otherwise. +""" +function policy_from_config(section::Union{Nothing,AbstractDict} = nothing; + overrides::Union{Nothing,AbstractDict} = nothing) + values = Dict{String,Any}() + for source in (section, overrides) + isnothing(source) && continue + for (k, v) in source + key = String(k) + key in POLICY_KEYS || throw(ArgumentError( + "unknown numeric policy key '$key' (expected one of $(join(POLICY_KEYS, ", ")))")) + isnothing(v) && continue + values[key] = v + end + end + NumericPolicy(; mode = get(values, "mode", "float64"), + precision_bits = get(values, "precision_bits", DEFAULT_PRECISION_BITS), + max_digits = get(values, "max_digits", DEFAULT_MAX_DIGITS), + display_digits = get(values, "display_digits", 6)) +end + +""" + policy_dict(p) -> Dict{String,Any} + +The policy as plain JSON-safe data for provenance. +""" +policy_dict(p::NumericPolicy) = Dict{String,Any}( + "version" => p.version, + "mode" => String(p.mode), + "precision_bits" => p.precision_bits, + "precision_applies_to" => p.mode === :bigfloat ? "all arithmetic in this analysis" : + "bigfloat mode only (recorded, not used here)", + "max_digits" => p.max_digits, + "display_digits" => p.display_digits, +) + +""" + arithmetic_bits(p) -> Int + +The significand width approximate arithmetic runs at: the policy's precision in +`bigfloat` mode, binary64's 53 bits in `float64` mode. Asking an `exact` policy +for it is an `UnsupportedNumericMode`, not a default: there is no approximate +arithmetic in exact mode. +""" +arithmetic_bits(p::NumericPolicy) = p.mode === :bigfloat ? p.precision_bits : + p.mode === :float64 ? 53 : + throw(UnsupportedNumericMode("arithmetic_bits", p.mode, + "exact mode performs no approximate arithmetic")) + +## --------------------------------------------------------------------------- +## Precision isolation +## --------------------------------------------------------------------------- + +const _PRECISION_LOCK = ReentrantLock() + +""" + with_precision(f, bits) -> f() + +Run `f` with BigFloat's working precision set to `bits` and restore the previous +precision before returning, under a process-wide lock. + +This is the only way this layer touches the global precision. The lock is what +makes concurrent analyses with different precisions safe: without it, two tasks +would interleave their settings and one would silently compute the other's +precision. The restore in a `finally` is what keeps the setting from leaking +into unrelated code. Nested calls are safe - the lock is reentrant and each +frame restores its own predecessor. +""" +function with_precision(f::Function, bits::Integer) + bits = _strict_int(bits, "working precision", MIN_PRECISION_BITS, MAX_PRECISION_BITS) + lock(_PRECISION_LOCK) + try + return setprecision(BigFloat, bits) do + f() + end + finally + unlock(_PRECISION_LOCK) + end +end + +""" + approximate_value(f, policy; what="value") -> BigFloat + +Run the closure `f` at the policy's working precision and return its result. + +`f` must construct its own `BigFloat`s (from integers or strings) so that every +value it touches is at the requested precision; the closure is evaluated with +the global precision set accordingly, so `BigFloat(n)` inside it is exact. +In `exact` mode this refuses: an approximation was not requested, and returning +one would be the silent downgrade this layer exists to prevent. +""" +function approximate_value(f::Function, policy::NumericPolicy; what::AbstractString = "value") + policy.mode === :exact && throw(UnsupportedNumericMode(String(what), policy.mode, + "select bigfloat (or float64) explicitly if an approximation is what you want")) + bits = arithmetic_bits(policy) + return with_precision(bits) do + v = f() + v isa BigFloat ? v : BigFloat(v) + end +end + +## --------------------------------------------------------------------------- +## Counts: checked conversion +## --------------------------------------------------------------------------- + +""" + exact_count(x; what="count") -> BigInt + +`x` as an exact non-negative integer, or a refusal. + +Accepted: non-negative `Integer`s of any width, `Rational`s with denominator 1, +and finite integral `AbstractFloat`s whose magnitude is at most 2^53-1. The last +bound is the point of the rule: binary64 cannot represent every integer above +it, so a float that large may already be a rounded neighbour of the count it +came from, and accepting it would be claiming an exactness the boundary did not +have. Refused, with the reason stated: fractional values (never rounded), NaN +and infinities, negatives, `Bool`, and unsupported types. +""" +function exact_count(x::Real; what::AbstractString = "count") + if x isa Bool + throw(InvalidNumericInput(String(what), "a Bool is not a read count")) + elseif x isa Integer + x >= 0 || throw(InvalidNumericInput(String(what), + "counts must be non-negative, got $x")) + return BigInt(x) + elseif x isa Rational + denominator(x) == 1 || throw(InvalidNumericInput(String(what), + "the fractional value $x is refused rather than rounded")) + c = numerator(x) + c >= 0 || throw(InvalidNumericInput(String(what), + "counts must be non-negative, got $x")) + return BigInt(c) + elseif x isa AbstractFloat + isfinite(x) || throw(InvalidNumericInput(String(what), + "the non-finite value $x is not a count")) + isinteger(x) || throw(InvalidNumericInput(String(what), + "the fractional value $x is refused rather than rounded")) + abs(x) <= FLOAT_EXACT_INT_MAX || throw(InvalidNumericInput(String(what), + "the floating-point value $x is above 2^53-1, where binary64 no longer " * + "represents every integer; it cannot be trusted to name the count it came from")) + c = BigInt(x) + c >= 0 || throw(InvalidNumericInput(String(what), + "counts must be non-negative, got $x")) + return c + end + throw(InvalidNumericInput(String(what), + "unsupported count type $(typeof(x))")) +end + +_index_string(i) = i isa CartesianIndex ? string(Tuple(i)) : string(i) + +""" + exact_counts(counts) -> Matrix{BigInt} + +`counts` converted entry by entry with `exact_count`. The first refusal names +the offending position, so a bad cell in a wide table is findable. +""" +function exact_counts(counts::AbstractMatrix{<:Real}) + out = Matrix{BigInt}(undef, size(counts)) + for i in eachindex(counts) + out[i] = exact_count(counts[i]; what = "count[$( _index_string(i) )]") + end + return out +end + +function exact_counts(counts::AbstractVector{<:Real}) + out = Vector{BigInt}(undef, length(counts)) + for i in eachindex(counts) + out[i] = exact_count(counts[i]; what = "count[$( _index_string(i) )]") + end + return out +end + +## --------------------------------------------------------------------------- +## Exact values and their bounds +## --------------------------------------------------------------------------- + +""" + decimal_digits(x) -> Int + +Number of decimal digits in |x| (1 for zero). Computed from the decimal +expansion rather than a logarithm, so it is exact for arbitrary-precision +integers. +""" +decimal_digits(x::Integer) = x == 0 ? 1 : length(string(abs(BigInt(x)))) + +""" + check_limit(x, policy; what="value") + +Refuse an exact integer or rational whose numerator or denominator exceeds +`policy.max_digits` decimal digits. + +Both halves of a rational are bounded: a rational can be tiny and yet carry an +enormous exact representation, and it is the representation that consumes memory +and makes a stored result unreadable. +""" +function check_limit(x::Integer, policy::NumericPolicy; what::AbstractString = "value") + decimal_digits(x) <= policy.max_digits || throw(NumericLimit(String(what), policy.max_digits)) + return x +end + +function check_limit(x::Rational, policy::NumericPolicy; what::AbstractString = "value") + d = max(decimal_digits(numerator(x)), decimal_digits(denominator(x))) + d <= policy.max_digits || throw(NumericLimit(String(what), policy.max_digits)) + return x +end + +""" + exact_ratio(num, den; policy, what="ratio") -> Rational{BigInt} + +`num/den` as an exact rational, in lowest terms. + +A zero denominator is a `ZeroDenominator`, never a substituted value and never +an infinity. The result is checked against `max_digits` before it is returned. +""" +function exact_ratio(num::Integer, den::Integer; + policy::NumericPolicy = NumericPolicy(), + what::AbstractString = "ratio") + (num isa Bool || den isa Bool) && throw(InvalidNumericInput(String(what), + "a Bool is not a count or a divisor")) + BigInt(den) == 0 && throw(ZeroDenominator(String(what))) + q = BigInt(num) // BigInt(den) + return check_limit(q, policy; what = what) +end + +## --------------------------------------------------------------------------- +## Display: rounded, always labelled as such +## --------------------------------------------------------------------------- + +"""Round an exact rational to an integer, half to even.""" +function _round_ratio(num::BigInt, den::BigInt)::BigInt + den == 0 && throw(ZeroDenominator("display")) + if den < 0 + num, den = -num, -den + end + q, r = divrem(num, den) # truncating; r has the sign of num + twice = 2 * abs(r) + if twice > den + return num < 0 ? q - 1 : q + 1 + elseif twice == den + # A tie: move to the even neighbour, which for an odd q means one step + # away from zero. + return isodd(abs(q)) ? (num < 0 ? q - 1 : q + 1) : q + end + return q +end + +"""Render a value already scaled by 10^`digits` as a fixed-point decimal.""" +function _scaled_decimal(n::BigInt, digits::Int)::String + s = string(abs(n)) + sign = n < 0 ? "-" : "" + digits == 0 && return sign * s + if length(s) <= digits + s = repeat("0", digits - length(s) + 1) * s + end + return string(sign, first(s, length(s) - digits), ".", last(s, digits)) +end + +"""Round a BigFloat to the nearest integer, half to even, using only exact steps.""" +function _round_to_bigint(y::BigFloat)::BigInt + isfinite(y) || throw(InvalidNumericInput("value", + "display formatting requires a finite value, got $y")) + return with_precision(max(precision(y), MIN_PRECISION_BITS)) do + f = floor(y) # exact: the result is integral + frac = y - f # exact at y's own precision + q = BigInt(f) + half = BigFloat(1) / 2 + if frac > half + q += 1 + elseif frac == half && isodd(abs(q)) + q += 1 # half to even + end + q + end +end + +""" + display_string(x; digits=6) -> String + +A fixed-point rendering of `x`, rounded half-to-even to `digits` decimal places. + +Display is presentation. It is never read back into a computation, and every +JSON document this layer writes marks a display value with +`"display_rounded" => true`, so the rounded string and the computed value cannot +be confused. Exact integers are rendered in full, without rounding. +""" +display_string(x::Integer; digits::Integer = 6) = string(BigInt(x)) + +function display_string(x::Rational; digits::Integer = 6) + d = clamp(Int(digits), 0, MAX_DISPLAY_DIGITS) + scale = BigInt(10)^d + return _scaled_decimal(_round_ratio(BigInt(numerator(x)) * scale, BigInt(denominator(x))), d) +end + +function display_string(x::BigFloat; digits::Integer = 6) + isfinite(x) || throw(InvalidNumericInput("value", + "display formatting requires a finite value, got $x")) + d = clamp(Int(digits), 0, MAX_DISPLAY_DIGITS) + # 4 bits per decimal digit is ample to hold 10^d exactly, with room for the + # value's own significand. + y = with_precision(max(precision(x), 4 * d + 64)) do + x * BigFloat(10)^d + end + return _scaled_decimal(_round_to_bigint(y), d) +end + +function display_string(x::AbstractFloat; digits::Integer = 6) + isfinite(x) || throw(InvalidNumericInput("value", + "display formatting requires a finite value, got $x")) + d = clamp(Int(digits), 0, MAX_DISPLAY_DIGITS) + # Float64 -> BigFloat is exact, so the binary value is displayed as the + # number it actually is (0.1 shows as 0.100000, not as a rounded 0.3). + return with_precision(max(64, 4 * d + 64)) do + display_string(BigFloat(x); digits = d) + end +end + +## --------------------------------------------------------------------------- +## The serialisation contract +## --------------------------------------------------------------------------- + +""" + exact_json(x; policy) -> Dict{String,Any} + +An exact integer or rational, tagged. + + {"kind": "exact", "type": "integer", "value": "12345", "display": "12345"} + {"kind": "exact", "type": "rational", "numerator": "3", "denominator": "4", + "value": "3/4", "display": "0.750000", "display_rounded": true} + +Integers are always decimal *strings*, never JSON numbers: a count above +2^53-1 would not survive a JSON round trip as a number, and emitting it as one +is how exact counts quietly stop being exact in a browser or an R session. The +rational is normalised (lowest terms, positive denominator) and a zero +denominator is refused here as everywhere else. +""" +function exact_json(x::Integer; policy::NumericPolicy = NumericPolicy()) + v = BigInt(x) + check_limit(v, policy; what = "integer value") + return Dict{String,Any}("kind" => "exact", + "type" => "integer", + "value" => string(v), + "display" => string(v)) +end + +function exact_json(x::Bool; policy::NumericPolicy = NumericPolicy()) + throw(InvalidNumericInput("value", "a Bool is not an exact statistic")) +end + +function exact_json(x::Rational; policy::NumericPolicy = NumericPolicy()) + den = denominator(x) + den == 0 && throw(ZeroDenominator("rational value")) + q = BigInt(numerator(x)) // BigInt(den) + check_limit(q, policy; what = "rational value") + n, d = numerator(q), denominator(q) + return Dict{String,Any}("kind" => "exact", + "type" => "rational", + "numerator" => string(n), + "denominator" => string(d), + "value" => string(n) * "/" * string(d), + "display" => display_string(q; digits = policy.display_digits), + "display_rounded" => d != 1) +end + +""" + approx_json(x; policy) -> Dict{String,Any} + +An approximate value, tagged as one. + + {"kind": "approximation", "type": "mpfr", "value": "...", "precision_bits": 256, + "rounding": "round-to-nearest-ties-to-even", "display": "0.693147", + "display_rounded": true} + +`value` is the computed approximation rendered as a decimal string (strings +again, so no consumer's JSON number type can silently shorten it). Non-finite +values are refused rather than encoded as `NaN`/`Infinity`, which are not JSON. +""" +function approx_json(x::BigFloat; policy::NumericPolicy = NumericPolicy(), + what::AbstractString = "value") + isfinite(x) || throw(InvalidNumericInput(String(what), + "a non-finite result is not a number and cannot be stored")) + return Dict{String,Any}("kind" => "approximation", + "type" => "mpfr", + "value" => string(x), + "precision_bits" => precision(x), + "rounding" => "round-to-nearest-ties-to-even", + "display" => display_string(x; digits = policy.display_digits), + "display_rounded" => true) +end + +function approx_json(x::AbstractFloat; policy::NumericPolicy = NumericPolicy(), + what::AbstractString = "value") + isfinite(x) || throw(InvalidNumericInput(String(what), + "a non-finite result is not a number and cannot be stored")) + return Dict{String,Any}("kind" => "approximation", + "type" => x isa Float64 ? "binary64" : string(typeof(x)), + "value" => string(x), + "precision_bits" => x isa Float64 ? 53 : 8 * sizeof(x), + "rounding" => "round-to-nearest-ties-to-even", + "display" => display_string(x; digits = policy.display_digits), + "display_rounded" => true) +end + +""" + parse_exact_json(d) -> Union{BigInt,Rational{BigInt}} + +Read an exact value back from its stored document. This is the round-trip half +of the contract: whatever `exact_json` writes, this returns as the same value, +and anything else - a wrong tag, a missing field, a zero denominator, an +unparseable string - is an `ArgumentError`. +""" +function parse_exact_json(d::AbstractDict) + get(d, "kind", nothing) == "exact" || throw(ArgumentError( + "not an exact value document: missing or wrong 'kind'")) + t = get(d, "type", nothing) + if t == "integer" + v = get(d, "value", nothing) + v isa AbstractString || throw(ArgumentError( + "an exact integer is stored as a decimal string, not $(typeof(v))")) + return parse(BigInt, v) + elseif t == "rational" + n = get(d, "numerator", nothing) + m = get(d, "denominator", nothing) + (n isa AbstractString && m isa AbstractString) || throw(ArgumentError( + "an exact rational is stored as numerator and denominator strings")) + num = parse(BigInt, n) + den = parse(BigInt, m) + den == 0 && throw(ZeroDenominator("stored rational")) + return num // den + end + throw(ArgumentError("unknown exact representation $(repr(t))")) +end + +""" + numeric_contract() -> Dict{String,Any} + +The cross-language and storage contract, as data, so that documentation and +tests quote the same text rather than drifting apart. + +1. Exact stays exact: counts become arbitrary-precision integers, and every + exact field is emitted as decimal strings. +2. Exact rationals are normalised, denominator positive, zero denominator + refused. +3. Approximations are tagged with their engine, precision and rounding mode; + they are never promoted to "exact" by any later step. +4. Display values are rounded separately and marked `display_rounded`. +5. Unsupported representations are rejected explicitly - NaN, infinities, + fractional counts, float counts above 2^53-1, integers outside JSON's exact + range emitted as numbers. +""" +numeric_contract() = Dict{String,Any}( + "version" => POLICY_VERSION, + "integer" => Dict{String,Any}( + "representation" => "arbitrary-precision integer", + "json" => "decimal string in 'value' (never a JSON number)", + "range" => "unbounded, subject to max_digits", + "refused" => ["fractional counts", "negative counts", "NaN", "Infinity", + "float counts with |x| > 2^53-1", "Bool"], + ), + "rational" => Dict{String,Any}( + "representation" => "Rational{BigInt}, lowest terms, positive denominator", + "json" => "'numerator' and 'denominator' decimal strings, plus 'value' as n/d", + "refused" => ["zero denominator", "numerator or denominator above max_digits"], + ), + "approximation" => Dict{String,Any}( + "representation" => "BigFloat (MPFR) or binary64", + "json" => "'value' as a decimal string, with 'precision_bits' and 'rounding'", + "refused" => ["NaN", "Infinity", "requesting it in exact mode"], + ), + "display" => Dict{String,Any}( + "representation" => "fixed-point decimal string, half-to-even", + "json" => "'display', with 'display_rounded' => true unless the value is an exact integer", + ), + "json_number_boundary" => "values outside -(2^53-1) .. 2^53-1 are never written as JSON numbers", +) + +end # module ExactNumeric diff --git a/src/analysis/exact_statistics.jl b/src/analysis/exact_statistics.jl new file mode 100644 index 00000000..0498e3cc --- /dev/null +++ b/src/analysis/exact_statistics.jl @@ -0,0 +1,1179 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# The optional, validated statistics layer (issue #1). +# +# This module is descriptive and exact-arithmetic in scope. It deliberately does +# not contain a hypothesis test, a p-value, a confidence interval or a model fit +# yet: the issue that asks for those requires method-specific acceptance +# tolerances and independent scientific review first, and shipping an unvalidated +# test is worse than shipping none. What it does contain is a small, fixed +# catalogue of summaries (/`METHOD_CATALOGUE`/), each with its supported response +# type, numeric modes, uncertainty statement and limitations published alongside +# the code, so a caller can see what is and is not claimed. +# +# Three properties run through everything here: +# +# * Numbers are never guessed. A statistic that cannot be computed exactly on +# the requested terms fails with a code, and the failure is stored with the +# record: `zero_denominator`, `resource_limit`, `invalid_input`, +# `unsupported_numeric_mode`, `depth_exceeds_library`. +# * Results are immutable and linked. Every analysis is content-addressed and +# written once; re-running with different settings produces a *new* record +# that names the input fingerprint it came from, never an overwrite. +# * Provenance travels with the numbers: method version, numeric policy, +# parameters, software and MPFR versions, and the input fingerprint. +# +# The layer is optional. Nothing in `DiversityMetrics`, `Analysis` or the +# pipeline calls it, so existing behaviour is unchanged when it is not selected. + +module ExactStatistics + +using Dates, JSON3 +using ..ExactNumeric +using ..ExactNumeric: NUMERIC_MODES, NumericPolicy, NumericError, InvalidNumericInput, + ZeroDenominator, NumericLimit, ResourceLimit, UnsupportedNumericMode, + error_code, exact_count, exact_counts, exact_ratio, check_limit, + exact_json, approx_json, parse_exact_json, display_string, + arithmetic_bits, policy_dict, approximate_value +using ..Canonical: jcs_bytes, jcs_sha256 + +export METHOD_VERSION, RECORD_SCHEMA, METHOD_CATALOGUE, method_catalogue, + GLOBAL_LIMITATIONS, analyse, analysis_id, record_directory, write_record, + read_record, list_records, RecordConflict, input_fingerprint, + simpson_index, shannon_index, expected_richness, proportion_value + +# Version of the method catalogue and the shape of a result. A change to what a +# method computes bumps this, and stored records keep the version they were made +# under. +const METHOD_VERSION = "1.0.0" + +# Schema tag of a stored record, distinct from the method version so that the +# storage layout can evolve without pretending an analysis changed. +const RECORD_SCHEMA = "metamanifold.statistics/1" + +# Rarefaction is exact but not free: the expectation is a product of `depth` +# rational factors per feature. Both bounds are refusals, not truncations. +const MAX_RAREFACTION_DEPTH = 10_000 +const MAX_RAREFACTION_WORK = 5_000_000 + +## --------------------------------------------------------------------------- +## What this layer claims, method by method +## --------------------------------------------------------------------------- + +""" + METHOD_CATALOGUE + +The bounded catalogue approved for this layer. Read-only: it is published as +data so the server can list it, the documentation can quote it, and a test can +check that every method named here actually exists. + +Each entry records the response type it accepts, whether it needs study-design +information, which numeric modes it supports, which of its fields are exact and +which are approximations, its uncertainty and multiple-testing statement, and +its limitations. A method that cannot state those things does not belong here. +""" +const METHOD_CATALOGUE = Dict{String,Any}( + + "summarise.counts" => Dict{String,Any}( + "version" => METHOD_VERSION, + "title" => "Per-sample and per-feature read-count summary", + "description" => "Exact totals, observed richness, zero-count features and mean " * + "reads per feature, per sample and per feature, over the supplied " * + "count matrix.", + "response" => "non-negative integer counts; samples x features", + "design_required" => false, + "numeric_modes" => collect(NUMERIC_MODES), + "exact_fields" => ["counts", "totals", "richness", "prevalence"], + "approximate_fields" => ["mean reads per feature (exact in exact mode)"], + "options" => Dict{String,Any}(), + "uncertainty" => "None. These are census summaries of the counts supplied, not " * + "estimates of a population quantity, so there is no sampling " * + "model to attach an interval to.", + "effect_size" => "None: no comparison between groups is made.", + "multiple_testing" => "Not applicable: no test is performed.", + "limitations" => [ + "A mean over features weights every feature equally, which has no biological " * + "interpretation unless the features are a meaningful sampling unit.", + "Zeros are counted, not interpreted. A zero can mean absence, failure to " * + "amplify, or a read lost to depth; this method cannot tell them apart.", + ], + ), + + "describe.alpha" => Dict{String,Any}( + "version" => METHOD_VERSION, + "title" => "Alpha diversity summary (richness, Simpson, Shannon, evenness)", + "description" => "Observed richness, the Gini-Simpson index, the Shannon index and " * + "Pielou evenness, per sample and for the pooled corpus.", + "response" => "non-negative integer counts; samples x features", + "design_required" => false, + "numeric_modes" => collect(NUMERIC_MODES), + "exact_fields" => ["richness"], + "approximate_fields" => ["Simpson index in float64/bigfloat modes (exact rational in " * + "exact mode)", "Shannon index (never exact)", "Pielou evenness (never exact)"], + "options" => Dict{String,Any}(), + "uncertainty" => "None. Indices are computed from the observed counts; no interval " * + "is estimated, because no sampling model is assumed.", + "effect_size" => "None: no comparison between groups is made.", + "multiple_testing" => "Not applicable: no test is performed.", + "limitations" => [ + "Indices are depth-sensitive unless the counts are rarefied first; this single " * + "sample summary does not normalise for library size, and comparing raw-index " * + "values across samples of different depth is not valid.", + "The Shannon index and Pielou evenness are transcendental and have no exact " * + "value: they are tagged `approximation` and refused outright in exact mode.", + "Pielou evenness is undefined for a sample with fewer than two observed " * + "features, and is reported as unsuccessful there rather than as zero or one.", + "Simpson here is the Gini-Simpson complement 1 - sum(p_i^2) on observed " * + "relative abundances. It is not an unbiased estimator of the population " * + "index, and no correction is applied.", + ], + ), + + "describe.proportions" => Dict{String,Any}( + "version" => METHOD_VERSION, + "title" => "Per-sample and corpus read proportions", + "description" => "Each feature's share of its sample and of the whole corpus.", + "response" => "non-negative integer counts; samples x features", + "design_required" => false, + "numeric_modes" => collect(NUMERIC_MODES), + "exact_fields" => ["counts", "sample totals", "corpus totals"], + "approximate_fields" => ["proportions in float64/bigfloat modes (exact rational in " * + "exact mode)"], + "options" => Dict{String,Any}(), + "uncertainty" => "None: a proportion of the observed reads is a census quantity.", + "effect_size" => "None: no comparison between groups is made.", + "multiple_testing" => "Not applicable: no test is performed.", + "limitations" => [ + "A proportion of reads is not an estimate of an organism's relative abundance; " * + "it is confounded by copy number, extraction and amplification efficiency.", + "A sample with zero reads has no proportions. It is reported as unsuccessful " * + "for that sample, never as all-zero rows.", + "Proportions are compositional: they sum to one, so they are not independent " * + "of each other and must not be used as independent response variables.", + ], + ), + + "rarefaction.expected_richness" => Dict{String,Any}( + "version" => METHOD_VERSION, + "title" => "Exact expected richness at a given rarefied depth", + "description" => "Hurlbert's (1971) expected number of observed features in a " * + "subsample of `depth` reads drawn without replacement from each " * + "sample. Exact rational in exact mode; no resampling is performed.", + "response" => "non-negative integer counts; samples x features", + "design_required" => false, + "numeric_modes" => collect(NUMERIC_MODES), + "exact_fields" => ["expected richness in exact mode"], + "approximate_fields" => ["expected richness in float64/bigfloat modes"], + "options" => Dict{String,Any}( + "depth" => Dict{String,Any}( + "required" => true, + "type" => "integer", + "range" => "1 .. $(MAX_RAREFACTION_DEPTH), and no more than the sample's library size", + "description" => "reads per subsample", + ), + ), + "uncertainty" => "None beyond the subsampling itself: this is the exact expectation " * + "over subsamples, not an interval and not a resampled estimate. It " * + "carries no Monte Carlo error, which is the point of computing it " * + "in closed form.", + "effect_size" => "None: no comparison between groups is made.", + "multiple_testing" => "Not applicable: no test is performed.", + "limitations" => [ + "The expectation assumes reads are sampled without replacement from the " * + "observed library and that the observed library is the whole community. It " * + "does not correct for unobserved species, PCR bias or sequencing error.", + "Rarefaction discards reads. Standardising depth does not make indices " * + "comparable across samples that differ in richness, and an expected value is " * + "not an observed value.", + "A depth above a sample's library size is refused rather than clamped.", + "Exact mode can exceed `max_digits` at large depth; the failure is explicit " * + "and says so, and no approximation is substituted.", + ], + ), + + "compare.taxon_ratio" => Dict{String,Any}( + "version" => METHOD_VERSION, + "title" => "Exact read-count ratio between two features", + "description" => "The ratio of two named features' total counts, corpus-wide and " * + "per sample.", + "response" => "non-negative integer counts; samples x features", + "design_required" => false, + "numeric_modes" => collect(NUMERIC_MODES), + "exact_fields" => ["counts", "ratio in exact mode"], + "approximate_fields" => ["ratio in float64/bigfloat modes"], + "options" => Dict{String,Any}( + "numerator" => Dict{String,Any}("required" => true, "type" => "feature name"), + "denominator" => Dict{String,Any}("required" => true, "type" => "feature name"), + ), + "uncertainty" => "None: a ratio of observed counts, with no interval estimated.", + "effect_size" => "The ratio is a descriptive fold-ratio, not a tested effect. " * + "No null value is assumed and no significance is implied.", + "multiple_testing" => "Not applicable: no test is performed.", + "limitations" => [ + "A ratio of read counts is not a ratio of abundances; it inherits every bias in " * + "the counts it is built from.", + "A zero denominator is refused for the scope in which it occurs (corpus or " * + "sample); it is never replaced by a pseudocount here.", + "Two features chosen because their ratio looks interesting produce a biased " * + "summary; this method does not know how the pair was selected.", + ], + ), +) + +""" + method_catalogue() -> Dict{String,Any} + +The catalogue itself. Callers must treat it as read-only. +""" +method_catalogue() = METHOD_CATALOGUE + +""" +Global statements attached to every record, so no result can be read as claiming +more than the layer supports. +""" +const GLOBAL_LIMITATIONS = [ + "This layer produces descriptive summaries only. It performs no hypothesis test, " * + "estimates no p-value, confidence interval or effect size, and makes no statement " * + "about significance.", + "Exact arithmetic removes representation error and nothing else. Measurement error, " * + "sampling uncertainty, model misspecification and biological bias are all unaffected.", + "Higher precision is not exactness. Values tagged `approximation` are approximations " * + "at the stated precision and rounding mode, and no later step promotes them.", + "Counts are read from the results database as binary64; values above 2^53-1 cannot be " * + "trusted to be the integers they came from and are refused.", +] + +## --------------------------------------------------------------------------- +## Failures, warnings and the per-entry state machine +## --------------------------------------------------------------------------- + +mutable struct _Context + policy :: NumericPolicy + warnings :: Vector{Dict{String,Any}} + failures :: Vector{Dict{String,Any}} +end + +_warn!(ctx::_Context, code::String, message::String) = + push!(ctx.warnings, Dict{String,Any}("code" => code, "message" => message)) + +_fail!(ctx::_Context, code::String, scope::String, message::String) = + push!(ctx.failures, Dict{String,Any}("code" => code, "scope" => scope, "message" => message)) + +""" + _entry(ctx, scope, f) -> Dict{String,Any} + +Build one named entry of a result, converting a numeric refusal into an explicit +unsuccessful entry that names its scope and carries the refusal's stable code. +A per-entry failure does not stop the rest of the table: a survey with one +zero-read sample still reports the others, and the failed sample is visible. +""" +function _entry(ctx::_Context, scope::String, f::Function) + try + d = f() + d = d isa Dict{String,Any} ? d : Dict{String,Any}(String(k) => v for (k, v) in d) + haskey(d, "status") || (d["status"] = "complete") + return d + catch e + e isa NumericError || rethrow() + message = sprint(showerror, e) + _fail!(ctx, error_code(e), scope, message) + return Dict{String,Any}("status" => "unsuccessful", + "code" => error_code(e), + "message" => message) + end +end + +""" + _field(ctx, scope, f) + +One field of an entry, allowed to fail on its own. A sample whose Shannon index +is unsupported in exact mode still reports its exact richness and Simpson index; +the unsupported field carries its code and message, and the entry's own status +becomes `"partial"`. +""" +function _field(ctx::_Context, scope::String, f::Function) + try + return f() + catch e + e isa NumericError || rethrow() + message = sprint(showerror, e) + _fail!(ctx, error_code(e), scope, message) + return Dict{String,Any}("status" => "unsuccessful", + "code" => error_code(e), + "message" => message) + end +end + +_is_unsuccessful(x) = x isa AbstractDict && get(x, "status", nothing) == "unsuccessful" + +## --------------------------------------------------------------------------- +## Plain-data conversion +## --------------------------------------------------------------------------- + +""" +Options travel into a stored record and therefore into a content hash. They are +accepted only in the JSON shapes the API can send back, and anything else is +refused rather than stringified: an option that cannot be represented is a +configuration mistake, not something to paper over. +""" +_plain(::Nothing) = nothing +_plain(x::Bool) = x +_plain(x::Integer) = begin + x isa Bool && throw(ArgumentError("a Bool is not an integer option")) + (typemin(Int) <= x <= typemax(Int)) || throw(ArgumentError( + "the integer option $x is outside the range a stored record can carry")) + Int(x) +end +_plain(x::AbstractFloat) = begin + isfinite(x) || throw(ArgumentError("the option $x is not finite")) + y = Float64(x) + # A BigFloat beyond binary64's range would become Inf here, so it is refused + # rather than stored as an infinity a consumer cannot read back. + isfinite(y) || throw(ArgumentError( + "the option $x is outside the range a stored binary64 can carry")) + y +end +_plain(x::AbstractString) = String(x) +_plain(x::AbstractVector) = Any[_plain(v) for v in x] +_plain(x::AbstractDict) = Dict{String,Any}(String(k) => _plain(v) for (k, v) in x) +_plain(x) = throw(ArgumentError("unsupported option type $(typeof(x))")) + +# JSON reads objects as JSON3.Object, which is not an AbstractDict; convert the +# stored document back to plain data so the rest of the module has one shape. +_plain_json(::Nothing) = nothing +_plain_json(x::Bool) = x +_plain_json(x::Real) = x +_plain_json(x::AbstractString) = String(x) +_plain_json(x::AbstractVector) = Any[_plain_json(v) for v in x] +_plain_json(x::AbstractDict) = Dict{String,Any}(String(k) => _plain_json(v) for (k, v) in x) +_plain_json(x::JSON3.Object) = Dict{String,Any}(String(k) => _plain_json(v) for (k, v) in x) +_plain_json(x) = string(x) + +_sum_big(v) = foldl(+, v; init = BigInt(0)) + +## --------------------------------------------------------------------------- +## The statistics themselves +## --------------------------------------------------------------------------- + +""" + simpson_index(counts) -> Rational{BigInt} + +The Gini-Simpson complement `1 - sum(p_i^2)` on observed relative abundances, +as an exact rational. Throws `ZeroDenominator` when the counts are all zero. +""" +function simpson_index(counts::AbstractVector{<:Integer}) + total = _sum_big(counts) + total == 0 && throw(ZeroDenominator("Simpson index of a sample with zero reads")) + squares = foldl(+, (BigInt(c)^2 for c in counts); init = BigInt(0)) + return 1 - squares // (total^2) +end + +""" + proportion_value(count, total) -> Rational{BigInt} + +`count/total` exactly. A zero total throws `ZeroDenominator`. +""" +function proportion_value(count::Integer, total::Integer) + return exact_ratio(count, total; what = "proportion of a zero-read total") +end + +# The approximate helpers below are shared by the public functions and the +# per-entry closures. They all refuse a zero total explicitly: in floating point +# 0/0 is NaN, and a NaN arriving at the serialiser is a much worse failure mode +# than a named refusal, because it survives arithmetic and only fails at the +# boundary. Every one of them is called inside an `approximate_value` block, so +# its BigFloats are at the requested precision. + +_require_positive_total(total, what::String) = + total == 0 ? throw(ZeroDenominator(what)) : total + +function _simpson_approx(counts, total) + _require_positive_total(total, "Simpson index of a sample with zero reads") + n = BigFloat(total) + s = BigFloat(0) + for c in counts + c == 0 && continue + p = BigFloat(BigInt(c)) / n + s += p * p + end + return 1 - s +end + +function _shannon_approx(counts, total) + _require_positive_total(total, "Shannon index of a sample with zero reads") + n = BigFloat(total) + h = BigFloat(0) + for c in counts + c == 0 && continue + p = BigFloat(BigInt(c)) / n + h -= p * log(p) + end + return h +end + +function _evenness_approx(counts, total, richness::Integer) + richness >= 2 || throw(InvalidNumericInput("Pielou evenness", + "evenness needs at least two observed features, got $richness")) + _require_positive_total(total, "Pielou evenness of a sample with zero reads") + return _shannon_approx(counts, total) / log(BigFloat(richness)) +end + +function _proportion_approx(count::Integer, total; what::String = "proportion of a zero-read total") + _require_positive_total(total, what) + return BigFloat(BigInt(count)) / BigFloat(total) +end + +function _mean_approx(total, n::Integer) + n == 0 && throw(ZeroDenominator("mean over zero items")) + return BigFloat(total) / BigFloat(n) +end + +""" + shannon_index(counts, policy) -> BigFloat + +`-sum(p_i log p_i)` at the policy's working precision. Transcendental, so never +exact; in exact mode this throws `UnsupportedNumericMode` rather than returning a +number the caller did not ask for. +""" +function shannon_index(counts::AbstractVector{<:Integer}, policy::NumericPolicy) + total = _sum_big(counts) + return approximate_value(policy; what = "Shannon index") do + _shannon_approx(counts, total) + end +end + +""" + expected_richness(counts, depth; policy) -> Union{Rational{BigInt},BigFloat} + +Hurlbert's (1971) expected number of features in a subsample of `depth` reads +drawn without replacement: + + E[S_m] = sum_i (1 - C(N - n_i, m) / C(N, m)) + +computed as a product of `m` exact rational factors per feature, so no binomial +coefficient is ever materialised. `depth` must be at least 1 and at most the +library size; a larger depth throws `ArgumentError` (the caller should refuse the +sample explicitly rather than let it be clamped). In exact mode the result is an +exact rational, and exceeding `max_digits` throws `NumericLimit`. +""" +function expected_richness(counts::AbstractVector{<:Integer}, depth::Integer; + policy::NumericPolicy = NumericPolicy()) + (depth isa Bool || !(depth isa Integer) || depth < 1) && throw(ArgumentError( + "rarefaction depth must be an integer of at least 1, not $depth")) + depth <= MAX_RAREFACTION_DEPTH || throw(ResourceLimit( + "a rarefaction depth of $depth", MAX_RAREFACTION_DEPTH)) + total = _sum_big(counts) + depth <= total || throw(ArgumentError( + "rarefaction depth $depth exceeds the library size $total")) + m = Int(depth) + + if policy.mode === :exact + s = zero(Rational{BigInt}) + for c in counts + c == 0 && continue + p = one(Rational{BigInt}) + for k in 0:(m - 1) + p *= (total - BigInt(c) - k) // (total - k) + # A zero factor (m > N - n_i) makes the probability zero for this + # feature and stays zero, which is the correct value; the product + # is checked against the digit bound as it grows. + k % 128 == 0 && check_limit(p, policy; what = "expected richness") + end + s += 1 - p + check_limit(s, policy; what = "expected richness") + end + return s + end + + return approximate_value(policy; what = "expected richness") do + n = BigFloat(total) + s = BigFloat(0) + for c in counts + c == 0 && continue + p = BigFloat(1) + for k in 0:(m - 1) + available = n - BigFloat(BigInt(c)) - BigFloat(k) + if available <= 0 + p = BigFloat(0) + break + end + p *= available / (n - BigFloat(k)) + end + s += 1 - p + end + s + end +end + +## --------------------------------------------------------------------------- +## Value rendering: exact in exact mode, approximation otherwise +## --------------------------------------------------------------------------- + +# In float64 mode an approximation is reported as binary64 (the type the rest of +# the application uses) but computed at 53 bits first, so the value is the +# correctly-rounded result at that width rather than a differently-rounded one. +_render_approx(x::BigFloat, policy::NumericPolicy) = + policy.mode === :float64 ? approx_json(Float64(x); policy = policy) : approx_json(x; policy = policy) + +""" + _statistic(ctx, exact_f, approx_f, what) + +One derived value, rendered according to the policy. In exact mode the exact +closure runs and its `Rational`/`Integer` is tagged exact. In the approximate +modes the approximate closure runs at the policy's precision, tagged as an +approximation. Neither path is used to rescue the other: an exact-mode refusal +is a refusal, and an approximation is never relabelled exact. +""" +function _statistic(ctx::_Context, exact_f::Function, approx_f::Function, what::String) + if ctx.policy.mode === :exact + return exact_json(exact_f(); policy = ctx.policy) + end + v = approximate_value(ctx.policy; what = what) do + approx_f() + end + return _render_approx(v, ctx.policy) +end + +_exact_count_json(ctx::_Context, x::Integer) = exact_json(BigInt(x); policy = ctx.policy) + +## --------------------------------------------------------------------------- +## The methods +## --------------------------------------------------------------------------- + +function _sample_entry(ctx::_Context, sample::String, row::AbstractVector{BigInt}, nfeat::Int) + return _entry(ctx, "sample:$sample", () -> begin + total = _sum_big(row) + d = Dict{String,Any}() + d["total_reads"] = _exact_count_json(ctx, total) + d["richness"] = _exact_count_json(ctx, count(!iszero, row)) + d["zero_count_features"] = _exact_count_json(ctx, count(iszero, row)) + d["mean_per_feature"] = _statistic(ctx, + () -> exact_ratio(total, BigInt(nfeat); policy = ctx.policy, + what = "mean reads per feature"), + () -> _mean_approx(total, nfeat), + "mean reads per feature") + d + end) +end + +function _feature_entry(ctx::_Context, feature::String, column::AbstractVector{BigInt}) + return _entry(ctx, "feature:$feature", () -> begin + total = _sum_big(column) + ns = length(column) + d = Dict{String,Any}() + d["total_reads"] = _exact_count_json(ctx, total) + d["prevalence"] = _exact_count_json(ctx, count(!iszero, column)) + d["mean_per_sample"] = _statistic(ctx, + () -> exact_ratio(total, BigInt(ns); policy = ctx.policy, + what = "mean reads per sample"), + () -> _mean_approx(total, ns), + "mean reads per sample") + d + end) +end + +function _describe_counts(ctx::_Context, exact::Matrix{BigInt}, + samples::Vector{String}, features::Vector{String}, opts) + ns, nf = size(exact) + result = Dict{String,Any}() + result["samples"] = Any[_sample_entry(ctx, samples[i], view(exact, i, :), nf) for i in 1:ns] + result["features"] = Any[_feature_entry(ctx, features[j], view(exact, :, j)) for j in 1:nf] + result["corpus"] = _entry(ctx, "corpus", () -> begin + total = sum(exact) + d = Dict{String,Any}() + d["total_reads"] = _exact_count_json(ctx, total) + d["observed_features"] = _exact_count_json(ctx, count(!iszero, vec(sum(exact; dims = 1)))) + d["samples"] = _exact_count_json(ctx, ns) + d["features"] = _exact_count_json(ctx, nf) + d + end) + return result +end + +function _alpha_entry(ctx::_Context, label::String, counts::AbstractVector{BigInt}) + return _entry(ctx, "sample:$label", () -> begin + total = _sum_big(counts) + rich = count(!iszero, counts) + d = Dict{String,Any}() + d["total_reads"] = _exact_count_json(ctx, total) + d["richness"] = _exact_count_json(ctx, rich) + # A zero-read sample has no index value at all: the fields below throw + # ZeroDenominator and are stored as unsuccessful, while the exact counts + # above remain available. + d["simpson"] = _field(ctx, "sample:$label simpson", () -> _statistic(ctx, + () -> simpson_index(counts), + () -> _simpson_approx(counts, total), + "Simpson index")) + d["shannon"] = _field(ctx, "sample:$label shannon", () -> _statistic(ctx, + () -> throw(UnsupportedNumericMode("Shannon index", :exact, + "the Shannon index involves logarithms and has no exact rational value")), + () -> _shannon_approx(counts, total), + "Shannon index")) + d["evenness"] = _field(ctx, "sample:$label evenness", () -> _statistic(ctx, + () -> throw(UnsupportedNumericMode("Pielou evenness", :exact, + "Pielou evenness is a ratio of logarithms and has no exact rational value")), + () -> _evenness_approx(counts, total, rich), + "Pielou evenness")) + d["status"] = any(_is_unsuccessful, (d["simpson"], d["shannon"], d["evenness"])) ? + "partial" : "complete" + d + end) +end + +function _describe_alpha(ctx::_Context, exact::Matrix{BigInt}, + samples::Vector{String}, features::Vector{String}, opts) + ns, nf = size(exact) + result = Dict{String,Any}() + result["samples"] = Any[_alpha_entry(ctx, samples[i], view(exact, i, :)) for i in 1:ns] + result["corpus"] = _alpha_entry(ctx, "corpus (all samples pooled)", vec(sum(exact; dims = 1))) + result["index_definitions"] = Dict{String,Any}( + "richness" => "number of features with a non-zero count", + "simpson" => "1 - sum(p_i^2) on observed relative abundances (Gini-Simpson complement)", + "shannon" => "-sum(p_i log p_i)", + "evenness" => "Shannon / log(richness), defined only for richness >= 2", + ) + return result +end + +function _describe_proportions(ctx::_Context, exact::Matrix{BigInt}, + samples::Vector{String}, features::Vector{String}, opts) + ns, nf = size(exact) + result = Dict{String,Any}() + samples_out = Vector{Any}(undef, ns) + for i in 1:ns + row = view(exact, i, :) + samples_out[i] = _entry(ctx, "sample:$(samples[i])", () -> begin + total = _sum_big(row) + d = Dict{String,Any}() + d["total_reads"] = _exact_count_json(ctx, total) + d["proportions"] = Any[ + _field(ctx, "sample:$(samples[i]) feature:$(features[j])", () -> begin + c = BigInt(row[j]) + _statistic(ctx, + () -> exact_ratio(c, total; policy = ctx.policy, + what = "proportion over the zero-read sample " * + "'$(samples[i])'"), + () -> _proportion_approx(c, total), + "proportion") + end) for j in 1:nf + ] + d["status"] = any(_is_unsuccessful, d["proportions"]) ? "partial" : "complete" + d + end) + end + result["samples"] = samples_out + result["features"] = Any[features[j] for j in 1:nf] + feature_totals = [sum(view(exact, :, j)) for j in 1:nf] + corpus_total = sum(exact) + result["corpus"] = _entry(ctx, "corpus", () -> begin + d = Dict{String,Any}() + d["total_reads"] = _exact_count_json(ctx, corpus_total) + d["proportions"] = Any[ + _field(ctx, "corpus feature:$(features[j])", () -> _statistic(ctx, + () -> exact_ratio(feature_totals[j], corpus_total; policy = ctx.policy, + what = "corpus proportion of a zero-read corpus"), + () -> _proportion_approx(feature_totals[j], corpus_total), + "proportion")) for j in 1:nf + ] + d["status"] = any(_is_unsuccessful, d["proportions"]) ? "partial" : "complete" + d + end) + return result +end + +function _rarefaction_entry(ctx::_Context, label::String, row::AbstractVector{BigInt}, depth::Int) + return _entry(ctx, "sample:$label", () -> begin + total = _sum_big(row) + if depth > total + message = "rarefaction depth $depth exceeds the library of $total reads in " * + "sample '$label'" + _fail!(ctx, "depth_exceeds_library", "sample:$label", message) + return Dict{String,Any}("status" => "unsuccessful", + "code" => "depth_exceeds_library", + "message" => message, + "library_reads" => _exact_count_json(ctx, total), + "depth" => depth) + end + d = Dict{String,Any}() + d["library_reads"] = _exact_count_json(ctx, total) + d["depth"] = depth + d["expected_richness"] = _statistic(ctx, + () -> expected_richness(row, depth; policy = ctx.policy), + () -> _expected_richness_approx(row, total, depth), + "expected richness at depth $depth") + d + end) +end + +function _expected_richness_approx(counts, total::BigInt, m::Int) + # Evaluated inside the policy's precision block by `_statistic`; every value + # here is therefore a BigFloat at the requested precision. + n = BigFloat(total) + s = BigFloat(0) + for c in counts + c == 0 && continue + p = BigFloat(1) + for k in 0:(m - 1) + available = n - BigFloat(BigInt(c)) - BigFloat(k) + if available <= 0 + p = BigFloat(0) + break + end + p *= available / (n - BigFloat(k)) + end + s += 1 - p + end + s +end + +function _expected_richness_method(ctx::_Context, exact::Matrix{BigInt}, + samples::Vector{String}, features::Vector{String}, opts) + depth = get(opts, "depth", nothing) + (depth isa Integer && !(depth isa Bool)) || throw(ArgumentError( + "rarefaction.expected_richness requires an integer option 'depth'")) + depth >= 1 || throw(ArgumentError("option 'depth' must be at least 1, not $depth")) + depth <= MAX_RAREFACTION_DEPTH || throw(ResourceLimit( + "a rarefaction depth of $depth", MAX_RAREFACTION_DEPTH)) + work = BigInt(size(exact, 1)) * BigInt(size(exact, 2)) * BigInt(depth) + work <= MAX_RAREFACTION_WORK || throw(ResourceLimit( + "a rarefaction depth of $depth over $(size(exact, 1)) samples and " * + "$(size(exact, 2)) features ($(work) factor-steps)", MAX_RAREFACTION_WORK)) + ns = size(exact, 1) + return Dict{String,Any}( + "depth" => Int(depth), + "samples" => Any[_rarefaction_entry(ctx, samples[i], view(exact, i, :), Int(depth)) for i in 1:ns], + "note" => "Exact expectation over subsamples drawn without replacement; no resampling " * + "and no seed is involved, so there is no Monte Carlo error to report.", + ) +end + +function _taxon_ratio_method(ctx::_Context, exact::Matrix{BigInt}, + samples::Vector{String}, features::Vector{String}, opts) + num_name = get(opts, "numerator", nothing) + den_name = get(opts, "denominator", nothing) + (num_name isa AbstractString && den_name isa AbstractString) || throw(ArgumentError( + "compare.taxon_ratio requires string options 'numerator' and 'denominator'")) + num_name = String(num_name); den_name = String(den_name) + num_name == den_name && throw(ArgumentError( + "compare.taxon_ratio needs two different features, both are '$num_name'")) + ni = findfirst(==(num_name), features) + di = findfirst(==(den_name), features) + isnothing(ni) && throw(ArgumentError("feature '$num_name' is not in this table")) + isnothing(di) && throw(ArgumentError("feature '$den_name' is not in this table")) + + ns = size(exact, 1) + num_totals = vec(sum(exact; dims = 1)) + ratio_for = (scope, n, d) -> _entry(ctx, scope, () -> Dict{String,Any}( + "numerator_total" => _exact_count_json(ctx, n), + "denominator_total" => _exact_count_json(ctx, d), + "ratio" => _statistic(ctx, + () -> exact_ratio(n, d; policy = ctx.policy, what = "ratio in $scope"), + () -> _proportion_approx(n, d; what = "ratio with a zero denominator"), + "ratio"), + )) + + result = Dict{String,Any}() + result["numerator"] = num_name + result["denominator"] = den_name + result["corpus"] = ratio_for("corpus", num_totals[ni], num_totals[di]) + result["samples"] = Any[ + ratio_for("sample:$(samples[i])", exact[i, ni], exact[i, di]) for i in 1:ns + ] + return result +end + +const _METHOD_RUNNERS = Dict{String,Function}( + "summarise.counts" => _describe_counts, + "describe.alpha" => _describe_alpha, + "describe.proportions" => _describe_proportions, + "rarefaction.expected_richness" => _expected_richness_method, + "compare.taxon_ratio" => _taxon_ratio_method, +) + +## --------------------------------------------------------------------------- +## Diagnostics and provenance +## --------------------------------------------------------------------------- + +function _diagnostics(exact, samples::Vector{String}, features::Vector{String}, ctx::_Context) + ns, nf = size(exact) + sample_totals = [_sum_big(view(exact, i, :)) for i in 1:ns] + feature_totals = [_sum_big(view(exact, :, j)) for j in 1:nf] + largest = isempty(exact) ? BigInt(0) : maximum(exact) + return Dict{String,Any}( + "numeric_mode" => String(ctx.policy.mode), + "arithmetic_precision_bits" => ctx.policy.mode === :exact ? nothing : + arithmetic_bits(ctx.policy), + "max_digits" => ctx.policy.max_digits, + "counts" => Dict{String,Any}( + "samples" => ns, + "features" => nf, + "total_reads" => string(_sum_big(exact)), + "largest_count" => string(largest), + "zero_total_samples" => count(iszero, sample_totals), + "zero_count_features" => count(iszero, feature_totals), + "singleton_features" => count(==(BigInt(1)), feature_totals), + ), + "duplicate_sample_names" => length(unique(samples)) != ns, + "duplicate_feature_names" => length(unique(features)) != nf, + "unsuccessful_entries" => length(ctx.failures), + ) +end + +const PACKAGE_VERSION = let v = try + pkgversion(parentmodule(@__MODULE__)) + catch + nothing + end + isnothing(v) ? "0.0.0+unknown" : string(v) +end + +# Recorded defensively: the MPFR version is a Base internal, and a result must +# not fail to be recorded because a Julia release moved or renamed it. +function _mpfr_version_string() + if isdefined(Base.MPFR, :MPFR_VERSION_STRING) + return string(Base.MPFR.MPFR_VERSION_STRING) + elseif isdefined(Base.MPFR, :version) + return string(Base.MPFR.version()) + end + return "unknown" +end + +const MPFR_VERSION = try + _mpfr_version_string() +catch + "unknown" +end + +function _provenance(method::String, policy::NumericPolicy, opts::Dict{String,Any}, + seed::Integer, source::Dict{String,Any}, fingerprint::String, + counts::Tuple{Int,Int}) + return Dict{String,Any}( + "software" => Dict{String,Any}( + "name" => "MetaManifold", + "version" => PACKAGE_VERSION, + "julia" => string(VERSION), + "mpfr" => MPFR_VERSION, + "numeric_policy_version" => policy.version, + ), + "method" => Dict{String,Any}("name" => method, + "version" => string(METHOD_CATALOGUE[method]["version"])), + "numeric_policy" => policy_dict(policy), + "parameters" => opts, + "seed" => Int(seed), + "randomness" => Dict{String,Any}( + "used" => false, + "note" => "no method in this catalogue draws a random number; the seed is " * + "recorded so a future resampling method is comparable, not because " * + "it affects this result", + ), + "input" => Dict{String,Any}( + "fingerprint" => fingerprint, + "fingerprint_algorithm" => "JCS (RFC 8785) canonical JSON, SHA-256", + "samples" => counts[1], + "features" => counts[2], + "source" => source, + ), + ) +end + +""" + input_fingerprint(counts, samples, features) -> String + +Content address of exactly what was analysed: the ordered sample names, the +ordered feature names, and every count as a decimal string, serialized as +canonical JSON (RFC 8785) and hashed with SHA-256. + +Counts are strings, so the fingerprint of a table with a count above 2^53-1 is +still well defined and is not silently shortened by a JSON number. +""" +function input_fingerprint(counts::AbstractMatrix{<:Integer}, + samples::AbstractVector{<:AbstractString}, + features::AbstractVector{<:AbstractString}) + size(counts) == (length(samples), length(features)) || throw(ArgumentError( + "count matrix is $(size(counts, 1)) x $(size(counts, 2)), but " * + "$(length(samples)) sample and $(length(features)) feature names were given")) + payload = Dict{String,Any}( + "schema" => "metamanifold.statistics.input/1", + "samples" => collect(String.(samples)), + "features" => collect(String.(features)), + # Column-major flattening of a Julia matrix; the names above pin the axes, + # so the order is unambiguous. + "counts" => [string(BigInt(counts[i])) for i in eachindex(counts)], + ) + return jcs_sha256(payload) +end + +## --------------------------------------------------------------------------- +## The entry point +## --------------------------------------------------------------------------- + +""" + analyse(counts, samples, features; method, policy, options, seed, source) + +Run one catalogue method over a samples x features count matrix and return a +record: `status`, `failures`, `warnings`, `results`, `diagnostics`, +`limitations`, `provenance`, plus the method's own output. + +`status` is one of: + + * `"complete"` - every entry was computed; + * `"partial"` - some entries are explicitly unsuccessful (each carries its own + code and message) while others are usable; + * `"unsuccessful"` - the input could not be converted exactly, or the method + refused the request outright. `results` is then empty. + +An unknown method, a size mismatch between the matrix and its names, or an +option of the wrong type is an `ArgumentError`: those are request errors, and +returning a record for them would suggest an analysis happened. Data-dependent +refusals are never thrown - they are recorded, because they belong to the result. +""" +function analyse(counts::AbstractMatrix{<:Real}, + samples::AbstractVector{<:AbstractString}, + features::AbstractVector{<:AbstractString}; + method::AbstractString, + policy::NumericPolicy = NumericPolicy(), + options::AbstractDict = Dict{String,Any}(), + seed::Integer = 0, + source::AbstractDict = Dict{String,Any}()) + name = String(method) + entry = get(METHOD_CATALOGUE, name, nothing) + isnothing(entry) && throw(ArgumentError( + "unknown statistics method '$name' (supported: " * + join(sort!(collect(keys(METHOD_CATALOGUE))), ", ") * ")")) + size(counts) == (length(samples), length(features)) || throw(ArgumentError( + "count matrix is $(size(counts, 1)) x $(size(counts, 2)), but " * + "$(length(samples)) sample and $(length(features)) feature names were given")) + seed isa Bool && throw(ArgumentError("seed must be an integer, not a Bool")) + abs(seed) <= 2^53 - 1 || throw(ArgumentError( + "seed $seed is outside the exact-integer range a stored record can carry")) + + opts = _plain(options) + allowed = entry["options"] + for k in keys(opts) + haskey(allowed, k) || throw(ArgumentError( + "method '$name' has no option '$k' (it accepts: " * + (isempty(allowed) ? "none" : join(sort!(collect(keys(allowed))), ", ")) * ")")) + end + for (k, spec) in allowed + spec["required"] === true && !haskey(opts, k) && throw(ArgumentError( + "method '$name' requires option '$k'")) + end + + sample_names = String.(samples) + feature_names = String.(features) + ctx = _Context(policy, Dict{String,Any}[], Dict{String,Any}[]) + limitations = String[GLOBAL_LIMITATIONS...] + for l in entry["limitations"] + push!(limitations, String(l)) + end + + exact = nothing + try + exact = exact_counts(counts) + catch e + e isa NumericError || rethrow() + # The input itself could not be represented exactly. Every field of the + # analysis is undefined, so the record is unsuccessful as a whole. + _fail!(ctx, error_code(e), "input", sprint(showerror, e)) + end + + results = Dict{String,Any}() + if !isnothing(exact) + if length(unique(sample_names)) != length(sample_names) + _warn!(ctx, "duplicate_sample_names", + "two or more samples share a name; entries are still keyed by position") + end + if length(unique(feature_names)) != length(feature_names) + _warn!(ctx, "duplicate_feature_names", + "two or more features share a name; entries are still keyed by position") + end + total = sum(exact) + total == 0 && _warn!(ctx, "zero_reads", + "every count is zero; index values are undefined and are reported " * + "as unsuccessful rather than as zero") + runner = _METHOD_RUNNERS[name] + try + results = runner(ctx, exact, sample_names, feature_names, opts) + catch e + e isa NumericError || rethrow() + # A method-level refusal (a work bound, for instance): nothing partial + # is returned as if it were the answer. + empty!(results) + _fail!(ctx, error_code(e), "method", sprint(showerror, e)) + end + end + + status = if isnothing(exact) || isempty(results) + "unsuccessful" + elseif isempty(ctx.failures) + "complete" + else + "partial" + end + + fingerprint = isnothing(exact) ? nothing : + input_fingerprint(exact, sample_names, feature_names) + + return Dict{String,Any}( + "record_schema" => RECORD_SCHEMA, + "method" => Dict{String,Any}("name" => name, "version" => string(entry["version"])), + "status" => status, + "failures" => ctx.failures, + "warnings" => ctx.warnings, + "results" => results, + "diagnostics" => isnothing(exact) ? + Dict{String,Any}("numeric_mode" => String(policy.mode), "conversion_failed" => true) : + _diagnostics(exact, sample_names, feature_names, ctx), + "limitations" => limitations, + "provenance" => isnothing(exact) ? + Dict{String,Any}("numeric_policy" => policy_dict(policy), + "method" => Dict{String,Any}("name" => name, + "version" => string(entry["version"]))) : + _provenance(name, policy, opts, seed, + _plain(source), fingerprint, size(exact)), + ) +end + +## --------------------------------------------------------------------------- +## Immutable, content-addressed records +## --------------------------------------------------------------------------- + +const _ID_RE = Regex("\\A[0-9a-f]{64}\\z") + +""" + record_directory(project_dir) -> String + +Where a run's statistics records live: `{projects/.../run}/analysis/statistics`. +""" +record_directory(project_dir::AbstractString) = + joinpath(project_dir, "analysis", "statistics") + +struct RecordConflict <: Exception + id :: String + path :: String +end + +Base.showerror(io::IO, e::RecordConflict) = print(io, + "statistics record $(e.id) already exists at $(e.path) with different content; " * + "records are write-once, so this is a corrupt or partially written file, not a " * + "reason to replace a stored analysis") + +""" + analysis_id(record) -> String + +The record's content address: SHA-256 over its canonical JSON, excluding +`analysis_id` and `created_at`, so the same analysis of the same input under the +same settings hashes identically whenever and wherever it is recomputed, while a +changed setting, input or method version produces a different id. +""" +function analysis_id(record::AbstractDict) + payload = Dict{String,Any}() + for (k, v) in record + key = String(k) + (key == "analysis_id" || key == "created_at") && continue + payload[key] = v + end + return jcs_sha256(payload) +end + +_iso_timestamp(dt::DateTime) = + Dates.format(dt, "yyyy-mm-dd") * "T" * Dates.format(dt, "HH:MM:SS") * "Z" + +""" + write_record(project_dir, record; created_at=now(UTC)) + +Store `record` once, under its content address, and return +`(; id, path, created, bytes)`. + +Write-once is the point. An existing file at the same id is never replaced: if +the bytes match, the write is a no-op and `created` is false, so re-running an +unchanged analysis is idempotent; if they differ, the stored file is corrupt or +was written by a different schema under the same id, and `RecordConflict` is +raised rather than an analysis being overwritten. New settings produce a new id +and therefore a new, linked record beside the old one. + +The bytes written are canonical JSON, so two machines storing the same analysis +produce byte-identical files. The temporary file is renamed into place, so a +reader never sees a half-written record. +""" +function write_record(project_dir::AbstractString, record::AbstractDict; + created_at::DateTime = now(UTC)) + dir = record_directory(project_dir) + mkpath(dir) + id = analysis_id(record) + stored = Dict{String,Any}(String(k) => v for (k, v) in record) + stored["analysis_id"] = id + stored["created_at"] = _iso_timestamp(created_at) + bytes = jcs_bytes(stored) + path = joinpath(dir, id * ".json") + if isfile(path) + existing = read(path) + existing == bytes && return (; id = id, path = path, created = false, bytes = bytes) + throw(RecordConflict(id, path)) + end + tmp = path * ".tmp-" * string(getpid()) * "-" * string(Threads.threadid()) + try + open(tmp, "w") do io + write(io, bytes) + end + mv(tmp, path) + finally + isfile(tmp) && rm(tmp; force = true) + end + return (; id = id, path = path, created = true, bytes = bytes) +end + +""" + read_record(project_dir, id) -> Union{Dict{String,Any},Nothing} + +The stored record, as plain data, or `nothing` when it does not exist. An id +that is not a SHA-256 hex digest is an `ArgumentError` - the id reaches a +filesystem path, so it is validated rather than trusted. +""" +function read_record(project_dir::AbstractString, id::AbstractString) + occursin(_ID_RE, id) || throw(ArgumentError("'$id' is not a statistics analysis id")) + path = joinpath(record_directory(project_dir), String(id) * ".json") + isfile(path) || return nothing + return _plain_json(JSON3.read(read(path, String))) +end + +""" + list_records(project_dir) -> Vector{Dict{String,Any}} + +Every stored record's summary, newest filename order aside (`readdir` sorted), +each with `analysis_id`, `path`, `method`, `version`, `status`, `created_at` and +`fingerprint`. A file that cannot be parsed is listed with +`"unreadable" => true` rather than skipped: a missing record is a different fact +from a damaged one. +""" +function list_records(project_dir::AbstractString) + dir = record_directory(project_dir) + isdir(dir) || return Dict{String,Any}[] + out = Dict{String,Any}[] + for file in sort(readdir(dir)) + endswith(file, ".json") || continue + id = file[1:end-5] + occursin(_ID_RE, id) || continue + path = joinpath(dir, file) + summary = Dict{String,Any}("analysis_id" => id, "path" => path) + try + rec = _plain_json(JSON3.read(read(path, String))) + method = get(rec, "method", Dict{String,Any}()) + summary["method"] = get(method, "name", nothing) + summary["version"] = get(method, "version", nothing) + summary["status"] = get(rec, "status", nothing) + summary["created_at"] = get(rec, "created_at", nothing) + prov = get(rec, "provenance", Dict{String,Any}()) + inp = prov isa AbstractDict ? get(prov, "input", Dict{String,Any}()) : Dict{String,Any}() + summary["fingerprint"] = inp isa AbstractDict ? get(inp, "fingerprint", nothing) : nothing + summary["unreadable"] = false + catch + summary["unreadable"] = true + end + push!(out, summary) + end + return out +end + +end # module ExactStatistics diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl index 2d3af55f..6370f1bd 100644 --- a/src/server/routes/analysis.jl +++ b/src/server/routes/analysis.jl @@ -387,8 +387,11 @@ end # The chart surfaces an exclusion may apply to. "diversity" covers alpha, NMDS # and PERMANOVA; "taxa" is the rank-tagged chart; "composition" is the # category-tagged chart; "venn" the presence/absence sets; "differential" the -# per-taxon differential abundance test. -const _EXCLUSION_SURFACES = Set(["diversity", "taxa", "composition", "venn", "differential"]) +# per-taxon differential abundance test; "statistics" the exact-arithmetic +# statistics layer. No existing exclusion names "statistics", so adding it here +# changes none of their behaviour: it only makes the surface addressable. +const _EXCLUSION_SURFACES = Set(["diversity", "taxa", "composition", "venn", + "differential", "statistics"]) # Resolve the configured `analysis.exclude_categories` for a run into a vector of # (set, category, surfaces) specs. Each names a composition category set and the diff --git a/src/server/routes/statistics.jl b/src/server/routes/statistics.jl new file mode 100644 index 00000000..4b5f23df --- /dev/null +++ b/src/server/routes/statistics.jl @@ -0,0 +1,274 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) + +# Routes: the optional exact-arithmetic statistics layer (issue #1). +# +# These endpoints are additive. They read the same results database as the other +# analysis routes but write only their own immutable records under +# `projects/.../run/analysis/statistics/`, so a request here cannot change a +# pipeline output or an existing analysis. +# +# GET /api/v1/studies/{study}/analysis/statistics/catalogue +# POST /api/v1/studies/{study}/runs/{run}/analysis/statistics +# GET /api/v1/studies/{study}/runs/{run}/analysis/statistics +# GET /api/v1/studies/{study}/runs/{run}/analysis/statistics/{analysis_id} +# +# The POST answers 200 with the stored record even when the record's `status` is +# "partial" or "unsuccessful": the request succeeded, and the analysis's own +# outcome (with its codes and messages) is what the record is for. A client must +# read `status`, which is why every failure path also carries a machine-readable +# `code`. Only a malformed request, an unknown method or option, or an +# unrepresentable numeric policy is an HTTP error. + +using JSON3 +using ..ExactNumeric: NUMERIC_MODES, DEFAULT_PRECISION_BITS, DEFAULT_MAX_DIGITS, + NumericError, error_code, policy_from_config, policy_dict +using ..ExactStatistics: analyse, write_record, read_record, list_records, + method_catalogue, RecordConflict +using ..Analysis: sample_columns, filtered_df, sequence_column_name + +## Which column identifies a feature row, for the names stored with a record. +const _FEATURE_ID_COLUMNS = ("ASV", "OTU", "SeqName") + +function _statistics_feature_column(columns::Vector{String}) + for name in _FEATURE_ID_COLUMNS + name in columns && return name + end + return sequence_column_name(columns) +end + +""" + _statistics_matrix(con, table, scols, columns, where_clause, where_params) + -> (Matrix{Float64}, Vector{String}) + +The count matrix and its feature names, from one read of the filtered table. + +`filtered_counts` cannot serve here because the feature *names* must line up with +the matrix's columns: the name and the counts come from the same rows, in the +same identity order, so a record's feature list cannot describe a different +ordering than the analysis used. `missing` counts count as zero, as they do in +`filtered_counts`. +""" +function _statistics_matrix(con, table::String, scols::Vector{String}, + columns::Vector{String}, where_clause::String, where_params::Vector) + df = filtered_df(con, table, where_clause, where_params) + n = nrow(df) + features = if n == 0 + String[] + else + idcol = _statistics_feature_column(columns) + if isnothing(idcol) + [string(j) for j in 1:n] + else + [string(df[!, Symbol(idcol)][j]) for j in 1:n] + end + end + mat = Matrix{Float64}(undef, length(scols), n) + for (i, c) in enumerate(scols) + column = df[!, Symbol(c)] + for j in 1:n + v = column[j] + mat[i, j] = ismissing(v) ? 0.0 : Float64(v) + end + end + return mat, features +end + +""" + _statistics_numeric_section(study) -> Dict{String,Any} + +The study's `analysis.statistics.numeric` settings, resolved through the config +cascade. A missing or `nothing` value falls back to the factory default; an +unknown or invalid value raises `ArgumentError` from the policy constructor +rather than being ignored. +""" +function _statistics_numeric_section(study::String) + resolved = _resolved_run_config(study, nothing, nothing, nothing) + val(k, d) = something(get(get(resolved, k, (; value=d)), :value, d), d) + return Dict{String,Any}( + "mode" => val("analysis.statistics.numeric.mode", "float64"), + "precision_bits" => val("analysis.statistics.numeric.precision_bits", DEFAULT_PRECISION_BITS), + "max_digits" => val("analysis.statistics.numeric.max_digits", DEFAULT_MAX_DIGITS), + "display_digits" => val("analysis.statistics.numeric.display_digits", 6), + ) +end + +""" + _statistics_numeric_override(raw) -> Dict{String,Any} + +The optional per-request `numeric` object. Only the four policy keys are +accepted, and only as plain scalars; `policy_from_config` validates the values +and their ranges, so an out-of-range precision is a 400 and never a silently +clamped setting. +""" +function _statistics_numeric_override(raw) + isnothing(raw) && return Dict{String,Any}() + (raw isa AbstractDict || raw isa JSON3.Object) || throw(ArgumentError( + "numeric must be a JSON object with mode, precision_bits, max_digits or display_digits")) + out = Dict{String,Any}() + for (k, v) in raw + out[string(k)] = v + end + return out +end + +""" + _statistics_options(raw) -> Dict{String,Any} + +The optional per-request `options` object for the chosen method. Values are +passed through unchanged: `ExactStatistics.analyse` owns validation, so a nested +object or a wrong type is reported once, by the layer that defines what the +option means. +""" +function _statistics_options(raw) + isnothing(raw) && return Dict{String,Any}() + (raw isa AbstractDict || raw isa JSON3.Object) || throw(ArgumentError( + "options must be a JSON object")) + out = Dict{String,Any}() + for (k, v) in raw + out[string(k)] = v + end + return out +end + +## The catalogue: what this layer claims, published as data. +@get "/api/v1/studies/{study}/analysis/statistics/catalogue" function(req, study::String) + study in _study_names() || return json_error(404, "study_not_found", + "Study '$study' not found") + policy = try + policy_from_config(_statistics_numeric_section(study)) + catch e + e isa ArgumentError || rethrow() + return json_error(400, "invalid_numeric_policy", sprint(showerror, e)) + end + body = Dict{String,Any}( + "methods" => method_catalogue(), + "numeric_modes" => collect(NUMERIC_MODES), + "default_numeric_policy" => policy_dict(policy), + "record_schema" => "metamanifold.statistics/1", + ) + HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(body)) +end + +## Run one method and store the record. +@post "/api/v1/studies/{study}/runs/{run}/analysis/statistics" function(req, + study::String, + run::String) + group = _req_group(req) + dir, err = _require_run_results(study, run; group) + !isnothing(err) && return err + + body = JSON3.read(String(req.body)) + table = string(get(body, :table, "merged")) + method = string(get(body, :method, "summarise.counts")) + prefix = let p = get(body, :prefix, nothing); isnothing(p) ? nothing : string(p) end + params = _body_filter_params(body) + + policy = try + policy_from_config(_statistics_numeric_section(study); + overrides = _statistics_numeric_override(get(body, :numeric, nothing))) + catch e + e isa ArgumentError || rethrow() + return json_error(400, "invalid_numeric_policy", sprint(showerror, e)) + end + options = try + _statistics_options(get(body, :options, nothing)) + catch e + e isa ArgumentError || rethrow() + return json_error(400, "invalid_options", sprint(showerror, e)) + end + + cfg_cache = Dict{Tuple{String,String,String}, Any}() + response = _with_analysis_results_table(study, run, table; group) do con, columns + scols = _filter_by_prefix(sample_columns(con, table), prefix) + isempty(scols) && return json_error(400, "no_samples", + "No sample columns found in table '$table'") + # The statistics surface obeys the same category exclusions as the other + # analysis surfaces, but only when a config names it: the factory default + # lists its surfaces explicitly, so statistics sees the whole table until + # an exclusion says otherwise. + exclude_conditions = _exclusion_conditions(study, run, group, columns; + surface = "statistics", + config_cache = cfg_cache) + where_clause, where_params = _analysis_where_clause(params, columns; + exclude_conditions) + scols = _retain_sample_columns(con, table, scols, params, where_clause, where_params) + isempty(scols) && return json_error(400, "no_samples", + "No samples pass the sample read-count filter") + + mat, feature_names = _statistics_matrix(con, table, scols, columns, + where_clause, where_params) + isempty(feature_names) && return json_error(400, "no_features", + "No rows remain in table '$table' after filtering") + + record = try + analyse(mat, scols, feature_names; + method = method, + policy = policy, + options = options, + seed = _study_seed(study), + source = Dict{String,Any}( + "study" => study, + "run" => run, + "group" => isnothing(group) ? nothing : group, + "table" => table, + )) + catch e + e isa ArgumentError && return json_error(400, "invalid_request", sprint(showerror, e)) + e isa NumericError && return json_error(422, error_code(e), sprint(showerror, e)) + rethrow() + end + + written = try + write_record(_run_project_dir(study, run; group), record) + catch e + e isa RecordConflict && return json_error(409, "record_conflict", sprint(showerror, e)) + e isa ArgumentError && return json_error(500, "record_not_serialisable", + sprint(showerror, e)) + rethrow() + end + + out = Dict{String,Any}(record) + out["analysis_id"] = written.id + out["record_path"] = written.path + out["record_created"] = written.created + HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(out)) + end + return response +end + +## List a run's records: what was analysed, when, under which policy. +@get "/api/v1/studies/{study}/runs/{run}/analysis/statistics" function(req, + study::String, + run::String) + group = _req_group(req) + err = _validate_run_request(study, run) + !isnothing(err) && return err + records = list_records(_run_project_dir(study, run; group)) + body = Dict{String,Any}( + "records" => records, + "note" => "Records are immutable and content-addressed. An unreadable record is " * + "reported rather than hidden, because a missing record and a damaged " * + "one are different facts.", + ) + HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(body)) +end + +## One stored record, by content address. +@get "/api/v1/studies/{study}/runs/{run}/analysis/statistics/{analysis_id}" function(req, + study::String, + run::String, + analysis_id::String) + group = _req_group(req) + err = _validate_run_request(study, run) + !isnothing(err) && return err + record = try + read_record(_run_project_dir(study, run; group), analysis_id) + catch e + e isa ArgumentError || rethrow() + return json_error(400, "invalid_analysis_id", sprint(showerror, e)) + end + isnothing(record) && return json_error(404, "analysis_not_found", + "No statistics record '$analysis_id' for run '$run'") + HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(record)) +end diff --git a/src/server/server.jl b/src/server/server.jl index 2cafb385..e82474bd 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -102,6 +102,9 @@ module Server include(joinpath(@__DIR__, "routes", "databases.jl")) include(joinpath(@__DIR__, "routes", "events.jl")) include(joinpath(@__DIR__, "routes", "analysis.jl")) + # The optional exact-arithmetic statistics layer's endpoints. After + # analysis.jl, whose table/sample helpers it reuses. + include(joinpath(@__DIR__, "routes", "statistics.jl")) include(joinpath(@__DIR__, "routes", "read_funnel.jl")) include(joinpath(@__DIR__, "routes", "composition.jl")) include(joinpath(@__DIR__, "routes", "publication_tables.jl")) diff --git a/test/fixtures/statistics_known_answers.json b/test/fixtures/statistics_known_answers.json new file mode 100644 index 00000000..b525177f --- /dev/null +++ b/test/fixtures/statistics_known_answers.json @@ -0,0 +1,295 @@ +{ + "_provenance": { + "producer": "scripts/statistics-reference.py", + "language": "Python 3.11.2", + "arithmetic": "fractions.Fraction (exact rationals); decimal.Decimal at 80 digits for logarithms", + "independence": [ + "written from the published definitions, not ported from the Julia code", + "expected richness checked against both a closed form and exhaustive subset enumeration", + "Shannon values computed with Decimal.ln at 80 digits, not MPFR" + ], + "does_not_validated": "anything inferential: the layer makes no inference, and no statistical test is validated here", + "shannon_digits": 60 + }, + "simpson": [ + { + "counts": [ + 1, + 2 + ], + "value": "4/9", + "permuted_value": "4/9", + "scaled_by_three": "4/9", + "display_6": "0.444444" + }, + { + "counts": [ + 1, + 1, + 1, + 1 + ], + "value": "3/4", + "permuted_value": "3/4", + "scaled_by_three": "3/4", + "display_6": "0.750000" + }, + { + "counts": [ + 50, + 50 + ], + "value": "1/2", + "permuted_value": "1/2", + "scaled_by_three": "1/2", + "display_6": "0.500000" + }, + { + "counts": [ + 90, + 10 + ], + "value": "9/50", + "permuted_value": "9/50", + "scaled_by_three": "9/50", + "display_6": "0.180000" + }, + { + "counts": [ + 1 + ], + "value": "0/1", + "permuted_value": "0/1", + "scaled_by_three": "0/1", + "display_6": "0.000000" + }, + { + "counts": [ + 2, + 3, + 5, + 7, + 11 + ], + "value": "36/49", + "permuted_value": "36/49", + "scaled_by_three": "36/49", + "display_6": "0.734694" + }, + { + "counts": [ + 1000000, + 3000000, + 0 + ], + "value": "3/8", + "permuted_value": "3/8", + "scaled_by_three": "3/8", + "display_6": "0.375000" + }, + { + "counts": [ + 0, + 0, + 7 + ], + "value": "0/1", + "permuted_value": "0/1", + "scaled_by_three": "0/1", + "display_6": "0.000000" + } + ], + "expected_richness": [ + { + "counts": [ + 2, + 3 + ], + "depth": 1, + "value": "1/1", + "permuted_value": "1/1", + "display_6": "1.000000", + "brute_force": "1/1" + }, + { + "counts": [ + 2, + 3 + ], + "depth": 2, + "value": "8/5", + "permuted_value": "8/5", + "display_6": "1.600000", + "brute_force": "8/5" + }, + { + "counts": [ + 2, + 3 + ], + "depth": 5, + "value": "2/1", + "permuted_value": "2/1", + "display_6": "2.000000", + "brute_force": "2/1" + }, + { + "counts": [ + 1, + 1, + 1 + ], + "depth": 2, + "value": "2/1", + "permuted_value": "2/1", + "display_6": "2.000000", + "brute_force": "2/1" + }, + { + "counts": [ + 5, + 0, + 0 + ], + "depth": 3, + "value": "1/1", + "permuted_value": "1/1", + "display_6": "1.000000", + "brute_force": "1/1" + }, + { + "counts": [ + 10, + 20, + 30 + ], + "depth": 15, + "value": "172468264847/58326852185", + "permuted_value": "172468264847/58326852185", + "display_6": "2.956927", + "brute_force": null + }, + { + "counts": [ + 1, + 2, + 3, + 4 + ], + "depth": 5, + "value": "799/252", + "permuted_value": "799/252", + "display_6": "3.170635", + "brute_force": "799/252" + }, + { + "counts": [ + 4, + 4, + 4, + 4 + ], + "depth": 8, + "value": "50/13", + "permuted_value": "50/13", + "display_6": "3.846154", + "brute_force": "50/13" + } + ], + "shannon": [ + { + "counts": [ + 50, + 50 + ], + "value_decimal": "0.693147180559945309417232121458176568075500134360255254120680", + "digits": 60, + "double": 0.6931471805599453 + }, + { + "counts": [ + 90, + 10 + ], + "value_decimal": "0.325082973391448239506550028223817939235618484547823962910878", + "digits": 60, + "double": 0.32508297339144826 + }, + { + "counts": [ + 1, + 1, + 1 + ], + "value_decimal": "1.098612288668109691395245236922525704647490557822749451734694", + "digits": 60, + "double": 1.0986122886681098 + }, + { + "counts": [ + 1, + 1000000000 + ], + "value_decimal": "2.1723265815723145340272111085303833449722746614636210E-8", + "digits": 60, + "double": 2.1723265815723146e-08 + }, + { + "counts": [ + 25, + 25, + 25, + 25 + ], + "value_decimal": "1.386294361119890618834464242916353136151000268720510508241360", + "digits": 60, + "double": 1.3862943611198906 + } + ], + "proportions": [ + { + "counts": [ + 1, + 2 + ], + "values": [ + "1/3", + "2/3" + ] + }, + { + "counts": [ + 3, + 3, + 3 + ], + "values": [ + "1/3", + "1/3", + "1/3" + ] + }, + { + "counts": [ + 90, + 10 + ], + "values": [ + "9/10", + "1/10" + ] + }, + { + "counts": [ + 0, + 5, + 5 + ], + "values": [ + "0/1", + "1/2", + "1/2" + ] + } + ] +} diff --git a/test/runtests.jl b/test/runtests.jl index ae11985b..c58ecd03 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -56,6 +56,13 @@ const UNIT_FILES = [ "test_server_security.jl", "test_canonical.jl", "test_doi.jl", + # The optional exact-arithmetic statistics layer (issue #1). Its arithmetic + # is checked against an independent Python reference + # (test/fixtures/statistics_known_answers.json, produced by + # scripts/statistics-reference.py), and its negative controls are the same + # ones scripts/statistics-negative-controls.jl reports. + "test_exact_numeric.jl", + "test_exact_statistics.jl", ] # The files named on the command line ("test_routes.jl", "routes" or diff --git a/test/unit/test_exact_numeric.jl b/test/unit/test_exact_numeric.jl new file mode 100644 index 00000000..17c8a134 --- /dev/null +++ b/test/unit/test_exact_numeric.jl @@ -0,0 +1,352 @@ +# Unit tests for the numeric policy of the exact statistics layer (issue #1). +# +# What these tests are for: they pin the *contract* - which representations are +# accepted, which are refused, how a value is tagged on the way out, and that the +# working precision of one analysis cannot leak into another's. The arithmetic +# itself is checked against an independent implementation in +# test/unit/test_exact_statistics.jl (fixture: test/fixtures/statistics_known_answers.json). +using MetaManifold.ExactNumeric +using Random + +@testset "ExactNumeric" begin + + @testset "policy validation" begin + p = NumericPolicy() + @test p.mode === :float64 + @test p.precision_bits == DEFAULT_PRECISION_BITS + @test p.max_digits == DEFAULT_MAX_DIGITS + @test p.display_digits == 6 + @test p.version == POLICY_VERSION + + @test NumericPolicy(mode = " Exact ").mode === :exact + @test NumericPolicy(mode = "FLOAT64").mode === :float64 + @test NumericPolicy(mode = "bigfloat", precision_bits = 1024).precision_bits == 1024 + @test NumericPolicy(precision_bits = MIN_PRECISION_BITS).precision_bits == 53 + @test NumericPolicy(precision_bits = MAX_PRECISION_BITS).precision_bits == 4096 + @test NumericPolicy(max_digits = MAX_MAX_DIGITS).max_digits == MAX_MAX_DIGITS + @test NumericPolicy(display_digits = MAX_DISPLAY_DIGITS).display_digits == 50 + + # Unsupported or mistyped requests are refused, never coerced. + @test_throws ArgumentError NumericPolicy(mode = "decimal") + # (mode is matched case-insensitively, like every other name in the config) + @test_throws ArgumentError NumericPolicy(mode = :exact) + @test_throws ArgumentError NumericPolicy(precision_bits = 52) + @test_throws ArgumentError NumericPolicy(precision_bits = 4097) + @test_throws ArgumentError NumericPolicy(precision_bits = 256.0) + @test_throws ArgumentError NumericPolicy(precision_bits = true) + @test_throws ArgumentError NumericPolicy(max_digits = 15) + @test_throws ArgumentError NumericPolicy(max_digits = MAX_MAX_DIGITS + 1) + @test_throws ArgumentError NumericPolicy(display_digits = -1) + @test_throws ArgumentError NumericPolicy(display_digits = 51) + end + + @testset "policy_from_config" begin + cfg = Dict{String,Any}("mode" => "bigfloat", "precision_bits" => 512, + "max_digits" => 128, "display_digits" => 4) + p = policy_from_config(cfg) + @test p.mode === :bigfloat + @test p.precision_bits == 512 + @test p.max_digits == 128 + @test p.display_digits == 4 + + # An override is applied last, and a `nothing` (YAML's `~`) means "unset" + # rather than "null", so a cascade cannot shadow a default with nothing. + p2 = policy_from_config(cfg; overrides = Dict("precision_bits" => 256, "mode" => nothing)) + @test p2.precision_bits == 256 + @test p2.mode === :bigfloat + + @test policy_from_config() == NumericPolicy() + @test policy_from_config(nothing; overrides = nothing) == NumericPolicy() + + # A misspelt key is a hard error: silently running at the default while + # the caller believes otherwise is the failure this layer exists to avoid. + @test_throws ArgumentError policy_from_config(Dict("precision" => 256)) + @test_throws ArgumentError policy_from_config(Dict("mode" => "exact", "unknown" => 1)) + @test_throws ArgumentError policy_from_config(Dict("mode" => "octal")) + end + + @testset "policy_dict and arithmetic_bits" begin + d = policy_dict(NumericPolicy(mode = "bigfloat", precision_bits = 128)) + @test d["version"] == POLICY_VERSION + @test d["mode"] == "bigfloat" + @test d["precision_bits"] == 128 + @test occursin("all arithmetic", d["precision_applies_to"]) + + @test arithmetic_bits(NumericPolicy()) == 53 + @test arithmetic_bits(NumericPolicy(mode = "bigfloat", precision_bits = 300)) == 300 + @test_throws UnsupportedNumericMode arithmetic_bits(NumericPolicy(mode = "exact")) + end + + @testset "exact_count refuses rather than rounds" begin + @test exact_count(0) == 0 && exact_count(0) isa BigInt + @test exact_count(12) == 12 + @test exact_count(typemax(Int)) == typemax(Int) + @test exact_count(Int128(2)^100) == BigInt(2)^100 + @test exact_count(big(2)^200) == BigInt(2)^200 + @test exact_count(4 // 1) == 4 && exact_count(4 // 1) isa BigInt + @test exact_count(42.0) == 42 + @test exact_count(Float32(7)) == 7 + @test exact_count(Float64(FLOAT_EXACT_INT_MAX)) == FLOAT_EXACT_INT_MAX + + # Fractional values are refused, never rounded to the nearest integer. + @test_throws InvalidNumericInput exact_count(1 // 2) + @test_throws InvalidNumericInput exact_count(2.5) + @test_throws InvalidNumericInput exact_count(0.0001) + # Non-finite values are not counts. + @test_throws InvalidNumericInput exact_count(NaN) + @test_throws InvalidNumericInput exact_count(Inf) + # Negatives are not counts. + @test_throws InvalidNumericInput exact_count(-1) + @test_throws InvalidNumericInput exact_count(-0.5) + # A Bool is not a count, even though Bool <: Integer. + @test_throws InvalidNumericInput exact_count(true) + # Above 2^53-1 a binary float is no longer a faithful name for an integer. + @test_throws InvalidNumericInput exact_count(Float64(2)^53) + + err = try + exact_count(2.5; what = "count[3,7]") + catch e + e + end + @test err isa InvalidNumericInput + @test occursin("count[3,7]", sprint(showerror, err)) + @test error_code(err) == "invalid_input" + end + + @testset "exact_counts names the offending cell" begin + m = [1 2; 3 4] + exact = exact_counts(m) + @test eltype(exact) === BigInt + @test exact == BigInt[1 2; 3 4] + + bad = [1.0 2.0; 3.0 1.5] + err = try + exact_counts(bad) + catch e + e + end + @test err isa InvalidNumericInput + @test occursin("count[2,2]", sprint(showerror, err)) + end + + @testset "exact_ratio" begin + @test exact_ratio(6, 4) == 3 // 2 + @test exact_ratio(6, 4) isa Rational{BigInt} + @test exact_ratio(-6, 4) == -3 // 2 + @test exact_ratio(6, -4) == -3 // 2 # denominator normalised positive + @test exact_ratio(0, 5) == 0 // 1 + @test exact_ratio(5, 1) == 5 // 1 + @test_throws ZeroDenominator exact_ratio(1, 0) + @test_throws InvalidNumericInput exact_ratio(true, 2) + @test error_code(ZeroDenominator("x")) == "zero_denominator" + + # The digit bound is enforced on both halves of the rational. + tiny = NumericPolicy(max_digits = 16) + @test_throws NumericLimit exact_ratio(big(10)^20, 3; policy = tiny) + @test_throws NumericLimit exact_ratio(1, big(3)^50; policy = tiny) + @test error_code(NumericLimit("x", 16)) == "resource_limit" + @test error_code(ResourceLimit("x", 16)) == "resource_limit" + @test error_code(UnsupportedNumericMode("x", :exact, "y")) == "unsupported_numeric_mode" + end + + @testset "check_limit and decimal_digits" begin + @test decimal_digits(0) == 1 + @test decimal_digits(9) == 1 + @test decimal_digits(-12345) == 5 + @test decimal_digits(big(10)^100) == 101 + + policy = NumericPolicy(max_digits = 16) + @test check_limit(big(10)^15, policy) == big(10)^15 + @test_throws NumericLimit check_limit(big(10)^16, policy) + @test check_limit(1 // 3, policy) == 1 // 3 + end + + @testset "display_string is rounded, and rounds half to even" begin + @test display_string(1234; digits = 6) == "1234" + @test display_string(big(10)^30; digits = 6) == string(big(10)^30) + @test display_string(3 // 4; digits = 6) == "0.750000" + @test display_string(1 // 3; digits = 4) == "0.3333" + @test display_string(2 // 3; digits = 4) == "0.6667" + @test display_string(-1 // 3; digits = 4) == "-0.3333" + @test display_string(1 // 8; digits = 2) == "0.12" # 0.125 -> 0.12 + @test display_string(3 // 8; digits = 2) == "0.38" # 0.375 -> 0.38 + @test display_string(5 // 2; digits = 0) == "2" # 2.5 -> 2 (even) + @test display_string(7 // 2; digits = 0) == "4" # 3.5 -> 4 (even) + @test display_string(-5 // 2; digits = 0) == "-2" + @test display_string(-7 // 2; digits = 0) == "-4" + @test display_string(1 // 1000; digits = 2) == "0.00" + @test display_string(0 // 1; digits = 2) == "0.00" + + # A binary64 is displayed as the number it actually is, not as the decimal + # it was typed as. + @test display_string(0.1; digits = 6) == "0.100000" + @test display_string(0.5; digits = 3) == "0.500" + @test display_string(3.0; digits = 2) == "3.00" + @test_throws InvalidNumericInput display_string(NaN) + @test_throws InvalidNumericInput display_string(Inf) + + h = with_precision(256) do + BigFloat(1) / 3 + end + @test display_string(h; digits = 6) == "0.333333" + @test display_string(h; digits = 12) == "0.333333333333" + @test_throws InvalidNumericInput display_string(BigFloat(NaN)) + end + + @testset "exact_json and its round trip" begin + policy = NumericPolicy(display_digits = 4) + + d = exact_json(big(10)^40; policy = policy) + @test d["kind"] == "exact" + @test d["type"] == "integer" + @test d["value"] == string(big(10)^40) # always a string, never a JSON number + @test d["display"] == string(big(10)^40) + @test parse_exact_json(d) == big(10)^40 + + r = exact_json(3 // 4; policy = policy) + @test r["type"] == "rational" + @test r["numerator"] == "3" + @test r["denominator"] == "4" + @test r["value"] == "3/4" + @test r["display"] == "0.7500" + @test r["display_rounded"] === true + @test parse_exact_json(r) == 3 // 4 + + @test_throws InvalidNumericInput exact_json(true) + @test_throws NumericLimit exact_json(big(10)^20; policy = NumericPolicy(max_digits = 16)) + @test error_code(InvalidNumericInput("x", "y")) == "invalid_input" + end + + @testset "parse_exact_json refuses malformed documents" begin + @test_throws ArgumentError parse_exact_json(Dict{String,Any}()) + @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "approx", "type" => "integer", "value" => "1")) + @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "integer")) + @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "integer", "value" => 1)) + @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "integer", "value" => "1.5")) + @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "rational", "numerator" => "1")) + @test_throws ZeroDenominator parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "rational", + "numerator" => "1", "denominator" => "0")) + @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "decimal", "value" => "1")) + + # Generative fuzz at the parsing boundary: no input may slip through as a + # value, and nothing other than a declared refusal may be thrown. + rng = MersenneTwister(20261022) + keys = ["kind", "type", "value", "numerator", "denominator"] + vals = Any["exact", "integer", "rational", "approx", "1", "0", "-3", "abc", "3/4", + "1e5", 1, 2.5, nothing, true] + for _ in 1:300 + d = Dict{String,Any}() + for _ in 1:rand(rng, 1:4) + d[rand(rng, keys)] = rand(rng, vals) + end + outcome = try + parse_exact_json(d) + :accepted + catch e + (e isa ArgumentError || e isa ZeroDenominator) ? :refused : :unexpected + end + @test outcome != :unexpected + end + end + + @testset "approx_json is tagged, and refuses non-finite values" begin + policy = NumericPolicy(display_digits = 6) + d = approx_json(0.5; policy = policy) + @test d["kind"] == "approximation" + @test d["type"] == "binary64" + @test d["precision_bits"] == 53 + @test d["display"] == "0.500000" + @test d["display_rounded"] === true + + b = with_precision(512) do + sqrt(BigFloat(2)) + end + j = approx_json(b; policy = policy) + @test j["type"] == "mpfr" + @test j["precision_bits"] == 512 + @test parse(Float64, j["value"]) ≈ sqrt(2.0) atol = 1e-15 + + @test_throws InvalidNumericInput approx_json(NaN) + @test_throws InvalidNumericInput approx_json(Inf; policy = policy) + @test_throws InvalidNumericInput approx_json(BigFloat(Inf)) + end + + @testset "with_precision restores the ambient precision" begin + before = precision(BigFloat) + v = with_precision(128) do + @test precision(BigFloat) == 128 + BigFloat(1) / 3 + end + @test precision(BigFloat) == before + @test precision(v) == 128 + + # Nested calls restore their own predecessor. + outer = with_precision(256) do + inner = with_precision(512) do + @test precision(BigFloat) == 512 + BigFloat(1) / 7 + end + @test precision(BigFloat) == 256 + inner + end + @test precision(outer) == 512 + @test precision(BigFloat) == before + + @test_throws ArgumentError with_precision(() -> 1, 52) + @test_throws ArgumentError with_precision(() -> 1, 256.0) + end + + @testset "concurrent analyses do not share precision" begin + before = precision(BigFloat) + bitsets = [64, 128, 256, 1024] + + sequential = Dict{Int,BigFloat}() + for bits in bitsets + sequential[bits] = with_precision(bits) do + BigFloat(1) / 3 + end + end + + tasks = [Threads.@spawn begin + bits = bitsets[mod1(i, length(bitsets))] + with_precision(bits) do + v = BigFloat(1) / 3 + (bits, precision(v), v) + end + end for i in 1:64] + observed = fetch.(tasks) + for (bits, prec, v) in observed + @test prec == bits + @test v == sequential[bits] + end + # And the interleaving left nothing behind. + @test precision(BigFloat) == before + end + + @testset "approximate_value obeys the policy" begin + v = approximate_value(NumericPolicy(mode = "bigfloat", precision_bits = 200)) do + BigFloat(1) / 7 + end + @test precision(v) == 200 + @test v ≈ 1 / 7 atol = 1e-15 + + f = approximate_value(NumericPolicy()) do + BigFloat(1) / 3 + end + @test precision(f) == 53 + + @test_throws UnsupportedNumericMode approximate_value(() -> BigFloat(1), + NumericPolicy(mode = "exact")) + end + + @testset "numeric_contract states the boundaries" begin + c = numeric_contract() + @test c["version"] == POLICY_VERSION + @test c["integer"]["json"] == "decimal string in 'value' (never a JSON number)" + @test "zero denominator" in c["rational"]["refused"] + @test any(x -> occursin("2^53", x), c["integer"]["refused"]) + @test occursin("2^53", c["json_number_boundary"]) + @test c["approximation"]["json"] isa String + end + +end diff --git a/test/unit/test_exact_statistics.jl b/test/unit/test_exact_statistics.jl new file mode 100644 index 00000000..b41a5fc7 --- /dev/null +++ b/test/unit/test_exact_statistics.jl @@ -0,0 +1,590 @@ +# Unit tests for the exact statistics layer (issue #1). +# +# The arithmetic is checked against `test/fixtures/statistics_known_answers.json`, +# which `scripts/statistics-reference.py` produces from a second implementation in +# another language (see that script's header). This file adds what a reference +# cannot: invariants and metamorphic relations that must hold for *any* input, +# adversarial cases, the immutability and provenance of stored records, precision +# isolation between concurrent analyses, and negative controls that check the +# validation actually rejects a wrong implementation. +# +# Nothing here reaches the network, R or DuckDB, so it runs in the ordinary unit +# suite. +using MetaManifold.ExactNumeric +using MetaManifold.ExactStatistics +using JSON3, Dates, Random + +# The negative controls share one definition with scripts/statistics-negative-controls.jl, +# so the evidence quoted by the script is the evidence the suite enforces. +include(joinpath(@__DIR__, "..", "..", "scripts", "statistics-negative-controls.jl")) + +const _FIXTURE_PATH = joinpath(@__DIR__, "..", "fixtures", "statistics_known_answers.json") + +function _parse_fixture_rational(s) + parts = split(String(s), "/") + length(parts) == 2 || error("not a rational in the fixture: $s") + return parse(BigInt, parts[1]) // parse(BigInt, parts[2]) +end + +_fixture_counts(entry) = BigInt[Int(c) for c in entry["counts"]] + +function _fixture_entry(collection, counts::Vector{Int}) + for entry in collection + [Int(c) for c in entry["counts"]] == counts && return entry + end + error("no fixture entry for counts $counts") +end + +"""Every subset of `1:k` out of `1:n`, for an independent enumeration check.""" +function _foreach_combination(n::Int, k::Int, cb::Function, start::Int = 1, + chosen::Vector{Int} = Int[]) + if length(chosen) == k + cb(chosen) + return + end + for i in start:(n - (k - length(chosen)) + 1) + push!(chosen, i) + _foreach_combination(n, k, cb, i + 1, chosen) + pop!(chosen) + end +end + +""" + _brute_expected_richness(counts, depth) + +Enumerate every subsample of `depth` reads and average the observed richness. +This shares no algebra with the closed form: it is the definition, executed. +""" +function _brute_expected_richness(counts::Vector{Int}, depth::Int) + reads = Int[] + for (feature, c) in enumerate(counts) + for _ in 1:c + push!(reads, feature) + end + end + n = length(reads) + total = zero(BigInt) + subsets = 0 + _foreach_combination(n, depth, chosen -> begin + total += length(unique(reads[chosen])) + subsets += 1 + end) + return total // subsets +end + +@testset "ExactStatistics" begin + + fixture = JSON3.read(read(_FIXTURE_PATH, String)) + full_policy = NumericPolicy(mode = "exact", max_digits = 100_000) + + @testset "catalogue is bounded and self-describing" begin + cat = method_catalogue() + @test Set(keys(cat)) == Set(["summarise.counts", "describe.alpha", + "describe.proportions", + "rarefaction.expected_richness", + "compare.taxon_ratio"]) + for (name, entry) in cat + @test entry["version"] == METHOD_VERSION + @test !isempty(entry["title"]) || error("$name has no title") + @test entry["numeric_modes"] == collect(NUMERIC_MODES) + # Every method states what it does not claim. A method with nothing to + # say here does not belong in a validated catalogue. + @test !isempty(entry["uncertainty"]) + @test !isempty(entry["multiple_testing"]) + @test !isempty(entry["limitations"]) + @test entry["design_required"] === false + end + @test GLOBAL_LIMITATIONS isa Vector{String} + @test any(l -> occursin("no hypothesis test", l), GLOBAL_LIMITATIONS) + @test any(l -> occursin("Higher precision is not exactness", l), GLOBAL_LIMITATIONS) + end + + @testset "known answers: Simpson index (independent reference)" begin + for entry in fixture["simpson"] + counts = _fixture_counts(entry) + expected = _parse_fixture_rational(entry["value"]) + @test simpson_index(counts) == expected + # Metamorphic: a permutation and a uniform rescaling cannot change it. + @test simpson_index(reverse(counts)) == expected + @test simpson_index(3 .* counts) == expected + # The independent reference's own permutation/rescaling results. + @test _parse_fixture_rational(entry["permuted_value"]) == expected + @test _parse_fixture_rational(entry["scaled_by_three"]) == expected + @test display_string(simpson_index(counts); digits = 6) == String(entry["display_6"]) + end + @test_throws ZeroDenominator simpson_index(BigInt[0, 0, 0]) + end + + @testset "known answers: expected richness (closed form, enumeration, fixture)" begin + for entry in fixture["expected_richness"] + counts = _fixture_counts(entry) + depth = Int(entry["depth"]) + expected = _parse_fixture_rational(entry["value"]) + @test expected_richness(counts, depth; policy = full_policy) == expected + @test expected_richness(reverse(counts), depth; policy = full_policy) == expected + @test display_string(expected_richness(counts, depth; policy = full_policy); + digits = 6) == String(entry["display_6"]) + if entry["brute_force"] !== nothing + enumerated = _parse_fixture_rational(entry["brute_force"]) + @test enumerated == expected + ints = [Int(c) for c in counts] + @test _brute_expected_richness(ints, depth) == expected + end + end + + # The same enumeration, on cases the fixture does not pin: the definition + # and the closed form must agree, so neither can be wrong in isolation. + for (counts, depth) in (([1, 2, 3], 2), ([2, 2, 2], 3), ([3, 1, 0], 2), ([1, 1], 1)) + @test expected_richness(BigInt.(counts), depth; policy = full_policy) == + _brute_expected_richness(counts, depth) + end + end + + @testset "known answers: Shannon index at higher precision" begin + for entry in fixture["shannon"] + counts = _fixture_counts(entry) + policy = NumericPolicy(mode = "bigfloat", precision_bits = 256) + value = shannon_index(counts, policy) + @test precision(value) == 256 + reference = with_precision(400) do + parse(BigFloat, String(entry["value_decimal"])) + end + @test abs(value - reference) < big"1e-55" + # A decimal reference was computed at 80 digits; 55 leaves a margin for + # its own rounding, and a mismatch of any more would be a real defect. + @test Float64(value) ≈ Float64(entry["double"]) atol = 1e-12 + end + @test_throws ZeroDenominator shannon_index(BigInt[0, 0], NumericPolicy()) + @test_throws UnsupportedNumericMode shannon_index(BigInt[1, 2], + NumericPolicy(mode = "exact")) + end + + @testset "known answers: exact proportions" begin + for entry in fixture["proportions"] + counts = [Int(c) for c in entry["counts"]] + total = sum(counts) + got = [proportion_value(c, total) for c in counts] + @test got == [_parse_fixture_rational(v) for v in entry["values"]] + @test sum(got) == 1 // 1 + end + @test_throws ZeroDenominator proportion_value(0, 0) + end + + @testset "invariants and metamorphic relations (generative)" begin + rng = MersenneTwister(20261022) + for _ in 1:15 + counts = [rand(rng, 0:12) for _ in 1:rand(rng, 2:6)] + total = sum(counts) + total == 0 && continue + rich = count(!iszero, counts) + counts_big = BigInt.(counts) + + # E[S_total] is the observed richness; E[S_1] is 1 for any non-empty + # library; the expectation is monotone in depth and bounded by it. + @test expected_richness(counts_big, 1; policy = full_policy) == 1 + @test expected_richness(counts_big, total; policy = full_policy) == rich + previous = zero(Rational{BigInt}) + for m in 1:total + e = expected_richness(counts_big, m; policy = full_policy) + @test 1 <= e <= rich + @test e >= previous + previous = e + end + + # The higher-precision path must agree with the exact one to the + # precision it claims; disagreement would mean one of them is wrong. + approx = expected_richness(counts_big, max(1, total ÷ 2); + policy = NumericPolicy(mode = "bigfloat", + precision_bits = 256)) + exact = expected_richness(counts_big, max(1, total ÷ 2); policy = full_policy) + @test abs(approx - BigFloat(exact)) < big"1e-40" + + # Simpson is a proportion-like quantity in [0, 1) for a real table. + s = simpson_index(counts_big) + @test 0 <= s < 1 + @test simpson_index(2 .* counts_big) == s + end + end + + @testset "adversarial inputs are refused explicitly" begin + # Huge counts are exact, and a count above 2^53 arriving as a float is not + # silently trusted. + huge = BigInt[big(10)^40] + @test simpson_index(huge) == 0 // 1 + @test_throws InvalidNumericInput exact_count(Float64(2)^53) + @test_throws InvalidNumericInput exact_count(1.5) + # A value whose exact representation breaches the digit bound is a named + # resource_limit failure, not a rounded answer. + @test_throws NumericLimit expected_richness(BigInt[1, big(10)^20], 2; + policy = NumericPolicy(mode = "exact", + max_digits = 16)) + + # An all-zero table has no index at all, at any precision. + zero_table = zeros(Int, 2, 3) + rec = analyse(zero_table, ["A", "B"], ["f1", "f2", "f3"]; + method = "describe.alpha", policy = NumericPolicy(mode = "bigfloat")) + @test rec["status"] == "partial" + @test any(f -> f["code"] == "zero_denominator", rec["failures"]) + @test any(w -> w["code"] == "zero_reads", rec["warnings"]) + + # Tiny probabilities: one read against a billion still has a defined, + # positive Shannon index, and it must not underflow to zero. + tiny = BigInt[1, 10]^9 + h = shannon_index(tiny, NumericPolicy(mode = "bigfloat", precision_bits = 256)) + @test h > 0 + @test abs(h - with_precision(300) do + p = BigFloat(1) / BigFloat(sum(tiny)) + q = BigFloat(10)^9 / BigFloat(sum(tiny)) + -(p * log(p) + q * log(q)) + end) < big"1e-50" + + # A rarefaction depth above the library is refused, not clamped. + rec = analyse([3 0; 0 4], ["A", "B"], ["f1", "f2"]; + method = "rarefaction.expected_richness", + policy = NumericPolicy(mode = "exact"), + options = Dict{String,Any}("depth" => 10)) + @test rec["status"] == "partial" + @test any(f -> f["code"] == "depth_exceeds_library", rec["failures"]) + end + + @testset "simulation study: the exact expectation is the sampling mean" begin + # A pre-set tolerance: four standard errors of the simulated mean, so a + # correct implementation fails about once in 30,000 such studies while a + # bias of one part in a hundred is caught easily. Deterministic seed, so + # the check cannot flake between runs. + counts = [4, 6, 2, 8] + depth = 5 + exact = expected_richness(BigInt.(counts), depth; policy = full_policy) + reads = Int[] + for (feature, c) in enumerate(counts) + for _ in 1:c + push!(reads, feature) + end + end + rng = MersenneTwister(987654) + n = 4_000 + values = Vector{Int}(undef, n) + for i in 1:n + subset = randperm(rng, length(reads))[1:depth] + values[i] = length(unique(reads[subset])) + end + μ = sum(values) / n + σ = sqrt(sum((v - μ)^2 for v in values) / (n - 1)) + se = σ / sqrt(n) + @test abs(μ - Float64(exact)) <= 4 * se + 1e-12 + end + + @testset "analyse: counts summary" begin + samples = ["S1", "S2", "S3"] + features = ["ASV1", "ASV2", "ASV3"] + mat = [10 20 0; + 0 0 0; + 5 5 10] + rec = analyse(mat, samples, features; method = "summarise.counts", + policy = NumericPolicy(mode = "exact"), + source = Dict{String,Any}("run" => "r1")) + @test rec["status"] == "complete" + @test isempty(rec["failures"]) + @test rec["results"]["corpus"]["total_reads"]["value"] == "50" + @test rec["results"]["samples"][1]["total_reads"]["value"] == "30" + @test rec["results"]["samples"][1]["richness"]["value"] == "2" + @test rec["results"]["samples"][1]["mean_per_feature"]["value"] == "10/1" + @test rec["results"]["samples"][2]["total_reads"]["value"] == "0" + @test rec["results"]["features"][3]["total_reads"]["value"] == "10" + @test rec["results"]["features"][3]["prevalence"]["value"] == "2" + @test rec["diagnostics"]["counts"]["zero_total_samples"] == 1 + @test rec["diagnostics"]["numeric_mode"] == "exact" + @test rec["provenance"]["input"]["source"]["run"] == "r1" + @test rec["provenance"]["method"]["name"] == "summarise.counts" + @test rec["provenance"]["numeric_policy"]["mode"] == "exact" + @test rec["provenance"]["seed"] == 0 + @test rec["provenance"]["software"]["mpfr"] isa String + @test length(rec["limitations"]) >= length(GLOBAL_LIMITATIONS) + end + + @testset "analyse: alpha, exact and approximate modes" begin + mat = reshape([90, 10], 1, 2) + samples = ["A"]; features = ["f1", "f2"] + + exact = analyse(mat, samples, features; method = "describe.alpha", + policy = NumericPolicy(mode = "exact")) + sample = exact["results"]["samples"][1] + @test sample["richness"]["kind"] == "exact" + @test sample["simpson"]["kind"] == "exact" + @test sample["simpson"]["value"] == "9/50" + # The exact mode refuses what has no exact value, and says why, instead of + # quietly returning a float. + @test sample["shannon"]["status"] == "unsuccessful" + @test sample["shannon"]["code"] == "unsupported_numeric_mode" + @test sample["evenness"]["code"] == "unsupported_numeric_mode" + @test sample["status"] == "partial" + @test exact["status"] == "partial" + @test any(f -> f["code"] == "unsupported_numeric_mode", exact["failures"]) + + big = analyse(mat, samples, features; method = "describe.alpha", + policy = NumericPolicy(mode = "bigfloat", precision_bits = 256)) + h = big["results"]["samples"][1]["shannon"] + @test h["kind"] == "approximation" + @test h["type"] == "mpfr" + @test h["precision_bits"] == 256 + entry = _fixture_entry(fixture["shannon"], [90, 10]) + reference = with_precision(400) do + parse(BigFloat, String(entry["value_decimal"])) + end + @test abs(parse(BigFloat, h["value"]) - reference) < big"1e-55" + + f64 = analyse(mat, samples, features; method = "describe.alpha", + policy = NumericPolicy(mode = "float64")) + h64 = f64["results"]["samples"][1]["shannon"] + @test h64["type"] == "binary64" + @test h64["precision_bits"] == 53 + @test parse(Float64, h64["value"]) ≈ Float64(entry["double"]) atol = 1e-12 + + # Evenness is undefined for a single observed feature, and says so. + lonely = analyse([5 0], ["A"], ["f1", "f2"]; method = "describe.alpha", + policy = NumericPolicy(mode = "float64")) + @test lonely["results"]["samples"][1]["evenness"]["code"] == "invalid_input" + end + + @testset "analyse: proportions" begin + mat = [1 2; 0 0; 90 10] + samples = ["S1", "S2", "S3"] + features = ["f1", "f2"] + rec = analyse(mat, samples, features; method = "describe.proportions", + policy = NumericPolicy(mode = "exact")) + @test rec["results"]["samples"][1]["proportions"][1]["value"] == "1/3" + @test rec["results"]["samples"][1]["proportions"][2]["value"] == "2/3" + # The zero-read sample has no proportions at all, and is not filled with + # zeros to make the table rectangular. + @test rec["results"]["samples"][2]["status"] == "partial" + @test rec["results"]["samples"][2]["proportions"][1]["code"] == "zero_denominator" + @test rec["results"]["samples"][2]["total_reads"]["value"] == "0" + @test rec["results"]["corpus"]["proportions"][1]["value"] == "91/103" + @test rec["status"] == "partial" + end + + @testset "analyse: rarefaction and taxon ratio" begin + mat = [2 3; 0 4] + samples = ["S1", "S2"] + features = ["f1", "f2"] + rec = analyse(mat, samples, features; method = "rarefaction.expected_richness", + policy = NumericPolicy(mode = "exact"), + options = Dict{String,Any}("depth" => 2)) + @test rec["results"]["samples"][1]["expected_richness"]["value"] == "8/5" + @test rec["results"]["samples"][2]["expected_richness"]["value"] == "1/1" + @test rec["results"]["depth"] == 2 + + # A work bound is an explicit refusal, not a slow request. + too_deep = analyse(ones(Int, 3, 4000), ["a", "b", "c"], string.("f", 1:4000); + method = "rarefaction.expected_richness", + policy = NumericPolicy(mode = "exact"), + options = Dict{String,Any}("depth" => 5000)) + @test too_deep["status"] == "unsuccessful" + @test too_deep["failures"][1]["code"] == "resource_limit" + @test isempty(too_deep["results"]) + + rmat = [2 3; 4 0] + ratio = analyse(rmat, samples, features; method = "compare.taxon_ratio", + policy = NumericPolicy(mode = "exact"), + options = Dict{String,Any}("numerator" => "f1", + "denominator" => "f2")) + @test ratio["results"]["corpus"]["ratio"]["value"] == "2/1" + @test ratio["results"]["samples"][2]["ratio"]["code"] == "zero_denominator" + @test ratio["results"]["samples"][1]["ratio"]["value"] == "2/3" + + @test_throws ArgumentError analyse(mat, samples, features; + method = "compare.taxon_ratio", + options = Dict{String,Any}("numerator" => "f1")) + @test_throws ArgumentError analyse(mat, samples, features; + method = "compare.taxon_ratio", + options = Dict{String,Any}("numerator" => "nope", + "denominator" => "f2")) + @test_throws ArgumentError analyse(mat, samples, features; + method = "compare.taxon_ratio", + options = Dict{String,Any}("numerator" => "f1", + "denominator" => "f1")) + end + + @testset "analyse: request errors are errors, data problems are records" begin + mat = [1 2; 3 4] + samples = ["A", "B"]; features = ["f1", "f2"] + + @test_throws ArgumentError analyse(mat, samples, features; method = "describe.magic") + @test_throws ArgumentError analyse(mat, ["A"], features; method = "summarise.counts") + @test_throws ArgumentError analyse(mat, samples, features; + method = "summarise.counts", + options = Dict{String,Any}("depth" => 3)) + @test_throws ArgumentError analyse(mat, samples, features; + method = "rarefaction.expected_richness", + options = Dict{String,Any}("depth" => 0)) + @test_throws ArgumentError analyse(mat, samples, features; + method = "rarefaction.expected_richness", + options = Dict{String,Any}("depth" => 2.5)) + @test_throws ArgumentError analyse(mat, samples, features; + method = "summarise.counts", + options = Dict{String,Any}("depth" => :two)) + @test_throws ArgumentError analyse(mat, samples, features; + method = "summarise.counts", seed = true) + @test_throws ArgumentError analyse(mat, samples, features; + method = "summarise.counts", seed = 2^60) + + fractional = [1.0 2.0; 3.0 1.5] + rec = analyse(fractional, samples, features; method = "summarise.counts") + @test rec["status"] == "unsuccessful" + @test isempty(rec["results"]) + @test rec["failures"][1]["code"] == "invalid_input" + @test occursin("count[2,2]", rec["failures"][1]["message"]) + @test rec["diagnostics"]["conversion_failed"] === true + + too_big = [Float64(2)^53 1.0; 2.0 3.0] + rec = analyse(too_big, samples, features; method = "summarise.counts") + @test rec["status"] == "unsuccessful" + @test rec["failures"][1]["code"] == "invalid_input" + @test occursin("2^53", rec["failures"][1]["message"]) + + # Duplicate names are warned about, not silently de-duplicated, because the + # position of a duplicated name is the only way to tell the entries apart. + rec = analyse([1 2; 3 4], ["A", "A"], ["f1", "f1"]; + method = "summarise.counts") + @test any(w -> w["code"] == "duplicate_sample_names", rec["warnings"]) + @test any(w -> w["code"] == "duplicate_feature_names", rec["warnings"]) + end + + @testset "input fingerprint" begin + a = input_fingerprint(BigInt[1 2; 3 4], ["A", "B"], ["f1", "f2"]) + @test length(a) == 64 + @test occursin(r"^[0-9a-f]{64}$", a) + @test a == input_fingerprint(BigInt[1 2; 3 4], ["A", "B"], ["f1", "f2"]) + @test a != input_fingerprint(BigInt[1 2; 3 5], ["A", "B"], ["f1", "f2"]) + @test a != input_fingerprint(BigInt[1 2; 3 4], ["B", "A"], ["f1", "f2"]) + @test a != input_fingerprint(BigInt[1 2; 3 4], ["A", "B"], ["f2", "f1"]) + # Counts above 2^53 are fingerprinted exactly, not through a float. + big = input_fingerprint(reshape(BigInt[10]^30, 1, 1), ["A"], ["f1"]) + @test big isa String + @test_throws ArgumentError input_fingerprint(BigInt[1 2; 3 4], ["A"], ["f1", "f2"]) + end + + @testset "records are immutable, content-addressed and reproducible" begin + mat = [1 2; 3 4] + samples = ["A", "B"]; features = ["f1", "f2"] + rec = analyse(mat, samples, features; method = "summarise.counts", + policy = NumericPolicy(mode = "exact")) + tmp = mktempdir() + stamp = DateTime(2026, 10, 22, 12, 0, 0) + + first_write = write_record(tmp, rec; created_at = stamp) + @test first_write.created + @test isfile(first_write.path) + @test endswith(first_write.path, first_write.id * ".json") + @test dirname(first_write.path) == record_directory(tmp) + @test analysis_id(rec) == first_write.id + + # Idempotent: the same analysis at a later time is the same record, and the + # stored file keeps its original timestamp. + second_write = write_record(tmp, rec; created_at = DateTime(2026, 10, 23, 1, 2, 3)) + @test !second_write.created + @test second_write.path == first_write.path + @test second_write.bytes == first_write.bytes + @test analysis_id(rec) == first_write.id + + # `created_at` is outside the content address; a changed setting is not. + with_stamp = copy(rec) + with_stamp["created_at"] = "1999-01-01T00:00:00Z" + @test analysis_id(with_stamp) == first_write.id + other = analyse(mat, samples, features; method = "summarise.counts", + policy = NumericPolicy(mode = "bigfloat")) + @test analysis_id(other) != first_write.id + other_write = write_record(tmp, other; created_at = stamp) + @test other_write.created + @test other_write.path != first_write.path + @test length(list_records(tmp)) == 2 + + # Byte-identical on another machine, at the same timestamp. + tmp2 = mktempdir() + elsewhere = write_record(tmp2, rec; created_at = stamp) + @test elsewhere.id == first_write.id + @test elsewhere.bytes == first_write.bytes + + # Reading back is the stored document, not a re-computation. + got = read_record(tmp, first_write.id) + @test got["analysis_id"] == first_write.id + @test got["status"] == rec["status"] + @test got["method"]["name"] == "summarise.counts" + @test got["provenance"]["input"]["fingerprint"] == rec["provenance"]["input"]["fingerprint"] + @test got["created_at"] == "2026-10-22T12:00:00Z" + @test read_record(tmp, repeat("a", 64)) === nothing + @test_throws ArgumentError read_record(tmp, "../etc/passwd") + @test_throws ArgumentError read_record(tmp, "not-an-id") + + # A record is write-once: tampering is a conflict, never an overwrite. + write(first_write.path, "{\"tampered\":true}") + @test_throws RecordConflict write_record(tmp, rec) + + # A damaged record is listed as unreadable rather than hidden: a missing + # record and a broken one are different facts. + write(joinpath(record_directory(tmp), repeat("b", 64) * ".json"), "not json") + listed = list_records(tmp) + @test length(listed) == 3 + @test count(x -> get(x, "unreadable", false), listed) == 1 + @test any(x -> x["analysis_id"] == first_write.id && !x["unreadable"], listed) + end + + @testset "precision is isolated between concurrent analyses" begin + before = precision(BigFloat) + mat = reshape([90, 10], 1, 2) + samples = ["A"]; features = ["f1", "f2"] + low = analyse(mat, samples, features; method = "describe.alpha", + policy = NumericPolicy(mode = "bigfloat", precision_bits = 128)) + high = analyse(mat, samples, features; method = "describe.alpha", + policy = NumericPolicy(mode = "bigfloat", precision_bits = 1024)) + @test low["results"]["samples"][1]["shannon"]["precision_bits"] == 128 + @test high["results"]["samples"][1]["shannon"]["precision_bits"] == 1024 + @test low["results"]["samples"][1]["shannon"]["value"] != + high["results"]["samples"][1]["shannon"]["value"] + + tasks = [Threads.@spawn analyse(mat, samples, features; + method = "describe.alpha", + policy = NumericPolicy(mode = "bigfloat", + precision_bits = (i % 2 == 0 ? 128 : 1024))) + for i in 1:16] + for (i, task) in enumerate(tasks) + record = fetch(task) + wanted = i % 2 == 0 ? low : high + @test record["results"]["samples"][1]["shannon"]["value"] == + wanted["results"]["samples"][1]["shannon"]["value"] + @test record["results"]["samples"][1]["shannon"]["precision_bits"] == + wanted["results"]["samples"][1]["shannon"]["precision_bits"] + @test analysis_id(record) == analysis_id(wanted) + end + @test precision(BigFloat) == before + end + + @testset "the optional layer does not disturb the existing analysis path" begin + # The layer is opt-in: calling it must leave the modules it does not own + # exactly as they were, and must not move the global precision. + before = precision(BigFloat) + baseline = (MetaManifold.DiversityMetrics.richness([1, 0, 2, 2]), + MetaManifold.DiversityMetrics.shannon([50, 50]), + MetaManifold.DiversityMetrics.simpson([50, 50])) + analyse([90 10; 5 5], ["A", "B"], ["f1", "f2"]; method = "describe.alpha", + policy = NumericPolicy(mode = "bigfloat", precision_bits = 512)) + after = (MetaManifold.DiversityMetrics.richness([1, 0, 2, 2]), + MetaManifold.DiversityMetrics.shannon([50, 50]), + MetaManifold.DiversityMetrics.simpson([50, 50])) + @test after == baseline + @test precision(BigFloat) == before + end + + @testset "negative controls: wrong implementations are caught" begin + # Each check is the validation that guards the real implementation. It must + # accept the real one and reject a deliberately wrong one; if a mutant + # passes, the check is not testing what it claims to. + @test !isempty(NEGATIVE_CONTROL_CHECKS) + for control in NEGATIVE_CONTROL_CHECKS + @test control.validate(control.real) + @test !control.validate(control.mutant) + end + @test run_negative_controls(devnull) + end + +end From 6c78cf0c90d58c4657fae5e704e16ebfd6dd6f52 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Thu, 22 Oct 2026 22:52:30 +0000 Subject: [PATCH 2/5] fix(doi): declare the DOI module that routes/doi.jl already imports main's src/core/doi.jl supplies a module terminator and is imported as `using ..DOI` by src/server/routes/doi.jl, but never declares `module DOI`. Julia therefore fails at load time with a ParseError at the trailing `end`, which stops the whole package (src/MetaManifold.jl:14) from loading, so every test fails before reaching any test code. Unrelated to the statistics layer and present on main; included here because the branch cannot be validated without it. Same one-line repair as #203. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/core/doi.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/doi.jl b/src/core/doi.jl index 4db53f69..f5b0bd95 100644 --- a/src/core/doi.jl +++ b/src/core/doi.jl @@ -16,6 +16,8 @@ # * Durable write-ahead journal; immutable DOI/provenance receipts # * DOI insertion never rewrites scientific objects +module DOI + using Dates, SHA, HTTP, JSON3, Logging export DOIConfig, Deposition, PublicationReceipt, @@ -678,4 +680,4 @@ function publish_study(config::DOIConfig, study::AbstractString, run::AbstractSt return receipt end -end +end # module DOI From ac2b6d488a114ce9928a2954183c1e520b606f83 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Thu, 22 Oct 2026 22:53:48 +0000 Subject: [PATCH 3/5] revert: drop the parallel statistics layer; main already ships it (#199) #199 merged the validated exact/high-precision statistics layer into main: src/analysis/{numeric_policy,exact_summaries,high_precision,derived_analyses}.jl with their unit tests. This branch had independently built a second layer (src/analysis/{exact_numeric,exact_statistics}.jl, routes/statistics.jl and its tests) with its own policy vocabulary (float64/bigfloat/exact) alongside main's (ordinary/exact_counts/high_precision). Two statistics stacks in one package would be worse than either one alone, so the duplicate is removed rather than reconciled: it was never reviewed, its tests were never executed, and #202 already covers the API/persistence/validation increment on top of #199. Reverting my edits to shared files also repairs two merge regressions the branch picked up: - src/MetaManifold.jl had lost the includes of numeric_policy.jl, exact_summaries.jl, high_precision.jl and derived_analyses.jl, which left main's shipped statistics layer unreachable (the files were present but never loaded). - config/defaults/pipeline.yml had lost the bayesian_alpha documentation added in recent main history. README, pipeline.yml, runtests.jl, server.jl and routes/analysis.jl are restored to their main content, so this branch now differs from main only by the DOI module declaration. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 7 - config/defaults/pipeline.yml | 27 +- docs/statistics-layer.md | 304 ----- scripts/statistics-negative-controls.jl | 170 --- scripts/statistics-reference.py | 232 ---- scripts/statistics-simulation.jl | 168 --- src/MetaManifold.jl | 18 +- src/analysis/exact_numeric.jl | 695 ----------- src/analysis/exact_statistics.jl | 1179 ------------------- src/server/routes/analysis.jl | 7 +- src/server/routes/statistics.jl | 274 ----- src/server/server.jl | 3 - test/fixtures/statistics_known_answers.json | 295 ----- test/runtests.jl | 7 - test/unit/test_exact_numeric.jl | 352 ------ test/unit/test_exact_statistics.jl | 590 ---------- 16 files changed, 14 insertions(+), 4314 deletions(-) delete mode 100644 docs/statistics-layer.md delete mode 100644 scripts/statistics-negative-controls.jl delete mode 100644 scripts/statistics-reference.py delete mode 100644 scripts/statistics-simulation.jl delete mode 100644 src/analysis/exact_numeric.jl delete mode 100644 src/analysis/exact_statistics.jl delete mode 100644 src/server/routes/statistics.jl delete mode 100644 test/fixtures/statistics_known_answers.json delete mode 100644 test/unit/test_exact_numeric.jl delete mode 100644 test/unit/test_exact_statistics.jl diff --git a/README.md b/README.md index cf68c47c..ba1132e2 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,6 @@ Once a run completes, analysis is performed on request through the web UI, both - Pipeline stage read-count summaries - NMDS ordination (Bray-Curtis, via R/vegan) - PERMANOVA, with PERMDISP to check for differences in dispersion (via R/vegan) -- An optional exact-arithmetic statistics layer: descriptive summaries with exact integer counts and rationals, or approximations at a requested precision, each stored as an immutable record with its provenance. It performs no test and no inference; see [docs/statistics-layer.md](docs/statistics-layer.md) Counts may be normalised before analysis (none, rarefaction to a fixed or auto-resolved depth, or scaling with ranked subsampling (SRS), which reaches the depth by scaling counts and keeps more of the community structure), and contamination-flagged taxa may be included or excluded. Ordinations can also apply a Hellinger transform before the Bray-Curtis dissimilarity. Composition charts can be drawn as a grid faceted on any two of run, group and sub-group. All analysis charts are returned as Plotly JSON and rendered interactively in the browser. @@ -627,12 +626,6 @@ analysis: bayesian_alpha: ~ # clr_welch: null (GBM estimate) or a number > 0 nmds: max_stress: 0.2 # warn if NMDS stress exceeds this value - statistics: # the optional exact-arithmetic statistics layer - numeric: - mode: float64 # float64 | bigfloat | exact - precision_bits: 256 # MPFR precision for bigfloat mode (53 to 4096) - max_digits: 4096 # bound on any exact value (16 to 100000) - display_digits: 6 # rounding applied to display strings only (0 to 50) ``` ### Configuring taxonomic filtering (`filters:` in `config/composition.yml`) diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 59012fb5..a3a5f6ff 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -383,29 +383,10 @@ analysis: # imputed proportion above its taxon's smallest observed proportion is # lowered to this fraction of it. Values below 0.01 or at least 0.9 warn. replacement_delta: 0.65 - # Exact-arithmetic statistics layer (issue #1), served at - # /api/v1/studies/{study}/runs/{run}/analysis/statistics. No pipeline stage - # reads this section and no existing analysis calls the layer, so a run that - # never asks for it is unaffected. Every stored record carries the policy it - # was computed under, so editing these values never rewrites earlier results. - statistics: - numeric: - # float64: IEEE-754 binary64, matching the rest of the application. - # bigfloat: MPFR at precision_bits - higher precision, still an approximation. - # exact: arbitrary-precision integers and exact rationals only. A statistic - # with no exact value (the Shannon index, for example) is refused outright - # with unsupported_numeric_mode rather than returned at another precision. - mode: float64 - # Working precision in bits for the bigfloat mode: 53 to 4096. Recorded but - # unused by float64 and exact. - precision_bits: 256 - # Largest decimal digits allowed in any exact numerator, denominator or - # integer: 16 to 100000. Exceeding it is an explicit resource_limit failure, - # never a quiet fallback to floating point. - max_digits: 4096 - # Decimal places in display strings only: 0 to 50. Display values are - # rounded and labelled; they never feed a computation. - display_digits: 6 + # clr_welch with bayesian_multiplicative only: null uses GBM's estimated + # Dirichlet concentration. A finite value greater than 0 overrides it with + # cmultRepl(method = "user") and the same leave-one-out prior mean. + bayesian_alpha: ~ ## Occupancy probe (MMW-01) # Measures each attested stage's memory: Julia's allocation total, and the diff --git a/docs/statistics-layer.md b/docs/statistics-layer.md deleted file mode 100644 index b235fa02..00000000 --- a/docs/statistics-layer.md +++ /dev/null @@ -1,304 +0,0 @@ -# The exact-arithmetic statistics layer - -Status: implemented, additive, **descriptive only** — awaiting independent -scientific review before any inferential method is added (issue #1). - -This document is the layer's published scope. Where it says a method is -supported, that is the whole of what is claimed; everything it does not say is -not claimed. - -## 1. What this layer is - -MetaManifold's existing analyses — alpha diversity, taxonomic composition, -differential abundance, PERMANOVA — are unchanged and remain the default path. -The statistics layer is a separate, opt-in set of descriptive summaries that: - -* preserve read counts as exact arbitrary-precision integers; -* produce exact rational values where a statistic has one (Simpson's index, - proportions, means, rarefaction expectation); -* compute the statistics that have no exact value (Shannon's index, Pielou - evenness) at an explicitly requested precision, tagged as approximations; -* store every result as an immutable, content-addressed record with its inputs, - settings, versions and diagnostics; -* refuse, with a named code, whatever it cannot compute honestly. - -It is reachable only through its own endpoints (§5). No pipeline stage and no -existing analysis calls it, so a run that never asks for it is unaffected. - -### What it is not - -* **Not a test.** There is no hypothesis test, p-value, confidence interval or - model fit anywhere in this layer. Exact statistical tests are a later, - separately validated extension. -* **Not model-based.** No distribution is assumed, so no estimator is claimed to - be unbiased for a population quantity. -* **Not symbolic.** Symbolic mathematics is out of scope. -* **Not a fix for measurement error.** Exact arithmetic removes representation - error. Sampling uncertainty, model misspecification, PCR bias, primer bias, - extraction bias, copy-number variation and every other biological or technical - bias are untouched by anything here. - -## 2. Method catalogue - -The catalogue is data: `MetaManifold.ExactStatistics.METHOD_CATALOGUE`, served at -`GET /api/v1/studies/{study}/analysis/statistics/catalogue`. A method is only in -it if it can state its response type, numeric modes, uncertainty statement and -limitations. - -| Method | Computes | Exact in `exact` mode | Approximate otherwise | -|---|---|---|---| -| `summarise.counts` | per-sample and per-feature totals, richness, zero counts, means | totals, richness, means | means | -| `describe.alpha` | richness, Gini–Simpson, Shannon, Pielou evenness | richness, Simpson | Simpson, Shannon, evenness | -| `describe.proportions` | each feature's share of its sample and of the corpus | proportions | proportions | -| `rarefaction.expected_richness` | Hurlbert's (1971) expected richness at a chosen depth | the expectation | the expectation | -| `compare.taxon_ratio` | exact ratio of two named features' totals | the ratio | the ratio | - -Supported response: a **non-negative integer count matrix**, samples × features. -Anything else is refused (§4). Every method is `design_required = false`: none of -them needs study-design information, which is exactly why none of them infers -anything. - -**Uncertainty, effect size, multiple testing.** Every entry states these -explicitly: uncertainty is not estimated (no sampling model is assumed), there is -no effect size (no comparison between groups is made), and multiple testing is -not applicable (no test is performed). Those are "not applicable" statements with -a reason, not omissions. - -**Rarefaction specifics.** The expectation is computed in closed form as a -product of rational factors per feature, so no binomial coefficient is -materialised, no random number is drawn, and there is no Monte Carlo error. A -depth above a sample's library size is refused for that sample -(`depth_exceeds_library`) rather than clamped, and depths above 10,000 or -feature × depth products above 5,000,000 are refused for the request -(`resource_limit`). - -## 3. The numeric policy - -One immutable `NumericPolicy` per request, resolved from -`analysis.statistics.numeric` in the config cascade and optionally overridden per -request: - -```yaml -analysis: - statistics: - numeric: - mode: float64 # float64 | bigfloat | exact - precision_bits: 256 # 53 .. 4096, used by bigfloat - max_digits: 4096 # 16 .. 100000, bounds every exact value - display_digits: 6 # 0 .. 50, display strings only -``` - -* `float64` — binary64 (53-bit significand), matching the rest of the - application. -* `bigfloat` — MPFR at `precision_bits`. Higher precision, still an approximation. -* `exact` — arbitrary-precision integers and exact rationals only. A statistic - with no exact value is refused with `unsupported_numeric_mode`. It is never - computed at another precision and returned as though the request had been met. - -Every argument is validated strictly, and a mistyped key is an error rather than a -silently ignored setting. - -### Precision isolation - -Julia's `BigFloat` precision is process-global. All arithmetic that depends on it -runs inside `ExactNumeric.with_precision`, which takes a process-wide lock, sets -the precision, and restores it in a `finally` before releasing the lock. Two -analyses asking for different precisions therefore cannot observe each other's -setting, and nothing is left behind afterwards. The tests run 64 concurrent -analyses at four precisions and compare each with its sequential result. - -### The serialisation contract - -Published as data by `ExactNumeric.numeric_contract()`. - -| Kind | Representation | JSON | -|---|---|---| -| exact integer | arbitrary precision | `{"kind":"exact","type":"integer","value":"","display":"..."}` | -| exact rational | lowest terms, positive denominator | `{"kind":"exact","type":"rational","numerator":"...","denominator":"...","value":"n/d","display":"...","display_rounded":true}` | -| approximation | MPFR at a stated precision, or binary64 | `{"kind":"approximation","type":"mpfr" or "binary64","value":"","precision_bits":N,"rounding":"round-to-nearest-ties-to-even","display":"...","display_rounded":true}` | - -Rules: - -1. Exact values are **always decimal strings**, never JSON numbers. A count above - 2^53−1 does not survive a JSON round trip as a number, and emitting it as one - is how exact counts quietly stop being exact in a browser or an R session. -2. Rationals are normalised; a zero denominator is refused. -3. Approximations carry their engine, precision and rounding mode and are never - promoted to exact by a later step. -4. Display values are rounded separately, half to even, and marked - `display_rounded`. They never feed a computation. -5. Unsupported representations are rejected explicitly: NaN, infinities, - fractional counts, float counts above 2^53−1, `Bool`, and exact values beyond - `max_digits`. - -`parse_exact_json` reverses the round trip; the tests check -`parse_exact_json(exact_json(x)) == x` for integers and rationals. - -## 4. Failures are states, not fallbacks - -Each refusal carries a stable code, a scope, and a message: - -| Code | Meaning | -|---|---| -| `invalid_input` | input cannot be represented exactly: fractional count, NaN, ∞, float count > 2^53−1, `Bool` | -| `zero_denominator` | the requested ratio, index or proportion has no value | -| `resource_limit` | an exact value, work bound or depth bound was exceeded | -| `unsupported_numeric_mode` | the operation has no representation in `exact` mode | -| `depth_exceeds_library` | rarefaction depth above a sample's library size | - -A record's `status` is `complete`, `partial` (some entries explicitly -unsuccessful, each naming its own code, while the rest are usable) or -`unsuccessful` (nothing could be computed). A partial result never fills a failed -entry with a plausible-looking number: a zero-read sample has no proportions, a -sample with one observed feature has no Pielou evenness, and the record says so. - -## 5. API - -``` -GET /api/v1/studies/{study}/analysis/statistics/catalogue -POST /api/v1/studies/{study}/runs/{run}/analysis/statistics -GET /api/v1/studies/{study}/runs/{run}/analysis/statistics -GET /api/v1/studies/{study}/runs/{run}/analysis/statistics/{analysis_id} -``` - -POST body: - -```json -{ - "table": "merged", - "method": "rarefaction.expected_richness", - "options": {"depth": 1000}, - "numeric": {"mode": "exact"}, - "filter": "...", "colFilters": {}, "sampleReads": {"min": 100} -} -``` - -The POST answers `200` with the stored record whenever the analysis ran, even when -its `status` is `partial` or `unsuccessful`: the request succeeded, and the -outcome is the result. A client must read `status`. Only a malformed request, an -unknown method or option, or an invalid numeric policy is an HTTP error. - -The statistics surface obeys `analysis.exclude_categories` when a config names -`statistics` in an entry's `apply_to`. The factory default does not, so the layer -sees the unfiltered table until an exclusion says otherwise. - -## 6. Records - -`projects/{study}/[{group}/]{run}/analysis/statistics/{analysis_id}.json` - -* `analysis_id` is the SHA-256 of the record's canonical JSON (RFC 8785), - excluding `analysis_id` and `created_at`: the same analysis of the same input - under the same settings hashes identically wherever it is recomputed, while a - changed setting, input or method version produces a different id. -* Records are **write-once**. Re-running an unchanged analysis is idempotent and - keeps the original file; a differing body at the same id is a `RecordConflict`, - never an overwrite. New settings produce a new record beside the old one, and - each record names the input fingerprint it was computed from. -* The file is written atomically (temporary file, then rename) and is - byte-identical across machines for the same analysis and timestamp. -* `list_records` reports a damaged file as `unreadable` rather than hiding it: a - missing record and a broken one are different facts. - -The input fingerprint is the SHA-256 of canonical JSON over the ordered sample -names, ordered feature names and every count as a decimal string. It changes if a -count, a name, or the order of either changes. - -Provenance recorded with every result: method name and version, numeric policy, -parameters, seed (recorded even though no catalogue method draws a random number), -Julia version, MPFR version, package version and the input fingerprint. - -## 7. Validation - -The evidence this layer ships with: - -| Evidence | Where | Runs in CI | -|---|---|---| -| Independent reference values in another language (`fractions.Fraction`, `decimal.Decimal` at 80 digits) | `scripts/statistics-reference.py` → `test/fixtures/statistics_known_answers.json` | yes (the fixture is checked by the suite) | -| Exhaustive subset enumeration, sharing no algebra with the closed form | `test/unit/test_exact_statistics.jl` | yes | -| Metamorphic relations (permutation, uniform rescaling) and bounds | same | yes | -| Adversarial cases: huge counts, all-zero tables, tiny probabilities, non-finite input, counts above 2^53, depth above library, work bounds | same | yes | -| Generative tests with seeded random tables | same | yes | -| Parsing fuzz at the exact-value and policy boundaries | `test/unit/test_exact_numeric.jl` | yes | -| Negative controls (8 mutants, each expected to be caught) | `scripts/statistics-negative-controls.jl`, enforced by the suite | yes | -| Simulation study: simulated mean vs exact expectation, pre-set 4-SE tolerance | small version in the suite; full study in `scripts/statistics-simulation.jl` | small version yes | -| Precision isolation under concurrency | `test/unit/test_exact_statistics.jl` | yes | -| Record reproducibility, immutability, corruption handling | same | yes | -| Existing behaviour unchanged when the layer is not selected | same | yes | -| Cost and allocation of the exact path | `scripts/statistics-simulation.jl` | no (opt-in) | - -Reproduce everything locally: - -```console -$ python3 scripts/statistics-reference.py # regenerate the fixture, then -$ julia --project=. test/runtests.jl exact # run the two layer test files -$ julia --project=. scripts/statistics-negative-controls.jl -$ julia --project=. scripts/statistics-simulation.jl -``` - -The simulation study's tolerance is set in advance at four standard errors of the -simulated mean: a correct implementation fails roughly once in 30,000 such -studies, while a bias above about 1% is caught. Seeds are fixed, so the study is -deterministic rather than flaky. - -### What has *not* been established - -Stated plainly, because a green test run is not the same as a validated method: - -* **No independent scientific review has happened.** The acceptance criteria in - issue #1 require it, and no amount of passing tests substitutes for it. -* **No inferential method exists**, so no coverage, error-rate or power claim is - made anywhere. -* **The layer has no UI panel.** It is backend-first by design: the issue rules - out new application TypeScript, and accessibility and workflow testing of a - panel is its own piece of work. -* **Cost on realistic large tables is not benchmarked in CI.** The opt-in script - reports it; the budgets there are advisory unless run with - `--strict-benchmark`. -* **Cross-language storage round trips** are validated against this document's - contract and a Python reference for the mathematics, not yet against R, DuckDB - or a browser client. - -## 8. Residual risks and limitations - -* Counts reach the layer as binary64 from the results database. Values above - 2^53−1 are refused, so a table whose counts exceed that cannot be summarised - here even though DuckDB could hold them exactly. This is a deliberate - refusal over a silent loss of exactness. -* Exact rationals grow. `max_digits` bounds them, and exceeding it in `exact` mode - fails the entry rather than degrading to floating point. A user who wants a - number at any cost can select `bigfloat` explicitly. -* `describe.alpha` does not normalise for sequencing depth. Comparing raw index - values across samples of different depth is not valid, and the method's - limitations say so; `rarefaction.expected_richness` exists for the - depth-standardised question. -* Simpson's index here is the plug-in Gini–Simpson complement on observed - relative abundances. It is not an unbiased estimator of the population index. -* No statistic in this layer accounts for compositionality beyond the warning in - `describe.proportions`. -* The `float64` mode rounds as binary64 does, so its values can differ in the last - bit from the existing `DiversityMetrics` routines even where both are correct. - The `exact` mode is the authoritative path. - -## 9. Acceptance criteria in issue #1 - -| Criterion | Status | -|---|---| -| Approve a bounded method catalogue and publish supported/unsupported conditions | done (§2) — bounded to five descriptive methods | -| Publish numeric conversion and cross-language/storage contracts, refusing unsupported representations | done (§3, §4); round trips tested for integers and rationals | -| Known-answer cases and independent reference implementations | done (`scripts/statistics-reference.py`, enumeration) | -| Numerical stability across precision levels; adversarial cases | done in the suite | -| Property/generative tests and boundary fuzzing | done | -| Negative controls and mutation sensitivity, commands and evidence recorded | done (`scripts/statistics-negative-controls.jl`) | -| Simulation studies for bias and error rates with pre-set tolerances | bias study done; interval coverage is not applicable, because no interval is estimated | -| Representative biological datasets and null/alternative cases | **open** — needs reviewed datasets; no inferential claim exists to validate yet | -| Reproducibility, serialization, concurrent precision isolation, failure recovery | done except cancellation of a long request, which needs the job queue and is **open** | -| UI-to-backend-to-result workflows, accessibility, provenance display | **open** — backend endpoints and provenance exist; no panel | -| Existing behaviour unchanged when unselected | tested | -| Benchmarks and bounds on expensive operations | bounds enforced; timings opt-in | -| Repository/owner standards gates in CI | runs through the ordinary CI workflow; results must be reviewed on the PR | -| Independent statistical/scientific review and release approval | **open** — owner action | - -The remaining open items are exactly the ones that cannot be closed by writing -code: reviewed datasets, a UI panel, cancellation through the job queue, and -independent scientific sign-off. Until they are closed, no inferential method -should be added and the companion symbolic-engine work stays blocked. diff --git a/scripts/statistics-negative-controls.jl b/scripts/statistics-negative-controls.jl deleted file mode 100644 index a973efcb..00000000 --- a/scripts/statistics-negative-controls.jl +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env julia -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# Negative controls for the exact statistics layer (issue #1). -# -# A test suite that only ever sees correct values cannot tell a working -# validation from one that accepts everything. Every check below is the same -# validation the unit tests apply to the real implementation; each is also -# applied to a deliberately wrong implementation (a mutant), and the record -# shows which mutants were caught. -# -# The mutations are chosen to be the mistakes that actually happen in this -# domain rather than arbitrary bit flips: -# -# * computing an exact statistic through binary64; -# * dividing by n-1 instead of n; -# * assuming sampling with replacement in a rarefaction expectation; -# * adding a pseudocount so a zero denominator never occurs; -# * rounding a fractional count instead of refusing it; -# * trusting a float count above 2^53; -# * ignoring the requested precision and using a fixed one. -# -# Run: -# -# julia --project=. scripts/statistics-negative-controls.jl -# -# It exits non-zero if any mutant escapes. The unit suite includes this file and -# asserts the same thing, so the evidence is enforced in CI rather than quoted. - -using MetaManifold.ExactNumeric -using MetaManifold.ExactStatistics - -## --------------------------------------------------------------------------- -## Mutants -## --------------------------------------------------------------------------- - -"""Simpson's index computed in binary64 instead of exactly.""" -mutant_simpson_binary64(counts) = - Float64(1 - sum((Float64(c) / Float64(sum(counts)))^2 for c in counts)) - -"""Simpson's index with a pseudocount, so a zero-read sample never divides by zero.""" -mutant_simpson_pseudocount(counts) = simpson_index(BigInt.(counts) .+ 1) - -"""A mean dividing by n-1 (a sample standard deviation habit applied to a mean).""" -mutant_mean_off_by_one(total, n) = - n <= 1 ? Float64(total) : Float64(total) / Float64(n - 1) - -"""Expected richness assuming reads are sampled *with* replacement.""" -function mutant_expected_richness_with_replacement(counts, depth) - total = sum(BigInt.(counts)) - s = zero(Rational{BigInt}) - for c in counts - c == 0 && continue - s += 1 - (one(Rational{BigInt}) - BigInt(c) // total)^depth - end - return s -end - -"""A proportion with a pseudocount, so a zero total becomes 1/1.""" -mutant_proportion_pseudocount(count, total) = BigInt(count + 1) // BigInt(total + 1) - -"""A count conversion that rounds instead of refusing a fractional value.""" -function mutant_count_rounds(x) - x isa AbstractFloat && return BigInt(round(Int, x)) - return BigInt(x) -end - -"""A count conversion that trusts a float above 2^53.""" -function mutant_count_trusts_large_floats(x) - x isa AbstractFloat && return BigInt(x) - return BigInt(x) -end - -"""Shannon's index computed at a fixed 256 bits whatever was requested.""" -mutant_shannon_fixed_precision(counts) = - shannon_index(counts, NumericPolicy(mode = "bigfloat", precision_bits = 256)) - -## --------------------------------------------------------------------------- -## The checks -## --------------------------------------------------------------------------- - -const _EXACT_FULL = NumericPolicy(mode = "exact", max_digits = 100_000) - -const NEGATIVE_CONTROL_CHECKS = [ - (name = "Simpson computed through binary64", - real = simpson_index, - mutant = mutant_simpson_binary64, - validate = f -> f(BigInt[1, 2]) isa Rational && f(BigInt[1, 2]) == 4 // 9), - - (name = "Simpson with a pseudocount", - real = simpson_index, - mutant = mutant_simpson_pseudocount, - validate = f -> f(BigInt[1, 2]) == 4 // 9), - - (name = "mean per feature dividing by n-1", - real = (total, n) -> exact_ratio(total, n; policy = _EXACT_FULL), - mutant = mutant_mean_off_by_one, - validate = f -> f(30, 3) == 10), - - (name = "expected richness assuming sampling with replacement", - real = (counts, depth) -> expected_richness(counts, depth; policy = _EXACT_FULL), - mutant = mutant_expected_richness_with_replacement, - validate = f -> f(BigInt[2, 3], 2) == 8 // 5), - - (name = "proportion with a pseudocount", - real = proportion_value, - mutant = mutant_proportion_pseudocount, - validate = f -> f(1, 3) == 1 // 3), - - (name = "fractional count rounded instead of refused", - real = exact_count, - mutant = mutant_count_rounds, - validate = f -> try - f(1.5) - false - catch e - e isa InvalidNumericInput - end), - - (name = "float count above 2^53 trusted", - real = exact_count, - mutant = mutant_count_trusts_large_floats, - validate = f -> try - f(Float64(2)^53) - false - catch e - e isa InvalidNumericInput - end), - - (name = "Shannon computed at a fixed precision", - real = counts -> shannon_index(counts, NumericPolicy(mode = "bigfloat", - precision_bits = 512)), - mutant = mutant_shannon_fixed_precision, - validate = f -> precision(f(BigInt[90, 10])) == 512), -] - -"""Run every negative control, printing the evidence, and return whether all passed.""" -function run_negative_controls(io::IO = stdout) - println(io, "statistics negative controls (issue #1)") - println(io, " ", rpad("check", 54), " real mutant") - ok = true - for control in NEGATIVE_CONTROL_CHECKS - real_passed = try - control.validate(control.real) === true - catch e - println(io, " real implementation threw in '", control.name, "': ", - sprint(showerror, e)) - false - end - mutant_rejected = try - control.validate(control.mutant) === false - catch e - # A mutant that throws is caught by a check that expects a value; it - # only counts as rejected if the check was itself an expectation of - # refusal, which `validate` returns false for on its own. - true - end - ok &= real_passed && mutant_rejected - println(io, " ", rpad(control.name, 54), " ", - rpad(real_passed ? "pass" : "FAIL", 7), " ", - mutant_rejected ? "caught" : "ESCAPED") - end - println(io, ok ? "all mutants caught" : "at least one mutant escaped") - return ok -end - -if abspath(PROGRAM_FILE) == abspath(@__FILE__) - exit(run_negative_controls() ? 0 : 1) -end diff --git a/scripts/statistics-reference.py b/scripts/statistics-reference.py deleted file mode 100644 index 325f5e9c..00000000 --- a/scripts/statistics-reference.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -"""Independent reference values for the exact statistics layer (issue #1). - -Why this file exists --------------------- -The Julia tests in `test/unit/test_exact_statistics.jl` assert known answers. -A known answer is only worth something if the number was produced somewhere -other than the implementation under test: comparing an implementation with -itself proves that it is consistently wrong just as well as that it is right. - -So the expected values here are computed by a second implementation, written -from the published definitions, in a different language and with different -arithmetic libraries: - - * exact rationals use Python's `fractions.Fraction`; - * Shannon values use `decimal.Decimal` at 80 significant digits, whose - `ln()` is a correctly-rounded-to-context implementation, not the same - algorithm Julia's MPFR call uses; - * every expected-richness value is checked twice - once with the closed form - written with `math.comb`, and once by brute-force enumeration over every - subset of the library (only for libraries small enough to enumerate). The - two are independent of each other, so a value that agrees with both is - pinned down rather than merely consistent. - -The output is `test/fixtures/statistics_known_answers.json`, which the Julia -suite reads. Regenerate with: - - python3 scripts/statistics-reference.py - -and the fixture is rewritten (the script prints nothing else, so it can be -diffed). If the Julia implementation and this reference ever disagree, one of -them has a bug; the fixture is the record of what was agreed at review time. - -Scope note: this validates arithmetic and the closed-form expectation. It says -nothing about biological validity, and it does not validate any statistical -method that does not yet exist in the catalogue. -""" - -from __future__ import annotations - -import json -import math -from decimal import Decimal, getcontext -from fractions import Fraction -from itertools import combinations -from pathlib import Path - -getcontext().prec = 80 - -FIXTURES = Path(__file__).resolve().parent.parent / "test" / "fixtures" / "statistics_known_answers.json" - - -def simpson(counts: list[int]) -> Fraction: - """Gini-Simpson complement: 1 - sum(p_i^2), exactly.""" - total = sum(counts) - if total == 0: - raise ZeroDivisionError("Simpson index of a sample with zero reads") - squares = sum(Fraction(c, total) ** 2 for c in counts) - return 1 - squares - - -def shannon(counts: list[int]) -> Decimal: - """-sum(p_i ln p_i) at the ambient Decimal precision.""" - total = Decimal(sum(counts)) - h = Decimal(0) - for c in counts: - if c == 0: - continue - p = Decimal(c) / total - h -= p * p.ln() - return +h - - -def expected_richness_closed_form(counts: list[int], depth: int) -> Fraction: - """Hurlbert (1971): sum_i (1 - C(N - n_i, m) / C(N, m)).""" - total = sum(counts) - if not 1 <= depth <= total: - raise ValueError("depth out of range") - denom = math.comb(total, depth) - s = Fraction(0) - for c in counts: - s += 1 - Fraction(math.comb(total - c, depth) if total - c >= depth else 0, denom) - return s - - -def expected_richness_brute_force(counts: list[int], depth: int) -> Fraction: - """Enumerate every subsample of `depth` reads and average the richness. - - Reads are treated as distinguishable individuals, which is exactly the - without-replacement model the closed form assumes. - """ - reads: list[int] = [] - for feature, c in enumerate(counts): - reads.extend([feature] * c) - total = len(reads) - if not 1 <= depth <= total or math.comb(total, depth) > 2_000_000: - raise ValueError("library too large to enumerate") - observed = Decimal(0) - n = 0 - for subset in combinations(reads, depth): - observed += len(set(subset)) - n += 1 - return Fraction(int(observed), n) - - -def ratio_string(value: Fraction) -> str: - return f"{value.numerator}/{value.denominator}" - - -def bytes_to_permutations(counts: list[int]) -> list[list[int]]: - """The reverse order, used to check that the expectation is permutation-invariant.""" - return list(reversed(counts)) - - -SIMPSON_CASES = [ - [1, 2], - [1, 1, 1, 1], - [50, 50], - [90, 10], - [1], - [2, 3, 5, 7, 11], - [10**6, 3 * 10**6, 0], - [0, 0, 7], -] - -EXPECTED_RICHNESS_CASES = [ - ([2, 3], 1), - ([2, 3], 2), - ([2, 3], 5), - ([1, 1, 1], 2), - ([5, 0, 0], 3), - ([10, 20, 30], 15), - ([1, 2, 3, 4], 5), - ([4, 4, 4, 4], 8), -] - -SHANNON_CASES = [ - [50, 50], - [90, 10], - [1, 1, 1], - [1, 10**9], - [25, 25, 25, 25], -] - -PROPORTION_CASES = [ - [1, 2], - [3, 3, 3], - [90, 10], - [0, 5, 5], -] - - -def main() -> None: - fixture: dict = { - "_provenance": { - "producer": "scripts/statistics-reference.py", - "language": "Python " + ".".join(str(v) for v in __import__("sys").version_info[:3]), - "arithmetic": "fractions.Fraction (exact rationals); decimal.Decimal at 80 digits for logarithms", - "independence": [ - "written from the published definitions, not ported from the Julia code", - "expected richness checked against both a closed form and exhaustive subset enumeration", - "Shannon values computed with Decimal.ln at 80 digits, not MPFR", - ], - "does_not_validated": "anything inferential: the layer makes no inference, and no statistical test is validated here", - "shannon_digits": 60, - }, - "simpson": [], - "expected_richness": [], - "shannon": [], - "proportions": [], - } - - for counts in SIMPSON_CASES: - value = simpson(counts) - fixture["simpson"].append({ - "counts": counts, - "value": ratio_string(value), - "permuted_value": ratio_string(simpson(bytes_to_permutations(counts))), - "scaled_by_three": ratio_string(simpson([3 * c for c in counts])), - "display_6": f"{float(value):.6f}", - }) - - for counts, depth in EXPECTED_RICHNESS_CASES: - closed = expected_richness_closed_form(counts, depth) - entry = { - "counts": counts, - "depth": depth, - "value": ratio_string(closed), - "permuted_value": ratio_string(expected_richness_closed_form(bytes_to_permutations(counts), depth)), - "display_6": f"{float(closed):.6f}", - } - try: - brute = expected_richness_brute_force(counts, depth) - except ValueError: - entry["brute_force"] = None - else: - if brute != closed: - raise SystemExit( - f"closed form and enumeration disagree for {counts} at depth {depth}: " - f"{closed} != {brute}" - ) - entry["brute_force"] = ratio_string(brute) - fixture["expected_richness"].append(entry) - - for counts in SHANNON_CASES: - value = shannon(counts) - fixture["shannon"].append({ - "counts": counts, - # 60 significant digits, truncated rather than rounded: the Julia test - # compares with a tolerance, and a truncated reference cannot be more - # precise than it claims. - "value_decimal": str(value.quantize(Decimal(1).scaleb(-60))), - "digits": 60, - "double": float(value), - }) - - for counts in PROPORTION_CASES: - total = sum(counts) - fixture["proportions"].append({ - "counts": counts, - "values": [ratio_string(Fraction(c, total)) for c in counts], - }) - - FIXTURES.parent.mkdir(parents=True, exist_ok=True) - FIXTURES.write_text(json.dumps(fixture, indent=2, sort_keys=False) + "\n") - print(f"wrote {FIXTURES}") - - -if __name__ == "__main__": - main() diff --git a/scripts/statistics-simulation.jl b/scripts/statistics-simulation.jl deleted file mode 100644 index f9655753..00000000 --- a/scripts/statistics-simulation.jl +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env julia -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# Simulation, stability and cost study for the exact statistics layer (issue #1). -# -# Three questions, each answered by running the layer rather than reasoning -# about it: -# -# 1. Bias. Is the closed-form expected richness the mean of the thing it -# claims to be the mean of? For each table and depth, subsamples are drawn -# without replacement and the simulated mean is compared with the exact -# value. The tolerance is fixed in advance at four standard errors of the -# simulated mean, so a correct implementation fails roughly once in 30,000 -# runs and a bias above ~1% is caught. Seeds are fixed, so the study is -# reproducible rather than flaky. -# -# 2. Precision stability. Does the Shannon index agree across 64, 256 and 1024 -# bits to the precision the narrowest of them can support? Disagreement -# beyond 53 bits' worth of agreement would mean the precision setting is not -# being honoured. -# -# 3. Cost. How long does the exact path take, and how much does it allocate, on -# a table of realistic width? This is reported, and only enforced with -# --strict-benchmark, because a wall-clock budget on shared CI hardware is a -# flaky assertion rather than a measurement. -# -# This script is NOT part of the unit suite: it runs for longer than a unit test -# should. The unit suite carries a small version of study 1 with the same -# pre-set tolerance. Run: -# -# julia --project=. scripts/statistics-simulation.jl -# julia --project=. scripts/statistics-simulation.jl --strict-benchmark -# -# It exits non-zero if a correctness check fails. - -using MetaManifold.ExactNumeric -using MetaManifold.ExactStatistics -using Random - -const STRICT = "--strict-benchmark" in ARGS -const EXACT = NumericPolicy(mode = "exact", max_digits = 100_000) -const BIG = NumericPolicy(mode = "bigfloat", precision_bits = 256) - -"""Draw `n` subsamples of `depth` reads without replacement; return mean and SE.""" -function simulate_richness(counts::Vector{Int}, depth::Int; n::Int = 4_000, seed::Int = 12345) - reads = Int[] - for (feature, c) in enumerate(counts) - for _ in 1:c - push!(reads, feature) - end - end - rng = MersenneTwister(seed) - values = Vector{Int}(undef, n) - for i in 1:n - subset = randperm(rng, length(reads))[1:depth] - values[i] = length(unique(reads[subset])) - end - μ = sum(values) / n - σ = sqrt(sum((v - μ)^2 for v in values) / (n - 1)) - return μ, σ / sqrt(n) -end - -function bias_study(io::IO) - println(io, "\n1. Expected richness: exact value vs simulated mean") - println(io, " tolerance: |simulated - exact| <= 4 standard errors (pre-set)\n") - println(io, " ", rpad("counts", 22), rpad("depth", 7), rpad("exact", 20), - rpad("simulated", 12), "SE result") - tables = [ - [4, 6, 2, 8], - [10, 10], - [20, 1, 1], - [30, 20, 10, 5], - [1, 1, 1, 1, 1, 1, 1, 1], - [50, 0, 0, 0], - [12, 7, 3], - ] - ok = true - for counts in tables, depth in unique([1, max(1, sum(counts) ÷ 4), max(1, sum(counts) ÷ 2)]) - exact = expected_richness(BigInt.(counts), depth; policy = EXACT) - μ, se = simulate_richness(counts, depth) - tolerance = 4 * se - passed = abs(μ - Float64(exact)) <= tolerance + 1e-12 - ok &= passed - println(io, " ", rpad(string(counts), 22), rpad(depth, 7), - rpad(display_string(exact; digits = 6), 20), - rpad(string(round(μ; digits = 4)), 12), - rpad(string(round(se; sigdigits = 3)), 10), - passed ? "pass" : "FAIL") - end - return ok -end - -function precision_study(io::IO) - println(io, "\n2. Shannon index across precisions") - tables = [[50, 50], [90, 10], [1, 10^9], [25, 25, 25, 25], [1, 1, 1]] - ok = true - for counts in tables - values = Dict(bits => shannon_index(BigInt.(counts), - NumericPolicy(mode = "bigfloat", - precision_bits = bits)) - for bits in (64, 256, 1024)) - narrow = values[64] - # 64 bits carries about 19 decimal digits; agreement to 1e-17 is what that - # width supports, and a wider computation that disagrees has lost precision. - agreement = maximum(abs(values[bits] - narrow) for bits in (256, 1024)) - passed = agreement < big"1e-17" - ok &= passed - println(io, " ", rpad(string(counts), 22), - " agreement across 64/256/1024 bits: ", - rpad(string(agreement), 12), passed ? "pass" : "FAIL") - end - return ok -end - -function cost_study(io::IO) - println(io, "\n3. Cost of the exact path (reported; enforced only with --strict-benchmark)") - rng = MersenneTwister(2026) - samples, features = 200, 50 - mat = [rand(rng, 0:500) for _ in 1:samples, _ in 1:features] - names_s = ["S$(i)" for i in 1:samples] - names_f = ["F$(j)" for j in 1:features] - - results = NamedTuple[] - for (label, method, policy, options) in ( - ("summarise.counts (exact)", "summarise.counts", EXACT, Dict{String,Any}()), - ("describe.alpha (bigfloat 256)", "describe.alpha", BIG, Dict{String,Any}()), - ("describe.proportions (exact)", "describe.proportions", EXACT, Dict{String,Any}()), - ("rarefaction.expected_richness (bigfloat 256)", - "rarefaction.expected_richness", BIG, Dict{String,Any}("depth" => 100))) - stats = @timed analyse(mat, names_s, names_f; method = method, policy = policy, - options = options) - record = stats.value - push!(results, (; label, seconds = stats.time, bytes = stats.bytes, - status = record["status"])) - end - - budgets = Dict("summarise.counts (exact)" => 5.0, - "describe.alpha (bigfloat 256)" => 10.0, - "describe.proportions (exact)" => 5.0, - "rarefaction.expected_richness (bigfloat 256)" => 60.0) - ok = true - for r in results - budget = get(budgets, r.label, 60.0) - within = r.seconds <= budget - ok &= within || !STRICT - println(io, " ", rpad(r.label, 46), - rpad(string(round(r.seconds; digits = 3)) * " s", 10), - rpad(string(round(r.bytes / 2^20; digits = 1)) * " MiB", 10), - rpad(r.status, 10), - within ? "within budget" : "OVER BUDGET ($(budget) s)") - end - return ok -end - -function main(io::IO = stdout) - println(io, "statistics simulation study (issue #1)") - println(io, "julia ", VERSION, ", ", Sys.CPU_THREADS, " threads") - ok = bias_study(io) - ok &= precision_study(io) - ok &= cost_study(io) - println(io, "\n", ok ? "all checks passed" : "at least one check failed") - return ok -end - -if abspath(PROGRAM_FILE) == abspath(@__FILE__) - exit(main() ? 0 : 1) -end diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl index 92b0c071..071c0165 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -32,22 +32,20 @@ include("pipeline/dada2.jl") include("pipeline/swarm.jl") include("pipeline/phylogeny.jl") -# Canonical JSON: deterministic bytes for hashing and content-addressing. -# Included before the analysis layer because the statistics records are -# content-addressed with it (their analysis id is the SHA-256 of their canonical -# bytes). It depends on SHA only, so nothing else moves. +# Canonical JSON precedes validated analyses: exact values and derived records +# use deterministic bytes for hashing and content-addressing. include("canonical.jl") # Analysis include("analysis/diversity.jl") include("analysis/analysis.jl") include("analysis/differential.jl") - -# Optional exact-arithmetic statistics layer (issue #1). Its own modules; the -# code above neither imports nor calls them, so every existing analysis and -# pipeline path is unchanged when the layer is not selected. -include("analysis/exact_numeric.jl") -include("analysis/exact_statistics.jl") +# Opt-in validated descriptive statistics (issue #1). Ordinary workflows above +# remain unchanged; these modes must be selected explicitly. +include("analysis/numeric_policy.jl") +include("analysis/exact_summaries.jl") +include("analysis/high_precision.jl") +include("analysis/derived_analyses.jl") # Web server include("server/server.jl") diff --git a/src/analysis/exact_numeric.jl b/src/analysis/exact_numeric.jl deleted file mode 100644 index d6bd6413..00000000 --- a/src/analysis/exact_numeric.jl +++ /dev/null @@ -1,695 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# The numeric policy of the optional statistics layer (issue #1). -# -# This file owns four things and nothing else: -# -# 1. What "exact" and "higher precision" mean here, as one validated, -# versioned `NumericPolicy` value. -# 2. Checked conversion of counts into arbitrary-precision integers, with an -# explicit refusal - never a rounding - at every boundary that cannot -# represent a count exactly. -# 3. Exact rational construction with explicit zero-denominator and resource -# failures. -# 4. The serialisation contract: every number leaves this layer tagged as an -# exact value, an approximation, or a rounded display of one of those, so a -# consumer cannot mistake a display value for a computed one. -# -# Scope, stated plainly. Exact arithmetic removes representation error. It does -# not remove measurement error, sampling uncertainty, model misspecification or -# biological bias, and nothing here estimates any of them. Higher precision is -# not exactness either: a 4096-bit approximation is still an approximation, and -# every value this file labels `"approximation"` is one. Symbolic mathematics is -# out of scope. -# -# Precision isolation. Julia's BigFloat precision is process-global state. Every -# computation that depends on it here runs inside `with_precision`, which takes -# a process-wide lock and restores the previous precision before releasing it, -# so two analyses asking for different precisions can run concurrently without -# either seeing the other's setting. Nothing in this file changes the ambient -# precision permanently, and no result carries an implicit dependence on it: a -# value is rounded to a stated number of bits when it is produced. - -module ExactNumeric - -export POLICY_VERSION, NUMERIC_MODES, DEFAULT_PRECISION_BITS, MIN_PRECISION_BITS, - MAX_PRECISION_BITS, DEFAULT_MAX_DIGITS, MAX_MAX_DIGITS, MAX_DISPLAY_DIGITS, - FLOAT_EXACT_INT_MAX, NumericPolicy, policy_from_config, policy_dict, - arithmetic_bits, with_precision, approximate_value, - NumericError, InvalidNumericInput, ZeroDenominator, NumericLimit, - ResourceLimit, UnsupportedNumericMode, error_code, - exact_count, exact_counts, exact_ratio, check_limit, - exact_json, approx_json, parse_exact_json, display_string, - decimal_digits, numeric_contract - -# Version of the numeric policy as a whole: the set of modes, the conversion -# rules, and the JSON tags below. A stored analysis records this string, so a -# later change to any of them is visible against an old result rather than -# silent. -const POLICY_VERSION = "1.0.0" - -# `float64` - IEEE-754 binary64 (53-bit significand), for compatibility -# with the values the rest of the application already produces. -# `bigfloat` - MPFR at `precision_bits`, an approximation computed and -# reported at that precision. -# `exact` - arbitrary-precision integers and exact rationals only. An -# operation with no exact representation is refused -# (`UnsupportedNumericMode`); it is never computed at some other -# precision and returned as if it were what was asked for. -const NUMERIC_MODES = ("float64", "bigfloat", "exact") - -const DEFAULT_PRECISION_BITS = 256 -# 53 is binary64's significand: below it, "higher precision" would be lower. -const MIN_PRECISION_BITS = 53 -# An upper bound rather than a suggestion: past it, an analysis is far more -# likely to be an accident than a requirement, and the cost is unbounded. -const MAX_PRECISION_BITS = 4096 - -# The largest number of decimal digits allowed in any exact numerator, -# denominator or integer this layer emits or stores. -const DEFAULT_MAX_DIGITS = 4096 -const MAX_MAX_DIGITS = 100_000 - -# Display rounding is presentation only; it never feeds a computation back. -const MAX_DISPLAY_DIGITS = 50 - -# Binary64 represents every integer in this range exactly, and no other. -const FLOAT_EXACT_INT_MAX = 2^53 - 1 - -## --------------------------------------------------------------------------- -## Failures -## -## Each of these is a refusal, not a fallback. The statistics layer turns them -## into an explicit unsuccessful state (with a stable machine-readable code) -## instead of a plausible-looking number. -## --------------------------------------------------------------------------- - -abstract type NumericError <: Exception end - -"An input cannot be represented exactly: a fractional count, NaN, or similar." -struct InvalidNumericInput <: NumericError - what :: String - reason :: String -end - -"A divisor was zero, so the requested ratio has no value." -struct ZeroDenominator <: NumericError - what :: String -end - -"An exact value exceeded the policy's `max_digits` bound." -struct NumericLimit <: NumericError - what :: String - limit :: Int -end - -"A computation exceeded a declared work bound (iterations or operations)." -struct ResourceLimit <: NumericError - what :: String - limit :: Int -end - -"An operation has no representation in the requested numeric mode." -struct UnsupportedNumericMode <: NumericError - what :: String - mode :: Symbol - reason :: String -end - -Base.showerror(io::IO, e::InvalidNumericInput) = - print(io, "invalid numeric input ($(e.what)): $(e.reason)") - -Base.showerror(io::IO, e::ZeroDenominator) = - print(io, "zero denominator: $(e.what) has no value when its divisor is zero") - -Base.showerror(io::IO, e::NumericLimit) = - print(io, "exact value limit: $(e.what) needs more than $(e.limit) decimal digits " * - "in a numerator, denominator or integer (raise numeric.max_digits, within " * - "its own bound, or select the bigfloat mode explicitly)") - -Base.showerror(io::IO, e::ResourceLimit) = - print(io, "resource limit: $(e.what) would exceed $(e.limit) units of work") - -Base.showerror(io::IO, e::UnsupportedNumericMode) = - print(io, "unsupported numeric mode: $(e.what) has no exact representation, and the " * - "policy is '$(e.mode)' (nothing was silently computed at another precision). " * - e.reason) - -""" - error_code(e::NumericError) -> String - -The stable code a failure is stored under, for clients that switch on it. -""" -error_code(::InvalidNumericInput) = "invalid_input" -error_code(::ZeroDenominator) = "zero_denominator" -error_code(::NumericLimit) = "resource_limit" -error_code(::ResourceLimit) = "resource_limit" -error_code(::UnsupportedNumericMode) = "unsupported_numeric_mode" - -## --------------------------------------------------------------------------- -## The policy -## --------------------------------------------------------------------------- - -""" - NumericPolicy(; mode="float64", precision_bits=256, max_digits=4096, - display_digits=6) - -The requested numeric behaviour of one analysis. Immutable, small, and carried -in every stored result's provenance, so a result states the rules it was -computed under rather than inheriting today's defaults. - -`mode` is one of `NUMERIC_MODES`. `precision_bits`, in [53, 4096], is MPFR's -working precision and applies to the `bigfloat` mode only; it is still recorded -in the other modes, because a policy is a request, not a report. `max_digits`, -in [16, 100000], bounds every exact numerator, denominator and integer the -analysis may produce. `display_digits`, in [0, 50], is the rounding applied to -*display* strings only. - -Every argument is validated strictly. A value of the wrong type is refused -rather than converted: `precision_bits=256.0` is an `ArgumentError`, not 256, -because silently accepting one spelling and not another is how two callers end -up meaning different things. `Bool` is refused for the integer fields. -""" -struct NumericPolicy - mode :: Symbol - precision_bits :: Int - max_digits :: Int - display_digits :: Int - version :: String -end - -function _strict_int(v, name::String, lo::Integer, hi::Integer)::Int - (v isa Integer && !(v isa Bool)) || throw(ArgumentError( - "numeric policy $name must be an integer, not $(typeof(v))")) - lo <= v <= hi || throw(ArgumentError( - "numeric policy $name must be between $lo and $hi, not $v")) - return Int(v) -end - -function NumericPolicy(; mode = "float64", - precision_bits = DEFAULT_PRECISION_BITS, - max_digits = DEFAULT_MAX_DIGITS, - display_digits = 6) - mode isa AbstractString || throw(ArgumentError( - "numeric policy mode must be a string, not $(typeof(mode))")) - m = lowercase(strip(String(mode))) - m in NUMERIC_MODES || throw(ArgumentError( - "numeric policy mode must be one of $(join(NUMERIC_MODES, ", ")), not '$mode'")) - NumericPolicy(Symbol(m), - _strict_int(precision_bits, "precision_bits", MIN_PRECISION_BITS, MAX_PRECISION_BITS), - _strict_int(max_digits, "max_digits", 16, MAX_MAX_DIGITS), - _strict_int(display_digits, "display_digits", 0, MAX_DISPLAY_DIGITS), - POLICY_VERSION) -end - -const POLICY_KEYS = ("mode", "precision_bits", "max_digits", "display_digits") - -""" - policy_from_config(section=nothing; overrides=nothing) -> NumericPolicy - -Build a policy from a config section (a `Dict` from the config cascade, or -`nothing`) and an optional request-level override, with the override applied -last. Keys are `"mode"`, `"precision_bits"`, `"max_digits"` and -`"display_digits"`; `nothing` values mean "not set" and are skipped, so a -cascade's explicit `~` does not shadow the default with a null. - -An unknown key is an `ArgumentError`. That is deliberate: a misspelt -`"precision"` must not leave the analysis quietly running at the default while -the caller believes otherwise. -""" -function policy_from_config(section::Union{Nothing,AbstractDict} = nothing; - overrides::Union{Nothing,AbstractDict} = nothing) - values = Dict{String,Any}() - for source in (section, overrides) - isnothing(source) && continue - for (k, v) in source - key = String(k) - key in POLICY_KEYS || throw(ArgumentError( - "unknown numeric policy key '$key' (expected one of $(join(POLICY_KEYS, ", ")))")) - isnothing(v) && continue - values[key] = v - end - end - NumericPolicy(; mode = get(values, "mode", "float64"), - precision_bits = get(values, "precision_bits", DEFAULT_PRECISION_BITS), - max_digits = get(values, "max_digits", DEFAULT_MAX_DIGITS), - display_digits = get(values, "display_digits", 6)) -end - -""" - policy_dict(p) -> Dict{String,Any} - -The policy as plain JSON-safe data for provenance. -""" -policy_dict(p::NumericPolicy) = Dict{String,Any}( - "version" => p.version, - "mode" => String(p.mode), - "precision_bits" => p.precision_bits, - "precision_applies_to" => p.mode === :bigfloat ? "all arithmetic in this analysis" : - "bigfloat mode only (recorded, not used here)", - "max_digits" => p.max_digits, - "display_digits" => p.display_digits, -) - -""" - arithmetic_bits(p) -> Int - -The significand width approximate arithmetic runs at: the policy's precision in -`bigfloat` mode, binary64's 53 bits in `float64` mode. Asking an `exact` policy -for it is an `UnsupportedNumericMode`, not a default: there is no approximate -arithmetic in exact mode. -""" -arithmetic_bits(p::NumericPolicy) = p.mode === :bigfloat ? p.precision_bits : - p.mode === :float64 ? 53 : - throw(UnsupportedNumericMode("arithmetic_bits", p.mode, - "exact mode performs no approximate arithmetic")) - -## --------------------------------------------------------------------------- -## Precision isolation -## --------------------------------------------------------------------------- - -const _PRECISION_LOCK = ReentrantLock() - -""" - with_precision(f, bits) -> f() - -Run `f` with BigFloat's working precision set to `bits` and restore the previous -precision before returning, under a process-wide lock. - -This is the only way this layer touches the global precision. The lock is what -makes concurrent analyses with different precisions safe: without it, two tasks -would interleave their settings and one would silently compute the other's -precision. The restore in a `finally` is what keeps the setting from leaking -into unrelated code. Nested calls are safe - the lock is reentrant and each -frame restores its own predecessor. -""" -function with_precision(f::Function, bits::Integer) - bits = _strict_int(bits, "working precision", MIN_PRECISION_BITS, MAX_PRECISION_BITS) - lock(_PRECISION_LOCK) - try - return setprecision(BigFloat, bits) do - f() - end - finally - unlock(_PRECISION_LOCK) - end -end - -""" - approximate_value(f, policy; what="value") -> BigFloat - -Run the closure `f` at the policy's working precision and return its result. - -`f` must construct its own `BigFloat`s (from integers or strings) so that every -value it touches is at the requested precision; the closure is evaluated with -the global precision set accordingly, so `BigFloat(n)` inside it is exact. -In `exact` mode this refuses: an approximation was not requested, and returning -one would be the silent downgrade this layer exists to prevent. -""" -function approximate_value(f::Function, policy::NumericPolicy; what::AbstractString = "value") - policy.mode === :exact && throw(UnsupportedNumericMode(String(what), policy.mode, - "select bigfloat (or float64) explicitly if an approximation is what you want")) - bits = arithmetic_bits(policy) - return with_precision(bits) do - v = f() - v isa BigFloat ? v : BigFloat(v) - end -end - -## --------------------------------------------------------------------------- -## Counts: checked conversion -## --------------------------------------------------------------------------- - -""" - exact_count(x; what="count") -> BigInt - -`x` as an exact non-negative integer, or a refusal. - -Accepted: non-negative `Integer`s of any width, `Rational`s with denominator 1, -and finite integral `AbstractFloat`s whose magnitude is at most 2^53-1. The last -bound is the point of the rule: binary64 cannot represent every integer above -it, so a float that large may already be a rounded neighbour of the count it -came from, and accepting it would be claiming an exactness the boundary did not -have. Refused, with the reason stated: fractional values (never rounded), NaN -and infinities, negatives, `Bool`, and unsupported types. -""" -function exact_count(x::Real; what::AbstractString = "count") - if x isa Bool - throw(InvalidNumericInput(String(what), "a Bool is not a read count")) - elseif x isa Integer - x >= 0 || throw(InvalidNumericInput(String(what), - "counts must be non-negative, got $x")) - return BigInt(x) - elseif x isa Rational - denominator(x) == 1 || throw(InvalidNumericInput(String(what), - "the fractional value $x is refused rather than rounded")) - c = numerator(x) - c >= 0 || throw(InvalidNumericInput(String(what), - "counts must be non-negative, got $x")) - return BigInt(c) - elseif x isa AbstractFloat - isfinite(x) || throw(InvalidNumericInput(String(what), - "the non-finite value $x is not a count")) - isinteger(x) || throw(InvalidNumericInput(String(what), - "the fractional value $x is refused rather than rounded")) - abs(x) <= FLOAT_EXACT_INT_MAX || throw(InvalidNumericInput(String(what), - "the floating-point value $x is above 2^53-1, where binary64 no longer " * - "represents every integer; it cannot be trusted to name the count it came from")) - c = BigInt(x) - c >= 0 || throw(InvalidNumericInput(String(what), - "counts must be non-negative, got $x")) - return c - end - throw(InvalidNumericInput(String(what), - "unsupported count type $(typeof(x))")) -end - -_index_string(i) = i isa CartesianIndex ? string(Tuple(i)) : string(i) - -""" - exact_counts(counts) -> Matrix{BigInt} - -`counts` converted entry by entry with `exact_count`. The first refusal names -the offending position, so a bad cell in a wide table is findable. -""" -function exact_counts(counts::AbstractMatrix{<:Real}) - out = Matrix{BigInt}(undef, size(counts)) - for i in eachindex(counts) - out[i] = exact_count(counts[i]; what = "count[$( _index_string(i) )]") - end - return out -end - -function exact_counts(counts::AbstractVector{<:Real}) - out = Vector{BigInt}(undef, length(counts)) - for i in eachindex(counts) - out[i] = exact_count(counts[i]; what = "count[$( _index_string(i) )]") - end - return out -end - -## --------------------------------------------------------------------------- -## Exact values and their bounds -## --------------------------------------------------------------------------- - -""" - decimal_digits(x) -> Int - -Number of decimal digits in |x| (1 for zero). Computed from the decimal -expansion rather than a logarithm, so it is exact for arbitrary-precision -integers. -""" -decimal_digits(x::Integer) = x == 0 ? 1 : length(string(abs(BigInt(x)))) - -""" - check_limit(x, policy; what="value") - -Refuse an exact integer or rational whose numerator or denominator exceeds -`policy.max_digits` decimal digits. - -Both halves of a rational are bounded: a rational can be tiny and yet carry an -enormous exact representation, and it is the representation that consumes memory -and makes a stored result unreadable. -""" -function check_limit(x::Integer, policy::NumericPolicy; what::AbstractString = "value") - decimal_digits(x) <= policy.max_digits || throw(NumericLimit(String(what), policy.max_digits)) - return x -end - -function check_limit(x::Rational, policy::NumericPolicy; what::AbstractString = "value") - d = max(decimal_digits(numerator(x)), decimal_digits(denominator(x))) - d <= policy.max_digits || throw(NumericLimit(String(what), policy.max_digits)) - return x -end - -""" - exact_ratio(num, den; policy, what="ratio") -> Rational{BigInt} - -`num/den` as an exact rational, in lowest terms. - -A zero denominator is a `ZeroDenominator`, never a substituted value and never -an infinity. The result is checked against `max_digits` before it is returned. -""" -function exact_ratio(num::Integer, den::Integer; - policy::NumericPolicy = NumericPolicy(), - what::AbstractString = "ratio") - (num isa Bool || den isa Bool) && throw(InvalidNumericInput(String(what), - "a Bool is not a count or a divisor")) - BigInt(den) == 0 && throw(ZeroDenominator(String(what))) - q = BigInt(num) // BigInt(den) - return check_limit(q, policy; what = what) -end - -## --------------------------------------------------------------------------- -## Display: rounded, always labelled as such -## --------------------------------------------------------------------------- - -"""Round an exact rational to an integer, half to even.""" -function _round_ratio(num::BigInt, den::BigInt)::BigInt - den == 0 && throw(ZeroDenominator("display")) - if den < 0 - num, den = -num, -den - end - q, r = divrem(num, den) # truncating; r has the sign of num - twice = 2 * abs(r) - if twice > den - return num < 0 ? q - 1 : q + 1 - elseif twice == den - # A tie: move to the even neighbour, which for an odd q means one step - # away from zero. - return isodd(abs(q)) ? (num < 0 ? q - 1 : q + 1) : q - end - return q -end - -"""Render a value already scaled by 10^`digits` as a fixed-point decimal.""" -function _scaled_decimal(n::BigInt, digits::Int)::String - s = string(abs(n)) - sign = n < 0 ? "-" : "" - digits == 0 && return sign * s - if length(s) <= digits - s = repeat("0", digits - length(s) + 1) * s - end - return string(sign, first(s, length(s) - digits), ".", last(s, digits)) -end - -"""Round a BigFloat to the nearest integer, half to even, using only exact steps.""" -function _round_to_bigint(y::BigFloat)::BigInt - isfinite(y) || throw(InvalidNumericInput("value", - "display formatting requires a finite value, got $y")) - return with_precision(max(precision(y), MIN_PRECISION_BITS)) do - f = floor(y) # exact: the result is integral - frac = y - f # exact at y's own precision - q = BigInt(f) - half = BigFloat(1) / 2 - if frac > half - q += 1 - elseif frac == half && isodd(abs(q)) - q += 1 # half to even - end - q - end -end - -""" - display_string(x; digits=6) -> String - -A fixed-point rendering of `x`, rounded half-to-even to `digits` decimal places. - -Display is presentation. It is never read back into a computation, and every -JSON document this layer writes marks a display value with -`"display_rounded" => true`, so the rounded string and the computed value cannot -be confused. Exact integers are rendered in full, without rounding. -""" -display_string(x::Integer; digits::Integer = 6) = string(BigInt(x)) - -function display_string(x::Rational; digits::Integer = 6) - d = clamp(Int(digits), 0, MAX_DISPLAY_DIGITS) - scale = BigInt(10)^d - return _scaled_decimal(_round_ratio(BigInt(numerator(x)) * scale, BigInt(denominator(x))), d) -end - -function display_string(x::BigFloat; digits::Integer = 6) - isfinite(x) || throw(InvalidNumericInput("value", - "display formatting requires a finite value, got $x")) - d = clamp(Int(digits), 0, MAX_DISPLAY_DIGITS) - # 4 bits per decimal digit is ample to hold 10^d exactly, with room for the - # value's own significand. - y = with_precision(max(precision(x), 4 * d + 64)) do - x * BigFloat(10)^d - end - return _scaled_decimal(_round_to_bigint(y), d) -end - -function display_string(x::AbstractFloat; digits::Integer = 6) - isfinite(x) || throw(InvalidNumericInput("value", - "display formatting requires a finite value, got $x")) - d = clamp(Int(digits), 0, MAX_DISPLAY_DIGITS) - # Float64 -> BigFloat is exact, so the binary value is displayed as the - # number it actually is (0.1 shows as 0.100000, not as a rounded 0.3). - return with_precision(max(64, 4 * d + 64)) do - display_string(BigFloat(x); digits = d) - end -end - -## --------------------------------------------------------------------------- -## The serialisation contract -## --------------------------------------------------------------------------- - -""" - exact_json(x; policy) -> Dict{String,Any} - -An exact integer or rational, tagged. - - {"kind": "exact", "type": "integer", "value": "12345", "display": "12345"} - {"kind": "exact", "type": "rational", "numerator": "3", "denominator": "4", - "value": "3/4", "display": "0.750000", "display_rounded": true} - -Integers are always decimal *strings*, never JSON numbers: a count above -2^53-1 would not survive a JSON round trip as a number, and emitting it as one -is how exact counts quietly stop being exact in a browser or an R session. The -rational is normalised (lowest terms, positive denominator) and a zero -denominator is refused here as everywhere else. -""" -function exact_json(x::Integer; policy::NumericPolicy = NumericPolicy()) - v = BigInt(x) - check_limit(v, policy; what = "integer value") - return Dict{String,Any}("kind" => "exact", - "type" => "integer", - "value" => string(v), - "display" => string(v)) -end - -function exact_json(x::Bool; policy::NumericPolicy = NumericPolicy()) - throw(InvalidNumericInput("value", "a Bool is not an exact statistic")) -end - -function exact_json(x::Rational; policy::NumericPolicy = NumericPolicy()) - den = denominator(x) - den == 0 && throw(ZeroDenominator("rational value")) - q = BigInt(numerator(x)) // BigInt(den) - check_limit(q, policy; what = "rational value") - n, d = numerator(q), denominator(q) - return Dict{String,Any}("kind" => "exact", - "type" => "rational", - "numerator" => string(n), - "denominator" => string(d), - "value" => string(n) * "/" * string(d), - "display" => display_string(q; digits = policy.display_digits), - "display_rounded" => d != 1) -end - -""" - approx_json(x; policy) -> Dict{String,Any} - -An approximate value, tagged as one. - - {"kind": "approximation", "type": "mpfr", "value": "...", "precision_bits": 256, - "rounding": "round-to-nearest-ties-to-even", "display": "0.693147", - "display_rounded": true} - -`value` is the computed approximation rendered as a decimal string (strings -again, so no consumer's JSON number type can silently shorten it). Non-finite -values are refused rather than encoded as `NaN`/`Infinity`, which are not JSON. -""" -function approx_json(x::BigFloat; policy::NumericPolicy = NumericPolicy(), - what::AbstractString = "value") - isfinite(x) || throw(InvalidNumericInput(String(what), - "a non-finite result is not a number and cannot be stored")) - return Dict{String,Any}("kind" => "approximation", - "type" => "mpfr", - "value" => string(x), - "precision_bits" => precision(x), - "rounding" => "round-to-nearest-ties-to-even", - "display" => display_string(x; digits = policy.display_digits), - "display_rounded" => true) -end - -function approx_json(x::AbstractFloat; policy::NumericPolicy = NumericPolicy(), - what::AbstractString = "value") - isfinite(x) || throw(InvalidNumericInput(String(what), - "a non-finite result is not a number and cannot be stored")) - return Dict{String,Any}("kind" => "approximation", - "type" => x isa Float64 ? "binary64" : string(typeof(x)), - "value" => string(x), - "precision_bits" => x isa Float64 ? 53 : 8 * sizeof(x), - "rounding" => "round-to-nearest-ties-to-even", - "display" => display_string(x; digits = policy.display_digits), - "display_rounded" => true) -end - -""" - parse_exact_json(d) -> Union{BigInt,Rational{BigInt}} - -Read an exact value back from its stored document. This is the round-trip half -of the contract: whatever `exact_json` writes, this returns as the same value, -and anything else - a wrong tag, a missing field, a zero denominator, an -unparseable string - is an `ArgumentError`. -""" -function parse_exact_json(d::AbstractDict) - get(d, "kind", nothing) == "exact" || throw(ArgumentError( - "not an exact value document: missing or wrong 'kind'")) - t = get(d, "type", nothing) - if t == "integer" - v = get(d, "value", nothing) - v isa AbstractString || throw(ArgumentError( - "an exact integer is stored as a decimal string, not $(typeof(v))")) - return parse(BigInt, v) - elseif t == "rational" - n = get(d, "numerator", nothing) - m = get(d, "denominator", nothing) - (n isa AbstractString && m isa AbstractString) || throw(ArgumentError( - "an exact rational is stored as numerator and denominator strings")) - num = parse(BigInt, n) - den = parse(BigInt, m) - den == 0 && throw(ZeroDenominator("stored rational")) - return num // den - end - throw(ArgumentError("unknown exact representation $(repr(t))")) -end - -""" - numeric_contract() -> Dict{String,Any} - -The cross-language and storage contract, as data, so that documentation and -tests quote the same text rather than drifting apart. - -1. Exact stays exact: counts become arbitrary-precision integers, and every - exact field is emitted as decimal strings. -2. Exact rationals are normalised, denominator positive, zero denominator - refused. -3. Approximations are tagged with their engine, precision and rounding mode; - they are never promoted to "exact" by any later step. -4. Display values are rounded separately and marked `display_rounded`. -5. Unsupported representations are rejected explicitly - NaN, infinities, - fractional counts, float counts above 2^53-1, integers outside JSON's exact - range emitted as numbers. -""" -numeric_contract() = Dict{String,Any}( - "version" => POLICY_VERSION, - "integer" => Dict{String,Any}( - "representation" => "arbitrary-precision integer", - "json" => "decimal string in 'value' (never a JSON number)", - "range" => "unbounded, subject to max_digits", - "refused" => ["fractional counts", "negative counts", "NaN", "Infinity", - "float counts with |x| > 2^53-1", "Bool"], - ), - "rational" => Dict{String,Any}( - "representation" => "Rational{BigInt}, lowest terms, positive denominator", - "json" => "'numerator' and 'denominator' decimal strings, plus 'value' as n/d", - "refused" => ["zero denominator", "numerator or denominator above max_digits"], - ), - "approximation" => Dict{String,Any}( - "representation" => "BigFloat (MPFR) or binary64", - "json" => "'value' as a decimal string, with 'precision_bits' and 'rounding'", - "refused" => ["NaN", "Infinity", "requesting it in exact mode"], - ), - "display" => Dict{String,Any}( - "representation" => "fixed-point decimal string, half-to-even", - "json" => "'display', with 'display_rounded' => true unless the value is an exact integer", - ), - "json_number_boundary" => "values outside -(2^53-1) .. 2^53-1 are never written as JSON numbers", -) - -end # module ExactNumeric diff --git a/src/analysis/exact_statistics.jl b/src/analysis/exact_statistics.jl deleted file mode 100644 index 0498e3cc..00000000 --- a/src/analysis/exact_statistics.jl +++ /dev/null @@ -1,1179 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# The optional, validated statistics layer (issue #1). -# -# This module is descriptive and exact-arithmetic in scope. It deliberately does -# not contain a hypothesis test, a p-value, a confidence interval or a model fit -# yet: the issue that asks for those requires method-specific acceptance -# tolerances and independent scientific review first, and shipping an unvalidated -# test is worse than shipping none. What it does contain is a small, fixed -# catalogue of summaries (/`METHOD_CATALOGUE`/), each with its supported response -# type, numeric modes, uncertainty statement and limitations published alongside -# the code, so a caller can see what is and is not claimed. -# -# Three properties run through everything here: -# -# * Numbers are never guessed. A statistic that cannot be computed exactly on -# the requested terms fails with a code, and the failure is stored with the -# record: `zero_denominator`, `resource_limit`, `invalid_input`, -# `unsupported_numeric_mode`, `depth_exceeds_library`. -# * Results are immutable and linked. Every analysis is content-addressed and -# written once; re-running with different settings produces a *new* record -# that names the input fingerprint it came from, never an overwrite. -# * Provenance travels with the numbers: method version, numeric policy, -# parameters, software and MPFR versions, and the input fingerprint. -# -# The layer is optional. Nothing in `DiversityMetrics`, `Analysis` or the -# pipeline calls it, so existing behaviour is unchanged when it is not selected. - -module ExactStatistics - -using Dates, JSON3 -using ..ExactNumeric -using ..ExactNumeric: NUMERIC_MODES, NumericPolicy, NumericError, InvalidNumericInput, - ZeroDenominator, NumericLimit, ResourceLimit, UnsupportedNumericMode, - error_code, exact_count, exact_counts, exact_ratio, check_limit, - exact_json, approx_json, parse_exact_json, display_string, - arithmetic_bits, policy_dict, approximate_value -using ..Canonical: jcs_bytes, jcs_sha256 - -export METHOD_VERSION, RECORD_SCHEMA, METHOD_CATALOGUE, method_catalogue, - GLOBAL_LIMITATIONS, analyse, analysis_id, record_directory, write_record, - read_record, list_records, RecordConflict, input_fingerprint, - simpson_index, shannon_index, expected_richness, proportion_value - -# Version of the method catalogue and the shape of a result. A change to what a -# method computes bumps this, and stored records keep the version they were made -# under. -const METHOD_VERSION = "1.0.0" - -# Schema tag of a stored record, distinct from the method version so that the -# storage layout can evolve without pretending an analysis changed. -const RECORD_SCHEMA = "metamanifold.statistics/1" - -# Rarefaction is exact but not free: the expectation is a product of `depth` -# rational factors per feature. Both bounds are refusals, not truncations. -const MAX_RAREFACTION_DEPTH = 10_000 -const MAX_RAREFACTION_WORK = 5_000_000 - -## --------------------------------------------------------------------------- -## What this layer claims, method by method -## --------------------------------------------------------------------------- - -""" - METHOD_CATALOGUE - -The bounded catalogue approved for this layer. Read-only: it is published as -data so the server can list it, the documentation can quote it, and a test can -check that every method named here actually exists. - -Each entry records the response type it accepts, whether it needs study-design -information, which numeric modes it supports, which of its fields are exact and -which are approximations, its uncertainty and multiple-testing statement, and -its limitations. A method that cannot state those things does not belong here. -""" -const METHOD_CATALOGUE = Dict{String,Any}( - - "summarise.counts" => Dict{String,Any}( - "version" => METHOD_VERSION, - "title" => "Per-sample and per-feature read-count summary", - "description" => "Exact totals, observed richness, zero-count features and mean " * - "reads per feature, per sample and per feature, over the supplied " * - "count matrix.", - "response" => "non-negative integer counts; samples x features", - "design_required" => false, - "numeric_modes" => collect(NUMERIC_MODES), - "exact_fields" => ["counts", "totals", "richness", "prevalence"], - "approximate_fields" => ["mean reads per feature (exact in exact mode)"], - "options" => Dict{String,Any}(), - "uncertainty" => "None. These are census summaries of the counts supplied, not " * - "estimates of a population quantity, so there is no sampling " * - "model to attach an interval to.", - "effect_size" => "None: no comparison between groups is made.", - "multiple_testing" => "Not applicable: no test is performed.", - "limitations" => [ - "A mean over features weights every feature equally, which has no biological " * - "interpretation unless the features are a meaningful sampling unit.", - "Zeros are counted, not interpreted. A zero can mean absence, failure to " * - "amplify, or a read lost to depth; this method cannot tell them apart.", - ], - ), - - "describe.alpha" => Dict{String,Any}( - "version" => METHOD_VERSION, - "title" => "Alpha diversity summary (richness, Simpson, Shannon, evenness)", - "description" => "Observed richness, the Gini-Simpson index, the Shannon index and " * - "Pielou evenness, per sample and for the pooled corpus.", - "response" => "non-negative integer counts; samples x features", - "design_required" => false, - "numeric_modes" => collect(NUMERIC_MODES), - "exact_fields" => ["richness"], - "approximate_fields" => ["Simpson index in float64/bigfloat modes (exact rational in " * - "exact mode)", "Shannon index (never exact)", "Pielou evenness (never exact)"], - "options" => Dict{String,Any}(), - "uncertainty" => "None. Indices are computed from the observed counts; no interval " * - "is estimated, because no sampling model is assumed.", - "effect_size" => "None: no comparison between groups is made.", - "multiple_testing" => "Not applicable: no test is performed.", - "limitations" => [ - "Indices are depth-sensitive unless the counts are rarefied first; this single " * - "sample summary does not normalise for library size, and comparing raw-index " * - "values across samples of different depth is not valid.", - "The Shannon index and Pielou evenness are transcendental and have no exact " * - "value: they are tagged `approximation` and refused outright in exact mode.", - "Pielou evenness is undefined for a sample with fewer than two observed " * - "features, and is reported as unsuccessful there rather than as zero or one.", - "Simpson here is the Gini-Simpson complement 1 - sum(p_i^2) on observed " * - "relative abundances. It is not an unbiased estimator of the population " * - "index, and no correction is applied.", - ], - ), - - "describe.proportions" => Dict{String,Any}( - "version" => METHOD_VERSION, - "title" => "Per-sample and corpus read proportions", - "description" => "Each feature's share of its sample and of the whole corpus.", - "response" => "non-negative integer counts; samples x features", - "design_required" => false, - "numeric_modes" => collect(NUMERIC_MODES), - "exact_fields" => ["counts", "sample totals", "corpus totals"], - "approximate_fields" => ["proportions in float64/bigfloat modes (exact rational in " * - "exact mode)"], - "options" => Dict{String,Any}(), - "uncertainty" => "None: a proportion of the observed reads is a census quantity.", - "effect_size" => "None: no comparison between groups is made.", - "multiple_testing" => "Not applicable: no test is performed.", - "limitations" => [ - "A proportion of reads is not an estimate of an organism's relative abundance; " * - "it is confounded by copy number, extraction and amplification efficiency.", - "A sample with zero reads has no proportions. It is reported as unsuccessful " * - "for that sample, never as all-zero rows.", - "Proportions are compositional: they sum to one, so they are not independent " * - "of each other and must not be used as independent response variables.", - ], - ), - - "rarefaction.expected_richness" => Dict{String,Any}( - "version" => METHOD_VERSION, - "title" => "Exact expected richness at a given rarefied depth", - "description" => "Hurlbert's (1971) expected number of observed features in a " * - "subsample of `depth` reads drawn without replacement from each " * - "sample. Exact rational in exact mode; no resampling is performed.", - "response" => "non-negative integer counts; samples x features", - "design_required" => false, - "numeric_modes" => collect(NUMERIC_MODES), - "exact_fields" => ["expected richness in exact mode"], - "approximate_fields" => ["expected richness in float64/bigfloat modes"], - "options" => Dict{String,Any}( - "depth" => Dict{String,Any}( - "required" => true, - "type" => "integer", - "range" => "1 .. $(MAX_RAREFACTION_DEPTH), and no more than the sample's library size", - "description" => "reads per subsample", - ), - ), - "uncertainty" => "None beyond the subsampling itself: this is the exact expectation " * - "over subsamples, not an interval and not a resampled estimate. It " * - "carries no Monte Carlo error, which is the point of computing it " * - "in closed form.", - "effect_size" => "None: no comparison between groups is made.", - "multiple_testing" => "Not applicable: no test is performed.", - "limitations" => [ - "The expectation assumes reads are sampled without replacement from the " * - "observed library and that the observed library is the whole community. It " * - "does not correct for unobserved species, PCR bias or sequencing error.", - "Rarefaction discards reads. Standardising depth does not make indices " * - "comparable across samples that differ in richness, and an expected value is " * - "not an observed value.", - "A depth above a sample's library size is refused rather than clamped.", - "Exact mode can exceed `max_digits` at large depth; the failure is explicit " * - "and says so, and no approximation is substituted.", - ], - ), - - "compare.taxon_ratio" => Dict{String,Any}( - "version" => METHOD_VERSION, - "title" => "Exact read-count ratio between two features", - "description" => "The ratio of two named features' total counts, corpus-wide and " * - "per sample.", - "response" => "non-negative integer counts; samples x features", - "design_required" => false, - "numeric_modes" => collect(NUMERIC_MODES), - "exact_fields" => ["counts", "ratio in exact mode"], - "approximate_fields" => ["ratio in float64/bigfloat modes"], - "options" => Dict{String,Any}( - "numerator" => Dict{String,Any}("required" => true, "type" => "feature name"), - "denominator" => Dict{String,Any}("required" => true, "type" => "feature name"), - ), - "uncertainty" => "None: a ratio of observed counts, with no interval estimated.", - "effect_size" => "The ratio is a descriptive fold-ratio, not a tested effect. " * - "No null value is assumed and no significance is implied.", - "multiple_testing" => "Not applicable: no test is performed.", - "limitations" => [ - "A ratio of read counts is not a ratio of abundances; it inherits every bias in " * - "the counts it is built from.", - "A zero denominator is refused for the scope in which it occurs (corpus or " * - "sample); it is never replaced by a pseudocount here.", - "Two features chosen because their ratio looks interesting produce a biased " * - "summary; this method does not know how the pair was selected.", - ], - ), -) - -""" - method_catalogue() -> Dict{String,Any} - -The catalogue itself. Callers must treat it as read-only. -""" -method_catalogue() = METHOD_CATALOGUE - -""" -Global statements attached to every record, so no result can be read as claiming -more than the layer supports. -""" -const GLOBAL_LIMITATIONS = [ - "This layer produces descriptive summaries only. It performs no hypothesis test, " * - "estimates no p-value, confidence interval or effect size, and makes no statement " * - "about significance.", - "Exact arithmetic removes representation error and nothing else. Measurement error, " * - "sampling uncertainty, model misspecification and biological bias are all unaffected.", - "Higher precision is not exactness. Values tagged `approximation` are approximations " * - "at the stated precision and rounding mode, and no later step promotes them.", - "Counts are read from the results database as binary64; values above 2^53-1 cannot be " * - "trusted to be the integers they came from and are refused.", -] - -## --------------------------------------------------------------------------- -## Failures, warnings and the per-entry state machine -## --------------------------------------------------------------------------- - -mutable struct _Context - policy :: NumericPolicy - warnings :: Vector{Dict{String,Any}} - failures :: Vector{Dict{String,Any}} -end - -_warn!(ctx::_Context, code::String, message::String) = - push!(ctx.warnings, Dict{String,Any}("code" => code, "message" => message)) - -_fail!(ctx::_Context, code::String, scope::String, message::String) = - push!(ctx.failures, Dict{String,Any}("code" => code, "scope" => scope, "message" => message)) - -""" - _entry(ctx, scope, f) -> Dict{String,Any} - -Build one named entry of a result, converting a numeric refusal into an explicit -unsuccessful entry that names its scope and carries the refusal's stable code. -A per-entry failure does not stop the rest of the table: a survey with one -zero-read sample still reports the others, and the failed sample is visible. -""" -function _entry(ctx::_Context, scope::String, f::Function) - try - d = f() - d = d isa Dict{String,Any} ? d : Dict{String,Any}(String(k) => v for (k, v) in d) - haskey(d, "status") || (d["status"] = "complete") - return d - catch e - e isa NumericError || rethrow() - message = sprint(showerror, e) - _fail!(ctx, error_code(e), scope, message) - return Dict{String,Any}("status" => "unsuccessful", - "code" => error_code(e), - "message" => message) - end -end - -""" - _field(ctx, scope, f) - -One field of an entry, allowed to fail on its own. A sample whose Shannon index -is unsupported in exact mode still reports its exact richness and Simpson index; -the unsupported field carries its code and message, and the entry's own status -becomes `"partial"`. -""" -function _field(ctx::_Context, scope::String, f::Function) - try - return f() - catch e - e isa NumericError || rethrow() - message = sprint(showerror, e) - _fail!(ctx, error_code(e), scope, message) - return Dict{String,Any}("status" => "unsuccessful", - "code" => error_code(e), - "message" => message) - end -end - -_is_unsuccessful(x) = x isa AbstractDict && get(x, "status", nothing) == "unsuccessful" - -## --------------------------------------------------------------------------- -## Plain-data conversion -## --------------------------------------------------------------------------- - -""" -Options travel into a stored record and therefore into a content hash. They are -accepted only in the JSON shapes the API can send back, and anything else is -refused rather than stringified: an option that cannot be represented is a -configuration mistake, not something to paper over. -""" -_plain(::Nothing) = nothing -_plain(x::Bool) = x -_plain(x::Integer) = begin - x isa Bool && throw(ArgumentError("a Bool is not an integer option")) - (typemin(Int) <= x <= typemax(Int)) || throw(ArgumentError( - "the integer option $x is outside the range a stored record can carry")) - Int(x) -end -_plain(x::AbstractFloat) = begin - isfinite(x) || throw(ArgumentError("the option $x is not finite")) - y = Float64(x) - # A BigFloat beyond binary64's range would become Inf here, so it is refused - # rather than stored as an infinity a consumer cannot read back. - isfinite(y) || throw(ArgumentError( - "the option $x is outside the range a stored binary64 can carry")) - y -end -_plain(x::AbstractString) = String(x) -_plain(x::AbstractVector) = Any[_plain(v) for v in x] -_plain(x::AbstractDict) = Dict{String,Any}(String(k) => _plain(v) for (k, v) in x) -_plain(x) = throw(ArgumentError("unsupported option type $(typeof(x))")) - -# JSON reads objects as JSON3.Object, which is not an AbstractDict; convert the -# stored document back to plain data so the rest of the module has one shape. -_plain_json(::Nothing) = nothing -_plain_json(x::Bool) = x -_plain_json(x::Real) = x -_plain_json(x::AbstractString) = String(x) -_plain_json(x::AbstractVector) = Any[_plain_json(v) for v in x] -_plain_json(x::AbstractDict) = Dict{String,Any}(String(k) => _plain_json(v) for (k, v) in x) -_plain_json(x::JSON3.Object) = Dict{String,Any}(String(k) => _plain_json(v) for (k, v) in x) -_plain_json(x) = string(x) - -_sum_big(v) = foldl(+, v; init = BigInt(0)) - -## --------------------------------------------------------------------------- -## The statistics themselves -## --------------------------------------------------------------------------- - -""" - simpson_index(counts) -> Rational{BigInt} - -The Gini-Simpson complement `1 - sum(p_i^2)` on observed relative abundances, -as an exact rational. Throws `ZeroDenominator` when the counts are all zero. -""" -function simpson_index(counts::AbstractVector{<:Integer}) - total = _sum_big(counts) - total == 0 && throw(ZeroDenominator("Simpson index of a sample with zero reads")) - squares = foldl(+, (BigInt(c)^2 for c in counts); init = BigInt(0)) - return 1 - squares // (total^2) -end - -""" - proportion_value(count, total) -> Rational{BigInt} - -`count/total` exactly. A zero total throws `ZeroDenominator`. -""" -function proportion_value(count::Integer, total::Integer) - return exact_ratio(count, total; what = "proportion of a zero-read total") -end - -# The approximate helpers below are shared by the public functions and the -# per-entry closures. They all refuse a zero total explicitly: in floating point -# 0/0 is NaN, and a NaN arriving at the serialiser is a much worse failure mode -# than a named refusal, because it survives arithmetic and only fails at the -# boundary. Every one of them is called inside an `approximate_value` block, so -# its BigFloats are at the requested precision. - -_require_positive_total(total, what::String) = - total == 0 ? throw(ZeroDenominator(what)) : total - -function _simpson_approx(counts, total) - _require_positive_total(total, "Simpson index of a sample with zero reads") - n = BigFloat(total) - s = BigFloat(0) - for c in counts - c == 0 && continue - p = BigFloat(BigInt(c)) / n - s += p * p - end - return 1 - s -end - -function _shannon_approx(counts, total) - _require_positive_total(total, "Shannon index of a sample with zero reads") - n = BigFloat(total) - h = BigFloat(0) - for c in counts - c == 0 && continue - p = BigFloat(BigInt(c)) / n - h -= p * log(p) - end - return h -end - -function _evenness_approx(counts, total, richness::Integer) - richness >= 2 || throw(InvalidNumericInput("Pielou evenness", - "evenness needs at least two observed features, got $richness")) - _require_positive_total(total, "Pielou evenness of a sample with zero reads") - return _shannon_approx(counts, total) / log(BigFloat(richness)) -end - -function _proportion_approx(count::Integer, total; what::String = "proportion of a zero-read total") - _require_positive_total(total, what) - return BigFloat(BigInt(count)) / BigFloat(total) -end - -function _mean_approx(total, n::Integer) - n == 0 && throw(ZeroDenominator("mean over zero items")) - return BigFloat(total) / BigFloat(n) -end - -""" - shannon_index(counts, policy) -> BigFloat - -`-sum(p_i log p_i)` at the policy's working precision. Transcendental, so never -exact; in exact mode this throws `UnsupportedNumericMode` rather than returning a -number the caller did not ask for. -""" -function shannon_index(counts::AbstractVector{<:Integer}, policy::NumericPolicy) - total = _sum_big(counts) - return approximate_value(policy; what = "Shannon index") do - _shannon_approx(counts, total) - end -end - -""" - expected_richness(counts, depth; policy) -> Union{Rational{BigInt},BigFloat} - -Hurlbert's (1971) expected number of features in a subsample of `depth` reads -drawn without replacement: - - E[S_m] = sum_i (1 - C(N - n_i, m) / C(N, m)) - -computed as a product of `m` exact rational factors per feature, so no binomial -coefficient is ever materialised. `depth` must be at least 1 and at most the -library size; a larger depth throws `ArgumentError` (the caller should refuse the -sample explicitly rather than let it be clamped). In exact mode the result is an -exact rational, and exceeding `max_digits` throws `NumericLimit`. -""" -function expected_richness(counts::AbstractVector{<:Integer}, depth::Integer; - policy::NumericPolicy = NumericPolicy()) - (depth isa Bool || !(depth isa Integer) || depth < 1) && throw(ArgumentError( - "rarefaction depth must be an integer of at least 1, not $depth")) - depth <= MAX_RAREFACTION_DEPTH || throw(ResourceLimit( - "a rarefaction depth of $depth", MAX_RAREFACTION_DEPTH)) - total = _sum_big(counts) - depth <= total || throw(ArgumentError( - "rarefaction depth $depth exceeds the library size $total")) - m = Int(depth) - - if policy.mode === :exact - s = zero(Rational{BigInt}) - for c in counts - c == 0 && continue - p = one(Rational{BigInt}) - for k in 0:(m - 1) - p *= (total - BigInt(c) - k) // (total - k) - # A zero factor (m > N - n_i) makes the probability zero for this - # feature and stays zero, which is the correct value; the product - # is checked against the digit bound as it grows. - k % 128 == 0 && check_limit(p, policy; what = "expected richness") - end - s += 1 - p - check_limit(s, policy; what = "expected richness") - end - return s - end - - return approximate_value(policy; what = "expected richness") do - n = BigFloat(total) - s = BigFloat(0) - for c in counts - c == 0 && continue - p = BigFloat(1) - for k in 0:(m - 1) - available = n - BigFloat(BigInt(c)) - BigFloat(k) - if available <= 0 - p = BigFloat(0) - break - end - p *= available / (n - BigFloat(k)) - end - s += 1 - p - end - s - end -end - -## --------------------------------------------------------------------------- -## Value rendering: exact in exact mode, approximation otherwise -## --------------------------------------------------------------------------- - -# In float64 mode an approximation is reported as binary64 (the type the rest of -# the application uses) but computed at 53 bits first, so the value is the -# correctly-rounded result at that width rather than a differently-rounded one. -_render_approx(x::BigFloat, policy::NumericPolicy) = - policy.mode === :float64 ? approx_json(Float64(x); policy = policy) : approx_json(x; policy = policy) - -""" - _statistic(ctx, exact_f, approx_f, what) - -One derived value, rendered according to the policy. In exact mode the exact -closure runs and its `Rational`/`Integer` is tagged exact. In the approximate -modes the approximate closure runs at the policy's precision, tagged as an -approximation. Neither path is used to rescue the other: an exact-mode refusal -is a refusal, and an approximation is never relabelled exact. -""" -function _statistic(ctx::_Context, exact_f::Function, approx_f::Function, what::String) - if ctx.policy.mode === :exact - return exact_json(exact_f(); policy = ctx.policy) - end - v = approximate_value(ctx.policy; what = what) do - approx_f() - end - return _render_approx(v, ctx.policy) -end - -_exact_count_json(ctx::_Context, x::Integer) = exact_json(BigInt(x); policy = ctx.policy) - -## --------------------------------------------------------------------------- -## The methods -## --------------------------------------------------------------------------- - -function _sample_entry(ctx::_Context, sample::String, row::AbstractVector{BigInt}, nfeat::Int) - return _entry(ctx, "sample:$sample", () -> begin - total = _sum_big(row) - d = Dict{String,Any}() - d["total_reads"] = _exact_count_json(ctx, total) - d["richness"] = _exact_count_json(ctx, count(!iszero, row)) - d["zero_count_features"] = _exact_count_json(ctx, count(iszero, row)) - d["mean_per_feature"] = _statistic(ctx, - () -> exact_ratio(total, BigInt(nfeat); policy = ctx.policy, - what = "mean reads per feature"), - () -> _mean_approx(total, nfeat), - "mean reads per feature") - d - end) -end - -function _feature_entry(ctx::_Context, feature::String, column::AbstractVector{BigInt}) - return _entry(ctx, "feature:$feature", () -> begin - total = _sum_big(column) - ns = length(column) - d = Dict{String,Any}() - d["total_reads"] = _exact_count_json(ctx, total) - d["prevalence"] = _exact_count_json(ctx, count(!iszero, column)) - d["mean_per_sample"] = _statistic(ctx, - () -> exact_ratio(total, BigInt(ns); policy = ctx.policy, - what = "mean reads per sample"), - () -> _mean_approx(total, ns), - "mean reads per sample") - d - end) -end - -function _describe_counts(ctx::_Context, exact::Matrix{BigInt}, - samples::Vector{String}, features::Vector{String}, opts) - ns, nf = size(exact) - result = Dict{String,Any}() - result["samples"] = Any[_sample_entry(ctx, samples[i], view(exact, i, :), nf) for i in 1:ns] - result["features"] = Any[_feature_entry(ctx, features[j], view(exact, :, j)) for j in 1:nf] - result["corpus"] = _entry(ctx, "corpus", () -> begin - total = sum(exact) - d = Dict{String,Any}() - d["total_reads"] = _exact_count_json(ctx, total) - d["observed_features"] = _exact_count_json(ctx, count(!iszero, vec(sum(exact; dims = 1)))) - d["samples"] = _exact_count_json(ctx, ns) - d["features"] = _exact_count_json(ctx, nf) - d - end) - return result -end - -function _alpha_entry(ctx::_Context, label::String, counts::AbstractVector{BigInt}) - return _entry(ctx, "sample:$label", () -> begin - total = _sum_big(counts) - rich = count(!iszero, counts) - d = Dict{String,Any}() - d["total_reads"] = _exact_count_json(ctx, total) - d["richness"] = _exact_count_json(ctx, rich) - # A zero-read sample has no index value at all: the fields below throw - # ZeroDenominator and are stored as unsuccessful, while the exact counts - # above remain available. - d["simpson"] = _field(ctx, "sample:$label simpson", () -> _statistic(ctx, - () -> simpson_index(counts), - () -> _simpson_approx(counts, total), - "Simpson index")) - d["shannon"] = _field(ctx, "sample:$label shannon", () -> _statistic(ctx, - () -> throw(UnsupportedNumericMode("Shannon index", :exact, - "the Shannon index involves logarithms and has no exact rational value")), - () -> _shannon_approx(counts, total), - "Shannon index")) - d["evenness"] = _field(ctx, "sample:$label evenness", () -> _statistic(ctx, - () -> throw(UnsupportedNumericMode("Pielou evenness", :exact, - "Pielou evenness is a ratio of logarithms and has no exact rational value")), - () -> _evenness_approx(counts, total, rich), - "Pielou evenness")) - d["status"] = any(_is_unsuccessful, (d["simpson"], d["shannon"], d["evenness"])) ? - "partial" : "complete" - d - end) -end - -function _describe_alpha(ctx::_Context, exact::Matrix{BigInt}, - samples::Vector{String}, features::Vector{String}, opts) - ns, nf = size(exact) - result = Dict{String,Any}() - result["samples"] = Any[_alpha_entry(ctx, samples[i], view(exact, i, :)) for i in 1:ns] - result["corpus"] = _alpha_entry(ctx, "corpus (all samples pooled)", vec(sum(exact; dims = 1))) - result["index_definitions"] = Dict{String,Any}( - "richness" => "number of features with a non-zero count", - "simpson" => "1 - sum(p_i^2) on observed relative abundances (Gini-Simpson complement)", - "shannon" => "-sum(p_i log p_i)", - "evenness" => "Shannon / log(richness), defined only for richness >= 2", - ) - return result -end - -function _describe_proportions(ctx::_Context, exact::Matrix{BigInt}, - samples::Vector{String}, features::Vector{String}, opts) - ns, nf = size(exact) - result = Dict{String,Any}() - samples_out = Vector{Any}(undef, ns) - for i in 1:ns - row = view(exact, i, :) - samples_out[i] = _entry(ctx, "sample:$(samples[i])", () -> begin - total = _sum_big(row) - d = Dict{String,Any}() - d["total_reads"] = _exact_count_json(ctx, total) - d["proportions"] = Any[ - _field(ctx, "sample:$(samples[i]) feature:$(features[j])", () -> begin - c = BigInt(row[j]) - _statistic(ctx, - () -> exact_ratio(c, total; policy = ctx.policy, - what = "proportion over the zero-read sample " * - "'$(samples[i])'"), - () -> _proportion_approx(c, total), - "proportion") - end) for j in 1:nf - ] - d["status"] = any(_is_unsuccessful, d["proportions"]) ? "partial" : "complete" - d - end) - end - result["samples"] = samples_out - result["features"] = Any[features[j] for j in 1:nf] - feature_totals = [sum(view(exact, :, j)) for j in 1:nf] - corpus_total = sum(exact) - result["corpus"] = _entry(ctx, "corpus", () -> begin - d = Dict{String,Any}() - d["total_reads"] = _exact_count_json(ctx, corpus_total) - d["proportions"] = Any[ - _field(ctx, "corpus feature:$(features[j])", () -> _statistic(ctx, - () -> exact_ratio(feature_totals[j], corpus_total; policy = ctx.policy, - what = "corpus proportion of a zero-read corpus"), - () -> _proportion_approx(feature_totals[j], corpus_total), - "proportion")) for j in 1:nf - ] - d["status"] = any(_is_unsuccessful, d["proportions"]) ? "partial" : "complete" - d - end) - return result -end - -function _rarefaction_entry(ctx::_Context, label::String, row::AbstractVector{BigInt}, depth::Int) - return _entry(ctx, "sample:$label", () -> begin - total = _sum_big(row) - if depth > total - message = "rarefaction depth $depth exceeds the library of $total reads in " * - "sample '$label'" - _fail!(ctx, "depth_exceeds_library", "sample:$label", message) - return Dict{String,Any}("status" => "unsuccessful", - "code" => "depth_exceeds_library", - "message" => message, - "library_reads" => _exact_count_json(ctx, total), - "depth" => depth) - end - d = Dict{String,Any}() - d["library_reads"] = _exact_count_json(ctx, total) - d["depth"] = depth - d["expected_richness"] = _statistic(ctx, - () -> expected_richness(row, depth; policy = ctx.policy), - () -> _expected_richness_approx(row, total, depth), - "expected richness at depth $depth") - d - end) -end - -function _expected_richness_approx(counts, total::BigInt, m::Int) - # Evaluated inside the policy's precision block by `_statistic`; every value - # here is therefore a BigFloat at the requested precision. - n = BigFloat(total) - s = BigFloat(0) - for c in counts - c == 0 && continue - p = BigFloat(1) - for k in 0:(m - 1) - available = n - BigFloat(BigInt(c)) - BigFloat(k) - if available <= 0 - p = BigFloat(0) - break - end - p *= available / (n - BigFloat(k)) - end - s += 1 - p - end - s -end - -function _expected_richness_method(ctx::_Context, exact::Matrix{BigInt}, - samples::Vector{String}, features::Vector{String}, opts) - depth = get(opts, "depth", nothing) - (depth isa Integer && !(depth isa Bool)) || throw(ArgumentError( - "rarefaction.expected_richness requires an integer option 'depth'")) - depth >= 1 || throw(ArgumentError("option 'depth' must be at least 1, not $depth")) - depth <= MAX_RAREFACTION_DEPTH || throw(ResourceLimit( - "a rarefaction depth of $depth", MAX_RAREFACTION_DEPTH)) - work = BigInt(size(exact, 1)) * BigInt(size(exact, 2)) * BigInt(depth) - work <= MAX_RAREFACTION_WORK || throw(ResourceLimit( - "a rarefaction depth of $depth over $(size(exact, 1)) samples and " * - "$(size(exact, 2)) features ($(work) factor-steps)", MAX_RAREFACTION_WORK)) - ns = size(exact, 1) - return Dict{String,Any}( - "depth" => Int(depth), - "samples" => Any[_rarefaction_entry(ctx, samples[i], view(exact, i, :), Int(depth)) for i in 1:ns], - "note" => "Exact expectation over subsamples drawn without replacement; no resampling " * - "and no seed is involved, so there is no Monte Carlo error to report.", - ) -end - -function _taxon_ratio_method(ctx::_Context, exact::Matrix{BigInt}, - samples::Vector{String}, features::Vector{String}, opts) - num_name = get(opts, "numerator", nothing) - den_name = get(opts, "denominator", nothing) - (num_name isa AbstractString && den_name isa AbstractString) || throw(ArgumentError( - "compare.taxon_ratio requires string options 'numerator' and 'denominator'")) - num_name = String(num_name); den_name = String(den_name) - num_name == den_name && throw(ArgumentError( - "compare.taxon_ratio needs two different features, both are '$num_name'")) - ni = findfirst(==(num_name), features) - di = findfirst(==(den_name), features) - isnothing(ni) && throw(ArgumentError("feature '$num_name' is not in this table")) - isnothing(di) && throw(ArgumentError("feature '$den_name' is not in this table")) - - ns = size(exact, 1) - num_totals = vec(sum(exact; dims = 1)) - ratio_for = (scope, n, d) -> _entry(ctx, scope, () -> Dict{String,Any}( - "numerator_total" => _exact_count_json(ctx, n), - "denominator_total" => _exact_count_json(ctx, d), - "ratio" => _statistic(ctx, - () -> exact_ratio(n, d; policy = ctx.policy, what = "ratio in $scope"), - () -> _proportion_approx(n, d; what = "ratio with a zero denominator"), - "ratio"), - )) - - result = Dict{String,Any}() - result["numerator"] = num_name - result["denominator"] = den_name - result["corpus"] = ratio_for("corpus", num_totals[ni], num_totals[di]) - result["samples"] = Any[ - ratio_for("sample:$(samples[i])", exact[i, ni], exact[i, di]) for i in 1:ns - ] - return result -end - -const _METHOD_RUNNERS = Dict{String,Function}( - "summarise.counts" => _describe_counts, - "describe.alpha" => _describe_alpha, - "describe.proportions" => _describe_proportions, - "rarefaction.expected_richness" => _expected_richness_method, - "compare.taxon_ratio" => _taxon_ratio_method, -) - -## --------------------------------------------------------------------------- -## Diagnostics and provenance -## --------------------------------------------------------------------------- - -function _diagnostics(exact, samples::Vector{String}, features::Vector{String}, ctx::_Context) - ns, nf = size(exact) - sample_totals = [_sum_big(view(exact, i, :)) for i in 1:ns] - feature_totals = [_sum_big(view(exact, :, j)) for j in 1:nf] - largest = isempty(exact) ? BigInt(0) : maximum(exact) - return Dict{String,Any}( - "numeric_mode" => String(ctx.policy.mode), - "arithmetic_precision_bits" => ctx.policy.mode === :exact ? nothing : - arithmetic_bits(ctx.policy), - "max_digits" => ctx.policy.max_digits, - "counts" => Dict{String,Any}( - "samples" => ns, - "features" => nf, - "total_reads" => string(_sum_big(exact)), - "largest_count" => string(largest), - "zero_total_samples" => count(iszero, sample_totals), - "zero_count_features" => count(iszero, feature_totals), - "singleton_features" => count(==(BigInt(1)), feature_totals), - ), - "duplicate_sample_names" => length(unique(samples)) != ns, - "duplicate_feature_names" => length(unique(features)) != nf, - "unsuccessful_entries" => length(ctx.failures), - ) -end - -const PACKAGE_VERSION = let v = try - pkgversion(parentmodule(@__MODULE__)) - catch - nothing - end - isnothing(v) ? "0.0.0+unknown" : string(v) -end - -# Recorded defensively: the MPFR version is a Base internal, and a result must -# not fail to be recorded because a Julia release moved or renamed it. -function _mpfr_version_string() - if isdefined(Base.MPFR, :MPFR_VERSION_STRING) - return string(Base.MPFR.MPFR_VERSION_STRING) - elseif isdefined(Base.MPFR, :version) - return string(Base.MPFR.version()) - end - return "unknown" -end - -const MPFR_VERSION = try - _mpfr_version_string() -catch - "unknown" -end - -function _provenance(method::String, policy::NumericPolicy, opts::Dict{String,Any}, - seed::Integer, source::Dict{String,Any}, fingerprint::String, - counts::Tuple{Int,Int}) - return Dict{String,Any}( - "software" => Dict{String,Any}( - "name" => "MetaManifold", - "version" => PACKAGE_VERSION, - "julia" => string(VERSION), - "mpfr" => MPFR_VERSION, - "numeric_policy_version" => policy.version, - ), - "method" => Dict{String,Any}("name" => method, - "version" => string(METHOD_CATALOGUE[method]["version"])), - "numeric_policy" => policy_dict(policy), - "parameters" => opts, - "seed" => Int(seed), - "randomness" => Dict{String,Any}( - "used" => false, - "note" => "no method in this catalogue draws a random number; the seed is " * - "recorded so a future resampling method is comparable, not because " * - "it affects this result", - ), - "input" => Dict{String,Any}( - "fingerprint" => fingerprint, - "fingerprint_algorithm" => "JCS (RFC 8785) canonical JSON, SHA-256", - "samples" => counts[1], - "features" => counts[2], - "source" => source, - ), - ) -end - -""" - input_fingerprint(counts, samples, features) -> String - -Content address of exactly what was analysed: the ordered sample names, the -ordered feature names, and every count as a decimal string, serialized as -canonical JSON (RFC 8785) and hashed with SHA-256. - -Counts are strings, so the fingerprint of a table with a count above 2^53-1 is -still well defined and is not silently shortened by a JSON number. -""" -function input_fingerprint(counts::AbstractMatrix{<:Integer}, - samples::AbstractVector{<:AbstractString}, - features::AbstractVector{<:AbstractString}) - size(counts) == (length(samples), length(features)) || throw(ArgumentError( - "count matrix is $(size(counts, 1)) x $(size(counts, 2)), but " * - "$(length(samples)) sample and $(length(features)) feature names were given")) - payload = Dict{String,Any}( - "schema" => "metamanifold.statistics.input/1", - "samples" => collect(String.(samples)), - "features" => collect(String.(features)), - # Column-major flattening of a Julia matrix; the names above pin the axes, - # so the order is unambiguous. - "counts" => [string(BigInt(counts[i])) for i in eachindex(counts)], - ) - return jcs_sha256(payload) -end - -## --------------------------------------------------------------------------- -## The entry point -## --------------------------------------------------------------------------- - -""" - analyse(counts, samples, features; method, policy, options, seed, source) - -Run one catalogue method over a samples x features count matrix and return a -record: `status`, `failures`, `warnings`, `results`, `diagnostics`, -`limitations`, `provenance`, plus the method's own output. - -`status` is one of: - - * `"complete"` - every entry was computed; - * `"partial"` - some entries are explicitly unsuccessful (each carries its own - code and message) while others are usable; - * `"unsuccessful"` - the input could not be converted exactly, or the method - refused the request outright. `results` is then empty. - -An unknown method, a size mismatch between the matrix and its names, or an -option of the wrong type is an `ArgumentError`: those are request errors, and -returning a record for them would suggest an analysis happened. Data-dependent -refusals are never thrown - they are recorded, because they belong to the result. -""" -function analyse(counts::AbstractMatrix{<:Real}, - samples::AbstractVector{<:AbstractString}, - features::AbstractVector{<:AbstractString}; - method::AbstractString, - policy::NumericPolicy = NumericPolicy(), - options::AbstractDict = Dict{String,Any}(), - seed::Integer = 0, - source::AbstractDict = Dict{String,Any}()) - name = String(method) - entry = get(METHOD_CATALOGUE, name, nothing) - isnothing(entry) && throw(ArgumentError( - "unknown statistics method '$name' (supported: " * - join(sort!(collect(keys(METHOD_CATALOGUE))), ", ") * ")")) - size(counts) == (length(samples), length(features)) || throw(ArgumentError( - "count matrix is $(size(counts, 1)) x $(size(counts, 2)), but " * - "$(length(samples)) sample and $(length(features)) feature names were given")) - seed isa Bool && throw(ArgumentError("seed must be an integer, not a Bool")) - abs(seed) <= 2^53 - 1 || throw(ArgumentError( - "seed $seed is outside the exact-integer range a stored record can carry")) - - opts = _plain(options) - allowed = entry["options"] - for k in keys(opts) - haskey(allowed, k) || throw(ArgumentError( - "method '$name' has no option '$k' (it accepts: " * - (isempty(allowed) ? "none" : join(sort!(collect(keys(allowed))), ", ")) * ")")) - end - for (k, spec) in allowed - spec["required"] === true && !haskey(opts, k) && throw(ArgumentError( - "method '$name' requires option '$k'")) - end - - sample_names = String.(samples) - feature_names = String.(features) - ctx = _Context(policy, Dict{String,Any}[], Dict{String,Any}[]) - limitations = String[GLOBAL_LIMITATIONS...] - for l in entry["limitations"] - push!(limitations, String(l)) - end - - exact = nothing - try - exact = exact_counts(counts) - catch e - e isa NumericError || rethrow() - # The input itself could not be represented exactly. Every field of the - # analysis is undefined, so the record is unsuccessful as a whole. - _fail!(ctx, error_code(e), "input", sprint(showerror, e)) - end - - results = Dict{String,Any}() - if !isnothing(exact) - if length(unique(sample_names)) != length(sample_names) - _warn!(ctx, "duplicate_sample_names", - "two or more samples share a name; entries are still keyed by position") - end - if length(unique(feature_names)) != length(feature_names) - _warn!(ctx, "duplicate_feature_names", - "two or more features share a name; entries are still keyed by position") - end - total = sum(exact) - total == 0 && _warn!(ctx, "zero_reads", - "every count is zero; index values are undefined and are reported " * - "as unsuccessful rather than as zero") - runner = _METHOD_RUNNERS[name] - try - results = runner(ctx, exact, sample_names, feature_names, opts) - catch e - e isa NumericError || rethrow() - # A method-level refusal (a work bound, for instance): nothing partial - # is returned as if it were the answer. - empty!(results) - _fail!(ctx, error_code(e), "method", sprint(showerror, e)) - end - end - - status = if isnothing(exact) || isempty(results) - "unsuccessful" - elseif isempty(ctx.failures) - "complete" - else - "partial" - end - - fingerprint = isnothing(exact) ? nothing : - input_fingerprint(exact, sample_names, feature_names) - - return Dict{String,Any}( - "record_schema" => RECORD_SCHEMA, - "method" => Dict{String,Any}("name" => name, "version" => string(entry["version"])), - "status" => status, - "failures" => ctx.failures, - "warnings" => ctx.warnings, - "results" => results, - "diagnostics" => isnothing(exact) ? - Dict{String,Any}("numeric_mode" => String(policy.mode), "conversion_failed" => true) : - _diagnostics(exact, sample_names, feature_names, ctx), - "limitations" => limitations, - "provenance" => isnothing(exact) ? - Dict{String,Any}("numeric_policy" => policy_dict(policy), - "method" => Dict{String,Any}("name" => name, - "version" => string(entry["version"]))) : - _provenance(name, policy, opts, seed, - _plain(source), fingerprint, size(exact)), - ) -end - -## --------------------------------------------------------------------------- -## Immutable, content-addressed records -## --------------------------------------------------------------------------- - -const _ID_RE = Regex("\\A[0-9a-f]{64}\\z") - -""" - record_directory(project_dir) -> String - -Where a run's statistics records live: `{projects/.../run}/analysis/statistics`. -""" -record_directory(project_dir::AbstractString) = - joinpath(project_dir, "analysis", "statistics") - -struct RecordConflict <: Exception - id :: String - path :: String -end - -Base.showerror(io::IO, e::RecordConflict) = print(io, - "statistics record $(e.id) already exists at $(e.path) with different content; " * - "records are write-once, so this is a corrupt or partially written file, not a " * - "reason to replace a stored analysis") - -""" - analysis_id(record) -> String - -The record's content address: SHA-256 over its canonical JSON, excluding -`analysis_id` and `created_at`, so the same analysis of the same input under the -same settings hashes identically whenever and wherever it is recomputed, while a -changed setting, input or method version produces a different id. -""" -function analysis_id(record::AbstractDict) - payload = Dict{String,Any}() - for (k, v) in record - key = String(k) - (key == "analysis_id" || key == "created_at") && continue - payload[key] = v - end - return jcs_sha256(payload) -end - -_iso_timestamp(dt::DateTime) = - Dates.format(dt, "yyyy-mm-dd") * "T" * Dates.format(dt, "HH:MM:SS") * "Z" - -""" - write_record(project_dir, record; created_at=now(UTC)) - -Store `record` once, under its content address, and return -`(; id, path, created, bytes)`. - -Write-once is the point. An existing file at the same id is never replaced: if -the bytes match, the write is a no-op and `created` is false, so re-running an -unchanged analysis is idempotent; if they differ, the stored file is corrupt or -was written by a different schema under the same id, and `RecordConflict` is -raised rather than an analysis being overwritten. New settings produce a new id -and therefore a new, linked record beside the old one. - -The bytes written are canonical JSON, so two machines storing the same analysis -produce byte-identical files. The temporary file is renamed into place, so a -reader never sees a half-written record. -""" -function write_record(project_dir::AbstractString, record::AbstractDict; - created_at::DateTime = now(UTC)) - dir = record_directory(project_dir) - mkpath(dir) - id = analysis_id(record) - stored = Dict{String,Any}(String(k) => v for (k, v) in record) - stored["analysis_id"] = id - stored["created_at"] = _iso_timestamp(created_at) - bytes = jcs_bytes(stored) - path = joinpath(dir, id * ".json") - if isfile(path) - existing = read(path) - existing == bytes && return (; id = id, path = path, created = false, bytes = bytes) - throw(RecordConflict(id, path)) - end - tmp = path * ".tmp-" * string(getpid()) * "-" * string(Threads.threadid()) - try - open(tmp, "w") do io - write(io, bytes) - end - mv(tmp, path) - finally - isfile(tmp) && rm(tmp; force = true) - end - return (; id = id, path = path, created = true, bytes = bytes) -end - -""" - read_record(project_dir, id) -> Union{Dict{String,Any},Nothing} - -The stored record, as plain data, or `nothing` when it does not exist. An id -that is not a SHA-256 hex digest is an `ArgumentError` - the id reaches a -filesystem path, so it is validated rather than trusted. -""" -function read_record(project_dir::AbstractString, id::AbstractString) - occursin(_ID_RE, id) || throw(ArgumentError("'$id' is not a statistics analysis id")) - path = joinpath(record_directory(project_dir), String(id) * ".json") - isfile(path) || return nothing - return _plain_json(JSON3.read(read(path, String))) -end - -""" - list_records(project_dir) -> Vector{Dict{String,Any}} - -Every stored record's summary, newest filename order aside (`readdir` sorted), -each with `analysis_id`, `path`, `method`, `version`, `status`, `created_at` and -`fingerprint`. A file that cannot be parsed is listed with -`"unreadable" => true` rather than skipped: a missing record is a different fact -from a damaged one. -""" -function list_records(project_dir::AbstractString) - dir = record_directory(project_dir) - isdir(dir) || return Dict{String,Any}[] - out = Dict{String,Any}[] - for file in sort(readdir(dir)) - endswith(file, ".json") || continue - id = file[1:end-5] - occursin(_ID_RE, id) || continue - path = joinpath(dir, file) - summary = Dict{String,Any}("analysis_id" => id, "path" => path) - try - rec = _plain_json(JSON3.read(read(path, String))) - method = get(rec, "method", Dict{String,Any}()) - summary["method"] = get(method, "name", nothing) - summary["version"] = get(method, "version", nothing) - summary["status"] = get(rec, "status", nothing) - summary["created_at"] = get(rec, "created_at", nothing) - prov = get(rec, "provenance", Dict{String,Any}()) - inp = prov isa AbstractDict ? get(prov, "input", Dict{String,Any}()) : Dict{String,Any}() - summary["fingerprint"] = inp isa AbstractDict ? get(inp, "fingerprint", nothing) : nothing - summary["unreadable"] = false - catch - summary["unreadable"] = true - end - push!(out, summary) - end - return out -end - -end # module ExactStatistics diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl index 271a55e1..cb865d0d 100644 --- a/src/server/routes/analysis.jl +++ b/src/server/routes/analysis.jl @@ -387,11 +387,8 @@ end # The chart surfaces an exclusion may apply to. "diversity" covers alpha, NMDS # and PERMANOVA; "taxa" is the rank-tagged chart; "composition" is the # category-tagged chart; "venn" the presence/absence sets; "differential" the -# per-taxon differential abundance test; "statistics" the exact-arithmetic -# statistics layer. No existing exclusion names "statistics", so adding it here -# changes none of their behaviour: it only makes the surface addressable. -const _EXCLUSION_SURFACES = Set(["diversity", "taxa", "composition", "venn", - "differential", "statistics"]) +# per-taxon differential abundance test. +const _EXCLUSION_SURFACES = Set(["diversity", "taxa", "composition", "venn", "differential"]) # Resolve the configured `analysis.exclude_categories` for a run into a vector of # (set, category, surfaces) specs. Each names a composition category set and the diff --git a/src/server/routes/statistics.jl b/src/server/routes/statistics.jl deleted file mode 100644 index 4b5f23df..00000000 --- a/src/server/routes/statistics.jl +++ /dev/null @@ -1,274 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) - -# Routes: the optional exact-arithmetic statistics layer (issue #1). -# -# These endpoints are additive. They read the same results database as the other -# analysis routes but write only their own immutable records under -# `projects/.../run/analysis/statistics/`, so a request here cannot change a -# pipeline output or an existing analysis. -# -# GET /api/v1/studies/{study}/analysis/statistics/catalogue -# POST /api/v1/studies/{study}/runs/{run}/analysis/statistics -# GET /api/v1/studies/{study}/runs/{run}/analysis/statistics -# GET /api/v1/studies/{study}/runs/{run}/analysis/statistics/{analysis_id} -# -# The POST answers 200 with the stored record even when the record's `status` is -# "partial" or "unsuccessful": the request succeeded, and the analysis's own -# outcome (with its codes and messages) is what the record is for. A client must -# read `status`, which is why every failure path also carries a machine-readable -# `code`. Only a malformed request, an unknown method or option, or an -# unrepresentable numeric policy is an HTTP error. - -using JSON3 -using ..ExactNumeric: NUMERIC_MODES, DEFAULT_PRECISION_BITS, DEFAULT_MAX_DIGITS, - NumericError, error_code, policy_from_config, policy_dict -using ..ExactStatistics: analyse, write_record, read_record, list_records, - method_catalogue, RecordConflict -using ..Analysis: sample_columns, filtered_df, sequence_column_name - -## Which column identifies a feature row, for the names stored with a record. -const _FEATURE_ID_COLUMNS = ("ASV", "OTU", "SeqName") - -function _statistics_feature_column(columns::Vector{String}) - for name in _FEATURE_ID_COLUMNS - name in columns && return name - end - return sequence_column_name(columns) -end - -""" - _statistics_matrix(con, table, scols, columns, where_clause, where_params) - -> (Matrix{Float64}, Vector{String}) - -The count matrix and its feature names, from one read of the filtered table. - -`filtered_counts` cannot serve here because the feature *names* must line up with -the matrix's columns: the name and the counts come from the same rows, in the -same identity order, so a record's feature list cannot describe a different -ordering than the analysis used. `missing` counts count as zero, as they do in -`filtered_counts`. -""" -function _statistics_matrix(con, table::String, scols::Vector{String}, - columns::Vector{String}, where_clause::String, where_params::Vector) - df = filtered_df(con, table, where_clause, where_params) - n = nrow(df) - features = if n == 0 - String[] - else - idcol = _statistics_feature_column(columns) - if isnothing(idcol) - [string(j) for j in 1:n] - else - [string(df[!, Symbol(idcol)][j]) for j in 1:n] - end - end - mat = Matrix{Float64}(undef, length(scols), n) - for (i, c) in enumerate(scols) - column = df[!, Symbol(c)] - for j in 1:n - v = column[j] - mat[i, j] = ismissing(v) ? 0.0 : Float64(v) - end - end - return mat, features -end - -""" - _statistics_numeric_section(study) -> Dict{String,Any} - -The study's `analysis.statistics.numeric` settings, resolved through the config -cascade. A missing or `nothing` value falls back to the factory default; an -unknown or invalid value raises `ArgumentError` from the policy constructor -rather than being ignored. -""" -function _statistics_numeric_section(study::String) - resolved = _resolved_run_config(study, nothing, nothing, nothing) - val(k, d) = something(get(get(resolved, k, (; value=d)), :value, d), d) - return Dict{String,Any}( - "mode" => val("analysis.statistics.numeric.mode", "float64"), - "precision_bits" => val("analysis.statistics.numeric.precision_bits", DEFAULT_PRECISION_BITS), - "max_digits" => val("analysis.statistics.numeric.max_digits", DEFAULT_MAX_DIGITS), - "display_digits" => val("analysis.statistics.numeric.display_digits", 6), - ) -end - -""" - _statistics_numeric_override(raw) -> Dict{String,Any} - -The optional per-request `numeric` object. Only the four policy keys are -accepted, and only as plain scalars; `policy_from_config` validates the values -and their ranges, so an out-of-range precision is a 400 and never a silently -clamped setting. -""" -function _statistics_numeric_override(raw) - isnothing(raw) && return Dict{String,Any}() - (raw isa AbstractDict || raw isa JSON3.Object) || throw(ArgumentError( - "numeric must be a JSON object with mode, precision_bits, max_digits or display_digits")) - out = Dict{String,Any}() - for (k, v) in raw - out[string(k)] = v - end - return out -end - -""" - _statistics_options(raw) -> Dict{String,Any} - -The optional per-request `options` object for the chosen method. Values are -passed through unchanged: `ExactStatistics.analyse` owns validation, so a nested -object or a wrong type is reported once, by the layer that defines what the -option means. -""" -function _statistics_options(raw) - isnothing(raw) && return Dict{String,Any}() - (raw isa AbstractDict || raw isa JSON3.Object) || throw(ArgumentError( - "options must be a JSON object")) - out = Dict{String,Any}() - for (k, v) in raw - out[string(k)] = v - end - return out -end - -## The catalogue: what this layer claims, published as data. -@get "/api/v1/studies/{study}/analysis/statistics/catalogue" function(req, study::String) - study in _study_names() || return json_error(404, "study_not_found", - "Study '$study' not found") - policy = try - policy_from_config(_statistics_numeric_section(study)) - catch e - e isa ArgumentError || rethrow() - return json_error(400, "invalid_numeric_policy", sprint(showerror, e)) - end - body = Dict{String,Any}( - "methods" => method_catalogue(), - "numeric_modes" => collect(NUMERIC_MODES), - "default_numeric_policy" => policy_dict(policy), - "record_schema" => "metamanifold.statistics/1", - ) - HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(body)) -end - -## Run one method and store the record. -@post "/api/v1/studies/{study}/runs/{run}/analysis/statistics" function(req, - study::String, - run::String) - group = _req_group(req) - dir, err = _require_run_results(study, run; group) - !isnothing(err) && return err - - body = JSON3.read(String(req.body)) - table = string(get(body, :table, "merged")) - method = string(get(body, :method, "summarise.counts")) - prefix = let p = get(body, :prefix, nothing); isnothing(p) ? nothing : string(p) end - params = _body_filter_params(body) - - policy = try - policy_from_config(_statistics_numeric_section(study); - overrides = _statistics_numeric_override(get(body, :numeric, nothing))) - catch e - e isa ArgumentError || rethrow() - return json_error(400, "invalid_numeric_policy", sprint(showerror, e)) - end - options = try - _statistics_options(get(body, :options, nothing)) - catch e - e isa ArgumentError || rethrow() - return json_error(400, "invalid_options", sprint(showerror, e)) - end - - cfg_cache = Dict{Tuple{String,String,String}, Any}() - response = _with_analysis_results_table(study, run, table; group) do con, columns - scols = _filter_by_prefix(sample_columns(con, table), prefix) - isempty(scols) && return json_error(400, "no_samples", - "No sample columns found in table '$table'") - # The statistics surface obeys the same category exclusions as the other - # analysis surfaces, but only when a config names it: the factory default - # lists its surfaces explicitly, so statistics sees the whole table until - # an exclusion says otherwise. - exclude_conditions = _exclusion_conditions(study, run, group, columns; - surface = "statistics", - config_cache = cfg_cache) - where_clause, where_params = _analysis_where_clause(params, columns; - exclude_conditions) - scols = _retain_sample_columns(con, table, scols, params, where_clause, where_params) - isempty(scols) && return json_error(400, "no_samples", - "No samples pass the sample read-count filter") - - mat, feature_names = _statistics_matrix(con, table, scols, columns, - where_clause, where_params) - isempty(feature_names) && return json_error(400, "no_features", - "No rows remain in table '$table' after filtering") - - record = try - analyse(mat, scols, feature_names; - method = method, - policy = policy, - options = options, - seed = _study_seed(study), - source = Dict{String,Any}( - "study" => study, - "run" => run, - "group" => isnothing(group) ? nothing : group, - "table" => table, - )) - catch e - e isa ArgumentError && return json_error(400, "invalid_request", sprint(showerror, e)) - e isa NumericError && return json_error(422, error_code(e), sprint(showerror, e)) - rethrow() - end - - written = try - write_record(_run_project_dir(study, run; group), record) - catch e - e isa RecordConflict && return json_error(409, "record_conflict", sprint(showerror, e)) - e isa ArgumentError && return json_error(500, "record_not_serialisable", - sprint(showerror, e)) - rethrow() - end - - out = Dict{String,Any}(record) - out["analysis_id"] = written.id - out["record_path"] = written.path - out["record_created"] = written.created - HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(out)) - end - return response -end - -## List a run's records: what was analysed, when, under which policy. -@get "/api/v1/studies/{study}/runs/{run}/analysis/statistics" function(req, - study::String, - run::String) - group = _req_group(req) - err = _validate_run_request(study, run) - !isnothing(err) && return err - records = list_records(_run_project_dir(study, run; group)) - body = Dict{String,Any}( - "records" => records, - "note" => "Records are immutable and content-addressed. An unreadable record is " * - "reported rather than hidden, because a missing record and a damaged " * - "one are different facts.", - ) - HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(body)) -end - -## One stored record, by content address. -@get "/api/v1/studies/{study}/runs/{run}/analysis/statistics/{analysis_id}" function(req, - study::String, - run::String, - analysis_id::String) - group = _req_group(req) - err = _validate_run_request(study, run) - !isnothing(err) && return err - record = try - read_record(_run_project_dir(study, run; group), analysis_id) - catch e - e isa ArgumentError || rethrow() - return json_error(400, "invalid_analysis_id", sprint(showerror, e)) - end - isnothing(record) && return json_error(404, "analysis_not_found", - "No statistics record '$analysis_id' for run '$run'") - HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON3.write(record)) -end diff --git a/src/server/server.jl b/src/server/server.jl index e82474bd..2cafb385 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -102,9 +102,6 @@ module Server include(joinpath(@__DIR__, "routes", "databases.jl")) include(joinpath(@__DIR__, "routes", "events.jl")) include(joinpath(@__DIR__, "routes", "analysis.jl")) - # The optional exact-arithmetic statistics layer's endpoints. After - # analysis.jl, whose table/sample helpers it reuses. - include(joinpath(@__DIR__, "routes", "statistics.jl")) include(joinpath(@__DIR__, "routes", "read_funnel.jl")) include(joinpath(@__DIR__, "routes", "composition.jl")) include(joinpath(@__DIR__, "routes", "publication_tables.jl")) diff --git a/test/fixtures/statistics_known_answers.json b/test/fixtures/statistics_known_answers.json deleted file mode 100644 index b525177f..00000000 --- a/test/fixtures/statistics_known_answers.json +++ /dev/null @@ -1,295 +0,0 @@ -{ - "_provenance": { - "producer": "scripts/statistics-reference.py", - "language": "Python 3.11.2", - "arithmetic": "fractions.Fraction (exact rationals); decimal.Decimal at 80 digits for logarithms", - "independence": [ - "written from the published definitions, not ported from the Julia code", - "expected richness checked against both a closed form and exhaustive subset enumeration", - "Shannon values computed with Decimal.ln at 80 digits, not MPFR" - ], - "does_not_validated": "anything inferential: the layer makes no inference, and no statistical test is validated here", - "shannon_digits": 60 - }, - "simpson": [ - { - "counts": [ - 1, - 2 - ], - "value": "4/9", - "permuted_value": "4/9", - "scaled_by_three": "4/9", - "display_6": "0.444444" - }, - { - "counts": [ - 1, - 1, - 1, - 1 - ], - "value": "3/4", - "permuted_value": "3/4", - "scaled_by_three": "3/4", - "display_6": "0.750000" - }, - { - "counts": [ - 50, - 50 - ], - "value": "1/2", - "permuted_value": "1/2", - "scaled_by_three": "1/2", - "display_6": "0.500000" - }, - { - "counts": [ - 90, - 10 - ], - "value": "9/50", - "permuted_value": "9/50", - "scaled_by_three": "9/50", - "display_6": "0.180000" - }, - { - "counts": [ - 1 - ], - "value": "0/1", - "permuted_value": "0/1", - "scaled_by_three": "0/1", - "display_6": "0.000000" - }, - { - "counts": [ - 2, - 3, - 5, - 7, - 11 - ], - "value": "36/49", - "permuted_value": "36/49", - "scaled_by_three": "36/49", - "display_6": "0.734694" - }, - { - "counts": [ - 1000000, - 3000000, - 0 - ], - "value": "3/8", - "permuted_value": "3/8", - "scaled_by_three": "3/8", - "display_6": "0.375000" - }, - { - "counts": [ - 0, - 0, - 7 - ], - "value": "0/1", - "permuted_value": "0/1", - "scaled_by_three": "0/1", - "display_6": "0.000000" - } - ], - "expected_richness": [ - { - "counts": [ - 2, - 3 - ], - "depth": 1, - "value": "1/1", - "permuted_value": "1/1", - "display_6": "1.000000", - "brute_force": "1/1" - }, - { - "counts": [ - 2, - 3 - ], - "depth": 2, - "value": "8/5", - "permuted_value": "8/5", - "display_6": "1.600000", - "brute_force": "8/5" - }, - { - "counts": [ - 2, - 3 - ], - "depth": 5, - "value": "2/1", - "permuted_value": "2/1", - "display_6": "2.000000", - "brute_force": "2/1" - }, - { - "counts": [ - 1, - 1, - 1 - ], - "depth": 2, - "value": "2/1", - "permuted_value": "2/1", - "display_6": "2.000000", - "brute_force": "2/1" - }, - { - "counts": [ - 5, - 0, - 0 - ], - "depth": 3, - "value": "1/1", - "permuted_value": "1/1", - "display_6": "1.000000", - "brute_force": "1/1" - }, - { - "counts": [ - 10, - 20, - 30 - ], - "depth": 15, - "value": "172468264847/58326852185", - "permuted_value": "172468264847/58326852185", - "display_6": "2.956927", - "brute_force": null - }, - { - "counts": [ - 1, - 2, - 3, - 4 - ], - "depth": 5, - "value": "799/252", - "permuted_value": "799/252", - "display_6": "3.170635", - "brute_force": "799/252" - }, - { - "counts": [ - 4, - 4, - 4, - 4 - ], - "depth": 8, - "value": "50/13", - "permuted_value": "50/13", - "display_6": "3.846154", - "brute_force": "50/13" - } - ], - "shannon": [ - { - "counts": [ - 50, - 50 - ], - "value_decimal": "0.693147180559945309417232121458176568075500134360255254120680", - "digits": 60, - "double": 0.6931471805599453 - }, - { - "counts": [ - 90, - 10 - ], - "value_decimal": "0.325082973391448239506550028223817939235618484547823962910878", - "digits": 60, - "double": 0.32508297339144826 - }, - { - "counts": [ - 1, - 1, - 1 - ], - "value_decimal": "1.098612288668109691395245236922525704647490557822749451734694", - "digits": 60, - "double": 1.0986122886681098 - }, - { - "counts": [ - 1, - 1000000000 - ], - "value_decimal": "2.1723265815723145340272111085303833449722746614636210E-8", - "digits": 60, - "double": 2.1723265815723146e-08 - }, - { - "counts": [ - 25, - 25, - 25, - 25 - ], - "value_decimal": "1.386294361119890618834464242916353136151000268720510508241360", - "digits": 60, - "double": 1.3862943611198906 - } - ], - "proportions": [ - { - "counts": [ - 1, - 2 - ], - "values": [ - "1/3", - "2/3" - ] - }, - { - "counts": [ - 3, - 3, - 3 - ], - "values": [ - "1/3", - "1/3", - "1/3" - ] - }, - { - "counts": [ - 90, - 10 - ], - "values": [ - "9/10", - "1/10" - ] - }, - { - "counts": [ - 0, - 5, - 5 - ], - "values": [ - "0/1", - "1/2", - "1/2" - ] - } - ] -} diff --git a/test/runtests.jl b/test/runtests.jl index bb25870f..75c79743 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -60,13 +60,6 @@ const UNIT_FILES = [ "test_server_security.jl", "test_canonical.jl", "test_doi.jl", - # The optional exact-arithmetic statistics layer (issue #1). Its arithmetic - # is checked against an independent Python reference - # (test/fixtures/statistics_known_answers.json, produced by - # scripts/statistics-reference.py), and its negative controls are the same - # ones scripts/statistics-negative-controls.jl reports. - "test_exact_numeric.jl", - "test_exact_statistics.jl", ] # The files named on the command line ("test_routes.jl", "routes" or diff --git a/test/unit/test_exact_numeric.jl b/test/unit/test_exact_numeric.jl deleted file mode 100644 index 17c8a134..00000000 --- a/test/unit/test_exact_numeric.jl +++ /dev/null @@ -1,352 +0,0 @@ -# Unit tests for the numeric policy of the exact statistics layer (issue #1). -# -# What these tests are for: they pin the *contract* - which representations are -# accepted, which are refused, how a value is tagged on the way out, and that the -# working precision of one analysis cannot leak into another's. The arithmetic -# itself is checked against an independent implementation in -# test/unit/test_exact_statistics.jl (fixture: test/fixtures/statistics_known_answers.json). -using MetaManifold.ExactNumeric -using Random - -@testset "ExactNumeric" begin - - @testset "policy validation" begin - p = NumericPolicy() - @test p.mode === :float64 - @test p.precision_bits == DEFAULT_PRECISION_BITS - @test p.max_digits == DEFAULT_MAX_DIGITS - @test p.display_digits == 6 - @test p.version == POLICY_VERSION - - @test NumericPolicy(mode = " Exact ").mode === :exact - @test NumericPolicy(mode = "FLOAT64").mode === :float64 - @test NumericPolicy(mode = "bigfloat", precision_bits = 1024).precision_bits == 1024 - @test NumericPolicy(precision_bits = MIN_PRECISION_BITS).precision_bits == 53 - @test NumericPolicy(precision_bits = MAX_PRECISION_BITS).precision_bits == 4096 - @test NumericPolicy(max_digits = MAX_MAX_DIGITS).max_digits == MAX_MAX_DIGITS - @test NumericPolicy(display_digits = MAX_DISPLAY_DIGITS).display_digits == 50 - - # Unsupported or mistyped requests are refused, never coerced. - @test_throws ArgumentError NumericPolicy(mode = "decimal") - # (mode is matched case-insensitively, like every other name in the config) - @test_throws ArgumentError NumericPolicy(mode = :exact) - @test_throws ArgumentError NumericPolicy(precision_bits = 52) - @test_throws ArgumentError NumericPolicy(precision_bits = 4097) - @test_throws ArgumentError NumericPolicy(precision_bits = 256.0) - @test_throws ArgumentError NumericPolicy(precision_bits = true) - @test_throws ArgumentError NumericPolicy(max_digits = 15) - @test_throws ArgumentError NumericPolicy(max_digits = MAX_MAX_DIGITS + 1) - @test_throws ArgumentError NumericPolicy(display_digits = -1) - @test_throws ArgumentError NumericPolicy(display_digits = 51) - end - - @testset "policy_from_config" begin - cfg = Dict{String,Any}("mode" => "bigfloat", "precision_bits" => 512, - "max_digits" => 128, "display_digits" => 4) - p = policy_from_config(cfg) - @test p.mode === :bigfloat - @test p.precision_bits == 512 - @test p.max_digits == 128 - @test p.display_digits == 4 - - # An override is applied last, and a `nothing` (YAML's `~`) means "unset" - # rather than "null", so a cascade cannot shadow a default with nothing. - p2 = policy_from_config(cfg; overrides = Dict("precision_bits" => 256, "mode" => nothing)) - @test p2.precision_bits == 256 - @test p2.mode === :bigfloat - - @test policy_from_config() == NumericPolicy() - @test policy_from_config(nothing; overrides = nothing) == NumericPolicy() - - # A misspelt key is a hard error: silently running at the default while - # the caller believes otherwise is the failure this layer exists to avoid. - @test_throws ArgumentError policy_from_config(Dict("precision" => 256)) - @test_throws ArgumentError policy_from_config(Dict("mode" => "exact", "unknown" => 1)) - @test_throws ArgumentError policy_from_config(Dict("mode" => "octal")) - end - - @testset "policy_dict and arithmetic_bits" begin - d = policy_dict(NumericPolicy(mode = "bigfloat", precision_bits = 128)) - @test d["version"] == POLICY_VERSION - @test d["mode"] == "bigfloat" - @test d["precision_bits"] == 128 - @test occursin("all arithmetic", d["precision_applies_to"]) - - @test arithmetic_bits(NumericPolicy()) == 53 - @test arithmetic_bits(NumericPolicy(mode = "bigfloat", precision_bits = 300)) == 300 - @test_throws UnsupportedNumericMode arithmetic_bits(NumericPolicy(mode = "exact")) - end - - @testset "exact_count refuses rather than rounds" begin - @test exact_count(0) == 0 && exact_count(0) isa BigInt - @test exact_count(12) == 12 - @test exact_count(typemax(Int)) == typemax(Int) - @test exact_count(Int128(2)^100) == BigInt(2)^100 - @test exact_count(big(2)^200) == BigInt(2)^200 - @test exact_count(4 // 1) == 4 && exact_count(4 // 1) isa BigInt - @test exact_count(42.0) == 42 - @test exact_count(Float32(7)) == 7 - @test exact_count(Float64(FLOAT_EXACT_INT_MAX)) == FLOAT_EXACT_INT_MAX - - # Fractional values are refused, never rounded to the nearest integer. - @test_throws InvalidNumericInput exact_count(1 // 2) - @test_throws InvalidNumericInput exact_count(2.5) - @test_throws InvalidNumericInput exact_count(0.0001) - # Non-finite values are not counts. - @test_throws InvalidNumericInput exact_count(NaN) - @test_throws InvalidNumericInput exact_count(Inf) - # Negatives are not counts. - @test_throws InvalidNumericInput exact_count(-1) - @test_throws InvalidNumericInput exact_count(-0.5) - # A Bool is not a count, even though Bool <: Integer. - @test_throws InvalidNumericInput exact_count(true) - # Above 2^53-1 a binary float is no longer a faithful name for an integer. - @test_throws InvalidNumericInput exact_count(Float64(2)^53) - - err = try - exact_count(2.5; what = "count[3,7]") - catch e - e - end - @test err isa InvalidNumericInput - @test occursin("count[3,7]", sprint(showerror, err)) - @test error_code(err) == "invalid_input" - end - - @testset "exact_counts names the offending cell" begin - m = [1 2; 3 4] - exact = exact_counts(m) - @test eltype(exact) === BigInt - @test exact == BigInt[1 2; 3 4] - - bad = [1.0 2.0; 3.0 1.5] - err = try - exact_counts(bad) - catch e - e - end - @test err isa InvalidNumericInput - @test occursin("count[2,2]", sprint(showerror, err)) - end - - @testset "exact_ratio" begin - @test exact_ratio(6, 4) == 3 // 2 - @test exact_ratio(6, 4) isa Rational{BigInt} - @test exact_ratio(-6, 4) == -3 // 2 - @test exact_ratio(6, -4) == -3 // 2 # denominator normalised positive - @test exact_ratio(0, 5) == 0 // 1 - @test exact_ratio(5, 1) == 5 // 1 - @test_throws ZeroDenominator exact_ratio(1, 0) - @test_throws InvalidNumericInput exact_ratio(true, 2) - @test error_code(ZeroDenominator("x")) == "zero_denominator" - - # The digit bound is enforced on both halves of the rational. - tiny = NumericPolicy(max_digits = 16) - @test_throws NumericLimit exact_ratio(big(10)^20, 3; policy = tiny) - @test_throws NumericLimit exact_ratio(1, big(3)^50; policy = tiny) - @test error_code(NumericLimit("x", 16)) == "resource_limit" - @test error_code(ResourceLimit("x", 16)) == "resource_limit" - @test error_code(UnsupportedNumericMode("x", :exact, "y")) == "unsupported_numeric_mode" - end - - @testset "check_limit and decimal_digits" begin - @test decimal_digits(0) == 1 - @test decimal_digits(9) == 1 - @test decimal_digits(-12345) == 5 - @test decimal_digits(big(10)^100) == 101 - - policy = NumericPolicy(max_digits = 16) - @test check_limit(big(10)^15, policy) == big(10)^15 - @test_throws NumericLimit check_limit(big(10)^16, policy) - @test check_limit(1 // 3, policy) == 1 // 3 - end - - @testset "display_string is rounded, and rounds half to even" begin - @test display_string(1234; digits = 6) == "1234" - @test display_string(big(10)^30; digits = 6) == string(big(10)^30) - @test display_string(3 // 4; digits = 6) == "0.750000" - @test display_string(1 // 3; digits = 4) == "0.3333" - @test display_string(2 // 3; digits = 4) == "0.6667" - @test display_string(-1 // 3; digits = 4) == "-0.3333" - @test display_string(1 // 8; digits = 2) == "0.12" # 0.125 -> 0.12 - @test display_string(3 // 8; digits = 2) == "0.38" # 0.375 -> 0.38 - @test display_string(5 // 2; digits = 0) == "2" # 2.5 -> 2 (even) - @test display_string(7 // 2; digits = 0) == "4" # 3.5 -> 4 (even) - @test display_string(-5 // 2; digits = 0) == "-2" - @test display_string(-7 // 2; digits = 0) == "-4" - @test display_string(1 // 1000; digits = 2) == "0.00" - @test display_string(0 // 1; digits = 2) == "0.00" - - # A binary64 is displayed as the number it actually is, not as the decimal - # it was typed as. - @test display_string(0.1; digits = 6) == "0.100000" - @test display_string(0.5; digits = 3) == "0.500" - @test display_string(3.0; digits = 2) == "3.00" - @test_throws InvalidNumericInput display_string(NaN) - @test_throws InvalidNumericInput display_string(Inf) - - h = with_precision(256) do - BigFloat(1) / 3 - end - @test display_string(h; digits = 6) == "0.333333" - @test display_string(h; digits = 12) == "0.333333333333" - @test_throws InvalidNumericInput display_string(BigFloat(NaN)) - end - - @testset "exact_json and its round trip" begin - policy = NumericPolicy(display_digits = 4) - - d = exact_json(big(10)^40; policy = policy) - @test d["kind"] == "exact" - @test d["type"] == "integer" - @test d["value"] == string(big(10)^40) # always a string, never a JSON number - @test d["display"] == string(big(10)^40) - @test parse_exact_json(d) == big(10)^40 - - r = exact_json(3 // 4; policy = policy) - @test r["type"] == "rational" - @test r["numerator"] == "3" - @test r["denominator"] == "4" - @test r["value"] == "3/4" - @test r["display"] == "0.7500" - @test r["display_rounded"] === true - @test parse_exact_json(r) == 3 // 4 - - @test_throws InvalidNumericInput exact_json(true) - @test_throws NumericLimit exact_json(big(10)^20; policy = NumericPolicy(max_digits = 16)) - @test error_code(InvalidNumericInput("x", "y")) == "invalid_input" - end - - @testset "parse_exact_json refuses malformed documents" begin - @test_throws ArgumentError parse_exact_json(Dict{String,Any}()) - @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "approx", "type" => "integer", "value" => "1")) - @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "integer")) - @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "integer", "value" => 1)) - @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "integer", "value" => "1.5")) - @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "rational", "numerator" => "1")) - @test_throws ZeroDenominator parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "rational", - "numerator" => "1", "denominator" => "0")) - @test_throws ArgumentError parse_exact_json(Dict{String,Any}("kind" => "exact", "type" => "decimal", "value" => "1")) - - # Generative fuzz at the parsing boundary: no input may slip through as a - # value, and nothing other than a declared refusal may be thrown. - rng = MersenneTwister(20261022) - keys = ["kind", "type", "value", "numerator", "denominator"] - vals = Any["exact", "integer", "rational", "approx", "1", "0", "-3", "abc", "3/4", - "1e5", 1, 2.5, nothing, true] - for _ in 1:300 - d = Dict{String,Any}() - for _ in 1:rand(rng, 1:4) - d[rand(rng, keys)] = rand(rng, vals) - end - outcome = try - parse_exact_json(d) - :accepted - catch e - (e isa ArgumentError || e isa ZeroDenominator) ? :refused : :unexpected - end - @test outcome != :unexpected - end - end - - @testset "approx_json is tagged, and refuses non-finite values" begin - policy = NumericPolicy(display_digits = 6) - d = approx_json(0.5; policy = policy) - @test d["kind"] == "approximation" - @test d["type"] == "binary64" - @test d["precision_bits"] == 53 - @test d["display"] == "0.500000" - @test d["display_rounded"] === true - - b = with_precision(512) do - sqrt(BigFloat(2)) - end - j = approx_json(b; policy = policy) - @test j["type"] == "mpfr" - @test j["precision_bits"] == 512 - @test parse(Float64, j["value"]) ≈ sqrt(2.0) atol = 1e-15 - - @test_throws InvalidNumericInput approx_json(NaN) - @test_throws InvalidNumericInput approx_json(Inf; policy = policy) - @test_throws InvalidNumericInput approx_json(BigFloat(Inf)) - end - - @testset "with_precision restores the ambient precision" begin - before = precision(BigFloat) - v = with_precision(128) do - @test precision(BigFloat) == 128 - BigFloat(1) / 3 - end - @test precision(BigFloat) == before - @test precision(v) == 128 - - # Nested calls restore their own predecessor. - outer = with_precision(256) do - inner = with_precision(512) do - @test precision(BigFloat) == 512 - BigFloat(1) / 7 - end - @test precision(BigFloat) == 256 - inner - end - @test precision(outer) == 512 - @test precision(BigFloat) == before - - @test_throws ArgumentError with_precision(() -> 1, 52) - @test_throws ArgumentError with_precision(() -> 1, 256.0) - end - - @testset "concurrent analyses do not share precision" begin - before = precision(BigFloat) - bitsets = [64, 128, 256, 1024] - - sequential = Dict{Int,BigFloat}() - for bits in bitsets - sequential[bits] = with_precision(bits) do - BigFloat(1) / 3 - end - end - - tasks = [Threads.@spawn begin - bits = bitsets[mod1(i, length(bitsets))] - with_precision(bits) do - v = BigFloat(1) / 3 - (bits, precision(v), v) - end - end for i in 1:64] - observed = fetch.(tasks) - for (bits, prec, v) in observed - @test prec == bits - @test v == sequential[bits] - end - # And the interleaving left nothing behind. - @test precision(BigFloat) == before - end - - @testset "approximate_value obeys the policy" begin - v = approximate_value(NumericPolicy(mode = "bigfloat", precision_bits = 200)) do - BigFloat(1) / 7 - end - @test precision(v) == 200 - @test v ≈ 1 / 7 atol = 1e-15 - - f = approximate_value(NumericPolicy()) do - BigFloat(1) / 3 - end - @test precision(f) == 53 - - @test_throws UnsupportedNumericMode approximate_value(() -> BigFloat(1), - NumericPolicy(mode = "exact")) - end - - @testset "numeric_contract states the boundaries" begin - c = numeric_contract() - @test c["version"] == POLICY_VERSION - @test c["integer"]["json"] == "decimal string in 'value' (never a JSON number)" - @test "zero denominator" in c["rational"]["refused"] - @test any(x -> occursin("2^53", x), c["integer"]["refused"]) - @test occursin("2^53", c["json_number_boundary"]) - @test c["approximation"]["json"] isa String - end - -end diff --git a/test/unit/test_exact_statistics.jl b/test/unit/test_exact_statistics.jl deleted file mode 100644 index b41a5fc7..00000000 --- a/test/unit/test_exact_statistics.jl +++ /dev/null @@ -1,590 +0,0 @@ -# Unit tests for the exact statistics layer (issue #1). -# -# The arithmetic is checked against `test/fixtures/statistics_known_answers.json`, -# which `scripts/statistics-reference.py` produces from a second implementation in -# another language (see that script's header). This file adds what a reference -# cannot: invariants and metamorphic relations that must hold for *any* input, -# adversarial cases, the immutability and provenance of stored records, precision -# isolation between concurrent analyses, and negative controls that check the -# validation actually rejects a wrong implementation. -# -# Nothing here reaches the network, R or DuckDB, so it runs in the ordinary unit -# suite. -using MetaManifold.ExactNumeric -using MetaManifold.ExactStatistics -using JSON3, Dates, Random - -# The negative controls share one definition with scripts/statistics-negative-controls.jl, -# so the evidence quoted by the script is the evidence the suite enforces. -include(joinpath(@__DIR__, "..", "..", "scripts", "statistics-negative-controls.jl")) - -const _FIXTURE_PATH = joinpath(@__DIR__, "..", "fixtures", "statistics_known_answers.json") - -function _parse_fixture_rational(s) - parts = split(String(s), "/") - length(parts) == 2 || error("not a rational in the fixture: $s") - return parse(BigInt, parts[1]) // parse(BigInt, parts[2]) -end - -_fixture_counts(entry) = BigInt[Int(c) for c in entry["counts"]] - -function _fixture_entry(collection, counts::Vector{Int}) - for entry in collection - [Int(c) for c in entry["counts"]] == counts && return entry - end - error("no fixture entry for counts $counts") -end - -"""Every subset of `1:k` out of `1:n`, for an independent enumeration check.""" -function _foreach_combination(n::Int, k::Int, cb::Function, start::Int = 1, - chosen::Vector{Int} = Int[]) - if length(chosen) == k - cb(chosen) - return - end - for i in start:(n - (k - length(chosen)) + 1) - push!(chosen, i) - _foreach_combination(n, k, cb, i + 1, chosen) - pop!(chosen) - end -end - -""" - _brute_expected_richness(counts, depth) - -Enumerate every subsample of `depth` reads and average the observed richness. -This shares no algebra with the closed form: it is the definition, executed. -""" -function _brute_expected_richness(counts::Vector{Int}, depth::Int) - reads = Int[] - for (feature, c) in enumerate(counts) - for _ in 1:c - push!(reads, feature) - end - end - n = length(reads) - total = zero(BigInt) - subsets = 0 - _foreach_combination(n, depth, chosen -> begin - total += length(unique(reads[chosen])) - subsets += 1 - end) - return total // subsets -end - -@testset "ExactStatistics" begin - - fixture = JSON3.read(read(_FIXTURE_PATH, String)) - full_policy = NumericPolicy(mode = "exact", max_digits = 100_000) - - @testset "catalogue is bounded and self-describing" begin - cat = method_catalogue() - @test Set(keys(cat)) == Set(["summarise.counts", "describe.alpha", - "describe.proportions", - "rarefaction.expected_richness", - "compare.taxon_ratio"]) - for (name, entry) in cat - @test entry["version"] == METHOD_VERSION - @test !isempty(entry["title"]) || error("$name has no title") - @test entry["numeric_modes"] == collect(NUMERIC_MODES) - # Every method states what it does not claim. A method with nothing to - # say here does not belong in a validated catalogue. - @test !isempty(entry["uncertainty"]) - @test !isempty(entry["multiple_testing"]) - @test !isempty(entry["limitations"]) - @test entry["design_required"] === false - end - @test GLOBAL_LIMITATIONS isa Vector{String} - @test any(l -> occursin("no hypothesis test", l), GLOBAL_LIMITATIONS) - @test any(l -> occursin("Higher precision is not exactness", l), GLOBAL_LIMITATIONS) - end - - @testset "known answers: Simpson index (independent reference)" begin - for entry in fixture["simpson"] - counts = _fixture_counts(entry) - expected = _parse_fixture_rational(entry["value"]) - @test simpson_index(counts) == expected - # Metamorphic: a permutation and a uniform rescaling cannot change it. - @test simpson_index(reverse(counts)) == expected - @test simpson_index(3 .* counts) == expected - # The independent reference's own permutation/rescaling results. - @test _parse_fixture_rational(entry["permuted_value"]) == expected - @test _parse_fixture_rational(entry["scaled_by_three"]) == expected - @test display_string(simpson_index(counts); digits = 6) == String(entry["display_6"]) - end - @test_throws ZeroDenominator simpson_index(BigInt[0, 0, 0]) - end - - @testset "known answers: expected richness (closed form, enumeration, fixture)" begin - for entry in fixture["expected_richness"] - counts = _fixture_counts(entry) - depth = Int(entry["depth"]) - expected = _parse_fixture_rational(entry["value"]) - @test expected_richness(counts, depth; policy = full_policy) == expected - @test expected_richness(reverse(counts), depth; policy = full_policy) == expected - @test display_string(expected_richness(counts, depth; policy = full_policy); - digits = 6) == String(entry["display_6"]) - if entry["brute_force"] !== nothing - enumerated = _parse_fixture_rational(entry["brute_force"]) - @test enumerated == expected - ints = [Int(c) for c in counts] - @test _brute_expected_richness(ints, depth) == expected - end - end - - # The same enumeration, on cases the fixture does not pin: the definition - # and the closed form must agree, so neither can be wrong in isolation. - for (counts, depth) in (([1, 2, 3], 2), ([2, 2, 2], 3), ([3, 1, 0], 2), ([1, 1], 1)) - @test expected_richness(BigInt.(counts), depth; policy = full_policy) == - _brute_expected_richness(counts, depth) - end - end - - @testset "known answers: Shannon index at higher precision" begin - for entry in fixture["shannon"] - counts = _fixture_counts(entry) - policy = NumericPolicy(mode = "bigfloat", precision_bits = 256) - value = shannon_index(counts, policy) - @test precision(value) == 256 - reference = with_precision(400) do - parse(BigFloat, String(entry["value_decimal"])) - end - @test abs(value - reference) < big"1e-55" - # A decimal reference was computed at 80 digits; 55 leaves a margin for - # its own rounding, and a mismatch of any more would be a real defect. - @test Float64(value) ≈ Float64(entry["double"]) atol = 1e-12 - end - @test_throws ZeroDenominator shannon_index(BigInt[0, 0], NumericPolicy()) - @test_throws UnsupportedNumericMode shannon_index(BigInt[1, 2], - NumericPolicy(mode = "exact")) - end - - @testset "known answers: exact proportions" begin - for entry in fixture["proportions"] - counts = [Int(c) for c in entry["counts"]] - total = sum(counts) - got = [proportion_value(c, total) for c in counts] - @test got == [_parse_fixture_rational(v) for v in entry["values"]] - @test sum(got) == 1 // 1 - end - @test_throws ZeroDenominator proportion_value(0, 0) - end - - @testset "invariants and metamorphic relations (generative)" begin - rng = MersenneTwister(20261022) - for _ in 1:15 - counts = [rand(rng, 0:12) for _ in 1:rand(rng, 2:6)] - total = sum(counts) - total == 0 && continue - rich = count(!iszero, counts) - counts_big = BigInt.(counts) - - # E[S_total] is the observed richness; E[S_1] is 1 for any non-empty - # library; the expectation is monotone in depth and bounded by it. - @test expected_richness(counts_big, 1; policy = full_policy) == 1 - @test expected_richness(counts_big, total; policy = full_policy) == rich - previous = zero(Rational{BigInt}) - for m in 1:total - e = expected_richness(counts_big, m; policy = full_policy) - @test 1 <= e <= rich - @test e >= previous - previous = e - end - - # The higher-precision path must agree with the exact one to the - # precision it claims; disagreement would mean one of them is wrong. - approx = expected_richness(counts_big, max(1, total ÷ 2); - policy = NumericPolicy(mode = "bigfloat", - precision_bits = 256)) - exact = expected_richness(counts_big, max(1, total ÷ 2); policy = full_policy) - @test abs(approx - BigFloat(exact)) < big"1e-40" - - # Simpson is a proportion-like quantity in [0, 1) for a real table. - s = simpson_index(counts_big) - @test 0 <= s < 1 - @test simpson_index(2 .* counts_big) == s - end - end - - @testset "adversarial inputs are refused explicitly" begin - # Huge counts are exact, and a count above 2^53 arriving as a float is not - # silently trusted. - huge = BigInt[big(10)^40] - @test simpson_index(huge) == 0 // 1 - @test_throws InvalidNumericInput exact_count(Float64(2)^53) - @test_throws InvalidNumericInput exact_count(1.5) - # A value whose exact representation breaches the digit bound is a named - # resource_limit failure, not a rounded answer. - @test_throws NumericLimit expected_richness(BigInt[1, big(10)^20], 2; - policy = NumericPolicy(mode = "exact", - max_digits = 16)) - - # An all-zero table has no index at all, at any precision. - zero_table = zeros(Int, 2, 3) - rec = analyse(zero_table, ["A", "B"], ["f1", "f2", "f3"]; - method = "describe.alpha", policy = NumericPolicy(mode = "bigfloat")) - @test rec["status"] == "partial" - @test any(f -> f["code"] == "zero_denominator", rec["failures"]) - @test any(w -> w["code"] == "zero_reads", rec["warnings"]) - - # Tiny probabilities: one read against a billion still has a defined, - # positive Shannon index, and it must not underflow to zero. - tiny = BigInt[1, 10]^9 - h = shannon_index(tiny, NumericPolicy(mode = "bigfloat", precision_bits = 256)) - @test h > 0 - @test abs(h - with_precision(300) do - p = BigFloat(1) / BigFloat(sum(tiny)) - q = BigFloat(10)^9 / BigFloat(sum(tiny)) - -(p * log(p) + q * log(q)) - end) < big"1e-50" - - # A rarefaction depth above the library is refused, not clamped. - rec = analyse([3 0; 0 4], ["A", "B"], ["f1", "f2"]; - method = "rarefaction.expected_richness", - policy = NumericPolicy(mode = "exact"), - options = Dict{String,Any}("depth" => 10)) - @test rec["status"] == "partial" - @test any(f -> f["code"] == "depth_exceeds_library", rec["failures"]) - end - - @testset "simulation study: the exact expectation is the sampling mean" begin - # A pre-set tolerance: four standard errors of the simulated mean, so a - # correct implementation fails about once in 30,000 such studies while a - # bias of one part in a hundred is caught easily. Deterministic seed, so - # the check cannot flake between runs. - counts = [4, 6, 2, 8] - depth = 5 - exact = expected_richness(BigInt.(counts), depth; policy = full_policy) - reads = Int[] - for (feature, c) in enumerate(counts) - for _ in 1:c - push!(reads, feature) - end - end - rng = MersenneTwister(987654) - n = 4_000 - values = Vector{Int}(undef, n) - for i in 1:n - subset = randperm(rng, length(reads))[1:depth] - values[i] = length(unique(reads[subset])) - end - μ = sum(values) / n - σ = sqrt(sum((v - μ)^2 for v in values) / (n - 1)) - se = σ / sqrt(n) - @test abs(μ - Float64(exact)) <= 4 * se + 1e-12 - end - - @testset "analyse: counts summary" begin - samples = ["S1", "S2", "S3"] - features = ["ASV1", "ASV2", "ASV3"] - mat = [10 20 0; - 0 0 0; - 5 5 10] - rec = analyse(mat, samples, features; method = "summarise.counts", - policy = NumericPolicy(mode = "exact"), - source = Dict{String,Any}("run" => "r1")) - @test rec["status"] == "complete" - @test isempty(rec["failures"]) - @test rec["results"]["corpus"]["total_reads"]["value"] == "50" - @test rec["results"]["samples"][1]["total_reads"]["value"] == "30" - @test rec["results"]["samples"][1]["richness"]["value"] == "2" - @test rec["results"]["samples"][1]["mean_per_feature"]["value"] == "10/1" - @test rec["results"]["samples"][2]["total_reads"]["value"] == "0" - @test rec["results"]["features"][3]["total_reads"]["value"] == "10" - @test rec["results"]["features"][3]["prevalence"]["value"] == "2" - @test rec["diagnostics"]["counts"]["zero_total_samples"] == 1 - @test rec["diagnostics"]["numeric_mode"] == "exact" - @test rec["provenance"]["input"]["source"]["run"] == "r1" - @test rec["provenance"]["method"]["name"] == "summarise.counts" - @test rec["provenance"]["numeric_policy"]["mode"] == "exact" - @test rec["provenance"]["seed"] == 0 - @test rec["provenance"]["software"]["mpfr"] isa String - @test length(rec["limitations"]) >= length(GLOBAL_LIMITATIONS) - end - - @testset "analyse: alpha, exact and approximate modes" begin - mat = reshape([90, 10], 1, 2) - samples = ["A"]; features = ["f1", "f2"] - - exact = analyse(mat, samples, features; method = "describe.alpha", - policy = NumericPolicy(mode = "exact")) - sample = exact["results"]["samples"][1] - @test sample["richness"]["kind"] == "exact" - @test sample["simpson"]["kind"] == "exact" - @test sample["simpson"]["value"] == "9/50" - # The exact mode refuses what has no exact value, and says why, instead of - # quietly returning a float. - @test sample["shannon"]["status"] == "unsuccessful" - @test sample["shannon"]["code"] == "unsupported_numeric_mode" - @test sample["evenness"]["code"] == "unsupported_numeric_mode" - @test sample["status"] == "partial" - @test exact["status"] == "partial" - @test any(f -> f["code"] == "unsupported_numeric_mode", exact["failures"]) - - big = analyse(mat, samples, features; method = "describe.alpha", - policy = NumericPolicy(mode = "bigfloat", precision_bits = 256)) - h = big["results"]["samples"][1]["shannon"] - @test h["kind"] == "approximation" - @test h["type"] == "mpfr" - @test h["precision_bits"] == 256 - entry = _fixture_entry(fixture["shannon"], [90, 10]) - reference = with_precision(400) do - parse(BigFloat, String(entry["value_decimal"])) - end - @test abs(parse(BigFloat, h["value"]) - reference) < big"1e-55" - - f64 = analyse(mat, samples, features; method = "describe.alpha", - policy = NumericPolicy(mode = "float64")) - h64 = f64["results"]["samples"][1]["shannon"] - @test h64["type"] == "binary64" - @test h64["precision_bits"] == 53 - @test parse(Float64, h64["value"]) ≈ Float64(entry["double"]) atol = 1e-12 - - # Evenness is undefined for a single observed feature, and says so. - lonely = analyse([5 0], ["A"], ["f1", "f2"]; method = "describe.alpha", - policy = NumericPolicy(mode = "float64")) - @test lonely["results"]["samples"][1]["evenness"]["code"] == "invalid_input" - end - - @testset "analyse: proportions" begin - mat = [1 2; 0 0; 90 10] - samples = ["S1", "S2", "S3"] - features = ["f1", "f2"] - rec = analyse(mat, samples, features; method = "describe.proportions", - policy = NumericPolicy(mode = "exact")) - @test rec["results"]["samples"][1]["proportions"][1]["value"] == "1/3" - @test rec["results"]["samples"][1]["proportions"][2]["value"] == "2/3" - # The zero-read sample has no proportions at all, and is not filled with - # zeros to make the table rectangular. - @test rec["results"]["samples"][2]["status"] == "partial" - @test rec["results"]["samples"][2]["proportions"][1]["code"] == "zero_denominator" - @test rec["results"]["samples"][2]["total_reads"]["value"] == "0" - @test rec["results"]["corpus"]["proportions"][1]["value"] == "91/103" - @test rec["status"] == "partial" - end - - @testset "analyse: rarefaction and taxon ratio" begin - mat = [2 3; 0 4] - samples = ["S1", "S2"] - features = ["f1", "f2"] - rec = analyse(mat, samples, features; method = "rarefaction.expected_richness", - policy = NumericPolicy(mode = "exact"), - options = Dict{String,Any}("depth" => 2)) - @test rec["results"]["samples"][1]["expected_richness"]["value"] == "8/5" - @test rec["results"]["samples"][2]["expected_richness"]["value"] == "1/1" - @test rec["results"]["depth"] == 2 - - # A work bound is an explicit refusal, not a slow request. - too_deep = analyse(ones(Int, 3, 4000), ["a", "b", "c"], string.("f", 1:4000); - method = "rarefaction.expected_richness", - policy = NumericPolicy(mode = "exact"), - options = Dict{String,Any}("depth" => 5000)) - @test too_deep["status"] == "unsuccessful" - @test too_deep["failures"][1]["code"] == "resource_limit" - @test isempty(too_deep["results"]) - - rmat = [2 3; 4 0] - ratio = analyse(rmat, samples, features; method = "compare.taxon_ratio", - policy = NumericPolicy(mode = "exact"), - options = Dict{String,Any}("numerator" => "f1", - "denominator" => "f2")) - @test ratio["results"]["corpus"]["ratio"]["value"] == "2/1" - @test ratio["results"]["samples"][2]["ratio"]["code"] == "zero_denominator" - @test ratio["results"]["samples"][1]["ratio"]["value"] == "2/3" - - @test_throws ArgumentError analyse(mat, samples, features; - method = "compare.taxon_ratio", - options = Dict{String,Any}("numerator" => "f1")) - @test_throws ArgumentError analyse(mat, samples, features; - method = "compare.taxon_ratio", - options = Dict{String,Any}("numerator" => "nope", - "denominator" => "f2")) - @test_throws ArgumentError analyse(mat, samples, features; - method = "compare.taxon_ratio", - options = Dict{String,Any}("numerator" => "f1", - "denominator" => "f1")) - end - - @testset "analyse: request errors are errors, data problems are records" begin - mat = [1 2; 3 4] - samples = ["A", "B"]; features = ["f1", "f2"] - - @test_throws ArgumentError analyse(mat, samples, features; method = "describe.magic") - @test_throws ArgumentError analyse(mat, ["A"], features; method = "summarise.counts") - @test_throws ArgumentError analyse(mat, samples, features; - method = "summarise.counts", - options = Dict{String,Any}("depth" => 3)) - @test_throws ArgumentError analyse(mat, samples, features; - method = "rarefaction.expected_richness", - options = Dict{String,Any}("depth" => 0)) - @test_throws ArgumentError analyse(mat, samples, features; - method = "rarefaction.expected_richness", - options = Dict{String,Any}("depth" => 2.5)) - @test_throws ArgumentError analyse(mat, samples, features; - method = "summarise.counts", - options = Dict{String,Any}("depth" => :two)) - @test_throws ArgumentError analyse(mat, samples, features; - method = "summarise.counts", seed = true) - @test_throws ArgumentError analyse(mat, samples, features; - method = "summarise.counts", seed = 2^60) - - fractional = [1.0 2.0; 3.0 1.5] - rec = analyse(fractional, samples, features; method = "summarise.counts") - @test rec["status"] == "unsuccessful" - @test isempty(rec["results"]) - @test rec["failures"][1]["code"] == "invalid_input" - @test occursin("count[2,2]", rec["failures"][1]["message"]) - @test rec["diagnostics"]["conversion_failed"] === true - - too_big = [Float64(2)^53 1.0; 2.0 3.0] - rec = analyse(too_big, samples, features; method = "summarise.counts") - @test rec["status"] == "unsuccessful" - @test rec["failures"][1]["code"] == "invalid_input" - @test occursin("2^53", rec["failures"][1]["message"]) - - # Duplicate names are warned about, not silently de-duplicated, because the - # position of a duplicated name is the only way to tell the entries apart. - rec = analyse([1 2; 3 4], ["A", "A"], ["f1", "f1"]; - method = "summarise.counts") - @test any(w -> w["code"] == "duplicate_sample_names", rec["warnings"]) - @test any(w -> w["code"] == "duplicate_feature_names", rec["warnings"]) - end - - @testset "input fingerprint" begin - a = input_fingerprint(BigInt[1 2; 3 4], ["A", "B"], ["f1", "f2"]) - @test length(a) == 64 - @test occursin(r"^[0-9a-f]{64}$", a) - @test a == input_fingerprint(BigInt[1 2; 3 4], ["A", "B"], ["f1", "f2"]) - @test a != input_fingerprint(BigInt[1 2; 3 5], ["A", "B"], ["f1", "f2"]) - @test a != input_fingerprint(BigInt[1 2; 3 4], ["B", "A"], ["f1", "f2"]) - @test a != input_fingerprint(BigInt[1 2; 3 4], ["A", "B"], ["f2", "f1"]) - # Counts above 2^53 are fingerprinted exactly, not through a float. - big = input_fingerprint(reshape(BigInt[10]^30, 1, 1), ["A"], ["f1"]) - @test big isa String - @test_throws ArgumentError input_fingerprint(BigInt[1 2; 3 4], ["A"], ["f1", "f2"]) - end - - @testset "records are immutable, content-addressed and reproducible" begin - mat = [1 2; 3 4] - samples = ["A", "B"]; features = ["f1", "f2"] - rec = analyse(mat, samples, features; method = "summarise.counts", - policy = NumericPolicy(mode = "exact")) - tmp = mktempdir() - stamp = DateTime(2026, 10, 22, 12, 0, 0) - - first_write = write_record(tmp, rec; created_at = stamp) - @test first_write.created - @test isfile(first_write.path) - @test endswith(first_write.path, first_write.id * ".json") - @test dirname(first_write.path) == record_directory(tmp) - @test analysis_id(rec) == first_write.id - - # Idempotent: the same analysis at a later time is the same record, and the - # stored file keeps its original timestamp. - second_write = write_record(tmp, rec; created_at = DateTime(2026, 10, 23, 1, 2, 3)) - @test !second_write.created - @test second_write.path == first_write.path - @test second_write.bytes == first_write.bytes - @test analysis_id(rec) == first_write.id - - # `created_at` is outside the content address; a changed setting is not. - with_stamp = copy(rec) - with_stamp["created_at"] = "1999-01-01T00:00:00Z" - @test analysis_id(with_stamp) == first_write.id - other = analyse(mat, samples, features; method = "summarise.counts", - policy = NumericPolicy(mode = "bigfloat")) - @test analysis_id(other) != first_write.id - other_write = write_record(tmp, other; created_at = stamp) - @test other_write.created - @test other_write.path != first_write.path - @test length(list_records(tmp)) == 2 - - # Byte-identical on another machine, at the same timestamp. - tmp2 = mktempdir() - elsewhere = write_record(tmp2, rec; created_at = stamp) - @test elsewhere.id == first_write.id - @test elsewhere.bytes == first_write.bytes - - # Reading back is the stored document, not a re-computation. - got = read_record(tmp, first_write.id) - @test got["analysis_id"] == first_write.id - @test got["status"] == rec["status"] - @test got["method"]["name"] == "summarise.counts" - @test got["provenance"]["input"]["fingerprint"] == rec["provenance"]["input"]["fingerprint"] - @test got["created_at"] == "2026-10-22T12:00:00Z" - @test read_record(tmp, repeat("a", 64)) === nothing - @test_throws ArgumentError read_record(tmp, "../etc/passwd") - @test_throws ArgumentError read_record(tmp, "not-an-id") - - # A record is write-once: tampering is a conflict, never an overwrite. - write(first_write.path, "{\"tampered\":true}") - @test_throws RecordConflict write_record(tmp, rec) - - # A damaged record is listed as unreadable rather than hidden: a missing - # record and a broken one are different facts. - write(joinpath(record_directory(tmp), repeat("b", 64) * ".json"), "not json") - listed = list_records(tmp) - @test length(listed) == 3 - @test count(x -> get(x, "unreadable", false), listed) == 1 - @test any(x -> x["analysis_id"] == first_write.id && !x["unreadable"], listed) - end - - @testset "precision is isolated between concurrent analyses" begin - before = precision(BigFloat) - mat = reshape([90, 10], 1, 2) - samples = ["A"]; features = ["f1", "f2"] - low = analyse(mat, samples, features; method = "describe.alpha", - policy = NumericPolicy(mode = "bigfloat", precision_bits = 128)) - high = analyse(mat, samples, features; method = "describe.alpha", - policy = NumericPolicy(mode = "bigfloat", precision_bits = 1024)) - @test low["results"]["samples"][1]["shannon"]["precision_bits"] == 128 - @test high["results"]["samples"][1]["shannon"]["precision_bits"] == 1024 - @test low["results"]["samples"][1]["shannon"]["value"] != - high["results"]["samples"][1]["shannon"]["value"] - - tasks = [Threads.@spawn analyse(mat, samples, features; - method = "describe.alpha", - policy = NumericPolicy(mode = "bigfloat", - precision_bits = (i % 2 == 0 ? 128 : 1024))) - for i in 1:16] - for (i, task) in enumerate(tasks) - record = fetch(task) - wanted = i % 2 == 0 ? low : high - @test record["results"]["samples"][1]["shannon"]["value"] == - wanted["results"]["samples"][1]["shannon"]["value"] - @test record["results"]["samples"][1]["shannon"]["precision_bits"] == - wanted["results"]["samples"][1]["shannon"]["precision_bits"] - @test analysis_id(record) == analysis_id(wanted) - end - @test precision(BigFloat) == before - end - - @testset "the optional layer does not disturb the existing analysis path" begin - # The layer is opt-in: calling it must leave the modules it does not own - # exactly as they were, and must not move the global precision. - before = precision(BigFloat) - baseline = (MetaManifold.DiversityMetrics.richness([1, 0, 2, 2]), - MetaManifold.DiversityMetrics.shannon([50, 50]), - MetaManifold.DiversityMetrics.simpson([50, 50])) - analyse([90 10; 5 5], ["A", "B"], ["f1", "f2"]; method = "describe.alpha", - policy = NumericPolicy(mode = "bigfloat", precision_bits = 512)) - after = (MetaManifold.DiversityMetrics.richness([1, 0, 2, 2]), - MetaManifold.DiversityMetrics.shannon([50, 50]), - MetaManifold.DiversityMetrics.simpson([50, 50])) - @test after == baseline - @test precision(BigFloat) == before - end - - @testset "negative controls: wrong implementations are caught" begin - # Each check is the validation that guards the real implementation. It must - # accept the real one and reject a deliberately wrong one; if a mutant - # passes, the check is not testing what it claims to. - @test !isempty(NEGATIVE_CONTROL_CHECKS) - for control in NEGATIVE_CONTROL_CHECKS - @test control.validate(control.real) - @test !control.validate(control.mutant) - end - @test run_negative_controls(devnull) - end - -end From f15ff91f103a9d1e756d1dac16640c8ea5e1cec6 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Thu, 22 Oct 2026 23:00:46 +0000 Subject: [PATCH 4/5] fix(doi): remove escaped quotes that made routes/doi.jl unparseable The two route macros were written as @post \"...\" and @get \"...\", so the Julia parser sees a backslash in argument position and stops with 'not a unary operator' at src/server/routes/doi.jl:58. Both route files are loaded by src/server/server.jl, so the server module could not be loaded and no test could run, independent of the missing module declaration fixed in the previous commit. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/server/routes/doi.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/routes/doi.jl b/src/server/routes/doi.jl index 76321e85..a54672cc 100644 --- a/src/server/routes/doi.jl +++ b/src/server/routes/doi.jl @@ -55,7 +55,7 @@ function _doi_publish_body(req)::Dict{String,Any} Dict{String,Any}(string(k) => v for (k, v) in body) end -@post \"/api/v1/studies/{study}/doi/publish\" function(req, study::String) +@post "/api/v1/studies/{study}/doi/publish" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") _valid_name(study) || return json_error(400, "invalid_study", "Invalid study name") @@ -87,7 +87,7 @@ end published=receipt.published, created_at=receipt.created_at)) end -@get \"/api/v1/studies/{study}/doi/status\" function(req, study::String) +@get "/api/v1/studies/{study}/doi/status" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") _valid_name(study) || return json_error(400, "invalid_study", "Invalid study name") From f2a3c78d7a2983e2abb3b40d60866ec15037a334 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Thu, 22 Oct 2026 23:28:45 +0000 Subject: [PATCH 5/5] fix(statistics): repair the three defects that kept the merged layer red CI now loads the package (previous two commits), so the suite runs for the first time since the statistics layer merged in #199. It reports 3882 passing tests and failures in that layer only; these are the causes it exposes. 1. canonical JSON could not hash the records it was asked to hash. ExactSummaries._fingerprint and DerivedAnalyses build a NamedTuple and pass it to jcs_sha256, but Canonical only had methods for AbstractDict, vectors, strings, integers, binary floats, Bool and nothing, so every summary that content-addresses its input raised a MethodError instead of returning a fingerprint. A NamedTuple is an object with unique keys, so it is now written with the same rules as a Dict, and test_canonical.jl pins the equality with the equivalent Dict plus the rejection of a non-binary64 float inside one. 2. assert_mode raised the wrong exception type. Asking for high_precision with an exact_counts policy is a caller error, not a value that cannot be represented, and the message was assembled from the exception's (what, value, target) fields in an order that produced a garbled sentence. It now raises ArgumentError with a readable message, which is also what test_high_precision.jl asserts. 3. A resource-limit test asserted a limit that the chosen values do not reach. exact_rational_sum([1/2^31, 1/2^31 + 1/3]) has denominator 3*2^30, which is 32 bits, exactly the 32-bit budget the test sets, so no exception is correct. The test now uses 1/2^31 + 1/3, whose denominator needs 33 bits, which still checks that the limit fires on the running total rather than only on inputs. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/numeric_policy.jl | 6 +++--- src/canonical.jl | 7 ++++++- test/unit/test_canonical.jl | 5 +++++ test/unit/test_numeric_policy.jl | 4 +++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/analysis/numeric_policy.jl b/src/analysis/numeric_policy.jl index fefee490..6abc9bd7 100644 --- a/src/analysis/numeric_policy.jl +++ b/src/analysis/numeric_policy.jl @@ -101,9 +101,9 @@ function validate_policy(spec::NumericPolicySpec) end function assert_mode(spec::NumericPolicySpec, mode::Symbol) - spec.mode === mode || throw(UnsupportedRepresentationError( - "mode $mode", "policy is $(spec.mode)", - "this analysis; select the required mode explicitly or use the existing ordinary workflow")) + spec.mode === mode || throw(ArgumentError( + "this analysis requires numeric mode '$mode' but the policy is '$(spec.mode)'; " * + "select the required mode explicitly or use the existing ordinary workflow")) spec end diff --git a/src/canonical.jl b/src/canonical.jl index b287b7fb..68c5f342 100644 --- a/src/canonical.jl +++ b/src/canonical.jl @@ -40,7 +40,7 @@ const SAFE_INT_MAX = 2^53 - 1 jcs_bytes(x) -> Vector{UInt8} The RFC 8785 canonical form of `x`, as bytes. Accepts the JSON-shaped -values the server produces: `Dict` and `JSON3.Object` (key order is +values the server produces: `Dict`, `JSON3.Object` and `NamedTuple` (key order is irrelevant; it is sorted away), `AbstractVector` and `JSON3.Array` (order is preserved, as JCS preserves array order), `AbstractString`, `Integer`, `Float16`, `Float32`, `Float64`, `Bool` and `nothing`. Floats serialize per ECMAScript's @@ -120,6 +120,11 @@ function _write_char!(io, c::Char) end end +# NamedTuple: the same object rules as a Dict. Records in the statistics layer are +# built as named tuples, and a named tuple has unique symbol keys by construction. +_write!(io, x::NamedTuple) = + _write!(io, Dict{String,Any}(String(k) => v for (k, v) in pairs(x))) + # AbstractDict (Dict and JSON3.Object alike): sorted keys, then each pair. function _write!(io, d::AbstractDict) print(io, '{') diff --git a/test/unit/test_canonical.jl b/test/unit/test_canonical.jl index 22194b92..064317f7 100644 --- a/test/unit/test_canonical.jl +++ b/test/unit/test_canonical.jl @@ -80,6 +80,11 @@ end @test_throws ArgumentError jcs_bytes(invalid) @test_throws ArgumentError jcs_bytes(Dict(invalid => 1)) @test_throws ArgumentError jcs_bytes(BigFloat(1) + eps(BigFloat)) + # Records in the statistics layer are built as named tuples; a named tuple is + # an object with unique keys, so it must hash exactly like the equivalent Dict. + @test jcs_bytes((b = 2, a = 1)) == jcs_bytes(Dict("a" => 1, "b" => 2)) + @test String(jcs_bytes((; a = [1, nothing], b = "s"))) == "{\"a\":[1,null],\"b\":\"s\"}" + @test_throws ArgumentError jcs_bytes((a = BigFloat(1) + eps(BigFloat),)) @test String(jcs_bytes([Float16(1.5), Float32(1.5), Float64(1.5)])) == "[1.5,1.5,1.5]" for T in (UInt8, UInt16, UInt32, UInt64, UInt128) @test String(jcs_bytes(T(1))) == "1" diff --git a/test/unit/test_numeric_policy.jl b/test/unit/test_numeric_policy.jl index 01b74131..f296cb5f 100644 --- a/test/unit/test_numeric_policy.jl +++ b/test/unit/test_numeric_policy.jl @@ -50,8 +50,10 @@ end tight = numeric_policy(mode=:exact_counts, max_denominator_bits=32) @test_throws ResourceLimitError exact_fraction(1, big(2)^40; policy=tight) @test_throws ResourceLimitError exact_fraction(big(2)^40, 1; policy=tight) + # Each addend fits the 32-bit budget on its own; their sum 1/2^31 + 1/3 needs + # 33 bits, so the limit must fire on the running total, not only on inputs. @test_throws ResourceLimitError exact_rational_sum( - [big(1)//big(2)^31, big(1)//big(2)^31 + big(1)//3]; policy=tight) + [big(1)//big(2)^31, big(1)//big(3)]; policy=tight) end @testset "Precision scopes and restoration" begin