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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,55 @@ types, tests, infrastructure, and alignment.

## [Unreleased]

### Fixed — `method = "TSS"` was refused by the layer that claimed to have shipped it (2026-09-25)

- **The allowed-normalisation table held the three names in a different case from the
one the configuration stored.** `NormalizationConfig` canonicalises its method to
lower case (`"TSS"` becomes `"tss"`), while `VALID_NORMALIZATION_FOR_METHOD` listed
`"TSS"`, `"CSS"`, `"RSS"`. The membership test therefore failed for every one of those
spellings, and `AnalysisConfig` raised
`normalization.method 'tss' incompatible with method 'nb_glm'` — including for
`test/unit/test_execution.jl`'s `TSS offset for NB_GLM` testset, which constructed
`method="TSS"` exactly as the documentation instructs. That failure is what reddened
the CI run for the merged TSS-offsets commit; it was not a flaky test.
- Both the table and the two comparisons (`AnalysisConfig` constructor, `validate_config`)
are lower case now, `test/unit/test_scaling.jl` asserts that every admissible spelling
of every method name is accepted, and the JSON schema enum still accepts the upper-case
spellings for existing documents.

### Added — TSS/CSS/RSS are computed as offsets and recorded as such (2026-09-25)

- **`src/analysis/scaling.jl` is wired into the execution path** (issue #16). Under
`nb_glm` the response stays the counts and the offset is the declared scaling factor's
logarithm: `tss` = log library size, `css` = log cumulative sum at the declared
`css_quantile` (Paulson et al. 2013), `rss` = log of the trimmed weighted mean of
log-ratios to a reference sample (TMM, Robinson & Oshlack 2010), `size_factors` =
median-of-ratios (DESeq2/RLE) — the estimator the name claimed all along and the code
did not compute. `none` keeps the plain log library size it has always meant.
- **The declared parameters travel with the configuration**: `css_quantile` (default
0.75), `tmm_ref_column` (default: chosen the way edgeR chooses it, and recorded),
`tmm_log_ratio_trim` (0.3) and `tmm_sum_trim` (0.05) are validated in the constructor,
included in the config hash and canonical JSON, round-tripped through `to_json`/
`from_json`/Nickel/DEED, mirrored in the JSON schema and the Nickel contracts, exposed
through the server's configuration route, and documented in `context_help` and the
frontend help. A run that asked for a different quantile is a different run.
- **What was computed is recorded**: `diagnostics.checks["scaling"]` and the manifest
provenance carry the kind, the definition sentence, the parameters, the raw quantities
before centring and a SHA-256 of the offset vector, so two runs can be shown to agree
and a run whose offset changed is detectable from the manifest alone.
- **Refusals instead of substitutions**: `css` and `rss` are refused for a response with
no counts to offset (a non-count response is what the old alias silently produced),
a zero or non-finite sample total is refused by name, a CSS cumulative sum of zero is
refused as `log(0)` rather than replaced, and an unknown `tmm_ref_column` is refused
with the real sample names instead of falling back to the data-driven choice.
- Evidence: `test/unit/test_scaling.jl` (known answers with the arithmetic written
beside them, properties a wrong implementation fails, the CSS robustness property,
negative controls, the integration path through `prepare_analysis_table`, and a
base-R transcription of the same conditions), plus the conditions document
`docs/statistics/method-conditions/scaling-and-offsets.md`. Parity with
`metagenomeSeq::cumNorm` and `edgeR::calcNormFactors` is **not** claimed: neither
package is in `renv.lock`, and the outstanding condition is recorded in issue #16.

### Fixed — the analysis path no longer returns placeholder statistics (2026-09-25)

- **`run_analysis` computed nothing and returned numbers anyway.** It derived
Expand Down
44 changes: 40 additions & 4 deletions config/schemas/analysis_config.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# AnalysisConfig Nickel contract — hyperpolymath/standards style Milestone 3
# From 1-formats/k9/*.ncl and .machine_readable/contractiles/_base.ncl
# Implements BH mandatory, DANGER banner, advanced validation for pseudocount/epsilon/zero_policy/etc.
# TSS/CSS/RSS deferred as alias to relative with warning, see GitHub issues
# tss/css/rss are exact offsets since 2026-09-25 (issue #16), see docs/statistics/method-conditions/scaling-and-offsets.md

let AnalysisMethod = std.enum.TagOrString & [| 'nb_glm, 'clr_lm, 'ilr_lm, 'logistic |] in
let NormalizationMethod = std.enum.TagOrString & [| 'none, 'rarefy, 'relative, 'size_factors, 'clr, 'ilr, 'presence_absence, 'TSS, 'CSS, 'RSS, 'tss, 'css, 'rss |] in
Expand Down Expand Up @@ -40,6 +40,20 @@ let PseudocountContract = fun label value =>
'Ok value
in

let CssQuantileContract = fun label value =>
if value <= 0 || value >= 1 then
'Error { message = "css_quantile must be in (0,1), got %{std.to_string value}. It is the per-sample quantile of the count distribution whose cumulative sum becomes the CSS scaling factor. See context_help('normalization.css_quantile')" }
else
'Ok value
in

let TmmTrimContract = fun label value =>
if value < 0 || value >= 0.5 then
'Error { message = "a TMM trim must be in [0,0.5), got %{std.to_string value}. See context_help('normalization.tmm_log_ratio_trim')" }
else
'Ok value
in

let EpsilonContract = fun label value =>
if value <= 0 || value >= 1 then
'Error { message = "epsilon must be in (0,1) for numerical stability, got %{std.to_string value}. Typical 1e-6. See context_help('advanced.epsilon')" }
Expand Down Expand Up @@ -94,7 +108,7 @@ let MethodNormalizationCompatibility = fun label value =>
let norm = value.normalization.method in
if method == 'nb_glm then
if norm == 'clr || norm == 'ilr then
'Error { message = "NB_GLM expects count data, not CLR/ILR transforms. Use clr_lm/ilr_lm for compositional, or change normalization to none/size_factors/relative/TSS/CSS/RSS. Refusing meaningless combination. See context_help('normalization.method')" }
'Error { message = "NB_GLM expects count data, not CLR/ILR transforms. Use clr_lm/ilr_lm for compositional, or change normalization to none/size_factors/relative/tss/css/rss. Refusing meaningless combination. See context_help('normalization.method')" }
else
'Ok value
else if method == 'clr_lm then
Expand Down Expand Up @@ -182,7 +196,7 @@ in
| {
method
| NormalizationMethod
| doc "Normalization / transform, must be compatible with method. TSS/CSS/RSS deferred alias to relative with warning, see GitHub issue 01.",
| doc "Normalization / transform, must be compatible with method. tss/css/rss are exact offsets (issue #16), not aliases to relative; see docs/statistics/method-conditions/scaling-and-offsets.md.",

pseudocount
| Number
Expand Down Expand Up @@ -211,7 +225,29 @@ in

tss_css_rss_note
| std.option.String
| doc "Note for TSS/CSS/RSS deferred features — currently aliased to relative with warning. See GitHub issue 01.",
| doc "Free-form note recorded with the configuration. Historical name: TSS/CSS/RSS are not deferred any more (issue #16), and this field is kept so existing documents keep parsing.",

css_quantile
| Number
| CssQuantileContract
| default = 0.75
| doc "CSS: per-sample quantile whose cumulative sum is the scaling factor. Used only by method='css'. Paulson et al. 2013 use 0.5-0.75. See docs/statistics/method-conditions/scaling-and-offsets.md.",

tmm_ref_column
| std.option.String
| doc "RSS/TMM: reference sample, or omitted to choose it the way edgeR chooses it (recorded either way). Used only by method='rss'.",

tmm_log_ratio_trim
| Number
| TmmTrimContract
| default = 0.3
| doc "RSS/TMM: fraction trimmed from each log-ratio tail before the weighted mean; edgeR default 0.3.",

tmm_sum_trim
| Number
| TmmTrimContract
| default = 0.05
| doc "RSS/TMM: fraction trimmed from each mean-abundance tail; edgeR default 0.05.",
},

correction
Expand Down
32 changes: 29 additions & 3 deletions config/schemas/analysis_config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hyperpolymath.github.io/MetaManifold-WebUI/schemas/analysis_config.schema.json",
"title": "AnalysisConfig — versioned, explicit, provenance-rich analysis configuration — Milestone 3",
"description": "Safe, explicit, versioned AnalysisConfig layer for parametric and nonparametric analyses (NB GLM, CLR/ILR+Gaussian LM, logistic in v1; BH mandatory; DANGER banner on overrides; Advanced Analysis section heavy validation/help/warnings for custom pseudocount/epsilon/zero_policy/etc.; DOI-ready bundles). From hyperpolymath/standards JSON + Nickel + DEED schemes. TSS/CSS/RSS deferred as alias to relative with warning, see GitHub issues.",
"description": "Safe, explicit, versioned AnalysisConfig layer for parametric and nonparametric analyses (NB GLM, CLR/ILR+Gaussian LM, logistic in v1; BH mandatory; DANGER banner on overrides; Advanced Analysis section heavy validation/help/warnings for custom pseudocount/epsilon/zero_policy/etc.; DOI-ready bundles). From hyperpolymath/standards JSON + Nickel + DEED schemes. tss/css/rss are exact offsets (issue #16, 2026-09-25), not aliases to relative; see docs/statistics/method-conditions/scaling-and-offsets.md.",
"type": "object",
"required": ["schema_version", "id", "method", "formula", "metadata_columns", "normalization", "correction"],
"properties": {
Expand Down Expand Up @@ -60,7 +60,7 @@
"method": {
"type": "string",
"enum": ["none", "rarefy", "relative", "size_factors", "clr", "ilr", "presence_absence", "TSS", "CSS", "RSS", "tss", "css", "rss"],
"description": "Normalization / transform. Must be compatible with method: nb_glm allows none/rarefy/size_factors/relative/TSS/CSS/RSS (TSS/CSS/RSS deferred alias to relative with warning), clr_lm requires clr, ilr_lm requires ilr, logistic allows none/relative/rarefy/presence_absence/TSS. See MethodNormalizationCompatibility Nickel contract and GitHub issues for TSS/CSS/RSS exact offsets."
"description": "Normalization / transform. Must be compatible with method: nb_glm allows none/rarefy/size_factors/relative/tss/css/rss, clr_lm requires clr, ilr_lm requires ilr, logistic allows none/relative/rarefy/presence_absence/tss. tss/css/rss are offsets computed exactly (issue #16, docs/statistics/method-conditions/scaling-and-offsets.md), not aliases to relative; css and rss apply to count responses only, because an offset needs something to offset. See MethodNormalizationCompatibility Nickel contract."
},
"pseudocount": {
"type": "number",
Expand Down Expand Up @@ -93,7 +93,33 @@
},
"tss_css_rss_note": {
"type": ["string", "null"],
"description": "Note for TSS/CSS/RSS deferred features — currently aliased to relative with warning. See GitHub issue 01-tss-css-rss-offsets."
"description": "Free-form note recorded with the configuration. The field name is historical: TSS/CSS/RSS are not deferred any more (issue #16, 2026-09-25) and are exact offsets. Retained so existing documents keep parsing."
},
"css_quantile": {
"type": "number",
"exclusiveMinimum": 0,
"exclusiveMaximum": 1,
"default": 0.75,
"description": "CSS: the per-sample quantile of the count distribution whose cumulative sum becomes the scaling factor (Paulson et al. 2013 use 0.5-0.75). Used only by normalization.method='css'. Refused at run time when that cumulative sum is zero for any sample, because log(0) is not a small number. metagenomeSeq's data-driven cumNormStatFast choice of the quantile is deliberately not implemented: a parameter chosen from the data must be recorded, not defaulted. See docs/statistics/method-conditions/scaling-and-offsets.md."
},
"tmm_ref_column": {
"type": ["string", "null"],
"default": null,
"description": "RSS/TMM: the sample every other sample's log-ratios are taken against. Null chooses it the way edgeR chooses it (the sample whose upper-quartile-scaled counts are closest to the mean of those values) and records which one. An unknown name is refused at run time, never silently replaced by the data-driven choice. Used only by normalization.method='rss'."
},
"tmm_log_ratio_trim": {
"type": "number",
"minimum": 0,
"exclusiveMaximum": 0.5,
"default": 0.3,
"description": "RSS/TMM: fraction trimmed from each tail of the log-ratios before the weighted mean; edgeR's default is 0.3. Zero disables the trimming, which removes the robustness TMM is used for — both values are recorded, so an untrimmed run is visible rather than assumed. Used only by normalization.method='rss'."
},
"tmm_sum_trim": {
"type": "number",
"minimum": 0,
"exclusiveMaximum": 0.5,
"default": 0.05,
"description": "RSS/TMM: fraction trimmed from each tail of the mean abundances before the weighted mean; edgeR's default is 0.05. Used only by normalization.method='rss'."
}
},
"allOf": [
Expand Down
11 changes: 8 additions & 3 deletions config/templates/analysis_config_chora.deed
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
:zero-policy "pseudocount"
:ilr-basis ""
:multiplicative-replacement-delta 0
:tss-css-rss-note "TSS/CSS/RSS deferred alias to relative, see GitHub issue 01")
:tss-css-rss-note ""
:css-quantile 0.75
:tmm-ref-column ""
:tmm-log-ratio-trim 0.3
:tmm-sum-trim 0.05)

(correction
:method "BH"
Expand Down Expand Up @@ -69,7 +73,7 @@
:method "Analysis method — explicit, no auto-selection v1 nb_glm clr_lm ilr_lm logistic. See JSON schema for scientific context and GitHub issues for deferred multinomial/occupancy/ordination."
:formula "R-style formula, e.g. '~ group' or 'disease ~ group + batch'. Must reference only metadata_columns. Forbidden ; backtick dollar. See ValidFormula Nickel contract."
:correction "BH mandatory in v1. Any override triggers DANGER banner and requires acknowledgment token I_UNDERSTAND_THE_RISK_AND_WANT_TO_OVERRIDE_BH. Scary banner for paper writers."
:normalization "Normalization must be compatible with method: nb_glm allows none/rarefy/size_factors/relative/TSS/CSS/RSS (TSS/CSS/RSS deferred alias), clr_lm requires clr, ilr_lm requires ilr. Pseudocount/epsilon/zero_policy heavy validation."
:normalization "Normalization must be compatible with method: nb_glm allows none/rarefy/size_factors/relative/tss/css/rss, clr_lm requires clr, ilr_lm requires ilr. tss/css/rss are exact offsets (tss = log library size, css = cumulative sum at css-quantile, rss = trimmed mean of log-ratios to a reference sample) and are refused for non-count responses; see docs/statistics/method-conditions/scaling-and-offsets.md. None of them removes the compositional constraint. Pseudocount/epsilon/zero_policy heavy validation."
:advanced "All advanced options behind Advanced Analysis expander, hidden unless Evidence Mode enabled. Heavy validation, refusal of meaningless inputs, warnings for custom pseudocount/epsilon/zero_policy/min_prevalence/max_features/min_samples_per_group."))

;; SPDX-License-Identifier: AGPL-3.0-only
Expand All @@ -81,4 +85,5 @@
;; ;; ║ Config ID: 00000000-0000-0000-0000-000000000000 ║
;; ;; ║ If you are writing a paper, you MUST disclose these overrides in Methods║
;; ;; ╚════════════════════════════════════════════════════════════════════════════╝
;; Deferred features: TSS/CSS/RSS offsets, multinomial/DM, occupancy, constrained ordinations, ILR basis phylogenetic/SBP, glmGamPoi/Bayesian multiplicative — see docs/issues/milestone3/
;; Deferred features: multinomial/DM, occupancy, constrained ordinations, ILR basis phylogenetic/SBP, glmGamPoi/Bayesian multiplicative — see docs/issues/milestone3/
;; Implemented since this template was written: TSS/CSS/RSS offsets (issue #16), src/analysis/scaling.jl.
Loading
Loading