Training and serving disagree about a feature. This says which side is wrong, why, and how many decisions it changed.
Training and serving skew is not a modelling problem. It is two implementations of the same definition, written by different people at different times against different stores, drifting apart quietly. The model keeps scoring. The offline metric keeps looking fine. Nothing fails.
A parity check finds the difference. It cannot tell you what to do about it, for three reasons this project is built around:
- Parity is symmetric. It says the two paths differ. It cannot say which one deviates from what was intended, because neither path is a reference. So this tool implements the declared semantics a third time and arbitrates a sample against it, which turns 13 named causes out of 16 into 16 out of 16, with a blamed side on 12 of them.
- A difference is not an incident. One scenario here differs on 90.2% of requests and changes one decision out of 1,200. Another differs on 9.2% and changes 106. Severity is measured in decisions, at a declared threshold, not in mismatch counts.
- Skew is not drift. Same dashboard, opposite responses: skew needs a code change and drift may need a retrain, and retraining on a skewed training set makes the skew permanent. Both are computed and the quadrant is named.
Fully offline, no API keys, one seed, deterministic. pytest fits a real scikit-learn model, replays
every injected skew through both pipelines, and re-derives every number in this file.

A replay of real command output. Every line is the actual stdout of the command shown above it,
paced by that command's measured wall time. Source: tools/record_demo.py. There is an
MP4 too.
$ sentinel audit --scenario clean --labels
verdict: parity (scenario clean)
requests: 1,200 features disagreeing: 0 of 9
decisions: 0 changed (0.00%) 0 up, 0 down threshold 0.4608
auc: training 0.8112 serving 0.8112 gap +0.0000
referee: 200 row(s) drift: agree
ok: every feature agrees within its declared toleranceThe control, and the correct output is silence. Now one feature arrives in milliseconds where the spec declared seconds:
$ sentinel audit --scenario unit_seconds_ms --labels
verdict: skew (scenario unit_seconds_ms)
requests: 1,200 features disagreeing: 1 of 9
decisions: 59 changed (4.92%) 0 up, 59 down threshold 0.4608
auc: training 0.8112 serving 0.5535 gap +0.2577
referee: 200 row(s) drift: skew_only
[x] unit_mismatch (serving deviates): seconds_since_last_txn differs by a constant factor of 1000 on
100.0% of requests: milliseconds where seconds were expected
blame: seconds_since_last_txn carries -0.13960 of the -0.13960 mean score gap (exact Shapley)
$ echo $?
1One feature, one factor, and 0.2577 AUC: a model that discriminated at 0.81 offline is discriminating at 0.55 in production. The cause is named, the deviating side is named, and the cost is in decisions.
Two faults produce identical evidence under any pairwise comparison:
- the training pipeline computes a seven-day window that reaches past the decision instant, so it has seen the future;
- the serving pipeline computes a three-day window where seven was intended.
Both make training's values larger than serving's, on the same three window aggregates, by a similar amount. One is a leak that inflates the offline metric and cannot be fixed at serving. The other is a serving defect that costs almost nothing today. Different owner, different fix, different urgency.
Turn arbitration off and the tool says what a parity checker can say:
$ sentinel audit --scenario time_travel_leak --referee-sample 0
[x] unexplained_skew: txn_count_7d disagrees on 88.7% of requests in a shape none of the known
causes describes
[x] unexplained_skew: amount_sum_7d disagrees on 88.7% of requests in a shape none of the known
causes describes
[x] unexplained_skew: amount_max_7d disagrees on 50.7% of requests in a shape none of the known
causes describesTrue, and useless. Turn it on:
$ sentinel audit --scenario time_travel_leak --labels
auc: training 0.845 serving 0.8112 gap +0.0338
[x] time_travel_leak (training deviates): 3 window aggregate(s) are larger in training than serving
can produce, up to 88.7% of requests, and the training path is the side that deviates from the
declared semanticsreferee.py implements the spec's semantics directly and slowly, one request at a time, no
vectorisation, no shared code with either production path. Deliberately: a fast referee would share
code and therefore share bugs with the path it is judging.
| causes named as declared | side blamed | |
|---|---|---|
| without the referee | 13 of 16 | 0 |
| with the referee | 16 of 16 | 12 |
It costs about 20x the serving path, roughly 0.24 s against 0.01 s for 200 requests, on a sample
rather than on the stream. Those seconds are wall-clock and move between machines, so the multiple is
quoted to the nearest five and the raw figures live in docs/experiments/referee_value.json.
10 arbitrated rows are enough to separate the confusable pair; the default of 200 exists because the report also prints per-feature agreement rates
and a rate over ten rows reads as noise. Full argument in
ADR-001.
16 scenarios, 14 causes, all replayed through the real pipelines by
experiments/skew_matrix.py and by the test suite, which run the same code path. Three of the
scenarios expect silence and get it.
| skew | cause named | deviating side | worst mismatch | decisions changed | AUC gap |
|---|---|---|---|---|---|
| no skew (control) | nothing (correct) | n/a | 0.0% | 0 (0.00%) | +0.0000 |
| seconds against milliseconds | unit_mismatch |
serving | 100.0% | 59 (4.92%) | +0.2577 |
| the training set saw the future | time_travel_leak |
training | 88.7% | 21 (1.75%) | +0.0338 |
| the serving window is shorter | window_mismatch |
serving | 67.6% | 4 (0.33%) | +0.0013 |
| the serving store is fifteen minutes behind | stale_features |
serving | 2.0% | 0 (0.00%) | +0.0000 |
| the fill value is applied on one side only | default_value_mismatch |
serving | 9.2% | 3 (0.25%) | +0.0015 |
| a sentinel instead of a missing value | sentinel_value |
serving | 9.2% | 106 (8.83%) | +0.0495 |
| the winsorisation is applied on one side only | transform_missing |
serving | 8.1% | 41 (3.42%) | +0.0060 |
| two defaults for one failed lookup | lookup_default_mismatch |
serving | 1.8% | 0 (0.00%) | +0.0024 |
| rounded crossing a serialisation boundary | rounding_at_boundary |
serving | 90.2% | 1 (0.08%) | +0.0001 |
| two feature slots filled in the wrong order | feature_slot_swap |
serving | 99.8% | 105 (8.75%) | +0.2296 |
| float32 in the serving runtime | nothing (correct) | n/a | 0.0% | 0 (0.00%) | +0.0000 |
| the same float32, judged with no tolerance | precision_loss |
serving | 98.2% | 0 (0.00%) | +0.0000 |
| a category the vocabulary has never seen | unseen_category |
n/a | 0.0% | 0 (0.00%) | +0.0000 |
| the world moved and both paths agree | nothing (correct) | n/a | 0.0% | 0 (0.00%) | +0.0000 |
| the world moved and the paths disagree | unit_mismatch |
serving | 100.0% | 94 (7.83%) | +0.2398 |
Two rows deserve reading together. rounding_at_boundary differs on 90.2% of requests and changes one
decision. sentinel_value differs on 9.2% and changes 106. The relationship between how often two
values differ and how much damage that does is not monotone in either direction, which is why severity
is measured in decisions (ADR-005).
The two causes with no scenario are unexplained_skew, the honest fallback for a shape no rule
describes, and population_drift, which the drift half reports rather than the classifier. Both are
covered by hand-built evidence in the unit tests, and tests/test_scenarios.py asserts that those two
and no others are missing, so a cause added without an injector fails the build.
$ sentinel audit --scenario json_rounding --labels # 90.2% of requests differ
decisions: 1 changed (0.08%)
[!] rounding_at_boundary (serving deviates): device_score is quantised to 4 decimal place(s) at
serving and not in training, on 90.2% of requests; 1 decision(s) change
$ echo $?
0
$ sentinel audit --scenario sentinel_999 --labels # 9.2% of requests differ
decisions: 106 changed (8.83%)
[x] sentinel_value (serving deviates): device_score is -999 on 111 serving row(s), outside the range
the training path ever produces
$ echo $?
1Same tool, opposite conclusions, and the mismatch rates point the other way. Exit codes are the
interface, so they are fixed and tested through the installed command, three runs per case: 0
nothing blocking, 1 a blocking finding, 2 a bad invocation, 3 a side could not be built.
--fail-on {none,warning,blocking} chooses the gate, changes the exit code, and never changes the
output.
The same population change, twice: once with a defect and once without.
$ sentinel audit --scenario population_drift --labels
referee: 200 row(s) drift: drift_only
ok: every feature agrees within its declared tolerance
$ sentinel audit --scenario drift_and_skew --labels
referee: 200 row(s) drift: both
[x] unit_mismatch (serving deviates): seconds_since_last_txn differs by a constant factor of 1000Skew is a paired comparison, same entity and same instant through two implementations. Drift is unpaired, one implementation across two time windows. They are different measurements of different things and they get the same dashboard, so this tool computes both and names the quadrant with the action:
- skew only: a code defect. Fix the pipeline and do not retrain, because a retrain fits the model to the bug.
- drift only: nothing is broken. Whether it needs a retrain is a question about labels.
- both: fix parity first. A shared upstream change can move both paths together, which drift sees and a parity check cannot, so this is the case where a parity-only tool reports "no change" during a real incident.
The drift verdict is correct in 16 of 16 scenarios. Getting there caught a bug in the fixture rather
than in the tool: the control scenario reported drift because the injected incident process started
when the request stream started, so accumulating features ramped across it and amount_sum_7d reached
a population stability index of 0.117. The cheap fix was to raise the threshold. The right one was
to give the incident process a history. Written up in
ADR-002.
$ sentinel explain --scenario slot_swap
mean score gap +0.019138 over 300 sampled request(s)
feature exact shapley one at a time difference
country_risk +0.013922 +0.000267 -0.013656
device_score +0.005216 -0.008440 -0.013656
exact sums to +0.019138, which is the whole gap by construction
one at a time sums to -0.008173, missing it by 0.027311
the two methods disagree about the top feature: country_risk against device_scoreThe cheap way to attribute a score gap is one feature at a time: swap one serving value into the training vector, rescore, call the difference that feature's contribution. It is what most parity dashboards do and the parts do not add up, because the score is a sigmoid of a linear combination.
This computes Shapley values exactly, over all 2**k subsets, and it is affordable because of one
structural fact: the logit of a hybrid vector is the training logit plus the sum of the per-feature
logit deltas taken from serving, so each subset is one addition rather than one forward pass. Above 16
differing features the tool refuses rather than switching silently to a sampled approximation and
printing it under the same heading.
Measured across the 13 scenarios with a score gap: exact Shapley sums to the whole gap in every one,
the two methods agree on the top feature in 12 of 13, and the worst relative additivity error is
142.7%. On slot_swap the cheap method gets the sign of the entire gap backwards, because a slot
swap is nothing but an interaction and a single-swap method cannot see one.
ADR-003.
Neither pipeline contains the facts a parity check needs. specs/fraud.yaml does, and it is reviewed
like code:
threshold: 0.460767
semantics:
window_days: 7
as_of: true # [T - 7 days, T). Strictly before T.
clip_amount_to: 25000 # both paths, or neither
missing_device_score: 0.0
unknown_country_risk: 0.0
time_unit: seconds
no_history_seconds: 2592000
features:
- name: seconds_since_last_txn
unit: seconds
tolerance: 1.0 # two clocks will not agree more closely than a second
- name: country_risk
unit: score
tolerance: 1.0e-6 # absorbs float32 and nothing that means anythingTolerance zero is not the strict choice. A serving runtime that casts to float32 is a correct
serving runtime, and under exact comparison it is flagged on 98% of requests. An alert that fires
on 98% of requests where nothing is wrong is muted within a week, and a muted alert cannot catch the
real thing either. The sweep in experiments/tolerance_cost.py finds a workable band of 1e-07 to 0.1,
seven orders of magnitude wide, with the real bug caught at every tolerance in it.
ADR-004.
Units are declared and never converted. A feature declared in seconds and delivered in milliseconds is a finding, not something to normalise away.
pip install -e ".[dev]"
pytest # 219 tests, fits a real model, replays every scenario
make demo # the whole CLI against 11 scenarios, asserting exit codessentinel audit --scenario unit_seconds_ms --labels # parity, impact, arbitration, drift, blame
sentinel parity --scenario unit_seconds_ms # per-feature agreement, the cheap question
sentinel arbitrate --scenario short_window # which side deviates, feature by feature
sentinel explain --scenario slot_swap # attribute the gap, exactly and cheaplyEvery command takes --json. audit also writes --out (JSON), --markdown (for a pull request
comment) and --html (one self-contained file for an incident ticket, no external asset of any kind).
Pointing it at a real deployment means replacing skews/ with the two implementations under audit and
writing a spec. The scenario registry lives outside the package for exactly that reason: it is a
fixture, not library code, and cli.py imports it lazily so the boundary is visible.
| tests | 219 tests, 9 files, no network, one seed |
| coverage | 95.9% line, 88.5% branch |
| scenarios | 16 injected, 14 declared causes, 3 expecting silence |
| experiments | 5, each writing JSON that the README is checked against |
| ADRs | 5, each naming the experiment that measures it |
Nothing above is typed by hand. tools/collect_metrics.py re-runs the suite and all five experiments
and writes docs/metrics.json; tools/check_numbers.py fails the build when a document quotes a
number that is no longer measured, or when the README quotes a number nothing measures at all.
The checker is anchored rather than digit-matching. A metric declares the wording that must surround
it, so "{} arbitrated rows" at 10 requires the phrase, not a bare 10 somewhere on the page. The
digit-only version of this tool shipped in an earlier project and passed while three claims were false,
because the sentence around the number had changed meaning.
$ python tools/check_numbers.py
ok: 32 anchored phrase(s) and 13 value(s) checked across 7 document(s)CI runs lint, the suite with coverage, the demo with its exit-code assertions, the metric collection, and the number check, on every push.
- ADR-001: parity cannot say which side is wrong, so the spec gets implemented a third time
- ADR-002: skew and drift get the same dashboard and need opposite responses
- ADR-003: exact Shapley over every subset, because the model is linear before the sigmoid
- ADR-004: the tolerance is declared per feature, and zero is not the safe choice
- ADR-005: severity is measured in changed decisions, and the two currencies disagree
Two mistakes are documented rather than quietly fixed, because they are the parts of this repository that took the longest to understand.
A leak that cost nothing. The first version of experiments/leakage_cost.py measured a
time-travel leak worth 0.002 AUC, and the conclusion sitting in the draft was "leakage is overrated".
It was not a finding about leakage. It was a finding about a synthetic world where the future window
carried the same information as the past one. Real leakage is expensive because the future contains the
consequence of the label: a card used fraudulently gets used again, hard, within hours. Once
inject_consequences put that in the world, the leaky model's offline number overstated what serving
delivers by 0.0874 AUC: 0.8853 on its own features and 0.7979 on the same serving features
the honest model scores 0.8112 on, which makes the leaky model 0.0133 AUC worse than the honest one
in production. Excellent offline, worse in production, which is the whole shape of the problem.
A fixture that would not hold still. Described above and in ADR-002. The control scenario failing a drift assertion was the only reason anybody looked.
src/skewsentinel/ spec, offline path, online path, referee, compare, diagnose,
attribute, drift, model, report, charts, cli
skews/ the 16 injected scenarios and the world they run in
specs/fraud.yaml declared semantics, units, tolerances, operating point
experiments/ 5 experiments, each writing docs/experiments/*.json
tests/ 219 tests across 9 files
tools/ metrics, number checking, demo, screenshots, video
docs/adr/ 5 decision records
MIT licensed.


