Skip to content

test(engine): unit tests + tarpaulin gate (#21) - #74

Merged
obchain merged 4 commits into
mainfrom
feat/21-engine-tests
May 27, 2026
Merged

obchain merged 4 commits into
mainfrom
feat/21-engine-tests

Conversation

@obchain

@obchain obchain commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

Drives engine-numerics coverage to 86.5 % (351/406 lines) — above #21's 80 % gate — by:

  1. Refactoring chain.rs::fetch_chains to extract a pure assemble_chains(rows, now) helper, then exhaustively testing the folding logic without a ClickHouse connection.
  2. Adding strip-builder edge-case tests called out in the issue body (irregular spacing, very wide spreads, single-side wings, forward-outside-range rejection).
  3. Adding JSON wire-shape tests for sinks::strip_envelope / leg_envelope to pin the /v1/options/strip contract.
  4. Landing a tarpaulin.toml profile that runs cargo tarpaulin (no args) against engine numerics and excludes the async I/O wrappers + binary entry — those are covered by the e2e smoke from M1 end-to-end smoke: Deribit → ingestion → normalizer → engine → API → browser tick verification #66/feat(scripts): M1 end-to-end smoke (Deribit → ingestion → engine → API → WS) #69, not by unit tests.

Closes #21.

Coverage delta

File Before After
bs.rs 18/20 (90 %) 18/20 (90 %)
chain.rs 13/67 (19 %) 43/69 (62 %)
interpolate.rs 31/34 (91 %) 31/34 (91 %)
snapshot.rs 74/80 (92 %) 74/80 (92 %)
spline.rs 56/60 (93 %) 56/60 (93 %)
strip.rs 85/99 (86 %) 89/99 (90 %)
variance.rs 40/44 (91 %) 40/44 (91 %)
engine numerics total 317/404 (78.5 %) 351/406 (86.5 %)

Excluded from the denominator per tarpaulin.toml:

  • crates/engine/src/main.rs — binary entry, exercised by scripts/e2e-smoke.sh
  • crates/engine/src/sinks.rs — ClickHouse + Redis publish path, exercised by scripts/e2e-smoke.sh

Test count delta

chain.rs:       5 → 14   (+9: assemble_chains permutations)
strip.rs:      14 → 18   (+4: edge cases from #21 body)
sinks.rs:       0 →  3   (+3: envelope JSON shape)
variance.rs:    9         (unchanged)
interpolate.rs: 10        (unchanged)
spline.rs:      8         (unchanged)
bs.rs:          7         (unchanged)
snapshot.rs:    8 + 2 helpers   (unchanged)
─────────────────
total tests:   66 → 82

All 82 pass: RUSTFLAGS=\"-D warnings\" cargo test -p volx-engine clean.

The chain.rs refactor

fetch_chains was a single async function doing both the ClickHouse query and the row-→-tree fold. Splitting them turns the latter into a pure synchronous function reachable from unit tests:

pub async fn fetch_chains(client, now) -> Result<AssetChains, ChainError> {
    let rows = client.query(...).fetch_all().await?;
    Ok(assemble_chains(rows, now))   // pure
}

fn assemble_chains(rows: Vec<ChainRow>, now) -> AssetChains { ... }

The async I/O surface (~30 lines) shrinks to "build query, hand off to the helper" — straightforward enough that the e2e smoke is the right place to assert it, not a mocked-client unit test.

Strip edge-case coverage

Issue #21 listed four regimes to cover. All addressed:

Regime Test
Missing strikes rejects_chain_without_two_sided_strike, rejects_chain_with_too_few_iv (pre-existing)
Irregular spacing irregular_strike_spacing_still_builds
Very wide spreads very_wide_strike_range_builds_with_dense_grid_spanning_full_range
Single-side wings single_side_wings_still_build_via_iv_fallback

Also added: rejects_when_forward_is_outside_listed_strike_range to nail the §4.3 step 3 "no extrapolation" guard.

Sinks envelope tests

Three tests pin the wire shape of /v1/options/strip:

  • leg_envelope_pins_field_names_and_quotes_triple_shape — forward, k_zero, time_to_expiry_y, quotes: [[K, Q, iv], ...]
  • strip_envelope_wraps_two_legs_with_top_level_id_and_ts — index_id is the ticker string, ts is present, near + next are distinct envelopes
  • strip_envelope_preserves_quote_count_per_leg — no truncation / cross-leg aliasing

Bug found while writing those tests: strip_envelope emits ts via OffsetDateTime's default Serialize (a numeric array) instead of the RFC 3339 string used everywhere else IndexValue is serialized. Filed as #73 with the fix hint inline. Test relaxed (!ts.is_null()) so the #73 PR can flip it to a proper string assertion.

tarpaulin.toml

[default]
packages = [\"volx-engine\"]
exclude-files = [
    \"crates/engine/src/main.rs\",
    \"crates/engine/src/sinks.rs\",
    \"crates/normalizer/src/*\",
    \"crates/shared-types/src/*\",
]
timeout = \"120s\"
engine = \"Llvm\"
out = [\"Stdout\"]

Run with cargo tarpaulin — no flags needed.

Out of scope

Verification

  • RUSTFLAGS=\"-D warnings\" cargo test --workspace → 82 engine tests pass, all other crates green
  • RUSTFLAGS=\"-D warnings\" cargo clippy --workspace --all-targets clean
  • cargo tarpaulin (no args) → 86.45 % engine numerics coverage
  • The CI workflow from GitHub Actions: CI (lint + test, no deploy) #28 fires on this PR as the second live test

Test plan

  • cargo test -p volx-engine green (82 tests)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo tarpaulin reports ≥ 80 % on engine numerics
  • All four CI jobs pass on this PR's first run
  • Subagent (rust-reviewer) review applied as fixup before merge

obchain added 4 commits May 27, 2026 18:47
Extracts the row → AssetChains folding from `fetch_chains` into a pure
helper `assemble_chains(rows, now)`. The I/O surface (the ClickHouse
`argMax(...) GROUP BY ...` query) keeps its boundary but the row-folding
logic — unknown asset/kind drop, finite-strike filter, call+put leg
merge, per-expiry year-fraction — is now reachable from unit tests
without a database.

Nine new tests cover:

- unknown asset dropped (warn + skip)
- unknown kind dropped (warn + skip)
- non-finite or non-positive strike dropped (NaN, ∞, 0, negative)
- call + put for same strike fold into one ChainLeg
- BTC and ETH chains isolated under the outer map
- multiple expiries within an asset isolated under the inner map
- non-finite mid + iv get filtered to `None`
- `now` parameter drives the year-fraction computation
- empty input yields empty map

Engine coverage on `chain.rs` jumps from 19 % (13/67) to 62 % (43/69);
the remaining 26 lines are the async query path which the e2e smoke
exercises end-to-end.
Strip builder — four new tests covering the regimes called out in #21:

- irregular strike spacing (asymmetric, non-uniform) still produces
  a monotonic 801-point dense grid
- very wide strike range (K spans an order of magnitude) covers the
  full listed range end-to-end
- single-side wings (call-only above F, put-only below F) build via
  the IV fallback path
- forward outside listed strike range rejects (the §4.3 step 3 guard
  against extrapolation)

Sinks envelopes — three new tests pin the JSON wire shape for the
`/v1/options/strip` endpoint that the Go API and methodology page
consume:

- `leg_envelope` field names and the `[K, Q, iv]` triple ordering
- `strip_envelope` top-level `index_id` + `ts` + `near` + `next`
- per-leg quote counts preserved (no truncation / aliasing between
  near and next)

Bug found while writing the sinks tests: `strip_envelope` emits `ts`
via `OffsetDateTime`'s default Serialize (numeric array) instead of
the RFC 3339 form used everywhere else IndexValue is serialized.
Filed as #73; the test pins "ts present in some form" so the fix-PR
has a flip target.
`cargo tarpaulin` (no args) now reads `tarpaulin.toml` and reports
engine-only numerics coverage. The async I/O wrappers
(`chain::fetch_chains`, `sinks::IndexSinks::publish`) and the binary
entry (`engine/src/main.rs`) are excluded — they are exercised by the
e2e smoke (`scripts/e2e-smoke.sh`, gated by CI on every PR), not by
unit tests. Counting them against the unit-test target would push us
toward brittle mocked-client tests that don't catch real-world
regressions.

Current numerics coverage: 86.5 % (351/406 lines), passing #21's
80 % gate.
HIGH-1: `cargo fmt` run, two formatting drifts (chain.rs row() args,
sinks.rs assert! lines) now match rustfmt.

MED-1: tarpaulin.toml comment corrected — `chain::fetch_chains` is
NOT excluded from the denominator (only main.rs + sinks.rs are).
Reviewer caught the stale claim; the actual exclude list now matches
the prose.

MED-2: `single_side_wings_still_build_via_iv_fallback` renamed to
`lower_wing_put_fallback_upper_wing_call_primary_still_build` to
match what it actually exercises (only the lower wing tests the
put-side fallback). Added the complementary test
`upper_wing_put_fallback_still_build` so both directions of the
fallback are now covered.

MED-4: `rejects_when_forward_is_outside_listed_strike_range` now
asserts the K_max=50 invariant up-front so a future maintainer adding
a leg above K=50 fails this test loudly instead of exiting via a
different `BuildError` variant.

LOW-1: sinks.rs `ts` assertion tightened from `!is_null()` to
`is_array() || is_string()`. Documents the current broken numeric-array
shape (#73) and the post-#73 RFC 3339 string form explicitly, so the
bug-fix PR flips the assertion in one line.

LOW-2 rejected — `packages = ["volx-engine"]` does NOT filter the
coverage scan; tarpaulin walks the engine dep graph and counts
normalizer/shared-types lines unless excluded. Re-added those
exclude-files entries with a comment.

MED-3 not actionable — `assert_eq!(chain.legs.len(), 1)` is already
present at chain.rs:323 in the assemble_drops_unknown_kind test;
reviewer missed it.

MED-5 + LOW-3 + LOW-4 skipped per the project's existing test-style
convention (bare `unwrap()` in tests, hand-rolled fixture math).
@obchain

obchain commented May 27, 2026

Copy link
Copy Markdown
Owner Author

Review r1 — addressed in fixup 0a14d77

Severity Finding Status
HIGH-1 cargo fmt --check failures in chain.rs + sinks.rs Fixed — cargo fmt applied
MED-1 tarpaulin.toml claimed chain::fetch_chains excluded; it wasn't Fixed — comment corrected, exclude list is authoritative
MED-2 single_side_wings test only exercised lower-wing put-fallback Fixed — renamed for accuracy, added complementary upper_wing_put_fallback_still_build
MED-3 assemble_drops_unknown_kind tautological without legs.len() check N/A — assert_eq!(chain.legs.len(), 1) already at chain.rs:323
MED-4 forward_outside test had no K_max anchor Fixed — added explicit K_max=50 fixture assertion
MED-5 unwrap() vs expect("...") in test code Skipped — matches existing project style
LOW-1 ts assertion too loose Fixed — tightened to is_array() || is_string() with explicit broken-vs-fixed comments
LOW-2 normalizer/* + shared-types/* excludes redundant given packages filter Rejected — packages does NOT filter coverage scan, only test-run set. Tarpaulin walks the engine dep graph and counts those lines unless excluded. Re-added with explanatory comment. Verified empirically: removing them drops coverage from 86.45% to 72.23%.
LOW-3 fixture_strip math obscure Skipped — editorial
LOW-4 assemble_chains missing #[must_use] N/A — private fn, single caller consumes

Coverage unchanged at 86.45 % (351/406 engine numerics lines). Engine test count up one (82 → 83) from the added case.

@obchain
obchain merged commit 0b4006d into main May 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine: unit tests 80% coverage (vs Python ref)

1 participant