Skip to content

Commit 6e8cdb2

Browse files
committed
Fix --fail-on-drift firing on a skipped/unmeasurable comparison (closes #472)
--fail-on-drift checked bool(result["flags"]), but flags also includes a kind-mismatch dimension's "drift comparison skipped" notice - genuinely informational, not evidence of drift (psi/drift_level are already zeroed for exactly this case). A schema change between two snapshots (a column reformatted, renamed, or dropped) falsely triggered "drift detected" in a CI gate, with PSI 0.000 and drift_level explicitly "none" - no drift was measured at all. _build_flags() now also returns drift_detected: true iff a *real* drift signal fired (score drop, missing-pct shift, drift_level != none, an appeared/disappeared group, or an added/removed dimension) - everything in flags except the kind-mismatch skip notices. compare() exposes it as a new top-level field; the CLI now checks drift_detected instead of bool(flags). Mirrored in assets/profiler-engine.js and documented in faircode/SPEC.md section 8. Verified with the exact repro from the issue - the banded-vs-raw age comparison now exits 0 (while still printing the informational flag), and a genuine representation-drift case still exits 1. Cross-checked Python vs. JS engine parity directly. Full compare/JS-parity/CLI/report test suite (110 tests) passes.
1 parent 3e07784 commit 6e8cdb2

4 files changed

Lines changed: 53 additions & 11 deletions

File tree

assets/profiler-engine.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -947,16 +947,25 @@
947947
var scoreDelta = (resultA.overall_score === null || resultB.overall_score === null)
948948
? null : resultB.overall_score - resultA.overall_score;
949949

950-
var flags = [];
950+
// flags is every human-readable notice, including a kind-mismatch
951+
// dimension's "drift comparison skipped" message - informational, since
952+
// the comparison genuinely could not be measured. driftDetected is the
953+
// narrower, structural signal of whether any *real* drift was measured -
954+
// matches faircode/compare.py's _build_flags() so a CLI-equivalent
955+
// consumer wouldn't false-positive on a skipped/unmeasurable comparison
956+
// the way checking flags.length alone would (#472).
957+
var flags = [], driftDetected = false;
951958
if (scoreDelta !== null && scoreDelta <= -SCORE_DROP_FLAG) {
952959
flags.push('overall representation score dropped ' + Math.abs(scoreDelta) +
953960
' points (' + resultA.overall_score + ' → ' + resultB.overall_score + ')');
961+
driftDetected = true;
954962
}
955963
dimensions.forEach(function (cd) {
956964
if (Math.abs(cd.missing_pct_delta) >= MISSING_DRIFT_FLAG) {
957965
flags.push(cd.name + ': missing-data share shifted ' +
958966
(cd.missing_pct_a * 100).toFixed(1) + '% → ' +
959967
(cd.missing_pct_b * 100).toFixed(1) + '%');
968+
driftDetected = true;
960969
}
961970
if (cd.kind_mismatch) {
962971
if (cd.kind_a !== cd.kind_b) {
@@ -973,21 +982,24 @@
973982
if (cd.drift_level !== 'none') {
974983
flags.push(cd.name + ': ' + cd.drift_level +
975984
' representation drift (PSI ' + cd.psi.toFixed(2) + ')');
985+
driftDetected = true;
976986
}
977987
cd.groups.forEach(function (g) {
978988
if (g.status === 'appeared') {
979989
flags.push(cd.name + ": '" + g.label + "' appeared (" +
980990
(g.share_a * 100).toFixed(1) + '% → ' +
981991
(g.share_b * 100).toFixed(1) + '%)');
992+
driftDetected = true;
982993
} else if (g.status === 'disappeared') {
983994
flags.push(cd.name + ": '" + g.label + "' disappeared (" +
984995
(g.share_a * 100).toFixed(1) + '% → ' +
985996
(g.share_b * 100).toFixed(1) + '%)');
997+
driftDetected = true;
986998
}
987999
});
9881000
});
989-
added.forEach(function (n) { flags.push("dimension '" + n + "' is present only in " + nameB); });
990-
removed.forEach(function (n) { flags.push("dimension '" + n + "' is present only in " + nameA); });
1001+
added.forEach(function (n) { flags.push("dimension '" + n + "' is present only in " + nameB); driftDetected = true; });
1002+
removed.forEach(function (n) { flags.push("dimension '" + n + "' is present only in " + nameA); driftDetected = true; });
9911003

9921004
return {
9931005
a: { name: nameA, n_rows: resultA.n_rows,
@@ -997,7 +1009,8 @@
9971009
overall_score: resultB.overall_score, grade: resultB.grade,
9981010
dimensions_detected: resultB.dimensions_detected, note: resultB.note },
9991011
score_delta: scoreDelta, dimensions: dimensions,
1000-
added_dimensions: added, removed_dimensions: removed, flags: flags
1012+
added_dimensions: added, removed_dimensions: removed, flags: flags,
1013+
drift_detected: driftDetected
10011014
};
10021015
}
10031016

faircode/SPEC.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,16 @@ Top level: `score_delta = overall_score_b − overall_score_a` when both scores
267267
`null` otherwise. `flags` is assembled from: an
268268
overall-score drop of `≥ SCORE_DROP_FLAG` points, every dimension whose `|missing_pct_delta| ≥
269269
MISSING_DRIFT_FLAG`, every dimension whose `drift_level ≠ none`, every `appeared`/`disappeared`
270-
group, and every added/removed dimension.
270+
group, every added/removed dimension, **and** every `kind_mismatch` dimension's own
271+
"drift comparison skipped" notice - that last case is informational only (the comparison genuinely
272+
couldn't be measured, not evidence of drift), so it's the one category of `flags` entry
273+
**excluded** from `drift_detected`.
274+
275+
`drift_detected` is a boolean - `true` iff at least one *real* drift signal fired (any of the first
276+
five categories above), `false` if `flags` is empty or contains only kind-mismatch notices. The CLI's
277+
`compare --fail-on-drift` checks `drift_detected`, not `bool(flags)`, so a schema change between two
278+
snapshots that makes a dimension unmeasurable (e.g. one side's ages banded, the other left raw)
279+
doesn't false-positive as "drift detected" in a CI gate (see issue #472).
271280

272281
### Result shape
273282

@@ -290,7 +299,8 @@ group, and every added/removed dimension.
290299
],
291300
"added_dimensions": ["income_bracket"],
292301
"removed_dimensions": [],
293-
"flags": [ "race: significant representation drift (PSI 0.34)", "race: 'Asian' disappeared (10.0% → 0.0%)" ]
302+
"flags": [ "race: significant representation drift (PSI 0.34)", "race: 'Asian' disappeared (10.0% → 0.0%)" ],
303+
"drift_detected": true
294304
}
295305
```
296306

faircode/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ def main(argv: list[str] | None = None) -> int:
364364
print(to_json(result, provenance=provenance))
365365
else:
366366
print(compare_to_terminal(result))
367-
if args.fail_on_drift and result["flags"]:
367+
if args.fail_on_drift and result["drift_detected"]:
368368
print(
369369
f"error: representation drift detected ({len(result['flags'])} flag(s)) "
370370
f"with --fail-on-drift set",

faircode/compare.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,32 @@ def _compare_dimension(dim_a: dict, dim_b: dict) -> dict:
149149

150150
def _build_flags(result_a: dict, result_b: dict, score_delta: int | None,
151151
dimensions: list, added: list, removed: list,
152-
name_a: str, name_b: str) -> list:
152+
name_a: str, name_b: str) -> tuple[list, bool]:
153+
"""Returns (flags, drift_detected). `flags` is every human-readable
154+
notice, including a kind-mismatch dimension's "drift comparison
155+
skipped" message - informational, since the underlying comparison
156+
genuinely could not be measured (psi/drift_level are already zeroed
157+
for exactly this dimension in _compare_dimension()). `drift_detected`
158+
is the narrower, structural signal of whether any *real* representation
159+
drift was measured - CLI's --fail-on-drift checks this instead of
160+
`bool(flags)`, so a schema change that made a comparison unmeasurable
161+
(e.g. one side's ages banded, the other raw) doesn't false-positive as
162+
"drift detected" the way any flags existing at all would (#472)."""
153163
flags: list[str] = []
164+
drift_detected = False
154165
if score_delta is not None and score_delta <= -SCORE_DROP_FLAG:
155166
flags.append(
156167
f"overall representation score dropped {abs(score_delta)} points "
157168
f"({result_a['overall_score']}{result_b['overall_score']})"
158169
)
170+
drift_detected = True
159171
for cd in dimensions:
160172
if abs(cd["missing_pct_delta"]) >= MISSING_DRIFT_FLAG:
161173
flags.append(
162174
f"{cd['name']}: missing-data share shifted "
163175
f"{cd['missing_pct_a'] * 100:.1f}% → {cd['missing_pct_b'] * 100:.1f}%"
164176
)
177+
drift_detected = True
165178
if cd["kind_mismatch"]:
166179
if cd["kind_a"] != cd["kind_b"]:
167180
flags.append(
@@ -181,22 +194,27 @@ def _build_flags(result_a: dict, result_b: dict, score_delta: int | None,
181194
f"{cd['name']}: {cd['drift_level']} representation drift "
182195
f"(PSI {cd['psi']:.2f})"
183196
)
197+
drift_detected = True
184198
for g in cd["groups"]:
185199
if g["status"] == "appeared":
186200
flags.append(
187201
f"{cd['name']}: '{g['label']}' appeared "
188202
f"({g['share_a'] * 100:.1f}% → {g['share_b'] * 100:.1f}%)"
189203
)
204+
drift_detected = True
190205
elif g["status"] == "disappeared":
191206
flags.append(
192207
f"{cd['name']}: '{g['label']}' disappeared "
193208
f"({g['share_a'] * 100:.1f}% → {g['share_b'] * 100:.1f}%)"
194209
)
210+
drift_detected = True
195211
for n in added:
196212
flags.append(f"dimension '{n}' is present only in {name_b}")
213+
drift_detected = True
197214
for n in removed:
198215
flags.append(f"dimension '{n}' is present only in {name_a}")
199-
return flags
216+
drift_detected = True
217+
return flags, drift_detected
200218

201219

202220
def compare(result_a: dict, result_b: dict, name_a="A", name_b="B") -> dict:
@@ -211,8 +229,8 @@ def compare(result_a: dict, result_b: dict, name_a="A", name_b="B") -> dict:
211229
dimensions = [_compare_dimension(dims_a[n], dims_b[n]) for n in shared]
212230
scores = (result_a["overall_score"], result_b["overall_score"])
213231
score_delta = scores[1] - scores[0] if None not in scores else None
214-
flags = _build_flags(result_a, result_b, score_delta, dimensions,
215-
added, removed, name_a, name_b)
232+
flags, drift_detected = _build_flags(result_a, result_b, score_delta, dimensions,
233+
added, removed, name_a, name_b)
216234

217235
return {
218236
"a": {"name": name_a, "n_rows": result_a["n_rows"],
@@ -228,4 +246,5 @@ def compare(result_a: dict, result_b: dict, name_a="A", name_b="B") -> dict:
228246
"added_dimensions": added,
229247
"removed_dimensions": removed,
230248
"flags": flags,
249+
"drift_detected": drift_detected,
231250
}

0 commit comments

Comments
 (0)