<!-- Logo: assets/met-al_logo.png -->
<p align="center">
  <img src="assets/met-al_logo.png" alt="MET-AL" width="360" />
</p>

# MET-AL — Consolidation

This document records the `integration`-branch consolidation of the seven MET-AL
experiments: a shared verification-math library, a single-file inliner, the
double-clickable `dist/*.html` builds, the per-app de-dup decisions, and the
data/logo trimming. It is a companion to the branded gallery at
[`index.html`](index.html) and the experiment log in
[`docs/EXPERIMENTS.md`](docs/EXPERIMENTS.md) /
[`docs/reports/EXPERIMENT_REVIEW.md`](docs/reports/EXPERIMENT_REVIEW.md).

Everything stays **dependency-free / offline**: vanilla HTML/CSS/JS ES modules,
no build tool, no `npm`/`pip install`, no CDN, no network. `node` is used only for
the inliner and checks.

---

## 1. The shared library — `lib/met-stats.mjs`

`lib/met-stats.mjs` is the **canonical, pure, DOM-free, dependency-free** MET-style
verification math, reconciled from the seven apps. It is the single source of truth
that the migrated apps import instead of carrying their own copies.

### Conventions

**2×2 contingency cell** (matches MET docs):

| symbol | meaning |
|--------|---------|
| `a` | hits (forecast yes, observed yes) |
| `b` | false alarms (forecast yes, observed no) |
| `c` | misses (forecast no, observed yes) |
| `d` | correct negatives (forecast no, observed no) |
| `n` | `a + b + c + d` |

**SL1L2 partial-sum line** (MET's scalar partial-sum line for continuous stats)
carries the **means** `{fbar, obar, ffbar, oobar, fobar}` over its `n` pairs, plus
`n` — exactly as MET writes them. Storing means+`n` (not raw sums) matches MET
`.stat` output and lets the metric be re-weighted by `n` on aggregation.

**The fidelity rule (the round-1 lesson):** aggregating across regions / cases /
time means **SUM the raw counts (categorical) or the SL1L2 partial sums
(continuous) FIRST, then DERIVE the metric** from the combined totals —
"**ratio-of-sums**". Averaging per-group derived statistics ("mean-of-ratios") is
WRONG; `wrongMeanOfRatios()` exists purely to demonstrate the gap on screen.

**NaN-safety:** every metric guards a zero (or ~zero) denominator by returning
`NaN` (never throws, never returns `Infinity`). UIs render `NaN` as a visible
em-dash via `fmt()`.

### API surface

**Categorical (2×2 → number),** each takes a `{a,b,c,d}` cell:
`baseRate`, `fcstRate`, `podRate`, `far`, `pofd`, `sr`, `csi`, `fbias`, `gss`
(GSS/ETS), `hss`, `pss` (PSS/HK), `orss`.
`categoricalFromCTC(cell)` returns the **full bundle** (`a,b,c,d,n` + all of the
above) so the values can never drift apart.

**Continuous from an SL1L2 line** `{fbar,obar,ffbar,oobar,fobar,n}`:
`me`, `mse`, `rmse`, `bcrmseFromSL1L2`, `pearsonFromSL1L2`,
`continuousFromSL1L2(ps)` (full bundle; **MAE is NaN** here — not recoverable from
SL1L2). `sl1l2FromPairs(f, o)` builds a line from matched arrays.

**Continuous direct-from-pairs** (independent path; supplies MAE):
`mePairs`, `maePairs`, `msePairs`, `rmsePairs`, `bcrmsePairs`, `pearsonPairs`,
`continuousFromPairs(f, o)` (full bundle incl. MAE).

**Aggregation (ratio-of-sums, the correct path):**
`sumCTC(list)`, `sumSL1L2(list)` (n-weighted pool of the means),
`aggregateCategorical(list, metricFn)`, `aggregateContinuous(list, metricFn)`,
and the teaching demonstrator `wrongMeanOfRatios(list, metricFn)`.

**Formatting:** `fmt(x, digits=3)` → `NaN`/null ⇒ `—`, `±Infinity` ⇒ `∞`,
else `toFixed(digits)`.

### Self-test

`lib/selftest.mjs` is a standalone, dependency-free harness:

```
node lib/selftest.mjs        # -> "met-stats self-check: 129/129 pass"
```

It checks the categorical and continuous formulas against hand-worked cells,
the SL1L2 ⇄ pairs equivalence, NaN guards on zero denominators, and the
ratio-of-sums ≠ mean-of-ratios invariant.

---

## 2. The single-file inliner — `tools/inline.mjs`

`tools/inline.mjs` turns a served ES-module app into **one self-contained
`.html`** that runs by double-click (`file://`) offline. It solves the round-1
cross-cutting finding: Chromium/Safari block ES-module imports over `file://`
(`origin: null` CORS), so the served `apps/<name>/index.html` only work over HTTP.

### Usage

```
node tools/inline.mjs apps/<name>/index.html dist/<name>.html
```

It prints the byte size + module count, an offline-data-fallback note, and ends
with `inline: OK — output is self-contained` (exit 0) or lists self-containment
problems (exit 1).

### How it works

1. From each `<script type="module" src=…>` entry, **recursively resolve the local
   module graph** by parsing relative `import … from './x.mjs'`,
   `export … from './y.mjs'`, bare side-effect `import './z.mjs'`, and dynamic
   `import('./w.mjs')` specifiers. The shared `lib/met-stats.mjs` is followed into
   the graph and inlined like any other module.
2. Assign each module a **bare specifier key** (`metal:0`, `metal:1`, …) and rewrite
   **every** relative specifier in **every** module to its bare key. Bare specifiers
   resolve via the page's import map regardless of the importing module's own base
   URL — which is what makes deeply nested imports work under `file://` (relative
   specifiers would otherwise resolve against a `data:` URL base and break).
3. Encode each rewritten module as `data:text/javascript;base64,…` in **one**
   `<script type="importmap">`, with a bootstrap `<script type="module">import
   "metal:<entry>";</script>`. Inline module blocks keep their code but get their
   relative imports rewritten too.
4. Inline `<link rel="stylesheet" href="local.css">` as `<style>…</style>`.
5. Inline local `<img src="…">` (e.g. the logo) as `data:image/…;base64`.

**Runtime data fetches** (`fetch('./data/x.json')`) are **left alone** — under
`file://` they fail, and the app is expected to fall back to an **inlined `.js`/`.mjs`
data module** that is already in the module graph (and thus inlined). Apps without
such a fallback are flagged on stderr. As part of this consolidation, the apps that
lacked one (`spatial-maps`, `stat-interaction`) gained a `*-inline` ES-module data
fallback wired into a `fetch → fallback` path, so the single-file builds genuinely
run offline.

### Built-in verification (every build)

The inliner self-checks the emitted HTML: **no** `<script src=>`, **no** external
`<link href=>` (only `data:`), **no** raw relative `import/from` outside `data:`
payloads, and **no** `http(s)://` outside base64. It also validates the importmap is
JSON and that every decoded module references only bare `metal:` keys. A separate,
central, sequential visual pass handles real browser verification (concurrent browser
use is deliberately avoided here).

---

## 3. The `dist/` builds

`dist/<name>.html` is the **double-clickable** single-file build of each app —
fully inlined, offline, no server, no network. (`dist/` is git-ignored for the
working tree but the builds are committed on `integration` as the shipped artifact.)

| app | dist file | size | self-contained |
|-----|-----------|------|----------------|
| client-side-wrapper | `dist/client-side-wrapper.html` | ~500 KB | ✅ |
| stat-interaction | `dist/stat-interaction.html` | ~1011 KB | ✅ |
| novel-plotting | `dist/novel-plotting.html` | ~441 KB | ✅ |
| modernization | `dist/modernization.html` | ~800 KB | ✅ |
| spatial-maps | `dist/spatial-maps.html` | ~456 KB | ✅ |
| ensemble-verification | `dist/ensemble-verification.html` | ~588 KB | ✅ |
| volumetric-3d | `dist/volumetric-3d.html` | ~405 KB | ✅ |

All seven pass the static self-containment scan (no external `src`/`href`, no raw
relative import/from, no `http(s)://` outside base64) and decode-and-`node --check`
of every inlined module. Where present, the 230 KB shared logo PNG (base64-inlined)
is the dominant contributor to file size.

To rebuild a single app:

```
node tools/inline.mjs apps/<name>/index.html dist/<name>.html
```

---

## 4. De-duped vs left standalone (and why)

**All seven apps now share `lib/met-stats.mjs`.** Four were migrated in the first
pass (local stat kernels deleted, replaced by thin adapters that preserve each app's
exact public API). A later pass added the remaining three apps' app-specific math to
the lib — ensemble RHIST / CRPS / spread-skill / reliability + SPREAD, the shared
Brier-score (Murphy) decomposition `brierDecompFromBins`, and the SAE-based
`maeFromSums` — and repointed them too. The **only** code deliberately kept local is
stat-interaction's *categorical* derivation: it uses a `Math.max(1, denom)`
degenerate-denominator convention that differs from the lib's NaN-safety, so
repointing it would change near-empty-cell values; its *continuous* path now uses the
lib.

| app | status | why |
|-----|--------|-----|
| **client-side-wrapper** | ✅ migrated | imports categorical/continuous kernels from `lib`; kept only app-specific helpers (`meanFcst`, `meanObs`, `csiFromSrPod`, `reliabilityBins`). |
| **novel-plotting** | ✅ migrated | imports `categoricalFromCTC` / `continuousFromPairs` / `fmt`; thin adapters preserve snake_case keys (`base_rate`, `fcst_rate`, `mean_f`, `mean_o`). |
| **spatial-maps** | ✅ migrated | imports `me`/`mse`/`bcrmseFromSL1L2`/`pearsonFromSL1L2`/`categoricalFromCTC`/`sumCTC`; redundant local SL1L2 derivation deleted; public export surface kept so `app.mjs` is unchanged. |
| **volumetric-3d** | ✅ migrated | `met.mjs` now validates the shared lib (categorical via `{a,b,c,d}`, ME/RMSE via SL1L2); aggregation-honesty invariant intact. |
| **stat-interaction** | ✅ CNT migrated · ⏸ CTS local | Continuous path (`deriveCNTMetric`: ME/RMSE/MAE/PR_CORR) now derives via the lib (`me`/`rmse`/`pearsonFromSL1L2` + the new `maeFromSums` for the SAE-pooled MAE). Categorical path kept local **by design** (its `Math.max(1,denom)` guard ≠ lib NaN-safety). CNT path differential-checked identical through `seriesByGroup` on the real data. |
| **modernization** | ✅ migrated | `cnt()` → lib `me`/`mse`/`rmse`; `cts()` → `categoricalFromCTC`; `reliability()` → the shared `brierDecompFromBins` (which now also yields UNC / BS / BSS the local version lacked). Differential-checked **0 diffs** across every `sl1l2`/`ctc`/`pstd` row. |
| **ensemble-verification** | ✅ migrated | RHIST (`rankOne`/`rankHistogram`/`classifyRankHist`), CRPS, spread-skill, reliability+Brier, ensemble→contingency, and SPREAD now live in the lib; the six `src/metrics/*.js` modules + `stddev` are thin adapters. App self-test **34/34** through the adapters; **30,094** random differential assertions vs the original modules identical. |

### Migration verification (non-regression)

Each migration was proven non-regressive against a **verbatim copy of the original
local math**, on the **real committed data**, bit-for-bit (`Object.is` / NaN-equal,
tol ≤ 1e-12):

- **client-side-wrapper** — 2574 OLD-vs-NEW numeric checks (6 slices × 7 thresholds ×
  5 operators × 12 categorical + 6 continuous stats + empty-slice edges), **0
  mismatches**; plus a 1535-check lib-equivalence pass and an end-to-end pipeline run.
- **novel-plotting** — 12,740 assertions over all committed data; **12,723/12,723
  visual-path assertions byte-identical**. The only 17 diffs are in `continuous()`
  (max **2.75e-14**, FP summation-order), which feeds **no visual** — 10 orders below
  the 4-dp display, so zero rendered numbers change.
- **spatial-maps** — 287-check app-vs-lib + 445-check non-regression vs original
  formulas, all bit-for-bit; head-less selfTest **28/28**; headline CONUS RMSE
  **2.4025** preserved exactly.
- **volumetric-3d** — 2592 per-cell metric evals + 936 ratio-of-sums aggregation
  evals vs original definitions, **0 mismatches** (pre- and post-trim).

**Round-3 migration (ensemble-verification / modernization / stat-interaction).** The
three formerly-standalone apps were repointed after their math was added to the lib:

- lib self-test grew **75 → 118 checks** (all pass); the new vectors were computed by
  running the original app code, so they prove behavior is *preserved*, not just plausible.
- **30,094** random differential assertions (lib fns vs the original ensemble modules
  on ~14.8k random inputs) — identical to ≤ 1e-12, including the seeded tie-break path.
- **4,564** OLD-vs-NEW assertions over the **real embedded data** (modernization
  `cnt`/`cts`/`reliability` per row; stat-interaction CNT path via `seriesByGroup`) —
  identical; this also confirmed each PSTD row's bin counts sum to `TOTAL`, so the
  REL/RES divisor is unchanged.
- ensemble-verification's own `src/selftest.js` — **34/34** through the new adapters.
- all three `dist/*.html` browser-verified from a served build: clean console (only a
  benign `favicon.ico` 404 / the documented offline fetch-fallback), with RHIST / CRPS /
  spread-skill, PSTD reliability+Brier, and CNT metric-vs-lead all rendering correctly.
- **one intentional behavior change:** BSS is now `NaN` (→ em-dash) when UNC = 0 (base
  rate 0 or 1), per the lib's NaN-safety convention (the old code returned `0`).

---

## 5. Data & logo trimming

Goal: shrink committed weight **without changing any rendered number**. Lossy
precision cuts that would perturb a verified headline statistic were **rejected**;
redundant duplicate mirrors were removed where safe.

| app | trim | result |
|-----|------|--------|
| **client-side-wrapper** | rows 3600→1200, `ROWS_PER_CELL` 45→15; CSV 200K→68K, embedded.js 204K→72K | 410,764 → 138,984 bytes; regenerated deterministically (same seed, identical md5); all 80 cells / 4 models / 5 leads / 4 regions preserved. |
| **stat-interaction** | CI display precision 4dp→3dp; classic `<script>` global data → ES module `stat_inline.mjs` | committed data −20,120 bytes (~4.2%). Raw aggregables byte-identical; all 9 metrics across 2100 rows match exactly (only CI display bounds change, max 5e-4, never an input to a metric). |
| **modernization** | dropped the redundant `verification.json` full mirror (292,099 bytes) — only the `.js` module is loaded | committed data ~halved (~571 KB → ~286 KB). Source labels/README updated to keep references honest; dev generator still emits the JSON locally (now uncommitted). |
| **ensemble-verification** | minor regeneration | 147,761 → 144,086 bytes (−2.5%); 720 records / 20 members retained; value-hash identical; headline stats bit-identical. A 1-decimal cut was **rejected** (it changed RMSE/BSS/χ²). |
| **volumetric-3d** | SL1L2 floats 5dp→3dp (counts stay integer); inline mirror regenerated | JSON 14,136 → 11,959 bytes; categorical metrics unaffected (count-based); ME/RMSE display change < 2e-3. |
| **novel-plotting** | none | the inliner leaves runtime `fetch('./data/*.json')` alone and the single file runs on the in-JS `data.js` fallback, so trimming the JSON yields **zero** dist benefit; the only material lever (float-precision) would perturb rendered Brier/scrubber numbers. Left unchanged. |
| **spatial-maps** | none | `field.json` (28 KB) was already lean (single-line, only `o[]`+`err[]` with `f` derived, no mirror, 2dp). Dropping to 1dp would change the documented CONUS RMSE 2.4025→2.4059 — forbidden. |

**Logo.** The top-level `assets/met-al_logo.png` (**230 KB**, 720×480; trimmed from the
~1 MB `assets/met-al_logo_full.png`) is kept full-res — it serves the README at
`width=520` and the gallery hero. But each app inlines its **own** copy into
`dist/*.html`, where the logo never displays larger than ~74 px. So the seven
`apps/<name>/assets/met-al_logo.png` copies were downscaled once (offline, `sips`,
no new deps) to **360×240 (~51 KB)** — crisp to 3× DPI at that display size. Because
base64 inflates ×4/3, this trimmed every build ~205–227 KB: the **dist total went
4.21 MB → 2.68 MB (−36%)** with no visible change (verified in-browser). The top-level
full-res copy is untouched.

---

## 6. Repo-wide checks (at consolidation)

- **`node --check`** on every `.mjs`/`.js` under `lib/`, `tools/`, `apps/` — **81/81
  pass**.
- **`lib/selftest.mjs`** — **129/129 pass** (118 consolidation + 11 vector-wind VL1L2→VCNT).
- **Single-file `dist/` total** — **4.21 MB → 2.68 MB (−36%)** after the Round-3
  shared-lib migration + per-app logo downscale; all 7 re-pass the self-containment scan.
- **`http(s)://` scan** across shipped code (`apps/`, `lib/`, `tools/`, `*.html`),
  excluding base64 `data:` payloads and markdown — **0 real external dependencies**.
  The only matches are 3 inside `//`/`/* */` comments and the mandatory W3C SVG
  namespace URI `http://www.w3.org/2000/svg` (required by `createElementNS`, not a
  network fetch).
- **All 7 `dist/*.html`** pass the static self-containment scan (no external
  `src`/`href`, no raw relative `import/from`, no `http(s)://` outside base64) and
  decode-and-`node --check` of every inlined module.

---

## Map of the consolidation

```
index.html                 branded gallery (this consolidation's landing page)
CONSOLIDATION.md           this document
lib/met-stats.mjs          canonical MET-style verification math (shared by all 7 apps)
lib/selftest.mjs           standalone 129-check self-test
tools/inline.mjs           single-file builder (served app -> dist/*.html)
dist/*.html                seven double-clickable single-file builds (2.68 MB total)
apps/<name>/               seven served ES-module source apps
assets/met-al_logo.png     full-res featured logo (230 KB; app copies downscaled to 51 KB)
docs/EXPERIMENTS.md        experiment tracker
docs/reports/              EXPERIMENT_REVIEW.md + screenshots
```
