diff --git a/.gitignore b/.gitignore
index d0bcac5..980f9a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,9 @@ htmlcov/
# matledger data
*.db
+smoke_report.*
+smoke_bundle.json
+smoke_test.db
*.sqlite
*.sqlite3
*.parquet
diff --git a/README.md b/README.md
index bcfd6ea..73aad2a 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,8 @@ print(client.report())
See [`examples/basic_campaign.py`](examples/basic_campaign.py) for a complete 3-iteration campaign, or [`examples/notebook_demo.ipynb`](examples/notebook_demo.ipynb) for an interactive Jupyter walkthrough.
You can also [run the notebook directly in Google Colab](https://colab.research.google.com/github/jaysonmulwa/matledger/blob/main/examples/notebook_demo.ipynb) — no local install required.
+For a realistic 5-iteration perovskite campaign that exercises every CLI command (CSV ingestion, failures, counterfactuals, reports), see the [smoke test fixtures](fixtures/smoke_test/README.md). The accompanying [analytics guide](fixtures/smoke_test/analytics_guide.html) explains how proxy gaming happens, what corrections exist, and how counterfactual replay quantifies the cost.
+
---
@@ -603,6 +605,7 @@ All available targets:
| `make coverage` | Run tests with coverage report (93%, 80% gate) |
| `make audit` | Audit dependencies for vulnerabilities |
| `make clean` | Remove build artifacts |
+| `bash fixtures/smoke_test/run_smoke_test.sh` | Run end-to-end smoke test ([details](fixtures/smoke_test/README.md)) |
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, architecture constraints, and commit format.
diff --git a/fixtures/smoke_test/README.md b/fixtures/smoke_test/README.md
new file mode 100644
index 0000000..5aaa956
--- /dev/null
+++ b/fixtures/smoke_test/README.md
@@ -0,0 +1,162 @@
+# Smoke Test: Perovskite Bandgap Optimisation Campaign
+
+End-to-end smoke test that exercises every matledger CLI command against a realistic
+materials science campaign. Run it to verify the full system works before release.
+
+```bash
+bash fixtures/smoke_test/run_smoke_test.sh
+```
+
+Produces: `smoke_test.db`, `smoke_report.{md,html,json}`, `smoke_bundle.json`
+
+---
+
+## What is being optimised?
+
+**Perovskite solar cell bandgap.** Perovskites are a family of crystalline materials
+used in next-generation solar cells. Their optical bandgap (measured in electron-volts,
+eV) determines how efficiently they convert sunlight to electricity. The ideal bandgap
+for a single-junction solar cell is around 1.1-1.5 eV, but for tandem cells stacked on
+top of silicon, a wider bandgap of 1.7-2.0 eV is desirable.
+
+**Campaign goal:** find a perovskite composition with bandgap > 1.8 eV and thermal
+stability above 300 degrees Celsius, within a $100k budget.
+
+**Was the goal achieved?** Partially. The best validated result was **1.98 eV**
+(pvk-034, iteration 5), exceeding the 1.8 eV target. However, the campaign shows
+clear signs of **proxy gaming** (surrogate model predictions inflating to 2.0-2.2 eV
+while validated results plateau around 1.93-1.98) and **diversity collapse** (all
+late-campaign candidates cluster in the high-Br region). A real PI would pause the
+campaign and retrain the surrogate with a corrected acquisition function.
+
+---
+
+## Campaign structure
+
+The campaign runs 5 iterations of a Bayesian optimisation loop:
+
+1. **Propose** -- a Gaussian process surrogate model (GP) predicts which compositions
+ are worth testing, ranked by predicted bandgap.
+2. **Test** -- a robotic lab (LIMS) synthesises each composition and starts experiments.
+3. **Learn** -- an XRD autoloader measures the bandgap of successfully synthesised films.
+ Some experiments fail (synthesis, stability, equipment errors).
+4. **Decide** -- the PI (dr-kimani) selects the best candidates and rejects poor ones.
+5. **Repeat** -- the surrogate model retrains on new data and proposes the next batch.
+
+### Actors
+
+| Actor | Role | Real-world analogue |
+|-------|------|---------------------|
+| `dr-kimani` | Principal investigator; creates campaign, selects/rejects, closes iterations | Lab PI or team lead |
+| `surrogate-gp` | Gaussian process surrogate model; proposes candidates and retrains | BoTorch, Ax, or GPyTorch model |
+| `lims` | Laboratory information management system; starts experiments | Automated scheduling system |
+| `lab-tech-ochieng` | Lab technician; records failures with physical detail | Bench scientist |
+| `xrd-autoloader` | X-ray diffraction instrument; records validated measurements | Bruker D8 or similar diffractometer |
+
+---
+
+## CSV column reference
+
+### Candidate files (`iter*_candidates.csv`)
+
+These are the compositions the surrogate model proposes for synthesis and testing.
+
+| Column | Type | Unit | Description |
+|--------|------|------|-------------|
+| `candidate_id` | string | -- | Unique identifier (pvk-NNN) |
+| `predicted_value` | float | eV | Surrogate model's predicted bandgap at proposal time |
+| `Cs` | float | molar fraction | Caesium content in the A-site of the perovskite lattice |
+| `Pb` | float | molar fraction | Lead content in the B-site |
+| `I` | float | molar fraction | Iodide content in the X-site |
+| `Br` | float | molar fraction | Bromide content in the X-site |
+| `Sn` | float | molar fraction | Tin content (partial B-site substitution for lead) |
+
+The general perovskite formula is ABX3 where A=Cs, B=Pb/Sn, X=I/Br. Higher bromide
+content generally increases bandgap but reduces stability -- the fundamental tradeoff
+the campaign explores.
+
+### Result files (`iter*_results.csv`)
+
+These are the validated experimental measurements from XRD characterisation.
+
+| Column | Type | Unit | Description |
+|--------|------|------|-------------|
+| `candidate_id` | string | -- | Which candidate was measured |
+| `value` | float | eV | Measured optical bandgap |
+| `uncertainty` | float | eV | Measurement uncertainty (1-sigma standard deviation) |
+| `calibration_ref` | string | -- | Calibration standard used (e.g. XRD-CAL-2024-07) |
+
+Not every candidate appears in the results file. Missing candidates failed during
+synthesis, stability testing, or due to equipment errors -- those are recorded as
+`FailureRecorded` events via the CLI.
+
+### Failure classes used
+
+| Class | Count | Meaning |
+|-------|-------|---------|
+| `synthesizability` | 7 | Composition did not form the target crystal phase |
+| `stability` | 4 | Film decomposed during accelerated aging (85C/85% relative humidity) |
+| `process_error` | 3 | Equipment malfunction (furnace, glovebox); not the candidate's fault |
+| `measurement_error` | 1 | XRD sample holder misaligned; measurement discarded |
+
+---
+
+## Iteration-by-iteration narrative
+
+| Iter | Proposed | Tested | Failed | Best result | Surrogate predictions | Signal |
+|------|----------|--------|--------|-------------|----------------------|--------|
+| 1 | 8 | 5 | 3 | 1.85 eV (pvk-003) | 1.55 - 1.91 eV | Initial exploration; high failure rate (37%) |
+| 2 | 8 | 5 | 3 | 1.89 eV (pvk-015) | 1.67 - 2.01 eV | Model starts exploiting Br-enrichment |
+| 3 | 8 | 5 | 3 | 1.95 eV (pvk-022) | 1.85 - 2.10 eV | Predictions inflating; Br fraction rising |
+| 4 | 8 | 5 | 3 | 1.97 eV (pvk-030) | 1.93 - 2.18 eV | Prediction-reality gap widening |
+| 5 | 8 | 5 | 3 | 1.98 eV (pvk-034) | 2.00 - 2.22 eV | Proxy gaming confirmed; diversity collapsed |
+
+**Totals:** 40 candidates, 25 validated results, 15 failures, $100k spent, 142 events.
+
+---
+
+## How realistic is this data?
+
+This fixture is calibrated against published self-driving lab (SDL) campaigns. The table
+below compares our fixture parameters against real campaigns from the literature.
+
+| Parameter | This fixture | Literature range | Reference |
+|-----------|-------------|-----------------|-----------|
+| Iterations | 5 | 5-50 typical | Perovskite BO (Nature 2026): 5 rounds of 33 total experiments |
+| Batch size | 8 per iteration | 5-8 (wet lab), up to 96 (high-throughput screening) | Christensen et al. (Commun. Chem. 2021): 8 parallel reaction loops |
+| Total candidates | 40 | 30-200 typical | A-Lab, Berkeley (Nature 2023): 58 targets over 17 days |
+| Failure rate | 37% (15/40) | 30-50% is normal | A-Lab: 29% target failure, 70% recipe failure; MF-BO drug discovery (ACS 2025): 34-51% synthesis failure |
+| Cost per experiment | $2,500 | $500-$5,000 | Typical materials synthesis + XRD characterisation |
+| Total budget | $100,000 | $25k-$250k | Mid-range academic lab campaign |
+| Actors | 5 | 3-10 | Realistic lab team (PI, tech, instruments, model) |
+
+### What we simplified
+
+- **Uniform batch size.** Real campaigns often vary batch size across iterations (larger
+ early batches for exploration, smaller late batches for exploitation). We use a fixed
+ 8 for simplicity.
+- **Uniform cost.** Real experiment costs vary by composition and characterisation method.
+ We use a flat $2,500 per experiment.
+- **Fixed failure rate.** Real failure rates often increase as the model pushes into
+ unexplored regions of composition space. Our fixture has a constant 3/8 failure rate
+ per iteration, though a real campaign would likely see failure rates climb in later
+ iterations.
+- **Single objective.** Real perovskite campaigns often optimise multiple objectives
+ simultaneously (bandgap, stability, defect density). We optimise bandgap only.
+- **No replicate measurements.** Real labs run 2-3 replicates per candidate to assess
+ reproducibility. We record one measurement per candidate.
+
+### Understanding the analytics
+
+For a visual walkthrough of how proxy gaming happens, what corrections exist,
+and how counterfactual replay applies to this campaign, see the
+[Analytics Guide](analytics_guide.html). It includes diagrams of the feedback
+loop, the divergence chart, correction strategies, and product direction for
+matledger's evolution from diagnostic ledger to advisory system.
+
+### References
+
+- Szymanski et al. 2023, "An autonomous laboratory for the accelerated synthesis of novel materials" (Nature 624). A-Lab at Berkeley: 353 synthesis attempts on 58 targets, 71% success rate.
+- Autonomous closed-loop framework for reproducible perovskite solar cells (Nature 2026). 33 experiments across 5 AL rounds, batch size 5-8.
+- Christensen et al. 2021, "Data-science driven autonomous process optimization" (Commun. Chem.). 120-192 experiments, 8 parallel reaction loops.
+- Bayesian optimization over multiple experimental fidelities (ACS Central Science 2025). Drug discovery: 3,500+ docked, 120+ synthesised, 34-51% synthesis failure rate.
diff --git a/fixtures/smoke_test/analytics_guide.html b/fixtures/smoke_test/analytics_guide.html
new file mode 100644
index 0000000..d786766
--- /dev/null
+++ b/fixtures/smoke_test/analytics_guide.html
@@ -0,0 +1,480 @@
+
+
+
1. Why we need both surrogate predictions and lab results
+
+
The Gaussian process (GP) surrogate model predicts bandgap cheaply using mathematics. The lab measures it physically on real materials. These are fundamentally different quantities:
+
+
+
GP prediction
Lab result
+
Source
Mathematical model
Physical measurement (XRD)
+
Cost
Near zero (computation)
~$2,500 per experiment
+
Speed
Milliseconds
Hours to days
+
Trust
Only as good as training data
Ground truth (with uncertainty)
+
+
+
The GP helps narrow which compositions to test. Testing every possible composition in the lab is too expensive and slow, so the GP ranks candidates by predicted value and the lab only synthesises the most promising batch per iteration. Each iteration should get smarter about where to look.
+
+
The risk matledger watches for is when these two trajectories diverge: predictions climb but lab results plateau. Without both numbers recorded at every iteration, you cannot detect the divergence.
+
+
+
2. How proxy gaming divergence happens
+
+
At first you don't know the GP is gaming. In early iterations, predictions and lab results roughly agree (iteration 1: predicts 1.55–1.91 eV, lab measures up to 1.85 eV). The model looks healthy.
+
+
The signal appears over time. By iteration 5 the GP predicts 2.0–2.2 eV but the lab never breaks 1.98 eV. You need multiple iterations to see it because one bad prediction is noise; a systematic trend is the alarm.
+
+
The feedback loop
+
+
+
+
+
+
The widening gap across 5 iterations
+
+
+
+
+
+
+
3. Three corrections for proxy gaming
+
+
All three target the root cause from different angles. In practice you would apply them together.
+
+
+
+
+
+
Correction 1: Train on failures
+
The most direct fix. The GP only sees candidates that successfully produced a measurement. If you assign failed candidates a penalty value (e.g. 0 eV), the model learns that high-Br compositions are risky, not just high-value. The failure ledger in matledger (the FailureRecorded events with their failure classes) provides exactly the data needed to implement this.
+
+
Correction 2: Change the acquisition function
+
Instead of "propose the composition with the highest predicted bandgap," switch to something like Lower Confidence Bound: "propose the composition with the highest predicted bandgap minus its uncertainty." This penalises regions where the model is overconfident on thin data. The uncertainty values recorded in ResultRecorded events feed this correction.
+
+
Correction 3: Force diversity in batches
+
Reserve 2–3 of the 8 batch slots for random or maximally distant compositions, regardless of what the GP predicts. This breaks the echo chamber and gives the model training data from unexplored regions. The diversity monitor in matledger would measure whether this correction is working by tracking whether candidate feature distributions broaden.
+
+
+matledger's role is detection, not correction.
+It flags the divergence so the PI can pause and apply these fixes before burning more budget. The corrections happen in the surrogate model and acquisition function, which are external to matledger.
+
+
+
+
4. How counterfactual replay applies
+
+
matledger's counterfactual replay answers: "given the same lab results, would a different selection strategy have found the best candidate sooner or cheaper?"
+
+
What changes and what stays fixed
+
+
+
Fixed (same in both timelines)
Changed (the variable)
+
Which candidates were proposed
Which candidates were selected after results
+
Which experiments were run
Which candidates were rejected
+
Which candidates failed
The selection policy (greedy, random, custom)
+
All lab results and their values
+
+
+
This is post-result selection replay, not acquisition replay. The alternate policy chooses from candidates that were already tested and have validated outcomes.
+
+
Available policies
+
+
+# "What if we always picked the best lab result?"
+matledger counterfactual --policy greedy --db smoke_test.db
+
+# "How much better were we than random chance?"
+matledger counterfactual --policy random --seed 42 --db smoke_test.db
+
+# Custom: implement the Policy protocol with your acquisition function
+
+
+
Iteration-by-iteration comparison
+
+
+
+
+
+
+Nuance for this smoke test.
+In this campaign, the PI's selections align with greedy because dr-kimani was already picking the highest validated result. The divergence shows up more in what was rejected and in the random policy comparison, which reveals whether the campaign's exploration strategy added value or was just noise.
+
+
+
The honest limitation
+
+
Counterfactual replay can only compare paths through candidates that were already tested. It can tell you "greedy would have found 1.98 eV at iteration 4 instead of 5" but it cannot tell you "if we had proposed different compositions entirely, we might have found 2.1 eV."
+
+
That second question requires acquisition replay: replaying the surrogate's proposal step with a different acquisition function. matledger does not implement this because it would need to host the surrogate model itself, which violates the architectural boundary (matledger is the ledger, not the engine).
+
+
+
+
+
+
+
5. Product implications for matledger
+
+
The gaps identified above suggest a natural evolution path. matledger today is a diagnostic ledger: it records, detects, and compares. The features below would extend it toward advisory and interventional capabilities, without crossing the line into hosting the surrogate model.
+
+
Near-term: advisory annotations
+
When the proxy gaming detector fires, the verdict could include structured recommendations as annotations on the event log:
+
+
"Retrain surrogate with failure penalties" (links to the failure ledger data)
+
"Increase exploration fraction in next batch" (links to diversity monitor output)
+
"Consider Lower Confidence Bound acquisition" (when prediction variance is high)
+
+
These are advisory only. The PI decides whether to act. matledger provides the evidence and the suggestion, not the action.
+
+
Medium-term: acquisition replay via plugin
+
The Policy protocol already exists as a pluggable interface. Extending it to support acquisition policies (not just selection policies) would let users answer "what if we had proposed different candidates?" without matledger hosting the surrogate. The user provides the acquisition function as a Policy implementation; matledger provides the campaign state and replay infrastructure.
+
+
Long-term: closed-loop guardrails
+
If matledger detects proxy gaming during a live campaign (not just post-hoc), it could emit a GuardrailTriggered event that pauses automated iteration and requires PI review before the next batch. This moves matledger from post-hoc diagnosis to real-time intervention, while still leaving the correction decision to the human.