Skip to content

Code coverage

doppler measures coverage with clang source-based coverage (-fprofile-instr-generate -fcoverage-mapping). The design leverages doppler's defining property — every algorithm is in C exactly once — so the same native/src/<obj>/<obj>_core.c is exercised by three harnesses:

Harness reaches _core.c via
C tests (native/tests/test_*_core.c) link the <obj>_core OBJECT lib
Python tests (src/doppler/**/tests/) load the .so (ext binding → core)
Rust tests (ffi/rust/) link libdoppler.a

Because one DOPPLER_COVERAGE build instruments those OBJECT files once, every consumer carries instrumentation; each harness emits .profraw, llvm-profdata merge unifies them, and llvm-cov attributes the merged result back to _core.c — a single report showing what C ∪ Python ∪ Rust covers.

Status: All four phases are wired — the merged C ∪ Python ∪ Rust report is produced on every PR and gates it via a diff-cover patch-coverage check.

Run it locally

make coverage          # instrumented build → C tests + pytest → merged report
# open the HTML:
xdg-open build-cov/html/index.html        # or: python -m http.server -d build-cov/html

Requirements: clang + matching llvm-profdata / llvm-cov (same LLVM version). Override version-suffixed tools if needed:

make coverage LLVM_PROFDATA=llvm-profdata-22 LLVM_COV=llvm-cov-22

The coverage target configures a dedicated build-cov/ tree (-DDOPPLER_COVERAGE=ON -DCMAKE_C_COMPILER=clang -DBUILD_PYTHON=ON, Debug/-O0 for accurate line mapping), then:

  1. runs the CTest suite (LLVM_PROFILE_FILE → build-cov/prof/c-*.profraw);
  2. stages the instrumented Python extension into a throwaway package (build-cov/pkg/) — PYTHON_PACKAGE_DIR is redirected there so the build never clobbers the dev's working src/doppler/ .so; the Python source is layered over the staged .so with tar;
  3. runs pytest against that staged package (LLVM_PROFILE_FILE → py-*.profraw), so the same cores get coverage from what Python exercises;
  4. runs the Rust FFI tests against the instrumented libdoppler.so (DOPPLER_BUILD_DIR points build.rs at build-cov); the .so self-writes its C .profraw via its own profile runtime — no Rust instrumentation needed, and rustc's LLVM matches clang's so the .profraw merges;
  5. llvm-profdata merges C ∪ Python ∪ Rust and emits the HTML + lcov, reported against libdoppler.so and every module .so (a line only one harness reaches still attributes correctly).

A live NATS broker on 127.0.0.1:4222 lets the nats:// stream path count too (the CI job starts one); without it stream_nats.c self-skips and shows 0%.

What's measured

  • Included: first-party native/src/**/*_core.c (and the wfm/timing/measure cores) — the hand-written algorithms.
  • Excluded (COV_IGNORE in the Makefile): vendor/**, the jm-generated *_ext.c binding aggregators, and the tests//benchmarks/ harnesses.

The report is generated against build-cov/libdoppler.so plus every staged module .so. Hand-owned binding fragments (<mod>_ext_<obj>.c) count; jm-generated <mod>_ext.c aggregators are excluded.

Phase 1 (C tests) covered ~72% of the cores it reached; phase 2 adds the Python harness, which lifts the cores the C tests can't reach (magnitude_db_cf32.c 0%→100%, wfm_synth_bridge.c 0%→96%) while widening the denominator with the binding fragments and the stream layer. Remaining 0% spots are genuine gaps worth a test — e.g. obw_from_power.c (no caller in any suite) — or are environment-gated (stream_nats.c needs the broker).

CI

The coverage (C + Python + Rust) job (.github/workflows/ci.yml) runs make coverage on every PR (with a NATS broker so the nats:// path counts), uploads the HTML + lcov as the coverage-report artifact (+ a TOTAL line in the job summary), and then gates the PR:

make coverage-gate COV_BASE=origin/main   # diff-cover, --fail-under=90

coverage is in the ci-passed required set, so a PR whose added/changed C lines fall below COV_PATCH_MIN (90%) is blocked. It's a patch gate, not a global threshold — the existing tree is never retroactively gated, and pure .py/.rs diffs (no C coverage data) aren't counted. diff-cover is a dev dependency pinned exactly in pyproject.toml and uv.lock, and the gate runs it through $(DEV_RUN) (uv run --group dev), so a local run gets CI's version. It is self-contained: no external service. The lcov's SF: paths are relativized so they match the git diff.

Notes / caveats

  • clang only. The gcc OS matrix is untouched; coverage is a separate clang+Linux build. (make coverage sets clang/clang++ — the latter for the vendored C++ in the optional stream component.)

  • x86-64-v2 baseline, not -march=native. Native would enable FMA, which breaks wfm_synth's bit-exact step()==steps() parity test (FMA contraction). The coverage build stays at the shipped portable baseline. awgn_core.c's weak libmvec _ZGVdN8v_logf declaration carries __attribute__((target("avx2"))) so its AVX dispatcher still compiles under clang at that baseline (ABI-only; gcc codegen unchanged).

  • "N functions have mismatched data" from llvm-cov is benign, but it is not "a few": the count scales with how many -object binaries the report covers, because a static function in a header is instantiated per translation unit and the copies' mappings differ. Measured 2026-09-12 on one profile set, varying only the objects passed:

    objects mismatched
    libdoppler.so alone 113
    + 10 C-test exes 367
    + the Python extension .sos 730
    + all 157 test/validate exes 3504

    So a four-figure count in CI is the shape of the report, not damage — the recipe passes every test executable on purpose (see the -object block in the Makefile), and that is what makes a directly-called static header copy countable at all. The Python phase reads the same .so it loaded, so its attribution is exact. What a real loss of data looks like is the totals collapsing, which coverage-gate's threshold is what catches.

  • "invalid instrumentation profile data (file header is corrupt)" is expected, not damage, and -failure-mode=all is why the merge survives it: two tests kill a child that had imported the instrumented extension, and a killed process never runs the profile runtime's flush. The Makefile carries the full reasoning beside the merge.

Roadmap

  1. C tests — done.
  2. + Python — done: instrumented .so staged into build-cov/pkg/, pytest run against it, its .profraw merged in.
  3. + Rust — done: the Rust FFI tests link the instrumented libdoppler.so (via DOPPLER_BUILD_DIR), which self-writes C .profraw; merged in.
  4. Patch gate — done: make coverage-gate runs diff-cover over the merged report at --fail-under=90 vs origin/main; coverage is in ci-passed. A patch gate, never a retroactive global threshold.