whassan commited on
Commit
d1ac27a
·
verified ·
1 Parent(s): 953f0c4

Deploy Sensorflow Studio

Browse files
app_backend.py CHANGED
@@ -1480,6 +1480,10 @@ app.include_router(bevfusion_router)
1480
  from sensorflow.safety.api import router as safety_router
1481
  app.include_router(safety_router)
1482
 
 
 
 
 
1483
  # Mount static folder
1484
  static_dir = Path(__file__).parent / "static"
1485
  if static_dir.exists():
 
1480
  from sensorflow.safety.api import router as safety_router
1481
  app.include_router(safety_router)
1482
 
1483
+ # Regression Root Cause Analysis workbench (staged offline-vs-shadow forensics).
1484
+ from sensorflow.rca.api import router as rca_router
1485
+ app.include_router(rca_router)
1486
+
1487
  # Mount static folder
1488
  static_dir = Path(__file__).parent / "static"
1489
  if static_dir.exists():
sensorflow/rca/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression Root Cause Analysis (RCA) investigation workbench.
2
+
3
+ A skeptical, staged methodology for answering "offline says +5%, shadow says
4
+ -2% -- which is real, and why?" without assuming either measurement is valid.
5
+
6
+ Modules:
7
+ models investigation entity, stage state machine, evidence records
8
+ scenario synthetic offline+shadow dataset generator with planted causes
9
+ stats Wilson CIs, PSI / JS / KS, cluster-aware effective sample size
10
+ diagnostics per-stage diagnostic computations (pure functions on data)
11
+ scoring root-cause scoring board, decision tree, experiment ranking
12
+ report final markdown + JSON report assembly
13
+ store persistence under runs/rca/
14
+ api FastAPI router mounted at /api/rca
15
+ """
sensorflow/rca/api.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI router for the RCA investigation workbench (prefix /api/rca)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from typing import Dict, Optional
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from pydantic import BaseModel, Field
10
+
11
+ from sensorflow.rca import diagnostics as dg, report as report_mod, scoring, store
12
+ from sensorflow.rca.models import (FINDING_STATUS, HYPOTHESIS_LABELS,
13
+ Investigation, ROOT_CAUSES, SEVERITIES,
14
+ STAGES, StageOrderError,
15
+ UnknownsNotAcknowledgedError, make_finding)
16
+ from sensorflow.rca.scenario import CAUSE_EXPLANATIONS, generate_scenario
17
+
18
+ router = APIRouter(prefix="/api/rca")
19
+
20
+ # The full battery is deterministic per investigation (data is immutable), so
21
+ # it is computed once and cached.
22
+ _battery_cache: Dict[str, Dict] = {}
23
+
24
+
25
+ def _inv_or_404(inv_id: str) -> Investigation:
26
+ inv = store.load_investigation(inv_id)
27
+ if inv is None:
28
+ raise HTTPException(404, f"Unknown investigation {inv_id}")
29
+ return inv
30
+
31
+
32
+ def _bundle_or_404(inv_id: str):
33
+ bundle = store.load_bundle(inv_id)
34
+ if bundle is None:
35
+ raise HTTPException(404, f"No scenario data for investigation {inv_id}")
36
+ return bundle
37
+
38
+
39
+ def _battery(inv_id: str) -> Dict:
40
+ if inv_id not in _battery_cache:
41
+ _battery_cache[inv_id] = dg.run_all(_bundle_or_404(inv_id))
42
+ return _battery_cache[inv_id]
43
+
44
+
45
+ # ------------------------------------------------------------------- requests
46
+
47
+
48
+ class CreateInvestigationRequest(BaseModel):
49
+ name: str = ""
50
+ baseline_model: str = "detr3d-a-v41"
51
+ candidate_model: str = "detr3d-b-v42"
52
+ # None + training_mode -> random hidden cause ("load demo investigation").
53
+ cause: Optional[str] = None
54
+ seed: Optional[int] = None
55
+ training_mode: bool = False
56
+
57
+
58
+ class CompleteStageRequest(BaseModel):
59
+ acknowledge_unknowns: bool = False
60
+ note: str = ""
61
+
62
+
63
+ class RecordFindingRequest(BaseModel):
64
+ stage: str
65
+ title: str
66
+ status: str = "MISMATCH"
67
+ severity: str = "WARN"
68
+ detail: str = ""
69
+ code: str = ""
70
+
71
+
72
+ class AssessHypothesisRequest(BaseModel):
73
+ hypothesis: str
74
+ confidence: str = Field(pattern="^(HIGH|MEDIUM|LOW|UNKNOWN)$")
75
+ note: str = ""
76
+
77
+
78
+ # ---------------------------------------------------------------- meta routes
79
+
80
+
81
+ @router.get("/causes")
82
+ def list_causes():
83
+ return {"causes": [{"id": c, "label": HYPOTHESIS_LABELS[c]}
84
+ for c in ROOT_CAUSES]}
85
+
86
+
87
+ @router.get("/stages")
88
+ def list_stages():
89
+ return {"stages": STAGES}
90
+
91
+
92
+ # -------------------------------------------------------------- investigations
93
+
94
+
95
+ @router.post("/investigations")
96
+ def create_investigation(req: CreateInvestigationRequest):
97
+ seed = req.seed if req.seed is not None else random.randint(1, 10_000_000)
98
+ if req.cause is not None:
99
+ if req.cause not in ROOT_CAUSES:
100
+ raise HTTPException(400, f"Unknown cause {req.cause!r}")
101
+ cause = req.cause
102
+ else:
103
+ cause = random.Random(seed).choice(ROOT_CAUSES)
104
+
105
+ bundle = generate_scenario(cause, seed=seed)
106
+ measured = bundle.meta["measured"]
107
+ claims = {
108
+ "metric": "object-level accuracy",
109
+ "offline_delta_pp": round(measured["offline_delta"] * 100, 2),
110
+ "shadow_delta_pp": round(measured["shadow_delta"] * 100, 2),
111
+ "offline_n": measured["offline_n"],
112
+ "shadow_scored_n": measured["shadow_scored_n"],
113
+ }
114
+ name = req.name or (f"{'Training drill' if req.training_mode else 'RCA'}: "
115
+ f"{req.candidate_model} vs {req.baseline_model}")
116
+ inv = Investigation.new(name=name, baseline_model=req.baseline_model,
117
+ candidate_model=req.candidate_model,
118
+ scenario_cause=cause, seed=seed,
119
+ training_mode=req.training_mode, claims=claims)
120
+ store.save_investigation(inv, bundle)
121
+ return inv.to_dict()
122
+
123
+
124
+ @router.get("/investigations")
125
+ def list_investigations():
126
+ return {"investigations": store.list_investigations()}
127
+
128
+
129
+ @router.get("/investigations/{inv_id}")
130
+ def get_investigation(inv_id: str):
131
+ return _inv_or_404(inv_id).to_dict()
132
+
133
+
134
+ @router.post("/investigations/{inv_id}/reveal")
135
+ def reveal(inv_id: str):
136
+ inv = _inv_or_404(inv_id)
137
+ inv.revealed = True
138
+ inv.log("revealed", "Planted root cause revealed")
139
+ store.save_investigation(inv)
140
+ return {"cause": inv.scenario_cause,
141
+ "explanation": CAUSE_EXPLANATIONS[inv.scenario_cause]}
142
+
143
+
144
+ # --------------------------------------------------------------------- stages
145
+
146
+
147
+ @router.post("/investigations/{inv_id}/stages/{index}/complete")
148
+ def complete_stage(inv_id: str, index: int, req: CompleteStageRequest):
149
+ inv = _inv_or_404(inv_id)
150
+ try:
151
+ stage = inv.complete_stage(index,
152
+ acknowledge_unknowns=req.acknowledge_unknowns,
153
+ ack_note=req.note)
154
+ except StageOrderError as e:
155
+ raise HTTPException(409, str(e))
156
+ except UnknownsNotAcknowledgedError as e:
157
+ raise HTTPException(409, str(e))
158
+ except KeyError as e:
159
+ raise HTTPException(404, str(e))
160
+ store.save_investigation(inv)
161
+ return {"stage": stage.to_dict(), "investigation": inv.to_dict()}
162
+
163
+
164
+ @router.post("/investigations/{inv_id}/stages/{index}/reopen")
165
+ def reopen_stage(inv_id: str, index: int):
166
+ inv = _inv_or_404(inv_id)
167
+ if not 0 <= index < len(inv.stages):
168
+ raise HTTPException(404, f"No stage {index}")
169
+ stage = inv.reopen_stage(index)
170
+ store.save_investigation(inv)
171
+ return {"stage": stage.to_dict(), "investigation": inv.to_dict()}
172
+
173
+
174
+ # ------------------------------------------------------------------- findings
175
+
176
+
177
+ @router.post("/investigations/{inv_id}/findings")
178
+ def record_finding(inv_id: str, req: RecordFindingRequest):
179
+ inv = _inv_or_404(inv_id)
180
+ stage_keys = [s["key"] for s in STAGES]
181
+ if req.stage not in stage_keys:
182
+ raise HTTPException(400, f"Unknown stage {req.stage!r}")
183
+ if req.status not in FINDING_STATUS:
184
+ raise HTTPException(400, f"status must be one of {FINDING_STATUS}")
185
+ if req.severity not in SEVERITIES:
186
+ raise HTTPException(400, f"severity must be one of {SEVERITIES}")
187
+ finding = make_finding(req.stage, req.code or f"HUMAN:{req.stage}",
188
+ req.title, req.status, req.severity, req.detail,
189
+ source="human")
190
+ inv.add_human_finding(finding)
191
+ store.save_investigation(inv)
192
+ return {"finding": finding.to_dict()}
193
+
194
+
195
+ # ---------------------------------------------------------------- diagnostics
196
+
197
+
198
+ @router.get("/investigations/{inv_id}/diagnostics/{stage_key}")
199
+ def stage_diagnostics(inv_id: str, stage_key: str):
200
+ inv = _inv_or_404(inv_id)
201
+ if stage_key not in dg.STAGE_DIAGNOSTICS:
202
+ raise HTTPException(404, f"No diagnostic for stage {stage_key!r}")
203
+ data, findings = _battery(inv_id)[stage_key]
204
+ inv.upsert_auto_findings(stage_key, findings)
205
+ store.save_investigation(inv)
206
+ return {"stage": stage_key, "data": data,
207
+ "findings": [f.to_dict() for f in findings]}
208
+
209
+
210
+ # ------------------------------------------------------- scoring and decision
211
+
212
+
213
+ @router.get("/investigations/{inv_id}/scoreboard")
214
+ def scoreboard(inv_id: str, recorded_only: bool = False):
215
+ inv = _inv_or_404(inv_id)
216
+ bundle = _bundle_or_404(inv_id)
217
+ if recorded_only:
218
+ return scoring.build_scoreboard(bundle, inv, recorded_only=True)
219
+ return scoring.build_scoreboard(bundle, inv, battery=_battery(inv_id))
220
+
221
+
222
+ @router.post("/investigations/{inv_id}/scoreboard/assess")
223
+ def assess_hypothesis(inv_id: str, req: AssessHypothesisRequest):
224
+ inv = _inv_or_404(inv_id)
225
+ if req.hypothesis not in ROOT_CAUSES:
226
+ raise HTTPException(400, f"Unknown hypothesis {req.hypothesis!r}")
227
+ inv.human_assessments[req.hypothesis] = {"confidence": req.confidence,
228
+ "note": req.note}
229
+ inv.log("human_assessment",
230
+ f"Human set {req.hypothesis} confidence to {req.confidence}",
231
+ {"note": req.note})
232
+ store.save_investigation(inv)
233
+ bundle = _bundle_or_404(inv_id)
234
+ return scoring.build_scoreboard(bundle, inv, battery=_battery(inv_id))
235
+
236
+
237
+ @router.get("/investigations/{inv_id}/decision-tree")
238
+ def decision_tree(inv_id: str):
239
+ bundle = _bundle_or_404(inv_id)
240
+ return scoring.evaluate_decision_tree(bundle, battery=_battery(inv_id))
241
+
242
+
243
+ @router.get("/investigations/{inv_id}/experiments")
244
+ def experiments(inv_id: str):
245
+ bundle = _bundle_or_404(inv_id)
246
+ return scoring.recommend_experiments(bundle, battery=_battery(inv_id))
247
+
248
+
249
+ @router.get("/investigations/{inv_id}/report")
250
+ def report(inv_id: str):
251
+ inv = _inv_or_404(inv_id)
252
+ bundle = _bundle_or_404(inv_id)
253
+ return report_mod.build_report(bundle, inv, battery=_battery(inv_id))
sensorflow/rca/diagnostics.py ADDED
@@ -0,0 +1,1029 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-stage diagnostic computations.
2
+
3
+ Every function takes a ScenarioBundle and returns (data, findings):
4
+ data JSON-serializable payload for the UI table/visualization
5
+ findings list of Finding records (auto evidence)
6
+
7
+ Nothing here is invented: every number is computed from the investigation's
8
+ offline/shadow unit data or its recorded configs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from datetime import date
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from sensorflow.rca import stats as st
21
+ from sensorflow.rca.models import Finding, make_finding
22
+ from sensorflow.rca.scenario import FEATURES, SEGMENT_DIMS, ScenarioBundle
23
+
24
+ LARGE_N_CAVEAT = (
25
+ "With thousands of units almost any difference is 'statistically "
26
+ "significant'; act on the practical-effect magnitude (PSI / JS labels), "
27
+ "not the p-value.")
28
+
29
+ CONF_BANDS = ((0.0, 0.35), (0.35, 0.50), (0.50, 0.70), (0.70, 1.01))
30
+
31
+
32
+ def _scored(bundle: ScenarioBundle) -> pd.DataFrame:
33
+ sh = bundle.shadow
34
+ return sh[sh["sampled"]]
35
+
36
+
37
+ def _delta_pp(df: pd.DataFrame) -> float:
38
+ if len(df) == 0:
39
+ return 0.0
40
+ return float((df["b_correct"].mean() - df["a_correct"].mean()) * 100)
41
+
42
+
43
+ def _parse_window(win: str) -> Optional[Tuple[date, date]]:
44
+ ds = re.findall(r"(\d{4})-(\d{2})-(\d{2})", win or "")
45
+ if len(ds) < 2:
46
+ return None
47
+ return (date(*map(int, ds[0])), date(*map(int, ds[1])))
48
+
49
+
50
+ # ================================================== stage 0: comparison validity
51
+
52
+ # (field, label, criticality-on-mismatch, expected-to-differ)
53
+ _CV_FIELDS = [
54
+ ("metric_definition", "Metric definition", "CRITICAL", False),
55
+ ("aggregation", "Aggregation", "WARN", False),
56
+ ("eval_window", "Evaluation window", "WARN", True),
57
+ ("population_source", "Population source", "WARN", True),
58
+ ("confidence_threshold", "Confidence threshold", "CRITICAL", False),
59
+ ("quantization", "Numeric precision / quantization", "CRITICAL", False),
60
+ ("runtime_version", "Runtime version", "WARN", False),
61
+ ("nms_iou", "NMS IoU", "WARN", False),
62
+ ("feature_pipeline_version", "Feature pipeline version", "CRITICAL", False),
63
+ ("label_policy_version", "Labeling policy", "CRITICAL", False),
64
+ ("label_maturity_policy", "Label maturity policy", "WARN", True),
65
+ ("sampling_policy", "Sampling policy", "WARN", True),
66
+ ]
67
+
68
+
69
+ def comparison_validity(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
70
+ off_cfg = bundle.meta["offline_config"]
71
+ sh_cfg = bundle.meta["shadow_config"]
72
+ rows: List[Dict] = []
73
+ findings: List[Finding] = []
74
+ stage = "comparison_validity"
75
+
76
+ for field, label, crit, expected_diff in _CV_FIELDS:
77
+ ov, sv = off_cfg.get(field), sh_cfg.get(field)
78
+ if ov is None or sv is None:
79
+ status = "unknown"
80
+ findings.append(make_finding(
81
+ stage, f"CV_UNKNOWN:{field}", f"{label} is unrecorded",
82
+ "UNKNOWN", crit,
83
+ f"Offline={ov!r}, shadow={sv!r}. You cannot certify the two "
84
+ f"numbers are comparable while {label.lower()} is unknown."))
85
+ elif ov == sv:
86
+ status = "comparable"
87
+ elif expected_diff:
88
+ status = "expected_difference"
89
+ else:
90
+ status = "mismatch"
91
+ findings.append(make_finding(
92
+ stage, f"CV_MISMATCH:{field}", f"{label} differs",
93
+ "MISMATCH", crit,
94
+ f"Offline={ov!r} vs shadow={sv!r}. The two measurements are "
95
+ "not measuring the same thing along this dimension.",
96
+ data={"offline": ov, "shadow": sv}))
97
+ rows.append({"field": field, "label": label, "offline": ov,
98
+ "shadow": sv, "status": status,
99
+ "criticality": crit})
100
+
101
+ # Window staleness: a large gap between offline eval window and the live
102
+ # shadow window is itself a validity hazard (world may have moved).
103
+ ow, sw = _parse_window(off_cfg.get("eval_window", "")), \
104
+ _parse_window(sh_cfg.get("eval_window", ""))
105
+ if ow and sw:
106
+ gap_days = (sw[0] - ow[1]).days
107
+ if gap_days > 45:
108
+ findings.append(make_finding(
109
+ stage, "CV_WINDOW_STALE", "Offline eval window is stale",
110
+ "MISMATCH", "WARN",
111
+ f"Offline window ends {ow[1].isoformat()}, shadow starts "
112
+ f"{sw[0].isoformat()} ({gap_days} days later). Data drift in "
113
+ "the gap can make both numbers correct on their own windows."))
114
+ rows.append({"field": "window_gap_days", "label": "Window gap (days)",
115
+ "offline": ow[1].isoformat(), "shadow": sw[0].isoformat(),
116
+ "status": "mismatch" if gap_days > 45 else "comparable",
117
+ "criticality": "WARN", "gap_days": gap_days})
118
+
119
+ if not any(f.status != "PASS" for f in findings):
120
+ findings.append(make_finding(
121
+ stage, "CV_COMPARABLE", "No blocking comparability mismatch",
122
+ "PASS", "INFO",
123
+ "All recorded dimensions are comparable or expected differences."))
124
+
125
+ data = {"rows": rows,
126
+ "explainer": ("Dimension-by-dimension check that '+5% offline' and "
127
+ "'-2% shadow' are even commensurable claims. Any "
128
+ "mismatch row is a candidate explanation that must "
129
+ "be ruled out before believing either number.")}
130
+ return data, findings
131
+
132
+
133
+ # ======================================================= stage 1: offline audit
134
+
135
+
136
+ def offline_audit(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
137
+ stage = "offline_audit"
138
+ findings: List[Finding] = []
139
+ off = bundle.offline
140
+ repro = bundle.meta["reproducibility"]
141
+ model_b = bundle.meta["model_b"]
142
+
143
+ orig = float(repro["original_offline_metric_delta"])
144
+ rerun = float(repro["rerun_metric_delta"])
145
+ diff = abs(rerun - orig)
146
+ reproduces = diff < 0.005
147
+ if reproduces:
148
+ findings.append(make_finding(
149
+ stage, "OA_REPRO_OK", "Offline result reproduces", "PASS", "INFO",
150
+ f"Re-run delta {rerun*100:+.2f}pp vs original {orig*100:+.2f}pp."))
151
+ else:
152
+ findings.append(make_finding(
153
+ stage, "OA_REPRO_FAIL", "Offline result does NOT reproduce",
154
+ "MISMATCH", "CRITICAL",
155
+ f"Re-running the baseline evaluation gives {rerun*100:+.2f}pp vs "
156
+ f"the claimed {orig*100:+.2f}pp (|diff| {diff*100:.2f}pp). An "
157
+ "irreproducible +5% is not evidence of anything."))
158
+ if repro["pins_present"]:
159
+ findings.append(make_finding(
160
+ stage, "OA_PINS_OK", "Version pins recorded", "PASS", "INFO",
161
+ repro["environment_lock"]))
162
+ else:
163
+ findings.append(make_finding(
164
+ stage, "OA_PINS_MISSING", "Version pins missing", "UNKNOWN", "WARN",
165
+ f"Environment: {repro['environment_lock']}. Cannot certify the "
166
+ "offline run's exact code/data state."))
167
+
168
+ # Leakage scan: near-duplicate flags + entity overlap with B's train set.
169
+ dup_mask = off["dup_similarity"] > 0.90
170
+ n_dup = int(dup_mask.sum())
171
+ train_ids = set(model_b.get("train_entity_ids", []))
172
+ overlap_entities = sorted(set(off["entity_id"]) & train_ids)
173
+ dup_frac = n_dup / max(1, len(off))
174
+ dup_delta = _delta_pp(off[dup_mask]) if n_dup else 0.0
175
+ clean_delta = _delta_pp(off[~dup_mask])
176
+ if n_dup > 0 or overlap_entities:
177
+ findings.append(make_finding(
178
+ stage, "OA_LEAKAGE_DUPLICATES", "Train/eval leakage detected",
179
+ "MISMATCH", "CRITICAL",
180
+ f"{n_dup} eval units ({dup_frac:.1%}) are near-duplicates "
181
+ f"(similarity>0.90) of candidate training data; "
182
+ f"{len(overlap_entities)} eval entities appear in B's training "
183
+ f"set. Delta on leaked units: {dup_delta:+.1f}pp vs "
184
+ f"{clean_delta:+.1f}pp on clean units.",
185
+ data={"n_duplicates": n_dup, "dup_fraction": dup_frac,
186
+ "overlap_entity_count": len(overlap_entities),
187
+ "dup_delta_pp": dup_delta, "clean_delta_pp": clean_delta}))
188
+ else:
189
+ findings.append(make_finding(
190
+ stage, "OA_LEAKAGE_CLEAN", "No leakage detected", "PASS", "INFO",
191
+ "No near-duplicates of candidate training data in the eval set; "
192
+ "no entity overlap with B's training entities."))
193
+
194
+ # Temporal leakage: eval window must start after B's training window ends.
195
+ eval_win = _parse_window(bundle.meta["offline_config"].get("eval_window", ""))
196
+ train_end = model_b.get("train_window", "").split("..")[-1].strip()
197
+ temporal_ok = True
198
+ if eval_win and re.match(r"\d{4}-\d{2}", train_end):
199
+ y, m = map(int, train_end.split("-")[:2])
200
+ temporal_ok = eval_win[0] >= date(y, m, 1)
201
+ findings.append(make_finding(
202
+ stage, "OA_TEMPORAL_OK" if temporal_ok else "OA_TEMPORAL_LEAK",
203
+ "Temporal split check", "PASS" if temporal_ok else "MISMATCH",
204
+ "INFO" if temporal_ok else "CRITICAL",
205
+ f"Eval window vs B train window ({model_b.get('train_window')})."))
206
+
207
+ split = model_b.get("split_strategy", "unknown")
208
+ if split == "row_level":
209
+ findings.append(make_finding(
210
+ stage, "OA_SPLIT_ROW_LEVEL", "Row-level split (correlated entities)",
211
+ "MISMATCH", "WARN",
212
+ "Train/eval split was row-level, so frames from the same drive "
213
+ "segment can straddle the boundary; correlated near-duplicates "
214
+ "inflate offline scores."))
215
+ else:
216
+ findings.append(make_finding(
217
+ stage, "OA_SPLIT_ENTITY_LEVEL", "Entity-level split", "PASS", "INFO",
218
+ "Split respects drive-segment boundaries."))
219
+
220
+ data = {
221
+ "reproducibility": {"original_delta_pp": orig * 100,
222
+ "rerun_delta_pp": rerun * 100,
223
+ "abs_diff_pp": diff * 100,
224
+ "reproduces": reproduces,
225
+ "pins_present": bool(repro["pins_present"]),
226
+ "environment_lock": repro["environment_lock"]},
227
+ "leakage": {"n_duplicates": n_dup, "dup_fraction": dup_frac,
228
+ "overlap_entity_count": len(overlap_entities),
229
+ "overlap_examples": overlap_entities[:8],
230
+ "dup_delta_pp": dup_delta, "clean_delta_pp": clean_delta,
231
+ "offline_n": int(len(off))},
232
+ "split": {"strategy": split,
233
+ "n_entities": int(off["entity_id"].nunique()),
234
+ "mean_units_per_entity": float(len(off) / max(1, off["entity_id"].nunique()))},
235
+ "temporal": {"ok": temporal_ok,
236
+ "b_train_window": model_b.get("train_window")},
237
+ "explainer": ("Audits the +5%% claim itself: does it reproduce, is the "
238
+ "environment pinned, and is the eval set contaminated by "
239
+ "the candidate's training data?"),
240
+ }
241
+ return data, findings
242
+
243
+
244
+ # ================================================ stage 2: population validation
245
+
246
+
247
+ def population_validation(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
248
+ stage = "population_validation"
249
+ findings: List[Finding] = []
250
+ off, sh = bundle.offline, bundle.shadow
251
+ scored = _scored(bundle)
252
+ traffic = bundle.meta["traffic"]
253
+
254
+ dim_tables = {}
255
+ for dim in SEGMENT_DIMS:
256
+ o = off[dim].value_counts(normalize=True)
257
+ s = scored[dim].value_counts(normalize=True)
258
+ cats = sorted(set(o.index) | set(s.index))
259
+ dim_tables[dim] = [{"value": c,
260
+ "offline_share": float(o.get(c, 0.0)),
261
+ "shadow_share": float(s.get(c, 0.0))}
262
+ for c in cats]
263
+
264
+ overlap = len(set(off["entity_id"]) & set(sh["entity_id"]))
265
+ if len(scored) < 1000:
266
+ findings.append(make_finding(
267
+ stage, "POP_VOLUME_LOW", "Shadow scored volume is small",
268
+ "UNKNOWN", "CRITICAL",
269
+ f"Only {len(scored)} scored shadow units "
270
+ f"({sh['entity_id'].nunique()} drive segments). Aggregate deltas "
271
+ "at this volume are dominated by sampling noise; treat -2% as a "
272
+ "hypothesis, not a measurement."))
273
+ if len(off) < 1000:
274
+ findings.append(make_finding(
275
+ stage, "POP_SMALL_OFFLINE", "Offline eval set is small",
276
+ "UNKNOWN", "WARN",
277
+ f"Offline eval has only {len(off)} units."))
278
+ if len(scored) >= 1000 and len(off) >= 1000:
279
+ findings.append(make_finding(
280
+ stage, "POP_VOLUME_OK", "Volumes adequate", "PASS", "INFO",
281
+ f"Offline n={len(off)}, shadow scored n={len(scored)} "
282
+ f"(of {traffic['eligible_count']} eligible)."))
283
+
284
+ data = {
285
+ "volumes": {"offline_n": int(len(off)),
286
+ "shadow_eligible_n": int(traffic["eligible_count"]),
287
+ "shadow_scored_n": int(len(scored)),
288
+ "sampling_rate": traffic["sampling_rate"],
289
+ "offline_entities": int(off["entity_id"].nunique()),
290
+ "shadow_entities": int(sh["entity_id"].nunique()),
291
+ "entity_overlap": overlap},
292
+ "dimensions": dim_tables,
293
+ "explainer": ("Confirms both evaluations have enough volume to say "
294
+ "anything, and shows the raw population composition "
295
+ "side-by-side before any shift statistics."),
296
+ }
297
+ return data, findings
298
+
299
+
300
+ # ================================================= stage 3: distribution shift
301
+
302
+
303
+ def distribution_shift(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
304
+ stage = "distribution_shift"
305
+ findings: List[Finding] = []
306
+ off, scored = bundle.offline, _scored(bundle)
307
+ volume_ok = min(len(off), len(scored)) >= 800
308
+
309
+ seg_rows, feat_rows = [], []
310
+ seg_psis = {}
311
+ for dim in SEGMENT_DIMS:
312
+ psi = st.psi_categorical(off[dim], scored[dim])
313
+ js = st.js_divergence_categorical(off[dim], scored[dim])
314
+ chi, p = st.chi2_test(off[dim], scored[dim])
315
+ seg_psis[dim] = psi
316
+ seg_rows.append({"dimension": dim, "kind": "segment", "psi": psi,
317
+ "js": js, "test": "chi2", "stat": chi, "p_value": p,
318
+ "magnitude": st.shift_magnitude_label(psi)})
319
+
320
+ for feat in FEATURES:
321
+ a = off[feat].dropna().to_numpy()
322
+ b = scored[feat].dropna().to_numpy()
323
+ psi = st.psi_continuous(a, b)
324
+ js = st.js_divergence_continuous(a, b)
325
+ ks, p = st.ks_test(a, b)
326
+ feat_rows.append({"dimension": feat, "kind": "feature", "psi": psi,
327
+ "js": js, "test": "ks", "stat": ks, "p_value": p,
328
+ "magnitude": st.shift_magnitude_label(psi),
329
+ "offline_mean": float(np.mean(a)),
330
+ "shadow_mean": float(np.mean(b))})
331
+
332
+ if not volume_ok:
333
+ findings.append(make_finding(
334
+ stage, "DS_VOLUME_TOO_SMALL",
335
+ "Too little data to assess distribution shift",
336
+ "UNKNOWN", "WARN",
337
+ f"Only {len(off)} offline / {len(scored)} scored shadow units. "
338
+ "PSI estimates are upward-biased at this volume; shift can be "
339
+ "neither confirmed nor excluded."))
340
+ data = {"segments": seg_rows, "features": feat_rows,
341
+ "caveat": LARGE_N_CAVEAT, "volume_ok": False,
342
+ "explainer": ("Per-dimension PSI + Jensen-Shannon + KS/"
343
+ "chi-square with BOTH significance and practical "
344
+ "magnitude.")}
345
+ return data, findings
346
+
347
+ high_segs = [d for d, v in seg_psis.items() if v >= 0.25]
348
+ for dim in high_segs:
349
+ findings.append(make_finding(
350
+ stage, f"DS_SHIFT_HIGH:{dim}", f"Large population shift on {dim}",
351
+ "MISMATCH", "CRITICAL" if seg_psis[dim] >= 0.5 else "WARN",
352
+ f"PSI={seg_psis[dim]:.2f} ({st.shift_magnitude_label(seg_psis[dim])}). "
353
+ "Offline and shadow are scoring materially different populations; "
354
+ "aggregate deltas are not directly comparable. " + LARGE_N_CAVEAT,
355
+ data={"psi": seg_psis[dim]}))
356
+
357
+ if not high_segs and all(v < 0.10 for v in seg_psis.values()):
358
+ findings.append(make_finding(
359
+ stage, "DS_SHIFT_LOW", "Population mix is stable", "PASS", "INFO",
360
+ f"All segment-dimension PSIs < 0.10 "
361
+ f"({', '.join(f'{d}={v:.03f}' for d, v in seg_psis.items())})."))
362
+
363
+ # A single feature shifting hugely while the population mix is stable is
364
+ # the signature of a pipeline artifact, not a population change.
365
+ if all(v < 0.10 for v in seg_psis.values()):
366
+ for row in feat_rows:
367
+ if row["psi"] >= 0.5:
368
+ findings.append(make_finding(
369
+ stage, f"DS_FEATURE_ONLY_SHIFT:{row['dimension']}",
370
+ f"Isolated shift in {row['dimension']} despite stable mix",
371
+ "MISMATCH", "WARN",
372
+ f"{row['dimension']} PSI={row['psi']:.2f} while all segment "
373
+ "mixes are stable -- consistent with a feature-pipeline "
374
+ "artifact rather than a real-world change. Check feature "
375
+ "parity (stage 7).",
376
+ data={"psi": row["psi"],
377
+ "offline_mean": row["offline_mean"],
378
+ "shadow_mean": row["shadow_mean"]}))
379
+
380
+ data = {"segments": seg_rows, "features": feat_rows,
381
+ "caveat": LARGE_N_CAVEAT, "volume_ok": True,
382
+ "explainer": ("Per-dimension PSI + Jensen-Shannon + KS/chi-square "
383
+ "with BOTH significance and practical magnitude. "
384
+ "Large shift means the -2% may be measured on a "
385
+ "different world than the +5%.")}
386
+ return data, findings
387
+
388
+
389
+ # ============================================ stage 4: conditional performance
390
+
391
+
392
+ def conditional_performance(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
393
+ stage = "conditional_performance"
394
+ findings: List[Finding] = []
395
+ off, scored = bundle.offline, _scored(bundle)
396
+
397
+ rows = []
398
+ agg_off, agg_sh = _delta_pp(off), _delta_pp(scored)
399
+ total_neg_contrib = 0.0
400
+ contribs: List[Tuple[str, float]] = []
401
+ consistent_volume = 0
402
+ neg_volume = 0
403
+
404
+ for (scene, tod), o_seg in off.groupby(["scene", "time_of_day"]):
405
+ s_seg = scored[(scored["scene"] == scene) & (scored["time_of_day"] == tod)]
406
+ od, sd = _delta_pp(o_seg), _delta_pp(s_seg)
407
+ off_share = len(o_seg) / len(off)
408
+ sh_share = len(s_seg) / max(1, len(scored))
409
+ seg_name = f"{scene}/{tod}"
410
+ low_vol = len(s_seg) < 60 or len(o_seg) < 60
411
+ if low_vol:
412
+ interp = "low_volume"
413
+ elif od > 0.5 and sd > 0.5:
414
+ interp = "consistent_improvement"
415
+ elif od < -0.5 and sd < -0.5:
416
+ interp = "consistent_regression"
417
+ elif od > 0.5 and sd < -0.5:
418
+ interp = "sign_flip"
419
+ else:
420
+ interp = "flat"
421
+ rows.append({"segment": seg_name, "scene": scene, "time_of_day": tod,
422
+ "offline_delta_pp": od, "shadow_delta_pp": sd,
423
+ "offline_n": int(len(o_seg)), "shadow_n": int(len(s_seg)),
424
+ "offline_share": off_share, "shadow_share": sh_share,
425
+ "shift_ratio": sh_share / max(1e-9, off_share),
426
+ "interpretation": interp})
427
+ if not low_vol:
428
+ if np.sign(od) == np.sign(sd) and abs(od) > 0.5 and abs(sd) > 0.5:
429
+ consistent_volume += len(s_seg)
430
+ if sd < 0:
431
+ neg_volume += len(s_seg)
432
+ contrib = sh_share * sd
433
+ total_neg_contrib += contrib
434
+ contribs.append((seg_name, contrib))
435
+
436
+ rows.sort(key=lambda r: r["shadow_delta_pp"])
437
+ consistency = consistent_volume / max(1, len(scored))
438
+
439
+ # Simpson's requires the mix to have actually moved: within-segment sign
440
+ # agreement plus an aggregate flip is only a mix artifact if the segment
441
+ # composition differs between the two evaluations.
442
+ mix_psi = max(st.psi_categorical(off["scene"], scored["scene"]),
443
+ st.psi_categorical(off["time_of_day"], scored["time_of_day"]))
444
+ simpsons = (agg_off > 0.5 and agg_sh < -0.5 and consistency >= 0.55
445
+ and mix_psi >= 0.25)
446
+ if simpsons:
447
+ findings.append(make_finding(
448
+ stage, "CP_SIMPSONS_DETECTED",
449
+ "Simpson's paradox: aggregate flip explained by mix",
450
+ "MISMATCH", "CRITICAL",
451
+ f"Aggregate offline {agg_off:+.1f}pp vs shadow {agg_sh:+.1f}pp, "
452
+ f"yet within-segment deltas agree in sign on {consistency:.0%} of "
453
+ "scored volume. The sign flip comes from WHERE the traffic is, "
454
+ "not from the model changing behavior.",
455
+ data={"consistency": consistency, "agg_offline_pp": agg_off,
456
+ "agg_shadow_pp": agg_sh}))
457
+ elif contribs:
458
+ contribs.sort(key=lambda c: c[1])
459
+ # Concentration is checked at segment level and along each single
460
+ # dimension (all-highway regression should register even when split
461
+ # across highway/day, highway/dusk, highway/night).
462
+ grouped: Dict[str, float] = {}
463
+ for seg_name, contrib in contribs:
464
+ scene, tod = seg_name.split("/")
465
+ grouped[seg_name] = grouped.get(seg_name, 0.0) + contrib
466
+ grouped[f"scene={scene}"] = grouped.get(f"scene={scene}", 0.0) + contrib
467
+ grouped[f"time={tod}"] = grouped.get(f"time={tod}", 0.0) + contrib
468
+ top_seg, top_contrib = min(grouped.items(), key=lambda kv: kv[1])
469
+ top_share = top_contrib / total_neg_contrib if total_neg_contrib < 0 else 0.0
470
+ if top_share >= 0.55:
471
+ findings.append(make_finding(
472
+ stage, f"CP_SEGMENT_CONCENTRATED:{top_seg}",
473
+ f"Shadow regression concentrated in {top_seg}",
474
+ "MISMATCH", "WARN",
475
+ f"{top_share:.0%} of the total shadow regression comes from "
476
+ f"{top_seg}. A concentrated regression points at something "
477
+ "that segment depends on (a feature, a sensor mode), not a "
478
+ "global model change.",
479
+ data={"segment": top_seg, "share_of_regression": top_share}))
480
+ elif neg_volume / max(1, len(scored)) >= 0.6:
481
+ findings.append(make_finding(
482
+ stage, "CP_UNIFORM_REGRESSION",
483
+ "Shadow regression is broad-based",
484
+ "MISMATCH", "WARN",
485
+ f"{neg_volume / max(1, len(scored)):.0%} of scored volume sits "
486
+ "in segments with negative shadow delta and no single segment "
487
+ "dominates -- consistent with a genuine model-level change.",
488
+ data={"negative_volume_share": neg_volume / max(1, len(scored))}))
489
+
490
+ if not findings:
491
+ findings.append(make_finding(
492
+ stage, "CP_NO_PATTERN", "No dominant conditional pattern",
493
+ "PASS", "INFO",
494
+ "Per-segment deltas show no Simpson flip, concentration, or "
495
+ "uniform regression above thresholds."))
496
+
497
+ data = {"rows": rows, "aggregate": {"offline_delta_pp": agg_off,
498
+ "shadow_delta_pp": agg_sh,
499
+ "sign_consistency": consistency},
500
+ "explainer": ("Segment x (offline delta, shadow delta, volume, "
501
+ "shift) matrix. This is where Simpson's paradox "
502
+ "becomes visible: per-segment agreement with an "
503
+ "aggregate sign flip means the mix, not the model.")}
504
+ return data, findings
505
+
506
+
507
+ # ================================================ stage 5: paired comparison
508
+
509
+
510
+ def paired_comparison(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
511
+ stage = "paired_comparison"
512
+ findings: List[Finding] = []
513
+ scored = _scored(bundle)
514
+ off = bundle.offline
515
+
516
+ def matrix(df: pd.DataFrame) -> Dict:
517
+ a, b = df["a_correct"] == 1, df["b_correct"] == 1
518
+ return {"both_correct": int((a & b).sum()),
519
+ "regressions": int((a & ~b).sum()),
520
+ "improvements": int((~a & b).sum()),
521
+ "both_wrong": int((~a & ~b).sum()),
522
+ "n": int(len(df)),
523
+ "net_pp": _delta_pp(df)}
524
+
525
+ overall = matrix(scored)
526
+ offline_overall = matrix(off)
527
+
528
+ by_segment = []
529
+ for (scene, tod), g in scored.groupby(["scene", "time_of_day"]):
530
+ m = matrix(g)
531
+ m["segment"] = f"{scene}/{tod}"
532
+ by_segment.append(m)
533
+ by_class = []
534
+ for cls, g in scored.groupby("object_class"):
535
+ m = matrix(g)
536
+ m["segment"] = cls
537
+ by_class.append(m)
538
+
539
+ by_band = []
540
+ conf_col = "b_conf_serving" if "b_conf_serving" in scored.columns else "b_conf"
541
+ total_reg = max(1, overall["regressions"])
542
+ band_reg_share = {}
543
+ for lo, hi in CONF_BANDS:
544
+ g = scored[(scored[conf_col] >= lo) & (scored[conf_col] < hi)]
545
+ m = matrix(g)
546
+ m["segment"] = f"[{lo:.2f}, {hi:.2f})"
547
+ m["unit_share"] = len(g) / max(1, len(scored))
548
+ m["regression_share"] = m["regressions"] / total_reg
549
+ m["lift"] = (m["regression_share"] / m["unit_share"]
550
+ if m["unit_share"] > 0 else 0.0)
551
+ band_reg_share[(lo, hi)] = m
552
+ by_band.append(m)
553
+
554
+ focus = band_reg_share.get((0.35, 0.50))
555
+ if focus and focus["lift"] >= 1.5 and focus["regression_share"] >= 0.30:
556
+ findings.append(make_finding(
557
+ stage, "PC_CONF_BAND_CONCENTRATION",
558
+ "Regressions concentrated in the 0.35-0.50 confidence band",
559
+ "MISMATCH", "CRITICAL",
560
+ f"{focus['regression_share']:.0%} of A-correct->B-incorrect "
561
+ f"transitions sit in the [0.35, 0.50) serving-confidence band, "
562
+ f"{focus['lift']:.1f}x its traffic share. That band is exactly "
563
+ "where a serving-threshold difference would delete detections.",
564
+ data={"lift": focus["lift"],
565
+ "regression_share": focus["regression_share"]}))
566
+
567
+ if overall["regressions"] > overall["improvements"] * 1.15:
568
+ findings.append(make_finding(
569
+ stage, "PC_NET_REGRESSION", "Net regression transitions in shadow",
570
+ "MISMATCH", "WARN",
571
+ f"{overall['regressions']} regression transitions vs "
572
+ f"{overall['improvements']} improvements on identical units "
573
+ f"(net {overall['net_pp']:+.1f}pp).",
574
+ data={"regressions": overall["regressions"],
575
+ "improvements": overall["improvements"]}))
576
+ else:
577
+ findings.append(make_finding(
578
+ stage, "PC_BALANCED", "Transitions roughly balanced", "PASS", "INFO",
579
+ f"{overall['regressions']} regressions vs "
580
+ f"{overall['improvements']} improvements."))
581
+
582
+ data = {"shadow": overall, "offline": offline_overall,
583
+ "by_segment": by_segment, "by_class": by_class, "by_band": by_band,
584
+ "conf_col": conf_col,
585
+ "explainer": ("Error-transition matrix on the SAME units: "
586
+ "A-correct->B-incorrect (regressions) vs "
587
+ "A-incorrect->B-correct (improvements), sliced by "
588
+ "segment, class, and serving-confidence band.")}
589
+ return data, findings
590
+
591
+
592
+ # ============================================== stage 6: statistical significance
593
+
594
+
595
+ def _seqeval_snapshot(scored: pd.DataFrame, margin: float) -> Optional[Dict]:
596
+ """Complementary anytime-valid evidence from sensorflow.seqeval, if the
597
+ package is available. The fixed-n cluster-robust CI stays the verdict
598
+ driver for this stage (one batch, one pre-committed look); the sequential
599
+ snapshot answers the different question 'could we have stopped early /
600
+ keep monitoring without alpha-spending'."""
601
+ try:
602
+ from sensorflow.seqeval import units as sq_units
603
+ from sensorflow.seqeval.sequential import PairedSequentialTest
604
+ except Exception:
605
+ return None
606
+ d = (scored["b_correct"] - scored["a_correct"]).to_numpy().astype(float)
607
+ means, _sizes = sq_units.cluster_units(d, scored["entity_id"].to_numpy())
608
+ order = np.random.default_rng(0).permutation(len(means))
609
+ test = PairedSequentialTest(delta=margin, alpha=0.05)
610
+ test.update_clusters(means[order])
611
+ test.record_objects(scored["a_correct"].to_numpy().astype(bool),
612
+ scored["b_correct"].to_numpy().astype(bool))
613
+ test.evaluate()
614
+ snap = test.snapshot()
615
+ snap["note"] = (
616
+ "Anytime-valid empirical-Bernstein confidence sequence "
617
+ "(sensorflow.seqeval). Wider than the fixed-n CI by design: it stays "
618
+ "valid under continuous monitoring and optional stopping.")
619
+ return snap
620
+
621
+
622
+ def statistical_significance(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
623
+ stage = "statistical_significance"
624
+ findings: List[Finding] = []
625
+ scored = _scored(bundle)
626
+ off = bundle.offline
627
+ margin = float(bundle.meta.get("practical_margin_pp", 1.0)) / 100.0
628
+
629
+ seq_snap = _seqeval_snapshot(scored, margin)
630
+ engine = "local+seqeval" if seq_snap is not None else "local"
631
+ sh_res = st.paired_delta_cluster(scored)
632
+ off_res = st.paired_delta_cluster(off)
633
+ outcome = st.three_way_outcome(sh_res["delta"], sh_res["ci_low"],
634
+ sh_res["ci_high"], margin)
635
+
636
+ wilson = {}
637
+ for name, df, col in (("offline_a", off, "a_correct"),
638
+ ("offline_b", off, "b_correct"),
639
+ ("shadow_a", scored, "a_correct"),
640
+ ("shadow_b", scored, "b_correct")):
641
+ lo, hi = st.wilson_ci(int(df[col].sum()), len(df))
642
+ wilson[name] = {"rate": float(df[col].mean()), "ci_low": lo, "ci_high": hi,
643
+ "n": int(len(df))}
644
+
645
+ if outcome == "significant_regression":
646
+ findings.append(make_finding(
647
+ stage, "SS_SIGNIFICANT_REGRESSION",
648
+ "Shadow regression is significant beyond the practical margin",
649
+ "MISMATCH", "CRITICAL",
650
+ f"Paired delta {sh_res['delta']*100:+.2f}pp, 95% CI "
651
+ f"[{sh_res['ci_low']*100:+.2f}, {sh_res['ci_high']*100:+.2f}]pp, "
652
+ f"entirely below the -{margin*100:.1f}pp practical margin "
653
+ f"(cluster-aware, effective n={sh_res['effective_n']}).",
654
+ data=sh_res))
655
+ elif outcome == "no_significant_difference":
656
+ findings.append(make_finding(
657
+ stage, "SS_NO_SIG_DIFF",
658
+ "No practically-important difference detectable",
659
+ "PASS", "INFO",
660
+ f"95% CI [{sh_res['ci_low']*100:+.2f}, {sh_res['ci_high']*100:+.2f}]pp "
661
+ f"excludes a regression beyond -{margin*100:.1f}pp.",
662
+ data=sh_res))
663
+ else:
664
+ findings.append(make_finding(
665
+ stage, "SS_INSUFFICIENT_EVIDENCE",
666
+ "Insufficient evidence to call the shadow delta",
667
+ "UNKNOWN", "CRITICAL",
668
+ f"Paired delta {sh_res['delta']*100:+.2f}pp with 95% CI "
669
+ f"[{sh_res['ci_low']*100:+.2f}, {sh_res['ci_high']*100:+.2f}]pp "
670
+ f"spans the practical margin. Effective n is only "
671
+ f"{sh_res['effective_n']} (design effect "
672
+ f"{sh_res['design_effect']:.1f} from {sh_res['n_clusters']} "
673
+ "clusters). Neither 'regression' nor 'fine' is supported.",
674
+ data=sh_res))
675
+
676
+ if sh_res["effective_n"] < 800:
677
+ findings.append(make_finding(
678
+ stage, "SS_LOW_ESS", "Low effective sample size", "UNKNOWN", "WARN",
679
+ f"Cluster correlation shrinks {sh_res['n']} units to an effective "
680
+ f"n of {sh_res['effective_n']}.",
681
+ data={"effective_n": sh_res["effective_n"],
682
+ "design_effect": sh_res["design_effect"]}))
683
+
684
+ data = {"engine": engine,
685
+ "engine_note": ("Verdict: fixed-n cluster-robust paired CI (valid "
686
+ "for this single pre-committed look). "
687
+ + ("Complementary anytime-valid confidence "
688
+ "sequence from sensorflow.seqeval attached."
689
+ if seq_snap is not None else
690
+ "sensorflow.seqeval not importable; local "
691
+ "Wilson/paired implementation only.")),
692
+ "practical_margin_pp": margin * 100,
693
+ "shadow_paired": sh_res, "offline_paired": off_res,
694
+ "outcome": outcome, "wilson": wilson,
695
+ "seqeval": seq_snap,
696
+ "explainer": ("Paired B-A delta with cluster-aware effective "
697
+ "sample size and a minimum practically-important "
698
+ "regression margin. Three-way verdict: significant "
699
+ "regression / no significant difference / "
700
+ "insufficient evidence.")}
701
+ return data, findings
702
+
703
+
704
+ # ==================================================== stage 7: feature parity
705
+
706
+
707
+ def feature_parity(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
708
+ stage = "feature_parity"
709
+ findings: List[Finding] = []
710
+ off, scored = bundle.offline, _scored(bundle)
711
+
712
+ rows = []
713
+ for feat in FEATURES:
714
+ a, b = off[feat], scored[feat]
715
+ a_clean, b_clean = a.dropna(), b.dropna()
716
+ pooled_sd = float(np.sqrt((a_clean.std() ** 2 + b_clean.std() ** 2) / 2))
717
+ smd = abs(float(b_clean.mean() - a_clean.mean())) / max(pooled_sd, 1e-9)
718
+
719
+ # Within-segment SMD controls for population mix: a real-world shift
720
+ # moves the mix, a pipeline skew moves the values inside segments too.
721
+ seg_smds, seg_ws = [], []
722
+ for (scene, tod), g_off in off.groupby(["scene", "time_of_day"]):
723
+ g_sh = scored[(scored["scene"] == scene)
724
+ & (scored["time_of_day"] == tod)]
725
+ if len(g_sh) < 40 or len(g_off) < 40:
726
+ continue
727
+ ac, bc = g_off[feat].dropna(), g_sh[feat].dropna()
728
+ sd = float(np.sqrt((ac.std() ** 2 + bc.std() ** 2) / 2))
729
+ seg_smds.append(abs(float(bc.mean() - ac.mean())) / max(sd, 1e-9))
730
+ seg_ws.append(len(g_sh))
731
+ within_smd = (float(np.average(seg_smds, weights=seg_ws))
732
+ if seg_smds else smd)
733
+
734
+ q_off = a_clean.quantile([0.05, 0.5, 0.95])
735
+ q_sh = b_clean.quantile([0.05, 0.5, 0.95])
736
+ median_ratio = float(q_sh[0.5] / q_off[0.5]) if q_off[0.5] else float("nan")
737
+ miss_off = float(a.isna().mean())
738
+ miss_sh = float(b.isna().mean())
739
+ skew = within_smd >= 1.0
740
+ rows.append({"feature": feat,
741
+ "offline_mean": float(a_clean.mean()),
742
+ "shadow_mean": float(b_clean.mean()),
743
+ "offline_p5": float(q_off[0.05]), "offline_p50": float(q_off[0.5]),
744
+ "offline_p95": float(q_off[0.95]),
745
+ "shadow_p5": float(q_sh[0.05]), "shadow_p50": float(q_sh[0.5]),
746
+ "shadow_p95": float(q_sh[0.95]),
747
+ "smd": smd, "within_segment_smd": within_smd,
748
+ "median_ratio": median_ratio,
749
+ "offline_missing": miss_off, "shadow_missing": miss_sh,
750
+ "missing_delta": miss_sh - miss_off,
751
+ "skew_flag": bool(skew)})
752
+
753
+ rows.sort(key=lambda r: r["within_segment_smd"], reverse=True)
754
+ for r in rows:
755
+ if r["skew_flag"]:
756
+ findings.append(make_finding(
757
+ stage, f"FP_FEATURE_SKEW:{r['feature']}",
758
+ f"Training-serving skew on {r['feature']}",
759
+ "MISMATCH", "CRITICAL",
760
+ f"{r['feature']}: shadow mean {r['shadow_mean']:.2f} vs offline "
761
+ f"{r['offline_mean']:.2f} (median ratio {r['median_ratio']:.2f}, "
762
+ f"within-segment SMD {r['within_segment_smd']:.2f}). The value "
763
+ "distribution differs INSIDE matched segments, so this is a "
764
+ "pipeline difference (unit/normalization), not a population "
765
+ "change.",
766
+ data={"feature": r["feature"],
767
+ "median_ratio": r["median_ratio"],
768
+ "within_segment_smd": r["within_segment_smd"]}))
769
+ elif abs(r["missing_delta"]) > 0.02:
770
+ findings.append(make_finding(
771
+ stage, f"FP_MISSINGNESS_DELTA:{r['feature']}",
772
+ f"Missingness differs on {r['feature']}", "MISMATCH", "WARN",
773
+ f"Missing offline {r['offline_missing']:.1%} vs shadow "
774
+ f"{r['shadow_missing']:.1%}."))
775
+ if not any(f.status == "MISMATCH" and f.severity == "CRITICAL"
776
+ for f in findings):
777
+ findings.append(make_finding(
778
+ stage, "FP_CLEAN", "No training-serving feature skew", "PASS", "INFO",
779
+ "All features have within-segment SMD < 1.0 and stable "
780
+ "missingness."))
781
+
782
+ data = {"rows": rows,
783
+ "explainer": ("Per-feature offline vs shadow deltas ranked by "
784
+ "WITHIN-SEGMENT standardized mean difference -- the "
785
+ "statistic that separates 'the world changed' from "
786
+ "'the pipeline computes this feature differently "
787
+ "online'.")}
788
+ return data, findings
789
+
790
+
791
+ # ==================================================== stage 8: serving parity
792
+
793
+ _SERVING_FIELDS = [
794
+ ("confidence_threshold", "Confidence threshold", "CRITICAL"),
795
+ ("quantization", "Quantization / precision", "CRITICAL"),
796
+ ("runtime_version", "Runtime version", "WARN"),
797
+ ("nms_iou", "NMS IoU threshold", "WARN"),
798
+ ("feature_pipeline_version", "Feature pipeline version", "CRITICAL"),
799
+ ]
800
+
801
+
802
+ def serving_parity(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
803
+ stage = "serving_parity"
804
+ findings: List[Finding] = []
805
+ off_cfg, sh_cfg = bundle.meta["offline_config"], bundle.meta["shadow_config"]
806
+
807
+ rows = []
808
+ for field, label, crit in _SERVING_FIELDS:
809
+ ov, sv = off_cfg.get(field), sh_cfg.get(field)
810
+ if ov is None or sv is None:
811
+ status = "unknown"
812
+ findings.append(make_finding(
813
+ stage, f"SP_UNKNOWN:{field}", f"{label} unrecorded in shadow",
814
+ "UNKNOWN", crit,
815
+ f"Offline={ov!r}, shadow={sv!r}. Serving parity cannot be "
816
+ "certified while this artifact/config is unrecorded."))
817
+ elif ov != sv:
818
+ status = "mismatch"
819
+ findings.append(make_finding(
820
+ stage, f"SP_CONFIG_DIFF:{field}", f"{label} differs in serving",
821
+ "MISMATCH", crit,
822
+ f"Offline={ov!r} vs shadow={sv!r}. The shadow stack is not "
823
+ "running the model the offline harness evaluated.",
824
+ data={"offline": ov, "shadow": sv}))
825
+ else:
826
+ status = "match"
827
+ rows.append({"field": field, "label": label, "offline": ov,
828
+ "shadow": sv, "status": status, "criticality": crit})
829
+
830
+ if not findings:
831
+ findings.append(make_finding(
832
+ stage, "SP_CLEAN", "Serving config at parity", "PASS", "INFO",
833
+ "All recorded serving artifacts/configs match the offline harness."))
834
+
835
+ data = {"rows": rows,
836
+ "explainer": ("Artifact/config diff between the offline harness "
837
+ "and the shadow serving stack: threshold, precision "
838
+ "mode, runtime, NMS, feature pipeline.")}
839
+ return data, findings
840
+
841
+
842
+ # ================================================== stage 9: shadow traffic
843
+
844
+
845
+ def shadow_traffic(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
846
+ stage = "shadow_traffic"
847
+ findings: List[Finding] = []
848
+ sh = bundle.shadow
849
+ scored = _scored(bundle)
850
+ unsampled = sh[~sh["sampled"]]
851
+ traffic = dict(bundle.meta["traffic"])
852
+
853
+ diff_psi = st.psi_continuous(sh["difficulty"].to_numpy(),
854
+ scored["difficulty"].to_numpy())
855
+ conf_psi = st.psi_continuous(sh["a_conf"].to_numpy(),
856
+ scored["a_conf"].to_numpy())
857
+ seg_psi = st.psi_categorical(sh["time_of_day"], scored["time_of_day"])
858
+ sampled_delta = _delta_pp(scored)
859
+ eligible_delta = _delta_pp(sh)
860
+ unsampled_delta = _delta_pp(unsampled)
861
+ gap = abs(sampled_delta - unsampled_delta)
862
+
863
+ drop_rate = (traffic["dropped_count"] + traffic["fallback_count"]
864
+ + traffic["timeout_count"]) / max(1, traffic["eligible_count"])
865
+
866
+ if diff_psi >= 0.1 or conf_psi >= 0.1 or (gap >= 4.0 and diff_psi >= 0.03):
867
+ findings.append(make_finding(
868
+ stage, "ST_SELECTION_BIAS", "Shadow sample is selection-biased",
869
+ "MISMATCH", "CRITICAL",
870
+ f"Scored sample differs from the eligible stream: difficulty "
871
+ f"PSI={diff_psi:.2f}, confidence PSI={conf_psi:.2f}. Sampled delta "
872
+ f"{sampled_delta:+.1f}pp vs unsampled {unsampled_delta:+.1f}pp "
873
+ f"(eligible stream {eligible_delta:+.1f}pp). The -2% is a "
874
+ "statement about the sampler, not the traffic.",
875
+ data={"difficulty_psi": diff_psi, "conf_psi": conf_psi,
876
+ "sampled_delta_pp": sampled_delta,
877
+ "unsampled_delta_pp": unsampled_delta,
878
+ "eligible_delta_pp": eligible_delta,
879
+ "sampler": traffic.get("sampler")}))
880
+ else:
881
+ findings.append(make_finding(
882
+ stage, "ST_SAMPLE_FAIR", "Scored sample matches eligible stream",
883
+ "PASS", "INFO",
884
+ f"Difficulty PSI={diff_psi:.3f}, confidence PSI={conf_psi:.3f}, "
885
+ f"sampled vs unsampled delta gap {gap:.1f}pp."))
886
+
887
+ if drop_rate > 0.05:
888
+ findings.append(make_finding(
889
+ stage, "ST_DROPS_HIGH", "High dropped/fallback/timeout rate",
890
+ "MISMATCH", "WARN",
891
+ f"{drop_rate:.1%} of eligible traffic was dropped, timed out, or "
892
+ "ran the fallback stack -- those frames never enter the shadow "
893
+ "metric.",
894
+ data={"drop_rate": drop_rate}))
895
+
896
+ data = {"traffic": traffic,
897
+ "selection": {"difficulty_psi": diff_psi, "conf_psi": conf_psi,
898
+ "segment_psi": seg_psi,
899
+ "sampled_delta_pp": sampled_delta,
900
+ "unsampled_delta_pp": unsampled_delta,
901
+ "eligible_delta_pp": eligible_delta,
902
+ "gap_pp": gap, "drop_rate": drop_rate},
903
+ "explainer": ("Audit of what the shadow sampler actually scored: "
904
+ "sampling rate, eligibility filters, drops, and "
905
+ "whether the scored sample statistically matches "
906
+ "the eligible stream.")}
907
+ return data, findings
908
+
909
+
910
+ # ================================================ stage 10: label integrity
911
+
912
+
913
+ def label_integrity(bundle: ScenarioBundle) -> Tuple[Dict, List[Finding]]:
914
+ stage = "label_integrity"
915
+ findings: List[Finding] = []
916
+ scored = _scored(bundle)
917
+ maturity_h = float(bundle.meta.get("label_maturity_hours", 72.0))
918
+ off_cfg, sh_cfg = bundle.meta["offline_config"], bundle.meta["shadow_config"]
919
+
920
+ prov = scored["label_is_provisional"] == True # noqa: E712 (CSV round-trip)
921
+ prov_frac = float(prov.mean())
922
+ mature = scored[~prov]
923
+ provisional = scored[prov]
924
+ mature_delta = _delta_pp(mature)
925
+ prov_delta = _delta_pp(provisional)
926
+
927
+ ages = scored["label_age_hours"]
928
+ hist_edges = [0, 24, 48, 72, 120, 240, 480]
929
+ hist = []
930
+ for lo, hi in zip(hist_edges[:-1], hist_edges[1:]):
931
+ hist.append({"bucket": f"{lo}-{hi}h",
932
+ "count": int(((ages >= lo) & (ages < hi)).sum())})
933
+ hist.append({"bucket": f">{hist_edges[-1]}h",
934
+ "count": int((ages >= hist_edges[-1]).sum())})
935
+
936
+ by_quartile = []
937
+ q = pd.qcut(scored["difficulty"], 4, labels=["q1", "q2", "q3", "q4"],
938
+ duplicates="drop")
939
+ for label, g in scored.groupby(q, observed=False):
940
+ gp = g[g["label_is_provisional"] == True] # noqa: E712
941
+ by_quartile.append({
942
+ "quartile": str(label),
943
+ "provisional_frac": float((g["label_is_provisional"] == True).mean()),
944
+ "delta_pp": _delta_pp(g),
945
+ "provisional_delta_pp": _delta_pp(gp) if len(gp) >= 30 else None,
946
+ "n": int(len(g))})
947
+
948
+ policy_diff = off_cfg.get("label_policy_version") != sh_cfg.get("label_policy_version")
949
+
950
+ if prov_frac > 0.2:
951
+ findings.append(make_finding(
952
+ stage, "LI_PROVISIONAL_HIGH", "Large provisional-label fraction",
953
+ "MISMATCH", "WARN",
954
+ f"{prov_frac:.0%} of scored shadow units are graded against "
955
+ f"provisional labels younger than {maturity_h:.0f}h.",
956
+ data={"provisional_fraction": prov_frac}))
957
+ if prov_frac > 0.1 and len(mature) > 200 and (mature_delta - prov_delta) > 4.0:
958
+ findings.append(make_finding(
959
+ stage, "LI_MATURE_DIVERGES",
960
+ "Verdict flips on mature labels",
961
+ "MISMATCH", "CRITICAL",
962
+ f"On mature labels the paired delta is {mature_delta:+.1f}pp; on "
963
+ f"provisional labels it is {prov_delta:+.1f}pp. The -2% is a "
964
+ "property of the provisional labels, not of the model.",
965
+ data={"mature_delta_pp": mature_delta,
966
+ "provisional_delta_pp": prov_delta}))
967
+ if len(by_quartile) == 4:
968
+ q1, q4 = by_quartile[0], by_quartile[-1]
969
+ if (q1["provisional_delta_pp"] is not None
970
+ and q4["provisional_delta_pp"] is not None
971
+ and (q1["provisional_delta_pp"] - q4["provisional_delta_pp"]) > 6.0):
972
+ findings.append(make_finding(
973
+ stage, "LI_DIFFICULTY_CORRELATED",
974
+ "Provisional-label penalty concentrates on hard cases",
975
+ "MISMATCH", "WARN",
976
+ f"Provisional-subset delta falls from "
977
+ f"{q1['provisional_delta_pp']:+.1f}pp (easiest quartile) to "
978
+ f"{q4['provisional_delta_pp']:+.1f}pp (hardest) -- the classic "
979
+ "signature of pseudo-labels that are wrong on hard cases."))
980
+ if policy_diff:
981
+ findings.append(make_finding(
982
+ stage, "LI_POLICY_DIFF", "Labeling policy version differs",
983
+ "MISMATCH", "WARN",
984
+ f"Offline: {off_cfg.get('label_policy_version')!r} vs shadow: "
985
+ f"{sh_cfg.get('label_policy_version')!r}."))
986
+ if not findings:
987
+ findings.append(make_finding(
988
+ stage, "LI_CLEAN", "Labels look trustworthy", "PASS", "INFO",
989
+ f"Provisional fraction {prov_frac:.1%}; mature-label delta "
990
+ f"({mature_delta:+.1f}pp) consistent with the overall shadow "
991
+ "verdict; same labeling policy."))
992
+
993
+ data = {"provisional_fraction": prov_frac,
994
+ "maturity_hours": maturity_h,
995
+ "age_histogram": hist,
996
+ "mature_delta_pp": mature_delta,
997
+ "provisional_delta_pp": prov_delta,
998
+ "mature_n": int(len(mature)), "provisional_n": int(len(provisional)),
999
+ "by_difficulty_quartile": by_quartile,
1000
+ "policy": {"offline": off_cfg.get("label_policy_version"),
1001
+ "shadow": sh_cfg.get("label_policy_version"),
1002
+ "differs": bool(policy_diff)},
1003
+ "explainer": ("Are shadow 'ground truth' labels actually ground "
1004
+ "truth? Label age distribution, provisional "
1005
+ "fraction, difficulty correlation, and the "
1006
+ "mature-vs-provisional verdict split.")}
1007
+ return data, findings
1008
+
1009
+
1010
+ # ------------------------------------------------------------------ registry
1011
+
1012
+ STAGE_DIAGNOSTICS = {
1013
+ "comparison_validity": comparison_validity,
1014
+ "offline_audit": offline_audit,
1015
+ "population_validation": population_validation,
1016
+ "distribution_shift": distribution_shift,
1017
+ "conditional_performance": conditional_performance,
1018
+ "paired_comparison": paired_comparison,
1019
+ "statistical_significance": statistical_significance,
1020
+ "feature_parity": feature_parity,
1021
+ "serving_parity": serving_parity,
1022
+ "shadow_traffic": shadow_traffic,
1023
+ "label_integrity": label_integrity,
1024
+ }
1025
+
1026
+
1027
+ def run_all(bundle: ScenarioBundle) -> Dict[str, Tuple[Dict, List[Finding]]]:
1028
+ """Run the full diagnostic battery (used by scoring, reports, tests)."""
1029
+ return {key: fn(bundle) for key, fn in STAGE_DIAGNOSTICS.items()}
sensorflow/rca/models.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core RCA entities: stages, findings, and the Investigation state machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Dict, List, Optional
9
+
10
+ # --------------------------------------------------------------------- stages
11
+
12
+ STAGES: List[Dict] = [
13
+ {"index": 0, "key": "comparison_validity", "title": "Comparison Validity",
14
+ "question": "Are the offline and shadow numbers even measuring the same thing?"},
15
+ {"index": 1, "key": "offline_audit", "title": "Offline Audit",
16
+ "question": "Can the offline +5% be reproduced, and is the eval set leak-free?"},
17
+ {"index": 2, "key": "population_validation", "title": "Population Validation",
18
+ "question": "Do the two evaluations see comparable populations and volumes?"},
19
+ {"index": 3, "key": "distribution_shift", "title": "Distribution Shift",
20
+ "question": "How different are the offline and shadow data distributions?"},
21
+ {"index": 4, "key": "conditional_performance", "title": "Conditional Performance",
22
+ "question": "Where (which segments) does the delta live in each environment?"},
23
+ {"index": 5, "key": "paired_comparison", "title": "Paired Model Comparison",
24
+ "question": "On the same units, which predictions flipped between A and B?"},
25
+ {"index": 6, "key": "statistical_significance", "title": "Statistical Significance",
26
+ "question": "Is the shadow regression distinguishable from noise, cluster-aware?"},
27
+ {"index": 7, "key": "feature_parity", "title": "Feature Parity",
28
+ "question": "Do the models see the same feature values online as offline?"},
29
+ {"index": 8, "key": "serving_parity", "title": "Serving Parity",
30
+ "question": "Is the serving configuration identical to the offline harness?"},
31
+ {"index": 9, "key": "shadow_traffic", "title": "Shadow Traffic Audit",
32
+ "question": "Is shadow traffic a fair sample, or biased by selection/drops?"},
33
+ {"index": 10, "key": "label_integrity", "title": "Label / Ground-Truth Integrity",
34
+ "question": "Are shadow labels mature, unbiased, and on the same policy?"},
35
+ {"index": 11, "key": "root_cause_scoring", "title": "Root Cause Scoring + Decision",
36
+ "question": "Which hypotheses does the accumulated evidence support?"},
37
+ {"index": 12, "key": "recommendations_report", "title": "Experiments + Report",
38
+ "question": "What is the minimum additional evidence needed, and the verdict?"},
39
+ ]
40
+
41
+ STAGE_KEYS = [s["key"] for s in STAGES]
42
+
43
+ STAGE_STATUS = ("pending", "in_progress", "complete", "complete_with_unknowns")
44
+
45
+ # ---------------------------------------------------------------- root causes
46
+
47
+ ROOT_CAUSES = (
48
+ "TRUE_MODEL_REGRESSION",
49
+ "DISTRIBUTION_SHIFT",
50
+ "FEATURE_SKEW",
51
+ "SERVING_MISMATCH",
52
+ "LABEL_LATENCY",
53
+ "SAMPLING_BIAS",
54
+ "STATISTICAL_NOISE",
55
+ "OFFLINE_CONTAMINATION",
56
+ )
57
+
58
+ HYPOTHESIS_LABELS: Dict[str, str] = {
59
+ "TRUE_MODEL_REGRESSION": "Candidate genuinely regresses on production data",
60
+ "DISTRIBUTION_SHIFT": "Production population differs from the offline eval set",
61
+ "FEATURE_SKEW": "Online/offline feature pipeline mismatch (training-serving skew)",
62
+ "SERVING_MISMATCH": "Shadow serving config differs (threshold / quantization / runtime)",
63
+ "LABEL_LATENCY": "Shadow labels immature / provisional and biased",
64
+ "SAMPLING_BIAS": "Shadow traffic selection is not a fair sample",
65
+ "STATISTICAL_NOISE": "Deltas are within noise; effective sample too small",
66
+ "OFFLINE_CONTAMINATION": "Offline +5% inflated by train/eval leakage",
67
+ }
68
+
69
+ FINDING_STATUS = ("PASS", "MISMATCH", "UNKNOWN")
70
+ SEVERITIES = ("INFO", "WARN", "CRITICAL")
71
+
72
+
73
+ # ------------------------------------------------------------------- findings
74
+
75
+
76
+ @dataclass
77
+ class Finding:
78
+ """One structured evidence record produced by a stage."""
79
+
80
+ id: str
81
+ stage: str # stage key
82
+ code: str # stable machine code, e.g. FP_FEATURE_SKEW:obj_distance_m
83
+ title: str
84
+ status: str # PASS / MISMATCH / UNKNOWN
85
+ severity: str # INFO / WARN / CRITICAL
86
+ detail: str = ""
87
+ source: str = "auto" # auto / human
88
+ created_at: float = field(default_factory=time.time)
89
+ data: Dict = field(default_factory=dict)
90
+
91
+ def to_dict(self) -> Dict:
92
+ return asdict(self)
93
+
94
+
95
+ def make_finding(stage: str, code: str, title: str, status: str, severity: str,
96
+ detail: str = "", data: Optional[Dict] = None,
97
+ source: str = "auto") -> Finding:
98
+ assert status in FINDING_STATUS, status
99
+ assert severity in SEVERITIES, severity
100
+ return Finding(id=f"fnd-{uuid.uuid4().hex[:10]}", stage=stage, code=code,
101
+ title=title, status=status, severity=severity, detail=detail,
102
+ data=data or {}, source=source)
103
+
104
+
105
+ # --------------------------------------------------------------- investigation
106
+
107
+
108
+ class StageOrderError(Exception):
109
+ """Raised when the enforced stage ordering is violated."""
110
+
111
+
112
+ class UnknownsNotAcknowledgedError(Exception):
113
+ """Raised when completing a stage with critical UNKNOWNs without an ack."""
114
+
115
+
116
+ @dataclass
117
+ class StageState:
118
+ index: int
119
+ key: str
120
+ title: str
121
+ status: str = "pending"
122
+ completed_at: Optional[float] = None
123
+ acknowledged_unknowns: bool = False
124
+ ack_note: str = ""
125
+ skip_acknowledged: bool = False
126
+
127
+ def to_dict(self) -> Dict:
128
+ return asdict(self)
129
+
130
+
131
+ @dataclass
132
+ class Investigation:
133
+ id: str
134
+ name: str
135
+ baseline_model: str
136
+ candidate_model: str
137
+ scenario_cause: str # the planted truth (hidden in training mode)
138
+ seed: int
139
+ training_mode: bool = False
140
+ revealed: bool = False
141
+ created_at: float = field(default_factory=time.time)
142
+ claims: Dict = field(default_factory=dict)
143
+ stages: List[StageState] = field(default_factory=list)
144
+ findings: List[Finding] = field(default_factory=list)
145
+ # human overrides on the scoring board: {hypothesis: {confidence, note}}
146
+ human_assessments: Dict[str, Dict] = field(default_factory=dict)
147
+ events: List[Dict] = field(default_factory=list)
148
+
149
+ # ----------------------------------------------------------- construction
150
+
151
+ @classmethod
152
+ def new(cls, name: str, baseline_model: str, candidate_model: str,
153
+ scenario_cause: str, seed: int, training_mode: bool = False,
154
+ claims: Optional[Dict] = None) -> "Investigation":
155
+ inv = cls(
156
+ id=f"inv-{uuid.uuid4().hex[:8]}",
157
+ name=name,
158
+ baseline_model=baseline_model,
159
+ candidate_model=candidate_model,
160
+ scenario_cause=scenario_cause,
161
+ seed=seed,
162
+ training_mode=training_mode,
163
+ claims=claims or {},
164
+ stages=[StageState(index=s["index"], key=s["key"], title=s["title"])
165
+ for s in STAGES],
166
+ )
167
+ inv.stages[0].status = "in_progress"
168
+ inv.log("created", f"Investigation created ({name})")
169
+ return inv
170
+
171
+ # ---------------------------------------------------------------- helpers
172
+
173
+ def log(self, kind: str, message: str, data: Optional[Dict] = None) -> None:
174
+ self.events.append({"ts": time.time(), "kind": kind, "message": message,
175
+ "data": data or {}})
176
+
177
+ def stage_by_key(self, key: str) -> StageState:
178
+ for st in self.stages:
179
+ if st.key == key:
180
+ return st
181
+ raise KeyError(f"Unknown stage {key}")
182
+
183
+ def findings_for(self, stage_key: str) -> List[Finding]:
184
+ return [f for f in self.findings if f.stage == stage_key]
185
+
186
+ def critical_unknowns(self, stage_key: str) -> List[Finding]:
187
+ return [f for f in self.findings_for(stage_key)
188
+ if f.status == "UNKNOWN" and f.severity == "CRITICAL"]
189
+
190
+ def upsert_auto_findings(self, stage_key: str, findings: List[Finding]) -> None:
191
+ """Replace prior auto findings for a stage (recorded human ones kept)."""
192
+ self.findings = [f for f in self.findings
193
+ if not (f.stage == stage_key and f.source == "auto")]
194
+ self.findings.extend(findings)
195
+
196
+ def add_human_finding(self, finding: Finding) -> None:
197
+ self.findings.append(finding)
198
+ self.log("finding_recorded", f"Human finding recorded on {finding.stage}",
199
+ {"code": finding.code})
200
+
201
+ # ------------------------------------------------------------ state machine
202
+
203
+ def complete_stage(self, index: int, acknowledge_unknowns: bool = False,
204
+ ack_note: str = "") -> StageState:
205
+ """Complete a stage. Ordering is enforced: every earlier stage must be
206
+ complete (possibly with acknowledged unknowns). Critical UNKNOWN
207
+ findings block completion unless explicitly acknowledged, and the
208
+ acknowledgment is recorded on the stage and in the event log."""
209
+ if not 0 <= index < len(self.stages):
210
+ raise KeyError(f"No stage {index}")
211
+ stage = self.stages[index]
212
+ for earlier in self.stages[:index]:
213
+ if earlier.status not in ("complete", "complete_with_unknowns"):
214
+ raise StageOrderError(
215
+ f"Stage {index} ({stage.title}) cannot complete before stage "
216
+ f"{earlier.index} ({earlier.title}) is complete. The methodology "
217
+ "is ordered on purpose: early plausible explanations do not "
218
+ "excuse skipping measurement-validity checks.")
219
+
220
+ unknowns = self.critical_unknowns(stage.key)
221
+ if unknowns and not acknowledge_unknowns:
222
+ raise UnknownsNotAcknowledgedError(
223
+ f"Stage {stage.title} has {len(unknowns)} critical UNKNOWN "
224
+ "finding(s). Resolve them or explicitly acknowledge proceeding "
225
+ "with unknowns.")
226
+
227
+ if unknowns:
228
+ stage.status = "complete_with_unknowns"
229
+ stage.acknowledged_unknowns = True
230
+ stage.ack_note = ack_note or "proceeding with unknowns"
231
+ self.log("unknowns_acknowledged",
232
+ f"Proceeding with {len(unknowns)} unknown(s) on {stage.title}",
233
+ {"codes": [f.code for f in unknowns], "note": stage.ack_note})
234
+ else:
235
+ stage.status = "complete"
236
+ stage.completed_at = time.time()
237
+
238
+ nxt = index + 1
239
+ if nxt < len(self.stages) and self.stages[nxt].status == "pending":
240
+ self.stages[nxt].status = "in_progress"
241
+ self.log("stage_completed", f"Stage {stage.title} -> {stage.status}")
242
+ return stage
243
+
244
+ def reopen_stage(self, index: int) -> StageState:
245
+ stage = self.stages[index]
246
+ stage.status = "in_progress"
247
+ stage.completed_at = None
248
+ self.log("stage_reopened", f"Stage {stage.title} reopened")
249
+ return stage
250
+
251
+ # ------------------------------------------------------------- serialization
252
+
253
+ def to_dict(self, include_cause: bool = False) -> Dict:
254
+ d = {
255
+ "id": self.id,
256
+ "name": self.name,
257
+ "baseline_model": self.baseline_model,
258
+ "candidate_model": self.candidate_model,
259
+ "seed": self.seed,
260
+ "training_mode": self.training_mode,
261
+ "revealed": self.revealed,
262
+ "created_at": self.created_at,
263
+ "claims": self.claims,
264
+ "stages": [s.to_dict() for s in self.stages],
265
+ "findings": [f.to_dict() for f in self.findings],
266
+ "human_assessments": self.human_assessments,
267
+ "events": self.events[-200:],
268
+ }
269
+ # The planted cause is only exposed for non-training investigations or
270
+ # once the user reveals it.
271
+ if include_cause or (not self.training_mode) or self.revealed:
272
+ d["scenario_cause"] = self.scenario_cause
273
+ return d
274
+
275
+ def to_json_dict(self) -> Dict:
276
+ """Full-fidelity dict for persistence (always includes the cause)."""
277
+ d = self.to_dict(include_cause=True)
278
+ d["events"] = self.events
279
+ return d
280
+
281
+ @classmethod
282
+ def from_json_dict(cls, d: Dict) -> "Investigation":
283
+ inv = cls(
284
+ id=d["id"], name=d["name"], baseline_model=d["baseline_model"],
285
+ candidate_model=d["candidate_model"],
286
+ scenario_cause=d["scenario_cause"], seed=d["seed"],
287
+ training_mode=d.get("training_mode", False),
288
+ revealed=d.get("revealed", False),
289
+ created_at=d.get("created_at", time.time()),
290
+ claims=d.get("claims", {}),
291
+ stages=[StageState(**s) for s in d.get("stages", [])],
292
+ findings=[Finding(**f) for f in d.get("findings", [])],
293
+ human_assessments=d.get("human_assessments", {}),
294
+ events=d.get("events", []),
295
+ )
296
+ return inv
sensorflow/rca/report.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final investigation report: markdown + JSON assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Dict, List, Optional
7
+
8
+ from sensorflow.rca import diagnostics as dg, scoring
9
+ from sensorflow.rca.models import (HYPOTHESIS_LABELS, Investigation, STAGES)
10
+ from sensorflow.rca.scenario import ScenarioBundle
11
+
12
+ _REMEDIATION: Dict[str, Dict[str, List[str]]] = {
13
+ "TRUE_MODEL_REGRESSION": {
14
+ "containment": ["Halt the rollout / keep B out of the serving path",
15
+ "Pin A as the serving model; keep B in shadow only"],
16
+ "short_term": ["Slice the regression by segment and error type to "
17
+ "find what B lost",
18
+ "Fine-tune B on recent-window data covering the "
19
+ "regressing segments"],
20
+ "long_term": ["Add a recent-window eval set to the offline gate",
21
+ "Automate freshness checks on eval data before any "
22
+ "launch decision"],
23
+ },
24
+ "DISTRIBUTION_SHIFT": {
25
+ "containment": ["Do not treat either aggregate as a launch signal; "
26
+ "gate on segment-level deltas"],
27
+ "short_term": ["Reweight offline eval to the production mix and "
28
+ "re-decide", "Target B's night/urban weakness with "
29
+ "focused training data"],
30
+ "long_term": ["Continuously monitor production mix vs eval mix (PSI "
31
+ "alerting)", "Stratified launch gates per segment"],
32
+ },
33
+ "FEATURE_SKEW": {
34
+ "containment": ["Freeze the rollout; the online model is consuming "
35
+ "corrupted features"],
36
+ "short_term": ["Fix the unit/normalization difference in the online "
37
+ "feature pipeline; add a unit test on the skewed "
38
+ "feature", "Re-run shadow after the fix"],
39
+ "long_term": ["Continuous training-serving feature parity monitoring "
40
+ "with per-feature SMD alerts",
41
+ "Version-pin and log feature pipeline versions in both "
42
+ "environments"],
43
+ },
44
+ "SERVING_MISMATCH": {
45
+ "containment": ["Align shadow serving config (threshold, precision) "
46
+ "with the evaluated artifact before reading any "
47
+ "shadow number"],
48
+ "short_term": ["Re-run shadow with the offline config; if the gap "
49
+ "closes, ship config alignment",
50
+ "Calibrate B's confidence to the serving threshold"],
51
+ "long_term": ["Config parity checks in CI between eval harness and "
52
+ "serving stack", "One artifact, one config: promote the "
53
+ "exact evaluated bundle"],
54
+ },
55
+ "LABEL_LATENCY": {
56
+ "containment": ["Suspend shadow-based verdicts until labels mature"],
57
+ "short_term": ["Re-grade the frozen cohort post-maturity",
58
+ "Exclude provisional labels from headline metrics"],
59
+ "long_term": ["Separate provisional and mature metrics in dashboards",
60
+ "Track label-age distribution as a first-class metric"],
61
+ },
62
+ "SAMPLING_BIAS": {
63
+ "containment": ["Stop reading the triage-sampled stream as an "
64
+ "unbiased estimate"],
65
+ "short_term": ["Add a parallel uniform sample for measurement",
66
+ "Reweight existing sample by inclusion probability "
67
+ "(if logged)"],
68
+ "long_term": ["Log inclusion probabilities for every sampled unit",
69
+ "Keep measurement sampling separate from triage "
70
+ "sampling"],
71
+ },
72
+ "STATISTICAL_NOISE": {
73
+ "containment": ["Make no launch decision on current evidence; both "
74
+ "+5% and -2% are within noise"],
75
+ "short_term": ["Extend collection to the pre-computed effective-n "
76
+ "target", "Use paired, cluster-aware tests only"],
77
+ "long_term": ["Pre-register power calculations before shadow "
78
+ "experiments", "Adopt anytime-valid sequential tests "
79
+ "(seqeval) to allow early stopping without p-hacking"],
80
+ },
81
+ "OFFLINE_CONTAMINATION": {
82
+ "containment": ["Retract the +5% claim; do not use the current "
83
+ "offline set for any decision"],
84
+ "short_term": ["Rebuild the eval set with entity-level dedup against "
85
+ "B's training data; re-run both models",
86
+ "Pin versions and record environment locks"],
87
+ "long_term": ["Automated leakage scans in the eval-set build "
88
+ "pipeline", "Entity-level splitting enforced by "
89
+ "tooling, not convention"],
90
+ },
91
+ "NO_PRACTICAL_REGRESSION": {
92
+ "containment": ["No containment needed"],
93
+ "short_term": ["Proceed with a guarded rollout with segment-level "
94
+ "monitoring"],
95
+ "long_term": ["Tighten practical-margin definitions per surface"],
96
+ },
97
+ }
98
+
99
+ _STAGE_HEADLINE = {
100
+ "comparison_validity": "Are the two numbers commensurable?",
101
+ "offline_audit": "Is the +5% real and reproducible?",
102
+ "population_validation": "Enough volume, comparable populations?",
103
+ "distribution_shift": "Did the world move between the two evals?",
104
+ "conditional_performance": "Where does the delta live?",
105
+ "paired_comparison": "Which units actually flipped?",
106
+ "statistical_significance": "Is -2% distinguishable from noise?",
107
+ "feature_parity": "Same features online and offline?",
108
+ "serving_parity": "Same config online and offline?",
109
+ "shadow_traffic": "Is the scored sample fair?",
110
+ "label_integrity": "Is shadow ground truth actually true?",
111
+ }
112
+
113
+
114
+ def build_report(bundle: ScenarioBundle, inv: Investigation,
115
+ battery: Optional[Dict] = None) -> Dict:
116
+ battery = battery or dg.run_all(bundle)
117
+ scoreboard = scoring.build_scoreboard(bundle, inv, battery=battery)
118
+ tree = scoring.evaluate_decision_tree(bundle, battery=battery)
119
+ experiments = scoring.recommend_experiments(bundle, scoreboard, battery)
120
+
121
+ top = scoreboard["rows"][0]
122
+ conclusion = tree["conclusion"]
123
+ remediation = _REMEDIATION.get(conclusion or top["hypothesis"],
124
+ _REMEDIATION[top["hypothesis"]])
125
+
126
+ stage_summaries = []
127
+ for key, (data, findings) in battery.items():
128
+ mism = [f for f in findings if f.status == "MISMATCH"]
129
+ unk = [f for f in findings if f.status == "UNKNOWN"]
130
+ stage_summaries.append({
131
+ "stage": key,
132
+ "headline": _STAGE_HEADLINE.get(key, key),
133
+ "mismatches": [f.title for f in mism],
134
+ "unknowns": [f.title for f in unk],
135
+ "n_pass": sum(1 for f in findings if f.status == "PASS"),
136
+ "verdict": ("mismatch" if mism else
137
+ "unknown" if unk else "clean"),
138
+ })
139
+
140
+ acked = [e for e in inv.events if e["kind"] == "unknowns_acknowledged"]
141
+
142
+ payload = {
143
+ "investigation_id": inv.id,
144
+ "name": inv.name,
145
+ "generated_at": time.time(),
146
+ "models": {"baseline": inv.baseline_model,
147
+ "candidate": inv.candidate_model},
148
+ "claims": inv.claims,
149
+ "executive_finding": {
150
+ "conclusion": conclusion,
151
+ "conclusion_kind": tree["conclusion_kind"],
152
+ "top_hypothesis": top["hypothesis"],
153
+ "label": HYPOTHESIS_LABELS.get(top["hypothesis"], top["hypothesis"]),
154
+ "confidence": top["auto_confidence"],
155
+ "score": top["score"],
156
+ "score_gap_to_runner_up": scoreboard["score_gap"],
157
+ },
158
+ "stage_summaries": stage_summaries,
159
+ "hypothesis_ranking": [
160
+ {"rank": r["rank"], "hypothesis": r["hypothesis"],
161
+ "score": r["score"], "confidence": r["auto_confidence"],
162
+ "human_confidence": r["human_confidence"],
163
+ "evidence_for": len(r["evidence_for"]),
164
+ "evidence_against": len(r["evidence_against"])}
165
+ for r in scoreboard["rows"]],
166
+ "decision_path": tree["path"],
167
+ "minimum_additional_evidence": experiments["minimum_additional_evidence"],
168
+ "recommended_experiments": experiments["experiments"],
169
+ "remediation": remediation,
170
+ "acknowledged_unknowns": acked,
171
+ "human_findings": [f.to_dict() for f in inv.findings
172
+ if f.source == "human"],
173
+ }
174
+ payload["markdown"] = _to_markdown(payload, inv)
175
+ return payload
176
+
177
+
178
+ def _to_markdown(p: Dict, inv: Investigation) -> str:
179
+ ef = p["executive_finding"]
180
+ lines = [
181
+ f"# RCA Report — {p['name']}",
182
+ "",
183
+ f"**Baseline:** `{p['models']['baseline']}` &nbsp; "
184
+ f"**Candidate:** `{p['models']['candidate']}`",
185
+ "",
186
+ f"**Claims under investigation:** offline "
187
+ f"{p['claims'].get('offline_delta_pp', '+5')}pp vs shadow "
188
+ f"{p['claims'].get('shadow_delta_pp', '-2')}pp "
189
+ f"({p['claims'].get('metric', 'accuracy')})",
190
+ "",
191
+ "## Executive finding",
192
+ "",
193
+ f"**{ef['conclusion'] or ef['top_hypothesis']}** — "
194
+ f"{ef['label']} (confidence: **{ef['confidence']}**, score "
195
+ f"{ef['score']}, gap to runner-up {ef['score_gap_to_runner_up']}).",
196
+ "",
197
+ "## Hypothesis ranking",
198
+ "",
199
+ "| # | Hypothesis | Score | Confidence | For | Against |",
200
+ "|---|-----------|-------|------------|-----|---------|",
201
+ ]
202
+ for r in p["hypothesis_ranking"]:
203
+ hc = f" (human: {r['human_confidence']})" if r["human_confidence"] else ""
204
+ lines.append(f"| {r['rank']} | {r['hypothesis']} | {r['score']} | "
205
+ f"{r['confidence']}{hc} | {r['evidence_for']} | "
206
+ f"{r['evidence_against']} |")
207
+
208
+ lines += ["", "## Stage summaries", ""]
209
+ for s in p["stage_summaries"]:
210
+ badge = {"mismatch": "MISMATCH", "unknown": "UNKNOWN",
211
+ "clean": "clean"}[s["verdict"]]
212
+ lines.append(f"- **{s['stage']}** ({s['headline']}) — {badge}")
213
+ for m in s["mismatches"]:
214
+ lines.append(f" - ⚠ {m}")
215
+ for u in s["unknowns"]:
216
+ lines.append(f" - ? {u}")
217
+
218
+ if p["acknowledged_unknowns"]:
219
+ lines += ["", "## Acknowledged unknowns", ""]
220
+ for e in p["acknowledged_unknowns"]:
221
+ lines.append(f"- {e['message']} — note: "
222
+ f"{e['data'].get('note', '')}")
223
+
224
+ lines += ["", "## Minimum additional evidence", "",
225
+ p["minimum_additional_evidence"], "",
226
+ "## Recommended experiments", ""]
227
+ for d in p["recommended_experiments"]:
228
+ lines.append(f"{d['rank']}. **{d['design']}** (cost: {d['cost']}, "
229
+ f"gain {d['information_gain']}) — {d['description']}")
230
+
231
+ lines += ["", "## Remediation", "", "**Containment (now):**"]
232
+ lines += [f"- {x}" for x in p["remediation"]["containment"]]
233
+ lines += ["", "**Short term:**"]
234
+ lines += [f"- {x}" for x in p["remediation"]["short_term"]]
235
+ lines += ["", "**Long term:**"]
236
+ lines += [f"- {x}" for x in p["remediation"]["long_term"]]
237
+ return "\n".join(lines)
sensorflow/rca/scenario.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic investigation scenario generator.
2
+
3
+ Produces a coherent offline + shadow dataset pair for models A (baseline) and
4
+ B (candidate) with a PLANTED root cause. The aggregate story is always the
5
+ same -- offline says B is ~+5pp better, shadow says B is ~-2pp worse -- but
6
+ the unit-level data carries the planted cause's downstream signatures, so the
7
+ diagnostic battery (not the generator) is what identifies the cause.
8
+
9
+ All randomness flows from a single seed (numpy default_rng), so scenarios are
10
+ deterministic and test-reproducible.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from sensorflow.rca.models import ROOT_CAUSES
22
+
23
+ SEGMENT_DIMS = ("scene", "time_of_day", "object_class")
24
+ SCENES = ("urban", "highway", "suburban")
25
+ TIMES = ("day", "dusk", "night")
26
+ CLASSES = ("vehicle", "pedestrian", "cyclist")
27
+
28
+ FEATURES = ("obj_distance_m", "ego_speed_mps", "occlusion_ratio",
29
+ "points_in_box", "track_age_s", "ambient_lux")
30
+
31
+ OFFLINE_MIX = {
32
+ "scene": {"urban": 0.45, "highway": 0.30, "suburban": 0.25},
33
+ "time_of_day": {"day": 0.62, "dusk": 0.24, "night": 0.14},
34
+ "object_class": {"vehicle": 0.60, "pedestrian": 0.25, "cyclist": 0.15},
35
+ }
36
+
37
+ # Shadow mix drifts slightly from offline for every cause (production is never
38
+ # identical); DISTRIBUTION_SHIFT replaces this with a heavy night/urban tilt.
39
+ SHADOW_MIX_DEFAULT = {
40
+ "scene": {"urban": 0.48, "highway": 0.28, "suburban": 0.24},
41
+ "time_of_day": {"day": 0.58, "dusk": 0.25, "night": 0.17},
42
+ "object_class": {"vehicle": 0.58, "pedestrian": 0.26, "cyclist": 0.16},
43
+ }
44
+
45
+ SHADOW_MIX_SHIFTED = {
46
+ "scene": {"urban": 0.58, "highway": 0.18, "suburban": 0.24},
47
+ "time_of_day": {"day": 0.28, "dusk": 0.22, "night": 0.50},
48
+ "object_class": {"vehicle": 0.52, "pedestrian": 0.30, "cyclist": 0.18},
49
+ }
50
+
51
+ LABEL_MATURITY_HOURS = 72.0
52
+
53
+ CAUSE_EXPLANATIONS: Dict[str, str] = {
54
+ "TRUE_MODEL_REGRESSION":
55
+ "B genuinely regresses on current production data (concept drift since "
56
+ "the offline eval window). The offline +5% is real for June data, but "
57
+ "stale. Signature: uniform within-segment shadow regression, all parity "
58
+ "checks clean, significance stage confirms a practical regression.",
59
+ "DISTRIBUTION_SHIFT":
60
+ "Production traffic is night/urban heavy while the offline set is "
61
+ "day-dominated. B is better in daytime segments and worse at night IN "
62
+ "BOTH environments -- a Simpson's paradox: the aggregate sign flips "
63
+ "purely from the population mix.",
64
+ "FEATURE_SKEW":
65
+ "The online feature pipeline emits obj_distance_m in feet (x3.281) "
66
+ "while offline uses meters. B depends on that feature more than A, so "
67
+ "shadow degrades B most where distance matters (highway). Signature: "
68
+ "one feature tops the parity report with a huge within-segment delta.",
69
+ "SERVING_MISMATCH":
70
+ "Shadow serving applies confidence threshold 0.50 + int8 quantization "
71
+ "vs the offline harness's 0.35/fp32. B's (recalibrated, lower) "
72
+ "confidences fall into the suppressed band far more often. Signature: "
73
+ "config diff + regression transitions concentrated in the 0.35-0.50 "
74
+ "confidence band.",
75
+ "LABEL_LATENCY":
76
+ "40% of shadow labels are provisional pseudo-labels from the incumbent "
77
+ "auto-labeler (which mirrors A) and are wrong mostly on hard cases. A "
78
+ "gets credit for matching wrong labels; B is penalized for disagreeing. "
79
+ "Signature: mature-label subset shows B ahead; provisional fraction is "
80
+ "difficulty-correlated.",
81
+ "SAMPLING_BIAS":
82
+ "The shadow sampler oversamples hard, low-confidence traffic (triage-"
83
+ "driven selection). B is slightly weaker only on very hard cases. The "
84
+ "full eligible stream agrees with offline (+); the sampled subset "
85
+ "flips negative. Signature: sampled-vs-eligible distribution mismatch "
86
+ "in the traffic audit.",
87
+ "STATISTICAL_NOISE":
88
+ "True delta is ~0 in both environments. Small n plus cluster-"
89
+ "correlated drive segments make the effective sample tiny, so +5%/-2% "
90
+ "are both within noise. Signature: overlapping CIs, low effective n, "
91
+ "significance stage returns insufficient evidence.",
92
+ "OFFLINE_CONTAMINATION":
93
+ "A quarter of the offline eval set are near-duplicates of B's training "
94
+ "data (row-level split leak), inflating B's offline score. Signature: "
95
+ "leakage scan fires, baseline re-run fails to reproduce, version pins "
96
+ "missing; shadow shows no reliable regression.",
97
+ }
98
+
99
+
100
+ @dataclass
101
+ class ScenarioBundle:
102
+ cause: str
103
+ seed: int
104
+ offline: pd.DataFrame
105
+ shadow: pd.DataFrame
106
+ meta: Dict
107
+
108
+
109
+ # ------------------------------------------------------------------ helpers
110
+
111
+
112
+ def _sigmoid(x: np.ndarray) -> np.ndarray:
113
+ return 1.0 / (1.0 + np.exp(-x))
114
+
115
+
116
+ def _pick(rng: np.random.Generator, table: Dict[str, float], n: int) -> np.ndarray:
117
+ keys = list(table.keys())
118
+ probs = np.array([table[k] for k in keys], dtype=float)
119
+ probs = probs / probs.sum()
120
+ return rng.choice(keys, size=n, p=probs)
121
+
122
+
123
+ def _sample_units(rng: np.random.Generator, n: int, mix: Dict, cluster_size: int,
124
+ entity_prefix: str) -> pd.DataFrame:
125
+ """Sample units grouped into drive-segment entities. Scene and time of day
126
+ are entity-level (a drive segment is one place, one time); object class is
127
+ unit-level."""
128
+ n_entities = max(2, int(np.ceil(n / cluster_size)))
129
+ ent_scene = _pick(rng, mix["scene"], n_entities)
130
+ ent_time = _pick(rng, mix["time_of_day"], n_entities)
131
+ ent_ids = np.array([f"{entity_prefix}-{i:05d}" for i in range(n_entities)])
132
+
133
+ idx = rng.integers(0, n_entities, size=n)
134
+ scene = ent_scene[idx]
135
+ time_of_day = ent_time[idx]
136
+ entity_id = ent_ids[idx]
137
+ object_class = _pick(rng, mix["object_class"], n)
138
+
139
+ dist_mu = np.where(scene == "urban", np.log(18.0),
140
+ np.where(scene == "highway", np.log(45.0), np.log(28.0)))
141
+ obj_distance = np.exp(rng.normal(dist_mu, 0.45))
142
+
143
+ speed_mu = np.where(scene == "urban", 9.0,
144
+ np.where(scene == "highway", 27.0, 14.0))
145
+ ego_speed = np.clip(rng.normal(speed_mu, 3.5), 0.0, None)
146
+
147
+ occ_a = np.where(scene == "urban", 2.5, np.where(scene == "highway", 1.5, 2.0))
148
+ occ_b = np.where(scene == "urban", 3.5, np.where(scene == "highway", 6.0, 5.0))
149
+ occlusion = rng.beta(occ_a, occ_b)
150
+
151
+ points = np.exp(rng.normal(6.8 - 1.1 * np.log(obj_distance / 20.0), 0.5))
152
+ track_age = rng.exponential(4.0, size=n)
153
+
154
+ lux_mu = np.where(time_of_day == "day", 10.3,
155
+ np.where(time_of_day == "dusk", 7.5, 3.6))
156
+ lux_sd = np.where(time_of_day == "day", 0.3,
157
+ np.where(time_of_day == "dusk", 0.4, 0.5))
158
+ ambient_lux = np.exp(rng.normal(lux_mu, lux_sd))
159
+
160
+ difficulty = np.clip(
161
+ rng.beta(2.2, 3.2, size=n)
162
+ + 0.20 * (time_of_day == "night")
163
+ + 0.12 * occlusion
164
+ + 0.08 * (obj_distance > 40.0)
165
+ - 0.10, 0.0, 1.0)
166
+
167
+ return pd.DataFrame({
168
+ "entity_id": entity_id,
169
+ "scene": scene,
170
+ "time_of_day": time_of_day,
171
+ "object_class": object_class,
172
+ "obj_distance_m": obj_distance,
173
+ "ego_speed_mps": ego_speed,
174
+ "occlusion_ratio": occlusion,
175
+ "points_in_box": points,
176
+ "track_age_s": track_age,
177
+ "ambient_lux": ambient_lux,
178
+ "difficulty": difficulty,
179
+ })
180
+
181
+
182
+ def _segment_adj(df: pd.DataFrame) -> np.ndarray:
183
+ """Shared (model-A) segment difficulty adjustments on the logit scale."""
184
+ adj = np.zeros(len(df))
185
+ adj += np.where(df["time_of_day"] == "night", -0.50,
186
+ np.where(df["time_of_day"] == "dusk", -0.15, 0.0))
187
+ adj += np.where(df["object_class"] == "pedestrian", -0.30,
188
+ np.where(df["object_class"] == "cyclist", -0.45, 0.0))
189
+ adj += np.where(df["scene"] == "urban", -0.15,
190
+ np.where(df["scene"] == "highway", 0.05, 0.0))
191
+ return adj
192
+
193
+
194
+ def _cluster_effects(rng: np.random.Generator, entities: pd.Series,
195
+ sigma_shared: float, sigma_b: float) -> Tuple[np.ndarray, np.ndarray]:
196
+ uniq = entities.unique()
197
+ shared = dict(zip(uniq, rng.normal(0.0, sigma_shared, size=len(uniq))))
198
+ b_only = dict(zip(uniq, rng.normal(0.0, sigma_b, size=len(uniq))))
199
+ return (entities.map(shared).to_numpy(), entities.map(b_only).to_numpy())
200
+
201
+
202
+ def _draw_correctness(rng: np.random.Generator, df: pd.DataFrame,
203
+ b_edge: np.ndarray, sigma_shared: float = 0.4,
204
+ sigma_b_cluster: float = 0.15) -> pd.DataFrame:
205
+ shared_eff, b_cluster = _cluster_effects(rng, df["entity_id"],
206
+ sigma_shared, sigma_b_cluster)
207
+ logit_a = 1.9 - 3.0 * df["difficulty"].to_numpy() + _segment_adj(df) + shared_eff
208
+ logit_b = logit_a + b_edge + b_cluster
209
+ p_a, p_b = _sigmoid(logit_a), _sigmoid(logit_b)
210
+ df = df.copy()
211
+ df["a_correct"] = (rng.random(len(df)) < p_a).astype(int)
212
+ df["b_correct"] = (rng.random(len(df)) < p_b).astype(int)
213
+ df["a_conf"] = np.clip(p_a + rng.normal(0, 0.07, len(df)), 0.02, 0.99)
214
+ df["b_conf"] = np.clip(p_b + rng.normal(0, 0.07, len(df)), 0.02, 0.99)
215
+ return df
216
+
217
+
218
+ def _calibrate(df: pd.DataFrame, target_delta: float, rng: np.random.Generator,
219
+ flip_mask: Optional[pd.Series] = None,
220
+ metric_mask: Optional[pd.Series] = None,
221
+ col_a: str = "a_correct", col_b: str = "b_correct") -> pd.DataFrame:
222
+ """Flip a minimal number of candidate-model outcomes so the measured
223
+ aggregate delta lands on target. Flips are allocated proportionally across
224
+ scene x time_of_day strata so planted per-segment structure survives."""
225
+ df = df.copy()
226
+ if metric_mask is None:
227
+ metric_mask = pd.Series(True, index=df.index)
228
+ if flip_mask is None:
229
+ flip_mask = pd.Series(True, index=df.index)
230
+
231
+ m = df[metric_mask]
232
+ n_metric = len(m)
233
+ if n_metric == 0:
234
+ return df
235
+ cur = m[col_b].mean() - m[col_a].mean()
236
+ need = target_delta - cur
237
+ k = int(round(abs(need) * n_metric))
238
+ if k == 0:
239
+ return df
240
+
241
+ want_value = 1 if need > 0 else 0
242
+ cand = df[flip_mask & metric_mask & (df[col_b] != want_value)]
243
+ if len(cand) == 0:
244
+ cand = df[metric_mask & (df[col_b] != want_value)]
245
+ k = min(k, len(cand))
246
+
247
+ strata = cand.groupby(["scene", "time_of_day"]).indices
248
+ chosen: List[int] = []
249
+ total = len(cand)
250
+ for _, idx in strata.items():
251
+ take = int(round(k * len(idx) / total))
252
+ take = min(take, len(idx))
253
+ if take > 0:
254
+ sel = rng.choice(idx, size=take, replace=False)
255
+ chosen.extend(cand.index[sel])
256
+ # Round-off top-up.
257
+ remaining = k - len(chosen)
258
+ if remaining > 0:
259
+ pool = cand.index.difference(pd.Index(chosen))
260
+ if len(pool) > 0:
261
+ extra = rng.choice(pool.to_numpy(), size=min(remaining, len(pool)),
262
+ replace=False)
263
+ chosen.extend(extra.tolist())
264
+ df.loc[chosen, col_b] = want_value
265
+ return df
266
+
267
+
268
+ # --------------------------------------------------------------- meta builder
269
+
270
+
271
+ def _base_configs() -> Tuple[Dict, Dict]:
272
+ offline = {
273
+ "metric_definition": "object-level accuracy (IoU>=0.5 match, class-aware)",
274
+ "aggregation": "micro-average over objects",
275
+ "eval_window": "2026-06-01 .. 2026-06-30",
276
+ "population_source": "curated holdout eval set (holdout-2026Q2)",
277
+ "confidence_threshold": 0.35,
278
+ "quantization": "fp32",
279
+ "runtime_version": "trt-9.2.1",
280
+ "nms_iou": 0.50,
281
+ "feature_pipeline_version": "fp-2.4.1",
282
+ "label_policy_version": "human-audited-v3",
283
+ "label_maturity_policy": "labels frozen post-audit",
284
+ "sampling_policy": "full eval set, no sampling",
285
+ }
286
+ shadow = dict(offline)
287
+ shadow.update({
288
+ "eval_window": "2026-08-01 .. 2026-08-10 (live)",
289
+ "population_source": "live shadow traffic",
290
+ "sampling_policy": "uniform 60% shadow sampler",
291
+ "label_maturity_policy": "mixed: audited after 72h maturity",
292
+ })
293
+ return offline, shadow
294
+
295
+
296
+ def _traffic_meta(rng: np.random.Generator, eligible: int, sampled: int,
297
+ biased: bool) -> Dict:
298
+ if biased:
299
+ return {
300
+ "eligible_count": eligible,
301
+ "sampled_count": sampled,
302
+ "sampling_rate": round(sampled / eligible, 4),
303
+ "dropped_count": int(eligible * 0.041),
304
+ "timeout_count": int(eligible * 0.018),
305
+ "fallback_count": int(eligible * 0.062),
306
+ "eligibility_filters": [
307
+ "exclude frames with fallback stack engaged",
308
+ "triage-priority sampler v2 (uplifts low-confidence / "
309
+ "disagreement traffic)",
310
+ ],
311
+ "sampler": "engaged-triage-sampler-v2",
312
+ }
313
+ return {
314
+ "eligible_count": eligible,
315
+ "sampled_count": sampled,
316
+ "sampling_rate": round(sampled / eligible, 4),
317
+ "dropped_count": int(eligible * 0.012),
318
+ "timeout_count": int(eligible * 0.006),
319
+ "fallback_count": int(eligible * 0.009),
320
+ "eligibility_filters": ["exclude frames with fallback stack engaged"],
321
+ "sampler": "uniform-shadow-sampler-v1",
322
+ }
323
+
324
+
325
+ # ------------------------------------------------------------------ generator
326
+
327
+
328
+ def generate_scenario(cause: str, seed: int = 7) -> ScenarioBundle:
329
+ if cause not in ROOT_CAUSES:
330
+ raise ValueError(f"Unknown root cause {cause!r}; expected one of {ROOT_CAUSES}")
331
+ rng = np.random.default_rng(seed)
332
+
333
+ n_off, off_cluster = 3000, 8
334
+ n_sh, sh_cluster = 6000, 10
335
+ sampled_rate = 0.6
336
+ sigma_b_cluster = 0.15
337
+ shadow_mix = SHADOW_MIX_DEFAULT
338
+ off_target, sh_target = 0.05, -0.02
339
+
340
+ if cause == "STATISTICAL_NOISE":
341
+ n_off, off_cluster = 500, 25
342
+ n_sh, sh_cluster = 420, 20
343
+ sampled_rate = 1.0
344
+ sigma_b_cluster = 0.60
345
+ elif cause == "TRUE_MODEL_REGRESSION":
346
+ n_sh = 20000
347
+ sh_target = -0.023
348
+ elif cause == "DISTRIBUTION_SHIFT":
349
+ shadow_mix = SHADOW_MIX_SHIFTED
350
+ elif cause == "OFFLINE_CONTAMINATION":
351
+ sh_target = -0.010
352
+
353
+ off = _sample_units(rng, n_off, OFFLINE_MIX, off_cluster, "off-seg")
354
+ sh = _sample_units(rng, n_sh, shadow_mix, sh_cluster, "sh-seg")
355
+
356
+ # ---------------------------------------------------------- B's true edge
357
+ def zeros(df):
358
+ return np.zeros(len(df))
359
+
360
+ b_edge_off, b_edge_sh = zeros(off), zeros(sh)
361
+ if cause == "TRUE_MODEL_REGRESSION":
362
+ b_edge_off = np.full(n_off, 0.35)
363
+ b_edge_sh = np.full(n_sh, -0.18) # concept drift hits B uniformly
364
+ elif cause == "DISTRIBUTION_SHIFT":
365
+ def mix_edge(df):
366
+ return np.where(df["time_of_day"] == "day", 0.60,
367
+ np.where(df["time_of_day"] == "dusk", 0.10, -0.55))
368
+ b_edge_off, b_edge_sh = mix_edge(off), mix_edge(sh)
369
+ elif cause == "FEATURE_SKEW":
370
+ b_edge_off = np.full(n_off, 0.35)
371
+ w = np.where(sh["scene"] == "highway", 1.0,
372
+ np.where(sh["scene"] == "suburban", 0.5, 0.2))
373
+ # Skewed distance feature degrades both models online, B far more.
374
+ b_edge_sh = 0.35 - 1.25 * w
375
+ elif cause == "SERVING_MISMATCH":
376
+ b_edge_off = np.full(n_off, 0.35)
377
+ b_edge_sh = np.full(n_sh, 0.35) # B is genuinely better...
378
+ elif cause == "LABEL_LATENCY":
379
+ b_edge_off = np.full(n_off, 0.35)
380
+ b_edge_sh = np.full(n_sh, 0.32)
381
+ elif cause == "SAMPLING_BIAS":
382
+ # B slightly weaker only on very hard cases, in BOTH environments.
383
+ b_edge_off = 0.55 - 1.1 * off["difficulty"].to_numpy()
384
+ b_edge_sh = 0.55 - 1.1 * sh["difficulty"].to_numpy()
385
+ elif cause == "STATISTICAL_NOISE":
386
+ pass # true delta ~ 0 everywhere
387
+ elif cause == "OFFLINE_CONTAMINATION":
388
+ b_edge_off = np.full(n_off, 0.02)
389
+ b_edge_sh = np.full(n_sh, -0.05)
390
+
391
+ off = _draw_correctness(rng, off, b_edge_off, sigma_b_cluster=sigma_b_cluster)
392
+ sh = _draw_correctness(rng, sh, b_edge_sh, sigma_b_cluster=sigma_b_cluster)
393
+
394
+ # -------------------------------------------------- offline contamination
395
+ off["is_near_duplicate"] = False
396
+ off["dup_similarity"] = 0.0
397
+ train_entity_ids: List[str] = []
398
+ if cause == "OFFLINE_CONTAMINATION":
399
+ entities = off["entity_id"].unique()
400
+ n_dup_ent = int(len(entities) * 0.25)
401
+ dup_entities = set(rng.choice(entities, size=n_dup_ent, replace=False))
402
+ dup_mask = off["entity_id"].isin(dup_entities)
403
+ off.loc[dup_mask, "is_near_duplicate"] = True
404
+ off.loc[dup_mask, "dup_similarity"] = rng.uniform(0.93, 0.999, dup_mask.sum())
405
+ # B memorized these rows during training.
406
+ mem = dup_mask & (rng.random(n_off) < 0.985)
407
+ off.loc[mem, "b_correct"] = 1
408
+ off.loc[dup_mask, "b_conf"] = np.clip(
409
+ off.loc[dup_mask, "b_conf"] + 0.15, 0.02, 0.995)
410
+ train_entity_ids = sorted(dup_entities)
411
+
412
+ # ------------------------------------------------------ shadow mechanisms
413
+ sh["a_correct_true"] = sh["a_correct"]
414
+ sh["b_correct_true"] = sh["b_correct"]
415
+ sh["a_conf_serving"] = sh["a_conf"]
416
+ sh["b_conf_serving"] = sh["b_conf"]
417
+
418
+ if cause == "FEATURE_SKEW":
419
+ # Online pipeline logs/consumes distance in feet.
420
+ sh["obj_distance_m"] = sh["obj_distance_m"] * 3.281
421
+
422
+ if cause == "SERVING_MISMATCH":
423
+ # int8 quantization nudges confidences down slightly (B more).
424
+ sh["a_conf_serving"] = np.clip(sh["a_conf"] - 0.01, 0.02, 0.99)
425
+ sh["b_conf_serving"] = np.clip(sh["b_conf"] - 0.05, 0.02, 0.99)
426
+ band = (sh["b_conf_serving"] >= 0.35) & (sh["b_conf_serving"] < 0.50)
427
+ suppressed = band & (rng.random(n_sh) < 0.9)
428
+ sh.loc[suppressed, "b_correct"] = 0
429
+
430
+ sh["label_is_provisional"] = False
431
+ sh["label_age_hours"] = rng.uniform(80.0, 400.0, n_sh)
432
+ if cause == "LABEL_LATENCY":
433
+ prov = rng.random(n_sh) < 0.40
434
+ sh["label_is_provisional"] = prov
435
+ sh.loc[prov, "label_age_hours"] = rng.uniform(1.0, 60.0, prov.sum())
436
+ diff = sh["difficulty"].to_numpy()
437
+ pseudo_wrong = prov & (rng.random(n_sh) < np.clip(0.65 * diff, 0, 0.9))
438
+ # Wrong pseudo-labels mirror the incumbent (A-like) auto-labeler:
439
+ # A "matches" the wrong label often; B rarely does.
440
+ a_match = pseudo_wrong & (rng.random(n_sh) < 0.75)
441
+ b_match = pseudo_wrong & (rng.random(n_sh) < 0.12)
442
+ sh.loc[pseudo_wrong, "a_correct"] = 0
443
+ sh.loc[pseudo_wrong & a_match, "a_correct"] = 1
444
+ sh.loc[pseudo_wrong, "b_correct"] = 0
445
+ sh.loc[pseudo_wrong & b_match, "b_correct"] = 1
446
+
447
+ # ---------------------------------------------------------- shadow sampling
448
+ if cause == "SAMPLING_BIAS":
449
+ disagree = (sh["a_correct_true"] != sh["b_correct_true"]).to_numpy()
450
+ w = 0.12 + 0.55 * disagree + 0.55 * sh["difficulty"].to_numpy() \
451
+ + 0.25 * (sh["a_conf"].to_numpy() < 0.5)
452
+ p_sample = np.clip(w, 0.02, 0.98)
453
+ sh["sampled"] = rng.random(n_sh) < p_sample
454
+ else:
455
+ sh["sampled"] = rng.random(n_sh) < sampled_rate
456
+
457
+ # ------------------------------------------------------------- calibration
458
+ off_flip_mask = off["is_near_duplicate"] if cause == "OFFLINE_CONTAMINATION" else None
459
+ off = _calibrate(off, off_target, rng, flip_mask=off_flip_mask)
460
+
461
+ if cause == "SERVING_MISMATCH":
462
+ flip = (sh["b_conf_serving"] >= 0.33) & (sh["b_conf_serving"] < 0.52)
463
+ elif cause == "LABEL_LATENCY":
464
+ flip = sh["label_is_provisional"]
465
+ else:
466
+ flip = None
467
+ if cause == "SAMPLING_BIAS":
468
+ # Only the triage-tilted sample regresses; the unsampled remainder of
469
+ # the eligible stream agrees with offline (B ahead).
470
+ sh = _calibrate(sh, sh_target, rng, metric_mask=sh["sampled"])
471
+ sh = _calibrate(sh, 0.06, rng, metric_mask=~sh["sampled"])
472
+ else:
473
+ # Calibrate the full stream, then a small touch-up on the scored
474
+ # sample, so sampled vs unsampled stay consistent (no spurious
475
+ # selection-bias signature).
476
+ sh = _calibrate(sh, sh_target, rng, flip_mask=flip)
477
+ sh = _calibrate(sh, sh_target, rng, flip_mask=flip,
478
+ metric_mask=sh["sampled"])
479
+
480
+ # Light feature missingness (parity report exercises missingness deltas).
481
+ for col, frac_off, frac_sh in (("points_in_box", 0.004, 0.009),
482
+ ("track_age_s", 0.002, 0.004)):
483
+ off.loc[off.sample(frac=frac_off, random_state=seed).index, col] = np.nan
484
+ sh.loc[sh.sample(frac=frac_sh, random_state=seed + 1).index, col] = np.nan
485
+
486
+ off.insert(0, "unit_id", [f"off-{i:06d}" for i in range(len(off))])
487
+ sh.insert(0, "unit_id", [f"sh-{i:06d}" for i in range(len(sh))])
488
+
489
+ # ------------------------------------------------------------------- meta
490
+ off_cfg, sh_cfg = _base_configs()
491
+ if cause == "SERVING_MISMATCH":
492
+ sh_cfg["confidence_threshold"] = 0.50
493
+ sh_cfg["quantization"] = "int8"
494
+ sh_cfg["runtime_version"] = "trt-9.3.0"
495
+ if cause == "FEATURE_SKEW":
496
+ sh_cfg["feature_pipeline_version"] = None # nobody recorded it
497
+ if cause == "LABEL_LATENCY":
498
+ sh_cfg["label_policy_version"] = "auto-provisional-v1 (mixed)"
499
+ sh_cfg["label_maturity_policy"] = "provisional pseudo-labels until 72h audit"
500
+ if cause == "SAMPLING_BIAS":
501
+ sh_cfg["sampling_policy"] = "engaged-triage-sampler-v2 (priority traffic)"
502
+ if cause == "TRUE_MODEL_REGRESSION":
503
+ off_cfg["eval_window"] = "2026-05-20 .. 2026-06-10 (8 weeks stale)"
504
+
505
+ repro_ok = cause != "OFFLINE_CONTAMINATION"
506
+ sampled_count = int(sh["sampled"].sum())
507
+
508
+ measured_off = float(off["b_correct"].mean() - off["a_correct"].mean())
509
+ m = sh[sh["sampled"]]
510
+ measured_sh = float(m["b_correct"].mean() - m["a_correct"].mean())
511
+
512
+ meta = {
513
+ "cause": cause,
514
+ "seed": seed,
515
+ "offline_config": off_cfg,
516
+ "shadow_config": sh_cfg,
517
+ "model_a": {
518
+ "version": "detr3d-a-v41",
519
+ "train_window": "2025-11 .. 2026-04",
520
+ "train_entity_ids": [],
521
+ "split_strategy": "entity_level",
522
+ },
523
+ "model_b": {
524
+ "version": "detr3d-b-v42",
525
+ "train_window": "2025-12 .. 2026-05",
526
+ "train_entity_ids": train_entity_ids,
527
+ "split_strategy": ("row_level" if cause == "OFFLINE_CONTAMINATION"
528
+ else "entity_level"),
529
+ },
530
+ "reproducibility": {
531
+ "original_offline_metric_delta": measured_off,
532
+ "rerun_metric_delta": (measured_off + (0.001 if repro_ok else -0.031)),
533
+ "pins_present": repro_ok,
534
+ "environment_lock": ("conda-lock + image digest recorded" if repro_ok
535
+ else "requirements.txt without pins; image tag "
536
+ "'latest'"),
537
+ },
538
+ "traffic": _traffic_meta(rng, n_sh, sampled_count,
539
+ biased=(cause == "SAMPLING_BIAS")),
540
+ "label_maturity_hours": LABEL_MATURITY_HOURS,
541
+ "practical_margin_pp": 1.0,
542
+ "measured": {
543
+ "offline_delta": measured_off,
544
+ "shadow_delta": measured_sh,
545
+ "offline_n": len(off),
546
+ "shadow_scored_n": int(m.shape[0]),
547
+ },
548
+ "explanation": CAUSE_EXPLANATIONS[cause],
549
+ }
550
+ return ScenarioBundle(cause=cause, seed=seed, offline=off, shadow=sh, meta=meta)
sensorflow/rca/scoring.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Root-cause scoring board, decision-tree evaluation, and experiment ranking.
2
+
3
+ The scoreboard is rule-based and auditable: each of the 8 hypotheses has an
4
+ explicit list of finding codes that count for/against it, with weights. Every
5
+ score is traceable to the stage findings that produced it (evidence links),
6
+ and the user can overlay their own confidence assessment without erasing the
7
+ auto one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ from sensorflow.rca import diagnostics as dg
15
+ from sensorflow.rca.models import (Finding, HYPOTHESIS_LABELS, Investigation,
16
+ ROOT_CAUSES)
17
+ from sensorflow.rca.scenario import ScenarioBundle
18
+
19
+ # (code_prefix, weight). A finding matches a rule when its code starts with
20
+ # the prefix, so FP_FEATURE_SKEW matches FP_FEATURE_SKEW:obj_distance_m.
21
+ _RULES: Dict[str, Dict[str, List[Tuple[str, float]]]] = {
22
+ "OFFLINE_CONTAMINATION": {
23
+ "for": [("OA_LEAKAGE_DUPLICATES", 3.0), ("OA_REPRO_FAIL", 2.5),
24
+ ("OA_SPLIT_ROW_LEVEL", 1.5), ("OA_PINS_MISSING", 1.0),
25
+ ("OA_TEMPORAL_LEAK", 2.0)],
26
+ "against": [("OA_LEAKAGE_CLEAN", 2.5), ("OA_REPRO_OK", 1.5),
27
+ ("OA_SPLIT_ENTITY_LEVEL", 0.5)],
28
+ },
29
+ "DISTRIBUTION_SHIFT": {
30
+ "for": [("DS_SHIFT_HIGH", 2.5), ("CP_SIMPSONS_DETECTED", 3.0)],
31
+ "against": [("DS_SHIFT_LOW", 2.5), ("DS_VOLUME_TOO_SMALL", 0.5),
32
+ ("CP_NO_PATTERN", 0.5)],
33
+ },
34
+ "FEATURE_SKEW": {
35
+ "for": [("FP_FEATURE_SKEW", 3.5), ("DS_FEATURE_ONLY_SHIFT", 1.5),
36
+ ("SP_UNKNOWN:feature_pipeline_version", 1.0),
37
+ ("CV_UNKNOWN:feature_pipeline_version", 0.5),
38
+ ("CP_SEGMENT_CONCENTRATED", 0.75)],
39
+ "against": [("FP_CLEAN", 3.0)],
40
+ },
41
+ "SERVING_MISMATCH": {
42
+ "for": [("SP_CONFIG_DIFF", 2.0), ("PC_CONF_BAND_CONCENTRATION", 2.5),
43
+ ("CV_MISMATCH:confidence_threshold", 0.5),
44
+ ("CV_MISMATCH:quantization", 0.5)],
45
+ "against": [("SP_CLEAN", 3.0)],
46
+ },
47
+ "LABEL_LATENCY": {
48
+ "for": [("LI_MATURE_DIVERGES", 3.0), ("LI_PROVISIONAL_HIGH", 1.5),
49
+ ("LI_DIFFICULTY_CORRELATED", 1.5), ("LI_POLICY_DIFF", 1.0),
50
+ ("CV_MISMATCH:label_policy_version", 0.5)],
51
+ "against": [("LI_CLEAN", 3.0)],
52
+ },
53
+ "SAMPLING_BIAS": {
54
+ "for": [("ST_SELECTION_BIAS", 3.5), ("ST_DROPS_HIGH", 1.0)],
55
+ "against": [("ST_SAMPLE_FAIR", 3.0)],
56
+ },
57
+ "STATISTICAL_NOISE": {
58
+ "for": [("SS_INSUFFICIENT_EVIDENCE", 2.0), ("SS_LOW_ESS", 2.0),
59
+ ("POP_VOLUME_LOW", 1.5), ("POP_SMALL_OFFLINE", 1.0),
60
+ ("DS_VOLUME_TOO_SMALL", 0.5)],
61
+ "against": [("SS_SIGNIFICANT_REGRESSION", 3.0), ("SS_NO_SIG_DIFF", 1.0),
62
+ ("POP_VOLUME_OK", 1.5)],
63
+ },
64
+ "TRUE_MODEL_REGRESSION": {
65
+ "for": [("SS_SIGNIFICANT_REGRESSION", 2.5), ("CP_UNIFORM_REGRESSION", 1.5),
66
+ ("CV_WINDOW_STALE", 0.75),
67
+ # Clean bill of health on every artifact channel is itself
68
+ # evidence that the regression is real.
69
+ ("OA_LEAKAGE_CLEAN", 0.6), ("FP_CLEAN", 0.6), ("SP_CLEAN", 0.6),
70
+ ("ST_SAMPLE_FAIR", 0.6), ("LI_CLEAN", 0.6), ("DS_SHIFT_LOW", 0.6)],
71
+ "against": [("OA_LEAKAGE_DUPLICATES", 2.0), ("OA_REPRO_FAIL", 1.5),
72
+ ("FP_FEATURE_SKEW", 2.0), ("SP_CONFIG_DIFF", 1.5),
73
+ ("ST_SELECTION_BIAS", 2.0), ("LI_MATURE_DIVERGES", 2.0),
74
+ ("CP_SIMPSONS_DETECTED", 2.0), ("DS_SHIFT_HIGH", 1.5),
75
+ ("SS_INSUFFICIENT_EVIDENCE", 1.5),
76
+ ("PC_CONF_BAND_CONCENTRATION", 1.0)],
77
+ },
78
+ }
79
+
80
+ _NEXT_TEST: Dict[str, str] = {
81
+ "TRUE_MODEL_REGRESSION":
82
+ "Split the shadow window into halves and check the paired delta is "
83
+ "stable; re-run the offline eval on freshly-labeled recent data "
84
+ "(same model, different data).",
85
+ "DISTRIBUTION_SHIFT":
86
+ "Reweight the scored shadow units to the offline segment mix and "
87
+ "recompute the paired delta -- if it turns positive, the mix is the "
88
+ "whole story.",
89
+ "FEATURE_SKEW":
90
+ "Replay ~500 shadow frames through the offline feature pipeline and "
91
+ "diff feature values unit-by-unit (same data, different pipeline); "
92
+ "start with the top-ranked parity feature.",
93
+ "SERVING_MISMATCH":
94
+ "Score the offline eval set through the shadow serving stack (same "
95
+ "data, different pipeline) with configs pinned one at a time "
96
+ "(threshold first, then quantization).",
97
+ "LABEL_LATENCY":
98
+ "Freeze the cohort and re-grade after labels mature past the audit "
99
+ "threshold; compare against the provisional-label verdict.",
100
+ "SAMPLING_BIAS":
101
+ "Score a uniform random sample of the eligible stream (bypass the "
102
+ "triage sampler) and compare the paired delta.",
103
+ "STATISTICAL_NOISE":
104
+ "Extend shadow collection until the cluster-aware CI half-width is "
105
+ "below the practical margin; pre-register the stopping rule "
106
+ "(anytime-valid sequential test).",
107
+ "OFFLINE_CONTAMINATION":
108
+ "Dedup the offline eval set against B's training data at entity "
109
+ "level, pin versions, and re-run the offline comparison.",
110
+ }
111
+
112
+
113
+ def _confidence(score: float, matched: int) -> str:
114
+ """UNKNOWN = no evidence touches this hypothesis yet. Otherwise the score
115
+ maps to confidence THAT THIS IS THE CAUSE (negative = evidence against)."""
116
+ if matched == 0:
117
+ return "UNKNOWN"
118
+ if score >= 5.0:
119
+ return "HIGH"
120
+ if score >= 2.5:
121
+ return "MEDIUM"
122
+ return "LOW"
123
+
124
+
125
+ def build_scoreboard(bundle: ScenarioBundle,
126
+ inv: Optional[Investigation] = None,
127
+ battery: Optional[Dict] = None,
128
+ recorded_only: bool = False) -> Dict:
129
+ """Score all 8 hypotheses from the full diagnostic battery plus any
130
+ human-recorded findings; overlay human assessments if present.
131
+
132
+ With recorded_only=True, only findings already recorded on the
133
+ investigation are scored (auto findings accrue as the engineer works
134
+ through the stages) -- used for the evolving working-hypothesis banner so
135
+ the board never leaks conclusions from stages not yet visited."""
136
+ if recorded_only and inv is not None:
137
+ findings: List[Finding] = list(inv.findings)
138
+ else:
139
+ battery = battery or dg.run_all(bundle)
140
+ findings = [f for _, (_, fs) in battery.items() for f in fs]
141
+ if inv is not None:
142
+ findings = findings + [f for f in inv.findings if f.source == "human"]
143
+
144
+ rows = []
145
+ for hyp in ROOT_CAUSES:
146
+ rules = _RULES[hyp]
147
+ ev_for, ev_against = [], []
148
+ score = 0.0
149
+ for f in findings:
150
+ for prefix, w in rules["for"]:
151
+ if f.code.startswith(prefix):
152
+ score += w
153
+ ev_for.append({"code": f.code, "stage": f.stage,
154
+ "title": f.title, "weight": w,
155
+ "severity": f.severity, "status": f.status,
156
+ "finding_id": f.id})
157
+ break
158
+ else:
159
+ for prefix, w in rules["against"]:
160
+ if f.code.startswith(prefix):
161
+ score -= w
162
+ ev_against.append({"code": f.code, "stage": f.stage,
163
+ "title": f.title, "weight": w,
164
+ "severity": f.severity,
165
+ "status": f.status,
166
+ "finding_id": f.id})
167
+ break
168
+ matched = len(ev_for) + len(ev_against)
169
+ auto_conf = _confidence(score, matched)
170
+ human = (inv.human_assessments.get(hyp) if inv is not None else None) or {}
171
+ rows.append({
172
+ "hypothesis": hyp,
173
+ "label": HYPOTHESIS_LABELS[hyp],
174
+ "score": round(score, 2),
175
+ "evidence_for": ev_for,
176
+ "evidence_against": ev_against,
177
+ "auto_confidence": auto_conf,
178
+ "human_confidence": human.get("confidence"),
179
+ "human_note": human.get("note", ""),
180
+ "next_discriminating_test": _NEXT_TEST[hyp],
181
+ })
182
+
183
+ rows.sort(key=lambda r: r["score"], reverse=True)
184
+ for i, r in enumerate(rows):
185
+ r["rank"] = i + 1
186
+
187
+ top = rows[0]
188
+ runner = rows[1] if len(rows) > 1 else None
189
+ # Skeptical semantics: a hypothesis leaves the working set only when the
190
+ # net recorded evidence is AGAINST it -- weak or absent evidence keeps it
191
+ # in play (never a premature single conclusion).
192
+ working_set = [r["hypothesis"] for r in rows
193
+ if r["score"] >= -0.5 or r["auto_confidence"] == "UNKNOWN"]
194
+ return {
195
+ "rows": rows,
196
+ "top_hypothesis": top["hypothesis"],
197
+ "top_confidence": top["auto_confidence"],
198
+ "score_gap": round(top["score"] - (runner["score"] if runner else 0.0), 2),
199
+ "working_hypothesis_set": working_set or [top["hypothesis"]],
200
+ "explainer": ("Rule-based, auditable scoring: every point traces to a "
201
+ "stage finding. Human overrides are recorded next to -- "
202
+ "never instead of -- the automatic assessment."),
203
+ }
204
+
205
+
206
+ # ------------------------------------------------------------- decision tree
207
+
208
+
209
+ def _has(findings: List[Finding], prefix: str) -> Optional[Finding]:
210
+ for f in findings:
211
+ if f.code.startswith(prefix):
212
+ return f
213
+ return None
214
+
215
+
216
+ def evaluate_decision_tree(bundle: ScenarioBundle,
217
+ battery: Optional[Dict] = None) -> Dict:
218
+ """Walk the measurement-validity -> distribution -> parity -> significance
219
+ causal chain with this investigation's actual findings at each node."""
220
+ battery = battery or dg.run_all(bundle)
221
+ findings = [f for _, (_, fs) in battery.items() for f in fs]
222
+
223
+ def node(nid, question, yes_when_absent, bad_prefixes, unknown_prefixes,
224
+ conclusion_if_no, next_id):
225
+ bad = [p for p in bad_prefixes if _has(findings, p)]
226
+ unk = [p for p in unknown_prefixes if _has(findings, p)]
227
+ if bad:
228
+ answer = "no"
229
+ elif unk:
230
+ answer = "unknown"
231
+ else:
232
+ answer = "yes" if yes_when_absent else "unknown"
233
+ basis = [f.code for f in findings
234
+ if any(f.code.startswith(p) for p in
235
+ list(bad_prefixes) + list(unknown_prefixes))]
236
+ return {"id": nid, "question": question, "answer": answer,
237
+ "basis": basis, "conclusion_if_no": conclusion_if_no,
238
+ "next": next_id}
239
+
240
+ nodes = [
241
+ node("offline_trustworthy",
242
+ "Is the offline +5% measurement itself trustworthy "
243
+ "(reproducible, leak-free)?", True,
244
+ ["OA_LEAKAGE_DUPLICATES", "OA_REPRO_FAIL", "OA_TEMPORAL_LEAK"],
245
+ ["OA_PINS_MISSING"],
246
+ "OFFLINE_CONTAMINATION", "labels_trustworthy"),
247
+ node("labels_trustworthy",
248
+ "Are shadow ground-truth labels mature and unbiased?", True,
249
+ ["LI_MATURE_DIVERGES"],
250
+ ["LI_PROVISIONAL_HIGH"],
251
+ "LABEL_LATENCY", "sample_representative"),
252
+ node("sample_representative",
253
+ "Is the scored shadow sample representative of eligible "
254
+ "traffic?", True,
255
+ ["ST_SELECTION_BIAS"], [],
256
+ "SAMPLING_BIAS", "same_population"),
257
+ node("same_population",
258
+ "Do offline and shadow score comparable populations?", True,
259
+ ["DS_SHIFT_HIGH", "CP_SIMPSONS_DETECTED"],
260
+ ["DS_VOLUME_TOO_SMALL"],
261
+ "DISTRIBUTION_SHIFT", "feature_parity"),
262
+ node("feature_parity",
263
+ "Do the models see the same feature values online as offline?",
264
+ True,
265
+ ["FP_FEATURE_SKEW"],
266
+ ["SP_UNKNOWN:feature_pipeline_version"],
267
+ "FEATURE_SKEW", "serving_parity"),
268
+ node("serving_parity",
269
+ "Is the serving configuration at parity with the offline "
270
+ "harness?", True,
271
+ ["SP_CONFIG_DIFF"],
272
+ ["SP_UNKNOWN"],
273
+ "SERVING_MISMATCH", "significant"),
274
+ ]
275
+
276
+ sig = _has(findings, "SS_SIGNIFICANT_REGRESSION")
277
+ insuff = _has(findings, "SS_INSUFFICIENT_EVIDENCE")
278
+ nodes.append({
279
+ "id": "significant",
280
+ "question": ("With all artifact channels checked, is the shadow "
281
+ "regression significant beyond the practical margin?"),
282
+ "answer": "yes" if sig else ("unknown" if insuff else "no"),
283
+ "basis": [f.code for f in findings if f.code.startswith("SS_")],
284
+ "conclusion_if_no": None,
285
+ "next": None,
286
+ })
287
+
288
+ # Walk the live path.
289
+ path: List[str] = []
290
+ conclusion = None
291
+ conclusion_kind = "root_cause"
292
+ for n in nodes:
293
+ path.append(n["id"])
294
+ if n["id"] == "significant":
295
+ if n["answer"] == "yes":
296
+ conclusion = "TRUE_MODEL_REGRESSION"
297
+ elif n["answer"] == "unknown":
298
+ conclusion = "STATISTICAL_NOISE"
299
+ conclusion_kind = "insufficient_evidence"
300
+ else:
301
+ conclusion = "NO_PRACTICAL_REGRESSION"
302
+ conclusion_kind = "no_regression"
303
+ break
304
+ if n["answer"] == "no":
305
+ conclusion = n["conclusion_if_no"]
306
+ break
307
+ # 'unknown' proceeds but is recorded on the node; the stage state
308
+ # machine is what forces the acknowledgment.
309
+
310
+ return {"nodes": nodes, "path": path, "conclusion": conclusion,
311
+ "conclusion_kind": conclusion_kind,
312
+ "explainer": ("The causal chain rendered with this "
313
+ "investigation's actual answers. A 'no' anywhere "
314
+ "upstream invalidates naive readings of every "
315
+ "number downstream.")}
316
+
317
+
318
+ # ---------------------------------------------------------------- experiments
319
+
320
+
321
+ def recommend_experiments(bundle: ScenarioBundle,
322
+ scoreboard: Optional[Dict] = None,
323
+ battery: Optional[Dict] = None) -> Dict:
324
+ battery = battery or dg.run_all(bundle)
325
+ scoreboard = scoreboard or build_scoreboard(bundle, battery=battery)
326
+ rows = scoreboard["rows"]
327
+ by_hyp = {r["hypothesis"]: r for r in rows}
328
+ top, runner = rows[0], rows[1]
329
+
330
+ sig_data = battery["statistical_significance"][0]
331
+ eff_n = sig_data["shadow_paired"]["effective_n"]
332
+ margin = sig_data["practical_margin_pp"]
333
+ se = sig_data["shadow_paired"]["se"]
334
+ # n needed for the CI half-width to fit inside the practical margin.
335
+ needed_n = int(eff_n * (1.96 * se / max(1e-9, margin / 100.0)) ** 2) + 1
336
+
337
+ fp_rows = battery["feature_parity"][0]["rows"]
338
+ top_feature = fp_rows[0]["feature"] if fp_rows else "top-ranked feature"
339
+
340
+ designs = [
341
+ {"id": "same_data_diff_pipeline",
342
+ "design": "Same data, different pipeline",
343
+ "description": (f"Replay ~500 scored shadow frames through the "
344
+ f"offline feature + serving pipeline and diff both "
345
+ f"feature values (start with {top_feature}) and "
346
+ "model outputs unit-by-unit."),
347
+ "discriminates": ["FEATURE_SKEW", "SERVING_MISMATCH"],
348
+ "cost": "low", "expected_days": 1},
349
+ {"id": "same_model_diff_data",
350
+ "design": "Same model, different data",
351
+ "description": ("Re-run the offline evaluation on a fresh, "
352
+ "entity-deduped, recent-window eval set (and "
353
+ "reweighted to the shadow segment mix)."),
354
+ "discriminates": ["OFFLINE_CONTAMINATION", "DISTRIBUTION_SHIFT",
355
+ "TRUE_MODEL_REGRESSION"],
356
+ "cost": "medium", "expected_days": 2},
357
+ {"id": "uniform_shadow_sample",
358
+ "design": "Same traffic, different sampler",
359
+ "description": ("Score a uniform random sample of eligible shadow "
360
+ "traffic, bypassing eligibility filters and the "
361
+ "triage sampler."),
362
+ "discriminates": ["SAMPLING_BIAS"],
363
+ "cost": "low", "expected_days": 1},
364
+ {"id": "mature_label_regrade",
365
+ "design": "Same units, different labels",
366
+ "description": ("Freeze the scored cohort and re-grade once all "
367
+ "labels pass the maturity threshold; compare "
368
+ "verdicts."),
369
+ "discriminates": ["LABEL_LATENCY"],
370
+ "cost": "medium", "expected_days": 4},
371
+ {"id": "extend_collection",
372
+ "design": "More data, pre-registered stop",
373
+ "description": (f"Extend shadow collection to ~{needed_n:,} "
374
+ f"effective units so the CI half-width fits inside "
375
+ f"the {margin:.1f}pp practical margin; use an "
376
+ "anytime-valid sequential test."),
377
+ "discriminates": ["STATISTICAL_NOISE", "TRUE_MODEL_REGRESSION"],
378
+ "cost": "medium", "expected_days": 5},
379
+ ]
380
+
381
+ # Information gain proxy: how much unresolved probability mass the
382
+ # experiment can move (sum of positive scores it discriminates among,
383
+ # with the top-2 gap counted double), divided by cost.
384
+ cost_w = {"low": 1.0, "medium": 2.0, "high": 4.0}
385
+ for d in designs:
386
+ mass = sum(max(0.0, by_hyp[h]["score"]) for h in d["discriminates"])
387
+ bonus = 3.0 if (top["hypothesis"] in d["discriminates"]
388
+ or runner["hypothesis"] in d["discriminates"]) else 0.0
389
+ d["information_gain"] = round(mass + bonus, 2)
390
+ d["priority"] = round((mass + bonus) / cost_w[d["cost"]], 2)
391
+ designs.sort(key=lambda d: d["priority"], reverse=True)
392
+ for i, d in enumerate(designs):
393
+ d["rank"] = i + 1
394
+
395
+ minimum = (
396
+ f"Current leader: {top['hypothesis']} "
397
+ f"({top['auto_confidence']}, score {top['score']}) vs runner-up "
398
+ f"{runner['hypothesis']} (score {runner['score']}). The minimum "
399
+ f"additional evidence to conclude is: {designs[0]['description']} "
400
+ f"If that comes back clean, fall back to: {designs[1]['description']}")
401
+
402
+ return {"experiments": designs, "minimum_additional_evidence": minimum,
403
+ "power": {"effective_n": eff_n, "needed_effective_n": needed_n,
404
+ "practical_margin_pp": margin},
405
+ "explainer": ("Counterfactual designs instantiated with this "
406
+ "investigation's specifics, ranked by information "
407
+ "gain per unit cost.")}
sensorflow/rca/stats.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Statistical primitives for the RCA diagnostics.
2
+
3
+ Local implementations of Wilson CIs and cluster-aware paired deltas. When the
4
+ sequential-testing package (sensorflow.seqeval: anytime-valid confidence
5
+ sequences + paired evaluation) lands, the significance stage should prefer it;
6
+ see diagnostics.significance_engine().
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Dict, List, Optional, Sequence, Tuple
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from scipy import stats as sps
17
+
18
+ Z95 = 1.959963984540054
19
+
20
+
21
+ def wilson_ci(successes: int, n: int, z: float = Z95) -> Tuple[float, float]:
22
+ """Wilson score interval for a binomial proportion."""
23
+ if n == 0:
24
+ return (0.0, 1.0)
25
+ p = successes / n
26
+ denom = 1 + z * z / n
27
+ center = (p + z * z / (2 * n)) / denom
28
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
29
+ return (max(0.0, center - half), min(1.0, center + half))
30
+
31
+
32
+ # ------------------------------------------------------------- divergence
33
+
34
+
35
+ def _hist_probs(a: np.ndarray, b: np.ndarray, bins: int = 10,
36
+ eps: float = 1e-4) -> Tuple[np.ndarray, np.ndarray]:
37
+ """Common-bin probability vectors for two continuous samples."""
38
+ lo = min(np.min(a), np.min(b))
39
+ hi = max(np.max(a), np.max(b))
40
+ if hi <= lo:
41
+ hi = lo + 1e-9
42
+ edges = np.linspace(lo, hi, bins + 1)
43
+ pa, _ = np.histogram(a, bins=edges)
44
+ pb, _ = np.histogram(b, bins=edges)
45
+ pa = pa / max(1, pa.sum())
46
+ pb = pb / max(1, pb.sum())
47
+ return pa + eps, pb + eps
48
+
49
+
50
+ def psi_continuous(expected: np.ndarray, actual: np.ndarray, bins: int = 10) -> float:
51
+ pa, pb = _hist_probs(np.asarray(expected, float), np.asarray(actual, float), bins)
52
+ pa, pb = pa / pa.sum(), pb / pb.sum()
53
+ return float(np.sum((pb - pa) * np.log(pb / pa)))
54
+
55
+
56
+ def psi_categorical(expected: Sequence, actual: Sequence, eps: float = 1e-4) -> float:
57
+ cats = sorted(set(expected) | set(actual))
58
+ e = pd.Series(list(expected)).value_counts(normalize=True)
59
+ a = pd.Series(list(actual)).value_counts(normalize=True)
60
+ pe = np.array([e.get(c, 0.0) + eps for c in cats])
61
+ pa = np.array([a.get(c, 0.0) + eps for c in cats])
62
+ pe, pa = pe / pe.sum(), pa / pa.sum()
63
+ return float(np.sum((pa - pe) * np.log(pa / pe)))
64
+
65
+
66
+ def js_divergence_continuous(a: np.ndarray, b: np.ndarray, bins: int = 10) -> float:
67
+ pa, pb = _hist_probs(np.asarray(a, float), np.asarray(b, float), bins)
68
+ pa, pb = pa / pa.sum(), pb / pb.sum()
69
+ m = 0.5 * (pa + pb)
70
+ return float(0.5 * np.sum(pa * np.log(pa / m)) + 0.5 * np.sum(pb * np.log(pb / m)))
71
+
72
+
73
+ def js_divergence_categorical(a: Sequence, b: Sequence, eps: float = 1e-4) -> float:
74
+ cats = sorted(set(a) | set(b))
75
+ sa = pd.Series(list(a)).value_counts(normalize=True)
76
+ sb = pd.Series(list(b)).value_counts(normalize=True)
77
+ pa = np.array([sa.get(c, 0.0) + eps for c in cats])
78
+ pb = np.array([sb.get(c, 0.0) + eps for c in cats])
79
+ pa, pb = pa / pa.sum(), pb / pb.sum()
80
+ m = 0.5 * (pa + pb)
81
+ return float(0.5 * np.sum(pa * np.log(pa / m)) + 0.5 * np.sum(pb * np.log(pb / m)))
82
+
83
+
84
+ def ks_test(a: np.ndarray, b: np.ndarray) -> Tuple[float, float]:
85
+ res = sps.ks_2samp(a, b)
86
+ return float(res.statistic), float(res.pvalue)
87
+
88
+
89
+ def chi2_test(a: Sequence, b: Sequence) -> Tuple[float, float]:
90
+ cats = sorted(set(a) | set(b))
91
+ ca = pd.Series(list(a)).value_counts()
92
+ cb = pd.Series(list(b)).value_counts()
93
+ table = np.array([[ca.get(c, 0) for c in cats], [cb.get(c, 0) for c in cats]])
94
+ table = table[:, table.sum(axis=0) > 0]
95
+ if table.shape[1] < 2:
96
+ return 0.0, 1.0
97
+ stat, p, _, _ = sps.chi2_contingency(table)
98
+ return float(stat), float(p)
99
+
100
+
101
+ def shift_magnitude_label(psi: float) -> str:
102
+ """Practical-effect label for a PSI value (independent of p-values)."""
103
+ if psi < 0.02:
104
+ return "negligible"
105
+ if psi < 0.1:
106
+ return "small"
107
+ if psi < 0.25:
108
+ return "moderate"
109
+ return "large"
110
+
111
+
112
+ # ---------------------------------------------------- cluster-aware paired delta
113
+
114
+
115
+ def paired_delta_cluster(df: pd.DataFrame, col_a: str = "a_correct",
116
+ col_b: str = "b_correct",
117
+ cluster_col: str = "entity_id") -> Dict:
118
+ """Paired B-A delta with a cluster-robust CI and effective sample size.
119
+
120
+ The per-unit paired differences d_i = b_i - a_i are aggregated per cluster
121
+ (drive segment); the SE of the overall delta uses between-cluster variance
122
+ of cluster sums (standard cluster-robust estimator for a mean). The
123
+ effective sample size is n / design_effect with DE = 1 + (m_bar - 1) * ICC.
124
+ """
125
+ d = (df[col_b].astype(float) - df[col_a].astype(float)).to_numpy()
126
+ n = len(d)
127
+ if n == 0:
128
+ return {"n": 0, "delta": 0.0, "ci_low": 0.0, "ci_high": 0.0,
129
+ "effective_n": 0, "icc": 0.0, "design_effect": 1.0, "se": 0.0}
130
+ delta = float(d.mean())
131
+
132
+ groups = df.groupby(cluster_col).indices
133
+ cluster_sums = np.array([d[idx].sum() for idx in groups.values()])
134
+ cluster_sizes = np.array([len(idx) for idx in groups.values()])
135
+ g = len(cluster_sums)
136
+ if g > 1:
137
+ # Cluster-robust variance of the mean of d.
138
+ resid = cluster_sums - cluster_sizes * delta
139
+ var = float(np.sum(resid ** 2)) * g / (g - 1) / (n ** 2)
140
+ se = math.sqrt(max(var, 1e-12))
141
+ else:
142
+ se = float(d.std(ddof=1) / math.sqrt(n)) if n > 1 else 0.0
143
+
144
+ icc = _icc(d, df[cluster_col].to_numpy())
145
+ m_bar = float(cluster_sizes.mean()) if g else 1.0
146
+ design_effect = max(1.0, 1.0 + (m_bar - 1.0) * max(0.0, icc))
147
+ eff_n = int(n / design_effect)
148
+
149
+ return {
150
+ "n": int(n),
151
+ "n_clusters": int(g),
152
+ "delta": delta,
153
+ "se": se,
154
+ "ci_low": delta - Z95 * se,
155
+ "ci_high": delta + Z95 * se,
156
+ "icc": icc,
157
+ "design_effect": design_effect,
158
+ "effective_n": eff_n,
159
+ }
160
+
161
+
162
+ def _icc(values: np.ndarray, clusters: np.ndarray) -> float:
163
+ """One-way ANOVA intraclass correlation estimate."""
164
+ dfr = pd.DataFrame({"v": values, "c": clusters})
165
+ grp = dfr.groupby("c")["v"]
166
+ k = grp.ngroups
167
+ n = len(dfr)
168
+ if k < 2 or n <= k:
169
+ return 0.0
170
+ grand = dfr["v"].mean()
171
+ m_i = grp.mean()
172
+ n_i = grp.size()
173
+ ss_between = float((n_i * (m_i - grand) ** 2).sum())
174
+ ss_within = float(((dfr["v"] - grp.transform("mean")) ** 2).sum())
175
+ ms_between = ss_between / (k - 1)
176
+ ms_within = ss_within / max(1, n - k)
177
+ n_bar = n / k
178
+ denom = ms_between + (n_bar - 1) * ms_within
179
+ if denom <= 0:
180
+ return 0.0
181
+ return float(max(0.0, (ms_between - ms_within) / denom))
182
+
183
+
184
+ def three_way_outcome(delta: float, ci_low: float, ci_high: float,
185
+ practical_margin: float) -> str:
186
+ """Classify a paired delta CI against a minimum practically-important
187
+ regression margin (expressed as a positive fraction, e.g. 0.01 = 1pp)."""
188
+ if ci_high < -practical_margin:
189
+ return "significant_regression"
190
+ if ci_low > -practical_margin:
191
+ # The CI excludes a practically-important regression entirely.
192
+ return "no_significant_difference"
193
+ return "insufficient_evidence"
sensorflow/rca/store.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistence for RCA investigations under runs/rca/.
2
+
3
+ Layout:
4
+ runs/rca/<investigation_id>/investigation.json
5
+ runs/rca/<investigation_id>/offline.csv
6
+ runs/rca/<investigation_id>/shadow.csv
7
+ runs/rca/<investigation_id>/meta.json
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional
16
+
17
+ import pandas as pd
18
+
19
+ from sensorflow.rca.models import Investigation
20
+ from sensorflow.rca.scenario import ScenarioBundle
21
+
22
+ _DEFAULT_ROOT = "runs/rca"
23
+ _root = Path(_DEFAULT_ROOT)
24
+ _lock = threading.Lock()
25
+ _cache: Dict[str, Investigation] = {}
26
+ _data_cache: Dict[str, ScenarioBundle] = {}
27
+
28
+
29
+ def set_rca_root(path: str) -> None:
30
+ """Point the store somewhere else (tests use a tmp dir)."""
31
+ global _root
32
+ _root = Path(path)
33
+ reset_rca_store()
34
+
35
+
36
+ def reset_rca_store() -> None:
37
+ _cache.clear()
38
+ _data_cache.clear()
39
+
40
+
41
+ def _inv_dir(inv_id: str) -> Path:
42
+ return _root / inv_id
43
+
44
+
45
+ def save_investigation(inv: Investigation,
46
+ bundle: Optional[ScenarioBundle] = None) -> None:
47
+ with _lock:
48
+ d = _inv_dir(inv.id)
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ (d / "investigation.json").write_text(
51
+ json.dumps(inv.to_json_dict(), indent=1))
52
+ if bundle is not None:
53
+ bundle.offline.to_csv(d / "offline.csv", index=False)
54
+ bundle.shadow.to_csv(d / "shadow.csv", index=False)
55
+ (d / "meta.json").write_text(json.dumps(bundle.meta, indent=1))
56
+ _data_cache[inv.id] = bundle
57
+ _cache[inv.id] = inv
58
+
59
+
60
+ def load_investigation(inv_id: str) -> Optional[Investigation]:
61
+ if inv_id in _cache:
62
+ return _cache[inv_id]
63
+ f = _inv_dir(inv_id) / "investigation.json"
64
+ if not f.exists():
65
+ return None
66
+ inv = Investigation.from_json_dict(json.loads(f.read_text()))
67
+ _cache[inv_id] = inv
68
+ return inv
69
+
70
+
71
+ def load_bundle(inv_id: str) -> Optional[ScenarioBundle]:
72
+ if inv_id in _data_cache:
73
+ return _data_cache[inv_id]
74
+ d = _inv_dir(inv_id)
75
+ if not (d / "meta.json").exists():
76
+ return None
77
+ meta = json.loads((d / "meta.json").read_text())
78
+ offline = pd.read_csv(d / "offline.csv")
79
+ shadow = pd.read_csv(d / "shadow.csv")
80
+ bundle = ScenarioBundle(cause=meta["cause"], seed=meta["seed"],
81
+ offline=offline, shadow=shadow, meta=meta)
82
+ _data_cache[inv_id] = bundle
83
+ return bundle
84
+
85
+
86
+ def list_investigations() -> List[Dict]:
87
+ out: List[Dict] = []
88
+ if not _root.exists():
89
+ return out
90
+ for d in sorted(_root.iterdir()):
91
+ f = d / "investigation.json"
92
+ if not f.is_file():
93
+ continue
94
+ inv = load_investigation(d.name)
95
+ if inv is None:
96
+ continue
97
+ done = sum(1 for s in inv.stages
98
+ if s.status in ("complete", "complete_with_unknowns"))
99
+ summary = {
100
+ "id": inv.id,
101
+ "name": inv.name,
102
+ "baseline_model": inv.baseline_model,
103
+ "candidate_model": inv.candidate_model,
104
+ "training_mode": inv.training_mode,
105
+ "revealed": inv.revealed,
106
+ "created_at": inv.created_at,
107
+ "claims": inv.claims,
108
+ "stages_complete": done,
109
+ "stages_total": len(inv.stages),
110
+ }
111
+ if not inv.training_mode or inv.revealed:
112
+ summary["scenario_cause"] = inv.scenario_cause
113
+ out.append(summary)
114
+ out.sort(key=lambda r: r["created_at"], reverse=True)
115
+ return out
src/App.tsx CHANGED
@@ -18,6 +18,7 @@ import {
18
  Radar,
19
  ShieldCheck,
20
  TrendingDown,
 
21
  Filter,
22
  UserCheck,
23
  Rocket,
@@ -46,6 +47,7 @@ import PipelineArchitecturePage from './pages/PipelineArchitecturePage';
46
  import SSAMSafetyDashboard from './pages/SSAMSafetyDashboard';
47
  import LegacyStudioPage from './pages/LegacyStudioPage';
48
  import CommandCenterPage from './pages/CommandCenterPage';
 
49
  import PageIntro from './components/help/PageIntro';
50
  import HelpMenu from './components/help/HelpMenu';
51
 
@@ -65,6 +67,7 @@ const PLATFORM_NAV: NavItem[] = [
65
  { id: 'rare-events', label: 'Rare Events', icon: <Radar size={18} /> },
66
  { id: 'quality', label: 'Quality Engine', icon: <ShieldCheck size={18} /> },
67
  { id: 'regression', label: 'Regression', icon: <TrendingDown size={18} /> },
 
68
  { id: 'triage', label: 'Triage', icon: <Filter size={18} /> },
69
  { id: 'review', label: 'Human Review', icon: <UserCheck size={18} /> },
70
  { id: 'training', label: 'Training', icon: <Rocket size={18} /> },
@@ -87,6 +90,7 @@ const PAGE_TITLES: Record<PageId, string> = {
87
  'rare-events': 'Rare Event Detection',
88
  quality: 'Quality Engine',
89
  regression: 'Regression Tracking',
 
90
  triage: 'Automated Triage',
91
  review: 'Human Review (HITL)',
92
  training: 'Training Flywheel',
@@ -291,6 +295,7 @@ export default function App() {
291
  {page === 'rare-events' ? <RareEventDashboard /> : null}
292
  {page === 'quality' ? <QualityEnginePage /> : null}
293
  {page === 'regression' ? <RegressionPage /> : null}
 
294
  {page === 'triage' ? <TriagePage /> : null}
295
  {page === 'review' ? <HumanReviewPage /> : null}
296
  {page === 'training' ? <TrainingPage /> : null}
 
18
  Radar,
19
  ShieldCheck,
20
  TrendingDown,
21
+ Microscope,
22
  Filter,
23
  UserCheck,
24
  Rocket,
 
47
  import SSAMSafetyDashboard from './pages/SSAMSafetyDashboard';
48
  import LegacyStudioPage from './pages/LegacyStudioPage';
49
  import CommandCenterPage from './pages/CommandCenterPage';
50
+ import RootCauseLabPage from './pages/rca/RootCauseLabPage';
51
  import PageIntro from './components/help/PageIntro';
52
  import HelpMenu from './components/help/HelpMenu';
53
 
 
67
  { id: 'rare-events', label: 'Rare Events', icon: <Radar size={18} /> },
68
  { id: 'quality', label: 'Quality Engine', icon: <ShieldCheck size={18} /> },
69
  { id: 'regression', label: 'Regression', icon: <TrendingDown size={18} /> },
70
+ { id: 'rca', label: 'Root Cause Lab', icon: <Microscope size={18} /> },
71
  { id: 'triage', label: 'Triage', icon: <Filter size={18} /> },
72
  { id: 'review', label: 'Human Review', icon: <UserCheck size={18} /> },
73
  { id: 'training', label: 'Training', icon: <Rocket size={18} /> },
 
90
  'rare-events': 'Rare Event Detection',
91
  quality: 'Quality Engine',
92
  regression: 'Regression Tracking',
93
+ rca: 'Root Cause Lab',
94
  triage: 'Automated Triage',
95
  review: 'Human Review (HITL)',
96
  training: 'Training Flywheel',
 
295
  {page === 'rare-events' ? <RareEventDashboard /> : null}
296
  {page === 'quality' ? <QualityEnginePage /> : null}
297
  {page === 'regression' ? <RegressionPage /> : null}
298
+ {page === 'rca' ? <RootCauseLabPage /> : null}
299
  {page === 'triage' ? <TriagePage /> : null}
300
  {page === 'review' ? <HumanReviewPage /> : null}
301
  {page === 'training' ? <TrainingPage /> : null}
src/components/help/HelpMenu.tsx CHANGED
@@ -175,6 +175,7 @@ const PAGE_ORDER: PageId[] = [
175
  'rare-events',
176
  'quality',
177
  'regression',
 
178
  'triage',
179
  'review',
180
  'training',
@@ -194,6 +195,7 @@ const PAGE_NAMES: Record<PageId, string> = {
194
  'rare-events': 'Rare Events',
195
  quality: 'Quality Engine',
196
  regression: 'Regression',
 
197
  triage: 'Triage',
198
  review: 'Human Review',
199
  training: 'Training',
 
175
  'rare-events',
176
  'quality',
177
  'regression',
178
+ 'rca',
179
  'triage',
180
  'review',
181
  'training',
 
195
  'rare-events': 'Rare Events',
196
  quality: 'Quality Engine',
197
  regression: 'Regression',
198
+ rca: 'Root Cause Lab',
199
  triage: 'Triage',
200
  review: 'Human Review',
201
  training: 'Training',
src/components/rca/ReportView.tsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Stage 12: recommended experiments, minimum-additional-evidence answer,
2
+ * rendered final report with remediation tiers, JSON/markdown export, and the
3
+ * training-mode reveal. */
4
+ import { useState } from 'react';
5
+ import Box from '@mui/material/Box';
6
+ import Button from '@mui/material/Button';
7
+ import Chip from '@mui/material/Chip';
8
+ import Collapse from '@mui/material/Collapse';
9
+ import Typography from '@mui/material/Typography';
10
+ import { Download, Eye, FileJson } from 'lucide-react';
11
+ import type { Experiments, Investigation, RcaReport, RevealResponse } from '../../types/rca';
12
+ import { BORDER, CONFIDENCE_COLORS, Explainer, SectionCard, StatusPill, tableSx } from './common';
13
+
14
+ function download(filename: string, text: string, mime: string) {
15
+ const blob = new Blob([text], { type: mime });
16
+ const url = URL.createObjectURL(blob);
17
+ const a = document.createElement('a');
18
+ a.href = url;
19
+ a.download = filename;
20
+ a.click();
21
+ URL.revokeObjectURL(url);
22
+ }
23
+
24
+ const COST_COLORS: Record<string, string> = { low: '#66bb6a', medium: '#ffb74d', high: '#ef5350' };
25
+
26
+ export function ExperimentsView({ exp }: { exp: Experiments }) {
27
+ return (
28
+ <>
29
+ <Explainer text={exp.explainer} />
30
+ <SectionCard title="Minimum additional evidence required">
31
+ <Typography variant="body2" sx={{ fontSize: 12.5, color: '#e3eaf0' }}>
32
+ {exp.minimum_additional_evidence}
33
+ </Typography>
34
+ <Box sx={{ display: 'flex', gap: 3, mt: 1 }}>
35
+ <Typography variant="caption" sx={{ color: '#8a949e' }}>
36
+ Current effective n: <b>{exp.power.effective_n.toLocaleString()}</b>
37
+ </Typography>
38
+ <Typography variant="caption" sx={{ color: '#8a949e' }}>
39
+ Needed for the {exp.power.practical_margin_pp.toFixed(1)}pp margin:{' '}
40
+ <b>{exp.power.needed_effective_n.toLocaleString()}</b>
41
+ </Typography>
42
+ </Box>
43
+ </SectionCard>
44
+ <SectionCard title="Recommended experiments — ranked by information gain / cost">
45
+ <Box component="table" sx={tableSx}>
46
+ <thead>
47
+ <tr>
48
+ <th>#</th><th>Design</th><th>Instantiated for this investigation</th>
49
+ <th>Discriminates</th><th>Cost</th><th>Gain</th><th>Priority</th>
50
+ </tr>
51
+ </thead>
52
+ <tbody>
53
+ {exp.experiments.map((d) => (
54
+ <tr key={d.id}>
55
+ <td style={{ fontWeight: 800 }}>{d.rank}</td>
56
+ <td style={{ fontWeight: 700, whiteSpace: 'nowrap' }}>{d.design}</td>
57
+ <td><Typography variant="caption" sx={{ color: '#cfd8e0' }}>{d.description}</Typography></td>
58
+ <td>
59
+ {d.discriminates.map((h) => (
60
+ <Chip key={h} size="small" label={h} sx={{ height: 17, fontSize: 9.5, mr: 0.25, mb: 0.25, bgcolor: '#232a31' }} />
61
+ ))}
62
+ </td>
63
+ <td>
64
+ <Chip size="small" label={`${d.cost} · ~${d.expected_days}d`} sx={{
65
+ height: 18, fontSize: 10, fontWeight: 700,
66
+ bgcolor: `${COST_COLORS[d.cost]}22`, color: COST_COLORS[d.cost],
67
+ }} />
68
+ </td>
69
+ <td style={{ fontFamily: 'monospace' }}>{d.information_gain.toFixed(1)}</td>
70
+ <td style={{ fontFamily: 'monospace', fontWeight: 800 }}>{d.priority.toFixed(1)}</td>
71
+ </tr>
72
+ ))}
73
+ </tbody>
74
+ </Box>
75
+ </SectionCard>
76
+ </>
77
+ );
78
+ }
79
+
80
+ export function ReportView({ report, inv, reveal, onReveal }: {
81
+ report: RcaReport;
82
+ inv: Investigation;
83
+ reveal: RevealResponse | null;
84
+ onReveal: () => void;
85
+ }) {
86
+ const [showMd, setShowMd] = useState(false);
87
+ const ef = report.executive_finding;
88
+ const confColor = CONFIDENCE_COLORS[ef.confidence];
89
+ return (
90
+ <>
91
+ <SectionCard
92
+ title="Executive finding"
93
+ action={
94
+ <Box sx={{ display: 'flex', gap: 1 }}>
95
+ <Button size="small" startIcon={<FileJson size={14} />} onClick={() => download(`${report.investigation_id}-report.json`, JSON.stringify(report, null, 2), 'application/json')}>
96
+ Export JSON
97
+ </Button>
98
+ <Button size="small" startIcon={<Download size={14} />} onClick={() => download(`${report.investigation_id}-report.md`, report.markdown, 'text/markdown')}>
99
+ Export MD
100
+ </Button>
101
+ </Box>
102
+ }
103
+ >
104
+ <Typography variant="h6" sx={{ fontWeight: 800, fontSize: 17 }}>
105
+ {ef.conclusion ?? ef.top_hypothesis}
106
+ <Chip size="small" label={`confidence: ${ef.confidence}`} sx={{ ml: 1.5, fontWeight: 800, bgcolor: `${confColor}22`, color: confColor, border: `1px solid ${confColor}55` }} />
107
+ </Typography>
108
+ <Typography variant="body2" sx={{ color: '#aab4be', mt: 0.5 }}>
109
+ {ef.label} — score {ef.score} (gap to runner-up {ef.score_gap_to_runner_up}). Claims under
110
+ investigation: offline {report.claims.offline_delta_pp >= 0 ? '+' : ''}{report.claims.offline_delta_pp}pp vs shadow{' '}
111
+ {report.claims.shadow_delta_pp}pp on {report.claims.metric}.
112
+ </Typography>
113
+ </SectionCard>
114
+
115
+ <SectionCard title="Per-stage summary">
116
+ <Box component="table" sx={tableSx}>
117
+ <thead>
118
+ <tr><th>Stage</th><th>Question</th><th>Verdict</th><th>Mismatches / unknowns</th></tr>
119
+ </thead>
120
+ <tbody>
121
+ {report.stage_summaries.map((s) => (
122
+ <tr key={s.stage}>
123
+ <td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.stage}</td>
124
+ <td><Typography variant="caption" sx={{ color: '#8a949e' }}>{s.headline}</Typography></td>
125
+ <td><StatusPill status={s.verdict === 'clean' ? 'PASS' : s.verdict === 'mismatch' ? 'MISMATCH' : 'UNKNOWN'} /></td>
126
+ <td>
127
+ <Typography variant="caption">
128
+ {[...s.mismatches, ...s.unknowns].join('; ') || '—'}
129
+ </Typography>
130
+ </td>
131
+ </tr>
132
+ ))}
133
+ </tbody>
134
+ </Box>
135
+ </SectionCard>
136
+
137
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
138
+ {(['containment', 'short_term', 'long_term'] as const).map((tier) => (
139
+ <SectionCard key={tier} title={tier === 'containment' ? 'Containment (now)' : tier === 'short_term' ? 'Short term' : 'Long term'}>
140
+ {report.remediation[tier].map((x) => (
141
+ <Typography key={x} variant="body2" sx={{ fontSize: 12, py: 0.25, color: '#cfd8e0' }}>
142
+ • {x}
143
+ </Typography>
144
+ ))}
145
+ </SectionCard>
146
+ ))}
147
+ </Box>
148
+
149
+ {report.acknowledged_unknowns.length ? (
150
+ <SectionCard title="Unknowns explicitly acknowledged during this investigation">
151
+ {report.acknowledged_unknowns.map((e, i) => (
152
+ <Typography key={i} variant="caption" sx={{ display: 'block', color: '#ffb74d' }}>
153
+ ⚠ {e.message} — note: {String((e.data as any)?.note ?? '')}
154
+ </Typography>
155
+ ))}
156
+ </SectionCard>
157
+ ) : null}
158
+
159
+ <SectionCard
160
+ title="Raw markdown"
161
+ action={<Button size="small" onClick={() => setShowMd((s) => !s)}>{showMd ? 'hide' : 'show'}</Button>}
162
+ >
163
+ <Collapse in={showMd}>
164
+ <Box component="pre" sx={{ fontSize: 11, fontFamily: 'monospace', whiteSpace: 'pre-wrap', color: '#aab4be', m: 0, maxHeight: 420, overflowY: 'auto' }}>
165
+ {report.markdown}
166
+ </Box>
167
+ </Collapse>
168
+ </SectionCard>
169
+
170
+ {inv.training_mode ? (
171
+ <SectionCard title="Training mode — the answer key">
172
+ {reveal ? (
173
+ <Box sx={{ p: 1.25, borderRadius: 1, border: `1px solid ${BORDER}`, bgcolor: '#0d1116' }}>
174
+ <Typography variant="body2" sx={{ fontWeight: 800, color: reveal.cause === ef.top_hypothesis ? '#66bb6a' : '#ef5350' }}>
175
+ Planted cause: {reveal.cause}{' '}
176
+ {reveal.cause === ef.top_hypothesis ? '— your board got it right ✓' : `— your board's top pick was ${ef.top_hypothesis} ✗`}
177
+ </Typography>
178
+ <Typography variant="caption" sx={{ color: '#aab4be' }}>{reveal.explanation}</Typography>
179
+ </Box>
180
+ ) : (
181
+ <Button variant="outlined" size="small" startIcon={<Eye size={14} />} onClick={onReveal}>
182
+ Reveal the planted root cause
183
+ </Button>
184
+ )}
185
+ </SectionCard>
186
+ ) : null}
187
+ </>
188
+ );
189
+ }
src/components/rca/StageRail.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Guided left rail: the 13 ordered stages with status and enforced locking.
2
+ * A stage is viewable only when every earlier stage is complete (or complete
3
+ * with acknowledged unknowns) — plausible early explanations don't unlock
4
+ * later stages; finishing the methodology does. */
5
+ import Box from '@mui/material/Box';
6
+ import LinearProgress from '@mui/material/LinearProgress';
7
+ import List from '@mui/material/List';
8
+ import ListItemButton from '@mui/material/ListItemButton';
9
+ import Tooltip from '@mui/material/Tooltip';
10
+ import Typography from '@mui/material/Typography';
11
+ import { CheckCircle2, CircleDot, Lock, TriangleAlert } from 'lucide-react';
12
+ import type { StageState } from '../../types/rca';
13
+ import { ACCENT, BG_PANEL, BORDER } from './common';
14
+
15
+ export function firstIncompleteIndex(stages: StageState[]): number {
16
+ for (const s of stages) {
17
+ if (s.status !== 'complete' && s.status !== 'complete_with_unknowns') return s.index;
18
+ }
19
+ return stages.length;
20
+ }
21
+
22
+ export default function StageRail({ stages, selected, onSelect }: {
23
+ stages: StageState[];
24
+ selected: number;
25
+ onSelect: (index: number) => void;
26
+ }) {
27
+ const frontier = firstIncompleteIndex(stages);
28
+ const done = stages.filter(
29
+ (s) => s.status === 'complete' || s.status === 'complete_with_unknowns'
30
+ ).length;
31
+
32
+ return (
33
+ <Box
34
+ sx={{
35
+ width: 250,
36
+ flexShrink: 0,
37
+ borderRight: `1px solid ${BORDER}`,
38
+ bgcolor: BG_PANEL,
39
+ overflowY: 'auto',
40
+ }}
41
+ >
42
+ <Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
43
+ <Typography variant="caption" sx={{ color: '#8a949e', fontWeight: 700, letterSpacing: 0.8 }}>
44
+ METHODOLOGY · {done}/{stages.length}
45
+ </Typography>
46
+ <LinearProgress
47
+ variant="determinate"
48
+ value={(done / stages.length) * 100}
49
+ sx={{ mt: 0.5, height: 5, borderRadius: 2, bgcolor: '#0d1116' }}
50
+ />
51
+ </Box>
52
+ <List dense sx={{ py: 0 }}>
53
+ {stages.map((s) => {
54
+ const locked = s.index > frontier;
55
+ const isSel = s.index === selected;
56
+ const icon =
57
+ s.status === 'complete' ? (
58
+ <CheckCircle2 size={15} color="#66bb6a" />
59
+ ) : s.status === 'complete_with_unknowns' ? (
60
+ <TriangleAlert size={15} color="#ffb74d" />
61
+ ) : locked ? (
62
+ <Lock size={14} color="#5c666f" />
63
+ ) : (
64
+ <CircleDot size={15} color={ACCENT} />
65
+ );
66
+ const body = (
67
+ <ListItemButton
68
+ key={s.key}
69
+ dense
70
+ disabled={locked}
71
+ selected={isSel}
72
+ onClick={() => onSelect(s.index)}
73
+ sx={{
74
+ py: 0.5,
75
+ px: 1.25,
76
+ gap: 1,
77
+ '&.Mui-selected': { bgcolor: 'rgba(79,195,247,0.12)' },
78
+ }}
79
+ >
80
+ <Box sx={{ width: 18, display: 'flex', justifyContent: 'center', flexShrink: 0 }}>{icon}</Box>
81
+ <Box sx={{ minWidth: 0 }}>
82
+ <Typography
83
+ variant="body2"
84
+ sx={{
85
+ fontSize: 12.5,
86
+ fontWeight: isSel ? 700 : 500,
87
+ color: locked ? '#5c666f' : isSel ? ACCENT : '#cfd8e0',
88
+ whiteSpace: 'nowrap',
89
+ overflow: 'hidden',
90
+ textOverflow: 'ellipsis',
91
+ }}
92
+ >
93
+ {s.index}. {s.title}
94
+ </Typography>
95
+ {s.status === 'complete_with_unknowns' ? (
96
+ <Typography variant="caption" sx={{ color: '#ffb74d', fontSize: 10 }}>
97
+ proceeded with unknowns
98
+ </Typography>
99
+ ) : null}
100
+ </Box>
101
+ </ListItemButton>
102
+ );
103
+ return locked ? (
104
+ <Tooltip
105
+ key={s.key}
106
+ title="Locked: complete the earlier stages first. The methodology is ordered on purpose — an early plausible explanation is not a license to skip measurement-validity checks."
107
+ placement="right"
108
+ arrow
109
+ >
110
+ <span>{body}</span>
111
+ </Tooltip>
112
+ ) : (
113
+ body
114
+ );
115
+ })}
116
+ </List>
117
+ </Box>
118
+ );
119
+ }
src/components/rca/boardViews.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Stage 11 views: the 8-hypothesis Root Cause Board (auto + human
2
+ * assessments, evidence links that jump to stage findings) and the decision
3
+ * tree with the live path highlighted. */
4
+ import { useState } from 'react';
5
+ import Box from '@mui/material/Box';
6
+ import Chip from '@mui/material/Chip';
7
+ import IconButton from '@mui/material/IconButton';
8
+ import MenuItem from '@mui/material/MenuItem';
9
+ import Select from '@mui/material/Select';
10
+ import TextField from '@mui/material/TextField';
11
+ import Tooltip from '@mui/material/Tooltip';
12
+ import Typography from '@mui/material/Typography';
13
+ import { ChevronDown, ChevronRight, Link2 } from 'lucide-react';
14
+ import type { Confidence, DecisionTree, EvidenceLink, RootCause, Scoreboard } from '../../types/rca';
15
+ import { BORDER, CONFIDENCE_COLORS, Explainer, SectionCard, SEVERITY_COLORS, tableSx } from './common';
16
+
17
+ function ConfidenceChip({ c }: { c: Confidence | null }) {
18
+ if (!c) return <Typography variant="caption" sx={{ color: '#5c666f' }}>—</Typography>;
19
+ return (
20
+ <Chip size="small" label={c} sx={{
21
+ height: 18, fontSize: 10, fontWeight: 800,
22
+ bgcolor: `${CONFIDENCE_COLORS[c]}22`, color: CONFIDENCE_COLORS[c],
23
+ border: `1px solid ${CONFIDENCE_COLORS[c]}55`,
24
+ }} />
25
+ );
26
+ }
27
+
28
+ function EvidenceList({ items, sign, onJump }: {
29
+ items: EvidenceLink[];
30
+ sign: '+' | '−';
31
+ onJump: (stage: string) => void;
32
+ }) {
33
+ if (!items.length) return <Typography variant="caption" sx={{ color: '#5c666f' }}>none</Typography>;
34
+ return (
35
+ <Box>
36
+ {items.map((e) => (
37
+ <Box key={`${e.code}-${e.finding_id}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.15 }}>
38
+ <Typography variant="caption" sx={{ fontFamily: 'monospace', color: sign === '+' ? '#a5d6a7' : '#ef9a9a', minWidth: 34 }}>
39
+ {sign}{e.weight.toFixed(1)}
40
+ </Typography>
41
+ <Tooltip title={`[${e.code}] from stage "${e.stage}" — click to open that stage`} arrow>
42
+ <Chip
43
+ size="small"
44
+ icon={<Link2 size={11} />}
45
+ label={e.title}
46
+ onClick={() => onJump(e.stage)}
47
+ sx={{
48
+ height: 19, fontSize: 10.5, maxWidth: 340,
49
+ bgcolor: '#232a31',
50
+ color: e.severity === 'CRITICAL' ? SEVERITY_COLORS.CRITICAL : '#cfd8e0',
51
+ '.MuiChip-icon': { color: '#8a949e', ml: 0.5 },
52
+ }}
53
+ />
54
+ </Tooltip>
55
+ </Box>
56
+ ))}
57
+ </Box>
58
+ );
59
+ }
60
+
61
+ export function ScoreBoardView({ board, onJumpToStage, onAssess }: {
62
+ board: Scoreboard;
63
+ onJumpToStage: (stageKey: string) => void;
64
+ onAssess: (hypothesis: RootCause, confidence: Confidence, note: string) => void;
65
+ }) {
66
+ const [open, setOpen] = useState<Record<string, boolean>>({ [board.rows[0]?.hypothesis]: true });
67
+ const [notes, setNotes] = useState<Record<string, string>>({});
68
+ return (
69
+ <>
70
+ <Explainer text={board.explainer} />
71
+ <SectionCard title="Root Cause Board — 8 hypotheses, scored from the evidence">
72
+ <Box component="table" sx={tableSx}>
73
+ <thead>
74
+ <tr>
75
+ <th /><th>#</th><th>Hypothesis</th><th>Score</th>
76
+ <th>Auto confidence</th><th>Your assessment</th><th>Next discriminating test</th>
77
+ </tr>
78
+ </thead>
79
+ <tbody>
80
+ {board.rows.map((r) => (
81
+ <>
82
+ <tr key={r.hypothesis} style={r.rank === 1 ? { background: '#4fc3f70d' } : undefined}>
83
+ <td>
84
+ <IconButton size="small" onClick={() => setOpen((o) => ({ ...o, [r.hypothesis]: !o[r.hypothesis] }))}>
85
+ {open[r.hypothesis] ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
86
+ </IconButton>
87
+ </td>
88
+ <td style={{ fontWeight: 800 }}>{r.rank}</td>
89
+ <td>
90
+ <Typography variant="body2" sx={{ fontWeight: r.rank === 1 ? 800 : 600, fontSize: 12.5 }}>
91
+ {r.hypothesis}
92
+ </Typography>
93
+ <Typography variant="caption" sx={{ color: '#8a949e' }}>{r.label}</Typography>
94
+ </td>
95
+ <td style={{ fontFamily: 'monospace', fontWeight: 700, color: r.score > 0 ? '#a5d6a7' : '#8a949e' }}>
96
+ {r.score.toFixed(1)}
97
+ </td>
98
+ <td><ConfidenceChip c={r.auto_confidence} /></td>
99
+ <td>
100
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
101
+ <Select
102
+ size="small"
103
+ value={r.human_confidence ?? ''}
104
+ displayEmpty
105
+ onChange={(e) => onAssess(r.hypothesis, e.target.value as Confidence, notes[r.hypothesis] ?? r.human_note)}
106
+ sx={{ fontSize: 11, height: 26, minWidth: 104, '.MuiSelect-select': { py: 0.25 } }}
107
+ >
108
+ <MenuItem value="" disabled><em>set…</em></MenuItem>
109
+ {(['HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'] as Confidence[]).map((c) => (
110
+ <MenuItem key={c} value={c} sx={{ fontSize: 12 }}>{c}</MenuItem>
111
+ ))}
112
+ </Select>
113
+ {r.human_confidence ? <ConfidenceChip c={r.human_confidence} /> : null}
114
+ </Box>
115
+ </td>
116
+ <td>
117
+ <Typography variant="caption" sx={{ color: '#aab4be', display: 'block', maxWidth: 320 }}>
118
+ {r.next_discriminating_test}
119
+ </Typography>
120
+ </td>
121
+ </tr>
122
+ {open[r.hypothesis] ? (
123
+ <tr key={`${r.hypothesis}-detail`}>
124
+ <td />
125
+ <td colSpan={6}>
126
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2, py: 0.5 }}>
127
+ <Box>
128
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#a5d6a7' }}>EVIDENCE FOR</Typography>
129
+ <EvidenceList items={r.evidence_for} sign="+" onJump={onJumpToStage} />
130
+ </Box>
131
+ <Box>
132
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#ef9a9a' }}>EVIDENCE AGAINST</Typography>
133
+ <EvidenceList items={r.evidence_against} sign="−" onJump={onJumpToStage} />
134
+ </Box>
135
+ <Box>
136
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#8a949e' }}>YOUR NOTE</Typography>
137
+ <TextField
138
+ size="small"
139
+ fullWidth
140
+ multiline
141
+ minRows={2}
142
+ placeholder="Why do you agree/disagree with the auto assessment?"
143
+ defaultValue={r.human_note}
144
+ onChange={(e) => setNotes((n) => ({ ...n, [r.hypothesis]: e.target.value }))}
145
+ onBlur={(e) => {
146
+ if (r.human_confidence && e.target.value !== r.human_note) {
147
+ onAssess(r.hypothesis, r.human_confidence, e.target.value);
148
+ }
149
+ }}
150
+ sx={{ '.MuiInputBase-input': { fontSize: 11.5 } }}
151
+ />
152
+ </Box>
153
+ </Box>
154
+ </td>
155
+ </tr>
156
+ ) : null}
157
+ </>
158
+ ))}
159
+ </tbody>
160
+ </Box>
161
+ </SectionCard>
162
+ </>
163
+ );
164
+ }
165
+
166
+ // --------------------------------------------------------------- decision tree
167
+
168
+ const ANSWER_COLORS = { yes: '#66bb6a', no: '#ef5350', unknown: '#ffb74d' };
169
+
170
+ export function DecisionTreeView({ tree }: { tree: DecisionTree }) {
171
+ const onPath = new Set(tree.path);
172
+ const terminal = tree.path[tree.path.length - 1];
173
+ return (
174
+ <>
175
+ <Explainer text={tree.explainer} />
176
+ <SectionCard title="Decision chain: measurement validity → distribution → parity → significance">
177
+ <Box sx={{ display: 'flex', flexDirection: 'column' }}>
178
+ {tree.nodes.map((n, i) => {
179
+ const active = onPath.has(n.id);
180
+ const isTerminal = n.id === terminal;
181
+ const stoppedHere = isTerminal && (n.answer === 'no' || n.id === 'significant');
182
+ return (
183
+ <Box key={n.id} sx={{ display: 'flex', gap: 1.5, opacity: active ? 1 : 0.42 }}>
184
+ <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: 22 }}>
185
+ <Box sx={{
186
+ width: 14, height: 14, borderRadius: '50%', mt: 0.75, flexShrink: 0,
187
+ bgcolor: ANSWER_COLORS[n.answer],
188
+ boxShadow: active ? `0 0 8px ${ANSWER_COLORS[n.answer]}88` : 'none',
189
+ }} />
190
+ {i < tree.nodes.length - 1 ? (
191
+ <Box sx={{ width: 2, flex: 1, minHeight: 26, bgcolor: active && !stoppedHere ? '#4fc3f7' : BORDER }} />
192
+ ) : null}
193
+ </Box>
194
+ <Box sx={{ pb: 2, minWidth: 0 }}>
195
+ <Typography variant="body2" sx={{ fontWeight: active ? 700 : 500, fontSize: 12.5 }}>
196
+ {n.question}
197
+ </Typography>
198
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap', mt: 0.25 }}>
199
+ <Chip size="small" label={n.answer.toUpperCase()} sx={{
200
+ height: 18, fontSize: 10, fontWeight: 800,
201
+ bgcolor: `${ANSWER_COLORS[n.answer]}22`, color: ANSWER_COLORS[n.answer],
202
+ }} />
203
+ {n.basis.map((b) => (
204
+ <Typography key={b} variant="caption" sx={{ fontFamily: 'monospace', color: '#8a949e', fontSize: 10 }}>
205
+ {b}
206
+ </Typography>
207
+ ))}
208
+ {n.answer === 'no' && n.conclusion_if_no && active ? (
209
+ <Chip size="small" label={`→ ${n.conclusion_if_no}`} sx={{ height: 18, fontSize: 10, fontWeight: 800, bgcolor: '#ef535022', color: '#ef9a9a' }} />
210
+ ) : null}
211
+ </Box>
212
+ </Box>
213
+ </Box>
214
+ );
215
+ })}
216
+ </Box>
217
+ <Box sx={{ mt: 1, p: 1.25, borderRadius: 1, border: `1px solid ${BORDER}`, bgcolor: '#0d1116' }}>
218
+ <Typography variant="caption" sx={{ color: '#8a949e', fontWeight: 700 }}>CHAIN CONCLUSION</Typography>
219
+ <Typography variant="body2" sx={{ fontWeight: 800, color: tree.conclusion_kind === 'insufficient_evidence' ? '#ffb74d' : '#4fc3f7' }}>
220
+ {tree.conclusion_kind === 'insufficient_evidence'
221
+ ? `INSUFFICIENT EVIDENCE (leaning ${tree.conclusion})`
222
+ : tree.conclusion ?? '—'}
223
+ </Typography>
224
+ </Box>
225
+ </SectionCard>
226
+ </>
227
+ );
228
+ }
src/components/rca/chartViews.tsx ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Visualization-heavy stage views: distribution shift, conditional
2
+ * performance heatmap (Simpson's paradox surface), paired transition matrix,
3
+ * significance CI plot, feature parity ranked deltas. */
4
+ import Box from '@mui/material/Box';
5
+ import Chip from '@mui/material/Chip';
6
+ import Tooltip from '@mui/material/Tooltip';
7
+ import Typography from '@mui/material/Typography';
8
+ import {
9
+ BORDER,
10
+ DeltaBar,
11
+ Explainer,
12
+ KV,
13
+ PsiBadge,
14
+ SectionCard,
15
+ StatusPill,
16
+ tableSx,
17
+ } from './common';
18
+
19
+ // ------------------------------------------------ stage 3: distribution shift
20
+
21
+ export function ShiftView({ data }: { data: any }) {
22
+ if (data.volume_ok === false) {
23
+ return (
24
+ <>
25
+ <Explainer text={data.explainer} />
26
+ <SectionCard title="Distribution shift">
27
+ <Typography variant="body2" sx={{ color: '#ffb74d' }}>
28
+ Too little data to assess shift: PSI estimates are upward-biased at
29
+ this volume, so shift can be neither confirmed nor excluded. This
30
+ is itself a finding — record it and proceed with the unknown
31
+ acknowledged.
32
+ </Typography>
33
+ </SectionCard>
34
+ </>
35
+ );
36
+ }
37
+ const renderRows = (rows: any[]) => (
38
+ <Box component="table" sx={tableSx}>
39
+ <thead>
40
+ <tr>
41
+ <th>Dimension</th>
42
+ <th>PSI (practical effect)</th>
43
+ <th>JS div</th>
44
+ <th>Test</th>
45
+ <th>p-value (see caveat)</th>
46
+ <th>Means (off → sh)</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ {rows.map((r: any) => (
51
+ <tr key={r.dimension}>
52
+ <td style={{ fontWeight: 600 }}>{r.dimension}</td>
53
+ <td><PsiBadge psi={r.psi} /></td>
54
+ <td style={{ fontFamily: 'monospace' }}>{r.js.toFixed(3)}</td>
55
+ <td style={{ fontFamily: 'monospace' }}>{r.test}={r.stat.toFixed(2)}</td>
56
+ <td style={{ fontFamily: 'monospace', color: r.p_value < 0.05 ? '#ffb74d' : '#8a949e' }}>
57
+ {r.p_value < 0.001 ? '<0.001' : r.p_value.toFixed(3)}
58
+ </td>
59
+ <td style={{ fontFamily: 'monospace', fontSize: 11 }}>
60
+ {r.offline_mean !== undefined
61
+ ? `${r.offline_mean.toFixed(1)} → ${r.shadow_mean.toFixed(1)}`
62
+ : '—'}
63
+ </td>
64
+ </tr>
65
+ ))}
66
+ </tbody>
67
+ </Box>
68
+ );
69
+ return (
70
+ <>
71
+ <Explainer text={data.explainer} />
72
+ <SectionCard title="Population mix (segment dimensions)">
73
+ {renderRows(data.segments)}
74
+ </SectionCard>
75
+ <SectionCard title="Feature distributions" subtitle="An isolated large shift in ONE feature while segment mixes stay stable points at a pipeline artifact, not a population change — cross-check with Feature Parity (stage 7).">
76
+ {renderRows(data.features)}
77
+ </SectionCard>
78
+ <Typography variant="caption" sx={{ color: '#ffb74d' }}>
79
+ ⚠ {data.caveat}
80
+ </Typography>
81
+ </>
82
+ );
83
+ }
84
+
85
+ // ------------------------------------------ stage 4: conditional performance
86
+
87
+ function heatColor(v: number, cap = 12): string {
88
+ const t = Math.max(-1, Math.min(1, v / cap));
89
+ if (t >= 0) {
90
+ const a = Math.round(40 + t * 150);
91
+ return `rgba(102, 187, 106, ${a / 255})`;
92
+ }
93
+ const a = Math.round(40 + -t * 170);
94
+ return `rgba(239, 83, 80, ${a / 255})`;
95
+ }
96
+
97
+ export function ConditionalHeatmapView({ data }: { data: any }) {
98
+ const rows: any[] = data.rows;
99
+ const scenes = Array.from(new Set(rows.map((r) => r.scene))).sort();
100
+ const times = Array.from(new Set(rows.map((r) => r.time_of_day))).sort();
101
+ const cell = (scene: string, tod: string) => rows.find((r) => r.scene === scene && r.time_of_day === tod);
102
+
103
+ const grid = (which: 'offline_delta_pp' | 'shadow_delta_pp', title: string) => (
104
+ <Box>
105
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#aab4be', display: 'block', mb: 0.5 }}>
106
+ {title}
107
+ </Typography>
108
+ <Box component="table" sx={{ borderCollapse: 'collapse' }}>
109
+ <thead>
110
+ <tr>
111
+ <th />
112
+ {times.map((t) => (
113
+ <Box component="th" key={t} sx={{ fontSize: 11, color: '#8a949e', px: 1, pb: 0.5 }}>{t}</Box>
114
+ ))}
115
+ </tr>
116
+ </thead>
117
+ <tbody>
118
+ {scenes.map((s) => (
119
+ <tr key={s}>
120
+ <Box component="td" sx={{ fontSize: 11, color: '#8a949e', pr: 1, textAlign: 'right' }}>{s}</Box>
121
+ {times.map((t) => {
122
+ const c = cell(s, t);
123
+ const v = c ? c[which] : 0;
124
+ return (
125
+ <td key={t}>
126
+ <Tooltip
127
+ arrow
128
+ title={c ? `${s}/${t}: offline ${c.offline_delta_pp.toFixed(1)}pp · shadow ${c.shadow_delta_pp.toFixed(1)}pp · n=${c.shadow_n} · ${c.interpretation}` : ''}
129
+ >
130
+ <Box
131
+ sx={{
132
+ width: 86,
133
+ height: 40,
134
+ m: 0.25,
135
+ borderRadius: 1,
136
+ display: 'flex',
137
+ alignItems: 'center',
138
+ justifyContent: 'center',
139
+ bgcolor: heatColor(v),
140
+ border: c?.interpretation === 'sign_flip' ? '2px dashed #ffb74d' : `1px solid ${BORDER}`,
141
+ fontFamily: 'monospace',
142
+ fontSize: 12,
143
+ fontWeight: 700,
144
+ }}
145
+ >
146
+ {v >= 0 ? '+' : ''}{v.toFixed(1)}
147
+ </Box>
148
+ </Tooltip>
149
+ </td>
150
+ );
151
+ })}
152
+ </tr>
153
+ ))}
154
+ </tbody>
155
+ </Box>
156
+ </Box>
157
+ );
158
+
159
+ const agg = data.aggregate;
160
+ return (
161
+ <>
162
+ <Explainer text={data.explainer} />
163
+ <SectionCard
164
+ title="Segment performance heatmap (B − A, pp)"
165
+ subtitle="Dashed amber border = sign flip between environments. If per-segment colors AGREE while the aggregates disagree, the mix — not the model — flipped the sign (Simpson's paradox)."
166
+ >
167
+ <Box sx={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
168
+ {grid('offline_delta_pp', `OFFLINE (aggregate ${agg.offline_delta_pp >= 0 ? '+' : ''}${agg.offline_delta_pp.toFixed(1)}pp)`)}
169
+ {grid('shadow_delta_pp', `SHADOW (aggregate ${agg.shadow_delta_pp >= 0 ? '+' : ''}${agg.shadow_delta_pp.toFixed(1)}pp)`)}
170
+ </Box>
171
+ <Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1 }}>
172
+ Within-segment sign consistency: {(agg.sign_consistency * 100).toFixed(0)}% of scored volume.
173
+ </Typography>
174
+ </SectionCard>
175
+ <SectionCard title="Segment detail">
176
+ <Box component="table" sx={tableSx}>
177
+ <thead>
178
+ <tr>
179
+ <th>Segment</th><th>Offline Δ</th><th>Shadow Δ</th><th>Vol (off/sh)</th><th>Shift ×</th><th>Interpretation</th>
180
+ </tr>
181
+ </thead>
182
+ <tbody>
183
+ {rows.map((r) => (
184
+ <tr key={r.segment}>
185
+ <td style={{ fontWeight: 600 }}>{r.segment}</td>
186
+ <td><DeltaBar value={r.offline_delta_pp} max={18} width={100} /></td>
187
+ <td><DeltaBar value={r.shadow_delta_pp} max={18} width={100} /></td>
188
+ <td style={{ fontFamily: 'monospace', fontSize: 11 }}>{r.offline_n}/{r.shadow_n}</td>
189
+ <td style={{ fontFamily: 'monospace' }}>{r.shift_ratio.toFixed(2)}</td>
190
+ <td>
191
+ <Chip size="small" label={r.interpretation} sx={{
192
+ height: 18, fontSize: 10,
193
+ bgcolor: r.interpretation === 'sign_flip' ? '#ffb74d22' : '#232a31',
194
+ color: r.interpretation === 'sign_flip' ? '#ffb74d' : '#aab4be',
195
+ }} />
196
+ </td>
197
+ </tr>
198
+ ))}
199
+ </tbody>
200
+ </Box>
201
+ </SectionCard>
202
+ </>
203
+ );
204
+ }
205
+
206
+ // ------------------------------------------------ stage 5: paired comparison
207
+
208
+ function TransitionMatrix({ m, title }: { m: any; title: string }) {
209
+ const cellSx = (bg: string, color: string) => ({
210
+ width: 130, height: 62, borderRadius: 1, m: 0.25, p: 1,
211
+ bgcolor: bg, border: `1px solid ${BORDER}`,
212
+ display: 'flex', flexDirection: 'column', justifyContent: 'center', color,
213
+ });
214
+ return (
215
+ <Box>
216
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#aab4be' }}>{title} (n={m.n.toLocaleString()})</Typography>
217
+ <Box sx={{ display: 'grid', gridTemplateColumns: 'auto auto auto', alignItems: 'center', mt: 0.5 }}>
218
+ <Box />
219
+ <Typography variant="caption" sx={{ textAlign: 'center', color: '#8a949e' }}>B correct</Typography>
220
+ <Typography variant="caption" sx={{ textAlign: 'center', color: '#8a949e' }}>B incorrect</Typography>
221
+ <Typography variant="caption" sx={{ color: '#8a949e', pr: 0.5 }}>A correct</Typography>
222
+ <Box sx={cellSx('#20302144', '#a5d6a7')}>
223
+ <Typography variant="caption">both correct</Typography>
224
+ <Typography sx={{ fontFamily: 'monospace', fontWeight: 800 }}>{m.both_correct.toLocaleString()}</Typography>
225
+ </Box>
226
+ <Box sx={cellSx('#ef535022', '#ef9a9a')}>
227
+ <Typography variant="caption" sx={{ fontWeight: 700 }}>REGRESSIONS ↓</Typography>
228
+ <Typography sx={{ fontFamily: 'monospace', fontWeight: 800 }}>{m.regressions.toLocaleString()}</Typography>
229
+ </Box>
230
+ <Typography variant="caption" sx={{ color: '#8a949e', pr: 0.5 }}>A incorrect</Typography>
231
+ <Box sx={cellSx('#66bb6a22', '#a5d6a7')}>
232
+ <Typography variant="caption" sx={{ fontWeight: 700 }}>improvements ↑</Typography>
233
+ <Typography sx={{ fontFamily: 'monospace', fontWeight: 800 }}>{m.improvements.toLocaleString()}</Typography>
234
+ </Box>
235
+ <Box sx={cellSx('#232a3155', '#90a4ae')}>
236
+ <Typography variant="caption">both wrong</Typography>
237
+ <Typography sx={{ fontFamily: 'monospace', fontWeight: 800 }}>{m.both_wrong.toLocaleString()}</Typography>
238
+ </Box>
239
+ </Box>
240
+ <Typography variant="caption" sx={{ color: '#8a949e' }}>net {m.net_pp >= 0 ? '+' : ''}{m.net_pp.toFixed(1)}pp</Typography>
241
+ </Box>
242
+ );
243
+ }
244
+
245
+ export function PairedTransitionView({ data }: { data: any }) {
246
+ const drill = (rows: any[], label: string) => (
247
+ <SectionCard title={`Transitions by ${label}`}>
248
+ <Box component="table" sx={tableSx}>
249
+ <thead>
250
+ <tr>
251
+ <th>{label}</th><th>Regressions (A✓→B✗)</th><th>Improvements (A✗→B✓)</th>
252
+ {rows[0]?.lift !== undefined ? <th>Unit share</th> : null}
253
+ {rows[0]?.lift !== undefined ? <th>Regression lift</th> : null}
254
+ <th>Net</th>
255
+ </tr>
256
+ </thead>
257
+ <tbody>
258
+ {rows.map((r) => (
259
+ <tr key={r.segment}>
260
+ <td style={{ fontWeight: 600, fontFamily: r.segment.startsWith('[') ? 'monospace' : undefined }}>{r.segment}</td>
261
+ <td style={{ fontFamily: 'monospace', color: '#ef9a9a' }}>{r.regressions}</td>
262
+ <td style={{ fontFamily: 'monospace', color: '#a5d6a7' }}>{r.improvements}</td>
263
+ {r.lift !== undefined ? <td style={{ fontFamily: 'monospace' }}>{(r.unit_share * 100).toFixed(0)}%</td> : null}
264
+ {r.lift !== undefined ? (
265
+ <td>
266
+ <Chip size="small" label={`${r.lift.toFixed(1)}×`} sx={{
267
+ height: 18, fontSize: 10, fontWeight: 700,
268
+ bgcolor: r.lift >= 1.5 ? '#ef535022' : '#232a31',
269
+ color: r.lift >= 1.5 ? '#ef5350' : '#aab4be',
270
+ }} />
271
+ </td>
272
+ ) : null}
273
+ <td><DeltaBar value={r.net_pp} max={15} width={90} /></td>
274
+ </tr>
275
+ ))}
276
+ </tbody>
277
+ </Box>
278
+ </SectionCard>
279
+ );
280
+ return (
281
+ <>
282
+ <Explainer text={data.explainer} />
283
+ <SectionCard title="Error-transition matrix — same units, both models">
284
+ <Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
285
+ <TransitionMatrix m={data.shadow} title="SHADOW (scored sample)" />
286
+ <TransitionMatrix m={data.offline} title="OFFLINE (eval set)" />
287
+ </Box>
288
+ </SectionCard>
289
+ {drill(data.by_band, `serving-confidence band (${data.conf_col})`)}
290
+ {drill(data.by_segment, 'segment')}
291
+ {drill(data.by_class, 'object class')}
292
+ </>
293
+ );
294
+ }
295
+
296
+ // ---------------------------------------------------- stage 6: significance
297
+
298
+ function CiPlot({ res, marginPp }: { res: any; marginPp: number }) {
299
+ // Scale: pp values mapped onto an SVG x-axis.
300
+ const w = 560; const h = 90;
301
+ const lo = Math.min(res.ci_low * 100, -marginPp) - 1.2;
302
+ const hi = Math.max(res.ci_high * 100, marginPp) + 1.2;
303
+ const x = (v: number) => ((v - lo) / (hi - lo)) * (w - 40) + 20;
304
+ const y = 38;
305
+ return (
306
+ <svg width={w} height={h}>
307
+ {/* practical-regression zone */}
308
+ <rect x={20} y={y - 16} width={x(-marginPp) - 20} height={32} fill="#ef5350" opacity={0.10} />
309
+ <line x1={x(-marginPp)} y1={y - 22} x2={x(-marginPp)} y2={y + 22} stroke="#ef5350" strokeDasharray="4 3" />
310
+ <text x={x(-marginPp)} y={y - 26} fill="#ef9a9a" fontSize={10} textAnchor="middle">
311
+ −{marginPp.toFixed(1)}pp practical margin
312
+ </text>
313
+ <line x1={x(0)} y1={y - 22} x2={x(0)} y2={y + 22} stroke="#5c666f" />
314
+ <text x={x(0)} y={y + 34} fill="#8a949e" fontSize={10} textAnchor="middle">0</text>
315
+ {/* CI */}
316
+ <line x1={x(res.ci_low * 100)} y1={y} x2={x(res.ci_high * 100)} y2={y} stroke="#4fc3f7" strokeWidth={4} strokeLinecap="round" />
317
+ <circle cx={x(res.delta * 100)} cy={y} r={5.5} fill="#4fc3f7" />
318
+ <text x={x(res.delta * 100)} y={y + 20} fill="#4fc3f7" fontSize={11} textAnchor="middle" fontFamily="monospace">
319
+ {res.delta * 100 >= 0 ? '+' : ''}{(res.delta * 100).toFixed(2)}pp
320
+ </text>
321
+ <text x={x(res.ci_low * 100)} y={y - 10} fill="#8a949e" fontSize={10} textAnchor="middle" fontFamily="monospace">
322
+ {(res.ci_low * 100).toFixed(2)}
323
+ </text>
324
+ <text x={x(res.ci_high * 100)} y={y - 10} fill="#8a949e" fontSize={10} textAnchor="middle" fontFamily="monospace">
325
+ {(res.ci_high * 100).toFixed(2)}
326
+ </text>
327
+ </svg>
328
+ );
329
+ }
330
+
331
+ const OUTCOME_LABEL: Record<string, { label: string; color: string }> = {
332
+ significant_regression: { label: 'SIGNIFICANT REGRESSION', color: '#ef5350' },
333
+ no_significant_difference: { label: 'NO SIGNIFICANT DIFFERENCE', color: '#66bb6a' },
334
+ insufficient_evidence: { label: 'INSUFFICIENT EVIDENCE', color: '#ffb74d' },
335
+ };
336
+
337
+ export function SignificanceView({ data }: { data: any }) {
338
+ const o = OUTCOME_LABEL[data.outcome];
339
+ const sp = data.shadow_paired;
340
+ return (
341
+ <>
342
+ <Explainer text={data.explainer} />
343
+ <SectionCard
344
+ title="Paired shadow delta vs practical-significance margin"
345
+ action={<Chip size="small" label={o.label} sx={{ fontWeight: 800, bgcolor: `${o.color}22`, color: o.color, border: `1px solid ${o.color}66` }} />}
346
+ >
347
+ <CiPlot res={sp} marginPp={data.practical_margin_pp} />
348
+ <Box sx={{ display: 'flex', gap: 4, flexWrap: 'wrap', mt: 1 }}>
349
+ <KV k="Units / clusters" v={`${sp.n.toLocaleString()} / ${sp.n_clusters.toLocaleString()}`} />
350
+ <KV k="ICC (paired diffs)" v={sp.icc.toFixed(3)} />
351
+ <KV k="Design effect" v={sp.design_effect.toFixed(2)} />
352
+ <KV k="Effective n" v={sp.effective_n.toLocaleString()} />
353
+ </Box>
354
+ <Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 0.5 }}>
355
+ Engine: {data.engine} — {data.engine_note}
356
+ </Typography>
357
+ </SectionCard>
358
+ {data.seqeval ? (
359
+ <SectionCard title="Anytime-valid complement (sensorflow.seqeval)">
360
+ <Box sx={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
361
+ <KV k="Sequential decision" v={<StatusPill status={data.seqeval.decision === 'REGRESSION' ? 'MISMATCH' : data.seqeval.decision === 'PASS' ? 'PASS' : 'UNKNOWN'} />} />
362
+ <KV k="Confidence sequence" v={`[${(data.seqeval.delta_ci[0] * 100).toFixed(2)}, ${(data.seqeval.delta_ci[1] * 100).toFixed(2)}]pp`} />
363
+ <KV k="e-value (regression)" v={data.seqeval.e_regression} />
364
+ <KV k="P(regression | data)" v={data.seqeval.bayes_p_regression ?? '—'} />
365
+ </Box>
366
+ <Typography variant="caption" sx={{ color: '#8a949e' }}>{data.seqeval.note}</Typography>
367
+ </SectionCard>
368
+ ) : null}
369
+ <SectionCard title="Per-model accuracy (Wilson 95% CI)">
370
+ <Box component="table" sx={tableSx}>
371
+ <thead>
372
+ <tr><th>Measurement</th><th>Rate</th><th>95% CI</th><th>n</th></tr>
373
+ </thead>
374
+ <tbody>
375
+ {Object.entries(data.wilson as Record<string, any>).map(([k, v]) => (
376
+ <tr key={k}>
377
+ <td style={{ fontWeight: 600 }}>{k.replace('_', ' · model ')}</td>
378
+ <td style={{ fontFamily: 'monospace' }}>{(v.rate * 100).toFixed(2)}%</td>
379
+ <td style={{ fontFamily: 'monospace' }}>[{(v.ci_low * 100).toFixed(2)}, {(v.ci_high * 100).toFixed(2)}]</td>
380
+ <td style={{ fontFamily: 'monospace' }}>{v.n.toLocaleString()}</td>
381
+ </tr>
382
+ ))}
383
+ </tbody>
384
+ </Box>
385
+ </SectionCard>
386
+ </>
387
+ );
388
+ }
389
+
390
+ // --------------------------------------------------- stage 7: feature parity
391
+
392
+ export function FeatureParityView({ data }: { data: any }) {
393
+ const maxSmd = Math.max(...data.rows.map((r: any) => r.within_segment_smd), 1);
394
+ return (
395
+ <>
396
+ <Explainer text={data.explainer} />
397
+ <SectionCard
398
+ title="Feature parity — ranked by within-segment SMD"
399
+ subtitle="Within-segment standardized mean difference controls for population mix: a value that still differs INSIDE matched segments is a pipeline difference, not a world change."
400
+ >
401
+ <Box component="table" sx={tableSx}>
402
+ <thead>
403
+ <tr>
404
+ <th>Feature</th><th>Within-segment SMD</th><th>Raw SMD</th>
405
+ <th>Mean (off → sh)</th><th>Median ratio</th><th>Missing (off / sh)</th><th>Verdict</th>
406
+ </tr>
407
+ </thead>
408
+ <tbody>
409
+ {data.rows.map((r: any) => (
410
+ <tr key={r.feature} style={r.skew_flag ? { background: '#ef535011' } : undefined}>
411
+ <td style={{ fontWeight: 700 }}>{r.feature}</td>
412
+ <td>
413
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
414
+ <Box sx={{ width: 130, height: 9, bgcolor: '#0d1116', borderRadius: 1, border: `1px solid ${BORDER}` }}>
415
+ <Box sx={{
416
+ width: `${Math.min(100, (r.within_segment_smd / maxSmd) * 100)}%`,
417
+ height: '100%',
418
+ bgcolor: r.skew_flag ? '#ef5350' : '#4fc3f7',
419
+ borderRadius: 1,
420
+ }} />
421
+ </Box>
422
+ <Typography variant="caption" sx={{ fontFamily: 'monospace' }}>{r.within_segment_smd.toFixed(2)}</Typography>
423
+ </Box>
424
+ </td>
425
+ <td style={{ fontFamily: 'monospace' }}>{r.smd.toFixed(2)}</td>
426
+ <td style={{ fontFamily: 'monospace', fontSize: 11 }}>{r.offline_mean.toFixed(1)} → {r.shadow_mean.toFixed(1)}</td>
427
+ <td style={{ fontFamily: 'monospace', fontWeight: r.skew_flag ? 800 : 400, color: r.skew_flag ? '#ef5350' : undefined }}>
428
+ ×{r.median_ratio.toFixed(2)}
429
+ </td>
430
+ <td style={{ fontFamily: 'monospace', fontSize: 11 }}>
431
+ {(r.offline_missing * 100).toFixed(1)}% / {(r.shadow_missing * 100).toFixed(1)}%
432
+ </td>
433
+ <td><StatusPill status={r.skew_flag ? 'MISMATCH' : 'PASS'} /></td>
434
+ </tr>
435
+ ))}
436
+ </tbody>
437
+ </Box>
438
+ </SectionCard>
439
+ </>
440
+ );
441
+ }
src/components/rca/common.tsx ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Shared visual language for the RCA workbench (dark theme, chip-heavy). */
2
+ import { type ReactNode } from 'react';
3
+ import Box from '@mui/material/Box';
4
+ import Chip from '@mui/material/Chip';
5
+ import Paper from '@mui/material/Paper';
6
+ import Tooltip from '@mui/material/Tooltip';
7
+ import Typography from '@mui/material/Typography';
8
+ import type { Confidence, Finding, FindingStatus, Severity } from '../../types/rca';
9
+
10
+ export const ACCENT = '#4fc3f7';
11
+ export const BG_PANEL = '#12171d';
12
+ export const BORDER = '#232a31';
13
+
14
+ export const STATUS_COLORS: Record<FindingStatus, string> = {
15
+ PASS: '#66bb6a',
16
+ MISMATCH: '#ef5350',
17
+ UNKNOWN: '#ffb74d',
18
+ };
19
+
20
+ export const SEVERITY_COLORS: Record<Severity, string> = {
21
+ INFO: '#78909c',
22
+ WARN: '#ffb74d',
23
+ CRITICAL: '#ef5350',
24
+ };
25
+
26
+ export const CONFIDENCE_COLORS: Record<Confidence, string> = {
27
+ HIGH: '#66bb6a',
28
+ MEDIUM: '#4fc3f7',
29
+ LOW: '#90a4ae',
30
+ UNKNOWN: '#ffb74d',
31
+ };
32
+
33
+ export function SectionCard({ title, subtitle, children, action }: {
34
+ title: string;
35
+ subtitle?: string;
36
+ action?: ReactNode;
37
+ children: ReactNode;
38
+ }) {
39
+ return (
40
+ <Paper variant="outlined" sx={{ p: 1.75, bgcolor: BG_PANEL, borderColor: BORDER, mb: 2 }}>
41
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', mb: subtitle ? 0.25 : 1 }}>
42
+ <Typography variant="subtitle2" sx={{ fontWeight: 800, flex: 1, fontSize: 13.5 }}>
43
+ {title}
44
+ </Typography>
45
+ {action}
46
+ </Box>
47
+ {subtitle ? (
48
+ <Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1.25 }}>
49
+ {subtitle}
50
+ </Typography>
51
+ ) : null}
52
+ {children}
53
+ </Paper>
54
+ );
55
+ }
56
+
57
+ export function StatusPill({ status }: { status: string }) {
58
+ const norm = status.toUpperCase() as FindingStatus;
59
+ const map: Record<string, { c: string; label: string }> = {
60
+ PASS: { c: STATUS_COLORS.PASS, label: 'PASS' },
61
+ MISMATCH: { c: STATUS_COLORS.MISMATCH, label: 'MISMATCH' },
62
+ UNKNOWN: { c: STATUS_COLORS.UNKNOWN, label: 'UNKNOWN' },
63
+ COMPARABLE: { c: STATUS_COLORS.PASS, label: 'comparable' },
64
+ MATCH: { c: STATUS_COLORS.PASS, label: 'match' },
65
+ EXPECTED_DIFFERENCE: { c: '#78909c', label: 'expected diff' },
66
+ };
67
+ const m = map[norm] ?? map[status.toUpperCase().replace(/ /g, '_')] ?? {
68
+ c: SEVERITY_COLORS.WARN,
69
+ label: status,
70
+ };
71
+ return (
72
+ <Chip
73
+ size="small"
74
+ label={m.label}
75
+ sx={{ height: 18, fontSize: 10, fontWeight: 700, bgcolor: `${m.c}22`, color: m.c, border: `1px solid ${m.c}55` }}
76
+ />
77
+ );
78
+ }
79
+
80
+ export function FindingChip({ finding, onClick }: { finding: Finding; onClick?: () => void }) {
81
+ const c = STATUS_COLORS[finding.status];
82
+ const sev = SEVERITY_COLORS[finding.severity];
83
+ return (
84
+ <Tooltip
85
+ title={
86
+ <Box sx={{ maxWidth: 380 }}>
87
+ <Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
88
+ [{finding.code}] {finding.title}
89
+ </Typography>
90
+ <Typography variant="caption">{finding.detail}</Typography>
91
+ </Box>
92
+ }
93
+ arrow
94
+ >
95
+ <Chip
96
+ size="small"
97
+ onClick={onClick}
98
+ label={
99
+ <span>
100
+ <b style={{ color: c }}>{finding.status}</b>
101
+ {finding.severity !== 'INFO' ? (
102
+ <b style={{ color: sev }}>{` · ${finding.severity}`}</b>
103
+ ) : null}
104
+ {` · ${finding.title}`}
105
+ {finding.source === 'human' ? ' · 👤' : ''}
106
+ </span>
107
+ }
108
+ sx={{
109
+ height: 22,
110
+ fontSize: 11,
111
+ bgcolor: `${c}14`,
112
+ border: `1px solid ${c}44`,
113
+ mr: 0.5,
114
+ mb: 0.5,
115
+ '.MuiChip-label': { px: 1 },
116
+ }}
117
+ />
118
+ </Tooltip>
119
+ );
120
+ }
121
+
122
+ export function FindingChips({ findings, onClick }: {
123
+ findings: Finding[];
124
+ onClick?: (f: Finding) => void;
125
+ }) {
126
+ if (!findings.length) return null;
127
+ return (
128
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', mt: 0.5 }}>
129
+ {findings.map((f) => (
130
+ <FindingChip key={f.id} finding={f} onClick={onClick ? () => onClick(f) : undefined} />
131
+ ))}
132
+ </Box>
133
+ );
134
+ }
135
+
136
+ export function Explainer({ text }: { text: string }) {
137
+ return (
138
+ <Box sx={{ borderLeft: `3px solid ${ACCENT}55`, pl: 1.25, py: 0.25, mb: 1.5 }}>
139
+ <Typography variant="caption" sx={{ color: '#aab4be' }}>
140
+ {text}
141
+ </Typography>
142
+ </Box>
143
+ );
144
+ }
145
+
146
+ /** Horizontal signed delta bar centered at zero (pp values). */
147
+ export function DeltaBar({ value, max, width = 140 }: { value: number; max: number; width?: number }) {
148
+ const half = width / 2;
149
+ const frac = Math.min(1, Math.abs(value) / Math.max(1e-9, max));
150
+ const w = Math.max(1.5, frac * half);
151
+ const color = value >= 0 ? '#66bb6a' : '#ef5350';
152
+ return (
153
+ <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
154
+ <svg width={width} height={14}>
155
+ <line x1={half} y1={0} x2={half} y2={14} stroke={BORDER} strokeWidth={1} />
156
+ <rect
157
+ x={value >= 0 ? half : half - w}
158
+ y={2.5}
159
+ width={w}
160
+ height={9}
161
+ rx={2}
162
+ fill={color}
163
+ opacity={0.85}
164
+ />
165
+ </svg>
166
+ <Typography
167
+ variant="caption"
168
+ sx={{ fontFamily: 'monospace', color, minWidth: 52, textAlign: 'right' }}
169
+ >
170
+ {value >= 0 ? '+' : ''}
171
+ {value.toFixed(1)}pp
172
+ </Typography>
173
+ </Box>
174
+ );
175
+ }
176
+
177
+ /** Simple 0..1 proportion bar for mix comparisons. */
178
+ export function ShareBar({ value, color = ACCENT, width = 90 }: {
179
+ value: number;
180
+ color?: string;
181
+ width?: number;
182
+ }) {
183
+ return (
184
+ <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
185
+ <Box sx={{ width, height: 8, bgcolor: '#0d1116', borderRadius: 1, overflow: 'hidden', border: `1px solid ${BORDER}` }}>
186
+ <Box sx={{ width: `${Math.min(100, value * 100)}%`, height: '100%', bgcolor: color }} />
187
+ </Box>
188
+ <Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#8a949e', minWidth: 40 }}>
189
+ {(value * 100).toFixed(1)}%
190
+ </Typography>
191
+ </Box>
192
+ );
193
+ }
194
+
195
+ export function PsiBadge({ psi }: { psi: number }) {
196
+ const mag = psi < 0.02 ? 'negligible' : psi < 0.1 ? 'small' : psi < 0.25 ? 'moderate' : 'large';
197
+ const color = mag === 'large' ? '#ef5350' : mag === 'moderate' ? '#ffb74d' : mag === 'small' ? '#4fc3f7' : '#78909c';
198
+ return (
199
+ <Tooltip title="Population Stability Index: practical shift magnitude, independent of p-values (<0.02 negligible, <0.1 small, <0.25 moderate, ≥0.25 large)" arrow>
200
+ <Chip
201
+ size="small"
202
+ label={`PSI ${psi.toFixed(2)} · ${mag}`}
203
+ sx={{ height: 18, fontSize: 10, fontWeight: 700, bgcolor: `${color}22`, color, border: `1px solid ${color}55` }}
204
+ />
205
+ </Tooltip>
206
+ );
207
+ }
208
+
209
+ export function KV({ k, v }: { k: string; v: ReactNode }) {
210
+ return (
211
+ <Box sx={{ display: 'flex', gap: 1, py: 0.2 }}>
212
+ <Typography variant="caption" sx={{ color: '#8a949e', minWidth: 190 }}>
213
+ {k}
214
+ </Typography>
215
+ <Typography variant="caption" component="div" sx={{ fontFamily: 'monospace' }}>
216
+ {v}
217
+ </Typography>
218
+ </Box>
219
+ );
220
+ }
221
+
222
+ export const tableSx = {
223
+ width: '100%',
224
+ borderCollapse: 'collapse' as const,
225
+ fontSize: 12,
226
+ '& th': {
227
+ textAlign: 'left' as const,
228
+ color: '#8a949e',
229
+ fontWeight: 700,
230
+ borderBottom: `1px solid ${BORDER}`,
231
+ px: 1,
232
+ py: 0.6,
233
+ fontSize: 11,
234
+ whiteSpace: 'nowrap' as const,
235
+ },
236
+ '& td': {
237
+ borderBottom: `1px solid ${BORDER}55`,
238
+ px: 1,
239
+ py: 0.55,
240
+ verticalAlign: 'middle' as const,
241
+ },
242
+ };
src/components/rca/dialogs.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Dialogs: record a human finding; acknowledge unknowns to complete a stage. */
2
+ import { useState } from 'react';
3
+ import Button from '@mui/material/Button';
4
+ import Dialog from '@mui/material/Dialog';
5
+ import DialogActions from '@mui/material/DialogActions';
6
+ import DialogContent from '@mui/material/DialogContent';
7
+ import DialogTitle from '@mui/material/DialogTitle';
8
+ import MenuItem from '@mui/material/MenuItem';
9
+ import TextField from '@mui/material/TextField';
10
+ import Typography from '@mui/material/Typography';
11
+ import type { FindingStatus, Severity } from '../../types/rca';
12
+
13
+ export function RecordFindingDialog({ open, stageTitle, onClose, onSubmit }: {
14
+ open: boolean;
15
+ stageTitle: string;
16
+ onClose: () => void;
17
+ onSubmit: (f: { title: string; status: FindingStatus; severity: Severity; detail: string }) => void;
18
+ }) {
19
+ const [title, setTitle] = useState('');
20
+ const [status, setStatus] = useState<FindingStatus>('MISMATCH');
21
+ const [severity, setSeverity] = useState<Severity>('WARN');
22
+ const [detail, setDetail] = useState('');
23
+ const reset = () => { setTitle(''); setStatus('MISMATCH'); setSeverity('WARN'); setDetail(''); };
24
+ return (
25
+ <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
26
+ <DialogTitle sx={{ fontSize: 15, fontWeight: 800 }}>Record finding — {stageTitle}</DialogTitle>
27
+ <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: '8px !important' }}>
28
+ <TextField
29
+ label="Finding" size="small" fullWidth autoFocus value={title}
30
+ onChange={(e) => setTitle(e.target.value)}
31
+ placeholder="e.g. Confirmed with infra team: feature pipeline fp-2.5 deployed online only"
32
+ />
33
+ <TextField select label="Status" size="small" value={status} onChange={(e) => setStatus(e.target.value as FindingStatus)}>
34
+ {(['PASS', 'MISMATCH', 'UNKNOWN'] as FindingStatus[]).map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
35
+ </TextField>
36
+ <TextField select label="Severity" size="small" value={severity} onChange={(e) => setSeverity(e.target.value as Severity)}>
37
+ {(['INFO', 'WARN', 'CRITICAL'] as Severity[]).map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
38
+ </TextField>
39
+ <TextField
40
+ label="Detail / evidence" size="small" fullWidth multiline minRows={2} value={detail}
41
+ onChange={(e) => setDetail(e.target.value)}
42
+ />
43
+ </DialogContent>
44
+ <DialogActions>
45
+ <Button onClick={onClose}>Cancel</Button>
46
+ <Button
47
+ variant="contained" disabled={!title.trim()}
48
+ onClick={() => { onSubmit({ title: title.trim(), status, severity, detail }); reset(); }}
49
+ >
50
+ Record
51
+ </Button>
52
+ </DialogActions>
53
+ </Dialog>
54
+ );
55
+ }
56
+
57
+ export function AcknowledgeUnknownsDialog({ open, stageTitle, unknownTitles, onClose, onAcknowledge }: {
58
+ open: boolean;
59
+ stageTitle: string;
60
+ unknownTitles: string[];
61
+ onClose: () => void;
62
+ onAcknowledge: (note: string) => void;
63
+ }) {
64
+ const [note, setNote] = useState('');
65
+ return (
66
+ <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
67
+ <DialogTitle sx={{ fontSize: 15, fontWeight: 800, color: '#ffb74d' }}>
68
+ Proceed with unknowns? — {stageTitle}
69
+ </DialogTitle>
70
+ <DialogContent sx={{ pt: '4px !important' }}>
71
+ <Typography variant="body2" sx={{ mb: 1 }}>
72
+ This stage has critical UNKNOWN findings. Completing it without resolving them
73
+ will be permanently recorded on the investigation ("proceeding with unknowns")
74
+ and will appear in the final report.
75
+ </Typography>
76
+ {unknownTitles.map((t) => (
77
+ <Typography key={t} variant="caption" sx={{ display: 'block', color: '#ffb74d' }}>
78
+ ? {t}
79
+ </Typography>
80
+ ))}
81
+ <TextField
82
+ label="Why is it acceptable to proceed? (recorded)" size="small" fullWidth multiline minRows={2}
83
+ sx={{ mt: 1.5 }} value={note} onChange={(e) => setNote(e.target.value)}
84
+ />
85
+ </DialogContent>
86
+ <DialogActions>
87
+ <Button onClick={onClose}>Go back and resolve</Button>
88
+ <Button color="warning" variant="contained" disabled={!note.trim()} onClick={() => { onAcknowledge(note.trim()); setNote(''); }}>
89
+ Acknowledge and proceed
90
+ </Button>
91
+ </DialogActions>
92
+ </Dialog>
93
+ );
94
+ }
src/components/rca/stageViews.tsx ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Table-style stage views: comparison validity, offline audit, population,
2
+ * serving parity, shadow traffic, label integrity. Each receives the
3
+ * diagnostics payload for its stage. */
4
+ import Box from '@mui/material/Box';
5
+ import Chip from '@mui/material/Chip';
6
+ import Typography from '@mui/material/Typography';
7
+ import {
8
+ DeltaBar,
9
+ Explainer,
10
+ KV,
11
+ PsiBadge,
12
+ SectionCard,
13
+ ShareBar,
14
+ StatusPill,
15
+ tableSx,
16
+ } from './common';
17
+
18
+ const num = (v: unknown, digits = 2): string =>
19
+ typeof v === 'number' ? v.toFixed(digits) : String(v ?? '—');
20
+
21
+ // ------------------------------------------------ stage 0: comparison validity
22
+
23
+ export function ComparisonValidityView({ data }: { data: any }) {
24
+ return (
25
+ <>
26
+ <Explainer text={data.explainer} />
27
+ <SectionCard
28
+ title="Comparison-validity matrix"
29
+ subtitle="Offline harness vs shadow serving, dimension by dimension. Mismatch rows are candidate explanations that must be ruled out before believing either headline number."
30
+ >
31
+ <Box component="table" sx={tableSx}>
32
+ <thead>
33
+ <tr>
34
+ <th>Dimension</th>
35
+ <th>Offline</th>
36
+ <th>Shadow</th>
37
+ <th>Verdict</th>
38
+ <th>If mismatched</th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ {data.rows.map((r: any) => (
43
+ <tr key={r.field}>
44
+ <td style={{ fontWeight: 600 }}>{r.label}</td>
45
+ <td style={{ fontFamily: 'monospace', fontSize: 11.5 }}>{String(r.offline ?? 'unrecorded')}</td>
46
+ <td style={{ fontFamily: 'monospace', fontSize: 11.5 }}>{String(r.shadow ?? 'unrecorded')}</td>
47
+ <td><StatusPill status={r.status} /></td>
48
+ <td>
49
+ <Typography variant="caption" sx={{ color: r.criticality === 'CRITICAL' ? '#ef5350' : '#8a949e' }}>
50
+ {r.criticality}
51
+ </Typography>
52
+ </td>
53
+ </tr>
54
+ ))}
55
+ </tbody>
56
+ </Box>
57
+ </SectionCard>
58
+ </>
59
+ );
60
+ }
61
+
62
+ // ---------------------------------------------------- stage 1: offline audit
63
+
64
+ export function OfflineAuditView({ data }: { data: any }) {
65
+ const r = data.reproducibility;
66
+ const l = data.leakage;
67
+ return (
68
+ <>
69
+ <Explainer text={data.explainer} />
70
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
71
+ <SectionCard title="Reproducibility fingerprint">
72
+ <KV k="Claimed offline delta" v={`${num(r.original_delta_pp)}pp`} />
73
+ <KV k="Re-run delta" v={`${num(r.rerun_delta_pp)}pp`} />
74
+ <KV k="|difference|" v={`${num(r.abs_diff_pp)}pp`} />
75
+ <KV k="Reproduces (<0.5pp)" v={<StatusPill status={r.reproduces ? 'PASS' : 'MISMATCH'} />} />
76
+ <KV k="Version pins present" v={<StatusPill status={r.pins_present ? 'PASS' : 'UNKNOWN'} />} />
77
+ <KV k="Environment lock" v={r.environment_lock} />
78
+ </SectionCard>
79
+ <SectionCard title="Leakage scan (train ∩ eval)">
80
+ <KV k="Near-duplicate eval units" v={`${l.n_duplicates} (${(l.dup_fraction * 100).toFixed(1)}% of ${l.offline_n})`} />
81
+ <KV k="Entities also in B's train set" v={l.overlap_entity_count} />
82
+ <KV k="Delta on leaked units" v={<DeltaBar value={l.dup_delta_pp} max={25} />} />
83
+ <KV k="Delta on clean units" v={<DeltaBar value={l.clean_delta_pp} max={25} />} />
84
+ {l.overlap_examples?.length ? (
85
+ <KV k="Examples" v={l.overlap_examples.join(', ')} />
86
+ ) : null}
87
+ </SectionCard>
88
+ <SectionCard title="Split-boundary analysis">
89
+ <KV k="Split strategy" v={<StatusPill status={data.split.strategy === 'entity_level' ? 'PASS' : 'MISMATCH'} />} />
90
+ <KV k="Strategy" v={data.split.strategy} />
91
+ <KV k="Eval entities" v={data.split.n_entities} />
92
+ <KV k="Mean units / entity" v={num(data.split.mean_units_per_entity, 1)} />
93
+ </SectionCard>
94
+ <SectionCard title="Temporal leakage">
95
+ <KV k="Eval window after B train end" v={<StatusPill status={data.temporal.ok ? 'PASS' : 'MISMATCH'} />} />
96
+ <KV k="B train window" v={data.temporal.b_train_window} />
97
+ </SectionCard>
98
+ </Box>
99
+ </>
100
+ );
101
+ }
102
+
103
+ // ---------------------------------------------- stage 2: population validation
104
+
105
+ export function PopulationView({ data }: { data: any }) {
106
+ const v = data.volumes;
107
+ return (
108
+ <>
109
+ <Explainer text={data.explainer} />
110
+ <SectionCard title="Volumes">
111
+ <Box sx={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
112
+ <KV k="Offline eval units" v={v.offline_n.toLocaleString()} />
113
+ <KV k="Shadow eligible" v={v.shadow_eligible_n.toLocaleString()} />
114
+ <KV k="Shadow scored" v={v.shadow_scored_n.toLocaleString()} />
115
+ <KV k="Sampling rate" v={`${(v.sampling_rate * 100).toFixed(1)}%`} />
116
+ <KV k="Entities (off / sh)" v={`${v.offline_entities} / ${v.shadow_entities}`} />
117
+ <KV k="Entity overlap" v={v.entity_overlap} />
118
+ </Box>
119
+ </SectionCard>
120
+ {Object.entries(data.dimensions as Record<string, any[]>).map(([dim, rows]) => (
121
+ <SectionCard key={dim} title={`Composition: ${dim}`}>
122
+ <Box component="table" sx={tableSx}>
123
+ <thead>
124
+ <tr><th>Value</th><th>Offline share</th><th>Shadow share</th></tr>
125
+ </thead>
126
+ <tbody>
127
+ {rows.map((r: any) => (
128
+ <tr key={r.value}>
129
+ <td style={{ fontWeight: 600 }}>{r.value}</td>
130
+ <td><ShareBar value={r.offline_share} color="#78909c" /></td>
131
+ <td><ShareBar value={r.shadow_share} /></td>
132
+ </tr>
133
+ ))}
134
+ </tbody>
135
+ </Box>
136
+ </SectionCard>
137
+ ))}
138
+ </>
139
+ );
140
+ }
141
+
142
+ // ----------------------------------------------------- stage 8: serving parity
143
+
144
+ export function ServingParityView({ data }: { data: any }) {
145
+ return (
146
+ <>
147
+ <Explainer text={data.explainer} />
148
+ <SectionCard
149
+ title="Serving artifact / config diff"
150
+ subtitle="The exact stack difference between what was evaluated offline and what shadow actually runs."
151
+ >
152
+ <Box component="table" sx={tableSx}>
153
+ <thead>
154
+ <tr><th>Artifact / config</th><th>Offline harness</th><th>Shadow serving</th><th>Verdict</th><th>Criticality</th></tr>
155
+ </thead>
156
+ <tbody>
157
+ {data.rows.map((r: any) => (
158
+ <tr key={r.field}>
159
+ <td style={{ fontWeight: 600 }}>{r.label}</td>
160
+ <td style={{ fontFamily: 'monospace' }}>{String(r.offline ?? 'unrecorded')}</td>
161
+ <td style={{ fontFamily: 'monospace' }}>{String(r.shadow ?? 'unrecorded')}</td>
162
+ <td><StatusPill status={r.status} /></td>
163
+ <td>
164
+ <Typography variant="caption" sx={{ color: r.criticality === 'CRITICAL' ? '#ef5350' : '#8a949e' }}>
165
+ {r.criticality}
166
+ </Typography>
167
+ </td>
168
+ </tr>
169
+ ))}
170
+ </tbody>
171
+ </Box>
172
+ </SectionCard>
173
+ </>
174
+ );
175
+ }
176
+
177
+ // ----------------------------------------------------- stage 9: shadow traffic
178
+
179
+ export function TrafficAuditView({ data }: { data: any }) {
180
+ const t = data.traffic;
181
+ const s = data.selection;
182
+ return (
183
+ <>
184
+ <Explainer text={data.explainer} />
185
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
186
+ <SectionCard title="Traffic accounting">
187
+ <KV k="Eligible units" v={t.eligible_count.toLocaleString()} />
188
+ <KV k="Sampled / scored" v={`${t.sampled_count.toLocaleString()} (${(t.sampling_rate * 100).toFixed(1)}%)`} />
189
+ <KV k="Dropped" v={t.dropped_count.toLocaleString()} />
190
+ <KV k="Timeouts" v={t.timeout_count.toLocaleString()} />
191
+ <KV k="Fallback engaged" v={t.fallback_count.toLocaleString()} />
192
+ <KV k="Sampler" v={t.sampler} />
193
+ <Box sx={{ mt: 0.5 }}>
194
+ {t.eligibility_filters.map((f: string) => (
195
+ <Chip key={f} size="small" label={f} sx={{ mr: 0.5, mb: 0.5, height: 20, fontSize: 10.5, bgcolor: '#232a31' }} />
196
+ ))}
197
+ </Box>
198
+ </SectionCard>
199
+ <SectionCard
200
+ title="Selection-bias indicators"
201
+ subtitle="Does the scored sample statistically match the eligible stream it claims to represent?"
202
+ >
203
+ <KV k="Difficulty shift (sampled vs eligible)" v={<PsiBadge psi={s.difficulty_psi} />} />
204
+ <KV k="Confidence shift" v={<PsiBadge psi={s.conf_psi} />} />
205
+ <KV k="Segment mix shift" v={<PsiBadge psi={s.segment_psi} />} />
206
+ <KV k="Δ on scored sample" v={<DeltaBar value={s.sampled_delta_pp} max={8} />} />
207
+ <KV k="Δ on unsampled remainder" v={<DeltaBar value={s.unsampled_delta_pp} max={8} />} />
208
+ <KV k="Δ on full eligible stream" v={<DeltaBar value={s.eligible_delta_pp} max={8} />} />
209
+ <KV k="Dropped+fallback+timeout rate" v={`${(s.drop_rate * 100).toFixed(1)}%`} />
210
+ </SectionCard>
211
+ </Box>
212
+ </>
213
+ );
214
+ }
215
+
216
+ // --------------------------------------------------- stage 10: label integrity
217
+
218
+ export function LabelIntegrityView({ data }: { data: any }) {
219
+ const maxCount = Math.max(...data.age_histogram.map((h: any) => h.count), 1);
220
+ return (
221
+ <>
222
+ <Explainer text={data.explainer} />
223
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
224
+ <SectionCard title="Label age distribution" subtitle={`Maturity threshold: ${data.maturity_hours}h (audited labels).`}>
225
+ {data.age_histogram.map((h: any) => (
226
+ <Box key={h.bucket} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.2 }}>
227
+ <Typography variant="caption" sx={{ minWidth: 70, color: '#8a949e', fontFamily: 'monospace' }}>
228
+ {h.bucket}
229
+ </Typography>
230
+ <Box sx={{ flex: 1, height: 10, bgcolor: '#0d1116', borderRadius: 1 }}>
231
+ <Box sx={{ width: `${(h.count / maxCount) * 100}%`, height: '100%', bgcolor: '#4fc3f7', borderRadius: 1 }} />
232
+ </Box>
233
+ <Typography variant="caption" sx={{ fontFamily: 'monospace', minWidth: 44, textAlign: 'right' }}>
234
+ {h.count}
235
+ </Typography>
236
+ </Box>
237
+ ))}
238
+ <Box sx={{ mt: 1 }}>
239
+ <KV k="Provisional fraction" v={`${(data.provisional_fraction * 100).toFixed(1)}%`} />
240
+ <KV k="Labeling policy (off / sh)" v={`${data.policy.offline} / ${data.policy.shadow}`} />
241
+ <KV k="Policy differs" v={<StatusPill status={data.policy.differs ? 'MISMATCH' : 'PASS'} />} />
242
+ </Box>
243
+ </SectionCard>
244
+ <SectionCard
245
+ title="Verdict by label maturity"
246
+ subtitle="If the conclusion flips between mature and provisional labels, the -2% is a property of the labels, not the model."
247
+ >
248
+ <KV k={`Mature labels (n=${data.mature_n})`} v={<DeltaBar value={data.mature_delta_pp} max={10} />} />
249
+ <KV k={`Provisional labels (n=${data.provisional_n})`} v={<DeltaBar value={data.provisional_delta_pp} max={10} />} />
250
+ <Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1.5, mb: 0.5, fontWeight: 700 }}>
251
+ BY DIFFICULTY QUARTILE
252
+ </Typography>
253
+ <Box component="table" sx={tableSx}>
254
+ <thead>
255
+ <tr><th>Quartile</th><th>Provisional %</th><th>Δ (all)</th><th>Δ (provisional)</th></tr>
256
+ </thead>
257
+ <tbody>
258
+ {data.by_difficulty_quartile.map((q: any) => (
259
+ <tr key={q.quartile}>
260
+ <td style={{ fontWeight: 600 }}>{q.quartile}</td>
261
+ <td>{(q.provisional_frac * 100).toFixed(0)}%</td>
262
+ <td><DeltaBar value={q.delta_pp} max={15} width={90} /></td>
263
+ <td>
264
+ {q.provisional_delta_pp == null ? (
265
+ <Typography variant="caption" sx={{ color: '#5c666f' }}>n too small</Typography>
266
+ ) : (
267
+ <DeltaBar value={q.provisional_delta_pp} max={15} width={90} />
268
+ )}
269
+ </td>
270
+ </tr>
271
+ ))}
272
+ </tbody>
273
+ </Box>
274
+ </SectionCard>
275
+ </Box>
276
+ </>
277
+ );
278
+ }
src/content/glossary.ts CHANGED
@@ -619,6 +619,41 @@ export const GLOSSARY: Record<string, GlossaryEntry> = {
619
  detail: 'Keyed by a hash of (run, filters, group-by, metrics). Hit rate shown in the header; identical dashboard queries return in ~0.1 ms.',
620
  caveat: 'Invalidated when a run publishes new artifacts.',
621
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  };
623
 
624
  export type GlossaryKey = keyof typeof GLOSSARY;
 
619
  detail: 'Keyed by a hash of (run, filters, group-by, metrics). Hit rate shown in the header; identical dashboard queries return in ~0.1 ms.',
620
  caveat: 'Invalidated when a run publishes new artifacts.',
621
  },
622
+
623
+ // ------------------------------------------------- root cause analysis (RCA)
624
+ psi: {
625
+ term: 'PSI (Population Stability Index)',
626
+ category: 'Aggregation & scale',
627
+ short: 'Practical magnitude of a distribution change between two populations.',
628
+ detail: 'Sum over bins of (actual% − expected%) × ln(actual%/expected%). Rule of thumb: <0.02 negligible, <0.1 small, <0.25 moderate, ≥0.25 large. Used in the Root Cause Lab to compare offline vs shadow populations independently of p-values.',
629
+ caveat: 'At large n almost any change is statistically significant; PSI answers "does it matter", not "is it detectable".',
630
+ },
631
+ effective_sample_size: {
632
+ term: 'Effective sample size',
633
+ category: 'Human grading & sampling',
634
+ short: 'The number of independent observations your correlated data is actually worth.',
635
+ detail: 'Rows from the same entity (scene/track) are correlated; n_eff = n / design-effect where the design effect grows with cluster size and intra-cluster correlation. Confidence intervals in the Root Cause Lab use n_eff, not raw row counts.',
636
+ caveat: 'Using raw n on clustered evaluation data makes CIs look several times narrower than they really are.',
637
+ },
638
+ simpsons_paradox: {
639
+ term: "Simpson's paradox",
640
+ category: 'Aggregation & scale',
641
+ short: 'Aggregate trend reverses the trend present in every segment.',
642
+ detail: 'If a model improves within every segment but the population mix shifts toward segments where it is weaker, the aggregate can show a regression that no segment shows. The conditional-performance heatmap in the Root Cause Lab surfaces this: consistent per-segment deltas + a mix shift.',
643
+ },
644
+ training_serving_skew: {
645
+ term: 'Training–serving skew',
646
+ category: 'Operations',
647
+ short: 'A feature is computed differently offline than in production serving.',
648
+ detail: 'Unit changes, normalization differences or missing upstream signals make the serving-time feature distribution diverge from the offline one; the model then sees inputs unlike its training data. Detected in the feature-parity stage as a large standardized mean difference concentrated in specific features.',
649
+ caveat: 'Diagnose within segments: an aggregate feature delta can also be an honest population shift.',
650
+ },
651
+ shadow_evaluation: {
652
+ term: 'Shadow evaluation',
653
+ category: 'Operations',
654
+ short: 'Running a candidate model on live traffic without acting on its output.',
655
+ detail: 'The candidate scores real production inputs in parallel with the incumbent; outcomes are compared post-hoc. Sensitive to sampling bias, serving-config differences and provisional labels — which is why the Root Cause Lab audits the shadow pipeline before trusting its number.',
656
+ },
657
  };
658
 
659
  export type GlossaryKey = keyof typeof GLOSSARY;
src/content/pageHelp.ts CHANGED
@@ -179,6 +179,18 @@ export const PAGE_HELP: Record<PageId, PageHelpEntry> = {
179
  actions: 'Navigate to any stage\u2019s page; use it as the mental model for the rest of the app.',
180
  dataFlow: 'This page is the map, not a stage: it visualizes the flows the other pages implement.',
181
  },
 
 
 
 
 
 
 
 
 
 
 
 
182
  ssam: {
183
  subtitle:
184
  'Surrogate-safety analysis of real intersections: conflicts ranked by TTC / PET / severity on an interactive map — the safety context the label platform feeds.',
 
179
  actions: 'Navigate to any stage\u2019s page; use it as the mental model for the rest of the app.',
180
  dataFlow: 'This page is the map, not a stage: it visualizes the flows the other pages implement.',
181
  },
182
+ rca: {
183
+ subtitle:
184
+ 'Guided forensic workbench for evaluation discrepancies: when offline says +5% and shadow production says −2%, walk a 13-stage skeptical methodology to find out which number is real — and why.',
185
+ purpose:
186
+ 'A metric disagreement has at least eight competing explanations (true regression, distribution shift, feature skew, serving mismatch, label latency, sampling bias, noise, offline contamination). Jumping to the plausible one is how bad models ship and good models die. This lab enforces the full measurement-validity → distribution → parity → significance chain before any conclusion is allowed.',
187
+ reading:
188
+ 'The left rail is the methodology: stages unlock strictly in order, and a stage with critical UNKNOWN findings can only be completed by explicitly acknowledging them (recorded forever). The banner keeps the current working-hypothesis set visible — never a single premature conclusion. Each stage screen shows its diagnostic (validity matrix, PSI-badged shift comparisons, the segment heatmap that surfaces Simpson\u2019s paradox, the 2\u00d72 error-transition matrix, the CI-vs-practical-margin plot, ranked feature-parity deltas). Stage 11 is the Root Cause Board: 8 hypotheses scored purely from recorded findings with evidence links back to their stage.',
189
+ actions:
190
+ 'Create an investigation (choose a scenario, or a demo with a hidden planted cause for training). Work each stage: review diagnostics, record your own findings, complete or acknowledge-and-proceed. Adjust hypothesis confidences on the board with notes, then export the final report (JSON/markdown) with remediation tiers and the minimum-additional-evidence answer.',
191
+ dataFlow:
192
+ 'Investigations generate a paired offline + shadow evaluation dataset (persisted under runs/rca/). Every diagnostic is computed from that data — nothing is invented — and every board score traces to a recorded finding. Reports feed promotion decisions and the recommended follow-up experiments.',
193
+ },
194
  ssam: {
195
  subtitle:
196
  'Surrogate-safety analysis of real intersections: conflicts ranked by TTC / PET / severity on an interactive map — the safety context the label platform feeds.',
src/context/LabelEvalContext.tsx CHANGED
@@ -16,6 +16,7 @@ export type PageId =
16
  | 'evaluation'
17
  | 'audit'
18
  | 'pipeline'
 
19
  | 'ssam'
20
  | 'legacy';
21
 
@@ -34,6 +35,7 @@ export const ALL_PAGE_IDS: PageId[] = [
34
  'evaluation',
35
  'audit',
36
  'pipeline',
 
37
  'ssam',
38
  'legacy',
39
  ];
 
16
  | 'evaluation'
17
  | 'audit'
18
  | 'pipeline'
19
+ | 'rca'
20
  | 'ssam'
21
  | 'legacy';
22
 
 
35
  'evaluation',
36
  'audit',
37
  'pipeline',
38
+ 'rca',
39
  'ssam',
40
  'legacy',
41
  ];
src/pages/rca/RootCauseLabPage.tsx ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Root Cause Lab — a guided, skeptical workbench for "offline says +5%,
2
+ * shadow says −2%": 13 enforced stages from comparison validity through
3
+ * root-cause scoring to recommended experiments. */
4
+ import { type ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
5
+ import Alert from '@mui/material/Alert';
6
+ import Box from '@mui/material/Box';
7
+ import Button from '@mui/material/Button';
8
+ import Chip from '@mui/material/Chip';
9
+ import CircularProgress from '@mui/material/CircularProgress';
10
+ import MenuItem from '@mui/material/MenuItem';
11
+ import Paper from '@mui/material/Paper';
12
+ import TextField from '@mui/material/TextField';
13
+ import Typography from '@mui/material/Typography';
14
+ import { ArrowLeft, CheckCheck, FlaskConical, Plus, StickyNote } from 'lucide-react';
15
+ import * as api from '../../services/rca';
16
+ import type {
17
+ Confidence,
18
+ DecisionTree,
19
+ DiagnosticsResponse,
20
+ Experiments,
21
+ Investigation,
22
+ InvestigationSummary,
23
+ RcaReport,
24
+ RevealResponse,
25
+ RootCause,
26
+ Scoreboard,
27
+ } from '../../types/rca';
28
+ import {
29
+ BG_PANEL,
30
+ BORDER,
31
+ Explainer,
32
+ FindingChips,
33
+ SectionCard,
34
+ tableSx,
35
+ } from '../../components/rca/common';
36
+ import StageRail, { firstIncompleteIndex } from '../../components/rca/StageRail';
37
+ import {
38
+ ComparisonValidityView,
39
+ LabelIntegrityView,
40
+ OfflineAuditView,
41
+ PopulationView,
42
+ ServingParityView,
43
+ TrafficAuditView,
44
+ } from '../../components/rca/stageViews';
45
+ import {
46
+ ConditionalHeatmapView,
47
+ FeatureParityView,
48
+ PairedTransitionView,
49
+ ShiftView,
50
+ SignificanceView,
51
+ } from '../../components/rca/chartViews';
52
+ import { DecisionTreeView, ScoreBoardView } from '../../components/rca/boardViews';
53
+ import { ExperimentsView, ReportView } from '../../components/rca/ReportView';
54
+ import { AcknowledgeUnknownsDialog, RecordFindingDialog } from '../../components/rca/dialogs';
55
+
56
+ const STAGE_VIEWS: Record<string, ComponentType<{ data: any }>> = {
57
+ comparison_validity: ComparisonValidityView,
58
+ offline_audit: OfflineAuditView,
59
+ population_validation: PopulationView,
60
+ distribution_shift: ShiftView,
61
+ conditional_performance: ConditionalHeatmapView,
62
+ paired_comparison: PairedTransitionView,
63
+ statistical_significance: SignificanceView,
64
+ feature_parity: FeatureParityView,
65
+ serving_parity: ServingParityView,
66
+ shadow_traffic: TrafficAuditView,
67
+ label_integrity: LabelIntegrityView,
68
+ };
69
+
70
+ // ----------------------------------------------------------- creation panel
71
+
72
+ function CreatePanel({ onCreated }: { onCreated: (inv: Investigation) => void }) {
73
+ const [causes, setCauses] = useState<{ id: RootCause; label: string }[]>([]);
74
+ const [baseline, setBaseline] = useState('detr3d-a-v41');
75
+ const [candidate, setCandidate] = useState('detr3d-b-v42');
76
+ const [cause, setCause] = useState<string>('DEMO');
77
+ const [seed, setSeed] = useState('');
78
+ const [busy, setBusy] = useState(false);
79
+ const [err, setErr] = useState('');
80
+
81
+ useEffect(() => {
82
+ api.getCauses().then((r) => setCauses(r.causes)).catch(() => undefined);
83
+ }, []);
84
+
85
+ const create = async () => {
86
+ setBusy(true);
87
+ setErr('');
88
+ try {
89
+ const inv = await api.createInvestigation({
90
+ baseline_model: baseline,
91
+ candidate_model: candidate,
92
+ cause: cause === 'DEMO' ? null : (cause as RootCause),
93
+ seed: seed ? Number(seed) : null,
94
+ training_mode: cause === 'DEMO',
95
+ });
96
+ onCreated(inv);
97
+ } catch (e) {
98
+ setErr(e instanceof Error ? e.message : String(e));
99
+ } finally {
100
+ setBusy(false);
101
+ }
102
+ };
103
+
104
+ return (
105
+ <SectionCard
106
+ title="New investigation"
107
+ subtitle="Generates a full offline + shadow evaluation pair with a planted root cause. In demo mode the cause is hidden — work the methodology, then reveal the answer at the end."
108
+ >
109
+ <Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
110
+ <TextField label="Baseline model (A)" size="small" value={baseline} onChange={(e) => setBaseline(e.target.value)} sx={{ width: 180 }} />
111
+ <TextField label="Candidate model (B)" size="small" value={candidate} onChange={(e) => setCandidate(e.target.value)} sx={{ width: 180 }} />
112
+ <TextField select label="Scenario" size="small" value={cause} onChange={(e) => setCause(e.target.value)} sx={{ width: 320 }}>
113
+ <MenuItem value="DEMO">
114
+ <em>Demo investigation — hidden random root cause (training mode)</em>
115
+ </MenuItem>
116
+ {causes.map((c) => (
117
+ <MenuItem key={c.id} value={c.id}>{c.id} — {c.label}</MenuItem>
118
+ ))}
119
+ </TextField>
120
+ <TextField label="Seed (optional)" size="small" value={seed} onChange={(e) => setSeed(e.target.value.replace(/\D/g, ''))} sx={{ width: 130 }} />
121
+ <Button variant="contained" size="small" startIcon={busy ? <CircularProgress size={14} /> : <Plus size={15} />} disabled={busy} onClick={create}>
122
+ Create
123
+ </Button>
124
+ </Box>
125
+ {err ? <Alert severity="error" sx={{ mt: 1 }}>{err}</Alert> : null}
126
+ </SectionCard>
127
+ );
128
+ }
129
+
130
+ // -------------------------------------------------------- investigation list
131
+
132
+ function InvestigationList({ onOpen }: { onOpen: (id: string) => void }) {
133
+ const [rows, setRows] = useState<InvestigationSummary[]>([]);
134
+ useEffect(() => {
135
+ api.listInvestigations().then((r) => setRows(r.investigations)).catch(() => undefined);
136
+ }, []);
137
+ return (
138
+ <SectionCard title="Investigations">
139
+ {rows.length === 0 ? (
140
+ <Typography variant="caption" sx={{ color: '#8a949e' }}>
141
+ None yet — create one above.
142
+ </Typography>
143
+ ) : (
144
+ <Box component="table" sx={tableSx}>
145
+ <thead>
146
+ <tr><th>Name</th><th>Models</th><th>Claims</th><th>Progress</th><th>Mode</th><th /></tr>
147
+ </thead>
148
+ <tbody>
149
+ {rows.map((r) => (
150
+ <tr key={r.id}>
151
+ <td style={{ fontWeight: 600 }}>{r.name}</td>
152
+ <td style={{ fontFamily: 'monospace', fontSize: 11 }}>{r.candidate_model} vs {r.baseline_model}</td>
153
+ <td style={{ fontFamily: 'monospace', fontSize: 11 }}>
154
+ <span style={{ color: '#a5d6a7' }}>+{r.claims.offline_delta_pp}pp off</span>{' / '}
155
+ <span style={{ color: '#ef9a9a' }}>{r.claims.shadow_delta_pp}pp sh</span>
156
+ </td>
157
+ <td>{r.stages_complete}/{r.stages_total} stages</td>
158
+ <td>
159
+ {r.training_mode ? (
160
+ <Chip size="small" label={r.revealed ? `revealed: ${r.scenario_cause}` : 'training (hidden cause)'} sx={{ height: 18, fontSize: 10, bgcolor: '#232a31' }} />
161
+ ) : (
162
+ <Chip size="small" label={r.scenario_cause} sx={{ height: 18, fontSize: 10, bgcolor: '#232a31' }} />
163
+ )}
164
+ </td>
165
+ <td><Button size="small" onClick={() => onOpen(r.id)}>Open</Button></td>
166
+ </tr>
167
+ ))}
168
+ </tbody>
169
+ </Box>
170
+ )}
171
+ </SectionCard>
172
+ );
173
+ }
174
+
175
+ // ----------------------------------------------------------- hypothesis banner
176
+
177
+ function HypothesisBanner({ inv, workingSet }: { inv: Investigation; workingSet: RootCause[] }) {
178
+ return (
179
+ <Paper variant="outlined" sx={{ px: 1.5, py: 0.75, mb: 1.5, bgcolor: BG_PANEL, borderColor: BORDER, display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
180
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#8a949e', letterSpacing: 0.6 }}>
181
+ CLAIMS
182
+ </Typography>
183
+ <Chip size="small" label={`offline +${inv.claims.offline_delta_pp}pp (n=${inv.claims.offline_n.toLocaleString()})`} sx={{ height: 20, fontSize: 11, bgcolor: '#66bb6a22', color: '#a5d6a7', fontFamily: 'monospace' }} />
184
+ <Chip size="small" label={`shadow ${inv.claims.shadow_delta_pp}pp (n=${inv.claims.shadow_scored_n.toLocaleString()})`} sx={{ height: 20, fontSize: 11, bgcolor: '#ef535022', color: '#ef9a9a', fontFamily: 'monospace' }} />
185
+ <Box sx={{ width: 1, height: 20, bgcolor: BORDER }} />
186
+ <Typography variant="caption" sx={{ fontWeight: 800, color: '#8a949e', letterSpacing: 0.6 }}>
187
+ WORKING HYPOTHESES ({workingSet.length})
188
+ </Typography>
189
+ {workingSet.map((h) => (
190
+ <Chip key={h} size="small" label={h} sx={{ height: 20, fontSize: 10, bgcolor: '#232a31', color: '#cfd8e0' }} />
191
+ ))}
192
+ <Typography variant="caption" sx={{ color: '#5c666f', ml: 'auto' }}>
193
+ narrowed by recorded evidence only — never a premature single conclusion
194
+ </Typography>
195
+ </Paper>
196
+ );
197
+ }
198
+
199
+ // -------------------------------------------------------- investigation view
200
+
201
+ function InvestigationWorkspace({ invId, onBack }: { invId: string; onBack: () => void }) {
202
+ const [inv, setInv] = useState<Investigation | null>(null);
203
+ const [stageIdx, setStageIdx] = useState(0);
204
+ const [diag, setDiag] = useState<Record<string, DiagnosticsResponse>>({});
205
+ const [workingSet, setWorkingSet] = useState<RootCause[]>([]);
206
+ const [board, setBoard] = useState<Scoreboard | null>(null);
207
+ const [tree, setTree] = useState<DecisionTree | null>(null);
208
+ const [experiments, setExperiments] = useState<Experiments | null>(null);
209
+ const [report, setReport] = useState<RcaReport | null>(null);
210
+ const [reveal, setReveal] = useState<RevealResponse | null>(null);
211
+ const [loading, setLoading] = useState(false);
212
+ const [error, setError] = useState('');
213
+ const [findingOpen, setFindingOpen] = useState(false);
214
+ const [ackOpen, setAckOpen] = useState(false);
215
+
216
+ const stage = inv?.stages[stageIdx];
217
+ const stageKey = stage?.key ?? '';
218
+
219
+ const refreshWorkingSet = useCallback((id: string) => {
220
+ api.getScoreboard(id, true)
221
+ .then((b) => setWorkingSet(b.working_hypothesis_set))
222
+ .catch(() => undefined);
223
+ }, []);
224
+
225
+ const load = useCallback(async () => {
226
+ const got = await api.getInvestigation(invId);
227
+ setInv(got);
228
+ setStageIdx((cur) => Math.min(cur, firstIncompleteIndex(got.stages)));
229
+ refreshWorkingSet(invId);
230
+ return got;
231
+ }, [invId, refreshWorkingSet]);
232
+
233
+ useEffect(() => {
234
+ load().catch((e) => setError(String(e)));
235
+ }, [load]);
236
+
237
+ // Load whatever the selected stage needs.
238
+ useEffect(() => {
239
+ if (!inv || !stage) return;
240
+ setError('');
241
+ if (STAGE_VIEWS[stage.key] && !diag[stage.key]) {
242
+ setLoading(true);
243
+ api.getDiagnostics(inv.id, stage.key)
244
+ .then((d) => {
245
+ setDiag((m) => ({ ...m, [stage.key]: d }));
246
+ refreshWorkingSet(inv.id);
247
+ })
248
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
249
+ .finally(() => setLoading(false));
250
+ }
251
+ if (stage.key === 'root_cause_scoring' && (!board || !tree)) {
252
+ setLoading(true);
253
+ Promise.all([api.getScoreboard(inv.id), api.getDecisionTree(inv.id)])
254
+ .then(([b, t]) => { setBoard(b); setTree(t); })
255
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
256
+ .finally(() => setLoading(false));
257
+ }
258
+ if (stage.key === 'recommendations_report' && (!experiments || !report)) {
259
+ setLoading(true);
260
+ Promise.all([api.getExperiments(inv.id), api.getReport(inv.id)])
261
+ .then(([x, r]) => { setExperiments(x); setReport(r); })
262
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
263
+ .finally(() => setLoading(false));
264
+ }
265
+ }, [inv, stage, diag, board, tree, experiments, report, refreshWorkingSet]);
266
+
267
+ const complete = async (ack = false, note = '') => {
268
+ if (!inv || !stage) return;
269
+ try {
270
+ const res = await api.completeStage(inv.id, stage.index, {
271
+ acknowledge_unknowns: ack,
272
+ note,
273
+ });
274
+ setInv(res.investigation);
275
+ setAckOpen(false);
276
+ if (stage.index + 1 < res.investigation.stages.length) setStageIdx(stage.index + 1);
277
+ refreshWorkingSet(inv.id);
278
+ } catch (e) {
279
+ const err = e as Error & { status?: number };
280
+ if (err.status === 409 && /UNKNOWN/i.test(err.message)) {
281
+ setAckOpen(true);
282
+ } else {
283
+ setError(err.message);
284
+ }
285
+ }
286
+ };
287
+
288
+ const submitFinding = async (f: { title: string; status: any; severity: any; detail: string }) => {
289
+ if (!inv || !stage) return;
290
+ await api.recordFinding(inv.id, { stage: stage.key, ...f });
291
+ setFindingOpen(false);
292
+ await load();
293
+ // Board scores include human findings — invalidate cached views.
294
+ setBoard(null);
295
+ setReport(null);
296
+ };
297
+
298
+ const assess = async (hypothesis: RootCause, confidence: Confidence, note: string) => {
299
+ if (!inv) return;
300
+ const b = await api.assessHypothesis(inv.id, { hypothesis, confidence, note });
301
+ setBoard(b);
302
+ setReport(null);
303
+ };
304
+
305
+ const doReveal = async () => {
306
+ if (!inv) return;
307
+ const r = await api.revealCause(inv.id);
308
+ setReveal(r);
309
+ await load();
310
+ };
311
+
312
+ const jumpToStage = useCallback((sk: string) => {
313
+ if (!inv) return;
314
+ const target = inv.stages.find((s) => s.key === sk);
315
+ if (target && target.index <= firstIncompleteIndex(inv.stages)) setStageIdx(target.index);
316
+ }, [inv]);
317
+
318
+ const stageFindings = useMemo(
319
+ () => (inv && stage ? inv.findings.filter((f) => f.stage === stage.key) : []),
320
+ [inv, stage]
321
+ );
322
+ const unknownTitles = stageFindings
323
+ .filter((f) => f.status === 'UNKNOWN' && f.severity === 'CRITICAL')
324
+ .map((f) => f.title);
325
+
326
+ if (!inv || !stage) {
327
+ return error ? <Alert severity="error">{error}</Alert> : <CircularProgress size={22} sx={{ m: 3 }} />;
328
+ }
329
+
330
+ const isDone = stage.status === 'complete' || stage.status === 'complete_with_unknowns';
331
+ const View = STAGE_VIEWS[stage.key];
332
+
333
+ return (
334
+ <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
335
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
336
+ <Button size="small" startIcon={<ArrowLeft size={14} />} onClick={onBack}>
337
+ All investigations
338
+ </Button>
339
+ <Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{inv.name}</Typography>
340
+ <Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#8a949e' }}>
341
+ {inv.candidate_model} vs {inv.baseline_model} · seed {inv.seed}
342
+ </Typography>
343
+ {inv.training_mode ? (
344
+ <Chip size="small" icon={<FlaskConical size={12} />} label="training mode" sx={{ height: 20, fontSize: 10, bgcolor: '#232a31' }} />
345
+ ) : null}
346
+ </Box>
347
+ <HypothesisBanner inv={inv} workingSet={workingSet} />
348
+ <Box sx={{ display: 'flex', flex: 1, minHeight: 0, border: `1px solid ${BORDER}`, borderRadius: 1, overflow: 'hidden' }}>
349
+ <StageRail stages={inv.stages} selected={stageIdx} onSelect={setStageIdx} />
350
+ <Box sx={{ flex: 1, minWidth: 0, overflowY: 'auto', p: 2 }}>
351
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.75 }}>
352
+ <Box sx={{ flex: 1 }}>
353
+ <Typography variant="h6" sx={{ fontWeight: 800, fontSize: 16 }}>
354
+ Stage {stage.index}: {stage.title}
355
+ </Typography>
356
+ </Box>
357
+ {STAGE_VIEWS[stage.key] || stage.key === 'root_cause_scoring' ? (
358
+ <Button size="small" variant="outlined" startIcon={<StickyNote size={13} />} onClick={() => setFindingOpen(true)}>
359
+ Record finding
360
+ </Button>
361
+ ) : null}
362
+ {!isDone ? (
363
+ <Button size="small" variant="contained" startIcon={<CheckCheck size={14} />} onClick={() => complete()}>
364
+ Mark stage complete
365
+ </Button>
366
+ ) : (
367
+ <Chip size="small" label={stage.status === 'complete' ? 'complete' : 'complete with unknowns'} sx={{
368
+ height: 24, fontWeight: 700, fontSize: 11,
369
+ bgcolor: stage.status === 'complete' ? '#66bb6a22' : '#ffb74d22',
370
+ color: stage.status === 'complete' ? '#66bb6a' : '#ffb74d',
371
+ }} />
372
+ )}
373
+ </Box>
374
+ {stageFindings.length ? (
375
+ <Box sx={{ mb: 1 }}>
376
+ <FindingChips findings={stageFindings} />
377
+ </Box>
378
+ ) : null}
379
+ {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
380
+ {loading ? <CircularProgress size={20} sx={{ my: 2 }} /> : null}
381
+
382
+ {View && diag[stage.key] ? <View data={diag[stage.key].data} /> : null}
383
+
384
+ {stage.key === 'root_cause_scoring' && board && tree ? (
385
+ <>
386
+ <ScoreBoardView board={board} onJumpToStage={jumpToStage} onAssess={assess} />
387
+ <DecisionTreeView tree={tree} />
388
+ </>
389
+ ) : null}
390
+
391
+ {stage.key === 'recommendations_report' && experiments && report ? (
392
+ <>
393
+ <ExperimentsView exp={experiments} />
394
+ <ReportView report={report} inv={inv} reveal={reveal} onReveal={doReveal} />
395
+ </>
396
+ ) : null}
397
+
398
+ {stage.key === 'root_cause_scoring' && !board && !loading ? (
399
+ <Explainer text="Scoring runs once stages 0–10 are complete; every point on the board traces back to a recorded stage finding." />
400
+ ) : null}
401
+ </Box>
402
+ </Box>
403
+
404
+ <RecordFindingDialog
405
+ open={findingOpen}
406
+ stageTitle={stage.title}
407
+ onClose={() => setFindingOpen(false)}
408
+ onSubmit={submitFinding}
409
+ />
410
+ <AcknowledgeUnknownsDialog
411
+ open={ackOpen}
412
+ stageTitle={stage.title}
413
+ unknownTitles={unknownTitles}
414
+ onClose={() => setAckOpen(false)}
415
+ onAcknowledge={(note) => complete(true, note)}
416
+ />
417
+ </Box>
418
+ );
419
+ }
420
+
421
+ // -------------------------------------------------------------------- page
422
+
423
+ export default function RootCauseLabPage() {
424
+ const [activeId, setActiveId] = useState<string | null>(null);
425
+ const [refreshKey, setRefreshKey] = useState(0);
426
+
427
+ if (activeId) {
428
+ return (
429
+ <Box sx={{ height: 'calc(100vh - 170px)', minHeight: 480 }}>
430
+ <InvestigationWorkspace invId={activeId} onBack={() => { setActiveId(null); setRefreshKey((k) => k + 1); }} />
431
+ </Box>
432
+ );
433
+ }
434
+ return (
435
+ <Box key={refreshKey}>
436
+ <Explainer text="Offline evaluation says the candidate is +5% better; shadow production says −2% worse. This lab refuses to assume either number is real: it walks a 13-stage skeptical methodology — comparison validity, offline audit, population, shift, conditional performance, paired transitions, significance, feature/serving parity, traffic, labels — before scoring 8 root-cause hypotheses against the evidence." />
437
+ <CreatePanel onCreated={(inv) => setActiveId(inv.id)} />
438
+ <InvestigationList onOpen={setActiveId} />
439
+ </Box>
440
+ );
441
+ }
src/services/rca.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Typed fetch wrappers for the Regression RCA workbench API (/api/rca).
3
+ * Mirrors the style of services/megaeval.ts.
4
+ */
5
+ import type {
6
+ Confidence,
7
+ DecisionTree,
8
+ DiagnosticsResponse,
9
+ Experiments,
10
+ Finding,
11
+ FindingStatus,
12
+ Investigation,
13
+ InvestigationSummary,
14
+ RcaReport,
15
+ RevealResponse,
16
+ RootCause,
17
+ Scoreboard,
18
+ Severity,
19
+ StageMeta,
20
+ StageState,
21
+ } from '../types/rca';
22
+
23
+ async function handle<T>(res: Response): Promise<T> {
24
+ if (!res.ok) {
25
+ let detail = '';
26
+ try {
27
+ const body = await res.json();
28
+ detail = typeof body?.detail === 'string' ? body.detail : JSON.stringify(body);
29
+ } catch {
30
+ detail = await res.text().catch(() => '');
31
+ }
32
+ const err = new Error(detail || res.statusText) as Error & { status?: number };
33
+ err.status = res.status;
34
+ throw err;
35
+ }
36
+ return res.json() as Promise<T>;
37
+ }
38
+
39
+ const JSON_HEADERS = { 'Content-Type': 'application/json' };
40
+
41
+ const get = <T,>(url: string): Promise<T> => fetch(url).then((r) => handle<T>(r));
42
+ const post = <T,>(url: string, body?: unknown): Promise<T> =>
43
+ fetch(url, {
44
+ method: 'POST',
45
+ headers: JSON_HEADERS,
46
+ body: body === undefined ? undefined : JSON.stringify(body),
47
+ }).then((r) => handle<T>(r));
48
+
49
+ export const getCauses = () =>
50
+ get<{ causes: { id: RootCause; label: string }[] }>('/api/rca/causes');
51
+
52
+ export const getStages = () => get<{ stages: StageMeta[] }>('/api/rca/stages');
53
+
54
+ export const createInvestigation = (body: {
55
+ name?: string;
56
+ baseline_model?: string;
57
+ candidate_model?: string;
58
+ cause?: RootCause | null;
59
+ seed?: number | null;
60
+ training_mode?: boolean;
61
+ }) => post<Investigation>('/api/rca/investigations', body);
62
+
63
+ export const listInvestigations = () =>
64
+ get<{ investigations: InvestigationSummary[] }>('/api/rca/investigations');
65
+
66
+ export const getInvestigation = (id: string) =>
67
+ get<Investigation>(`/api/rca/investigations/${encodeURIComponent(id)}`);
68
+
69
+ export const completeStage = (id: string, index: number, body: {
70
+ acknowledge_unknowns?: boolean;
71
+ note?: string;
72
+ }) =>
73
+ post<{ stage: StageState; investigation: Investigation }>(
74
+ `/api/rca/investigations/${encodeURIComponent(id)}/stages/${index}/complete`,
75
+ body
76
+ );
77
+
78
+ export const reopenStage = (id: string, index: number) =>
79
+ post<{ stage: StageState; investigation: Investigation }>(
80
+ `/api/rca/investigations/${encodeURIComponent(id)}/stages/${index}/reopen`
81
+ );
82
+
83
+ export const recordFinding = (id: string, body: {
84
+ stage: string;
85
+ title: string;
86
+ status: FindingStatus;
87
+ severity: Severity;
88
+ detail?: string;
89
+ code?: string;
90
+ }) =>
91
+ post<{ finding: Finding }>(
92
+ `/api/rca/investigations/${encodeURIComponent(id)}/findings`,
93
+ body
94
+ );
95
+
96
+ export const getDiagnostics = (id: string, stageKey: string) =>
97
+ get<DiagnosticsResponse>(
98
+ `/api/rca/investigations/${encodeURIComponent(id)}/diagnostics/${encodeURIComponent(stageKey)}`
99
+ );
100
+
101
+ export const getScoreboard = (id: string, recordedOnly = false) =>
102
+ get<Scoreboard>(
103
+ `/api/rca/investigations/${encodeURIComponent(id)}/scoreboard${recordedOnly ? '?recorded_only=true' : ''}`
104
+ );
105
+
106
+ export const assessHypothesis = (id: string, body: {
107
+ hypothesis: RootCause;
108
+ confidence: Confidence;
109
+ note?: string;
110
+ }) =>
111
+ post<Scoreboard>(
112
+ `/api/rca/investigations/${encodeURIComponent(id)}/scoreboard/assess`,
113
+ body
114
+ );
115
+
116
+ export const getDecisionTree = (id: string) =>
117
+ get<DecisionTree>(`/api/rca/investigations/${encodeURIComponent(id)}/decision-tree`);
118
+
119
+ export const getExperiments = (id: string) =>
120
+ get<Experiments>(`/api/rca/investigations/${encodeURIComponent(id)}/experiments`);
121
+
122
+ export const getReport = (id: string) =>
123
+ get<RcaReport>(`/api/rca/investigations/${encodeURIComponent(id)}/report`);
124
+
125
+ export const revealCause = (id: string) =>
126
+ post<RevealResponse>(`/api/rca/investigations/${encodeURIComponent(id)}/reveal`);
src/types/rca.ts ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Types for the Regression RCA workbench (/api/rca). */
2
+
3
+ export type RootCause =
4
+ | 'TRUE_MODEL_REGRESSION'
5
+ | 'DISTRIBUTION_SHIFT'
6
+ | 'FEATURE_SKEW'
7
+ | 'SERVING_MISMATCH'
8
+ | 'LABEL_LATENCY'
9
+ | 'SAMPLING_BIAS'
10
+ | 'STATISTICAL_NOISE'
11
+ | 'OFFLINE_CONTAMINATION';
12
+
13
+ export type FindingStatus = 'PASS' | 'MISMATCH' | 'UNKNOWN';
14
+ export type Severity = 'INFO' | 'WARN' | 'CRITICAL';
15
+ export type StageStatus =
16
+ | 'pending'
17
+ | 'in_progress'
18
+ | 'complete'
19
+ | 'complete_with_unknowns';
20
+ export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
21
+
22
+ export interface StageMeta {
23
+ index: number;
24
+ key: string;
25
+ title: string;
26
+ question: string;
27
+ }
28
+
29
+ export interface StageState {
30
+ index: number;
31
+ key: string;
32
+ title: string;
33
+ status: StageStatus;
34
+ completed_at: number | null;
35
+ acknowledged_unknowns: boolean;
36
+ ack_note: string;
37
+ skip_acknowledged: boolean;
38
+ }
39
+
40
+ export interface Finding {
41
+ id: string;
42
+ stage: string;
43
+ code: string;
44
+ title: string;
45
+ status: FindingStatus;
46
+ severity: Severity;
47
+ detail: string;
48
+ source: 'auto' | 'human';
49
+ created_at: number;
50
+ data: Record<string, unknown>;
51
+ }
52
+
53
+ export interface InvestigationClaims {
54
+ metric: string;
55
+ offline_delta_pp: number;
56
+ shadow_delta_pp: number;
57
+ offline_n: number;
58
+ shadow_scored_n: number;
59
+ }
60
+
61
+ export interface Investigation {
62
+ id: string;
63
+ name: string;
64
+ baseline_model: string;
65
+ candidate_model: string;
66
+ seed: number;
67
+ training_mode: boolean;
68
+ revealed: boolean;
69
+ created_at: number;
70
+ claims: InvestigationClaims;
71
+ stages: StageState[];
72
+ findings: Finding[];
73
+ human_assessments: Record<string, { confidence: Confidence; note: string }>;
74
+ events: { ts: number; kind: string; message: string; data: Record<string, unknown> }[];
75
+ scenario_cause?: RootCause;
76
+ }
77
+
78
+ export interface InvestigationSummary {
79
+ id: string;
80
+ name: string;
81
+ baseline_model: string;
82
+ candidate_model: string;
83
+ training_mode: boolean;
84
+ revealed: boolean;
85
+ created_at: number;
86
+ claims: InvestigationClaims;
87
+ stages_complete: number;
88
+ stages_total: number;
89
+ scenario_cause?: RootCause;
90
+ }
91
+
92
+ export interface DiagnosticsResponse {
93
+ stage: string;
94
+ data: Record<string, any>;
95
+ findings: Finding[];
96
+ }
97
+
98
+ export interface EvidenceLink {
99
+ code: string;
100
+ stage: string;
101
+ title: string;
102
+ weight: number;
103
+ severity: Severity;
104
+ status: FindingStatus;
105
+ finding_id: string;
106
+ }
107
+
108
+ export interface ScoreboardRow {
109
+ hypothesis: RootCause;
110
+ label: string;
111
+ score: number;
112
+ rank: number;
113
+ evidence_for: EvidenceLink[];
114
+ evidence_against: EvidenceLink[];
115
+ auto_confidence: Confidence;
116
+ human_confidence: Confidence | null;
117
+ human_note: string;
118
+ next_discriminating_test: string;
119
+ }
120
+
121
+ export interface Scoreboard {
122
+ rows: ScoreboardRow[];
123
+ top_hypothesis: RootCause;
124
+ top_confidence: Confidence;
125
+ score_gap: number;
126
+ working_hypothesis_set: RootCause[];
127
+ explainer: string;
128
+ }
129
+
130
+ export interface DecisionNode {
131
+ id: string;
132
+ question: string;
133
+ answer: 'yes' | 'no' | 'unknown';
134
+ basis: string[];
135
+ conclusion_if_no: string | null;
136
+ next: string | null;
137
+ }
138
+
139
+ export interface DecisionTree {
140
+ nodes: DecisionNode[];
141
+ path: string[];
142
+ conclusion: string | null;
143
+ conclusion_kind: 'root_cause' | 'insufficient_evidence' | 'no_regression';
144
+ explainer: string;
145
+ }
146
+
147
+ export interface ExperimentDesign {
148
+ id: string;
149
+ design: string;
150
+ description: string;
151
+ discriminates: RootCause[];
152
+ cost: 'low' | 'medium' | 'high';
153
+ expected_days: number;
154
+ information_gain: number;
155
+ priority: number;
156
+ rank: number;
157
+ }
158
+
159
+ export interface Experiments {
160
+ experiments: ExperimentDesign[];
161
+ minimum_additional_evidence: string;
162
+ power: { effective_n: number; needed_effective_n: number; practical_margin_pp: number };
163
+ explainer: string;
164
+ }
165
+
166
+ export interface RcaReport {
167
+ investigation_id: string;
168
+ name: string;
169
+ generated_at: number;
170
+ models: { baseline: string; candidate: string };
171
+ claims: InvestigationClaims;
172
+ executive_finding: {
173
+ conclusion: string | null;
174
+ conclusion_kind: string;
175
+ top_hypothesis: RootCause;
176
+ label: string;
177
+ confidence: Confidence;
178
+ score: number;
179
+ score_gap_to_runner_up: number;
180
+ };
181
+ stage_summaries: {
182
+ stage: string;
183
+ headline: string;
184
+ mismatches: string[];
185
+ unknowns: string[];
186
+ n_pass: number;
187
+ verdict: 'mismatch' | 'unknown' | 'clean';
188
+ }[];
189
+ hypothesis_ranking: {
190
+ rank: number;
191
+ hypothesis: RootCause;
192
+ score: number;
193
+ confidence: Confidence;
194
+ human_confidence: Confidence | null;
195
+ evidence_for: number;
196
+ evidence_against: number;
197
+ }[];
198
+ decision_path: string[];
199
+ minimum_additional_evidence: string;
200
+ recommended_experiments: ExperimentDesign[];
201
+ remediation: { containment: string[]; short_term: string[]; long_term: string[] };
202
+ acknowledged_unknowns: { ts: number; kind: string; message: string; data: Record<string, unknown> }[];
203
+ human_findings: Finding[];
204
+ markdown: string;
205
+ }
206
+
207
+ export interface RevealResponse {
208
+ cause: RootCause;
209
+ explanation: string;
210
+ }