Your data never leaves the browser — and CI proves it on every commit.
A fleet of local-only, single-signal physiological analyzers. No upload. No accounts. No network.
One signal in, honest numbers out. Each analyzer reads one raw biosignal from a consumer device, derives metrics from it, and reports inward over a shared event bus (Ganglior) so a fusion layer (the Integrator) can read across them. Every number carries an evidence grade, so you always know which kind of number you're looking at. And "100% client-side" isn't a promise — it's a test: a headless privacy gate boots every shipped surface and fails the build if anything so much as reaches for the network.
Suite version: 2.5.0 — 31 ledger-backed releases, each computed from a green tree.
| 🧠 Analyzers | 8 live single-signal nodes · 1 planned (EEGDex) |
| 🔀 Fusion | Integrator (cross-signal) + 2 ingest front doors — Data Unifier · OverDex — all live |
| 🧪 Tests | 7,333 assertions across 470 groups, run as 6 partitioned CI shards — green on every push |
| 🐍 Capture lane | the Health Box (capture-host/) — 3,200+ Python tests at a 100 % statement and branch coverage floor |
| 🛡️ CI gates | no-network · tests · types · biome · CodeQL · capture-host — the privacy claim is enforced, not asserted |
| 🎫 Honesty | every surfaced number carries an evidence grade — nothing ships unlabelled |
| 🔬 Reproducible | every preprint regenerates from its live tool; every bundle is content-addressed |
Roadmap ▰▰▰▰▰▰▰▰▱ 8 of 9 planned analyzers live — EEGDex (Muse) is next.
Momentum.
v2.0.0is the suite's only MAJOR release, and the break is a retirement: ECGDex'sapnea.estimatedAHIandapnea.riskCategorywere the CVHR index wearing AHI's units and clinical cut-points — measured at r = −0.151 against device-scored AHI — so they were withdrawn rather than relabelled. It also closed a cross-device timing gap nobody had measured: the two wearables were never compared to each other and turn out to sit ~3.3 s apart on every phone-captured night.The
2.1 → 2.5line has been about finding out which gates were bluffing.tools/mutate.mjsbreaks the code on purpose and reports which tests fail to notice — its first sweep found that 41 % of mutations toclock.js, the Clock Contract itself, went unnoticed, and closing that is ongoing work with published numbers rather than a claim. Alongside it, the host-disciplined time axis (Clock Contract §7) reconciles the device crystal against the capture host's clock, and it refuses rather than guesses: an O2Ring axis that turned out to be drawn — synthesized from a sample index, not a clock — is now detected and declared as such instead of being silently trusted. Full history in the changelog.
| Node | Signal | Device | Status | Flagship metric |
|---|---|---|---|---|
| OxyDex | SpO₂ / oximetry | O2Ring · Wellue · ViATOM | 🟢 Live | ODI-4 · T90 · hypoxic burden |
| ECGDex | Raw ECG | Polar H10 | 🟢 Live | QTc · rMSSD |
| PulseDex | HRV from raw RR | Polar H10 · Coospo · Wahoo | 🟢 Live | rMSSD · SDNN |
| PpgDex | Wrist PPG | Polar Verity Sense | 🟢 Live | PPI → HRV + pulse-wave morphology |
| HRVDex | HRV summaries | (exports) | 🟢 Live | additive multi-day rMSSD / SDNN ledger |
| GlucoDex | CGM | (exports) | 🟢 Live | Time in Range · GMI |
| CPAPDex | CPAP therapy | ResMed · EDF | 🟢 Live | pressure · leak · respiratory events |
| MotionDex | Inertial motion (IMU) | Polar Verity Sense · H10 (*_ACC/_GYRO/_MAGN) |
🟢 Live | body position · actigraphy · motion SQI |
| EEGDex | EEG | Muse | ⚪ Planned | — |
git clone https://github.com/Plantucha/Tepna.gitThen open index.html in any modern browser and click your device. No build step, no server, no
install — every node app is a single self-contained HTML file that runs straight from disk.
💡 Why that's remarkable: an entire overnight sleep-apnea screen, an ECG QT analysis, or a CGM report runs from a file on your disk — the raw biosignal is parsed, the math is done, and the figures are drawn, all without a single byte leaving the tab.
Most health apps say your data is private. Tepna proves it, mechanically, on every commit.
no-network.html is a self-verifying gate — the privacy analogue of a build
signature. It statically scans every shipped surface (the 9 provenance-gated bundles + the 2
orchestrators + their loose modules), boots each one in a trapped iframe where any cross-origin
request throws, and asserts zero network egress. A planted-canary negative control ships inside
the gate, so a vacuous "all clear" can never pass. It runs headless in CI on every push.
| CI gate | What it guarantees |
|---|---|
| no-network | No shipped surface reaches the network — privacy is enforced, not asserted |
| tests | 7,333 assertions over the real DSP modules, run as 6 parallel shards that provably partition the suite — so the union of the shards is the whole gate, and no group can quietly go unrun |
| types | JSDoc type-checking (tsc --checkJs) across the signal contracts |
| biome | Format + house-invariant lint (frozen names, Clock Contract, SPDX, evidence vocabulary) |
| CodeQL | Static security analysis on every push |
| capture-host | The Python capture lane on its own runners — ruff · shellcheck (strictest level, every .sh) · pytest at a 100 % statement and branch coverage floor |
Render-coverage (booting each real app bundle in an iframe and asserting computed values reach the
DOM) is a heavier browser gate — it lives in Dex-Test-Suite.html?full and the manual
browser-gates workflow, not on the per-push path.
Beyond CI, verify-provenance.html content-addresses every bundle
(GATE A: code identity) and every fixture (GATE B: known-answer reproducibility) — so a shipped app
is provably the code it claims to be.
Browser-enforced, too. Every bundle ships a strict Content-Security-Policy. connect-src 'none'
blocks egress at the browser layer, and script-src lists a per-inline-script sha256 hash with
no 'unsafe-inline' — so even a <script> injected into the DOM (a future untrusted→HTML
regression) cannot execute. CSP is an injection backstop, not just an egress control; a headless
negative-control test asserts an injected script stays inert, and the security · csp-strict gate
holds the invariant (no inline handler survives, every script-src is hash-based).
Tepna derives a large surface of metrics, but they are not all equally trustworthy — and we say so on every number. The honest front door is this short list of externally validated metrics, each anchored to a published standard. If you read nothing else, read these.
| Metric | Signal · App | What it is | Anchor |
|---|---|---|---|
| ODI-4 | SpO₂ · OxyDex | 4% oxygen-desaturation index — apnea-screening workhorse | AASM |
| T90 | SpO₂ · OxyDex | % of night below 90% SpO₂ — hypoxia severity | Sleep-apnea literature |
| Hypoxic burden | SpO₂ · OxyDex | Area·depth·duration of desaturations | Azarbarzin 2019 |
| rMSSD | RR/ECG · PulseDex · ECGDex · HRVDex | Short-term parasympathetic HRV | Task Force 1996 |
| SDNN | RR/ECG · PulseDex · ECGDex | Overall HRV over the window | Task Force 1996 |
| QTc | ECG · ECGDex | Rate-corrected QT (Bazett / Fridericia) | Repolarisation standard |
| Time in Range | CGM · GlucoDex | % time 70–180 mg/dL — primary CGM metric | 2019 consensus |
| GMI | CGM · GlucoDex | Glucose Management Indicator from mean glucose | Bergenstal 2018 |
Everything past this list exists, but lives lower on the evidence ladder. Treat it accordingly.
Each metric carries one of five evidence tiers. The grade is shown as a disc badge in every app — disc shape encodes trust, never hue (so the ladder reads the same to colour-blind users and in greyscale print). The grade is a per-node fact, defined once in each node's registry.
| Rank | Tier | Means | Example |
|---|---|---|---|
| 0 | measured | Read directly off the device, not derived | Mean SpO₂, Mean HR |
| 1 | validated | Established + externally validated against a published standard | ODI-4, rMSSD, TIR |
| 2 | emerging | Published, but device-dependent or less standardized | Nonlinear HRV, coupling |
| 3 | experimental | Plausible, internally consistent, not externally confirmed | Composite indices |
| 4 | heuristic | Rule-of-thumb estimate — directional signal only | Sleep-derived BP estimate |
A metric's tier is sourced from its node's registry (*-registry.js) and mirrored into the
reference guides; the badge visuals come from one canonical stylesheet. A higher rank is not a
"better" metric — it's a louder one. A heuristic that points the right direction can still be
useful; the ladder just makes sure you always know which kind of number you're looking at.
| App | Signal | Device | Reads | Reference |
|---|---|---|---|---|
| OxyDex | SpO₂ / oximetry | O2Ring / Wellue / ViATOM | Raw 1 Hz overnight SpO₂·HR·motion | Technical guide |
| PpgDex | Wrist PPG | Polar Verity Sense | Raw optical pulse → PPI → HRV + pulse-wave morphology | Technical guide |
| PulseDex | HRV from raw RR | Polar H10 (*_RR.txt; Coospo/Wahoo too) |
Beat-to-beat RR-interval streams | Technical guide |
| ECGDex | Raw ECG | Polar H10 | ECG (~130 Hz, Polar Sensor Logger) | Technical guide |
| HRVDex | HRV summaries | — | Vendor HRV exports + ECGDex exports (additive, multi-day) | Technical guide |
| GlucoDex | CGM | — | Continuous glucose traces | Technical guide |
| CPAPDex | CPAP therapy | ResMed · EDF | EDF therapy data — pressure · leak · respiratory events | Technical guide |
| MotionDex | Inertial motion (IMU) | Polar Verity Sense · H10 | Accelerometer / gyro / magnetometer streams → body position, actigraphy, respiratory effort, motion SQI | (no reference guide yet) |
| EEGDex | EEG (planned) | Muse | Raw EEG | (planned) |
Each node emits a ganglior.node-export JSON that the Integrator fuses across signals. Two
ingest front-doors feed it: the Data Unifier takes individual files you drop and routes each to
the right node, and OverDex takes a whole folder of mixed exports — it walks the tree, runs the
right node on each file, and hands every result to the Integrator automatically.
HRVDex is the suite's HRV ledger. Its imports are additive: every Welltory CSV or ECGDex export (CSV or ganglior.node-export JSON, including the multi-recording array) appends to one accumulating multi-day table — drop many files or nights at once, exact-duplicate measurements are skipped, and the table is persisted in the browser between visits (clear it with Clear saved history). ECGDex's ⬇ HRVDex export writes a Welltory-style CSV with all loaded nights in one file, so a whole H10 history lands in HRVDex in a single drop.
MotionDex is live but not yet fully surfaced. It has its own bundle, its own DSP/registry modules and a provenance ledger fragment, and the Integrator consumes its output to motion-gate HRV — but it has no reference guide yet and no tile on the landing picker. Both are open work.
The suite reads files. Something has to write them, and for the nightly multi-device recordings
that is capture-host/ — an out-of-suite Python service for a bedside Raspberry Pi that holds
the live BLE links (Polar H10 ECG, Verity PPG/ACC) and writes existing vendor layouts into
per-night directories the Dex apps already know how to read. It is a producer, deliberately
outside the browser suite: no Dex app ever talks to a device.
It is also a separate lane with a separate gate. npm run check does not cover it —
capture-host/check.sh does, and CI runs the same three jobs: ruff, shellcheck at its strictest
level over every shell script, and pytest with a 100 % statement and branch coverage floor
across 3,200+ tests. That floor is the point: this code runs unattended overnight against
hardware that misbehaves, so the failure modes it must survive are the ones nobody is awake to see.
Per-device capture instructions for everyone else — no Pi required — live in
how-to-collect/.
A calculated number is not automatically a valid measurement. The chain runs:
physical signal → acquisition → timestamping → conditioning → event detection
→ metric → cross-signal fusion → physiological interpretation
A failure at any earlier stage invalidates everything downstream, and a sophisticated algorithm cannot recover information the acquisition path never preserved. That matters most with consumer sensors, where an exported value can hide resampling, proprietary filtering, buffering, transport latency, clock drift, reconstructed samples, or an undocumented event detector.
This is not hypothetical here. ECGDex's apnea.estimatedAHI was withdrawn in v2.0.0, not
relabelled: it was a CVHR index wearing AHI's units and clinical cut-points, and it correlated with
device-scored AHI at r = −0.151. The algorithm was fine. The construct was not.
A timestamp is an observation of time, not automatically the truth — and cross-device work makes that the whole problem. Two devices can each report milliseconds and still not share a millisecond.
Measured on this corpus:
| H10 ↔ Verity offset, phone-captured nights | ~3.3 s |
| the same pair, box-captured nights | ~0.2 s |
| the capture host's own clock | 0.008 ppm |
| an O2Ring crystal, worst case | −3035 ppm, decaying to −1622 — non-linear, so a single ppm figure is the wrong model, not merely an imprecise one |
Hence DexClock.hostAxis: a running median over host/device anchor pairs, which refuses rather than
guessing when it has fewer than three anchors or the divergence exceeds 5 %. A node may not hand-roll a
rate correction, and a refusal returns no correction at all — a caller must not be able to apply a
silent zero.
Two sensors agreeing is evidence of physiological truth only if they are independent. They may instead share a clock, an artifact, a preprocessing step, or a selection rule.
The suite has a measured instance. Whether a recording contains a second clock at all is decided by the spread of host-vs-device residuals, not by the rate — and the corpus is bimodal with nothing in between:
| capture | residual spread | meaning |
|---|---|---|
| box | 101.89 ms – 5124 ms | two genuinely independent clocks |
| phone | 0.13 – 1.00 ms | one stamp quantum — the "host" column is the device stamp, rounded |
A phone-captured night therefore has no second clock, and a near-zero drift there is the absence
of a measurement wearing the shape of one. It also explains the ~3.3 s vs ~0.2 s split in the previous
section: only the box actually puts both devices on one timebase, so only there is the offset a
measurement rather than an artifact of shared derivation. hostAxis publishes independent so a
consumer cannot mistake the two.
The suite is built to attack its own attractive results, and a useful failure is kept as a regression test rather than deleted.
- The privacy gate ships a planted-canary negative control, so a vacuous "all clear" cannot pass.
- A packet-arrival check had two arms; the
smearedarm was retired for firing on every stream on its first real night. The premise was wrong, not the threshold. Its surviving arm was only wired in after a corpus check across 355 sessions returned zero false positives. - Fixtures prefer an adversarial committed twin over a real recording, because CI can re-run the twin from committed bytes and cannot re-run a gitignored night.
The questions it asks of itself: can correlated artifacts manufacture multimodal agreement? Can a host-derived clock validate itself? Can quality filtering select only the observations that support the desired relationship? Can a synthetic test share an assumption with the algorithm it is testing?
Every non-trivial number traces to a published method, and each of the 21 working preprints in
papers/ is regenerated from the live tool behind it — no hand-drawn
figures, no cherry-picked runs. Highlights:
- One phone is not one clock — a single-subject methods study measuring ~48 ppm inter-device timing drift across 11 nights (145k beats), showing why beat-level fusion of two consumer wearables needs a single acquisition clock.
- Dead ends — a synthesis of the walls the suite hit and why: fixed, flagged, or fundamental. Negative results, reported honestly.
- Sensor-trio power & σ analyses, rMSSD equivalence, ODI-4 calibration, timestamp pathology — each with its live regenerating tool.
The methods overview is Science.html and the system design is
Architecture.html.
Most people never need this: open index.html and click your device. For anyone reading the
source, here's the lay of the land.
| You want… | Look at |
|---|---|
| A map of every doc | DOCS-INDEX.md — the single entry path over all ~400 briefs, audits, and READMEs, grouped by topic. Start here before opening any individual brief. |
| The app for your device | the bundled OxyDex.html, PpgDex.html, PulseDex.html, ECGDex.html, HRVDex.html, GlucoDex.html, CPAPDex.html, MotionDex.html — open them directly. Integrator.html fuses them all; Data Unifier.html routes any file you drop to the right node, and OverDex.html runs a whole folder at once. |
| The front door | index.html (the landing/device picker) — the page normal people start from. |
| How an app is built | its source modules — <node>-dsp.js (signal math), -render.js, -app.js, -registry.js — plus <App>.src.html. Edit these; never the bundled *.html, then re-bundle. |
| Shared engine | metric-registry.js (evidence badges), ganglior-provenance.js, ans-design.css. |
| Research tools | *-analysis.html — the live tool behind each paper — plus cohort-*.html, synth-gen.html. Gates: Dex-Test-Suite.html, verify-provenance.html. |
| The papers | papers/ — 21 working preprints, each regenerated from its tool. |
| The capture service | capture-host/ — the Health Box Python lane (its own gate: capture-host/check.sh). Device-by-device capture instructions: how-to-collect/. |
| Testing the tests | tools/mutate.mjs — breaks the code on purpose and reports which gates fail to notice. The honest measure of whether a green suite means anything. |
| Architecture docs | wiring/ — the "How It's Wired" reference set; start at wiring/How It's Wired - the Dex Suite.html. |
| Design system | templates/ — visual-language spec, theme + evidence-badge previews. |
| House rules | CLAUDE.md (constitution) · CONTRIBUTING.md (on-ramp) · ARCHITECTURE-PRINCIPLES.md · docs/LEXICON.md. |
The eight node registries define 404 graded metrics. That number describes surface area, not confidence — which is exactly why the suite publishes the breakdown rather than the headline:
| Tier | Count | Share |
|---|---|---|
| measured — read off the device | 118 | 29 % |
| validated — anchored to a published standard | 84 | 21 % |
| emerging — published, device-dependent | 78 | 19 % |
| experimental — internally consistent, not externally confirmed | 96 | 24 % |
| heuristic — directional only | 28 | 7 % |
So roughly half the surface is measured or validated, and just under a third sits at
experimental or heuristic. A count of 404 would be a boast; the distribution is the honest
version of the same fact, and it is why the ladder leads and the count follows.
For the why behind Tepna — the philosophy, the constraints, and the jokes — see
docs/WHY-THIS-EXISTS.md (styled mirror: Why This Exists.html). It is
narrative, not spec: it defines no formats, APIs, or behavior, and nothing in it should be read
as an instruction to implement. Authoritative rules live in CLAUDE.md, this README, and the
*-BRIEF.md documents.
Apache-2.0. Author: Michal Planicka. Product brand: Tepna. See LICENSE, NOTICE,
CITATION.cff, and THIRD-PARTY.md. User-facing surfaces carry the health intended-use
disclaimer — Tepna is not a medical device and does not diagnose, treat, or monitor any
condition.