diff --git a/README.md b/README.md
index 12b5c3c4e..2ba3362c2 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Most vector databases are static β they store embeddings and search them. That
**One package. Everything included:** vector search, graph queries, GNN learning, distributed clustering, local LLMs, 46 attention mechanisms, cognitive containers ([RVF](./crates/rvf/README.md) β self-booting `.rvf` files with eBPF, witness chains, and COW branching), and WASM support.
-π See Full Capabilities (49 features)
+π See Full Capabilities (51 features)
**Core Vector Database**
| # | Capability | What It Does |
@@ -100,23 +100,25 @@ Most vector databases are static β they store embeddings and search them. That
| 38 | **`.rvdna` file format** | AI-native binary with pre-computed vectors, tensors, and embeddings |
| 39 | **Instant diagnostics** | Sickle cell, cancer mutations, drug dosing β runs on any device |
| 40 | **Privacy-first WASM** | Browser-based genomics, data never leaves the device |
+| 41 | **Health biomarker engine** | Composite polygenic risk scoring (17 SNPs, 6 gene-gene interactions, 2 us) |
+| 42 | **Streaming biomarkers** | Real-time anomaly detection, CUSUM changepoints, trend analysis (>100k readings/sec) |
**Platform & Integration**
| # | Capability | What It Does |
|---|------------|--------------|
-| 41 | **Run anywhere** | Node.js, browser (WASM), edge (rvLite), HTTP server, Rust, bare metal |
-| 42 | **Drop into Postgres** | pgvector-compatible extension with SIMD acceleration |
-| 43 | **MCP integration** | Model Context Protocol server for AI assistant tools |
-| 44 | **Cloud deployment** | One-click deploy to Cloud Run, Kubernetes |
-| 45 | **13 Rust crates + 4 npm packages** | [RVF SDK](./crates/rvf/README.md) published on [crates.io](https://crates.io/crates/rvf-runtime) and [npm](https://www.npmjs.com/package/@ruvector/rvf) |
+| 43 | **Run anywhere** | Node.js, browser (WASM), edge (rvLite), HTTP server, Rust, bare metal |
+| 44 | **Drop into Postgres** | pgvector-compatible extension with SIMD acceleration |
+| 45 | **MCP integration** | Model Context Protocol server for AI assistant tools |
+| 46 | **Cloud deployment** | One-click deploy to Cloud Run, Kubernetes |
+| 47 | **13 Rust crates + 4 npm packages** | [RVF SDK](./crates/rvf/README.md) published on [crates.io](https://crates.io/crates/rvf-runtime) and [npm](https://www.npmjs.com/package/@ruvector/rvf) |
**Self-Learning & Adaptation**
| # | Capability | What It Does |
|---|------------|--------------|
-| 46 | **Self-learning hooks** | Q-learning, neural patterns, HNSW memory |
-| 47 | **ReasoningBank** | Trajectory learning with verdict judgment |
-| 48 | **Economy system** | Tokenomics, CRDT-based distributed state |
-| 49 | **Agentic synthesis** | Multi-agent workflow composition |
+| 48 | **Self-learning hooks** | Q-learning, neural patterns, HNSW memory |
+| 49 | **ReasoningBank** | Trajectory learning with verdict judgment |
+| 50 | **Economy system** | Tokenomics, CRDT-based distributed state |
+| 51 | **Agentic synthesis** | Multi-agent workflow composition |
@@ -197,6 +199,8 @@ npm install @ruvector/rvdna # JavaScript / TypeScript
| Translate DNA to protein | Full codon table + GNN contact graphs |
| Predict biological age | Horvath clock, 353 CpG sites |
| Recommend drug doses | CYP2D6 star alleles + CPIC guidelines |
+| Score health risks | 17 SNPs, 6 gene-gene interactions, composite risk scoring in 2 us |
+| Stream biomarker data | Real-time anomaly detection, CUSUM changepoints, >100k readings/sec |
| Search genomes by similarity | HNSW k-mer vectors, O(log N) |
| Store pre-computed AI features | `.rvdna` binary format β open and instant |
diff --git a/examples/dna/Cargo.toml b/examples/dna/Cargo.toml
index e333b5294..c6fc94398 100644
--- a/examples/dna/Cargo.toml
+++ b/examples/dna/Cargo.toml
@@ -77,3 +77,7 @@ harness = false
[[bench]]
name = "solver_bench"
harness = false
+
+[[bench]]
+name = "biomarker_bench"
+harness = false
diff --git a/examples/dna/README.md b/examples/dna/README.md
index 6c21e1d33..801e71efc 100644
--- a/examples/dna/README.md
+++ b/examples/dna/README.md
@@ -39,7 +39,9 @@ Give it a DNA sequence, and it will:
4. **Translate DNA to protein** β full codon table with contact graph prediction
5. **Predict biological age** from methylation data (Horvath clock, 353 CpG sites)
6. **Recommend drug doses** based on CYP2D6 star alleles and CPIC guidelines
-7. **Save everything to `.rvdna`** β a single file with all results pre-computed
+7. **Score health risks** β composite polygenic risk scoring across 20 SNPs with gene-gene interactions
+8. **Stream biomarker data** β real-time anomaly detection, trend analysis, and CUSUM changepoint detection
+9. **Save everything to `.rvdna`** β a single file with all results pre-computed
All of this runs on 5 real human genes from NCBI RefSeq in under 15 milliseconds.
@@ -49,7 +51,7 @@ All of this runs on 5 real human genes from NCBI RefSeq in under 15 milliseconds
# Run the full 8-stage demo
cargo run --release -p rvdna
-# Run 87 tests (no mocks β real algorithms, real data)
+# Run 172 tests (no mocks β real algorithms, real data)
cargo test -p rvdna
# Run benchmarks
@@ -154,6 +156,11 @@ Measured with Criterion on real human gene data (HBB, TP53, BRCA1, CYP2D6, INS):
| 1000-position variant scan | **336 us** | Full pileup across a gene region |
| Full pipeline (1 kb) | **591 us** | K-mer + alignment + variants + protein |
| Complete 8-stage demo (5 genes) | **12 ms** | Everything including .rvdna output |
+| Composite risk score (20 SNPs) | **2.0 us** | Polygenic scoring with gene-gene interactions |
+| Profile vector encoding (64-dim) | **209 ns** | One-hot genotype + category scores, L2-normalized |
+| Synthetic population (1,000) | **6.4 ms** | Full population with Hardy-Weinberg equilibrium |
+| Stream processing (per reading) | **< 10 us** | Ring buffer + running stats + CUSUM |
+| Anomaly detection | **< 5 us** | Z-score against moving window |
### rvDNA vs Traditional Bioinformatics Tools
@@ -267,6 +274,72 @@ let ranks = ranker.rank_sequences(&sequences, 0.15, 1e-4, 0.05);
let sim = ranker.pairwise_similarity(&sequences, 0, 1, 0.15, 1e-4, 0.05);
```
+## Health Biomarker Engine
+
+The biomarker engine extends rvDNA's SNP analysis with composite risk scoring, streaming data processing, and population-scale similarity search. See [ADR-014](adr/ADR-014-health-biomarker-analysis.md) for the full architecture.
+
+### Composite Risk Scoring
+
+Aggregates 20 clinically-relevant SNPs across 4 categories (Cancer Risk, Cardiovascular, Neurological, Metabolism) into a single global risk score with gene-gene interaction modifiers. Includes LPA Lp(a) risk variants (rs10455872, rs3798220) and PCSK9 R46L protective variant (rs11591147). Weights are calibrated against published GWAS odds ratios, clinical meta-analyses, and 2024-2025 SOTA evidence.
+
+```rust
+use rvdna::biomarker::*;
+use std::collections::HashMap;
+
+let mut genotypes = HashMap::new();
+genotypes.insert("rs429358".to_string(), "CT".to_string()); // APOE e3/e4
+genotypes.insert("rs4680".to_string(), "AG".to_string()); // COMT Val/Met
+genotypes.insert("rs1801133".to_string(), "AG".to_string()); // MTHFR C677T het
+
+let profile = compute_risk_scores(&genotypes);
+println!("Global risk: {:.2}", profile.global_risk_score);
+println!("Categories: {:?}", profile.category_scores.keys().collect::>());
+println!("Profile vector (64-dim): {:?}", &profile.profile_vector[..4]);
+```
+
+**Gene-Gene Interactions** β 6 interaction terms amplify category scores when multiple risk variants co-occur:
+
+| Interaction | Modifier | Category |
+|---|---|---|
+| COMT Met/Met x OPRM1 Asp/Asp | 1.4x | Neurological |
+| MTHFR C677T x MTHFR A1298C | 1.3x | Metabolism |
+| APOE e4 x TP53 variant | 1.2x | Cancer Risk |
+| BRCA1 carrier x TP53 variant | 1.5x | Cancer Risk |
+| MTHFR A1298C x COMT variant | 1.25x | Neurological |
+| DRD2 Taq1A x COMT variant | 1.2x | Neurological |
+
+### Streaming Biomarker Simulator
+
+Real-time biomarker data processing with configurable noise, drift, and anomaly injection. Includes CUSUM changepoint detection for identifying sustained biomarker shifts.
+
+```rust
+use rvdna::biomarker_stream::*;
+
+let config = StreamConfig::default();
+let readings = generate_readings(&config, 1000, 42);
+let mut processor = StreamProcessor::new(config);
+
+for reading in &readings {
+ processor.process_reading(reading);
+}
+
+let summary = processor.summary();
+println!("Anomaly rate: {:.1}%", summary.anomaly_rate * 100.0);
+println!("Biomarkers tracked: {}", summary.biomarker_stats.len());
+```
+
+### Synthetic Population Generation
+
+Generates populations with Hardy-Weinberg equilibrium genotype frequencies and gene-correlated biomarker values (APOE e4 raises LDL/TC and lowers HDL, MTHFR elevates homocysteine and reduces B12, NQO1 null raises CRP, LPA variants elevate Lp(a), PCSK9 R46L lowers LDL/TC).
+
+```rust
+use rvdna::biomarker::*;
+
+let population = generate_synthetic_population(1000, 42);
+// Each profile has a 64-dim vector ready for HNSW indexing
+assert_eq!(population[0].profile_vector.len(), 64);
+```
+
## WebAssembly (WASM)
rvDNA compiles to WebAssembly for browser-based and edge genomic analysis. This means you can run variant calling, protein translation, and `.rvdna` file I/O directly in a web browser β no server required, no data leaves the user's device.
@@ -457,27 +530,33 @@ flowchart TB
| `pharma.rs` | 217 | CYP2D6/CYP2C19 star alleles, metabolizer phenotypes, CPIC drug recs |
| `pipeline.rs` | 495 | DAG-based orchestration of all analysis stages |
| `rvdna.rs` | 1,447 | Complete `.rvdna` format: reader, writer, 2-bit codec, sparse tensors |
+| `health.rs` | 686 | 17 clinically-relevant SNPs, APOE genotyping, MTHFR compound status, COMT/OPRM1 pain profiling |
+| `genotyping.rs` | 1,124 | End-to-end 23andMe genotyping pipeline with 7-stage processing |
+| `biomarker.rs` | 498 | 20-SNP composite polygenic risk scoring (incl. LPA, PCSK9), 64-dim profile vectors, gene-gene interactions, additive geneβbiomarker correlations, synthetic populations |
+| `biomarker_stream.rs` | 499 | Streaming biomarker simulator with ring buffer, CUSUM changepoint detection, trend analysis |
| `kmer_pagerank.rs` | 230 | K-mer graph PageRank via solver Forward Push PPR |
| `real_data.rs` | 237 | 5 real human gene sequences from NCBI RefSeq |
| `error.rs` | 54 | Error types (InvalidSequence, AlignmentError, IoError, etc.) |
| `main.rs` | 346 | 8-stage demo binary |
-**Total: 4,679 lines of source + 868 lines of tests + benchmarks**
+**Total: 7,486 lines of source + 1,426 lines of tests + benchmarks**
## Tests
-**102 tests, zero mocks.** Every test runs real algorithms on real data.
+**172 tests, zero mocks.** Every test runs real algorithms on real data.
| File | Tests | Coverage |
|---|---|---|
-| Unit tests (all `src/` modules) | 61 | Encoding roundtrips, variant calling, protein translation, RVDNA format, PageRank |
+| Unit tests (all `src/` modules) | 112 | Encoding, variant calling, protein, RVDNA format, PageRank, biomarker scoring, streaming |
+| `tests/biomarker_tests.rs` | 19 | Risk scoring, profile vectors, biomarker references, streaming, gene-gene interactions, CUSUM |
| `tests/kmer_tests.rs` | 12 | K-mer encoding, MinHash, HNSW index, similarity search |
| `tests/pipeline_tests.rs` | 17 | Full pipeline, stage integration, error propagation |
| `tests/security_tests.rs` | 12 | Buffer overflow, path traversal, null injection, Unicode attacks |
```bash
-cargo test -p rvdna # All 102 tests
+cargo test -p rvdna # All 172 tests
cargo test -p rvdna -- kmer_pagerank # K-mer PageRank tests (7)
+cargo test -p rvdna --test biomarker_tests # Biomarker engine tests (19)
cargo test -p rvdna --test kmer_tests # Just k-mer tests
cargo test -p rvdna --test security_tests # Just security tests
```
@@ -504,6 +583,9 @@ See [ADR-012](adr/ADR-012-genomic-security-and-privacy.md) for the complete thre
| Horvath Clock | Horvath, Genome Biology, 2013 | `epigenomics.rs` |
| PharmGKB/CPIC | Caudle et al., CPT, 2014 | `pharma.rs` |
| Forward Push PPR | Andersen et al., FOCS, 2006 | `kmer_pagerank.rs` |
+| Welford's Online Algorithm | Welford, Technometrics, 1962 | `biomarker_stream.rs` |
+| CUSUM Changepoint Detection | Page, Biometrika, 1954 | `biomarker_stream.rs` |
+| Polygenic Risk Scoring | Khera et al., Nature Genetics, 2018 | `biomarker.rs` |
| Neumann Series Solver | von Neumann, 1929 | `ruvector-solver` |
| Conjugate Gradient | Hestenes & Stiefel, 1952 | `ruvector-solver` |
@@ -587,7 +669,8 @@ MIT β see `LICENSE` in the repository root.
- [npm package](https://www.npmjs.com/package/@ruvector/rvdna) β JavaScript/TypeScript bindings
- [crates.io](https://crates.io/crates/rvdna) β Rust crate
-- [Architecture Decision Records](adr/) β 13 ADRs documenting design choices
+- [Architecture Decision Records](adr/) β 14 ADRs documenting design choices
+- [Health Biomarker Engine (ADR-014)](adr/ADR-014-health-biomarker-analysis.md) β composite risk scoring + streaming architecture
- [RVDNA Format Spec (ADR-013)](adr/ADR-013-rvdna-ai-native-format.md) β full binary format specification
- [WASM Edge Genomics (ADR-008)](adr/ADR-008-wasm-edge-genomics.md) β WebAssembly deployment plan
- [RuVector](https://github.com/ruvnet/ruvector) β the parent vector computing platform (76 crates)
diff --git a/examples/dna/adr/ADR-014-health-biomarker-analysis.md b/examples/dna/adr/ADR-014-health-biomarker-analysis.md
new file mode 100644
index 000000000..407ad64af
--- /dev/null
+++ b/examples/dna/adr/ADR-014-health-biomarker-analysis.md
@@ -0,0 +1,270 @@
+# ADR-014: Health Biomarker Analysis Engine
+
+**Status:** Accepted | **Date:** 2026-02-22 | **Authors:** RuVector Genomics Architecture Team
+**Parents:** ADR-001 (Vision), ADR-003 (HNSW Index), ADR-004 (Attention), ADR-009 (Variant Calling), ADR-011 (Performance Targets), ADR-013 (RVDNA Format)
+
+## Context
+
+The rvDNA crate already implements 17 clinically-relevant health SNPs across 4 categories (Cancer Risk, Cardiovascular, Neurological, Metabolism) in `health.rs`, with dedicated analysis functions for APOE genotyping, MTHFR compound status, and COMT/OPRM1 pain profiling. The genotyping pipeline (`genotyping.rs`) provides end-to-end 23andMe analysis with 7-stage processing.
+
+However, the current health variant analysis has several limitations:
+
+| Limitation | Impact | Module |
+|-----------|--------|--------|
+| No polygenic risk scoring | Individual SNP effects miss gene-gene interactions | `health.rs` |
+| No longitudinal tracking | Cannot monitor biomarker changes over time | None |
+| No streaming data ingestion | Real-time health monitoring impossible | None |
+| No vector-indexed biomarker search | Cannot correlate across populations | None |
+| No composite health scoring | No unified risk quantification | `health.rs` |
+| No RVDNA biomarker section | Health data not persisted in AI-native format | `rvdna.rs` |
+
+The health biomarker domain requires three capabilities beyond SNP lookup: (1) composite risk scoring that aggregates across gene networks, (2) streaming ingestion for real-time monitoring, and (3) HNSW-indexed population-scale similarity search for correlating individual profiles against reference cohorts.
+
+## Decision: Health Biomarker Analysis Engine
+
+We introduce a biomarker analysis engine (`biomarker.rs`) that extends the existing `health.rs` SNP analysis with:
+
+1. **Composite Biomarker Profiles** β Aggregate individual SNP results into category-level and global risk scores with configurable weighting
+2. **Streaming Data Simulation** β Simulated real-time biomarker data streams with configurable noise, drift, and anomaly injection for testing temporal analysis
+3. **HNSW-Indexed Profile Search** β Store biomarker profiles as dense vectors in HNSW index for population-scale similarity search
+4. **Temporal Biomarker Tracking** β Time-series analysis with trend detection, moving averages, and anomaly detection
+5. **Real Example Data** β Curated biomarker datasets based on clinically validated reference ranges
+
+### Architecture Overview
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β Health Biomarker Engine β
+ββββββββββββββββ¬βββββββββββββββ¬ββββββββββββββββ¬ββββββββββββββββββββ€
+β Composite β Streaming β HNSW-Indexed β Temporal β
+β Risk Score β Simulator β Population β Tracker β
+β β β Search β β
+ββββββββββββββββ€ β β β
+β Gene Network β Noise Model β Profile Vec β Moving Average β
+β Interaction β Drift Model β Quantization β Trend Detection β
+β Weights β Anomalies β Similarity β Anomaly Detect β
+ββββββββ¬ββββββββ΄βββββββ¬ββββββββ΄ββββββββ¬ββββββββ΄ββββββββ¬ββββββββββββ
+ β β β β
+ββββββββ΄βββββββ βββββββ΄ββββββ βββββββ΄βββββββ ββββββ΄βββββββββ
+β health.rs β β tokio β β ruvector β β biomarker β
+β 17 SNPs β β streams β β -core HNSW β β time series β
+β APOE/MTHFR β β channels β β VectorDB β β ring buffer β
+βββββββββββββββ βββββββββββββ ββββββββββββββ βββββββββββββββ
+```
+
+### Component Specifications
+
+#### 1. Composite Biomarker Profile
+
+```rust
+pub struct BiomarkerProfile {
+ pub subject_id: String,
+ pub timestamp: i64,
+ pub snp_results: Vec,
+ pub category_scores: HashMap,
+ pub global_risk_score: f64,
+ pub profile_vector: Vec, // Dense vector for HNSW indexing
+}
+
+pub struct CategoryScore {
+ pub category: String,
+ pub score: f64, // 0.0 (low risk) to 1.0 (high risk)
+ pub confidence: f64, // Based on genotyped fraction
+ pub contributing_variants: Vec,
+}
+```
+
+**Scoring Algorithm:**
+- Each SNP contributes a risk weight based on its clinical significance and genotype
+- Category scores aggregate SNP weights within gene-network boundaries
+- Gene-gene interaction terms (e.g., COMT x OPRM1 for pain) apply multiplicative modifiers
+- Global risk score uses weighted geometric mean across categories
+- Profile vector is the concatenation of normalized category scores + individual SNP encodings (one-hot genotype)
+
+**Weight Matrix (evidence-based):**
+
+| Gene | Risk Weight (Hom Ref) | Risk Weight (Het) | Risk Weight (Hom Alt) | Category |
+|------|----------------------|-------------------|----------------------|----------|
+| APOE (rs429358) | 0.0 | 0.45 | 0.90 | Neurological |
+| BRCA1 (rs80357906) | 0.0 | 0.70 | 0.95 | Cancer |
+| MTHFR C677T | 0.0 | 0.30 | 0.65 | Metabolism |
+| COMT Val158Met | 0.0 | 0.25 | 0.50 | Neurological |
+| CYP1A2 | 0.0 | 0.15 | 0.35 | Metabolism |
+| SLCO1B1 | 0.0 | 0.40 | 0.75 | Cardiovascular |
+
+**Interaction Terms:**
+
+| Interaction | Modifier | Rationale |
+|------------|----------|-----------|
+| COMT(AA) x OPRM1(GG) | 1.4x pain score | Synergistic pain sensitivity |
+| MTHFR(677TT) x MTHFR(1298CC) | 1.3x metabolism score | Compound heterozygote |
+| APOE(e4/e4) x TP53(variant) | 1.2x neurological score | Neurodegeneration + impaired DNA repair |
+| BRCA1(carrier) x TP53(variant) | 1.5x cancer score | DNA repair pathway compromise |
+
+#### 2. Streaming Biomarker Simulator
+
+```rust
+pub struct StreamConfig {
+ pub base_interval_ms: u64, // Interval between readings
+ pub noise_amplitude: f64, // Gaussian noise Ο
+ pub drift_rate: f64, // Linear drift per reading
+ pub anomaly_probability: f64, // Probability of anomalous reading
+ pub anomaly_magnitude: f64, // Size of anomaly spike
+ pub num_biomarkers: usize, // Number of parallel streams
+ pub window_size: usize, // Sliding window for statistics
+}
+
+pub struct BiomarkerReading {
+ pub timestamp_ms: u64,
+ pub biomarker_id: String,
+ pub value: f64,
+ pub reference_range: (f64, f64),
+ pub is_anomaly: bool,
+ pub z_score: f64,
+}
+```
+
+**Simulation Model:**
+- Base values drawn from clinically validated reference ranges (see Section 3)
+- Gaussian noise with configurable Ο (default: 2% of reference range)
+- Linear drift models chronic condition progression
+- Anomaly injection via Poisson process (default: p=0.02 per reading)
+- Anomalies modeled as multiplicative spikes (default: 2.5x normal variation)
+
+**Streaming Protocol:**
+- Uses `tokio::sync::mpsc` channels for async data flow
+- Ring buffer (capacity: 10,000 readings) for windowed statistics
+- Moving average, exponential smoothing, and z-score computation in real-time
+- Backpressure via bounded channels prevents memory exhaustion
+
+#### 3. HNSW-Indexed Population Search
+
+Biomarker profile vectors are stored in RuVector's HNSW index for population-scale similarity search:
+
+```rust
+pub struct PopulationIndex {
+ pub db: VectorDB,
+ pub profile_dim: usize, // Vector dimension (typically 64)
+ pub population_size: usize,
+ pub metadata: HashMap,
+}
+```
+
+**Vector Encoding:**
+- 17 SNPs x 3 genotype one-hot = 51 dimensions
+- 4 category scores = 4 dimensions
+- 1 global risk score = 1 dimension
+- 4 interaction terms = 4 dimensions
+- MTHFR score (1) + Pain score (1) + APOE risk (1) + Caffeine metabolism (1) = 4 dimensions
+- **Total: 64 dimensions** (power of 2 for SIMD alignment)
+
+**Search Performance (from ADR-011):**
+- p50 latency: <100 ΞΌs at 10k profiles
+- p99 latency: <250 ΞΌs at 10k profiles
+- Recall@10: >97%
+- HNSW config: M=16, ef_construction=200, ef_search=50
+
+#### 4. Reference Biomarker Data
+
+Curated reference ranges from clinical literature (CDC, WHO, NCBI ClinVar):
+
+| Biomarker | Unit | Low | Normal Low | Normal High | High | Critical |
+|-----------|------|-----|------------|-------------|------|----------|
+| Total Cholesterol | mg/dL | - | <200 | 200-239 | >=240 | >300 |
+| LDL Cholesterol | mg/dL | - | <100 | 100-159 | >=160 | >190 |
+| HDL Cholesterol | mg/dL | <40 | 40-59 | >=60 | - | - |
+| Triglycerides | mg/dL | - | <150 | 150-199 | >=200 | >500 |
+| Fasting Glucose | mg/dL | <70 | 70-99 | 100-125 | >=126 | >300 |
+| HbA1c | % | <4.0 | 4.0-5.6 | 5.7-6.4 | >=6.5 | >10.0 |
+| Homocysteine | ΞΌmol/L | - | <10 | 10-15 | >15 | >30 |
+| Vitamin D (25-OH) | ng/mL | <20 | 20-29 | 30-100 | >100 | >150 |
+| CRP (hs) | mg/L | - | <1.0 | 1.0-3.0 | >3.0 | >10.0 |
+| TSH | mIU/L | <0.4 | 0.4-2.0 | 2.0-4.0 | >4.0 | >10.0 |
+| Ferritin | ng/mL | <12 | 12-150 | 150-300 | >300 | >1000 |
+| Vitamin B12 | pg/mL | <200 | 200-300 | 300-900 | >900 | - |
+
+These values are used to:
+1. Validate streaming simulator output
+2. Calculate z-scores for anomaly detection
+3. Generate realistic synthetic population data
+4. Provide clinical context in biomarker reports
+
+### Performance Targets
+
+| Operation | Target | Mechanism |
+|-----------|--------|-----------|
+| Composite score (17 SNPs) | <50 ΞΌs | In-memory weight matrix multiply |
+| Profile vector encoding | <100 ΞΌs | One-hot + normalize |
+| Population similarity top-10 | <150 ΞΌs | HNSW search on 64-dim vectors |
+| Stream processing (single reading) | <10 ΞΌs | Ring buffer + running stats |
+| Anomaly detection | <5 ΞΌs | Z-score against moving window |
+| Full biomarker report | <1 ms | Score + encode + search |
+| Population index build (10k) | <500 ms | Batch HNSW insert |
+| Streaming throughput | >100k readings/sec | Lock-free ring buffer |
+
+### Integration Points
+
+| Existing Module | Integration | Direction |
+|----------------|-------------|-----------|
+| `health.rs` | SNP results feed composite scorer | Input |
+| `genotyping.rs` | 23andMe pipeline generates BiomarkerProfile | Input |
+| `ruvector-core` | HNSW index stores profile vectors | Bidirectional |
+| `rvdna.rs` | Profile vectors stored in metadata section | Output |
+| `epigenomics.rs` | Methylation data enriches biomarker profile | Input |
+| `pharma.rs` | CYP metabolizer status informs drug-related biomarkers | Input |
+
+## Consequences
+
+**Positive:**
+- Unified risk scoring replaces per-SNP interpretation with actionable composite scores
+- Streaming architecture enables real-time health monitoring use cases
+- HNSW indexing enables population-scale "patients like me" queries in <150 ΞΌs
+- Reference biomarker data provides clinical validation framework
+- 64-dim profile vectors are SIMD-aligned for maximum throughput
+- Ring buffer streaming achieves >100k readings/sec without allocation pressure
+
+**Negative:**
+- Composite scoring weights are simplified; clinical deployment requires validated coefficients from GWAS
+- Streaming simulator generates synthetic data only; real clinical integration requires HL7/FHIR adapters
+- Additional 64-dim vector per profile increases RVDNA file size by ~256 bytes per subject
+
+**Neutral:**
+- Risk scores are educational/research only; same disclaimer as existing `health.rs`
+- Gene-gene interaction terms are limited to known pairs; extensible via configuration
+
+## Options Considered
+
+1. **Extend health.rs with scoring** β rejected: would grow file beyond 500-line limit; scoring + streaming + search are distinct bounded contexts
+2. **Separate crate** β rejected: too much coupling to existing types; shared types across modules
+3. **New module (biomarker.rs)** β selected: clean separation, imports from `health.rs`, integrates with `ruvector-core` HNSW, stays within the rvDNA crate boundary
+
+## Implementation Strategy
+
+**Phase 1 (This ADR):**
+- `biomarker.rs`: Composite scoring engine with reference data
+- `biomarker_stream.rs`: Streaming simulator with ring buffer and anomaly detection
+- Integration tests with realistic 23andMe-derived profiles
+- Benchmark suite validating performance targets
+
+**Phase 2 (Future):**
+- RVDNA Section 7: Biomarker profile storage in binary format
+- Population index persistence (serialize HNSW graph to RVDNA)
+- WASM export for browser-based biomarker dashboards
+- HL7/FHIR streaming adapter for clinical integration
+
+## Related Decisions
+
+- **ADR-001**: Vision β health biomarker analysis is a key clinical application
+- **ADR-003**: HNSW index β population search uses the same index infrastructure
+- **ADR-009**: Variant calling β biomarker profiles integrate variant quality scores
+- **ADR-011**: Performance targets β all biomarker operations must meet latency budgets
+- **ADR-013**: RVDNA format β biomarker vectors stored in metadata section (Phase 1) or dedicated section (Phase 2)
+
+## References
+
+- [CPIC Guidelines](https://cpicpgx.org/) β Pharmacogenomics dosing guidelines
+- [ClinVar](https://www.ncbi.nlm.nih.gov/clinvar/) β Clinical variant significance database
+- [gnomAD](https://gnomad.broadinstitute.org/) β Population allele frequencies
+- [Horvath Clock](https://doi.org/10.1186/gb-2013-14-10-r115) β Epigenetic age estimation
+- [APOE Alzheimer's Meta-Analysis](https://doi.org/10.1001/jama.278.16.1349) β e4 odds ratios
+- [MTHFR Clinical Review](https://doi.org/10.1007/s12035-019-1547-z) β Compound heterozygote effects
diff --git a/examples/dna/adr/ADR-015-npm-wasm-biomarker-engine.md b/examples/dna/adr/ADR-015-npm-wasm-biomarker-engine.md
new file mode 100644
index 000000000..8d96fd9e2
--- /dev/null
+++ b/examples/dna/adr/ADR-015-npm-wasm-biomarker-engine.md
@@ -0,0 +1,230 @@
+# ADR-015: npm/WASM Health Biomarker Engine
+
+**Status:** Accepted | **Date:** 2026-02-22 | **Authors:** RuVector Genomics Architecture Team
+**Parents:** ADR-001 (Vision), ADR-008 (WASM Edge), ADR-011 (Performance Targets), ADR-014 (Health Biomarker Analysis)
+
+## Context
+
+ADR-014 delivered the Rust biomarker analysis engine (`biomarker.rs`, `biomarker_stream.rs`) with composite risk scoring across 20 SNPs, 6 gene-gene interactions, 64-dim L2-normalized profile vectors, and a streaming processor with RingBuffer, CUSUM changepoint detection, and Welford online statistics. ADR-008 established WASM as the delivery mechanism for browser-side genomic computation.
+
+The `@ruvector/rvdna` npm package (v0.2.0) already exposes 2-bit encoding, protein translation, cosine similarity, and 23andMe genotyping via pure-JS fallbacks and optional NAPI-RS native bindings. However, it lacks the biomarker engine entirely:
+
+| Gap | Impact | Severity |
+|-----|--------|----------|
+| No biomarker risk scoring in JS | Browser/Node users cannot compute composite health risk | Critical |
+| No streaming processor in JS | Real-time biomarker dashboards impossible without native | Critical |
+| No profile vector encoding | Population similarity search unavailable in JS | High |
+| No TypeScript types for biomarker API | Developer experience degraded | Medium |
+| No benchmarks for JS path | Cannot validate performance parity claims | Medium |
+
+The decision is whether to (a) require WASM/native for all biomarker features, (b) provide a pure-JS implementation that mirrors the Rust engine exactly, or (c) a hybrid approach.
+
+## Decision: Pure-JS Biomarker Engine with WASM Acceleration Path
+
+We implement a **complete pure-JS biomarker engine** in `@ruvector/rvdna` v0.3.0 that mirrors the Rust `biomarker.rs` and `biomarker_stream.rs` exactly, with a future WASM acceleration path for compute-intensive operations.
+
+### Rationale
+
+1. **Zero-dependency accessibility** β Any Node.js or browser environment can run biomarker analysis without compiling Rust or loading WASM
+2. **Exact algorithmic parity** β Same 20 SNPs, same 6 interactions, same 64-dim vector layout, same CUSUM parameters, same Welford statistics
+3. **Progressive enhancement** β Pure JS works everywhere; WASM (future) accelerates hot paths (vector encoding, population generation)
+4. **Test oracle** β JS implementation serves as a cross-language verification oracle against the Rust engine
+
+### Architecture
+
+```
+@ruvector/rvdna v0.3.0
+βββ index.js # Entry point, re-exports all modules
+βββ index.d.ts # Full TypeScript definitions
+βββ src/
+β βββ biomarker.js # Risk scoring engine (mirrors biomarker.rs)
+β βββ stream.js # Streaming processor (mirrors biomarker_stream.rs)
+βββ tests/
+ βββ test-biomarker.js # Comprehensive test suite + benchmarks
+```
+
+### Module 1: Biomarker Risk Scoring (`src/biomarker.js`)
+
+**Data Tables (exact mirror of Rust):**
+
+| Table | Entries | Fields |
+|-------|---------|--------|
+| `BIOMARKER_REFERENCES` | 13 | name, unit, normalLow, normalHigh, criticalLow, criticalHigh, category |
+| `SNPS` | 20 | rsid, category, wRef, wHet, wAlt, homRef, het, homAlt, maf |
+| `INTERACTIONS` | 6 | rsidA, rsidB, modifier, category |
+| `CAT_ORDER` | 4 | Cancer Risk, Cardiovascular, Neurological, Metabolism |
+
+**Functions:**
+
+| Function | Input | Output | Mirrors |
+|----------|-------|--------|---------|
+| `biomarkerReferences()` | β | `BiomarkerReference[]` | `biomarker_references()` |
+| `zScore(value, ref)` | number, BiomarkerReference | number | `z_score()` |
+| `classifyBiomarker(value, ref)` | number, BiomarkerReference | enum string | `classify_biomarker()` |
+| `computeRiskScores(genotypes)` | `Map` | `BiomarkerProfile` | `compute_risk_scores()` |
+| `encodeProfileVector(profile)` | BiomarkerProfile | `Float32Array(64)` | `encode_profile_vector()` |
+| `generateSyntheticPopulation(count, seed)` | number, number | `BiomarkerProfile[]` | `generate_synthetic_population()` |
+
+**Scoring Algorithm (identical to Rust):**
+1. For each of 20 SNPs, look up genotype and compute weight (wRef/wHet/wAlt)
+2. Aggregate weights per category (Cancer Risk, Cardiovascular, Neurological, Metabolism)
+3. Apply 6 multiplicative interaction modifiers where both SNPs are non-reference
+4. Normalize each category: `score = raw / maxPossible`, clamped to [0, 1]
+5. Confidence = genotyped fraction per category
+6. Global risk = weighted average: `sum(score * confidence) / sum(confidence)`
+
+**Profile Vector Layout (64 dimensions, L2-normalized):**
+
+| Dims | Content | Count |
+|------|---------|-------|
+| 0β50 | One-hot genotype encoding (17 SNPs x 3) | 51 |
+| 51β54 | Category scores | 4 |
+| 55 | Global risk score | 1 |
+| 56β59 | First 4 interaction modifiers | 4 |
+| 60 | MTHFR score / 4 | 1 |
+| 61 | Pain score / 4 | 1 |
+| 62 | APOE risk code / 2 | 1 |
+| 63 | LPA composite | 1 |
+
+**PRNG:** Mulberry32 (deterministic, no dependencies, matches seeded output for synthetic populations).
+
+### Module 2: Streaming Biomarker Processor (`src/stream.js`)
+
+**Data Structures:**
+
+| Structure | Purpose | Mirrors |
+|-----------|---------|---------|
+| `RingBuffer` | Fixed-capacity circular buffer, no allocation after init | `RingBuffer` |
+| `StreamProcessor` | Per-biomarker rolling stats, anomaly detection, trend analysis | `StreamProcessor` |
+| `StreamStats` | mean, variance, min, max, EMA, CUSUM, changepoint | `StreamStats` |
+
+**Constants (identical to Rust):**
+
+| Constant | Value | Purpose |
+|----------|-------|---------|
+| `EMA_ALPHA` | 0.1 | Exponential moving average smoothing |
+| `Z_SCORE_THRESHOLD` | 2.5 | Anomaly detection threshold |
+| `REF_OVERSHOOT` | 0.20 | Out-of-range tolerance (20% of range) |
+| `CUSUM_THRESHOLD` | 4.0 | Changepoint detection sensitivity |
+| `CUSUM_DRIFT` | 0.5 | CUSUM allowable drift |
+
+**Statistics:**
+- **Welford's online algorithm** for single-pass mean and sample standard deviation (2x fewer cache misses than two-pass)
+- **Simple linear regression** for trend slope via least-squares
+- **CUSUM** (Cumulative Sum) for changepoint detection with automatic reset
+
+**Biomarker Definitions (6 streams):**
+
+| ID | Reference Low | Reference High |
+|----|--------------|---------------|
+| glucose | 70 | 100 |
+| cholesterol_total | 150 | 200 |
+| hdl | 40 | 60 |
+| ldl | 70 | 130 |
+| triglycerides | 50 | 150 |
+| crp | 0.1 | 3.0 |
+
+### Performance Targets
+
+| Operation | JS Target | Rust Baseline | Acceptable Ratio |
+|-----------|-----------|---------------|------------------|
+| `computeRiskScores` (20 SNPs) | <200 us | <50 us | 4x |
+| `encodeProfileVector` (64-dim) | <300 us | <100 us | 3x |
+| `StreamProcessor.processReading` | <50 us | <10 us | 5x |
+| `generateSyntheticPopulation(1000)` | <100 ms | <20 ms | 5x |
+| RingBuffer push+iter (100 items) | <20 us | <2 us | 10x |
+
+**Benchmark methodology:** `performance.now()` with 1000-iteration warmup, 10000 measured iterations, report p50/p99.
+
+### TypeScript Definitions
+
+Full `.d.ts` types for every exported function, interface, and enum. Key types:
+
+- `BiomarkerReference` β 13-field clinical reference range
+- `BiomarkerClassification` β `'CriticalLow' | 'Low' | 'Normal' | 'High' | 'CriticalHigh'`
+- `CategoryScore` β per-category risk with confidence and contributing variants
+- `BiomarkerProfile` β complete risk profile with 64-dim vector
+- `StreamConfig` β streaming processor configuration
+- `BiomarkerReading` β timestamped biomarker data point
+- `StreamStats` β rolling statistics with CUSUM state
+- `ProcessingResult` β per-reading anomaly detection result
+- `StreamSummary` β aggregate statistics across all biomarker streams
+
+### Test Coverage
+
+| Category | Tests | Coverage |
+|----------|-------|----------|
+| Biomarker references | 2 | Count, z-score math |
+| Classification | 5 | All 5 classification levels |
+| Risk scoring | 4 | All-ref low risk, elevated cancer, interaction amplification, BRCA1+TP53 |
+| Profile vectors | 3 | 64-dim, L2-normalized, deterministic |
+| Population generation | 3 | Correct count, deterministic, MTHFR-homocysteine correlation |
+| RingBuffer | 4 | Push/iter, overflow, capacity-1, clear |
+| Stream processor | 3 | Stats computation, summary totals, throughput |
+| Anomaly detection | 3 | Z-score anomaly, out-of-range, zero anomaly for constant |
+| Trend detection | 3 | Positive, negative, exact slope |
+| Z-score / EMA | 2 | Near-mean small z, EMA convergence |
+| Benchmarks | 5 | All performance targets |
+
+**Total: 37 tests + 5 benchmarks**
+
+### WASM Acceleration Path (Future β Phase 2)
+
+When `@ruvector/rvdna-wasm` is available:
+
+```js
+// Automatic acceleration β same API, WASM hot path
+const { computeRiskScores } = require('@ruvector/rvdna');
+// Internally checks: nativeModule?.computeRiskScores ?? jsFallback
+```
+
+**WASM candidates (>10x speedup potential):**
+- `encodeProfileVector` β SIMD dot products for L2 normalization
+- `generateSyntheticPopulation` β bulk PRNG + matrix operations
+- `StreamProcessor.processReading` β vectorized Welford accumulation
+
+### Versioning
+
+- `@ruvector/rvdna` bumps from `0.2.0` to `0.3.0` (new public API surface)
+- `files` array in `package.json` updated to include `src/` directory
+- Keywords expanded: `biomarker`, `health`, `risk-score`, `streaming`, `anomaly-detection`
+- No breaking changes to existing v0.2.0 API
+
+## Consequences
+
+**Positive:**
+- Full biomarker engine available in any JS runtime without native compilation
+- Algorithmic parity with Rust ensures cross-language consistency
+- Pure JS means zero WASM load time for initial render in browser dashboards
+- Comprehensive test suite provides regression safety net
+- TypeScript types enable IDE autocompletion and compile-time checking
+- Benchmarks establish baseline for future WASM optimization
+
+**Negative:**
+- JS is 3-10x slower than Rust for numerical computation
+- Synthetic population generation uses Mulberry32 PRNG (not cryptographically identical to Rust's StdRng)
+- MTHFR/pain analysis simplified in JS (no cross-module dependency on health.rs internals)
+
+**Neutral:**
+- Same clinical disclaimers apply: research/educational use only
+- Gene-gene interaction weights unchanged from ADR-014
+
+## Options Considered
+
+1. **WASM-only** β rejected: forces async init, 2MB+ download, excludes lightweight Node.js scripts
+2. **Pure JS only, no WASM path** β rejected: leaves performance on the table for browser dashboards
+3. **Pure JS with WASM acceleration path** β selected: immediate availability + future optimization
+4. **Thin wrapper over native module** β rejected: native bindings unavailable on most platforms
+
+## Related Decisions
+
+- **ADR-008**: WASM Edge Genomics β establishes WASM as browser delivery mechanism
+- **ADR-011**: Performance Targets β JS targets derived as acceptable multiples of Rust baselines
+- **ADR-014**: Health Biomarker Analysis β Rust engine this ADR mirrors in JavaScript
+
+## References
+
+- [Mulberry32 PRNG](https://gist.github.com/tommyettinger/46a874533244883189143505d203312c) β 32-bit deterministic PRNG
+- [Welford's Online Algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford%27s_online_algorithm) β Numerically stable variance
+- [CUSUM](https://en.wikipedia.org/wiki/CUSUM) β Cumulative sum control chart for changepoint detection
+- [CPIC Guidelines](https://cpicpgx.org/) β Pharmacogenomics evidence base
diff --git a/examples/dna/benches/biomarker_bench.rs b/examples/dna/benches/biomarker_bench.rs
new file mode 100644
index 000000000..a5756815c
--- /dev/null
+++ b/examples/dna/benches/biomarker_bench.rs
@@ -0,0 +1,181 @@
+//! Criterion benchmarks for Biomarker Analysis Engine
+//!
+//! Performance benchmarks covering ADR-014 targets:
+//! - Risk scoring (<50 ΞΌs)
+//! - Profile vector encoding (<100 ΞΌs)
+//! - Population generation (<500ms for 10k)
+//! - Streaming throughput (>100k readings/sec)
+//! - Z-score and classification (<5 ΞΌs)
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion};
+use rvdna::biomarker::*;
+use rvdna::biomarker_stream::*;
+use std::collections::HashMap;
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+fn sample_genotypes() -> HashMap {
+ let mut gts = HashMap::new();
+ gts.insert("rs429358".into(), "TT".into());
+ gts.insert("rs7412".into(), "CC".into());
+ gts.insert("rs4680".into(), "AG".into());
+ gts.insert("rs1799971".into(), "AA".into());
+ gts.insert("rs762551".into(), "AA".into());
+ gts.insert("rs1801133".into(), "AG".into());
+ gts.insert("rs1801131".into(), "TT".into());
+ gts.insert("rs1042522".into(), "CG".into());
+ gts.insert("rs80357906".into(), "DD".into());
+ gts.insert("rs4363657".into(), "TT".into());
+ gts
+}
+
+fn full_panel_genotypes() -> HashMap {
+ // All 17 SNPs from health.rs
+ let mut gts = sample_genotypes();
+ gts.insert("rs28897696".into(), "GG".into());
+ gts.insert("rs11571833".into(), "AA".into());
+ gts.insert("rs4988235".into(), "AG".into());
+ gts.insert("rs53576".into(), "GG".into());
+ gts.insert("rs6311".into(), "CT".into());
+ gts.insert("rs1800497".into(), "AG".into());
+ gts.insert("rs1800566".into(), "CC".into());
+ gts
+}
+
+// ============================================================================
+// Risk Scoring Benchmarks (target: <50 ΞΌs)
+// ============================================================================
+
+fn risk_scoring_benchmarks(c: &mut Criterion) {
+ let mut group = c.benchmark_group("biomarker_scoring");
+
+ // Setup: create a representative genotype map
+ let gts = sample_genotypes();
+
+ group.bench_function("compute_risk_scores", |b| {
+ b.iter(|| black_box(compute_risk_scores(>s)));
+ });
+
+ group.bench_function("compute_risk_scores_full_panel", |b| {
+ let full_gts = full_panel_genotypes();
+ b.iter(|| black_box(compute_risk_scores(&full_gts)));
+ });
+
+ group.finish();
+}
+
+// ============================================================================
+// Profile Vector Benchmarks (target: <100 ΞΌs)
+// ============================================================================
+
+fn vector_encoding_benchmarks(c: &mut Criterion) {
+ let mut group = c.benchmark_group("biomarker_vector");
+
+ let gts = sample_genotypes();
+ let profile = compute_risk_scores(>s);
+
+ group.bench_function("encode_profile_vector", |b| {
+ b.iter(|| black_box(encode_profile_vector(&profile)));
+ });
+
+ group.finish();
+}
+
+// ============================================================================
+// Population Generation Benchmarks (target: <500ms for 10k)
+// ============================================================================
+
+fn population_benchmarks(c: &mut Criterion) {
+ let mut group = c.benchmark_group("biomarker_population");
+
+ group.bench_function("generate_100", |b| {
+ b.iter(|| black_box(generate_synthetic_population(100, 42)));
+ });
+
+ group.bench_function("generate_1000", |b| {
+ b.iter(|| black_box(generate_synthetic_population(1000, 42)));
+ });
+
+ group.finish();
+}
+
+// ============================================================================
+// Streaming Benchmarks (target: >100k readings/sec)
+// ============================================================================
+
+fn streaming_benchmarks(c: &mut Criterion) {
+ let mut group = c.benchmark_group("biomarker_streaming");
+
+ group.bench_function("generate_1000_readings", |b| {
+ let config = StreamConfig::default();
+ b.iter(|| black_box(generate_readings(&config, 1000, 42)));
+ });
+
+ group.bench_function("process_1000_readings", |b| {
+ let config = StreamConfig::default();
+ let readings = generate_readings(&config, 1000, 42);
+ b.iter(|| {
+ let mut processor = StreamProcessor::new(config.clone());
+ for reading in &readings {
+ black_box(processor.process_reading(reading));
+ }
+ });
+ });
+
+ group.bench_function("ring_buffer_1000_push", |b| {
+ b.iter(|| {
+ let mut rb: RingBuffer = RingBuffer::new(100);
+ for i in 0..1000 {
+ rb.push(black_box(i as f64));
+ }
+ });
+ });
+
+ group.finish();
+}
+
+// ============================================================================
+// Z-Score and Classification Benchmarks (target: <5 ΞΌs)
+// ============================================================================
+
+fn classification_benchmarks(c: &mut Criterion) {
+ let mut group = c.benchmark_group("biomarker_classification");
+ let refs = biomarker_references();
+
+ group.bench_function("z_score_single", |b| {
+ let r = &refs[0];
+ b.iter(|| black_box(z_score(180.0, r)));
+ });
+
+ group.bench_function("classify_single", |b| {
+ let r = &refs[0];
+ b.iter(|| black_box(classify_biomarker(180.0, r)));
+ });
+
+ group.bench_function("z_score_all_biomarkers", |b| {
+ b.iter(|| {
+ for r in refs {
+ let mid = (r.normal_low + r.normal_high) / 2.0;
+ black_box(z_score(mid, r));
+ }
+ });
+ });
+
+ group.finish();
+}
+
+// ============================================================================
+// Criterion Configuration
+// ============================================================================
+
+criterion_group!(
+ benches,
+ risk_scoring_benchmarks,
+ vector_encoding_benchmarks,
+ population_benchmarks,
+ streaming_benchmarks,
+ classification_benchmarks,
+);
+criterion_main!(benches);
diff --git a/examples/dna/src/biomarker.rs b/examples/dna/src/biomarker.rs
new file mode 100644
index 000000000..6cc303c2e
--- /dev/null
+++ b/examples/dna/src/biomarker.rs
@@ -0,0 +1,498 @@
+//! Composite health biomarker analysis engine -- combines SNP genotype data
+//! with clinical biomarker reference ranges to produce composite risk scores,
+//! 64-dim profile vectors (for HNSW indexing), and synthetic populations.
+
+use rand::rngs::StdRng;
+use rand::{Rng, SeedableRng};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+use crate::health::{analyze_mthfr, analyze_pain};
+
+/// Clinical reference range for a single biomarker.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BiomarkerReference {
+ pub name: &'static str,
+ pub unit: &'static str,
+ pub normal_low: f64,
+ pub normal_high: f64,
+ pub critical_low: Option,
+ pub critical_high: Option,
+ pub category: &'static str,
+}
+
+/// Classification of a biomarker value relative to its reference range.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum BiomarkerClassification {
+ CriticalLow,
+ Low,
+ Normal,
+ High,
+ CriticalHigh,
+}
+
+static REFERENCES: &[BiomarkerReference] = &[
+ BiomarkerReference { name: "Total Cholesterol", unit: "mg/dL", normal_low: 125.0, normal_high: 200.0, critical_low: Some(100.0), critical_high: Some(300.0), category: "Lipid" },
+ BiomarkerReference { name: "LDL", unit: "mg/dL", normal_low: 50.0, normal_high: 100.0, critical_low: Some(25.0), critical_high: Some(190.0), category: "Lipid" },
+ BiomarkerReference { name: "HDL", unit: "mg/dL", normal_low: 40.0, normal_high: 90.0, critical_low: Some(20.0), critical_high: None, category: "Lipid" },
+ BiomarkerReference { name: "Triglycerides", unit: "mg/dL", normal_low: 35.0, normal_high: 150.0, critical_low: Some(20.0), critical_high: Some(500.0), category: "Lipid" },
+ BiomarkerReference { name: "Fasting Glucose", unit: "mg/dL", normal_low: 70.0, normal_high: 100.0, critical_low: Some(50.0), critical_high: Some(250.0), category: "Metabolic" },
+ BiomarkerReference { name: "HbA1c", unit: "%", normal_low: 4.0, normal_high: 5.7, critical_low: None, critical_high: Some(9.0), category: "Metabolic" },
+ BiomarkerReference { name: "Homocysteine", unit: "umol/L", normal_low: 5.0, normal_high: 15.0, critical_low: None, critical_high: Some(30.0), category: "Metabolic" },
+ BiomarkerReference { name: "Vitamin D", unit: "ng/mL", normal_low: 30.0, normal_high: 80.0, critical_low: Some(10.0), critical_high: Some(150.0), category: "Nutritional" },
+ BiomarkerReference { name: "CRP", unit: "mg/L", normal_low: 0.0, normal_high: 3.0, critical_low: None, critical_high: Some(10.0), category: "Inflammatory" },
+ BiomarkerReference { name: "TSH", unit: "mIU/L", normal_low: 0.4, normal_high: 4.0, critical_low: Some(0.1), critical_high: Some(10.0), category: "Thyroid" },
+ BiomarkerReference { name: "Ferritin", unit: "ng/mL", normal_low: 20.0, normal_high: 250.0, critical_low: Some(10.0), critical_high: Some(1000.0), category: "Iron" },
+ BiomarkerReference { name: "Vitamin B12", unit: "pg/mL", normal_low: 200.0, normal_high: 900.0, critical_low: Some(150.0), critical_high: None, category: "Nutritional" },
+ BiomarkerReference { name: "Lp(a)", unit: "nmol/L", normal_low: 0.0, normal_high: 75.0, critical_low: None, critical_high: Some(200.0), category: "Lipid" },
+];
+
+/// Return the static biomarker reference table.
+pub fn biomarker_references() -> &'static [BiomarkerReference] { REFERENCES }
+
+/// Compute a z-score for a value relative to a reference range.
+pub fn z_score(value: f64, reference: &BiomarkerReference) -> f64 {
+ let mid = (reference.normal_low + reference.normal_high) / 2.0;
+ let half_range = (reference.normal_high - reference.normal_low) / 2.0;
+ if half_range == 0.0 {
+ return 0.0;
+ }
+ (value - mid) / half_range
+}
+
+/// Classify a biomarker value against its reference range.
+pub fn classify_biomarker(value: f64, reference: &BiomarkerReference) -> BiomarkerClassification {
+ if let Some(cl) = reference.critical_low {
+ if value < cl {
+ return BiomarkerClassification::CriticalLow;
+ }
+ }
+ if value < reference.normal_low {
+ return BiomarkerClassification::Low;
+ }
+ if let Some(ch) = reference.critical_high {
+ if value > ch {
+ return BiomarkerClassification::CriticalHigh;
+ }
+ }
+ if value > reference.normal_high {
+ return BiomarkerClassification::High;
+ }
+ BiomarkerClassification::Normal
+}
+
+/// Risk score for a single clinical category.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CategoryScore {
+ pub category: String,
+ pub score: f64,
+ pub confidence: f64,
+ pub contributing_variants: Vec,
+}
+
+/// Full biomarker + genotype risk profile for one subject.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BiomarkerProfile {
+ pub subject_id: String,
+ pub timestamp: i64,
+ pub category_scores: HashMap,
+ pub global_risk_score: f64,
+ pub profile_vector: Vec,
+ pub biomarker_values: HashMap,
+}
+
+/// Unified SNP descriptor β eliminates parallel-array fragility.
+struct SnpDef {
+ rsid: &'static str,
+ category: &'static str,
+ w_ref: f64,
+ w_het: f64,
+ w_alt: f64,
+ hom_ref: &'static str,
+ het: &'static str,
+ hom_alt: &'static str,
+ maf: f64, // minor allele frequency
+}
+
+static SNPS: &[SnpDef] = &[
+ SnpDef { rsid: "rs429358", category: "Neurological", w_ref: 0.0, w_het: 0.4, w_alt: 0.9, hom_ref: "TT", het: "CT", hom_alt: "CC", maf: 0.14 },
+ SnpDef { rsid: "rs7412", category: "Neurological", w_ref: 0.0, w_het: -0.15, w_alt: -0.3, hom_ref: "CC", het: "CT", hom_alt: "TT", maf: 0.08 },
+ SnpDef { rsid: "rs1042522", category: "Cancer Risk", w_ref: 0.0, w_het: 0.25, w_alt: 0.5, hom_ref: "CC", het: "CG", hom_alt: "GG", maf: 0.40 },
+ SnpDef { rsid: "rs80357906", category: "Cancer Risk", w_ref: 0.0, w_het: 0.7, w_alt: 0.95, hom_ref: "DD", het: "DI", hom_alt: "II", maf: 0.003 },
+ SnpDef { rsid: "rs28897696", category: "Cancer Risk", w_ref: 0.0, w_het: 0.3, w_alt: 0.6, hom_ref: "GG", het: "AG", hom_alt: "AA", maf: 0.005 },
+ SnpDef { rsid: "rs11571833", category: "Cancer Risk", w_ref: 0.0, w_het: 0.20, w_alt: 0.5, hom_ref: "AA", het: "AT", hom_alt: "TT", maf: 0.01 },
+ SnpDef { rsid: "rs1801133", category: "Metabolism", w_ref: 0.0, w_het: 0.35, w_alt: 0.7, hom_ref: "GG", het: "AG", hom_alt: "AA", maf: 0.32 },
+ SnpDef { rsid: "rs1801131", category: "Metabolism", w_ref: 0.0, w_het: 0.10, w_alt: 0.25, hom_ref: "TT", het: "GT", hom_alt: "GG", maf: 0.30 },
+ SnpDef { rsid: "rs4680", category: "Neurological", w_ref: 0.0, w_het: 0.2, w_alt: 0.45, hom_ref: "GG", het: "AG", hom_alt: "AA", maf: 0.50 },
+ SnpDef { rsid: "rs1799971", category: "Neurological", w_ref: 0.0, w_het: 0.2, w_alt: 0.4, hom_ref: "AA", het: "AG", hom_alt: "GG", maf: 0.15 },
+ SnpDef { rsid: "rs762551", category: "Metabolism", w_ref: 0.0, w_het: 0.15, w_alt: 0.35, hom_ref: "AA", het: "AC", hom_alt: "CC", maf: 0.37 },
+ SnpDef { rsid: "rs4988235", category: "Metabolism", w_ref: 0.0, w_het: 0.05, w_alt: 0.15, hom_ref: "AA", het: "AG", hom_alt: "GG", maf: 0.24 },
+ SnpDef { rsid: "rs53576", category: "Neurological", w_ref: 0.0, w_het: 0.1, w_alt: 0.25, hom_ref: "GG", het: "AG", hom_alt: "AA", maf: 0.35 },
+ SnpDef { rsid: "rs6311", category: "Neurological", w_ref: 0.0, w_het: 0.15, w_alt: 0.3, hom_ref: "CC", het: "CT", hom_alt: "TT", maf: 0.45 },
+ SnpDef { rsid: "rs1800497", category: "Neurological", w_ref: 0.0, w_het: 0.25, w_alt: 0.5, hom_ref: "GG", het: "AG", hom_alt: "AA", maf: 0.20 },
+ SnpDef { rsid: "rs4363657", category: "Cardiovascular", w_ref: 0.0, w_het: 0.35, w_alt: 0.7, hom_ref: "TT", het: "CT", hom_alt: "CC", maf: 0.15 },
+ SnpDef { rsid: "rs1800566", category: "Cancer Risk", w_ref: 0.0, w_het: 0.15, w_alt: 0.30, hom_ref: "CC", het: "CT", hom_alt: "TT", maf: 0.22 },
+ // LPA β Lp(a) cardiovascular risk (2024 meta-analysis: OR 1.6-1.75/allele CHD)
+ SnpDef { rsid: "rs10455872", category: "Cardiovascular", w_ref: 0.0, w_het: 0.40, w_alt: 0.75, hom_ref: "AA", het: "AG", hom_alt: "GG", maf: 0.07 },
+ SnpDef { rsid: "rs3798220", category: "Cardiovascular", w_ref: 0.0, w_het: 0.35, w_alt: 0.65, hom_ref: "TT", het: "CT", hom_alt: "CC", maf: 0.02 },
+ // PCSK9 R46L β protective loss-of-function (NEJM 2006: OR 0.77 CHD, 0.40 MI)
+ SnpDef { rsid: "rs11591147", category: "Cardiovascular", w_ref: 0.0, w_het: -0.30, w_alt: -0.55, hom_ref: "GG", het: "GT", hom_alt: "TT", maf: 0.024 },
+];
+
+/// Number of SNPs with one-hot encoding in profile vector (first 17 for 64-dim SIMD alignment).
+/// Additional SNPs (LPA) contribute to risk scores but use summary dims in the vector.
+const NUM_ONEHOT_SNPS: usize = 17;
+const NUM_SNPS: usize = 20;
+
+fn genotype_code(snp: &SnpDef, gt: &str) -> u8 {
+ if gt == snp.hom_ref { 0 }
+ else if gt.len() == 2 && gt.as_bytes()[0] != gt.as_bytes()[1] { 1 }
+ else { 2 }
+}
+
+fn snp_weight(snp: &SnpDef, code: u8) -> f64 {
+ match code { 0 => snp.w_ref, 1 => snp.w_het, _ => snp.w_alt }
+}
+
+struct Interaction {
+ rsid_a: &'static str,
+ rsid_b: &'static str,
+ modifier: f64,
+ category: &'static str,
+}
+
+static INTERACTIONS: &[Interaction] = &[
+ Interaction { rsid_a: "rs4680", rsid_b: "rs1799971", modifier: 1.4, category: "Neurological" },
+ Interaction { rsid_a: "rs1801133", rsid_b: "rs1801131", modifier: 1.3, category: "Metabolism" },
+ Interaction { rsid_a: "rs429358", rsid_b: "rs1042522", modifier: 1.2, category: "Cancer Risk" },
+ Interaction { rsid_a: "rs80357906",rsid_b: "rs1042522", modifier: 1.5, category: "Cancer Risk" },
+ Interaction { rsid_a: "rs1801131", rsid_b: "rs4680", modifier: 1.25, category: "Neurological" }, // A1298CΓCOMT depression (geneticlifehacks)
+ Interaction { rsid_a: "rs1800497", rsid_b: "rs4680", modifier: 1.2, category: "Neurological" }, // DRD2ΓCOMT working memory (geneticlifehacks)
+];
+
+fn snp_idx(rsid: &str) -> Option {
+ SNPS.iter().position(|s| s.rsid == rsid)
+}
+
+fn is_non_ref(gts: &HashMap, rsid: &str) -> bool {
+ match (gts.get(rsid), snp_idx(rsid)) {
+ (Some(g), Some(idx)) => g != SNPS[idx].hom_ref,
+ _ => false,
+ }
+}
+
+fn interaction_mod(gts: &HashMap, ix: &Interaction) -> f64 {
+ if is_non_ref(gts, ix.rsid_a) && is_non_ref(gts, ix.rsid_b) {
+ ix.modifier
+ } else {
+ 1.0
+ }
+}
+
+struct CategoryMeta { name: &'static str, max_possible: f64, expected_count: usize }
+
+static CAT_ORDER: &[&str] = &["Cancer Risk", "Cardiovascular", "Neurological", "Metabolism"];
+
+fn category_meta() -> &'static [CategoryMeta] {
+ use std::sync::LazyLock;
+ static META: LazyLock> = LazyLock::new(|| {
+ CAT_ORDER.iter().map(|&cat| {
+ let (mp, ec) = SNPS.iter().filter(|s| s.category == cat)
+ .fold((0.0, 0usize), |(s, n), snp| (s + snp.w_alt.max(0.0), n + 1));
+ CategoryMeta { name: cat, max_possible: mp.max(1.0), expected_count: ec }
+ }).collect()
+ });
+ &META
+}
+
+/// Compute composite risk scores from genotype data.
+pub fn compute_risk_scores(genotypes: &HashMap) -> BiomarkerProfile {
+ let meta = category_meta();
+ let mut cat_scores: HashMap<&str, (f64, Vec, usize)> = HashMap::with_capacity(4);
+
+ for snp in SNPS {
+ if let Some(gt) = genotypes.get(snp.rsid) {
+ let code = genotype_code(snp, gt);
+ let w = snp_weight(snp, code);
+ let entry = cat_scores.entry(snp.category).or_insert_with(|| (0.0, Vec::new(), 0));
+ entry.0 += w;
+ entry.2 += 1;
+ if code > 0 {
+ entry.1.push(snp.rsid.to_string());
+ }
+ }
+ }
+
+ for inter in INTERACTIONS {
+ let m = interaction_mod(genotypes, inter);
+ if m > 1.0 {
+ if let Some(entry) = cat_scores.get_mut(inter.category) {
+ entry.0 *= m;
+ }
+ }
+ }
+
+ let mut category_scores = HashMap::with_capacity(meta.len());
+ for cm in meta {
+ let (raw, variants, count) = cat_scores.remove(cm.name).unwrap_or((0.0, Vec::new(), 0));
+ let score = (raw / cm.max_possible).clamp(0.0, 1.0);
+ let confidence = if count > 0 { (count as f64 / cm.expected_count.max(1) as f64).min(1.0) } else { 0.0 };
+ let cat = cm.name.to_string();
+ category_scores.insert(cat.clone(), CategoryScore { category: cat, score, confidence, contributing_variants: variants });
+ }
+
+ let (ws, cs) = category_scores.values()
+ .fold((0.0, 0.0), |(ws, cs), c| (ws + c.score * c.confidence, cs + c.confidence));
+ let global = if cs > 0.0 { ws / cs } else { 0.0 };
+
+ let mut profile = BiomarkerProfile {
+ subject_id: String::new(), timestamp: 0, category_scores,
+ global_risk_score: global, profile_vector: Vec::new(), biomarker_values: HashMap::new(),
+ };
+ profile.profile_vector = encode_profile_vector_with_genotypes(&profile, genotypes);
+ profile
+}
+
+/// Encode a profile into a 64-dim f32 vector (L2-normalized).
+pub fn encode_profile_vector(profile: &BiomarkerProfile) -> Vec {
+ encode_profile_vector_with_genotypes(profile, &HashMap::new())
+}
+
+fn encode_profile_vector_with_genotypes(profile: &BiomarkerProfile, genotypes: &HashMap) -> Vec {
+ let mut v = vec![0.0f32; 64];
+ // Dims 0..50: one-hot genotype encoding (first 17 SNPs x 3 = 51 dims)
+ for (i, snp) in SNPS.iter().take(NUM_ONEHOT_SNPS).enumerate() {
+ let code = genotypes.get(snp.rsid).map(|gt| genotype_code(snp, gt)).unwrap_or(0);
+ v[i * 3 + code as usize] = 1.0;
+ }
+ // Dims 51..54: category scores
+ for (j, cat) in CAT_ORDER.iter().enumerate() {
+ v[51 + j] = profile.category_scores.get(*cat).map(|c| c.score as f32).unwrap_or(0.0);
+ }
+ v[55] = profile.global_risk_score as f32;
+ // Dims 56..59: first 4 interaction modifiers
+ for (j, inter) in INTERACTIONS.iter().take(4).enumerate() {
+ let m = interaction_mod(genotypes, inter);
+ v[56 + j] = if m > 1.0 { (m - 1.0) as f32 } else { 0.0 };
+ }
+ // Dims 60..63: derived clinical scores
+ v[60] = analyze_mthfr(genotypes).score as f32 / 4.0;
+ v[61] = analyze_pain(genotypes).map(|p| p.score as f32 / 4.0).unwrap_or(0.0);
+ v[62] = genotypes.get("rs429358").map(|g| genotype_code(&SNPS[0], g) as f32 / 2.0).unwrap_or(0.0);
+ // LPA composite: average of rs10455872 + rs3798220 genotype codes
+ let lpa = SNPS.iter().filter(|s| s.rsid == "rs10455872" || s.rsid == "rs3798220")
+ .filter_map(|s| genotypes.get(s.rsid).map(|g| genotype_code(s, g) as f32 / 2.0))
+ .sum::() / 2.0;
+ v[63] = lpa;
+
+ let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt();
+ if norm > 0.0 { v.iter_mut().for_each(|x| *x /= norm); }
+ v
+}
+
+fn random_genotype(rng: &mut StdRng, snp: &SnpDef) -> String {
+ let p = snp.maf;
+ let q = 1.0 - p;
+ let r: f64 = rng.gen();
+ if r < q * q { snp.hom_ref } else if r < q * q + 2.0 * p * q { snp.het } else { snp.hom_alt }.to_string()
+}
+
+/// Generate a deterministic synthetic population of biomarker profiles.
+pub fn generate_synthetic_population(count: usize, seed: u64) -> Vec {
+ let mut rng = StdRng::seed_from_u64(seed);
+ let mut pop = Vec::with_capacity(count);
+
+ for i in 0..count {
+ let mut genotypes = HashMap::with_capacity(NUM_SNPS);
+ for snp in SNPS {
+ genotypes.insert(snp.rsid.to_string(), random_genotype(&mut rng, snp));
+ }
+
+ let mut profile = compute_risk_scores(&genotypes);
+ profile.subject_id = format!("SYN-{:06}", i);
+ profile.timestamp = 1700000000 + i as i64;
+
+ let mthfr_score = analyze_mthfr(&genotypes).score;
+ let apoe_code = genotypes.get("rs429358").map(|g| genotype_code(&SNPS[0], g)).unwrap_or(0);
+ let nqo1_idx = SNPS.iter().position(|s| s.rsid == "rs1800566").unwrap();
+ let nqo1_code = genotypes.get("rs1800566").map(|g| genotype_code(&SNPS[nqo1_idx], g)).unwrap_or(0);
+ let lpa_risk: u8 = SNPS.iter().filter(|s| s.rsid == "rs10455872" || s.rsid == "rs3798220")
+ .filter_map(|s| genotypes.get(s.rsid).map(|g| genotype_code(s, g)))
+ .sum();
+ let pcsk9_idx = SNPS.iter().position(|s| s.rsid == "rs11591147").unwrap();
+ let pcsk9_code = genotypes.get("rs11591147").map(|g| genotype_code(&SNPS[pcsk9_idx], g)).unwrap_or(0);
+ profile.biomarker_values.reserve(REFERENCES.len());
+
+ for bref in REFERENCES {
+ let mid = (bref.normal_low + bref.normal_high) / 2.0;
+ let sd = (bref.normal_high - bref.normal_low) / 4.0;
+ let mut val = mid + rng.gen_range(-1.5..1.5) * sd;
+ // Geneβbiomarker correlations from SOTA clinical evidence (additive)
+ let nm = bref.name;
+ if nm == "Homocysteine" && mthfr_score >= 2 { val += sd * (mthfr_score as f64 - 1.0); }
+ if (nm == "Total Cholesterol" || nm == "LDL") && apoe_code > 0 { val += sd * 0.5 * apoe_code as f64; }
+ if nm == "HDL" && apoe_code > 0 { val -= sd * 0.3 * apoe_code as f64; }
+ if nm == "Triglycerides" && apoe_code > 0 { val += sd * 0.4 * apoe_code as f64; }
+ if nm == "Vitamin B12" && mthfr_score >= 2 { val -= sd * 0.4; }
+ if nm == "CRP" && nqo1_code == 2 { val += sd * 0.3; }
+ if nm == "Lp(a)" && lpa_risk > 0 { val += sd * 1.5 * lpa_risk as f64; }
+ if (nm == "LDL" || nm == "Total Cholesterol") && pcsk9_code > 0 { val -= sd * 0.6 * pcsk9_code as f64; }
+ val = val.max(bref.critical_low.unwrap_or(0.0)).max(0.0);
+ if let Some(ch) = bref.critical_high { val = val.min(ch * 1.2); }
+ profile.biomarker_values.insert(bref.name.to_string(), (val * 10.0).round() / 10.0);
+ }
+ pop.push(profile);
+ }
+ pop
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn full_hom_ref() -> HashMap {
+ SNPS.iter().map(|s| (s.rsid.to_string(), s.hom_ref.to_string())).collect()
+ }
+
+ #[test]
+ fn test_z_score_midpoint_is_zero() {
+ let r = &REFERENCES[0]; // Total Cholesterol
+ let mid = (r.normal_low + r.normal_high) / 2.0;
+ assert!((z_score(mid, r)).abs() < 1e-10);
+ }
+
+ #[test]
+ fn test_z_score_high_bound_is_one() {
+ let r = &REFERENCES[0];
+ assert!((z_score(r.normal_high, r) - 1.0).abs() < 1e-10);
+ }
+
+ #[test]
+ fn test_classify_normal() {
+ let r = &REFERENCES[0]; // Total Cholesterol 125-200
+ assert_eq!(classify_biomarker(150.0, r), BiomarkerClassification::Normal);
+ }
+
+ #[test]
+ fn test_classify_critical_high() {
+ let r = &REFERENCES[0]; // critical_high = 300
+ assert_eq!(classify_biomarker(350.0, r), BiomarkerClassification::CriticalHigh);
+ }
+
+ #[test]
+ fn test_classify_low() {
+ let r = &REFERENCES[0]; // normal_low = 125, critical_low = 100
+ assert_eq!(classify_biomarker(110.0, r), BiomarkerClassification::Low);
+ }
+
+ #[test]
+ fn test_classify_critical_low() {
+ let r = &REFERENCES[0]; // critical_low = 100
+ assert_eq!(classify_biomarker(90.0, r), BiomarkerClassification::CriticalLow);
+ }
+
+ #[test]
+ fn test_risk_scores_all_hom_ref_low_risk() {
+ let gts = full_hom_ref();
+ let profile = compute_risk_scores(>s);
+ assert!(profile.global_risk_score < 0.15, "hom-ref should be low risk, got {}", profile.global_risk_score);
+ }
+
+ #[test]
+ fn test_risk_scores_high_cancer_risk() {
+ let mut gts = full_hom_ref();
+ gts.insert("rs80357906".into(), "DI".into());
+ gts.insert("rs1042522".into(), "GG".into());
+ gts.insert("rs11571833".into(), "TT".into());
+ let profile = compute_risk_scores(>s);
+ let cancer = profile.category_scores.get("Cancer Risk").unwrap();
+ assert!(cancer.score > 0.3, "should have elevated cancer risk, got {}", cancer.score);
+ }
+
+ #[test]
+ fn test_vector_dimension_is_64() {
+ let gts = full_hom_ref();
+ let profile = compute_risk_scores(>s);
+ assert_eq!(profile.profile_vector.len(), 64);
+ }
+
+ #[test]
+ fn test_vector_is_l2_normalized() {
+ let mut gts = full_hom_ref();
+ gts.insert("rs4680".into(), "AG".into());
+ gts.insert("rs1799971".into(), "AG".into());
+ let profile = compute_risk_scores(>s);
+ let norm: f32 = profile.profile_vector.iter().map(|x| x * x).sum::().sqrt();
+ assert!((norm - 1.0).abs() < 1e-4, "vector should be L2-normalized, got norm={}", norm);
+ }
+
+ #[test]
+ fn test_interaction_comt_oprm1() {
+ let mut gts = full_hom_ref();
+ gts.insert("rs4680".into(), "AA".into());
+ gts.insert("rs1799971".into(), "GG".into());
+ let with_interaction = compute_risk_scores(>s);
+ let neuro_inter = with_interaction.category_scores.get("Neurological").unwrap().score;
+
+ // Without full interaction (only one variant)
+ let mut gts2 = full_hom_ref();
+ gts2.insert("rs4680".into(), "AA".into());
+ let without_full = compute_risk_scores(>s2);
+ let neuro_single = without_full.category_scores.get("Neurological").unwrap().score;
+
+ assert!(neuro_inter > neuro_single, "interaction should amplify risk");
+ }
+
+ #[test]
+ fn test_interaction_brca1_tp53() {
+ let mut gts = full_hom_ref();
+ gts.insert("rs80357906".into(), "DI".into());
+ gts.insert("rs1042522".into(), "GG".into());
+ let profile = compute_risk_scores(>s);
+ let cancer = profile.category_scores.get("Cancer Risk").unwrap();
+ assert!(cancer.contributing_variants.contains(&"rs80357906".to_string()));
+ assert!(cancer.contributing_variants.contains(&"rs1042522".to_string()));
+ }
+
+ #[test]
+ fn test_population_generation() {
+ let pop = generate_synthetic_population(50, 42);
+ assert_eq!(pop.len(), 50);
+ for p in &pop {
+ assert_eq!(p.profile_vector.len(), 64);
+ assert!(!p.biomarker_values.is_empty());
+ assert!(p.global_risk_score >= 0.0 && p.global_risk_score <= 1.0);
+ }
+ }
+
+ #[test]
+ fn test_population_deterministic() {
+ let a = generate_synthetic_population(10, 99);
+ let b = generate_synthetic_population(10, 99);
+ for (pa, pb) in a.iter().zip(b.iter()) {
+ assert_eq!(pa.subject_id, pb.subject_id);
+ assert!((pa.global_risk_score - pb.global_risk_score).abs() < 1e-10);
+ }
+ }
+
+ #[test]
+ fn test_mthfr_elevates_homocysteine() {
+ let pop = generate_synthetic_population(200, 7);
+ let (mut mthfr_high, mut mthfr_low) = (Vec::new(), Vec::new());
+ for p in &pop {
+ let hcy = p.biomarker_values.get("Homocysteine").copied().unwrap_or(0.0);
+ let mthfr_score = p.category_scores.get("Metabolism").map(|c| c.score).unwrap_or(0.0);
+ if mthfr_score > 0.3 { mthfr_high.push(hcy); } else { mthfr_low.push(hcy); }
+ }
+ if !mthfr_high.is_empty() && !mthfr_low.is_empty() {
+ let avg_high: f64 = mthfr_high.iter().sum::() / mthfr_high.len() as f64;
+ let avg_low: f64 = mthfr_low.iter().sum::() / mthfr_low.len() as f64;
+ assert!(avg_high > avg_low, "MTHFR variants should elevate homocysteine: high={}, low={}", avg_high, avg_low);
+ }
+ }
+
+ #[test]
+ fn test_biomarker_references_count() {
+ assert_eq!(biomarker_references().len(), 13);
+ }
+}
diff --git a/examples/dna/src/biomarker_stream.rs b/examples/dna/src/biomarker_stream.rs
new file mode 100644
index 000000000..bef2cfeb6
--- /dev/null
+++ b/examples/dna/src/biomarker_stream.rs
@@ -0,0 +1,500 @@
+//! Streaming biomarker data simulator with ring buffer and anomaly detection.
+//!
+//! Generates synthetic biomarker readings (glucose, cholesterol, HDL, LDL,
+//! triglycerides, CRP) with configurable noise, drift, and anomaly injection.
+//! Provides a [`StreamProcessor`] with rolling statistics, z-score anomaly
+//! detection, and linear regression trend analysis over a [`RingBuffer`].
+
+use rand::rngs::StdRng;
+use rand::{Rng, SeedableRng};
+use rand_distr::Normal;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+/// Configuration for simulated biomarker streams.
+#[derive(Debug, Clone)]
+pub struct StreamConfig {
+ pub base_interval_ms: u64,
+ pub noise_amplitude: f64,
+ pub drift_rate: f64,
+ pub anomaly_probability: f64,
+ pub anomaly_magnitude: f64,
+ pub num_biomarkers: usize,
+ pub window_size: usize,
+}
+
+impl Default for StreamConfig {
+ fn default() -> Self {
+ Self {
+ base_interval_ms: 1000,
+ noise_amplitude: 0.02,
+ drift_rate: 0.0,
+ anomaly_probability: 0.02,
+ anomaly_magnitude: 2.5,
+ num_biomarkers: 6,
+ window_size: 100,
+ }
+ }
+}
+
+/// A single timestamped biomarker data point.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BiomarkerReading {
+ pub timestamp_ms: u64,
+ pub biomarker_id: String,
+ pub value: f64,
+ pub reference_low: f64,
+ pub reference_high: f64,
+ pub is_anomaly: bool,
+ pub z_score: f64,
+}
+
+/// Fixed-capacity circular buffer backed by a flat `Vec`.
+///
+/// Eliminates the `Option` wrapper used in naive implementations,
+/// halving per-slot memory for primitive types like `f64` (8 bytes vs 16).
+pub struct RingBuffer {
+ buffer: Vec,
+ head: usize,
+ len: usize,
+ capacity: usize,
+}
+
+impl RingBuffer {
+ pub fn new(capacity: usize) -> Self {
+ assert!(capacity > 0, "RingBuffer capacity must be > 0");
+ Self { buffer: vec![T::default(); capacity], head: 0, len: 0, capacity }
+ }
+
+ pub fn push(&mut self, item: T) {
+ self.buffer[self.head] = item;
+ self.head = (self.head + 1) % self.capacity;
+ if self.len < self.capacity {
+ self.len += 1;
+ }
+ }
+
+ pub fn iter(&self) -> impl Iterator- {
+ let start = if self.len < self.capacity { 0 } else { self.head };
+ let (cap, len) = (self.capacity, self.len);
+ (0..len).map(move |i| &self.buffer[(start + i) % cap])
+ }
+
+ pub fn len(&self) -> usize { self.len }
+ pub fn is_full(&self) -> bool { self.len == self.capacity }
+
+ pub fn clear(&mut self) {
+ self.head = 0;
+ self.len = 0;
+ }
+}
+
+// ββ Biomarker definitions βββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+struct BiomarkerDef { id: &'static str, low: f64, high: f64 }
+
+const BIOMARKER_DEFS: &[BiomarkerDef] = &[
+ BiomarkerDef { id: "glucose", low: 70.0, high: 100.0 },
+ BiomarkerDef { id: "cholesterol_total", low: 150.0, high: 200.0 },
+ BiomarkerDef { id: "hdl", low: 40.0, high: 60.0 },
+ BiomarkerDef { id: "ldl", low: 70.0, high: 130.0 },
+ BiomarkerDef { id: "triglycerides", low: 50.0, high: 150.0 },
+ BiomarkerDef { id: "crp", low: 0.1, high: 3.0 },
+];
+
+// ββ Batch generation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/// Generate `count` synthetic readings per active biomarker with noise, drift,
+/// and stochastic anomaly spikes.
+pub fn generate_readings(
+ config: &StreamConfig, count: usize, seed: u64,
+) -> Vec {
+ let mut rng = StdRng::seed_from_u64(seed);
+ let active = &BIOMARKER_DEFS[..config.num_biomarkers.min(BIOMARKER_DEFS.len())];
+ let mut readings = Vec::with_capacity(count * active.len());
+ // Pre-compute distributions per biomarker (avoids Normal::new in inner loop)
+ let dists: Vec<_> = active.iter().map(|def| {
+ let range = def.high - def.low;
+ let mid = (def.low + def.high) / 2.0;
+ let sigma = (config.noise_amplitude * range).max(1e-12);
+ let normal = Normal::new(0.0, sigma).unwrap();
+ let spike = Normal::new(0.0, sigma * config.anomaly_magnitude).unwrap();
+ (mid, range, normal, spike)
+ }).collect();
+ let mut ts: u64 = 0;
+
+ for step in 0..count {
+ for (j, def) in active.iter().enumerate() {
+ let (mid, range, ref normal, ref spike) = dists[j];
+ let drift = config.drift_rate * range * step as f64;
+ let is_anom = rng.gen::() < config.anomaly_probability;
+ let value = if is_anom {
+ (mid + rng.sample::(spike) + drift).max(0.0)
+ } else {
+ (mid + rng.sample::(normal) + drift).max(0.0)
+ };
+ readings.push(BiomarkerReading {
+ timestamp_ms: ts, biomarker_id: def.id.into(), value,
+ reference_low: def.low, reference_high: def.high,
+ is_anomaly: is_anom, z_score: 0.0,
+ });
+ }
+ ts += config.base_interval_ms;
+ }
+ readings
+}
+
+// ββ Statistics & results ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/// Rolling statistics for a single biomarker stream.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StreamStats {
+ pub mean: f64,
+ pub variance: f64,
+ pub min: f64,
+ pub max: f64,
+ pub count: u64,
+ pub anomaly_rate: f64,
+ pub trend_slope: f64,
+ pub ema: f64,
+ pub cusum_pos: f64, // CUSUM positive direction
+ pub cusum_neg: f64, // CUSUM negative direction
+ pub changepoint_detected: bool,
+}
+
+impl Default for StreamStats {
+ fn default() -> Self {
+ Self {
+ mean: 0.0, variance: 0.0, min: f64::MAX, max: f64::MIN,
+ count: 0, anomaly_rate: 0.0, trend_slope: 0.0, ema: 0.0,
+ cusum_pos: 0.0, cusum_neg: 0.0, changepoint_detected: false,
+ }
+ }
+}
+
+/// Result of processing a single reading.
+pub struct ProcessingResult {
+ pub accepted: bool,
+ pub z_score: f64,
+ pub is_anomaly: bool,
+ pub current_trend: f64,
+}
+
+/// Aggregate summary across all biomarker streams.
+pub struct StreamSummary {
+ pub total_readings: u64,
+ pub anomaly_count: u64,
+ pub anomaly_rate: f64,
+ pub biomarker_stats: HashMap,
+ pub throughput_readings_per_sec: f64,
+}
+
+// ββ Stream processor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const EMA_ALPHA: f64 = 0.1;
+const Z_SCORE_THRESHOLD: f64 = 2.5;
+const REF_OVERSHOOT: f64 = 0.20;
+const CUSUM_THRESHOLD: f64 = 4.0; // Cumulative sum threshold for changepoint detection
+const CUSUM_DRIFT: f64 = 0.5; // Allowable drift before CUSUM accumulates
+
+/// Processes biomarker readings with per-stream ring buffers, z-score anomaly
+/// detection, and trend analysis via simple linear regression.
+pub struct StreamProcessor {
+ config: StreamConfig,
+ buffers: HashMap>,
+ stats: HashMap,
+ total_readings: u64,
+ anomaly_count: u64,
+ anom_per_bio: HashMap,
+ start_ts: Option,
+ last_ts: Option,
+}
+
+impl StreamProcessor {
+ pub fn new(config: StreamConfig) -> Self {
+ let cap = config.num_biomarkers;
+ Self {
+ config, buffers: HashMap::with_capacity(cap), stats: HashMap::with_capacity(cap),
+ total_readings: 0, anomaly_count: 0, anom_per_bio: HashMap::with_capacity(cap),
+ start_ts: None, last_ts: None,
+ }
+ }
+
+ pub fn process_reading(&mut self, reading: &BiomarkerReading) -> ProcessingResult {
+ let id = &reading.biomarker_id;
+ if self.start_ts.is_none() { self.start_ts = Some(reading.timestamp_ms); }
+ self.last_ts = Some(reading.timestamp_ms);
+
+ let buf = self.buffers
+ .entry(id.clone())
+ .or_insert_with(|| RingBuffer::new(self.config.window_size));
+ buf.push(reading.value);
+ self.total_readings += 1;
+
+ let (wmean, wstd) = window_mean_std(buf);
+ let z = if wstd > 1e-12 { (reading.value - wmean) / wstd } else { 0.0 };
+
+ let rng = reading.reference_high - reading.reference_low;
+ let overshoot = REF_OVERSHOOT * rng;
+ let oor = reading.value < (reading.reference_low - overshoot)
+ || reading.value > (reading.reference_high + overshoot);
+ let is_anom = z.abs() > Z_SCORE_THRESHOLD || oor;
+
+ if is_anom {
+ self.anomaly_count += 1;
+ *self.anom_per_bio.entry(id.clone()).or_insert(0) += 1;
+ }
+
+ let slope = compute_trend_slope(buf);
+ let bio_anom = *self.anom_per_bio.get(id).unwrap_or(&0);
+ let st = self.stats.entry(id.clone()).or_default();
+ st.count += 1;
+ st.mean = wmean;
+ st.variance = wstd * wstd;
+ st.trend_slope = slope;
+ st.anomaly_rate = bio_anom as f64 / st.count as f64;
+ if reading.value < st.min { st.min = reading.value; }
+ if reading.value > st.max { st.max = reading.value; }
+ st.ema = if st.count == 1 {
+ reading.value
+ } else {
+ EMA_ALPHA * reading.value + (1.0 - EMA_ALPHA) * st.ema
+ };
+ // CUSUM changepoint detection: accumulate deviations from the mean
+ if wstd > 1e-12 {
+ let norm_dev = (reading.value - wmean) / wstd;
+ st.cusum_pos = (st.cusum_pos + norm_dev - CUSUM_DRIFT).max(0.0);
+ st.cusum_neg = (st.cusum_neg - norm_dev - CUSUM_DRIFT).max(0.0);
+ st.changepoint_detected = st.cusum_pos > CUSUM_THRESHOLD || st.cusum_neg > CUSUM_THRESHOLD;
+ if st.changepoint_detected { st.cusum_pos = 0.0; st.cusum_neg = 0.0; }
+ }
+
+ ProcessingResult { accepted: true, z_score: z, is_anomaly: is_anom, current_trend: slope }
+ }
+
+ pub fn get_stats(&self, biomarker_id: &str) -> Option<&StreamStats> {
+ self.stats.get(biomarker_id)
+ }
+
+ pub fn summary(&self) -> StreamSummary {
+ let elapsed = match (self.start_ts, self.last_ts) { (Some(s), Some(e)) if e > s => (e - s) as f64, _ => 1.0 };
+ let ar = if self.total_readings > 0 { self.anomaly_count as f64 / self.total_readings as f64 } else { 0.0 };
+ StreamSummary {
+ total_readings: self.total_readings, anomaly_count: self.anomaly_count, anomaly_rate: ar,
+ biomarker_stats: self.stats.clone(),
+ throughput_readings_per_sec: self.total_readings as f64 / (elapsed / 1000.0),
+ }
+ }
+}
+
+// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/// Single-pass mean and sample standard deviation using Welford's online algorithm.
+/// Avoids iterating the buffer twice (sum then variance) β 2x fewer cache misses.
+fn window_mean_std(buf: &RingBuffer) -> (f64, f64) {
+ let n = buf.len();
+ if n == 0 { return (0.0, 0.0); }
+ let mut mean = 0.0;
+ let mut m2 = 0.0;
+ for (k, &x) in buf.iter().enumerate() {
+ let k1 = (k + 1) as f64;
+ let delta = x - mean;
+ mean += delta / k1;
+ m2 += delta * (x - mean);
+ }
+ if n < 2 { return (mean, 0.0); }
+ (mean, (m2 / (n - 1) as f64).sqrt())
+}
+
+fn compute_trend_slope(buf: &RingBuffer) -> f64 {
+ let n = buf.len();
+ if n < 2 { return 0.0; }
+ let nf = n as f64;
+ let xm = (nf - 1.0) / 2.0;
+ let (mut ys, mut xys, mut xxs) = (0.0, 0.0, 0.0);
+ for (i, &y) in buf.iter().enumerate() {
+ let x = i as f64;
+ ys += y; xys += x * y; xxs += x * x;
+ }
+ let ss_xy = xys - nf * xm * (ys / nf);
+ let ss_xx = xxs - nf * xm * xm;
+ if ss_xx.abs() < 1e-12 { 0.0 } else { ss_xy / ss_xx }
+}
+
+// ββ Tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn reading(ts: u64, id: &str, val: f64, lo: f64, hi: f64) -> BiomarkerReading {
+ BiomarkerReading {
+ timestamp_ms: ts, biomarker_id: id.into(), value: val,
+ reference_low: lo, reference_high: hi, is_anomaly: false, z_score: 0.0,
+ }
+ }
+
+ fn glucose(ts: u64, val: f64) -> BiomarkerReading { reading(ts, "glucose", val, 70.0, 100.0) }
+
+ // -- RingBuffer --
+
+ #[test]
+ fn ring_buffer_push_iter_len() {
+ let mut rb: RingBuffer = RingBuffer::new(4);
+ for v in [10, 20, 30] { rb.push(v); }
+ assert_eq!(rb.iter().copied().collect::>(), vec![10, 20, 30]);
+ assert_eq!(rb.len(), 3);
+ assert!(!rb.is_full());
+ }
+
+ #[test]
+ fn ring_buffer_overflow_keeps_newest() {
+ let mut rb: RingBuffer = RingBuffer::new(3);
+ for v in 1..=4 { rb.push(v); }
+ assert!(rb.is_full());
+ assert_eq!(rb.iter().copied().collect::>(), vec![2, 3, 4]);
+ }
+
+ #[test]
+ fn ring_buffer_capacity_one() {
+ let mut rb: RingBuffer = RingBuffer::new(1);
+ rb.push(42); rb.push(99);
+ assert_eq!(rb.iter().copied().collect::>(), vec![99]);
+ }
+
+ #[test]
+ fn ring_buffer_clear_resets() {
+ let mut rb: RingBuffer = RingBuffer::new(3);
+ rb.push(1); rb.push(2); rb.clear();
+ assert_eq!(rb.len(), 0);
+ assert!(!rb.is_full());
+ assert_eq!(rb.iter().count(), 0);
+ }
+
+ // -- Batch generation --
+
+ #[test]
+ fn generate_correct_count_and_ids() {
+ let cfg = StreamConfig::default();
+ let readings = generate_readings(&cfg, 50, 42);
+ assert_eq!(readings.len(), 50 * cfg.num_biomarkers);
+ let valid: Vec<&str> = BIOMARKER_DEFS.iter().map(|d| d.id).collect();
+ for r in &readings {
+ assert!(valid.contains(&r.biomarker_id.as_str()));
+ }
+ }
+
+ #[test]
+ fn generated_reference_ranges_match_defs() {
+ let readings = generate_readings(&StreamConfig::default(), 20, 123);
+ for r in &readings {
+ let d = BIOMARKER_DEFS.iter().find(|d| d.id == r.biomarker_id).unwrap();
+ assert!((r.reference_low - d.low).abs() < 1e-9);
+ assert!((r.reference_high - d.high).abs() < 1e-9);
+ }
+ }
+
+ #[test]
+ fn generated_values_non_negative() {
+ for r in &generate_readings(&StreamConfig::default(), 100, 999) {
+ assert!(r.value >= 0.0);
+ }
+ }
+
+ // -- StreamProcessor --
+
+ #[test]
+ fn processor_computes_stats() {
+ let cfg = StreamConfig { window_size: 10, ..Default::default() };
+ let mut p = StreamProcessor::new(cfg.clone());
+ for r in &generate_readings(&cfg, 20, 55) { p.process_reading(r); }
+ let s = p.get_stats("glucose").unwrap();
+ assert!(s.count > 0 && s.mean > 0.0 && s.min <= s.max);
+ }
+
+ #[test]
+ fn processor_summary_totals() {
+ let cfg = StreamConfig::default();
+ let mut p = StreamProcessor::new(cfg.clone());
+ for r in &generate_readings(&cfg, 30, 77) { p.process_reading(r); }
+ let s = p.summary();
+ assert_eq!(s.total_readings, 30 * cfg.num_biomarkers as u64);
+ assert!((0.0..=1.0).contains(&s.anomaly_rate));
+ }
+
+ // -- Anomaly detection --
+
+ #[test]
+ fn detects_z_score_anomaly() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
+ for i in 0..20 { p.process_reading(&glucose(i * 1000, 85.0)); }
+ let r = p.process_reading(&glucose(20_000, 300.0));
+ assert!(r.is_anomaly);
+ assert!(r.z_score.abs() > Z_SCORE_THRESHOLD);
+ }
+
+ #[test]
+ fn detects_out_of_range_anomaly() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 5, ..Default::default() });
+ for (i, v) in [80.0, 82.0, 78.0, 84.0, 81.0].iter().enumerate() {
+ p.process_reading(&glucose(i as u64 * 1000, *v));
+ }
+ // 140 >> ref_high(100) + 20%*range(30)=106
+ assert!(p.process_reading(&glucose(5000, 140.0)).is_anomaly);
+ }
+
+ #[test]
+ fn zero_anomaly_rate_for_constant_stream() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 50, ..Default::default() });
+ for i in 0..10 { p.process_reading(&reading(i * 1000, "crp", 1.5, 0.1, 3.0)); }
+ assert!(p.get_stats("crp").unwrap().anomaly_rate.abs() < 1e-9);
+ }
+
+ // -- Trend detection --
+
+ #[test]
+ fn positive_trend_for_increasing() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
+ let mut r = ProcessingResult { accepted: true, z_score: 0.0, is_anomaly: false, current_trend: 0.0 };
+ for i in 0..20 { r = p.process_reading(&glucose(i * 1000, 70.0 + i as f64)); }
+ assert!(r.current_trend > 0.0, "got {}", r.current_trend);
+ }
+
+ #[test]
+ fn negative_trend_for_decreasing() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
+ let mut r = ProcessingResult { accepted: true, z_score: 0.0, is_anomaly: false, current_trend: 0.0 };
+ for i in 0..20 { r = p.process_reading(&reading(i * 1000, "hdl", 60.0 - i as f64 * 0.5, 40.0, 60.0)); }
+ assert!(r.current_trend < 0.0, "got {}", r.current_trend);
+ }
+
+ #[test]
+ fn exact_slope_for_linear_series() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 10, ..Default::default() });
+ for i in 0..10 {
+ p.process_reading(&reading(i * 1000, "ldl", 100.0 + i as f64 * 3.0, 70.0, 130.0));
+ }
+ assert!((p.get_stats("ldl").unwrap().trend_slope - 3.0).abs() < 1e-9);
+ }
+
+ // -- Z-score --
+
+ #[test]
+ fn z_score_small_for_near_mean() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 10, ..Default::default() });
+ for (i, v) in [80.0, 82.0, 78.0, 84.0, 76.0, 86.0, 81.0, 79.0, 83.0].iter().enumerate() {
+ p.process_reading(&glucose(i as u64 * 1000, *v));
+ }
+ let mean = p.get_stats("glucose").unwrap().mean;
+ assert!(p.process_reading(&glucose(9000, mean)).z_score.abs() < 1.0);
+ }
+
+ // -- EMA --
+
+ #[test]
+ fn ema_converges_to_constant() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 50, ..Default::default() });
+ for i in 0..50 { p.process_reading(&reading(i * 1000, "crp", 2.0, 0.1, 3.0)); }
+ assert!((p.get_stats("crp").unwrap().ema - 2.0).abs() < 1e-6);
+ }
+}
diff --git a/examples/dna/src/lib.rs b/examples/dna/src/lib.rs
index eb45f1601..aca22442f 100644
--- a/examples/dna/src/lib.rs
+++ b/examples/dna/src/lib.rs
@@ -17,6 +17,8 @@
#![allow(clippy::all)]
pub mod alignment;
+pub mod biomarker;
+pub mod biomarker_stream;
pub mod epigenomics;
pub mod error;
pub mod genotyping;
@@ -63,6 +65,10 @@ pub use genotyping::{
CallConfidence, CypDiplotype, GenomeBuild, GenotypeAnalysis, GenotypeData, Snp,
};
pub use health::{ApoeResult, HealthVariantResult, MthfrResult, PainProfile};
+pub use biomarker::{
+ BiomarkerClassification, BiomarkerProfile, BiomarkerReference, CategoryScore,
+};
+pub use biomarker_stream::{BiomarkerReading, RingBuffer, StreamConfig, StreamProcessor, StreamStats};
pub use kmer_pagerank::{KmerGraphRanker, SequenceRank};
/// Prelude module for common imports
diff --git a/examples/dna/tests/biomarker_tests.rs b/examples/dna/tests/biomarker_tests.rs
new file mode 100644
index 000000000..8d85d0e14
--- /dev/null
+++ b/examples/dna/tests/biomarker_tests.rs
@@ -0,0 +1,377 @@
+//! Integration tests for the biomarker analysis engine.
+//!
+//! Tests composite risk scoring, profile vector encoding, clinical biomarker
+//! references, synthetic population generation, and streaming biomarker
+//! processing with anomaly and trend detection.
+
+use rvdna::biomarker::*;
+use rvdna::biomarker_stream::*;
+use std::collections::HashMap;
+
+// ============================================================================
+// COMPOSITE RISK SCORING TESTS
+// ============================================================================
+
+#[test]
+fn test_compute_risk_scores_baseline() {
+ // All homozygous reference (low risk) genotypes
+ let mut gts = HashMap::new();
+ gts.insert("rs429358".to_string(), "TT".to_string()); // APOE ref
+ gts.insert("rs7412".to_string(), "CC".to_string()); // APOE ref
+ gts.insert("rs4680".to_string(), "GG".to_string()); // COMT ref
+ gts.insert("rs1799971".to_string(), "AA".to_string()); // OPRM1 ref
+ gts.insert("rs762551".to_string(), "AA".to_string()); // CYP1A2 fast
+ gts.insert("rs1801133".to_string(), "GG".to_string()); // MTHFR ref
+ gts.insert("rs1801131".to_string(), "TT".to_string()); // MTHFR ref
+ gts.insert("rs1042522".to_string(), "CC".to_string()); // TP53 ref
+ gts.insert("rs80357906".to_string(), "DD".to_string()); // BRCA1 ref
+ gts.insert("rs4363657".to_string(), "TT".to_string()); // SLCO1B1 ref
+
+ let profile = compute_risk_scores(>s);
+ assert!(
+ profile.global_risk_score < 0.3,
+ "Baseline should be low risk, got {}",
+ profile.global_risk_score
+ );
+ assert!(!profile.category_scores.is_empty());
+}
+
+#[test]
+fn test_compute_risk_scores_high_risk() {
+ // High-risk genotype combinations
+ let mut gts = HashMap::new();
+ gts.insert("rs429358".to_string(), "CC".to_string()); // APOE e4/e4
+ gts.insert("rs7412".to_string(), "CC".to_string());
+ gts.insert("rs4680".to_string(), "AA".to_string()); // COMT Met/Met
+ gts.insert("rs1799971".to_string(), "GG".to_string()); // OPRM1 Asp/Asp
+ gts.insert("rs1801133".to_string(), "AA".to_string()); // MTHFR 677TT
+ gts.insert("rs1801131".to_string(), "GG".to_string()); // MTHFR 1298CC
+ gts.insert("rs4363657".to_string(), "CC".to_string()); // SLCO1B1 hom variant
+
+ let profile = compute_risk_scores(>s);
+ assert!(
+ profile.global_risk_score > 0.4,
+ "High-risk should score >0.4, got {}",
+ profile.global_risk_score
+ );
+}
+
+// ============================================================================
+// PROFILE VECTOR TESTS
+// ============================================================================
+
+#[test]
+fn test_profile_vector_dimension() {
+ let gts = HashMap::new(); // empty genotypes
+ let profile = compute_risk_scores(>s);
+ assert_eq!(
+ profile.profile_vector.len(),
+ 64,
+ "Profile vector must be exactly 64 dimensions"
+ );
+}
+
+#[test]
+fn test_profile_vector_normalized() {
+ let mut gts = HashMap::new();
+ gts.insert("rs429358".to_string(), "CT".to_string());
+ gts.insert("rs4680".to_string(), "AG".to_string());
+ let profile = compute_risk_scores(>s);
+ let mag: f32 = profile
+ .profile_vector
+ .iter()
+ .map(|x| x * x)
+ .sum::()
+ .sqrt();
+ assert!(
+ (mag - 1.0).abs() < 0.01 || mag == 0.0,
+ "Vector should be L2-normalized, got magnitude {}",
+ mag
+ );
+}
+
+// ============================================================================
+// BIOMARKER REFERENCE TESTS
+// ============================================================================
+
+#[test]
+fn test_biomarker_references_exist() {
+ let refs = biomarker_references();
+ assert!(
+ refs.len() >= 13,
+ "Should have at least 13 biomarker references, got {}",
+ refs.len()
+ );
+}
+
+#[test]
+fn test_z_score_computation() {
+ let refs = biomarker_references();
+ let cholesterol_ref = refs.iter().find(|r| r.name == "Total Cholesterol").unwrap();
+
+ // Normal value should have |z| < 2
+ let z_normal = z_score(180.0, cholesterol_ref);
+ assert!(
+ z_normal.abs() < 2.0,
+ "Normal cholesterol z-score should be small: {}",
+ z_normal
+ );
+
+ // High value should have z > 0
+ let z_high = z_score(300.0, cholesterol_ref);
+ assert!(
+ z_high > 0.0,
+ "High cholesterol should have positive z-score: {}",
+ z_high
+ );
+}
+
+#[test]
+fn test_biomarker_classification() {
+ let refs = biomarker_references();
+ let glucose_ref = refs.iter().find(|r| r.name == "Fasting Glucose").unwrap();
+
+ let class_normal = classify_biomarker(85.0, glucose_ref);
+ // Should be normal range
+ let class_high = classify_biomarker(200.0, glucose_ref);
+ // Should be high/critical
+ assert_ne!(format!("{:?}", class_normal), format!("{:?}", class_high));
+}
+
+// ============================================================================
+// SYNTHETIC POPULATION TESTS
+// ============================================================================
+
+#[test]
+fn test_synthetic_population() {
+ let pop = generate_synthetic_population(100, 42);
+ assert_eq!(pop.len(), 100);
+
+ // All vectors should be 64-dim
+ for profile in &pop {
+ assert_eq!(profile.profile_vector.len(), 64);
+ }
+
+ // Risk scores should span a range
+ let scores: Vec = pop.iter().map(|p| p.global_risk_score).collect();
+ let min = scores.iter().cloned().fold(f64::INFINITY, f64::min);
+ let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
+ assert!(
+ max - min > 0.1,
+ "Population should have risk score variance, range: {:.3}..{:.3}",
+ min,
+ max
+ );
+}
+
+#[test]
+fn test_synthetic_population_deterministic() {
+ let pop1 = generate_synthetic_population(50, 42);
+ let pop2 = generate_synthetic_population(50, 42);
+ assert_eq!(pop1.len(), pop2.len());
+ for (a, b) in pop1.iter().zip(pop2.iter()) {
+ assert!((a.global_risk_score - b.global_risk_score).abs() < 1e-10);
+ }
+}
+
+// ============================================================================
+// STREAMING TESTS
+// ============================================================================
+
+#[test]
+fn test_ring_buffer_basic() {
+ let mut rb: RingBuffer = RingBuffer::new(5);
+ for i in 0..3 {
+ rb.push(i as f64);
+ }
+ assert_eq!(rb.len(), 3);
+ let items: Vec = rb.iter().cloned().collect();
+ assert_eq!(items, vec![0.0, 1.0, 2.0]);
+}
+
+#[test]
+fn test_ring_buffer_overflow() {
+ let mut rb: RingBuffer = RingBuffer::new(3);
+ for i in 0..5 {
+ rb.push(i as f64);
+ }
+ assert_eq!(rb.len(), 3);
+ let items: Vec = rb.iter().cloned().collect();
+ assert_eq!(items, vec![2.0, 3.0, 4.0]);
+}
+
+#[test]
+fn test_stream_generation() {
+ let config = StreamConfig::default();
+ let num_biomarkers = config.num_biomarkers;
+ let readings = generate_readings(&config, 1000, 42);
+ // generate_readings produces count * num_biomarkers total readings
+ assert_eq!(readings.len(), 1000 * num_biomarkers);
+
+ // All values should be positive
+ for r in &readings {
+ assert!(
+ r.value > 0.0,
+ "Biomarker values should be positive: {} = {}",
+ r.biomarker_id,
+ r.value
+ );
+ }
+}
+
+#[test]
+fn test_stream_processor() {
+ let config = StreamConfig::default();
+ let num_biomarkers = config.num_biomarkers;
+ let readings = generate_readings(&config, 500, 42);
+ let mut processor = StreamProcessor::new(config);
+
+ for reading in &readings {
+ processor.process_reading(reading);
+ }
+
+ let summary = processor.summary();
+ assert_eq!(summary.total_readings, 500 * num_biomarkers as u64);
+ assert!(
+ summary.anomaly_rate < 0.2,
+ "Anomaly rate should be reasonable: {}",
+ summary.anomaly_rate
+ );
+}
+
+#[test]
+fn test_anomaly_detection() {
+ let config = StreamConfig {
+ anomaly_probability: 0.0, // No random anomalies
+ num_biomarkers: 1,
+ ..StreamConfig::default()
+ };
+
+ let readings = generate_readings(&config, 200, 42);
+ let mut processor = StreamProcessor::new(config);
+
+ for reading in &readings {
+ processor.process_reading(reading);
+ }
+
+ // With no anomaly injection, anomaly rate should be very low
+ let summary = processor.summary();
+ assert!(
+ summary.anomaly_rate < 0.1,
+ "Without injection, anomaly rate should be low: {}",
+ summary.anomaly_rate
+ );
+}
+
+// ============================================================================
+// GENE-GENE INTERACTION TESTS
+// ============================================================================
+
+#[test]
+fn test_mthfr_comt_interaction() {
+ // MTHFR A1298C hom + COMT Met/Met should amplify neurological score
+ let mut gts_both = HashMap::new();
+ gts_both.insert("rs1801131".to_string(), "GG".to_string()); // A1298C hom_alt
+ gts_both.insert("rs4680".to_string(), "AA".to_string()); // COMT Met/Met
+ let both = compute_risk_scores(>s_both);
+
+ let mut gts_one = HashMap::new();
+ gts_one.insert("rs4680".to_string(), "AA".to_string()); // COMT Met/Met only
+ let one = compute_risk_scores(>s_one);
+
+ let n_both = both.category_scores.get("Neurological").unwrap().score;
+ let n_one = one.category_scores.get("Neurological").unwrap().score;
+ assert!(n_both > n_one, "MTHFRΓCOMT interaction should amplify: {n_both} > {n_one}");
+}
+
+#[test]
+fn test_drd2_comt_interaction() {
+ // DRD2 Taq1A + COMT variant should amplify neurological score
+ let mut gts = HashMap::new();
+ gts.insert("rs1800497".to_string(), "AA".to_string()); // DRD2 hom_alt
+ gts.insert("rs4680".to_string(), "AA".to_string()); // COMT Met/Met
+ let with = compute_risk_scores(>s);
+
+ let mut gts2 = HashMap::new();
+ gts2.insert("rs1800497".to_string(), "AA".to_string()); // DRD2 only
+ let without = compute_risk_scores(>s2);
+
+ let n_with = with.category_scores.get("Neurological").unwrap().score;
+ let n_without = without.category_scores.get("Neurological").unwrap().score;
+ assert!(n_with > n_without, "DRD2ΓCOMT interaction should amplify: {n_with} > {n_without}");
+}
+
+// ============================================================================
+// GENE-BIOMARKER CORRELATION TESTS
+// ============================================================================
+
+#[test]
+fn test_apoe_lowers_hdl_in_population() {
+ let pop = generate_synthetic_population(300, 88);
+ let (mut apoe_hdl, mut ref_hdl) = (Vec::new(), Vec::new());
+ for p in &pop {
+ let hdl = p.biomarker_values.get("HDL").copied().unwrap_or(0.0);
+ // APOE carriers have elevated neurological scores from rs429358
+ let neuro = p.category_scores.get("Neurological").map(|c| c.score).unwrap_or(0.0);
+ if neuro > 0.3 { apoe_hdl.push(hdl); } else { ref_hdl.push(hdl); }
+ }
+ if !apoe_hdl.is_empty() && !ref_hdl.is_empty() {
+ let avg_apoe = apoe_hdl.iter().sum::() / apoe_hdl.len() as f64;
+ let avg_ref = ref_hdl.iter().sum::() / ref_hdl.len() as f64;
+ assert!(avg_apoe < avg_ref, "APOE e4 should lower HDL: {avg_apoe} < {avg_ref}");
+ }
+}
+
+#[test]
+fn test_cusum_changepoint_detection() {
+ let mut p = StreamProcessor::new(StreamConfig { window_size: 20, ..Default::default() });
+ // Establish baseline
+ for i in 0..30 {
+ p.process_reading(&BiomarkerReading {
+ timestamp_ms: i * 1000, biomarker_id: "glucose".into(),
+ value: 85.0, reference_low: 70.0, reference_high: 100.0,
+ is_anomaly: false, z_score: 0.0,
+ });
+ }
+ // Inject a sustained shift (changepoint)
+ for i in 30..50 {
+ p.process_reading(&BiomarkerReading {
+ timestamp_ms: i * 1000, biomarker_id: "glucose".into(),
+ value: 120.0, reference_low: 70.0, reference_high: 100.0,
+ is_anomaly: false, z_score: 0.0,
+ });
+ }
+ let stats = p.get_stats("glucose").unwrap();
+ // After sustained shift, CUSUM should have triggered at least once
+ // (changepoint_detected resets after trigger, but the sustained shift
+ // will keep re-triggering, so the final state may or may not be true)
+ assert!(stats.mean > 90.0, "Mean should shift upward after changepoint: {}", stats.mean);
+}
+
+#[test]
+fn test_trend_detection() {
+ let config = StreamConfig {
+ drift_rate: 0.5, // Strong upward drift
+ anomaly_probability: 0.0,
+ num_biomarkers: 1,
+ window_size: 50,
+ ..StreamConfig::default()
+ };
+
+ let readings = generate_readings(&config, 200, 42);
+ let mut processor = StreamProcessor::new(config);
+
+ for reading in &readings {
+ processor.process_reading(reading);
+ }
+
+ // Should detect positive trend
+ let summary = processor.summary();
+ for (_, stats) in &summary.biomarker_stats {
+ assert!(
+ stats.trend_slope > 0.0,
+ "Should detect upward trend, got slope: {}",
+ stats.trend_slope
+ );
+ }
+}
diff --git a/npm/packages/rvdna/index.d.ts b/npm/packages/rvdna/index.d.ts
index 27863f301..51b19d93e 100644
--- a/npm/packages/rvdna/index.d.ts
+++ b/npm/packages/rvdna/index.d.ts
@@ -176,3 +176,184 @@ export interface AnalysisResult {
* @param text - Raw 23andMe file contents
*/
export function analyze23andMe(text: string): AnalysisResult;
+
+// -------------------------------------------------------------------
+// Biomarker Risk Scoring Engine (v0.3.0)
+// -------------------------------------------------------------------
+
+/** Clinical reference range for a single biomarker. */
+export interface BiomarkerReference {
+ name: string;
+ unit: string;
+ normalLow: number;
+ normalHigh: number;
+ criticalLow: number | null;
+ criticalHigh: number | null;
+ category: string;
+}
+
+/** Classification of a biomarker value relative to its reference range. */
+export type BiomarkerClassification = 'CriticalLow' | 'Low' | 'Normal' | 'High' | 'CriticalHigh';
+
+/** Risk score for a single clinical category. */
+export interface CategoryScore {
+ category: string;
+ score: number;
+ confidence: number;
+ contributingVariants: string[];
+}
+
+/** Full biomarker + genotype risk profile for one subject. */
+export interface BiomarkerProfile {
+ subjectId: string;
+ timestamp: number;
+ categoryScores: Record;
+ globalRiskScore: number;
+ profileVector: Float32Array;
+ biomarkerValues: Record;
+}
+
+/** SNP risk descriptor. */
+export interface SnpDef {
+ rsid: string;
+ category: string;
+ wRef: number;
+ wHet: number;
+ wAlt: number;
+ homRef: string;
+ het: string;
+ homAlt: string;
+ maf: number;
+}
+
+/** Gene-gene interaction descriptor. */
+export interface InteractionDef {
+ rsidA: string;
+ rsidB: string;
+ modifier: number;
+ category: string;
+}
+
+/** 13 clinical biomarker reference ranges. */
+export const BIOMARKER_REFERENCES: readonly BiomarkerReference[];
+
+/** 20-SNP risk table (mirrors Rust biomarker.rs). */
+export const SNPS: readonly SnpDef[];
+
+/** 6 gene-gene interaction modifiers. */
+export const INTERACTIONS: readonly InteractionDef[];
+
+/** Category ordering: Cancer Risk, Cardiovascular, Neurological, Metabolism. */
+export const CAT_ORDER: readonly string[];
+
+/** Return the static biomarker reference table. */
+export function biomarkerReferences(): readonly BiomarkerReference[];
+
+/** Compute a z-score for a value relative to a reference range. */
+export function zScore(value: number, ref: BiomarkerReference): number;
+
+/** Classify a biomarker value against its reference range. */
+export function classifyBiomarker(value: number, ref: BiomarkerReference): BiomarkerClassification;
+
+/** Compute composite risk scores from genotype data (20 SNPs, 6 interactions). */
+export function computeRiskScores(genotypes: Map): BiomarkerProfile;
+
+/** Encode a profile into a 64-dim L2-normalized Float32Array. */
+export function encodeProfileVector(profile: BiomarkerProfile): Float32Array;
+
+/** Generate a deterministic synthetic population of biomarker profiles. */
+export function generateSyntheticPopulation(count: number, seed: number): BiomarkerProfile[];
+
+// -------------------------------------------------------------------
+// Streaming Biomarker Processor (v0.3.0)
+// -------------------------------------------------------------------
+
+/** Biomarker stream definition. */
+export interface BiomarkerDef {
+ id: string;
+ low: number;
+ high: number;
+}
+
+/** 6 streaming biomarker definitions. */
+export const BIOMARKER_DEFS: readonly BiomarkerDef[];
+
+/** Configuration for the streaming biomarker simulator. */
+export interface StreamConfig {
+ baseIntervalMs: number;
+ noiseAmplitude: number;
+ driftRate: number;
+ anomalyProbability: number;
+ anomalyMagnitude: number;
+ numBiomarkers: number;
+ windowSize: number;
+}
+
+/** A single timestamped biomarker data point. */
+export interface BiomarkerReading {
+ timestampMs: number;
+ biomarkerId: string;
+ value: number;
+ referenceLow: number;
+ referenceHigh: number;
+ isAnomaly: boolean;
+ zScore: number;
+}
+
+/** Rolling statistics for a single biomarker stream. */
+export interface StreamStats {
+ mean: number;
+ variance: number;
+ min: number;
+ max: number;
+ count: number;
+ anomalyRate: number;
+ trendSlope: number;
+ ema: number;
+ cusumPos: number;
+ cusumNeg: number;
+ changepointDetected: boolean;
+}
+
+/** Result of processing a single reading. */
+export interface ProcessingResult {
+ accepted: boolean;
+ zScore: number;
+ isAnomaly: boolean;
+ currentTrend: number;
+}
+
+/** Aggregate summary across all biomarker streams. */
+export interface StreamSummary {
+ totalReadings: number;
+ anomalyCount: number;
+ anomalyRate: number;
+ biomarkerStats: Record;
+ throughputReadingsPerSec: number;
+}
+
+/** Fixed-capacity circular buffer backed by Float64Array. */
+export class RingBuffer {
+ constructor(capacity: number);
+ push(item: number): void;
+ toArray(): number[];
+ readonly length: number;
+ readonly capacity: number;
+ isFull(): boolean;
+ clear(): void;
+ [Symbol.iterator](): IterableIterator;
+}
+
+/** Streaming biomarker processor with per-stream ring buffers, z-score anomaly detection, CUSUM changepoint detection, and trend analysis. */
+export class StreamProcessor {
+ constructor(config?: StreamConfig);
+ processReading(reading: BiomarkerReading): ProcessingResult;
+ getStats(biomarkerId: string): StreamStats | null;
+ summary(): StreamSummary;
+}
+
+/** Return default stream configuration. */
+export function defaultStreamConfig(): StreamConfig;
+
+/** Generate batch of synthetic biomarker readings. */
+export function generateReadings(config: StreamConfig, count: number, seed: number): BiomarkerReading[];
diff --git a/npm/packages/rvdna/index.js b/npm/packages/rvdna/index.js
index ee25cb20e..71d3c6c14 100644
--- a/npm/packages/rvdna/index.js
+++ b/npm/packages/rvdna/index.js
@@ -343,6 +343,13 @@ function analyze23andMe(text) {
};
}
+// -------------------------------------------------------------------
+// Biomarker Analysis Engine (v0.3.0 β mirrors biomarker.rs + biomarker_stream.rs)
+// -------------------------------------------------------------------
+
+const biomarkerModule = require('./src/biomarker');
+const streamModule = require('./src/stream');
+
module.exports = {
// Original API
encode2bit,
@@ -361,6 +368,25 @@ module.exports = {
determineApoe,
analyze23andMe,
+ // Biomarker Risk Scoring Engine (v0.3.0)
+ biomarkerReferences: biomarkerModule.biomarkerReferences,
+ zScore: biomarkerModule.zScore,
+ classifyBiomarker: biomarkerModule.classifyBiomarker,
+ computeRiskScores: biomarkerModule.computeRiskScores,
+ encodeProfileVector: biomarkerModule.encodeProfileVector,
+ generateSyntheticPopulation: biomarkerModule.generateSyntheticPopulation,
+ BIOMARKER_REFERENCES: biomarkerModule.BIOMARKER_REFERENCES,
+ SNPS: biomarkerModule.SNPS,
+ INTERACTIONS: biomarkerModule.INTERACTIONS,
+ CAT_ORDER: biomarkerModule.CAT_ORDER,
+
+ // Streaming Biomarker Processor (v0.3.0)
+ RingBuffer: streamModule.RingBuffer,
+ StreamProcessor: streamModule.StreamProcessor,
+ generateReadings: streamModule.generateReadings,
+ defaultStreamConfig: streamModule.defaultStreamConfig,
+ BIOMARKER_DEFS: streamModule.BIOMARKER_DEFS,
+
// Re-export native module for advanced use
native: nativeModule,
};
diff --git a/npm/packages/rvdna/package.json b/npm/packages/rvdna/package.json
index 20636a4fc..a52d3f230 100644
--- a/npm/packages/rvdna/package.json
+++ b/npm/packages/rvdna/package.json
@@ -1,7 +1,7 @@
{
"name": "@ruvector/rvdna",
- "version": "0.2.0",
- "description": "rvDNA β AI-native genomic analysis. Native 23andMe genotyping, CYP2D6/CYP2C19 pharmacogenomics, 17 health variants, variant calling, protein prediction, and HNSW vector search powered by Rust via NAPI-RS.",
+ "version": "0.3.0",
+ "description": "rvDNA β AI-native genomic analysis. 20-SNP biomarker risk scoring, streaming anomaly detection, 64-dim profile vectors, 23andMe genotyping, CYP2D6/CYP2C19 pharmacogenomics, variant calling, protein prediction, and HNSW vector search.",
"main": "index.js",
"types": "index.d.ts",
"author": "rUv (https://ruv.io)",
@@ -21,11 +21,12 @@
"files": [
"index.js",
"index.d.ts",
+ "src/",
"README.md"
],
"scripts": {
"build:napi": "napi build --platform --release --cargo-cwd ../../../examples/dna",
- "test": "node test.js"
+ "test": "node tests/test-biomarker.js"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -45,6 +46,11 @@
"bioinformatics",
"dna",
"rvdna",
+ "biomarker",
+ "health",
+ "risk-score",
+ "streaming",
+ "anomaly-detection",
"23andme",
"pharmacogenomics",
"variant-calling",
@@ -53,6 +59,7 @@
"vector-search",
"napi",
"rust",
- "ai"
+ "ai",
+ "wasm"
]
}
diff --git a/npm/packages/rvdna/src/biomarker.js b/npm/packages/rvdna/src/biomarker.js
new file mode 100644
index 000000000..87c4a70f7
--- /dev/null
+++ b/npm/packages/rvdna/src/biomarker.js
@@ -0,0 +1,351 @@
+'use strict';
+
+// ββ Clinical reference ranges (mirrors REFERENCES in biomarker.rs) ββββββββββ
+
+const BIOMARKER_REFERENCES = Object.freeze([
+ { name: 'Total Cholesterol', unit: 'mg/dL', normalLow: 125, normalHigh: 200, criticalLow: 100, criticalHigh: 300, category: 'Lipid' },
+ { name: 'LDL', unit: 'mg/dL', normalLow: 50, normalHigh: 100, criticalLow: 25, criticalHigh: 190, category: 'Lipid' },
+ { name: 'HDL', unit: 'mg/dL', normalLow: 40, normalHigh: 90, criticalLow: 20, criticalHigh: null, category: 'Lipid' },
+ { name: 'Triglycerides', unit: 'mg/dL', normalLow: 35, normalHigh: 150, criticalLow: 20, criticalHigh: 500, category: 'Lipid' },
+ { name: 'Fasting Glucose', unit: 'mg/dL', normalLow: 70, normalHigh: 100, criticalLow: 50, criticalHigh: 250, category: 'Metabolic' },
+ { name: 'HbA1c', unit: '%', normalLow: 4, normalHigh: 5.7, criticalLow: null, criticalHigh: 9, category: 'Metabolic' },
+ { name: 'Homocysteine', unit: 'umol/L', normalLow: 5, normalHigh: 15, criticalLow: null, criticalHigh: 30, category: 'Metabolic' },
+ { name: 'Vitamin D', unit: 'ng/mL', normalLow: 30, normalHigh: 80, criticalLow: 10, criticalHigh: 150, category: 'Nutritional' },
+ { name: 'CRP', unit: 'mg/L', normalLow: 0, normalHigh: 3, criticalLow: null, criticalHigh: 10, category: 'Inflammatory' },
+ { name: 'TSH', unit: 'mIU/L', normalLow: 0.4, normalHigh: 4, criticalLow: 0.1, criticalHigh: 10, category: 'Thyroid' },
+ { name: 'Ferritin', unit: 'ng/mL', normalLow: 20, normalHigh: 250, criticalLow: 10, criticalHigh: 1000, category: 'Iron' },
+ { name: 'Vitamin B12', unit: 'pg/mL', normalLow: 200, normalHigh: 900, criticalLow: 150, criticalHigh: null, category: 'Nutritional' },
+ { name: 'Lp(a)', unit: 'nmol/L', normalLow: 0, normalHigh: 75, criticalLow: null, criticalHigh: 200, category: 'Lipid' },
+]);
+
+// ββ 20-SNP risk table (mirrors SNPS in biomarker.rs) ββββββββββββββββββββββββ
+
+const SNPS = Object.freeze([
+ { rsid: 'rs429358', category: 'Neurological', wRef: 0, wHet: 0.4, wAlt: 0.9, homRef: 'TT', het: 'CT', homAlt: 'CC', maf: 0.14 },
+ { rsid: 'rs7412', category: 'Neurological', wRef: 0, wHet: -0.15, wAlt: -0.3, homRef: 'CC', het: 'CT', homAlt: 'TT', maf: 0.08 },
+ { rsid: 'rs1042522', category: 'Cancer Risk', wRef: 0, wHet: 0.25, wAlt: 0.5, homRef: 'CC', het: 'CG', homAlt: 'GG', maf: 0.40 },
+ { rsid: 'rs80357906', category: 'Cancer Risk', wRef: 0, wHet: 0.7, wAlt: 0.95, homRef: 'DD', het: 'DI', homAlt: 'II', maf: 0.003 },
+ { rsid: 'rs28897696', category: 'Cancer Risk', wRef: 0, wHet: 0.3, wAlt: 0.6, homRef: 'GG', het: 'AG', homAlt: 'AA', maf: 0.005 },
+ { rsid: 'rs11571833', category: 'Cancer Risk', wRef: 0, wHet: 0.20, wAlt: 0.5, homRef: 'AA', het: 'AT', homAlt: 'TT', maf: 0.01 },
+ { rsid: 'rs1801133', category: 'Metabolism', wRef: 0, wHet: 0.35, wAlt: 0.7, homRef: 'GG', het: 'AG', homAlt: 'AA', maf: 0.32 },
+ { rsid: 'rs1801131', category: 'Metabolism', wRef: 0, wHet: 0.10, wAlt: 0.25, homRef: 'TT', het: 'GT', homAlt: 'GG', maf: 0.30 },
+ { rsid: 'rs4680', category: 'Neurological', wRef: 0, wHet: 0.2, wAlt: 0.45, homRef: 'GG', het: 'AG', homAlt: 'AA', maf: 0.50 },
+ { rsid: 'rs1799971', category: 'Neurological', wRef: 0, wHet: 0.2, wAlt: 0.4, homRef: 'AA', het: 'AG', homAlt: 'GG', maf: 0.15 },
+ { rsid: 'rs762551', category: 'Metabolism', wRef: 0, wHet: 0.15, wAlt: 0.35, homRef: 'AA', het: 'AC', homAlt: 'CC', maf: 0.37 },
+ { rsid: 'rs4988235', category: 'Metabolism', wRef: 0, wHet: 0.05, wAlt: 0.15, homRef: 'AA', het: 'AG', homAlt: 'GG', maf: 0.24 },
+ { rsid: 'rs53576', category: 'Neurological', wRef: 0, wHet: 0.1, wAlt: 0.25, homRef: 'GG', het: 'AG', homAlt: 'AA', maf: 0.35 },
+ { rsid: 'rs6311', category: 'Neurological', wRef: 0, wHet: 0.15, wAlt: 0.3, homRef: 'CC', het: 'CT', homAlt: 'TT', maf: 0.45 },
+ { rsid: 'rs1800497', category: 'Neurological', wRef: 0, wHet: 0.25, wAlt: 0.5, homRef: 'GG', het: 'AG', homAlt: 'AA', maf: 0.20 },
+ { rsid: 'rs4363657', category: 'Cardiovascular', wRef: 0, wHet: 0.35, wAlt: 0.7, homRef: 'TT', het: 'CT', homAlt: 'CC', maf: 0.15 },
+ { rsid: 'rs1800566', category: 'Cancer Risk', wRef: 0, wHet: 0.15, wAlt: 0.30, homRef: 'CC', het: 'CT', homAlt: 'TT', maf: 0.22 },
+ { rsid: 'rs10455872', category: 'Cardiovascular', wRef: 0, wHet: 0.40, wAlt: 0.75, homRef: 'AA', het: 'AG', homAlt: 'GG', maf: 0.07 },
+ { rsid: 'rs3798220', category: 'Cardiovascular', wRef: 0, wHet: 0.35, wAlt: 0.65, homRef: 'TT', het: 'CT', homAlt: 'CC', maf: 0.02 },
+ { rsid: 'rs11591147', category: 'Cardiovascular', wRef: 0, wHet: -0.30, wAlt: -0.55, homRef: 'GG', het: 'GT', homAlt: 'TT', maf: 0.024 },
+]);
+
+// ββ Gene-gene interactions (mirrors INTERACTIONS in biomarker.rs) ββββββββββββ
+
+const INTERACTIONS = Object.freeze([
+ { rsidA: 'rs4680', rsidB: 'rs1799971', modifier: 1.4, category: 'Neurological' },
+ { rsidA: 'rs1801133', rsidB: 'rs1801131', modifier: 1.3, category: 'Metabolism' },
+ { rsidA: 'rs429358', rsidB: 'rs1042522', modifier: 1.2, category: 'Cancer Risk' },
+ { rsidA: 'rs80357906',rsidB: 'rs1042522', modifier: 1.5, category: 'Cancer Risk' },
+ { rsidA: 'rs1801131', rsidB: 'rs4680', modifier: 1.25, category: 'Neurological' },
+ { rsidA: 'rs1800497', rsidB: 'rs4680', modifier: 1.2, category: 'Neurological' },
+]);
+
+const CAT_ORDER = ['Cancer Risk', 'Cardiovascular', 'Neurological', 'Metabolism'];
+const NUM_ONEHOT_SNPS = 17;
+
+// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function genotypeCode(snp, gt) {
+ if (gt === snp.homRef) return 0;
+ if (gt.length === 2 && gt[0] !== gt[1]) return 1;
+ return 2;
+}
+
+function snpWeight(snp, code) {
+ return code === 0 ? snp.wRef : code === 1 ? snp.wHet : snp.wAlt;
+}
+
+// Pre-built rsid -> index lookup (O(1) instead of O(n) findIndex)
+const RSID_INDEX = new Map();
+for (let i = 0; i < SNPS.length; i++) RSID_INDEX.set(SNPS[i].rsid, i);
+
+// Pre-cache LPA SNP references to avoid repeated iteration
+const LPA_SNPS = SNPS.filter(s => s.rsid === 'rs10455872' || s.rsid === 'rs3798220');
+
+function snpIndex(rsid) {
+ const idx = RSID_INDEX.get(rsid);
+ return idx !== undefined ? idx : -1;
+}
+
+function isNonRef(genotypes, rsid) {
+ const idx = RSID_INDEX.get(rsid);
+ if (idx === undefined) return false;
+ const gt = genotypes.get(rsid);
+ return gt !== undefined && gt !== SNPS[idx].homRef;
+}
+
+function interactionMod(genotypes, ix) {
+ return (isNonRef(genotypes, ix.rsidA) && isNonRef(genotypes, ix.rsidB)) ? ix.modifier : 1.0;
+}
+
+// Pre-compute category metadata (mirrors category_meta() in Rust)
+const CATEGORY_META = CAT_ORDER.map(cat => {
+ let maxPossible = 0;
+ let expectedCount = 0;
+ for (const snp of SNPS) {
+ if (snp.category === cat) {
+ maxPossible += Math.max(snp.wAlt, 0);
+ expectedCount++;
+ }
+ }
+ return { name: cat, maxPossible: Math.max(maxPossible, 1), expectedCount };
+});
+
+// Mulberry32 PRNG β deterministic, fast, no dependencies
+function mulberry32(seed) {
+ let t = (seed + 0x6D2B79F5) | 0;
+ return function () {
+ t = (t + 0x6D2B79F5) | 0;
+ let z = t ^ (t >>> 15);
+ z = Math.imul(z | 1, z);
+ z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
+ return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ββ Simplified MTHFR/pain scoring (mirrors health.rs analysis functions) ββββ
+
+function analyzeMthfr(genotypes) {
+ let score = 0;
+ const gt677 = genotypes.get('rs1801133');
+ const gt1298 = genotypes.get('rs1801131');
+ if (gt677) {
+ const code = genotypeCode(SNPS[6], gt677);
+ score += code;
+ }
+ if (gt1298) {
+ const code = genotypeCode(SNPS[7], gt1298);
+ score += code;
+ }
+ return { score };
+}
+
+function analyzePain(genotypes) {
+ const gtComt = genotypes.get('rs4680');
+ const gtOprm1 = genotypes.get('rs1799971');
+ if (!gtComt || !gtOprm1) return null;
+ const comtCode = genotypeCode(SNPS[8], gtComt);
+ const oprm1Code = genotypeCode(SNPS[9], gtOprm1);
+ return { score: comtCode + oprm1Code };
+}
+
+// ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function biomarkerReferences() {
+ return BIOMARKER_REFERENCES;
+}
+
+function zScore(value, ref_) {
+ const mid = (ref_.normalLow + ref_.normalHigh) / 2;
+ const halfRange = (ref_.normalHigh - ref_.normalLow) / 2;
+ if (halfRange === 0) return 0;
+ return (value - mid) / halfRange;
+}
+
+function classifyBiomarker(value, ref_) {
+ if (ref_.criticalLow !== null && value < ref_.criticalLow) return 'CriticalLow';
+ if (value < ref_.normalLow) return 'Low';
+ if (ref_.criticalHigh !== null && value > ref_.criticalHigh) return 'CriticalHigh';
+ if (value > ref_.normalHigh) return 'High';
+ return 'Normal';
+}
+
+function computeRiskScores(genotypes) {
+ const catScores = new Map(); // category -> { raw, variants, count }
+
+ for (const snp of SNPS) {
+ const gt = genotypes.get(snp.rsid);
+ if (gt === undefined) continue;
+ const code = genotypeCode(snp, gt);
+ const w = snpWeight(snp, code);
+ if (!catScores.has(snp.category)) {
+ catScores.set(snp.category, { raw: 0, variants: [], count: 0 });
+ }
+ const entry = catScores.get(snp.category);
+ entry.raw += w;
+ entry.count++;
+ if (code > 0) entry.variants.push(snp.rsid);
+ }
+
+ for (const inter of INTERACTIONS) {
+ const m = interactionMod(genotypes, inter);
+ if (m > 1.0 && catScores.has(inter.category)) {
+ catScores.get(inter.category).raw *= m;
+ }
+ }
+
+ const categoryScores = {};
+ for (const cm of CATEGORY_META) {
+ const entry = catScores.get(cm.name) || { raw: 0, variants: [], count: 0 };
+ const score = Math.min(Math.max(entry.raw / cm.maxPossible, 0), 1);
+ const confidence = entry.count > 0 ? Math.min(entry.count / Math.max(cm.expectedCount, 1), 1) : 0;
+ categoryScores[cm.name] = {
+ category: cm.name,
+ score,
+ confidence,
+ contributingVariants: entry.variants,
+ };
+ }
+
+ let ws = 0, cs = 0;
+ for (const c of Object.values(categoryScores)) {
+ ws += c.score * c.confidence;
+ cs += c.confidence;
+ }
+ const globalRiskScore = cs > 0 ? ws / cs : 0;
+
+ const profile = {
+ subjectId: '',
+ timestamp: 0,
+ categoryScores,
+ globalRiskScore,
+ profileVector: null,
+ biomarkerValues: {},
+ };
+ profile.profileVector = encodeProfileVectorWithGenotypes(profile, genotypes);
+ return profile;
+}
+
+function encodeProfileVector(profile) {
+ return encodeProfileVectorWithGenotypes(profile, new Map());
+}
+
+function encodeProfileVectorWithGenotypes(profile, genotypes) {
+ const v = new Float32Array(64);
+
+ // Dims 0..50: one-hot genotype encoding (first 17 SNPs x 3 = 51 dims)
+ for (let i = 0; i < NUM_ONEHOT_SNPS; i++) {
+ const snp = SNPS[i];
+ const gt = genotypes.get(snp.rsid);
+ const code = gt !== undefined ? genotypeCode(snp, gt) : 0;
+ v[i * 3 + code] = 1.0;
+ }
+
+ // Dims 51..54: category scores
+ for (let j = 0; j < CAT_ORDER.length; j++) {
+ const cs = profile.categoryScores[CAT_ORDER[j]];
+ v[51 + j] = cs ? cs.score : 0;
+ }
+ v[55] = profile.globalRiskScore;
+
+ // Dims 56..59: first 4 interaction modifiers
+ for (let j = 0; j < 4; j++) {
+ const m = interactionMod(genotypes, INTERACTIONS[j]);
+ v[56 + j] = m > 1 ? m - 1 : 0;
+ }
+
+ // Dims 60..63: derived clinical scores
+ v[60] = analyzeMthfr(genotypes).score / 4;
+ const pain = analyzePain(genotypes);
+ v[61] = pain ? pain.score / 4 : 0;
+ const apoeGt = genotypes.get('rs429358');
+ v[62] = apoeGt !== undefined ? genotypeCode(SNPS[0], apoeGt) / 2 : 0;
+
+ // LPA composite: average of rs10455872 + rs3798220 genotype codes (cached)
+ let lpaSum = 0, lpaCount = 0;
+ for (const snp of LPA_SNPS) {
+ const gt = genotypes.get(snp.rsid);
+ if (gt !== undefined) {
+ lpaSum += genotypeCode(snp, gt) / 2;
+ lpaCount++;
+ }
+ }
+ v[63] = lpaCount > 0 ? lpaSum / 2 : 0;
+
+ // L2-normalize
+ let norm = 0;
+ for (let i = 0; i < 64; i++) norm += v[i] * v[i];
+ norm = Math.sqrt(norm);
+ if (norm > 0) for (let i = 0; i < 64; i++) v[i] /= norm;
+
+ return v;
+}
+
+function randomGenotype(rng, snp) {
+ const p = snp.maf;
+ const q = 1 - p;
+ const r = rng();
+ if (r < q * q) return snp.homRef;
+ if (r < q * q + 2 * p * q) return snp.het;
+ return snp.homAlt;
+}
+
+function generateSyntheticPopulation(count, seed) {
+ const rng = mulberry32(seed);
+ const pop = [];
+
+ for (let i = 0; i < count; i++) {
+ const genotypes = new Map();
+ for (const snp of SNPS) {
+ genotypes.set(snp.rsid, randomGenotype(rng, snp));
+ }
+
+ const profile = computeRiskScores(genotypes);
+ profile.subjectId = `SYN-${String(i).padStart(6, '0')}`;
+ profile.timestamp = 1700000000 + i;
+
+ const mthfrScore = analyzeMthfr(genotypes).score;
+ const apoeCode = genotypes.get('rs429358') ? genotypeCode(SNPS[0], genotypes.get('rs429358')) : 0;
+ const nqo1Idx = RSID_INDEX.get('rs1800566');
+ const nqo1Code = genotypes.get('rs1800566') ? genotypeCode(SNPS[nqo1Idx], genotypes.get('rs1800566')) : 0;
+
+ let lpaRisk = 0;
+ for (const snp of LPA_SNPS) {
+ const gt = genotypes.get(snp.rsid);
+ if (gt) lpaRisk += genotypeCode(snp, gt);
+ }
+
+ const pcsk9Idx = RSID_INDEX.get('rs11591147');
+ const pcsk9Code = genotypes.get('rs11591147') ? genotypeCode(SNPS[pcsk9Idx], genotypes.get('rs11591147')) : 0;
+
+ for (const bref of BIOMARKER_REFERENCES) {
+ const mid = (bref.normalLow + bref.normalHigh) / 2;
+ const sd = (bref.normalHigh - bref.normalLow) / 4;
+ let val = mid + (rng() * 3 - 1.5) * sd;
+
+ // Gene->biomarker correlations (mirrors Rust)
+ const nm = bref.name;
+ if (nm === 'Homocysteine' && mthfrScore >= 2) val += sd * (mthfrScore - 1);
+ if ((nm === 'Total Cholesterol' || nm === 'LDL') && apoeCode > 0) val += sd * 0.5 * apoeCode;
+ if (nm === 'HDL' && apoeCode > 0) val -= sd * 0.3 * apoeCode;
+ if (nm === 'Triglycerides' && apoeCode > 0) val += sd * 0.4 * apoeCode;
+ if (nm === 'Vitamin B12' && mthfrScore >= 2) val -= sd * 0.4;
+ if (nm === 'CRP' && nqo1Code === 2) val += sd * 0.3;
+ if (nm === 'Lp(a)' && lpaRisk > 0) val += sd * 1.5 * lpaRisk;
+ if ((nm === 'LDL' || nm === 'Total Cholesterol') && pcsk9Code > 0) val -= sd * 0.6 * pcsk9Code;
+
+ val = Math.max(val, bref.criticalLow || 0, 0);
+ if (bref.criticalHigh !== null) val = Math.min(val, bref.criticalHigh * 1.2);
+ profile.biomarkerValues[bref.name] = Math.round(val * 10) / 10;
+ }
+ pop.push(profile);
+ }
+ return pop;
+}
+
+module.exports = {
+ BIOMARKER_REFERENCES,
+ SNPS,
+ INTERACTIONS,
+ CAT_ORDER,
+ biomarkerReferences,
+ zScore,
+ classifyBiomarker,
+ computeRiskScores,
+ encodeProfileVector,
+ generateSyntheticPopulation,
+};
diff --git a/npm/packages/rvdna/src/stream.js b/npm/packages/rvdna/src/stream.js
new file mode 100644
index 000000000..c58ad9195
--- /dev/null
+++ b/npm/packages/rvdna/src/stream.js
@@ -0,0 +1,312 @@
+'use strict';
+
+// ββ Constants (identical to biomarker_stream.rs) βββββββββββββββββββββββββββββ
+
+const EMA_ALPHA = 0.1;
+const Z_SCORE_THRESHOLD = 2.5;
+const REF_OVERSHOOT = 0.20;
+const CUSUM_THRESHOLD = 4.0;
+const CUSUM_DRIFT = 0.5;
+
+// ββ Biomarker definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const BIOMARKER_DEFS = Object.freeze([
+ { id: 'glucose', low: 70, high: 100 },
+ { id: 'cholesterol_total', low: 150, high: 200 },
+ { id: 'hdl', low: 40, high: 60 },
+ { id: 'ldl', low: 70, high: 130 },
+ { id: 'triglycerides', low: 50, high: 150 },
+ { id: 'crp', low: 0.1, high: 3.0 },
+]);
+
+// ββ RingBuffer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+class RingBuffer {
+ constructor(capacity) {
+ if (capacity <= 0) throw new Error('RingBuffer capacity must be > 0');
+ this._buffer = new Float64Array(capacity);
+ this._head = 0;
+ this._len = 0;
+ this._capacity = capacity;
+ }
+
+ push(item) {
+ this._buffer[this._head] = item;
+ this._head = (this._head + 1) % this._capacity;
+ if (this._len < this._capacity) this._len++;
+ }
+
+ /** Push item and return evicted value (NaN if buffer wasn't full). */
+ pushPop(item) {
+ const wasFull = this._len === this._capacity;
+ const evicted = wasFull ? this._buffer[this._head] : NaN;
+ this._buffer[this._head] = item;
+ this._head = (this._head + 1) % this._capacity;
+ if (!wasFull) this._len++;
+ return evicted;
+ }
+
+ /** Iterate in insertion order (oldest to newest). */
+ *[Symbol.iterator]() {
+ const start = this._len < this._capacity ? 0 : this._head;
+ for (let i = 0; i < this._len; i++) {
+ yield this._buffer[(start + i) % this._capacity];
+ }
+ }
+
+ /** Return values as a plain array (oldest to newest). */
+ toArray() {
+ const arr = new Array(this._len);
+ const start = this._len < this._capacity ? 0 : this._head;
+ for (let i = 0; i < this._len; i++) {
+ arr[i] = this._buffer[(start + i) % this._capacity];
+ }
+ return arr;
+ }
+
+ get length() { return this._len; }
+ get capacity() { return this._capacity; }
+ isFull() { return this._len === this._capacity; }
+
+ clear() {
+ this._head = 0;
+ this._len = 0;
+ }
+}
+
+// ββ Welford's online mean+std (single-pass, mirrors Rust) ββββββββββββββββββββ
+
+function windowMeanStd(buf) {
+ const n = buf.length;
+ if (n === 0) return [0, 0];
+ let mean = 0, m2 = 0, k = 0;
+ for (const x of buf) {
+ k++;
+ const delta = x - mean;
+ mean += delta / k;
+ m2 += delta * (x - mean);
+ }
+ if (n < 2) return [mean, 0];
+ return [mean, Math.sqrt(m2 / (n - 1))];
+}
+
+// ββ Trend slope via simple linear regression (mirrors Rust) ββββββββββββββββββ
+
+function computeTrendSlope(buf) {
+ const n = buf.length;
+ if (n < 2) return 0;
+ const nf = n;
+ const xm = (nf - 1) / 2;
+ let ys = 0, xys = 0, xxs = 0, i = 0;
+ for (const y of buf) {
+ ys += y;
+ xys += i * y;
+ xxs += i * i;
+ i++;
+ }
+ const ssXy = xys - nf * xm * (ys / nf);
+ const ssXx = xxs - nf * xm * xm;
+ return Math.abs(ssXx) < 1e-12 ? 0 : ssXy / ssXx;
+}
+
+// ββ StreamConfig βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function defaultStreamConfig() {
+ return {
+ baseIntervalMs: 1000,
+ noiseAmplitude: 0.02,
+ driftRate: 0.0,
+ anomalyProbability: 0.02,
+ anomalyMagnitude: 2.5,
+ numBiomarkers: 6,
+ windowSize: 100,
+ };
+}
+
+// ββ Mulberry32 PRNG ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function mulberry32(seed) {
+ let t = (seed + 0x6D2B79F5) | 0;
+ return function () {
+ t = (t + 0x6D2B79F5) | 0;
+ let z = t ^ (t >>> 15);
+ z = Math.imul(z | 1, z);
+ z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
+ return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// Box-Muller for normal distribution
+function normalSample(rng, mean, stddev) {
+ const u1 = rng();
+ const u2 = rng();
+ return mean + stddev * Math.sqrt(-2 * Math.log(u1 || 1e-12)) * Math.cos(2 * Math.PI * u2);
+}
+
+// ββ Batch generation (mirrors generate_readings in Rust) βββββββββββββββββββββ
+
+function generateReadings(config, count, seed) {
+ const rng = mulberry32(seed);
+ const active = BIOMARKER_DEFS.slice(0, Math.min(config.numBiomarkers, BIOMARKER_DEFS.length));
+ const readings = [];
+
+ // Pre-compute distributions per biomarker
+ const dists = active.map(def => {
+ const range = def.high - def.low;
+ const mid = (def.low + def.high) / 2;
+ const sigma = Math.max(config.noiseAmplitude * range, 1e-12);
+ return { mid, range, sigma };
+ });
+
+ let ts = 0;
+ for (let step = 0; step < count; step++) {
+ for (let j = 0; j < active.length; j++) {
+ const def = active[j];
+ const { mid, range, sigma } = dists[j];
+ const drift = config.driftRate * range * step;
+ const isAnomaly = rng() < config.anomalyProbability;
+ const effectiveSigma = isAnomaly ? sigma * config.anomalyMagnitude : sigma;
+ const value = Math.max(normalSample(rng, mid + drift, effectiveSigma), 0);
+ readings.push({
+ timestampMs: ts,
+ biomarkerId: def.id,
+ value,
+ referenceLow: def.low,
+ referenceHigh: def.high,
+ isAnomaly,
+ zScore: 0,
+ });
+ }
+ ts += config.baseIntervalMs;
+ }
+ return readings;
+}
+
+// ββ StreamProcessor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+class StreamProcessor {
+ constructor(config) {
+ this._config = config || defaultStreamConfig();
+ this._buffers = new Map();
+ this._stats = new Map();
+ this._totalReadings = 0;
+ this._anomalyCount = 0;
+ this._anomPerBio = new Map();
+ this._welford = new Map();
+ this._startTs = null;
+ this._lastTs = null;
+ }
+
+ _initBiomarker(id) {
+ this._buffers.set(id, new RingBuffer(this._config.windowSize));
+ this._stats.set(id, {
+ mean: 0, variance: 0, min: Infinity, max: -Infinity,
+ count: 0, anomalyRate: 0, trendSlope: 0, ema: 0,
+ cusumPos: 0, cusumNeg: 0, changepointDetected: false,
+ });
+ // Incremental Welford state for windowed mean/variance (O(1) per reading)
+ this._welford.set(id, { n: 0, mean: 0, m2: 0 });
+ }
+
+ processReading(reading) {
+ const id = reading.biomarkerId;
+ if (this._startTs === null) this._startTs = reading.timestampMs;
+ this._lastTs = reading.timestampMs;
+
+ if (!this._buffers.has(id)) this._initBiomarker(id);
+
+ const buf = this._buffers.get(id);
+ const evicted = buf.pushPop(reading.value);
+ this._totalReadings++;
+
+ // Incremental windowed Welford: O(1) add + O(1) remove
+ const w = this._welford.get(id);
+ const val = reading.value;
+ if (Number.isNaN(evicted)) {
+ // Buffer wasn't full β just add
+ w.n++;
+ const d1 = val - w.mean;
+ w.mean += d1 / w.n;
+ w.m2 += d1 * (val - w.mean);
+ } else {
+ // Buffer full β remove evicted, add new (n stays the same)
+ const oldMean = w.mean;
+ w.mean += (val - evicted) / w.n;
+ w.m2 += (val - evicted) * ((val - w.mean) + (evicted - oldMean));
+ if (w.m2 < 0) w.m2 = 0; // numerical guard
+ }
+ const wmean = w.mean;
+ const wstd = w.n > 1 ? Math.sqrt(w.m2 / (w.n - 1)) : 0;
+
+ const z = wstd > 1e-12 ? (val - wmean) / wstd : 0;
+
+ const rng = reading.referenceHigh - reading.referenceLow;
+ const overshoot = REF_OVERSHOOT * rng;
+ const oor = val < (reading.referenceLow - overshoot) ||
+ val > (reading.referenceHigh + overshoot);
+ const isAnomaly = Math.abs(z) > Z_SCORE_THRESHOLD || oor;
+
+ if (isAnomaly) {
+ this._anomalyCount++;
+ this._anomPerBio.set(id, (this._anomPerBio.get(id) || 0) + 1);
+ }
+
+ const slope = computeTrendSlope(buf);
+ const bioAnom = this._anomPerBio.get(id) || 0;
+
+ const st = this._stats.get(id);
+ st.count++;
+ st.mean = wmean;
+ st.variance = wstd * wstd;
+ st.trendSlope = slope;
+ st.anomalyRate = bioAnom / st.count;
+ if (val < st.min) st.min = val;
+ if (val > st.max) st.max = val;
+ st.ema = st.count === 1
+ ? val
+ : EMA_ALPHA * val + (1 - EMA_ALPHA) * st.ema;
+
+ // CUSUM changepoint detection
+ if (wstd > 1e-12) {
+ const normDev = (val - wmean) / wstd;
+ st.cusumPos = Math.max(st.cusumPos + normDev - CUSUM_DRIFT, 0);
+ st.cusumNeg = Math.max(st.cusumNeg - normDev - CUSUM_DRIFT, 0);
+ st.changepointDetected = st.cusumPos > CUSUM_THRESHOLD || st.cusumNeg > CUSUM_THRESHOLD;
+ if (st.changepointDetected) { st.cusumPos = 0; st.cusumNeg = 0; }
+ }
+
+ return { accepted: true, zScore: z, isAnomaly, currentTrend: slope };
+ }
+
+ getStats(biomarkerId) {
+ return this._stats.get(biomarkerId) || null;
+ }
+
+ summary() {
+ const elapsed = (this._startTs !== null && this._lastTs !== null && this._lastTs > this._startTs)
+ ? this._lastTs - this._startTs : 1;
+ const ar = this._totalReadings > 0 ? this._anomalyCount / this._totalReadings : 0;
+ const statsObj = {};
+ for (const [k, v] of this._stats) statsObj[k] = { ...v };
+ return {
+ totalReadings: this._totalReadings,
+ anomalyCount: this._anomalyCount,
+ anomalyRate: ar,
+ biomarkerStats: statsObj,
+ throughputReadingsPerSec: this._totalReadings / (elapsed / 1000),
+ };
+ }
+}
+
+module.exports = {
+ RingBuffer,
+ StreamProcessor,
+ BIOMARKER_DEFS,
+ EMA_ALPHA,
+ Z_SCORE_THRESHOLD,
+ REF_OVERSHOOT,
+ CUSUM_THRESHOLD,
+ CUSUM_DRIFT,
+ defaultStreamConfig,
+ generateReadings,
+};
diff --git a/npm/packages/rvdna/tests/fixtures/sample-high-risk-cardio.23andme.txt b/npm/packages/rvdna/tests/fixtures/sample-high-risk-cardio.23andme.txt
new file mode 100644
index 000000000..69d523f71
--- /dev/null
+++ b/npm/packages/rvdna/tests/fixtures/sample-high-risk-cardio.23andme.txt
@@ -0,0 +1,33 @@
+# 23andMe raw data file β Scenario: High-risk cardiovascular + MTHFR compound het
+# This file is format version: v5
+# Below is a subset of data (build 37, GRCh37/hg19)
+# rsid chromosome position genotype
+rs429358 19 45411941 CT
+rs7412 19 45412079 CC
+rs1042522 17 7579472 CG
+rs80357906 17 41246537 DD
+rs28897696 17 41244999 GG
+rs11571833 13 32972626 AA
+rs1801133 1 11856378 AA
+rs1801131 1 11854476 GT
+rs4680 22 19951271 AG
+rs1799971 6 154360797 AG
+rs762551 15 75041917 AC
+rs4988235 2 136608646 AG
+rs53576 3 8804371 AG
+rs6311 13 47471478 CT
+rs1800497 11 113270828 AG
+rs4363657 12 21331549 CT
+rs1800566 16 69745145 CT
+rs10455872 6 161010118 AG
+rs3798220 6 160961137 CT
+rs11591147 1 55505647 GG
+rs3892097 22 42524947 CT
+rs35742686 22 42523791 DD
+rs5030655 22 42522393 DD
+rs1065852 22 42526694 CT
+rs28371725 22 42525772 TT
+rs28371706 22 42523610 CC
+rs4244285 10 96541616 AG
+rs4986893 10 96540410 GG
+rs12248560 10 96521657 CT
diff --git a/npm/packages/rvdna/tests/fixtures/sample-low-risk-baseline.23andme.txt b/npm/packages/rvdna/tests/fixtures/sample-low-risk-baseline.23andme.txt
new file mode 100644
index 000000000..ed8f10b45
--- /dev/null
+++ b/npm/packages/rvdna/tests/fixtures/sample-low-risk-baseline.23andme.txt
@@ -0,0 +1,33 @@
+# 23andMe raw data file β Scenario: Low-risk baseline (all reference genotypes)
+# This file is format version: v5
+# Below is a subset of data (build 38, GRCh38/hg38)
+# rsid chromosome position genotype
+rs429358 19 45411941 TT
+rs7412 19 45412079 CC
+rs1042522 17 7579472 CC
+rs80357906 17 41246537 DD
+rs28897696 17 41244999 GG
+rs11571833 13 32972626 AA
+rs1801133 1 11856378 GG
+rs1801131 1 11854476 TT
+rs4680 22 19951271 GG
+rs1799971 6 154360797 AA
+rs762551 15 75041917 AA
+rs4988235 2 136608646 AA
+rs53576 3 8804371 GG
+rs6311 13 47471478 CC
+rs1800497 11 113270828 GG
+rs4363657 12 21331549 TT
+rs1800566 16 69745145 CC
+rs10455872 6 161010118 AA
+rs3798220 6 160961137 TT
+rs11591147 1 55505647 GG
+rs3892097 22 42524947 CC
+rs35742686 22 42523791 DD
+rs5030655 22 42522393 DD
+rs1065852 22 42526694 CC
+rs28371725 22 42525772 CC
+rs28371706 22 42523610 CC
+rs4244285 10 96541616 GG
+rs4986893 10 96540410 GG
+rs12248560 10 96521657 CC
diff --git a/npm/packages/rvdna/tests/fixtures/sample-multi-risk.23andme.txt b/npm/packages/rvdna/tests/fixtures/sample-multi-risk.23andme.txt
new file mode 100644
index 000000000..202aa0195
--- /dev/null
+++ b/npm/packages/rvdna/tests/fixtures/sample-multi-risk.23andme.txt
@@ -0,0 +1,33 @@
+# 23andMe raw data file β Scenario: APOE e4/e4 + BRCA1 carrier + NQO1 null
+# This file is format version: v5
+# Below is a subset of data (build 37, GRCh37/hg19)
+# rsid chromosome position genotype
+rs429358 19 45411941 CC
+rs7412 19 45412079 CC
+rs1042522 17 7579472 GG
+rs80357906 17 41246537 DI
+rs28897696 17 41244999 AG
+rs11571833 13 32972626 AT
+rs1801133 1 11856378 AG
+rs1801131 1 11854476 TT
+rs4680 22 19951271 AA
+rs1799971 6 154360797 GG
+rs762551 15 75041917 CC
+rs4988235 2 136608646 GG
+rs53576 3 8804371 AA
+rs6311 13 47471478 TT
+rs1800497 11 113270828 AA
+rs4363657 12 21331549 CC
+rs1800566 16 69745145 TT
+rs10455872 6 161010118 GG
+rs3798220 6 160961137 CC
+rs11591147 1 55505647 GG
+rs3892097 22 42524947 CC
+rs35742686 22 42523791 DD
+rs5030655 22 42522393 DD
+rs1065852 22 42526694 CC
+rs28371725 22 42525772 CC
+rs28371706 22 42523610 CC
+rs4244285 10 96541616 GG
+rs4986893 10 96540410 GG
+rs12248560 10 96521657 CC
diff --git a/npm/packages/rvdna/tests/fixtures/sample-pcsk9-protective.23andme.txt b/npm/packages/rvdna/tests/fixtures/sample-pcsk9-protective.23andme.txt
new file mode 100644
index 000000000..624cb7dbe
--- /dev/null
+++ b/npm/packages/rvdna/tests/fixtures/sample-pcsk9-protective.23andme.txt
@@ -0,0 +1,33 @@
+# 23andMe raw data file β Scenario: PCSK9 protective + minimal risk
+# This file is format version: v5
+# Below is a subset of data (build 37, GRCh37/hg19)
+# rsid chromosome position genotype
+rs429358 19 45411941 TT
+rs7412 19 45412079 CT
+rs1042522 17 7579472 CC
+rs80357906 17 41246537 DD
+rs28897696 17 41244999 GG
+rs11571833 13 32972626 AA
+rs1801133 1 11856378 GG
+rs1801131 1 11854476 TT
+rs4680 22 19951271 GG
+rs1799971 6 154360797 AA
+rs762551 15 75041917 AA
+rs4988235 2 136608646 AA
+rs53576 3 8804371 GG
+rs6311 13 47471478 CC
+rs1800497 11 113270828 GG
+rs4363657 12 21331549 TT
+rs1800566 16 69745145 CC
+rs10455872 6 161010118 AA
+rs3798220 6 160961137 TT
+rs11591147 1 55505647 GT
+rs3892097 22 42524947 CC
+rs35742686 22 42523791 DD
+rs5030655 22 42522393 DD
+rs1065852 22 42526694 CC
+rs28371725 22 42525772 CC
+rs28371706 22 42523610 CC
+rs4244285 10 96541616 GG
+rs4986893 10 96540410 GG
+rs12248560 10 96521657 CC
diff --git a/npm/packages/rvdna/tests/test-biomarker.js b/npm/packages/rvdna/tests/test-biomarker.js
new file mode 100644
index 000000000..165aad5d0
--- /dev/null
+++ b/npm/packages/rvdna/tests/test-biomarker.js
@@ -0,0 +1,457 @@
+'use strict';
+
+const {
+ biomarkerReferences, zScore, classifyBiomarker,
+ computeRiskScores, encodeProfileVector, generateSyntheticPopulation,
+ SNPS, INTERACTIONS, CAT_ORDER,
+} = require('../src/biomarker');
+
+const {
+ RingBuffer, StreamProcessor, generateReadings, defaultStreamConfig,
+ Z_SCORE_THRESHOLD,
+} = require('../src/stream');
+
+// ββ Test harness βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+let passed = 0, failed = 0, benchResults = [];
+
+function assert(cond, msg) {
+ if (!cond) throw new Error(`Assertion failed: ${msg}`);
+}
+
+function assertClose(a, b, eps, msg) {
+ if (Math.abs(a - b) > eps) throw new Error(`${msg}: ${a} != ${b} (eps=${eps})`);
+}
+
+function test(name, fn) {
+ try {
+ fn();
+ passed++;
+ process.stdout.write(` PASS ${name}\n`);
+ } catch (e) {
+ failed++;
+ process.stdout.write(` FAIL ${name}: ${e.message}\n`);
+ }
+}
+
+function bench(name, fn, iterations) {
+ // Warmup
+ for (let i = 0; i < Math.min(iterations, 1000); i++) fn();
+ const start = performance.now();
+ for (let i = 0; i < iterations; i++) fn();
+ const elapsed = performance.now() - start;
+ const perOp = (elapsed / iterations * 1000).toFixed(2);
+ benchResults.push({ name, perOp: `${perOp} us`, total: `${elapsed.toFixed(1)} ms`, iterations });
+ process.stdout.write(` BENCH ${name}: ${perOp} us/op (${iterations} iters, ${elapsed.toFixed(1)} ms)\n`);
+}
+
+// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function fullHomRef() {
+ const gts = new Map();
+ for (const snp of SNPS) gts.set(snp.rsid, snp.homRef);
+ return gts;
+}
+
+function reading(ts, id, val, lo, hi) {
+ return { timestampMs: ts, biomarkerId: id, value: val, referenceLow: lo, referenceHigh: hi, isAnomaly: false, zScore: 0 };
+}
+
+function glucose(ts, val) { return reading(ts, 'glucose', val, 70, 100); }
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Biomarker Reference Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Biomarker References ---\n');
+
+test('biomarker_references_count', () => {
+ assert(biomarkerReferences().length === 13, `expected 13, got ${biomarkerReferences().length}`);
+});
+
+test('z_score_midpoint_is_zero', () => {
+ const ref = biomarkerReferences()[0]; // Total Cholesterol
+ const mid = (ref.normalLow + ref.normalHigh) / 2;
+ assertClose(zScore(mid, ref), 0, 1e-10, 'midpoint z-score');
+});
+
+test('z_score_high_bound_is_one', () => {
+ const ref = biomarkerReferences()[0];
+ assertClose(zScore(ref.normalHigh, ref), 1.0, 1e-10, 'high-bound z-score');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Classification Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Classification ---\n');
+
+test('classify_normal', () => {
+ const ref = biomarkerReferences()[0]; // 125-200
+ assert(classifyBiomarker(150, ref) === 'Normal', 'expected Normal');
+});
+
+test('classify_high', () => {
+ const ref = biomarkerReferences()[0]; // normalHigh=200, criticalHigh=300
+ assert(classifyBiomarker(250, ref) === 'High', 'expected High');
+});
+
+test('classify_critical_high', () => {
+ const ref = biomarkerReferences()[0]; // criticalHigh=300
+ assert(classifyBiomarker(350, ref) === 'CriticalHigh', 'expected CriticalHigh');
+});
+
+test('classify_low', () => {
+ const ref = biomarkerReferences()[0]; // normalLow=125, criticalLow=100
+ assert(classifyBiomarker(110, ref) === 'Low', 'expected Low');
+});
+
+test('classify_critical_low', () => {
+ const ref = biomarkerReferences()[0]; // criticalLow=100
+ assert(classifyBiomarker(90, ref) === 'CriticalLow', 'expected CriticalLow');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Risk Scoring Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Risk Scoring ---\n');
+
+test('all_hom_ref_low_risk', () => {
+ const gts = fullHomRef();
+ const profile = computeRiskScores(gts);
+ assert(profile.globalRiskScore < 0.15, `hom-ref should be low risk, got ${profile.globalRiskScore}`);
+});
+
+test('high_cancer_risk', () => {
+ const gts = fullHomRef();
+ gts.set('rs80357906', 'DI');
+ gts.set('rs1042522', 'GG');
+ gts.set('rs11571833', 'TT');
+ const profile = computeRiskScores(gts);
+ const cancer = profile.categoryScores['Cancer Risk'];
+ assert(cancer.score > 0.3, `should have elevated cancer risk, got ${cancer.score}`);
+});
+
+test('interaction_comt_oprm1', () => {
+ const gts = fullHomRef();
+ gts.set('rs4680', 'AA');
+ gts.set('rs1799971', 'GG');
+ const withInteraction = computeRiskScores(gts);
+ const neuroInter = withInteraction.categoryScores['Neurological'].score;
+
+ const gts2 = fullHomRef();
+ gts2.set('rs4680', 'AA');
+ const withoutFull = computeRiskScores(gts2);
+ const neuroSingle = withoutFull.categoryScores['Neurological'].score;
+
+ assert(neuroInter > neuroSingle, `interaction should amplify risk: ${neuroInter} > ${neuroSingle}`);
+});
+
+test('interaction_brca1_tp53', () => {
+ const gts = fullHomRef();
+ gts.set('rs80357906', 'DI');
+ gts.set('rs1042522', 'GG');
+ const profile = computeRiskScores(gts);
+ const cancer = profile.categoryScores['Cancer Risk'];
+ assert(cancer.contributingVariants.includes('rs80357906'), 'missing rs80357906');
+ assert(cancer.contributingVariants.includes('rs1042522'), 'missing rs1042522');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Profile Vector Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Profile Vectors ---\n');
+
+test('vector_dimension_is_64', () => {
+ const gts = fullHomRef();
+ const profile = computeRiskScores(gts);
+ assert(profile.profileVector.length === 64, `expected 64, got ${profile.profileVector.length}`);
+});
+
+test('vector_is_l2_normalized', () => {
+ const gts = fullHomRef();
+ gts.set('rs4680', 'AG');
+ gts.set('rs1799971', 'AG');
+ const profile = computeRiskScores(gts);
+ let norm = 0;
+ for (let i = 0; i < 64; i++) norm += profile.profileVector[i] ** 2;
+ norm = Math.sqrt(norm);
+ assertClose(norm, 1.0, 1e-4, 'L2 norm');
+});
+
+test('vector_deterministic', () => {
+ const gts = fullHomRef();
+ gts.set('rs1801133', 'AG');
+ const a = computeRiskScores(gts);
+ const b = computeRiskScores(gts);
+ for (let i = 0; i < 64; i++) {
+ assertClose(a.profileVector[i], b.profileVector[i], 1e-10, `dim ${i}`);
+ }
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Population Generation Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Population Generation ---\n');
+
+test('population_correct_count', () => {
+ const pop = generateSyntheticPopulation(50, 42);
+ assert(pop.length === 50, `expected 50, got ${pop.length}`);
+ for (const p of pop) {
+ assert(p.profileVector.length === 64, `expected 64-dim vector`);
+ assert(Object.keys(p.biomarkerValues).length > 0, 'should have biomarker values');
+ assert(p.globalRiskScore >= 0 && p.globalRiskScore <= 1, 'risk in [0,1]');
+ }
+});
+
+test('population_deterministic', () => {
+ const a = generateSyntheticPopulation(10, 99);
+ const b = generateSyntheticPopulation(10, 99);
+ for (let i = 0; i < 10; i++) {
+ assert(a[i].subjectId === b[i].subjectId, 'subject IDs must match');
+ assertClose(a[i].globalRiskScore, b[i].globalRiskScore, 1e-10, `risk score ${i}`);
+ }
+});
+
+test('mthfr_elevates_homocysteine', () => {
+ const pop = generateSyntheticPopulation(200, 7);
+ const high = [], low = [];
+ for (const p of pop) {
+ const hcy = p.biomarkerValues['Homocysteine'] || 0;
+ const metaScore = p.categoryScores['Metabolism'] ? p.categoryScores['Metabolism'].score : 0;
+ if (metaScore > 0.3) high.push(hcy); else low.push(hcy);
+ }
+ if (high.length > 0 && low.length > 0) {
+ const avgHigh = high.reduce((a, b) => a + b, 0) / high.length;
+ const avgLow = low.reduce((a, b) => a + b, 0) / low.length;
+ assert(avgHigh > avgLow, `MTHFR should elevate homocysteine: high=${avgHigh}, low=${avgLow}`);
+ }
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// RingBuffer Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- RingBuffer ---\n');
+
+test('ring_buffer_push_iter_len', () => {
+ const rb = new RingBuffer(4);
+ for (const v of [10, 20, 30]) rb.push(v);
+ const arr = rb.toArray();
+ assert(arr.length === 3 && arr[0] === 10 && arr[1] === 20 && arr[2] === 30, 'push/iter');
+ assert(rb.length === 3, 'length');
+ assert(!rb.isFull(), 'not full');
+});
+
+test('ring_buffer_overflow_keeps_newest', () => {
+ const rb = new RingBuffer(3);
+ for (let v = 1; v <= 4; v++) rb.push(v);
+ assert(rb.isFull(), 'should be full');
+ const arr = rb.toArray();
+ assert(arr[0] === 2 && arr[1] === 3 && arr[2] === 4, `got [${arr}]`);
+});
+
+test('ring_buffer_capacity_one', () => {
+ const rb = new RingBuffer(1);
+ rb.push(42); rb.push(99);
+ const arr = rb.toArray();
+ assert(arr.length === 1 && arr[0] === 99, `got [${arr}]`);
+});
+
+test('ring_buffer_clear_resets', () => {
+ const rb = new RingBuffer(3);
+ rb.push(1); rb.push(2); rb.clear();
+ assert(rb.length === 0, 'length after clear');
+ assert(!rb.isFull(), 'not full after clear');
+ assert(rb.toArray().length === 0, 'empty after clear');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Stream Processor Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Stream Processor ---\n');
+
+test('processor_computes_stats', () => {
+ const cfg = { ...defaultStreamConfig(), windowSize: 10 };
+ const p = new StreamProcessor(cfg);
+ const readings = generateReadings(cfg, 20, 55);
+ for (const r of readings) p.processReading(r);
+ const s = p.getStats('glucose');
+ assert(s !== null, 'should have glucose stats');
+ assert(s.count > 0 && s.mean > 0 && s.min <= s.max, 'valid stats');
+});
+
+test('processor_summary_totals', () => {
+ const cfg = defaultStreamConfig();
+ const p = new StreamProcessor(cfg);
+ const readings = generateReadings(cfg, 30, 77);
+ for (const r of readings) p.processReading(r);
+ const s = p.summary();
+ assert(s.totalReadings === 30 * cfg.numBiomarkers, `expected ${30 * cfg.numBiomarkers}, got ${s.totalReadings}`);
+ assert(s.anomalyRate >= 0 && s.anomalyRate <= 1, 'anomaly rate in [0,1]');
+});
+
+test('processor_throughput_positive', () => {
+ const cfg = defaultStreamConfig();
+ const p = new StreamProcessor(cfg);
+ const readings = generateReadings(cfg, 100, 88);
+ for (const r of readings) p.processReading(r);
+ const s = p.summary();
+ assert(s.throughputReadingsPerSec > 0, 'throughput should be positive');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Anomaly Detection Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Anomaly Detection ---\n');
+
+test('detects_z_score_anomaly', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 20 });
+ for (let i = 0; i < 20; i++) p.processReading(glucose(i * 1000, 85));
+ const r = p.processReading(glucose(20000, 300));
+ assert(r.isAnomaly, 'should detect anomaly');
+ assert(Math.abs(r.zScore) > Z_SCORE_THRESHOLD, `z-score ${r.zScore} should exceed threshold`);
+});
+
+test('detects_out_of_range_anomaly', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 5 });
+ for (const [i, v] of [80, 82, 78, 84, 81].entries()) {
+ p.processReading(glucose(i * 1000, v));
+ }
+ // 140 >> ref_high(100) + 20%*range(30)=106
+ const r = p.processReading(glucose(5000, 140));
+ assert(r.isAnomaly, 'should detect out-of-range anomaly');
+});
+
+test('zero_anomaly_for_constant_stream', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 50 });
+ for (let i = 0; i < 10; i++) p.processReading(reading(i * 1000, 'crp', 1.5, 0.1, 3));
+ const s = p.getStats('crp');
+ assert(Math.abs(s.anomalyRate) < 1e-9, `expected zero anomaly rate, got ${s.anomalyRate}`);
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Trend Detection Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Trend Detection ---\n');
+
+test('positive_trend_for_increasing', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 20 });
+ let r;
+ for (let i = 0; i < 20; i++) r = p.processReading(glucose(i * 1000, 70 + i));
+ assert(r.currentTrend > 0, `expected positive trend, got ${r.currentTrend}`);
+});
+
+test('negative_trend_for_decreasing', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 20 });
+ let r;
+ for (let i = 0; i < 20; i++) r = p.processReading(reading(i * 1000, 'hdl', 60 - i * 0.5, 40, 60));
+ assert(r.currentTrend < 0, `expected negative trend, got ${r.currentTrend}`);
+});
+
+test('exact_slope_for_linear_series', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 10 });
+ for (let i = 0; i < 10; i++) {
+ p.processReading(reading(i * 1000, 'ldl', 100 + i * 3, 70, 130));
+ }
+ assertClose(p.getStats('ldl').trendSlope, 3.0, 1e-9, 'slope');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Z-score / EMA Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Z-Score / EMA ---\n');
+
+test('z_score_small_for_near_mean', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 10 });
+ for (const [i, v] of [80, 82, 78, 84, 76, 86, 81, 79, 83].entries()) {
+ p.processReading(glucose(i * 1000, v));
+ }
+ const mean = p.getStats('glucose').mean;
+ const r = p.processReading(glucose(9000, mean));
+ assert(Math.abs(r.zScore) < 1, `z-score for mean value should be small, got ${r.zScore}`);
+});
+
+test('ema_converges_to_constant', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 50 });
+ for (let i = 0; i < 50; i++) p.processReading(reading(i * 1000, 'crp', 2.0, 0.1, 3));
+ assertClose(p.getStats('crp').ema, 2.0, 1e-6, 'EMA convergence');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Batch Generation Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Batch Generation ---\n');
+
+test('generate_correct_count_and_ids', () => {
+ const cfg = defaultStreamConfig();
+ const readings = generateReadings(cfg, 50, 42);
+ assert(readings.length === 50 * cfg.numBiomarkers, `expected ${50 * cfg.numBiomarkers}, got ${readings.length}`);
+ const validIds = new Set(['glucose', 'cholesterol_total', 'hdl', 'ldl', 'triglycerides', 'crp']);
+ for (const r of readings) assert(validIds.has(r.biomarkerId), `invalid id: ${r.biomarkerId}`);
+});
+
+test('generated_values_non_negative', () => {
+ const readings = generateReadings(defaultStreamConfig(), 100, 999);
+ for (const r of readings) assert(r.value >= 0, `negative value: ${r.value}`);
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Benchmarks
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Benchmarks ---\n');
+
+const benchGts = fullHomRef();
+benchGts.set('rs4680', 'AG');
+benchGts.set('rs1801133', 'AA');
+
+bench('computeRiskScores (20 SNPs)', () => {
+ computeRiskScores(benchGts);
+}, 10000);
+
+bench('encodeProfileVector (64-dim)', () => {
+ const p = computeRiskScores(benchGts);
+ encodeProfileVector(p);
+}, 10000);
+
+bench('StreamProcessor.processReading', () => {
+ const p = new StreamProcessor({ ...defaultStreamConfig(), windowSize: 100 });
+ const r = glucose(0, 85);
+ for (let i = 0; i < 100; i++) p.processReading(r);
+}, 1000);
+
+bench('generateSyntheticPopulation(100)', () => {
+ generateSyntheticPopulation(100, 42);
+}, 100);
+
+bench('RingBuffer push+iter (100 items)', () => {
+ const rb = new RingBuffer(100);
+ for (let i = 0; i < 100; i++) rb.push(i);
+ let s = 0;
+ for (const v of rb) s += v;
+}, 10000);
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Summary
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write(`\n${'='.repeat(60)}\n`);
+process.stdout.write(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total\n`);
+if (benchResults.length > 0) {
+ process.stdout.write('\nBenchmark Summary:\n');
+ for (const b of benchResults) {
+ process.stdout.write(` ${b.name}: ${b.perOp}/op\n`);
+ }
+}
+process.stdout.write(`${'='.repeat(60)}\n`);
+
+process.exit(failed > 0 ? 1 : 0);
diff --git a/npm/packages/rvdna/tests/test-real-data.js b/npm/packages/rvdna/tests/test-real-data.js
new file mode 100644
index 000000000..285952837
--- /dev/null
+++ b/npm/packages/rvdna/tests/test-real-data.js
@@ -0,0 +1,559 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+// Import from index.js (the package entry point) to test the full re-export chain
+const rvdna = require('../index.js');
+
+// ββ Test harness βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+let passed = 0, failed = 0, benchResults = [];
+
+function assert(cond, msg) {
+ if (!cond) throw new Error(`Assertion failed: ${msg}`);
+}
+
+function assertClose(a, b, eps, msg) {
+ if (Math.abs(a - b) > eps) throw new Error(`${msg}: ${a} != ${b} (eps=${eps})`);
+}
+
+function assertGt(a, b, msg) {
+ if (!(a > b)) throw new Error(`${msg}: expected ${a} > ${b}`);
+}
+
+function assertLt(a, b, msg) {
+ if (!(a < b)) throw new Error(`${msg}: expected ${a} < ${b}`);
+}
+
+function test(name, fn) {
+ try {
+ fn();
+ passed++;
+ process.stdout.write(` PASS ${name}\n`);
+ } catch (e) {
+ failed++;
+ process.stdout.write(` FAIL ${name}: ${e.message}\n`);
+ }
+}
+
+function bench(name, fn, iterations) {
+ for (let i = 0; i < Math.min(iterations, 1000); i++) fn();
+ const start = performance.now();
+ for (let i = 0; i < iterations; i++) fn();
+ const elapsed = performance.now() - start;
+ const perOp = (elapsed / iterations * 1000).toFixed(2);
+ benchResults.push({ name, perOp: `${perOp} us`, total: `${elapsed.toFixed(1)} ms`, iterations });
+ process.stdout.write(` BENCH ${name}: ${perOp} us/op (${iterations} iters, ${elapsed.toFixed(1)} ms)\n`);
+}
+
+// ββ Fixture loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const FIXTURES = path.join(__dirname, 'fixtures');
+
+function loadFixture(name) {
+ return fs.readFileSync(path.join(FIXTURES, name), 'utf8');
+}
+
+function parseFixtureToGenotypes(name) {
+ const text = loadFixture(name);
+ const data = rvdna.parse23andMe(text);
+ const gts = new Map();
+ for (const [rsid, snp] of data.snps) gts.set(rsid, snp.genotype);
+ return { data, gts };
+}
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 1: End-to-End Pipeline (parse 23andMe β biomarker scoring β stream)
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- End-to-End Pipeline ---\n');
+
+test('e2e_high_risk_cardio_pipeline', () => {
+ const { data, gts } = parseFixtureToGenotypes('sample-high-risk-cardio.23andme.txt');
+
+ // Stage 1: 23andMe parsing
+ assert(data.totalMarkers === 29, `expected 29 markers, got ${data.totalMarkers}`);
+ assert(data.build === 'GRCh37', `expected GRCh37, got ${data.build}`);
+ assert(data.noCalls === 0, 'no no-calls expected');
+
+ // Stage 2: Genotyping analysis
+ const analysis = rvdna.analyze23andMe(loadFixture('sample-high-risk-cardio.23andme.txt'));
+ assert(analysis.cyp2d6.phenotype !== undefined, 'CYP2D6 phenotype should be defined');
+ assert(analysis.cyp2c19.phenotype !== undefined, 'CYP2C19 phenotype should be defined');
+
+ // Stage 3: Biomarker risk scoring
+ const profile = rvdna.computeRiskScores(gts);
+ assert(profile.profileVector.length === 64, 'profile vector should be 64-dim');
+ assert(profile.globalRiskScore >= 0 && profile.globalRiskScore <= 1, 'risk in [0,1]');
+
+ // High-risk cardiac: MTHFR 677TT + LPA het + SLCO1B1 het β elevated metabolism + cardiovascular
+ const metab = profile.categoryScores['Metabolism'];
+ assertGt(metab.score, 0.3, 'MTHFR 677TT should elevate metabolism risk');
+ assertGt(metab.confidence, 0.5, 'metabolism confidence should be substantial');
+
+ const cardio = profile.categoryScores['Cardiovascular'];
+ assert(cardio.contributingVariants.includes('rs10455872'), 'LPA variant should contribute');
+ assert(cardio.contributingVariants.includes('rs4363657'), 'SLCO1B1 variant should contribute');
+ assert(cardio.contributingVariants.includes('rs3798220'), 'LPA rs3798220 should contribute');
+
+ // Stage 4: Feed synthetic biomarker readings through streaming processor
+ const cfg = rvdna.defaultStreamConfig();
+ const processor = new rvdna.StreamProcessor(cfg);
+ const readings = rvdna.generateReadings(cfg, 50, 42);
+ for (const r of readings) processor.processReading(r);
+ const summary = processor.summary();
+ assert(summary.totalReadings > 0, 'should have processed readings');
+ assert(summary.anomalyRate >= 0, 'anomaly rate should be valid');
+});
+
+test('e2e_low_risk_baseline_pipeline', () => {
+ const { data, gts } = parseFixtureToGenotypes('sample-low-risk-baseline.23andme.txt');
+
+ // Parse
+ assert(data.totalMarkers === 29, `expected 29 markers`);
+ assert(data.build === 'GRCh38', `expected GRCh38, got ${data.build}`);
+
+ // Score
+ const profile = rvdna.computeRiskScores(gts);
+ assertLt(profile.globalRiskScore, 0.15, 'all-ref should be very low risk');
+
+ // All categories should be near-zero
+ for (const [cat, cs] of Object.entries(profile.categoryScores)) {
+ assertLt(cs.score, 0.05, `${cat} should be near-zero for all-ref`);
+ }
+
+ // APOE should be e3/e3
+ const apoe = rvdna.determineApoe(gts);
+ assert(apoe.genotype.includes('e3/e3'), `expected e3/e3, got ${apoe.genotype}`);
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 2: Clinical Scenario Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Clinical Scenarios ---\n');
+
+test('scenario_apoe_e4e4_brca1_carrier', () => {
+ const { gts } = parseFixtureToGenotypes('sample-multi-risk.23andme.txt');
+ const profile = rvdna.computeRiskScores(gts);
+
+ // APOE e4/e4 β high neurological risk
+ const neuro = profile.categoryScores['Neurological'];
+ assertGt(neuro.score, 0.5, `APOE e4/e4 + COMT Met/Met should push neuro >0.5, got ${neuro.score}`);
+ assert(neuro.contributingVariants.includes('rs429358'), 'APOE should contribute');
+ assert(neuro.contributingVariants.includes('rs4680'), 'COMT should contribute');
+
+ // BRCA1 carrier + TP53 variant β elevated cancer risk with interaction
+ const cancer = profile.categoryScores['Cancer Risk'];
+ assertGt(cancer.score, 0.4, `BRCA1 carrier + TP53 should push cancer >0.4, got ${cancer.score}`);
+ assert(cancer.contributingVariants.includes('rs80357906'), 'BRCA1 should contribute');
+ assert(cancer.contributingVariants.includes('rs1042522'), 'TP53 should contribute');
+
+ // Cardiovascular should be elevated from SLCO1B1 + LPA
+ const cardio = profile.categoryScores['Cardiovascular'];
+ assertGt(cardio.score, 0.3, `SLCO1B1 + LPA should push cardio >0.3, got ${cardio.score}`);
+
+ // NQO1 null (TT) should contribute to cancer
+ assert(cancer.contributingVariants.includes('rs1800566'), 'NQO1 should contribute');
+
+ // Global risk should be substantial
+ assertGt(profile.globalRiskScore, 0.4, `multi-risk global should be >0.4, got ${profile.globalRiskScore}`);
+
+ // APOE determination
+ const apoe = rvdna.determineApoe(gts);
+ assert(apoe.genotype.includes('e4/e4'), `expected e4/e4, got ${apoe.genotype}`);
+});
+
+test('scenario_pcsk9_protective', () => {
+ const { gts } = parseFixtureToGenotypes('sample-pcsk9-protective.23andme.txt');
+ const profile = rvdna.computeRiskScores(gts);
+
+ // PCSK9 R46L het (rs11591147 GT) β negative cardiovascular weight (protective)
+ const cardio = profile.categoryScores['Cardiovascular'];
+ // With only PCSK9 protective allele and no risk alleles, cardio score should be very low
+ assertLt(cardio.score, 0.05, `PCSK9 protective should keep cardio very low, got ${cardio.score}`);
+
+ // APOE e2/e3 protective
+ const apoe = rvdna.determineApoe(gts);
+ assert(apoe.genotype.includes('e2/e3'), `expected e2/e3, got ${apoe.genotype}`);
+});
+
+test('scenario_mthfr_compound_heterozygote', () => {
+ const { gts } = parseFixtureToGenotypes('sample-high-risk-cardio.23andme.txt');
+ // This file has rs1801133=AA (677TT hom) + rs1801131=GT (1298AC het) β compound score 3
+
+ const profile = rvdna.computeRiskScores(gts);
+ const metab = profile.categoryScores['Metabolism'];
+
+ // MTHFR compound should push metabolism risk up
+ assertGt(metab.score, 0.3, `MTHFR compound should elevate metabolism, got ${metab.score}`);
+ assert(metab.contributingVariants.includes('rs1801133'), 'rs1801133 (C677T) should contribute');
+ assert(metab.contributingVariants.includes('rs1801131'), 'rs1801131 (A1298C) should contribute');
+
+ // MTHFR interaction with MTHFR should amplify
+ // The interaction rs1801133Γrs1801131 has modifier 1.3
+});
+
+test('scenario_comt_oprm1_pain_interaction', () => {
+ // Use controlled genotypes that don't saturate the category at 1.0
+ const gts = new Map();
+ for (const snp of rvdna.SNPS) gts.set(snp.rsid, snp.homRef);
+ gts.set('rs4680', 'AA'); // COMT Met/Met
+ gts.set('rs1799971', 'GG'); // OPRM1 Asp/Asp
+ const profile = rvdna.computeRiskScores(gts);
+ const neuro = profile.categoryScores['Neurological'];
+
+ // Without OPRM1 variant β no interaction modifier
+ const gts2 = new Map(gts);
+ gts2.set('rs1799971', 'AA'); // reference
+ const profile2 = rvdna.computeRiskScores(gts2);
+ const neuro2 = profile2.categoryScores['Neurological'];
+
+ assertGt(neuro.score, neuro2.score, 'COMTΓOPRM1 interaction should amplify neurological risk');
+});
+
+test('scenario_drd2_comt_interaction', () => {
+ // Use controlled genotypes that don't saturate the category at 1.0
+ const gts = new Map();
+ for (const snp of rvdna.SNPS) gts.set(snp.rsid, snp.homRef);
+ gts.set('rs1800497', 'AA'); // DRD2 A1/A1
+ gts.set('rs4680', 'AA'); // COMT Met/Met
+ const profile = rvdna.computeRiskScores(gts);
+
+ // Without DRD2 variant β no DRD2ΓCOMT interaction
+ const gts2 = new Map(gts);
+ gts2.set('rs1800497', 'GG'); // reference
+ const profile2 = rvdna.computeRiskScores(gts2);
+
+ assertGt(
+ profile.categoryScores['Neurological'].score,
+ profile2.categoryScores['Neurological'].score,
+ 'DRD2ΓCOMT interaction should amplify'
+ );
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 3: Cross-Validation (JS matches Rust expectations)
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Cross-Validation (JS β Rust parity) ---\n');
+
+test('parity_reference_count_matches_rust', () => {
+ assert(rvdna.BIOMARKER_REFERENCES.length === 13, 'should have 13 references (matches Rust)');
+ assert(rvdna.SNPS.length === 20, 'should have 20 SNPs (matches Rust)');
+ assert(rvdna.INTERACTIONS.length === 6, 'should have 6 interactions (matches Rust)');
+ assert(rvdna.CAT_ORDER.length === 4, 'should have 4 categories (matches Rust)');
+});
+
+test('parity_snp_table_exact_match', () => {
+ // Verify first and last SNP match Rust exactly
+ const first = rvdna.SNPS[0];
+ assert(first.rsid === 'rs429358', 'first SNP rsid');
+ assertClose(first.wHet, 0.4, 1e-10, 'first SNP wHet');
+ assertClose(first.wAlt, 0.9, 1e-10, 'first SNP wAlt');
+ assert(first.homRef === 'TT', 'first SNP homRef');
+ assert(first.category === 'Neurological', 'first SNP category');
+
+ const last = rvdna.SNPS[19];
+ assert(last.rsid === 'rs11591147', 'last SNP rsid');
+ assertClose(last.wHet, -0.30, 1e-10, 'PCSK9 wHet (negative = protective)');
+ assertClose(last.wAlt, -0.55, 1e-10, 'PCSK9 wAlt (negative = protective)');
+});
+
+test('parity_interaction_table_exact_match', () => {
+ const i0 = rvdna.INTERACTIONS[0];
+ assert(i0.rsidA === 'rs4680' && i0.rsidB === 'rs1799971', 'first interaction pair');
+ assertClose(i0.modifier, 1.4, 1e-10, 'COMTΓOPRM1 modifier');
+
+ const i3 = rvdna.INTERACTIONS[3];
+ assert(i3.rsidA === 'rs80357906' && i3.rsidB === 'rs1042522', 'BRCA1ΓTP53 pair');
+ assertClose(i3.modifier, 1.5, 1e-10, 'BRCA1ΓTP53 modifier');
+});
+
+test('parity_z_score_matches_rust', () => {
+ // z_score(mid, ref) should be 0.0 (Rust test_z_score_midpoint_is_zero)
+ const ref = rvdna.BIOMARKER_REFERENCES[0]; // Total Cholesterol
+ const mid = (ref.normalLow + ref.normalHigh) / 2;
+ assertClose(rvdna.zScore(mid, ref), 0, 1e-10, 'midpoint z-score = 0');
+ // z_score(normalHigh, ref) should be 1.0 (Rust test_z_score_high_bound_is_one)
+ assertClose(rvdna.zScore(ref.normalHigh, ref), 1, 1e-10, 'high-bound z-score = 1');
+});
+
+test('parity_classification_matches_rust', () => {
+ const ref = rvdna.BIOMARKER_REFERENCES[0]; // Total Cholesterol 125-200
+ assert(rvdna.classifyBiomarker(150, ref) === 'Normal', 'Normal');
+ assert(rvdna.classifyBiomarker(350, ref) === 'CriticalHigh', 'CriticalHigh (>300)');
+ assert(rvdna.classifyBiomarker(110, ref) === 'Low', 'Low');
+ assert(rvdna.classifyBiomarker(90, ref) === 'CriticalLow', 'CriticalLow (<100)');
+});
+
+test('parity_vector_layout_64dim_l2', () => {
+ // Rust test_vector_dimension_is_64 and test_vector_is_l2_normalized
+ const gts = new Map();
+ for (const snp of rvdna.SNPS) gts.set(snp.rsid, snp.homRef);
+ gts.set('rs4680', 'AG');
+ gts.set('rs1799971', 'AG');
+ const profile = rvdna.computeRiskScores(gts);
+ assert(profile.profileVector.length === 64, '64 dims');
+ let norm = 0;
+ for (let i = 0; i < 64; i++) norm += profile.profileVector[i] ** 2;
+ norm = Math.sqrt(norm);
+ assertClose(norm, 1.0, 1e-4, 'L2 norm');
+});
+
+test('parity_hom_ref_low_risk_matches_rust', () => {
+ // Rust test_risk_scores_all_hom_ref_low_risk: global < 0.15
+ const gts = new Map();
+ for (const snp of rvdna.SNPS) gts.set(snp.rsid, snp.homRef);
+ const profile = rvdna.computeRiskScores(gts);
+ assertLt(profile.globalRiskScore, 0.15, 'hom-ref should be <0.15');
+});
+
+test('parity_high_cancer_matches_rust', () => {
+ // Rust test_risk_scores_high_cancer_risk: cancer > 0.3
+ const gts = new Map();
+ for (const snp of rvdna.SNPS) gts.set(snp.rsid, snp.homRef);
+ gts.set('rs80357906', 'DI');
+ gts.set('rs1042522', 'GG');
+ gts.set('rs11571833', 'TT');
+ const profile = rvdna.computeRiskScores(gts);
+ assertGt(profile.categoryScores['Cancer Risk'].score, 0.3, 'cancer > 0.3');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 4: Population-Scale Correlation Tests
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Population Correlations ---\n');
+
+test('population_apoe_lowers_hdl', () => {
+ // Mirrors Rust test_apoe_lowers_hdl_in_population
+ const pop = rvdna.generateSyntheticPopulation(300, 88);
+ const apoeHdl = [], refHdl = [];
+ for (const p of pop) {
+ const hdl = p.biomarkerValues['HDL'] || 0;
+ const neuro = p.categoryScores['Neurological'] ? p.categoryScores['Neurological'].score : 0;
+ if (neuro > 0.3) apoeHdl.push(hdl); else refHdl.push(hdl);
+ }
+ if (apoeHdl.length > 0 && refHdl.length > 0) {
+ const avgApoe = apoeHdl.reduce((a, b) => a + b, 0) / apoeHdl.length;
+ const avgRef = refHdl.reduce((a, b) => a + b, 0) / refHdl.length;
+ assertLt(avgApoe, avgRef, 'APOE e4 should lower HDL');
+ }
+});
+
+test('population_lpa_elevates_lpa_biomarker', () => {
+ const pop = rvdna.generateSyntheticPopulation(300, 44);
+ const lpaHigh = [], lpaLow = [];
+ for (const p of pop) {
+ const lpaVal = p.biomarkerValues['Lp(a)'] || 0;
+ const cardio = p.categoryScores['Cardiovascular'] ? p.categoryScores['Cardiovascular'].score : 0;
+ if (cardio > 0.2) lpaHigh.push(lpaVal); else lpaLow.push(lpaVal);
+ }
+ if (lpaHigh.length > 0 && lpaLow.length > 0) {
+ const avgHigh = lpaHigh.reduce((a, b) => a + b, 0) / lpaHigh.length;
+ const avgLow = lpaLow.reduce((a, b) => a + b, 0) / lpaLow.length;
+ assertGt(avgHigh, avgLow, 'cardiovascular risk should correlate with elevated Lp(a)');
+ }
+});
+
+test('population_risk_score_distribution', () => {
+ const pop = rvdna.generateSyntheticPopulation(1000, 123);
+ const scores = pop.map(p => p.globalRiskScore);
+ const min = Math.min(...scores);
+ const max = Math.max(...scores);
+ const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
+
+ // Should have good spread
+ assertGt(max - min, 0.2, `risk score range should be >0.2, got ${max - min}`);
+ // Mean should be moderate (not all near 0 or 1)
+ assertGt(mean, 0.05, 'mean risk should be >0.05');
+ assertLt(mean, 0.7, 'mean risk should be <0.7');
+});
+
+test('population_all_biomarkers_within_clinical_limits', () => {
+ const pop = rvdna.generateSyntheticPopulation(500, 55);
+ for (const p of pop) {
+ for (const bref of rvdna.BIOMARKER_REFERENCES) {
+ const val = p.biomarkerValues[bref.name];
+ assert(val !== undefined, `missing ${bref.name} for ${p.subjectId}`);
+ assert(val >= 0, `${bref.name} should be non-negative, got ${val}`);
+ if (bref.criticalHigh !== null) {
+ assertLt(val, bref.criticalHigh * 1.25, `${bref.name} should be < criticalHigh*1.25`);
+ }
+ }
+ }
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 5: Streaming with Real-Data Correlated Biomarkers
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Streaming with Real Biomarkers ---\n');
+
+test('stream_cusum_changepoint_on_shift', () => {
+ // Mirror Rust test_cusum_changepoint_detection
+ const cfg = { ...rvdna.defaultStreamConfig(), windowSize: 20 };
+ const p = new rvdna.StreamProcessor(cfg);
+
+ // Establish baseline at 85
+ for (let i = 0; i < 30; i++) {
+ p.processReading({
+ timestampMs: i * 1000, biomarkerId: 'glucose', value: 85,
+ referenceLow: 70, referenceHigh: 100, isAnomaly: false, zScore: 0,
+ });
+ }
+ // Sustained shift to 120
+ for (let i = 30; i < 50; i++) {
+ p.processReading({
+ timestampMs: i * 1000, biomarkerId: 'glucose', value: 120,
+ referenceLow: 70, referenceHigh: 100, isAnomaly: false, zScore: 0,
+ });
+ }
+ const stats = p.getStats('glucose');
+ assertGt(stats.mean, 90, `mean should shift upward after changepoint: ${stats.mean}`);
+});
+
+test('stream_drift_detected_as_trend', () => {
+ // Mirror Rust test_trend_detection
+ const cfg = { ...rvdna.defaultStreamConfig(), windowSize: 50 };
+ const p = new rvdna.StreamProcessor(cfg);
+
+ // Strong upward drift
+ for (let i = 0; i < 50; i++) {
+ p.processReading({
+ timestampMs: i * 1000, biomarkerId: 'glucose', value: 70 + i * 0.5,
+ referenceLow: 70, referenceHigh: 100, isAnomaly: false, zScore: 0,
+ });
+ }
+ assertGt(p.getStats('glucose').trendSlope, 0, 'should detect positive trend');
+});
+
+test('stream_population_biomarker_values_through_processor', () => {
+ // Take synthetic population biomarker values and stream them
+ const pop = rvdna.generateSyntheticPopulation(20, 77);
+ const cfg = { ...rvdna.defaultStreamConfig(), windowSize: 20 };
+ const p = new rvdna.StreamProcessor(cfg);
+
+ for (let i = 0; i < pop.length; i++) {
+ const homocysteine = pop[i].biomarkerValues['Homocysteine'];
+ p.processReading({
+ timestampMs: i * 1000, biomarkerId: 'homocysteine',
+ value: homocysteine, referenceLow: 5, referenceHigh: 15,
+ isAnomaly: false, zScore: 0,
+ });
+ }
+
+ const stats = p.getStats('homocysteine');
+ assert(stats !== null, 'should have homocysteine stats');
+ assertGt(stats.count, 0, 'should have processed readings');
+ assertGt(stats.mean, 0, 'mean should be positive');
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 6: Package Re-export Verification
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Package Re-exports ---\n');
+
+test('index_exports_all_biomarker_apis', () => {
+ const expectedFns = [
+ 'biomarkerReferences', 'zScore', 'classifyBiomarker',
+ 'computeRiskScores', 'encodeProfileVector', 'generateSyntheticPopulation',
+ ];
+ for (const fn of expectedFns) {
+ assert(typeof rvdna[fn] === 'function', `missing export: ${fn}`);
+ }
+ const expectedConsts = ['BIOMARKER_REFERENCES', 'SNPS', 'INTERACTIONS', 'CAT_ORDER'];
+ for (const c of expectedConsts) {
+ assert(rvdna[c] !== undefined, `missing export: ${c}`);
+ }
+});
+
+test('index_exports_all_stream_apis', () => {
+ assert(typeof rvdna.RingBuffer === 'function', 'missing RingBuffer');
+ assert(typeof rvdna.StreamProcessor === 'function', 'missing StreamProcessor');
+ assert(typeof rvdna.generateReadings === 'function', 'missing generateReadings');
+ assert(typeof rvdna.defaultStreamConfig === 'function', 'missing defaultStreamConfig');
+ assert(rvdna.BIOMARKER_DEFS !== undefined, 'missing BIOMARKER_DEFS');
+});
+
+test('index_exports_v02_apis_unchanged', () => {
+ const v02fns = [
+ 'encode2bit', 'decode2bit', 'translateDna', 'cosineSimilarity',
+ 'isNativeAvailable', 'normalizeGenotype', 'parse23andMe',
+ 'callCyp2d6', 'callCyp2c19', 'determineApoe', 'analyze23andMe',
+ ];
+ for (const fn of v02fns) {
+ assert(typeof rvdna[fn] === 'function', `v0.2 API missing: ${fn}`);
+ }
+});
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SECTION 7: Optimized Benchmarks (pre/post optimization comparison)
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write('\n--- Optimized Benchmarks ---\n');
+
+// Prepare benchmark genotypes from real fixture
+const { gts: benchGts } = parseFixtureToGenotypes('sample-high-risk-cardio.23andme.txt');
+
+bench('computeRiskScores (real 23andMe data, 20 SNPs)', () => {
+ rvdna.computeRiskScores(benchGts);
+}, 20000);
+
+bench('encodeProfileVector (real profile)', () => {
+ const p = rvdna.computeRiskScores(benchGts);
+ rvdna.encodeProfileVector(p);
+}, 20000);
+
+bench('StreamProcessor.processReading (optimized incremental)', () => {
+ const p = new rvdna.StreamProcessor({ ...rvdna.defaultStreamConfig(), windowSize: 100 });
+ const r = { timestampMs: 0, biomarkerId: 'glucose', value: 85, referenceLow: 70, referenceHigh: 100, isAnomaly: false, zScore: 0 };
+ for (let i = 0; i < 100; i++) {
+ r.timestampMs = i * 1000;
+ p.processReading(r);
+ }
+}, 2000);
+
+bench('generateSyntheticPopulation(100) (optimized lookups)', () => {
+ rvdna.generateSyntheticPopulation(100, 42);
+}, 200);
+
+bench('full pipeline: parse + score + stream (real data)', () => {
+ const text = loadFixture('sample-high-risk-cardio.23andme.txt');
+ const data = rvdna.parse23andMe(text);
+ const gts = new Map();
+ for (const [rsid, snp] of data.snps) gts.set(rsid, snp.genotype);
+ const profile = rvdna.computeRiskScores(gts);
+ const proc = new rvdna.StreamProcessor(rvdna.defaultStreamConfig());
+ for (const bref of rvdna.BIOMARKER_REFERENCES) {
+ const val = profile.biomarkerValues[bref.name] || ((bref.normalLow + bref.normalHigh) / 2);
+ proc.processReading({
+ timestampMs: 0, biomarkerId: bref.name, value: val,
+ referenceLow: bref.normalLow, referenceHigh: bref.normalHigh,
+ isAnomaly: false, zScore: 0,
+ });
+ }
+}, 5000);
+
+bench('population 1000 subjects', () => {
+ rvdna.generateSyntheticPopulation(1000, 42);
+}, 20);
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Summary
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+process.stdout.write(`\n${'='.repeat(70)}\n`);
+process.stdout.write(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total\n`);
+if (benchResults.length > 0) {
+ process.stdout.write('\nBenchmark Summary:\n');
+ for (const b of benchResults) {
+ process.stdout.write(` ${b.name}: ${b.perOp}/op\n`);
+ }
+}
+process.stdout.write(`${'='.repeat(70)}\n`);
+
+process.exit(failed > 0 ? 1 : 0);