The verification protocol¶
crossverify runs one analysis through six phases. This document describes each
phase twice: once as a brief overview for a statistically literate reader, and
once in technical detail for someone wiring up their own analysis.
In brief¶
Computational results can fail to replicate for reasons that have nothing to do
with the data: a transformation applied in the wrong order, a statistic read off
the wrong object, an estimator whose default differs between packages, or an
unset seed. crossverify runs an analysis through six checks aimed at those
failure modes, with an independent reimplementation in a second language as the
backstop. You provide the analysis twice, in Python and in R, each emitting the
same named statistics, and the harness reconciles them.
1. Intake. The dataset is summarized as loaded: dimensions, dtypes, missingness, descriptives, and category frequencies. This is a provenance check against the source file, so a silent wrong-file or bad-parse error surfaces before it propagates.
2. Transformations. If the analysis declares a preparation step, the harness snapshots the data before and after and tests the invariants you assert about it: bounds on a rescaled variable, no unintended row duplication from a merge, an expected N. A large share of wrong numbers downstream originate here.
3. Consistency and spot-checks. Each reported statistic is range- and sign-checked against what its type permits: an R-squared or proportion in [0, 1], a p-value in [0, 1], a standardized loading or correlation in [-1, 1], an OLS-with-intercept residual sum near zero, a coefficient of a hypothesized sign (a mismatch is flagged for review, not failed — it is often the finding), convergence flags, centroids inside the analyzed variable's support. Group invariants are checked too, such as cluster sizes summing to N or a variance decomposition summing to one. Then at least one statistic is recomputed directly from the raw data along an independent path, so the estimate cannot silently disagree with its own inputs.
4. Reproducibility. The Python analysis is re-executed and required to return essentially identical results (a very tight tolerance, not bit-exact, so a multithreaded BLAS reducing sums in a different order does not fail correct deterministic code). A value that drifts beyond that signals uncontrolled state or unseeded randomness. This tests determinism within one process; it is not a guarantee of reproducibility on another machine or BLAS build. A fixed seed makes a same-tool re-run reproducible, but that does not carry across tools (see Phase 5).
5. Cross-tool triangulation. The core check. The analysis is re-estimated in
R, written independently, and the two result sets are compared statistic by
statistic within tolerance. Agreement is strong evidence that a result is
implementation-independent — not an artifact of one library's conventions
(estimator defaults, denominator choices, tie or missing handling). It is not
proof of correctness: you write both sides, so a shared specification error
agrees perfectly. Nor is disagreement always error — defensible convention
differences (robust SEs, ddof, contrast coding) can exceed a tight tolerance on
correct code, so a mismatch is a prompt to understand why, not a signal to
force one tool to mimic the other. Crucially, the comparison is meaningful only
for deterministic estimators: Python and R use different RNGs, so a shared
seed does not align random streams, and genuinely stochastic procedures will not
match across tools even when both are correct. Tolerances are configurable per
statistic, including magnitude-only comparison for sign-indeterminate quantities
such as PCA loadings. Mismatches, and statistics present in one tool but not the
other, are reported explicitly.
6. Reporting. Everything is compiled into a verification log, the cross-tool comparison table, a machine-readable results file, and a methodology paragraph pre-filled with versions, seed, tolerance, and match counts for adaptation into a manuscript. The run returns a nonzero exit code on any failure, so it composes with CI.
Scope. The harness establishes that results are internally consistent, reproducible within a process, and implementation-independent. It does not establish correctness: agreement is not sufficient (a shared specification error, or a shared BLAS kernel, agrees perfectly) and not strictly necessary (correct analyses can differ for defensible reasons). It also does not adjudicate specification — whether the model is appropriate, the identifying assumptions hold, or an effect is substantively meaningful. Those judgments stay with the analyst, and the log says so explicitly.
In detail¶
The setup¶
Before any phase runs, the command-line tool reads your project.yaml, validates
it (the data file exists, a Python analysis is declared, the R script resolves),
loads the dataset as a CSV, and imports your Python analysis module. You supply
three things: a Python adapter exposing run(df, seed=None) that returns a flat
dictionary of named statistics, an R script that computes the same-named
statistics independently, and the project file that declares which checks to
apply. The harness then runs the phases you select (all six by default) and
writes its outputs to crossverify_out/<project>/.
Phase 1: Data intake and inspection¶
The harness reports the dataset exactly as it loaded it: the row-and-column count, every column's data type, the count of missing values per column, the first ten rows, descriptive statistics for the numeric columns, and frequency counts for the categorical ones. Nothing here passes or fails; it is all informational. The point is to let you confirm that the file the harness sees is your raw file, before a single number is computed. This phase also records the minimum and maximum of each numeric column, which a later phase uses to sanity-check cluster centroids.
Phase 2: Transformation sanity checks¶
This phase is opt-in and only does something if your Python adapter defines a
prepare(df) step. When it does, the harness runs that cleaning step, records a
before-and-after shape snapshot, and applies any transformation checks you
declared: that a standardized column actually falls inside its expected range,
that no rows were silently duplicated (the classic symptom of a bad merge key),
or that the row count is what you expected. If there is no prepare() step, the
phase records that the analysis consumes the raw data as loaded. The purpose is
to catch data-wrangling errors that would otherwise quietly corrupt everything
downstream.
Phase 3: Analysis, internal consistency, and spot-checks¶
The harness runs your Python analysis and collects the statistics it returns. It
then applies two kinds of automatic checks. The first is internal consistency:
each statistic is checked to be the kind of number it claims to be. An R-squared
or a variance-explained (a proportion, not a percentage or eigenvalue) must lie
in [0, 1], a p-value in [0, 1], a standardized factor loading or correlation in
[-1, 1] (unstandardized loadings legitimately exceed 1, so declare
standardized: false for them), a count must be a non-negative integer, and an
iterative procedure must report convergence. A residual sum near zero is a
property of OLS with an intercept only — not of GLM/logistic, no-intercept,
WLS/GLS, or penalized fits — and its tolerance scales to the response magnitude
when you declare a column, so a correct large-scale fit is not failed by
floating-point accumulation. A coefficient with the wrong sign is reported as
informational by default rather than failed, because a flipped sign is often
the substantive finding (set severity: fail to harden it). A cluster centroid
is checked against the observed range of its variable in the analyzed space.
When the adapter declares a prepare() step, that step is run once and its
output is the analyzed frame: it is what run() receives and what these ranges
are derived from, so a statistic and the range it is checked against cannot end
up in different spaces. Group
checks cover cases that span several statistics, such as cluster sizes summing to
N or a variance decomposition summing to one. The second kind is the spot-check:
the harness recomputes a reported value directly from the raw data (a mean, sum,
median, standard deviation with a configurable ddof, and so on, optionally
filtered to a subgroup) and confirms the analysis's reported value matches. This
is the step that catches an impossible number or an analysis that has quietly
drifted away from its own source data.
Phase 4: Reproducibility¶
The harness runs your Python analysis a second time and requires every statistic
to come back essentially identical. The default tolerance is extremely tight
(rtol = 1e-12) rather than bit-exact, so genuinely deterministic code is not
failed when a multithreaded BLAS reduces sums in a slightly different order
between two calls; set reproducibility: {atol, rtol} to change it, or pin
OMP_NUM_THREADS=1 for bit-for-bit equality. If you set a seed, both runs use
it, so a stochastic procedure reproduces once the seed is fixed. Any statistic
that drifts beyond tolerance, or appears on one run but not the other, is flagged
(a NaN is treated as a failure, not as two runs "agreeing"). This tests
determinism within one process — not reproducibility on a different machine,
OS, or library build, which is what the word can connote — and a seed that fixes
this same-tool re-run does not align random streams across Python and R.
Phase 5: Cross-tool triangulation¶
This is the centerpiece. The harness runs your independent R implementation as a
separate process, handing it the data path, an output path, and the seed, and
pointing it at the bundled R helper so it can emit its results as JSON. It then
compares the Python and R results one statistic at a time, within tolerance. The
default tolerance is tight, but you can loosen it per statistic, and you can tell
it to compare magnitudes only for quantities whose sign is implementation-defined,
such as PCA loadings that legitimately flip sign between the two tools. Any
statistic present in one tool but missing in the other is flagged. The result is
a comparison table showing the Python value, the R value, the absolute
difference, and whether they matched. If R is unavailable, or you pass
--skip-r, or no R script is declared, the phase is skipped and reported as such
rather than counted as a failure.
The purpose is to establish that a result is implementation-independent, not
that it is correct. Two correctly-disagreeing tools (robust-SE variants, ddof
or denominator conventions, factor contrast coding, optimizer defaults) can
exceed the tolerance on a correct analysis, so read a mismatch as a question, not
a verdict, and resist "fixing" sound code to satisfy a verifier. When you have
identified such a divergence as expected, declare severity: info in that
statistic's per-key tolerance: the harness then reports the disagreement as
informational instead of failing the build, so the exit code stops rewarding the
degradation of correct code (a statistic missing from one tool stays a hard
failure regardless, since that means the replication is incomplete). The comparison
is also only meaningful for deterministic estimators: because Python and R use
different random number generators, a shared seed does not produce the same
random stream, so stochastic procedures (bootstrap, k-means initialization,
permutation tests) will not match across tools even when both are right — compare
their expectations within a sampling-error tolerance instead. Prefer triangulating
a coefficient and its standard error over a p-value, which compresses the test
statistic through a nonlinear tail and diverges near the boundary.
Phase 6: Compiled log and methodology statement¶
The harness assembles everything into durable, reviewer-facing evidence. It writes a verification log that lists every phase and every check with a PASS, FAIL, or INFO status, embeds the Phase 1 intake summary and the Phase 5 comparison table, and ends with a short checklist of the judgments that remain yours. It writes the comparison table as its own file, a machine-readable JSON of all results, and a methodology-statement paragraph pre-filled with your tool versions, seed, tolerance, and how many statistics matched across tools, marked clearly as a template to adapt for a manuscript. Finally it prints a console summary and sets its exit code: zero if nothing failed, one if any check failed, so the whole run drops cleanly into a Makefile or CI step.
The boundary¶
Across all six phases the tool checks that numbers are consistent, reproducible, and tool-independent. It does not establish that they are correct: agreement across two implementations you both wrote cannot catch a shared specification error, and correct analyses can disagree for defensible reasons. And it does not judge whether you chose the right model or whether a coefficient is substantively meaningful. Those lines are deliberate, and the verification log restates them at the end so the human judgment stays with the human.