whassan commited on
Commit
0523c2e
·
verified ·
1 Parent(s): d1ac27a

Deploy Sensorflow Studio (part 2)

Browse files
tests/test_rca/__init__.py ADDED
File without changes
tests/test_rca/conftest.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixtures for the RCA workbench tests.
2
+
3
+ The full diagnostic battery per planted cause is computed once per session
4
+ (it is deterministic), and the API tests run against an isolated runs/rca
5
+ root in a tmp dir.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from fastapi import FastAPI
12
+ from fastapi.testclient import TestClient
13
+
14
+ from sensorflow.rca import diagnostics as dg, scoring, store
15
+ from sensorflow.rca.models import ROOT_CAUSES
16
+ from sensorflow.rca.scenario import generate_scenario
17
+
18
+ SEED = 7
19
+
20
+
21
+ @pytest.fixture(scope="session")
22
+ def batteries():
23
+ """{cause: (bundle, battery, scoreboard, tree)} computed once."""
24
+ out = {}
25
+ for cause in ROOT_CAUSES:
26
+ bundle = generate_scenario(cause, seed=SEED)
27
+ battery = dg.run_all(bundle)
28
+ board = scoring.build_scoreboard(bundle, battery=battery)
29
+ tree = scoring.evaluate_decision_tree(bundle, battery=battery)
30
+ out[cause] = (bundle, battery, board, tree)
31
+ return out
32
+
33
+
34
+ @pytest.fixture()
35
+ def rca_client(tmp_path):
36
+ store.set_rca_root(str(tmp_path / "rca"))
37
+ from sensorflow.rca import api as rca_api
38
+ rca_api._battery_cache.clear()
39
+ app = FastAPI()
40
+ app.include_router(rca_api.router)
41
+ with TestClient(app) as client:
42
+ yield client
43
+ store.set_rca_root("runs/rca")
44
+ rca_api._battery_cache.clear()
tests/test_rca/test_api.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API lifecycle via TestClient: create -> diagnostics -> stage completion ->
2
+ scoreboard -> decision tree -> experiments -> report -> reveal."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ def _create(client, **kw):
8
+ body = {"cause": "FEATURE_SKEW", "seed": 7}
9
+ body.update(kw)
10
+ res = client.post("/api/rca/investigations", json=body)
11
+ assert res.status_code == 200, res.text
12
+ return res.json()
13
+
14
+
15
+ def test_meta_routes(rca_client):
16
+ causes = rca_client.get("/api/rca/causes").json()["causes"]
17
+ assert len(causes) == 8
18
+ stages = rca_client.get("/api/rca/stages").json()["stages"]
19
+ assert len(stages) == 13
20
+
21
+
22
+ def test_create_and_list(rca_client):
23
+ inv = _create(rca_client)
24
+ assert inv["scenario_cause"] == "FEATURE_SKEW"
25
+ assert inv["claims"]["offline_delta_pp"] > 4.0
26
+ assert inv["claims"]["shadow_delta_pp"] < -1.0
27
+ listed = rca_client.get("/api/rca/investigations").json()["investigations"]
28
+ assert any(r["id"] == inv["id"] for r in listed)
29
+
30
+
31
+ def test_training_mode_hides_cause_until_reveal(rca_client):
32
+ inv = _create(rca_client, training_mode=True, cause=None, seed=123)
33
+ assert "scenario_cause" not in inv
34
+ got = rca_client.get(f"/api/rca/investigations/{inv['id']}").json()
35
+ assert "scenario_cause" not in got
36
+ reveal = rca_client.post(f"/api/rca/investigations/{inv['id']}/reveal").json()
37
+ assert reveal["cause"] in {
38
+ "TRUE_MODEL_REGRESSION", "DISTRIBUTION_SHIFT", "FEATURE_SKEW",
39
+ "SERVING_MISMATCH", "LABEL_LATENCY", "SAMPLING_BIAS",
40
+ "STATISTICAL_NOISE", "OFFLINE_CONTAMINATION"}
41
+ assert reveal["explanation"]
42
+ got = rca_client.get(f"/api/rca/investigations/{inv['id']}").json()
43
+ assert got["scenario_cause"] == reveal["cause"]
44
+
45
+
46
+ def test_all_diagnostic_endpoints(rca_client):
47
+ inv = _create(rca_client)
48
+ for key in ("comparison_validity", "offline_audit", "population_validation",
49
+ "distribution_shift", "conditional_performance",
50
+ "paired_comparison", "statistical_significance",
51
+ "feature_parity", "serving_parity", "shadow_traffic",
52
+ "label_integrity"):
53
+ res = rca_client.get(
54
+ f"/api/rca/investigations/{inv['id']}/diagnostics/{key}")
55
+ assert res.status_code == 200, f"{key}: {res.text}"
56
+ body = res.json()
57
+ assert body["stage"] == key
58
+ assert body["data"]
59
+ assert isinstance(body["findings"], list) and body["findings"]
60
+ res = rca_client.get(
61
+ f"/api/rca/investigations/{inv['id']}/diagnostics/nope")
62
+ assert res.status_code == 404
63
+
64
+
65
+ def test_stage_ordering_enforced_via_api(rca_client):
66
+ inv = _create(rca_client)
67
+ res = rca_client.post(
68
+ f"/api/rca/investigations/{inv['id']}/stages/3/complete", json={})
69
+ assert res.status_code == 409
70
+ res = rca_client.post(
71
+ f"/api/rca/investigations/{inv['id']}/stages/0/complete", json={})
72
+ assert res.status_code == 200
73
+
74
+
75
+ def test_unknown_ack_flow_via_api(rca_client):
76
+ inv = _create(rca_client) # FEATURE_SKEW -> CV_UNKNOWN:feature_pipeline_version
77
+ iid = inv["id"]
78
+ rca_client.get(f"/api/rca/investigations/{iid}/diagnostics/comparison_validity")
79
+ res = rca_client.post(
80
+ f"/api/rca/investigations/{iid}/stages/0/complete", json={})
81
+ assert res.status_code == 409
82
+ assert "UNKNOWN" in res.json()["detail"]
83
+ res = rca_client.post(
84
+ f"/api/rca/investigations/{iid}/stages/0/complete",
85
+ json={"acknowledge_unknowns": True, "note": "pipeline team paged"})
86
+ assert res.status_code == 200
87
+ assert res.json()["stage"]["status"] == "complete_with_unknowns"
88
+
89
+
90
+ def test_record_finding_and_scoreboard(rca_client):
91
+ inv = _create(rca_client)
92
+ iid = inv["id"]
93
+ res = rca_client.post(f"/api/rca/investigations/{iid}/findings", json={
94
+ "stage": "feature_parity", "title": "Confirmed with pipeline team",
95
+ "status": "MISMATCH", "severity": "CRITICAL",
96
+ "detail": "fp-2.5 deployed online only",
97
+ "code": "FP_FEATURE_SKEW:human_confirmed"})
98
+ assert res.status_code == 200
99
+ board = rca_client.get(f"/api/rca/investigations/{iid}/scoreboard").json()
100
+ assert board["rows"][0]["hypothesis"] == "FEATURE_SKEW"
101
+ # The human finding contributes to the score via the same rule table.
102
+ top = board["rows"][0]
103
+ assert any(e["finding_id"] and e["code"] == "FP_FEATURE_SKEW:human_confirmed"
104
+ for e in top["evidence_for"])
105
+
106
+ res = rca_client.post(f"/api/rca/investigations/{iid}/scoreboard/assess",
107
+ json={"hypothesis": "FEATURE_SKEW",
108
+ "confidence": "HIGH", "note": "confirmed"})
109
+ assert res.status_code == 200
110
+ row = next(r for r in res.json()["rows"] if r["hypothesis"] == "FEATURE_SKEW")
111
+ assert row["human_confidence"] == "HIGH"
112
+ assert row["auto_confidence"] # both assessments are kept
113
+
114
+
115
+ def test_tree_experiments_report(rca_client):
116
+ inv = _create(rca_client)
117
+ iid = inv["id"]
118
+ tree = rca_client.get(f"/api/rca/investigations/{iid}/decision-tree").json()
119
+ assert tree["conclusion"] == "FEATURE_SKEW"
120
+ assert tree["path"]
121
+ exp = rca_client.get(f"/api/rca/investigations/{iid}/experiments").json()
122
+ assert exp["minimum_additional_evidence"]
123
+ rep = rca_client.get(f"/api/rca/investigations/{iid}/report").json()
124
+ assert rep["executive_finding"]["conclusion"] == "FEATURE_SKEW"
125
+ assert rep["markdown"].startswith("# RCA Report")
126
+ assert len(rep["hypothesis_ranking"]) == 8
127
+ assert len(rep["stage_summaries"]) == 11
128
+ assert rep["remediation"]["containment"]
129
+
130
+
131
+ def test_persistence_roundtrip(rca_client):
132
+ """Data survives the CSV round trip: fresh store caches cleared, then the
133
+ scoreboard still ranks correctly."""
134
+ from sensorflow.rca import store
135
+ from sensorflow.rca import api as rca_api
136
+ inv = _create(rca_client)
137
+ store._cache.clear()
138
+ store._data_cache.clear()
139
+ rca_api._battery_cache.clear()
140
+ board = rca_client.get(
141
+ f"/api/rca/investigations/{inv['id']}/scoreboard").json()
142
+ assert board["rows"][0]["hypothesis"] == "FEATURE_SKEW"
143
+
144
+
145
+ def test_recorded_only_scoreboard_evolves(rca_client):
146
+ """The working-hypothesis banner must not leak unvisited stages: with no
147
+ diagnostics run, all 8 hypotheses stay in play; evidence narrows it."""
148
+ inv = _create(rca_client)
149
+ iid = inv["id"]
150
+ board = rca_client.get(
151
+ f"/api/rca/investigations/{iid}/scoreboard?recorded_only=true").json()
152
+ assert len(board["working_hypothesis_set"]) == 8
153
+ for key in ("feature_parity", "offline_audit", "shadow_traffic",
154
+ "label_integrity", "serving_parity"):
155
+ rca_client.get(f"/api/rca/investigations/{iid}/diagnostics/{key}")
156
+ board = rca_client.get(
157
+ f"/api/rca/investigations/{iid}/scoreboard?recorded_only=true").json()
158
+ ws = board["working_hypothesis_set"]
159
+ assert "FEATURE_SKEW" in ws
160
+ assert "OFFLINE_CONTAMINATION" not in ws # ruled out by clean audit
161
+ assert len(ws) < 8
162
+
163
+
164
+ def test_404s(rca_client):
165
+ assert rca_client.get("/api/rca/investigations/nope").status_code == 404
166
+ assert rca_client.get(
167
+ "/api/rca/investigations/nope/scoreboard").status_code == 404
tests/test_rca/test_scenarios.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """For EACH planted root cause: run the full diagnostic battery and assert the
2
+ scoring board ranks the true cause on top with the right signature findings,
3
+ and that the decision tree walks to the right conclusion."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from sensorflow.rca.models import ROOT_CAUSES
11
+ from sensorflow.rca.scenario import generate_scenario
12
+
13
+ SEED = 7
14
+
15
+
16
+ def _codes(battery):
17
+ return {f.code for _, (_, fs) in battery.items() for f in fs}
18
+
19
+
20
+ def _rank(board, cause):
21
+ return next(r["rank"] for r in board["rows"] if r["hypothesis"] == cause)
22
+
23
+
24
+ # ------------------------------------------------------------- global shape
25
+
26
+
27
+ @pytest.mark.parametrize("cause", ROOT_CAUSES)
28
+ def test_claims_shape(batteries, cause):
29
+ bundle, _, _, _ = batteries[cause]
30
+ m = bundle.meta["measured"]
31
+ assert 0.040 <= m["offline_delta"] <= 0.060, "offline claim should be ~+5pp"
32
+ assert -0.035 <= m["shadow_delta"] <= -0.005, "shadow claim should be ~-2pp"
33
+
34
+
35
+ @pytest.mark.parametrize("cause", ROOT_CAUSES)
36
+ def test_determinism(cause):
37
+ a = generate_scenario(cause, seed=SEED)
38
+ b = generate_scenario(cause, seed=SEED)
39
+ assert a.offline["b_correct"].tolist() == b.offline["b_correct"].tolist()
40
+ assert a.shadow["a_correct"].tolist() == b.shadow["a_correct"].tolist()
41
+ assert a.meta["measured"] == b.meta["measured"]
42
+
43
+
44
+ # ------------------------------------------------------- ranking + signatures
45
+
46
+
47
+ @pytest.mark.parametrize("cause", ROOT_CAUSES)
48
+ def test_true_cause_ranks_top(batteries, cause):
49
+ _, _, board, _ = batteries[cause]
50
+ assert _rank(board, cause) <= 2, (
51
+ f"{cause} ranked {_rank(board, cause)}: "
52
+ f"{[(r['hypothesis'], r['score']) for r in board['rows'][:3]]}")
53
+ top = board["rows"][0]
54
+ if top["hypothesis"] == cause:
55
+ assert top["auto_confidence"] in ("HIGH", "MEDIUM")
56
+
57
+
58
+ @pytest.mark.parametrize("cause", ROOT_CAUSES)
59
+ def test_decision_tree_conclusion(batteries, cause):
60
+ _, _, _, tree = batteries[cause]
61
+ assert tree["conclusion"] == cause
62
+ if cause == "STATISTICAL_NOISE":
63
+ assert tree["conclusion_kind"] == "insufficient_evidence"
64
+ assert tree["path"][-1] == tree["nodes"][
65
+ [n["id"] for n in tree["nodes"]].index(tree["path"][-1])]["id"]
66
+
67
+
68
+ def test_feature_skew_signature(batteries):
69
+ bundle, battery, _, _ = batteries["FEATURE_SKEW"]
70
+ codes = _codes(battery)
71
+ assert any(c.startswith("FP_FEATURE_SKEW:obj_distance_m") for c in codes)
72
+ # The skewed feature must TOP the parity ranking.
73
+ rows = battery["feature_parity"][0]["rows"]
74
+ assert rows[0]["feature"] == "obj_distance_m"
75
+ assert rows[0]["skew_flag"]
76
+ # Median ratio reflects the meters->feet unit error.
77
+ assert 2.5 <= rows[0]["median_ratio"] <= 4.2
78
+ # Regression concentrated where the feature matters (highway).
79
+ assert any(c.startswith("CP_SEGMENT_CONCENTRATED:scene=highway")
80
+ for c in codes)
81
+
82
+
83
+ def test_contamination_signature(batteries):
84
+ _, battery, _, _ = batteries["OFFLINE_CONTAMINATION"]
85
+ codes = _codes(battery)
86
+ assert "OA_LEAKAGE_DUPLICATES" in codes, "leakage scan must fire"
87
+ assert "OA_REPRO_FAIL" in codes, "reproducibility must fail"
88
+ assert "OA_SPLIT_ROW_LEVEL" in codes
89
+ leak = battery["offline_audit"][0]["leakage"]
90
+ assert leak["n_duplicates"] > 0.15 * leak["offline_n"]
91
+ # Leaked units carry the inflation; clean units do not.
92
+ assert leak["dup_delta_pp"] > leak["clean_delta_pp"] + 5.0
93
+
94
+
95
+ def test_noise_signature(batteries):
96
+ _, battery, _, _ = batteries["STATISTICAL_NOISE"]
97
+ codes = _codes(battery)
98
+ assert "SS_INSUFFICIENT_EVIDENCE" in codes
99
+ assert "SS_LOW_ESS" in codes
100
+ assert "POP_VOLUME_LOW" in codes
101
+ sig = battery["statistical_significance"][0]
102
+ assert sig["outcome"] == "insufficient_evidence"
103
+ # CI must span the practical margin on both sides.
104
+ sp = sig["shadow_paired"]
105
+ assert sp["ci_low"] < -0.01 < 0.0 < sp["ci_high"] + 0.01
106
+ assert sp["effective_n"] < 800
107
+
108
+
109
+ def test_true_regression_signature(batteries):
110
+ _, battery, _, _ = batteries["TRUE_MODEL_REGRESSION"]
111
+ codes = _codes(battery)
112
+ assert "SS_SIGNIFICANT_REGRESSION" in codes
113
+ assert "CP_UNIFORM_REGRESSION" in codes
114
+ # All artifact channels clean.
115
+ for clean in ("OA_LEAKAGE_CLEAN", "FP_CLEAN", "SP_CLEAN",
116
+ "ST_SAMPLE_FAIR", "LI_CLEAN"):
117
+ assert clean in codes, f"{clean} expected for a genuine regression"
118
+
119
+
120
+ def test_distribution_shift_signature(batteries):
121
+ _, battery, _, _ = batteries["DISTRIBUTION_SHIFT"]
122
+ codes = _codes(battery)
123
+ assert "DS_SHIFT_HIGH:time_of_day" in codes
124
+ assert "CP_SIMPSONS_DETECTED" in codes
125
+ # Simpson's: aggregate signs flip while within-segment deltas agree.
126
+ agg = battery["conditional_performance"][0]["aggregate"]
127
+ assert agg["offline_delta_pp"] > 0 > agg["shadow_delta_pp"]
128
+ assert agg["sign_consistency"] >= 0.55
129
+ # Night segments regress in BOTH environments (the per-segment truth).
130
+ rows = battery["conditional_performance"][0]["rows"]
131
+ night = [r for r in rows if r["time_of_day"] == "night"
132
+ and r["interpretation"] != "low_volume"]
133
+ assert night and all(r["offline_delta_pp"] < 0 and r["shadow_delta_pp"] < 0
134
+ for r in night)
135
+
136
+
137
+ def test_serving_mismatch_signature(batteries):
138
+ _, battery, _, _ = batteries["SERVING_MISMATCH"]
139
+ codes = _codes(battery)
140
+ assert "SP_CONFIG_DIFF:confidence_threshold" in codes
141
+ assert "SP_CONFIG_DIFF:quantization" in codes
142
+ assert "PC_CONF_BAND_CONCENTRATION" in codes
143
+ bands = battery["paired_comparison"][0]["by_band"]
144
+ focus = next(b for b in bands if b["segment"].startswith("[0.35"))
145
+ assert focus["lift"] >= 1.5
146
+
147
+
148
+ def test_label_latency_signature(batteries):
149
+ _, battery, _, _ = batteries["LABEL_LATENCY"]
150
+ codes = _codes(battery)
151
+ assert "LI_MATURE_DIVERGES" in codes
152
+ assert "LI_PROVISIONAL_HIGH" in codes
153
+ li = battery["label_integrity"][0]
154
+ assert li["provisional_fraction"] > 0.3
155
+ # Verdict flips on mature labels: B is actually ahead.
156
+ assert li["mature_delta_pp"] > 0 > li["provisional_delta_pp"]
157
+
158
+
159
+ def test_sampling_bias_signature(batteries):
160
+ _, battery, _, _ = batteries["SAMPLING_BIAS"]
161
+ codes = _codes(battery)
162
+ assert "ST_SELECTION_BIAS" in codes
163
+ sel = battery["shadow_traffic"][0]["selection"]
164
+ # Sampled stream regresses; the rest of the eligible stream does not.
165
+ assert sel["sampled_delta_pp"] < 0 < sel["unsampled_delta_pp"]
166
+ assert sel["difficulty_psi"] >= 0.05
167
+
168
+
169
+ # ------------------------------------------------------ cross-cause exclusion
170
+
171
+
172
+ @pytest.mark.parametrize("cause", ROOT_CAUSES)
173
+ def test_no_foreign_critical_signatures(batteries, cause):
174
+ """Discriminating CRITICAL signatures must not fire for other causes."""
175
+ _, battery, _, _ = batteries[cause]
176
+ codes = _codes(battery)
177
+ if cause != "OFFLINE_CONTAMINATION":
178
+ assert "OA_LEAKAGE_DUPLICATES" not in codes
179
+ assert "OA_REPRO_FAIL" not in codes
180
+ if cause != "FEATURE_SKEW":
181
+ assert not any(c.startswith("FP_FEATURE_SKEW") for c in codes)
182
+ if cause != "SERVING_MISMATCH":
183
+ assert not any(c.startswith("SP_CONFIG_DIFF") for c in codes)
184
+ if cause != "LABEL_LATENCY":
185
+ assert "LI_MATURE_DIVERGES" not in codes
186
+ if cause != "SAMPLING_BIAS":
187
+ assert "ST_SELECTION_BIAS" not in codes
188
+ if cause != "DISTRIBUTION_SHIFT":
189
+ assert "CP_SIMPSONS_DETECTED" not in codes
190
+
191
+
192
+ # ------------------------------------------------------------ experiments
193
+
194
+
195
+ @pytest.mark.parametrize("cause", ROOT_CAUSES)
196
+ def test_experiments_target_top_hypothesis(batteries, cause):
197
+ from sensorflow.rca import scoring
198
+ bundle, battery, board, _ = batteries[cause]
199
+ rec = scoring.recommend_experiments(bundle, board, battery)
200
+ assert rec["minimum_additional_evidence"]
201
+ assert len(rec["experiments"]) == 5
202
+ ranked = rec["experiments"]
203
+ assert ranked == sorted(ranked, key=lambda d: d["priority"], reverse=True)
204
+ # The top experiment must discriminate the leading hypothesis.
205
+ top_hyp = board["rows"][0]["hypothesis"]
206
+ assert any(top_hyp in d["discriminates"] for d in ranked[:2])
tests/test_rca/test_state_machine.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage state machine: enforced ordering + unknown acknowledgment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from sensorflow.rca.models import (Investigation, StageOrderError,
8
+ UnknownsNotAcknowledgedError, make_finding,
9
+ STAGES)
10
+
11
+
12
+ def _inv() -> Investigation:
13
+ return Investigation.new("t", "a", "b", "STATISTICAL_NOISE", seed=1)
14
+
15
+
16
+ def test_initial_state():
17
+ inv = _inv()
18
+ assert len(inv.stages) == 13
19
+ assert inv.stages[0].status == "in_progress"
20
+ assert all(s.status == "pending" for s in inv.stages[1:])
21
+
22
+
23
+ def test_cannot_skip_ahead():
24
+ inv = _inv()
25
+ with pytest.raises(StageOrderError):
26
+ inv.complete_stage(5)
27
+ # Even the immediately-next stage is blocked until stage 0 completes.
28
+ with pytest.raises(StageOrderError):
29
+ inv.complete_stage(1)
30
+
31
+
32
+ def test_ordered_completion_advances():
33
+ inv = _inv()
34
+ inv.complete_stage(0)
35
+ assert inv.stages[0].status == "complete"
36
+ assert inv.stages[1].status == "in_progress"
37
+ inv.complete_stage(1)
38
+ assert inv.stages[2].status == "in_progress"
39
+
40
+
41
+ def test_critical_unknowns_block_without_ack():
42
+ inv = _inv()
43
+ inv.upsert_auto_findings("comparison_validity", [
44
+ make_finding("comparison_validity", "CV_UNKNOWN:x", "x unknown",
45
+ "UNKNOWN", "CRITICAL")])
46
+ with pytest.raises(UnknownsNotAcknowledgedError):
47
+ inv.complete_stage(0)
48
+ stage = inv.complete_stage(0, acknowledge_unknowns=True,
49
+ ack_note="shipping anyway, tracked in JIRA-123")
50
+ assert stage.status == "complete_with_unknowns"
51
+ assert stage.acknowledged_unknowns
52
+ assert stage.ack_note == "shipping anyway, tracked in JIRA-123"
53
+ # The acknowledgment is recorded in the event log ("proceeding with
54
+ # unknowns" is auditable).
55
+ kinds = [e["kind"] for e in inv.events]
56
+ assert "unknowns_acknowledged" in kinds
57
+
58
+
59
+ def test_non_critical_unknowns_do_not_block():
60
+ inv = _inv()
61
+ inv.upsert_auto_findings("comparison_validity", [
62
+ make_finding("comparison_validity", "CV_X", "warn unknown",
63
+ "UNKNOWN", "WARN")])
64
+ stage = inv.complete_stage(0)
65
+ assert stage.status == "complete"
66
+
67
+
68
+ def test_reopen():
69
+ inv = _inv()
70
+ inv.complete_stage(0)
71
+ inv.reopen_stage(0)
72
+ assert inv.stages[0].status == "in_progress"
73
+ with pytest.raises(StageOrderError):
74
+ inv.complete_stage(1)
75
+
76
+
77
+ def test_serialization_roundtrip():
78
+ inv = _inv()
79
+ inv.upsert_auto_findings("comparison_validity", [
80
+ make_finding("comparison_validity", "CV_UNKNOWN:x", "x", "UNKNOWN",
81
+ "CRITICAL")])
82
+ inv.complete_stage(0, acknowledge_unknowns=True)
83
+ d = inv.to_json_dict()
84
+ inv2 = Investigation.from_json_dict(d)
85
+ assert inv2.stages[0].status == "complete_with_unknowns"
86
+ assert inv2.findings[0].code == "CV_UNKNOWN:x"
87
+ assert inv2.scenario_cause == "STATISTICAL_NOISE"
88
+
89
+
90
+ def test_training_mode_hides_cause():
91
+ inv = Investigation.new("t", "a", "b", "FEATURE_SKEW", seed=1,
92
+ training_mode=True)
93
+ assert "scenario_cause" not in inv.to_dict()
94
+ inv.revealed = True
95
+ assert inv.to_dict()["scenario_cause"] == "FEATURE_SKEW"
96
+
97
+
98
+ def test_stage_keys_match_spec_order():
99
+ keys = [s["key"] for s in STAGES]
100
+ assert keys[0] == "comparison_validity"
101
+ assert keys[6] == "statistical_significance"
102
+ assert keys[11] == "root_cause_scoring"
103
+ assert keys[12] == "recommendations_report"