Object Validation¶
Every object doppler certifies carries its own evidence: measurements taken from the shipped C through its shipped binding, a review that judges them, and a set of limits a caller may rely on. This page is the process. Follow it rather than reconstructing it from the last object that went through.
Why the evidence layer exists¶
The campaign started because a report's "Limits" section ended with the
sentence "Claims a caller may rely on. A failure here is a regression, not
a new finding" — and nothing executed it. The tree lived at
src/doppler/tests/validation/, no make target reached it, no CI job named
it, and pytest --collect-only found zero tests inside it. Forty-four
claims across the first two objects were asserted by nobody, and two
regressions went straight through them in one afternoon.
So the rule that shapes everything below: a claim nothing runs is prose.
When you add a phase, ask which gate executes it. If the answer is "none",
it is documentation, and it belongs in docs/design/ where nobody will
mistake it for a guarantee.
The order is not negotiable¶
- The C header is the SSOT. Read
native/inc/<obj>/<obj>_core.hfirst and enumerate every claim its prose makes. - Map each claim onto
native/tests/test_<obj>_core.c— pinned, pinned only at literals, or absent. The uncovered rows dictate the C tests to write. Write them, and prove each by sabotage before trusting it. - Only then build the Python validation folder, which characterises, plots and asserts.
This was learned rather than designed. NCO and LO were the first two
objects, and NCO went Python-first: its header claims were discovered
empirically from outside, which produced evidence in the wrong language for
the gate and cost a rewrite. The question that corrected it — "did we
start with nco_core.h as SSOT, then update/write C tests?" — is the one
to ask yourself at the start of each object.
The reason is not tidiness. An object's most important surface is often the
one the binding does not expose: resamp's control accumulator,
resamp_get_ctrl_acc, is the diagnostic for a closed timing loop and has
no Python binding at all. A Python-first audit measures whatever the
binding happens to reach and reports a clean bill of health for the surface
that matters least.
Step 1 — the claim inventory¶
Read the header and write down every prose claim, then grep the C test for it. Three outcomes, and the middle one is the trap:
| verdict | meaning |
|---|---|
| pinned | an assertion genuinely tests the claim |
| pinned only at literals | the test's comment claims more than its assertion does — prose wearing a test's clothes |
| absent | zero mentions |
resamp's inventory is the worked example: 16 public entry points against a
593-line test, of which three had zero mentions
(resamp_get_ctrl_acc, resamp_dc_gain, resamp_destroy(NULL)) and two
more were literals-only — set_rate's comment promised "preserves phase"
while asserting only that get_rate() read back, and reset's promised
"zeroes phase/delay" while asserting only that the rate survived. A reset
that zeroed nothing at all passed that test.
Step 2 — the C tests, and the sabotage¶
Write a test for every uncovered claim, then break the code and watch it go red. A test you have not seen fail is not evidence.
Two failure modes to design against:
Measure against an external truth, not against the other entry point.
A consistency test (path A equals path B) is structurally blind to any
defect the two paths share. resamp's eq_ctrl_push compared the block and
push control paths, swept the deviation across unity on every run, and
passed for the entire period the control accumulator was running the
decimator's recurrence on the interpolator's structure. Prefer truths that
need no timing convention: a resampled pure tone is still a pure tone; the
output count is the integral of the rate regardless of the filter; a
polyphase arm can be read off the output by giving arm p the single tap
p + 1.
A reject test can pass vacuously. Assert the precondition too. resamp's
§12 asserts that mu stays bit-exactly 0.0 at an exact rate — which an
accessor hard-wired to 0.0 also satisfies. The sabotage that took §10,
§11 and §12's slewing case red left the steady case green, and it now
carries its own precondition: steer the same rate off-exact and require
mu to move. Same shape as reset — a reset test proves nothing if the
state was already zero when it ran.
Step 3 — the validation folder¶
Each certified object owns a folder beside its module's tests — the
module, not the object, so nco and lo are siblings under source:
src/doppler/<module>/tests/validation/<object>/
validate.py the runner: characterise -> review -> limits
results.md the authoritative report, GENERATED, never hand-edited
*.png plots it embeds
data/*.csv raw sweeps, so any number can be re-derived
Shared machinery lives in src/doppler/tests/:
_validation_common.py— theReportclass and the--checkCLI, so the format cannot drift between objectsloop_reference.py— the closed-loop reference every steered object is measured against, with the detector as a parameter
Anything a second object needs moves into the shared home when that second caller appears, not in anticipation of one.
The report's five sections¶
- The object — links to
docs/design/<obj>.mdand the header; does not restate them. If this section starts writing design rationale, that content belongs indocs/design/. - Characterisation — measured behaviour, tables and plots, no
verdicts. Name the C section each part tracks in its heading (resamp
does:
### 2.4 Decimating: the stopband … (C §17)) so the two read side by side. The report's own numbering stays sequential regardless — it is not a mirror of the C file's, because a report section routinely merges several C ones, and nco's claim that it was a mirror is what let a gap at §2.8 sit unnoticed. Claims unreachable from Python are reported as C-ONLY with the C section that covers them, never silently skipped. - Review — findings with verdicts:
BY DESIGN,GAP,CONFIRMED,FIXED,C-ONLY. - Limits — the envelope a caller may rely on, asserted.
- Summary.
Phases in order, and no fixes during characterisation — findings only. A fix made while measuring contaminates the measurement it came from.
The two gates¶
They answer different questions, and the split is the whole point.
| gate | question | what runs it |
|---|---|---|
test_validation_limits.py |
do the limits still hold? | make test-python, per module |
make validate-check |
is the committed results.md stale? |
make gates |
The limits gate lives at
src/doppler/<module>/tests/test_validation_limits.py. It runs each
object's own build(write=False) and fails on any limit that does not
hold, so the evidence and the gate are the same code and cannot disagree
the way a hand-copied assertion would. write=False suppresses
results.md, the plots and the CSVs — a test must never write into the
repo — while every measurement still executes.
It is a per-module file rather than one tree-wide collector because each validator imports its own module's objects; a single collector would import every extension in the tree to run any object's limits.
The staleness gate is make validate-check, which re-renders each
report in memory and fails if the committed bytes differ. make validate
regenerates. Both discover validators by glob, so a new object is gated
the moment its folder exists — there is no registration step to forget.
Adding an object¶
- [ ] Enumerate the header's claims; map each onto the C test
- [ ] Write C tests for the uncovered ones; sabotage each and watch it fail
- [ ] Create
src/doppler/<module>/tests/validation/<object>/withvalidate.pyand an__init__.py - [ ] Add the module's
test_validation_limits.pyif it is the module's first object; otherwise add the object to itsOBJECTSmap - [ ]
make validateto generateresults.md, plots and CSVs - [ ]
make validate-checkand the module's pytest, both green - [ ] Commit the generated report — it is the deliverable, not a build artifact
Conventions worth knowing¶
results.mdis generated. It is excluded from mdformat, andvalidate.pyrstrips to exactly one trailing newline so the end-of-file-fixer and the generator cannot fight each other.- The report self-checks as it renders.
Report.render()refuses to emit a document that contradicts itself: a§N.Mreference with no such section, section-2 numbering with a gap, or a table cell truncated mid-reference. All three shipped before the check existed, andmake validate-checkstayed green through every one of them — it provesresults.mdmatches what the generator renders, which says nothing about whether what is rendered is coherent. Registration-free: every object renders through the same method. - Render findings in full.
R.find()'s text is the argument for a verdict, and the report is where it belongs. Truncating to a first sentence (txt.split(".")[0]) both hides the reasoning and cuts any sentence containing a decimal mid-reference. - Emit
from the section, never fromplots().plots()runs only whenwrite=True, so markdown emitted there is absent from the--checkrender — andmake validate-checkis then permanently stale with a diff that looks like drift and is really a missing section.plots()draws files; the section owns the reference. - A failing limit is a regression. A new problem found while characterising is a finding, and goes in section 3 with a verdict.
- Findings that will not be fixed in the same pass get filed as issues before the PR merges, not explained in a docstring.