Spaces:
Paused
Paused
Deploy Sensorflow Studio
Browse files- .gitignore +1 -0
- ARCHITECTURE.md +78 -0
- app_backend.py +4 -0
- sensorflow/evaluation/api.py +13 -0
- sensorflow/megaeval/__init__.py +16 -0
- sensorflow/megaeval/analysis.py +314 -0
- sensorflow/megaeval/api.py +387 -0
- sensorflow/megaeval/cube.py +270 -0
- sensorflow/megaeval/errors.py +106 -0
- sensorflow/megaeval/population.py +281 -0
- sensorflow/megaeval/runs.py +704 -0
- sensorflow/megaeval/sampling.py +261 -0
- sensorflow/megaeval/sketches.py +173 -0
- src/App.tsx +6 -1
- src/components/megaeval/CohortTab.tsx +266 -0
- src/components/megaeval/CompareTab.tsx +221 -0
- src/components/megaeval/ContainersTab.tsx +321 -0
- src/components/megaeval/HeaderBar.tsx +335 -0
- src/components/megaeval/HeroRow.tsx +117 -0
- src/components/megaeval/InvestigationTab.tsx +266 -0
- src/components/megaeval/LineageCard.tsx +100 -0
- src/components/megaeval/QualityTab.tsx +358 -0
- src/components/megaeval/ReviewTab.tsx +205 -0
- src/components/megaeval/RunProgressCard.tsx +63 -0
- src/components/megaeval/ShiftTab.tsx +96 -0
- src/components/megaeval/shared.tsx +304 -0
- src/context/LabelEvalContext.tsx +2 -0
- src/pages/CommandCenterPage.tsx +325 -0
- src/services/labeleval.ts +9 -0
- src/services/megaeval.ts +152 -0
- src/types/labeleval.ts +4 -0
- src/types/megaeval.ts +536 -0
- tests/test_megaeval/__init__.py +0 -0
- tests/test_megaeval/conftest.py +36 -0
- tests/test_megaeval/test_analysis.py +76 -0
- tests/test_megaeval/test_api_lifecycle.py +190 -0
- tests/test_megaeval/test_cube.py +109 -0
- tests/test_megaeval/test_error_index.py +64 -0
- tests/test_megaeval/test_sampling.py +64 -0
- tests/test_megaeval/test_sketches.py +70 -0
.gitignore
CHANGED
|
@@ -64,6 +64,7 @@ temp/
|
|
| 64 |
|
| 65 |
# Generated runtime/test artifacts
|
| 66 |
runs/labeleval/
|
|
|
|
| 67 |
runs/pipeline/frame_test/
|
| 68 |
runs/pipeline/qg_test/
|
| 69 |
runs/pipeline/roundtrip/
|
|
|
|
| 64 |
|
| 65 |
# Generated runtime/test artifacts
|
| 66 |
runs/labeleval/
|
| 67 |
+
runs/megaeval/
|
| 68 |
runs/pipeline/frame_test/
|
| 69 |
runs/pipeline/qg_test/
|
| 70 |
runs/pipeline/roundtrip/
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sensorflow Studio — Aggregate-First Evaluation Architecture (megaeval)
|
| 2 |
+
|
| 3 |
+
The `sensorflow/megaeval/` layer replaces the "evaluation-record browser" model
|
| 4 |
+
(annotation as the primary unit) with an **aggregate-first, risk-first,
|
| 5 |
+
cohort-first, drill-down-on-demand** design.
|
| 6 |
+
|
| 7 |
+
## Mental model → code
|
| 8 |
+
|
| 9 |
+
| Concept | Meaning | Module / storage |
|
| 10 |
+
| --- | --- | --- |
|
| 11 |
+
| Dataset | immutable source population | `population.py` → `runs/megaeval/populations/{pop}/part-*.npz` |
|
| 12 |
+
| Evaluation Run | model+config being evaluated (async job, first-class) | `runs.py` → `runs/megaeval/runs/{run}/run.json` |
|
| 13 |
+
| Population | all objects evaluated in a run | headline row of the metric cube |
|
| 14 |
+
| Cohort | slice by class/weather/lighting/road/scenario/sensor/distance/speed/occlusion | cube cells + `POST /api/evaluations/query` |
|
| 15 |
+
| Container | scene/frame/segment holding related objects | `containers.npz` aggregate table |
|
| 16 |
+
| Annotation | individual object — forensic view only | per-object `objects-part-*.npz`, loaded one partition at a time |
|
| 17 |
+
|
| 18 |
+
## Data flow
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
generate population (vectorized numpy, partitioned npz)
|
| 22 |
+
│
|
| 23 |
+
create run (queued) ──► worker pool (4 threads) processes partitions:
|
| 24 |
+
│ model simulation → PARTIAL sufficient statistics
|
| 25 |
+
│ (n, tp, fp, fn, sum_iou, conf sums, histograms …)
|
| 26 |
+
│ grouped by the 9 cube dimensions, per partition
|
| 27 |
+
▼
|
| 28 |
+
reduce (concat + groupby-sum) ──► materialize METRIC CUBE (cube.npz)
|
| 29 |
+
│ + error index, container table, sketches,
|
| 30 |
+
│ container embeddings
|
| 31 |
+
▼
|
| 32 |
+
publish ──► queries served by QueryRouter: cache → cube → (rare) record scan
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Aggregation **never** scans raw records at query time; queries like
|
| 36 |
+
"pedestrian recall at night in rain for model v42" are cube lookups.
|
| 37 |
+
|
| 38 |
+
## Single-node stand-ins for distributed infrastructure
|
| 39 |
+
|
| 40 |
+
This repo runs on one laptop; the seams are shaped so each piece maps to the
|
| 41 |
+
production-scale equivalent:
|
| 42 |
+
|
| 43 |
+
| Here (single node) | Production analogue |
|
| 44 |
+
| --- | --- |
|
| 45 |
+
| npz column partitions (`part-0000.npz…`, hash-partitioned by container) | Parquet files in an Iceberg/Delta table, partitioned by scene |
|
| 46 |
+
| `ThreadPoolExecutor` workers over partitions | Spark executors / Flink task slots |
|
| 47 |
+
| partial stats per partition + `reduce_partials` | map-side combine + shuffle reduce |
|
| 48 |
+
| `QueryRouter` (cache → cube → scan) with latency+source reporting | OLAP engine (Druid/Pinot/ClickHouse) over the lakehouse |
|
| 49 |
+
| `QueryCache` keyed by hash(dataset+model+filters+group_by+metrics) | result cache / materialized views |
|
| 50 |
+
| in-process SSE progress events | job-status topic (Kafka) + progress service |
|
| 51 |
+
| numpy cosine similarity over container embeddings | vector DB (hybrid retrieval) |
|
| 52 |
+
|
| 53 |
+
## Honesty rules encoded in the API
|
| 54 |
+
|
| 55 |
+
- Cube counts and derived ratios are **exact** (`meta.exact = true`).
|
| 56 |
+
- Percentiles/distributions come from fixed-bin quantile histograms;
|
| 57 |
+
cardinalities from HyperLogLog — always labeled approximate.
|
| 58 |
+
- Precision/recall from human review are **sampling estimates with 95% CIs**
|
| 59 |
+
(stratified risk-weighted design: Wilson per stratum, weighted normal-approx
|
| 60 |
+
combination), reported with n reviewed / frame size / method.
|
| 61 |
+
- Every query response reports `source` (cache/cube/scan), `cache_hit`,
|
| 62 |
+
and `latency_ms` so aggregate-first behavior is observable in the UI.
|
| 63 |
+
- Evaluation runs are reproducible: lineage (dataset, model+checkpoint, label
|
| 64 |
+
version, evaluator code version, metric version, thresholds, sampling config,
|
| 65 |
+
seed, hardware) is stored on the run and derives the deterministic seed.
|
| 66 |
+
|
| 67 |
+
## Performance SLOs (measured locally, 320k-object population, 67k cube cells)
|
| 68 |
+
|
| 69 |
+
| Query | SLO | Measured |
|
| 70 |
+
| --- | --- | --- |
|
| 71 |
+
| aggregate dashboard (group by class) | < 2 s | ~75 ms |
|
| 72 |
+
| filtered cohort query | < 3 s | ~350 ms first / sub-ms cached |
|
| 73 |
+
| cached query | < 100 ms | ~0.1 ms |
|
| 74 |
+
| object investigation (one container, forensic) | < 1 s | ~50–200 ms |
|
| 75 |
+
|
| 76 |
+
The legacy `sensorflow/evaluation/` layer (label-evaluation pipeline, triage,
|
| 77 |
+
HITL, training flywheel) remains unchanged; megaeval integrates with its
|
| 78 |
+
regression tracker and alerting rather than duplicating them.
|
app_backend.py
CHANGED
|
@@ -1463,6 +1463,10 @@ def get_mitl_queue(sequence_id: str = "seq_001"):
|
|
| 1463 |
from sensorflow.evaluation.api import router as labeleval_router
|
| 1464 |
app.include_router(labeleval_router)
|
| 1465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1466 |
# Mount static folder
|
| 1467 |
static_dir = Path(__file__).parent / "static"
|
| 1468 |
if static_dir.exists():
|
|
|
|
| 1463 |
from sensorflow.evaluation.api import router as labeleval_router
|
| 1464 |
app.include_router(labeleval_router)
|
| 1465 |
|
| 1466 |
+
# Aggregate-first mega-scale evaluation layer (metric cube, async runs, query API).
|
| 1467 |
+
from sensorflow.megaeval.api import router as megaeval_router
|
| 1468 |
+
app.include_router(megaeval_router)
|
| 1469 |
+
|
| 1470 |
# Mount static folder
|
| 1471 |
static_dir = Path(__file__).parent / "static"
|
| 1472 |
if static_dir.exists():
|
sensorflow/evaluation/api.py
CHANGED
|
@@ -564,6 +564,18 @@ def copilot_explain_evidence(req: CopilotRequest):
|
|
| 564 |
# ------------------------------------------------------------------ SSE stream
|
| 565 |
|
| 566 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
@router.get("/events/stream")
|
| 568 |
async def events_stream(ticks: int = 600):
|
| 569 |
async def gen():
|
|
@@ -573,6 +585,7 @@ async def events_stream(ticks: int = 600):
|
|
| 573 |
"pipeline": _pipeline_state(),
|
| 574 |
"training": _active_training_job(),
|
| 575 |
"alerts_count": len(get_store().all("alerts")),
|
|
|
|
| 576 |
}
|
| 577 |
yield f"data: {json.dumps(payload)}\n\n"
|
| 578 |
await asyncio.sleep(1.0)
|
|
|
|
| 564 |
# ------------------------------------------------------------------ SSE stream
|
| 565 |
|
| 566 |
|
| 567 |
+
def _megaeval_status() -> Dict:
|
| 568 |
+
try:
|
| 569 |
+
from sensorflow.megaeval.runs import get_mega_store
|
| 570 |
+
mstore = get_mega_store()
|
| 571 |
+
active = [r.progress_dict() for r in mstore.runs.values()
|
| 572 |
+
if r.status in ("queued", "running", "reducing", "materializing")]
|
| 573 |
+
return {"active_runs": active, "cache": mstore.router.cache.stats(),
|
| 574 |
+
"total_runs": len(mstore.runs)}
|
| 575 |
+
except Exception:
|
| 576 |
+
return {"active_runs": [], "cache": None, "total_runs": 0}
|
| 577 |
+
|
| 578 |
+
|
| 579 |
@router.get("/events/stream")
|
| 580 |
async def events_stream(ticks: int = 600):
|
| 581 |
async def gen():
|
|
|
|
| 585 |
"pipeline": _pipeline_state(),
|
| 586 |
"training": _active_training_job(),
|
| 587 |
"alerts_count": len(get_store().all("alerts")),
|
| 588 |
+
"megaeval": _megaeval_status(),
|
| 589 |
}
|
| 590 |
yield f"data: {json.dumps(payload)}\n\n"
|
| 591 |
await asyncio.sleep(1.0)
|
sensorflow/megaeval/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""megaeval — aggregate-first, risk-first evaluation layer for mega-scale populations.
|
| 2 |
+
|
| 3 |
+
Mental model (maps 1:1 to modules):
|
| 4 |
+
|
| 5 |
+
Dataset (immutable population) -> population.py (partitioned npz storage)
|
| 6 |
+
Evaluation Run (first-class job) -> runs.py (async workers -> partial stats -> reduce)
|
| 7 |
+
Population / Cohort -> cube.py (metric cube + query router + cache)
|
| 8 |
+
Container -> runs.py (container-level aggregate table)
|
| 9 |
+
Annotation (forensic drill-down) -> runs.py (per-object partitions, loaded on demand)
|
| 10 |
+
|
| 11 |
+
Single-node stand-ins for distributed infra (see ARCHITECTURE.md):
|
| 12 |
+
npz partitions ~ Parquet files in an Iceberg table
|
| 13 |
+
worker thread pool ~ Spark executors / Flink task slots
|
| 14 |
+
partial-stats+reduce~ map-side combine + shuffle reduce
|
| 15 |
+
QueryRouter ~ OLAP engine (Druid/Pinot) in front of the lakehouse
|
| 16 |
+
"""
|
sensorflow/megaeval/analysis.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-run analysis: model-vs-model comparison, distribution shift, the "why"
|
| 2 |
+
layer, and hybrid similarity search.
|
| 3 |
+
|
| 4 |
+
All computations run on cubes and the error index — never on raw records.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from sensorflow.megaeval import cube as cube_mod
|
| 17 |
+
from sensorflow.megaeval import population as pop_mod
|
| 18 |
+
from sensorflow.megaeval.population import DIMENSIONS, DIM_NAMES
|
| 19 |
+
from sensorflow.megaeval.runs import ERROR_TYPES, EvaluationRun, MegaStore, run_dir
|
| 20 |
+
|
| 21 |
+
# ------------------------------------------------------------------ model vs model
|
| 22 |
+
|
| 23 |
+
DEFAULT_PROMOTION_POLICY = {
|
| 24 |
+
"max_recall_drop": 0.010,
|
| 25 |
+
"max_precision_drop": 0.015,
|
| 26 |
+
"max_safety_recall_drop": 0.005,
|
| 27 |
+
"max_cohort_recall_drop": 0.050,
|
| 28 |
+
"min_cohort_support": 200,
|
| 29 |
+
"cohort_dims": ["road_type", "lighting", "class"],
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
_HEADLINE_METRICS = ["precision", "recall", "f1", "mean_iou", "safety_recall", "anomaly_rate"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def compare_runs(store: MegaStore, candidate: EvaluationRun, baseline: EvaluationRun,
|
| 36 |
+
policy: Optional[Dict] = None) -> Dict:
|
| 37 |
+
policy = {**DEFAULT_PROMOTION_POLICY, **(policy or {})}
|
| 38 |
+
cand_art, base_art = store.artifacts(candidate.run_id), store.artifacts(baseline.run_id)
|
| 39 |
+
cand_cube, base_cube = cand_art["cube"], base_art["cube"]
|
| 40 |
+
|
| 41 |
+
headline = []
|
| 42 |
+
for m in _HEADLINE_METRICS:
|
| 43 |
+
c, b = candidate.headline.get(m), baseline.headline.get(m)
|
| 44 |
+
if c is None or b is None:
|
| 45 |
+
continue
|
| 46 |
+
headline.append({"metric": m, "baseline": round(b, 4), "candidate": round(c, 4),
|
| 47 |
+
"delta": round(c - b, 4)})
|
| 48 |
+
|
| 49 |
+
per_class = []
|
| 50 |
+
for cls in DIMENSIONS["class"]:
|
| 51 |
+
c, b = candidate.per_class.get(cls), baseline.per_class.get(cls)
|
| 52 |
+
if not c or not b:
|
| 53 |
+
continue
|
| 54 |
+
per_class.append({
|
| 55 |
+
"class": cls, "n": c.get("n"),
|
| 56 |
+
"recall_baseline": b.get("recall"), "recall_candidate": c.get("recall"),
|
| 57 |
+
"recall_delta": round((c.get("recall") or 0) - (b.get("recall") or 0), 4),
|
| 58 |
+
"precision_baseline": b.get("precision"), "precision_candidate": c.get("precision"),
|
| 59 |
+
"precision_delta": round((c.get("precision") or 0) - (b.get("precision") or 0), 4),
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
dims = policy["cohort_dims"]
|
| 63 |
+
cand_rows, _ = cube_mod.aggregate(cand_cube, None, dims, ["n", "recall", "precision"], 2000)
|
| 64 |
+
base_rows, _ = cube_mod.aggregate(base_cube, None, dims, ["n", "recall", "precision"], 2000)
|
| 65 |
+
base_map = {tuple(r[d] for d in dims): r for r in base_rows}
|
| 66 |
+
cohorts = []
|
| 67 |
+
for r in cand_rows:
|
| 68 |
+
key = tuple(r[d] for d in dims)
|
| 69 |
+
b = base_map.get(key)
|
| 70 |
+
if not b or r["n"] < policy["min_cohort_support"]:
|
| 71 |
+
continue
|
| 72 |
+
if r.get("recall") is None or b.get("recall") is None:
|
| 73 |
+
continue
|
| 74 |
+
cohorts.append({
|
| 75 |
+
"cohort": "/".join(key), **{d: r[d] for d in dims}, "n": r["n"],
|
| 76 |
+
"recall_baseline": b["recall"], "recall_candidate": r["recall"],
|
| 77 |
+
"recall_delta": round(r["recall"] - b["recall"], 4),
|
| 78 |
+
})
|
| 79 |
+
cohorts.sort(key=lambda x: x["recall_delta"])
|
| 80 |
+
regressions = [c for c in cohorts if c["recall_delta"] < -policy["max_cohort_recall_drop"]]
|
| 81 |
+
|
| 82 |
+
blockers: List[str] = []
|
| 83 |
+
hd = {h["metric"]: h["delta"] for h in headline}
|
| 84 |
+
if hd.get("recall", 0) < -policy["max_recall_drop"]:
|
| 85 |
+
blockers.append(f"headline recall dropped {hd['recall']:+.4f} "
|
| 86 |
+
f"(policy allows -{policy['max_recall_drop']})")
|
| 87 |
+
if hd.get("precision", 0) < -policy["max_precision_drop"]:
|
| 88 |
+
blockers.append(f"headline precision dropped {hd['precision']:+.4f}")
|
| 89 |
+
if hd.get("safety_recall", 0) < -policy["max_safety_recall_drop"]:
|
| 90 |
+
blockers.append(f"safety recall dropped {hd['safety_recall']:+.4f} "
|
| 91 |
+
f"(policy allows -{policy['max_safety_recall_drop']})")
|
| 92 |
+
for c in regressions[:5]:
|
| 93 |
+
blockers.append(
|
| 94 |
+
f"REGRESSION: {c['cohort']} recall "
|
| 95 |
+
f"{c['recall_baseline']*100:.1f}% -> {c['recall_candidate']*100:.1f}% "
|
| 96 |
+
f"(Δ {c['recall_delta']*100:+.1f})")
|
| 97 |
+
|
| 98 |
+
# integrate with the existing regression tracker so the Regression page shows it
|
| 99 |
+
try:
|
| 100 |
+
from sensorflow.evaluation import regression as legacy_regression
|
| 101 |
+
from sensorflow.evaluation.records import get_store as get_legacy_store
|
| 102 |
+
legacy_regression.compare_runs(
|
| 103 |
+
get_legacy_store(),
|
| 104 |
+
current_metrics={"precision": candidate.headline.get("precision"),
|
| 105 |
+
"recall": candidate.headline.get("recall"),
|
| 106 |
+
"safety_critical_recall": candidate.headline.get("safety_recall"),
|
| 107 |
+
"mean_iou_3d": candidate.headline.get("mean_iou")},
|
| 108 |
+
model_version=candidate.model_version,
|
| 109 |
+
dataset_version=candidate.population_id,
|
| 110 |
+
run_id=candidate.run_id,
|
| 111 |
+
baseline_metrics={"precision": baseline.headline.get("precision"),
|
| 112 |
+
"recall": baseline.headline.get("recall"),
|
| 113 |
+
"safety_critical_recall": baseline.headline.get("safety_recall"),
|
| 114 |
+
"mean_iou_3d": baseline.headline.get("mean_iou")},
|
| 115 |
+
baseline_version=baseline.model_version,
|
| 116 |
+
affected_classes=[c["class"] for c in per_class if c["recall_delta"] < -0.03],
|
| 117 |
+
affected_scenarios=sorted({c.get("road_type", "") for c in regressions[:5] if c}),
|
| 118 |
+
)
|
| 119 |
+
except Exception:
|
| 120 |
+
pass
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"candidate": {"run_id": candidate.run_id, "model_version": candidate.model_version},
|
| 124 |
+
"baseline": {"run_id": baseline.run_id, "model_version": baseline.model_version},
|
| 125 |
+
"headline_deltas": headline,
|
| 126 |
+
"per_class": per_class,
|
| 127 |
+
"worst_cohorts": cohorts[:15],
|
| 128 |
+
"regressions": regressions[:10],
|
| 129 |
+
"policy": policy,
|
| 130 |
+
"recommendation": "DO_NOT_PROMOTE" if blockers else "PROMOTE",
|
| 131 |
+
"blockers": blockers,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ------------------------------------------------------------------ distribution shift
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def distribution_shift(store: MegaStore, run: EvaluationRun,
|
| 139 |
+
min_eval_count: int = 300, rel_threshold: float = 0.35) -> Dict:
|
| 140 |
+
cache_path = os.path.join(run_dir(run.run_id), "shift.json")
|
| 141 |
+
if os.path.exists(cache_path):
|
| 142 |
+
with open(cache_path) as f:
|
| 143 |
+
return json.load(f)
|
| 144 |
+
|
| 145 |
+
meta = pop_mod.load_meta(run.population_id)
|
| 146 |
+
cube_df = store.artifacts(run.run_id)["cube"]
|
| 147 |
+
overall_rows, _ = cube_mod.aggregate(cube_df, None, None, ["recall"], 1)
|
| 148 |
+
overall_recall = overall_rows[0].get("recall")
|
| 149 |
+
|
| 150 |
+
shifts = []
|
| 151 |
+
for row in meta.get("train_mix", []):
|
| 152 |
+
train, evals = row["train_share"], row["eval_share"]
|
| 153 |
+
if train <= 0 or row["eval_count"] < min_eval_count:
|
| 154 |
+
continue
|
| 155 |
+
rel = (evals - train) / train
|
| 156 |
+
if abs(rel) < rel_threshold:
|
| 157 |
+
continue
|
| 158 |
+
filt = {"class": [row["class"]], "weather": [row["weather"]],
|
| 159 |
+
"lighting": [row["lighting"]]}
|
| 160 |
+
crows, _ = cube_mod.aggregate(cube_df, filt, None, ["n", "recall", "precision"], 1)
|
| 161 |
+
c = crows[0] if crows else {}
|
| 162 |
+
shifts.append({
|
| 163 |
+
"cohort": f"{row['class']}/{row['weather']}/{row['lighting']}",
|
| 164 |
+
"class": row["class"], "weather": row["weather"], "lighting": row["lighting"],
|
| 165 |
+
"train_share": round(train, 5), "eval_share": round(evals, 5),
|
| 166 |
+
"relative_change": round(rel, 4),
|
| 167 |
+
"eval_count": row["eval_count"],
|
| 168 |
+
"cohort_recall": c.get("recall"),
|
| 169 |
+
"overall_recall": overall_recall,
|
| 170 |
+
"recall_gap": (round(c["recall"] - overall_recall, 4)
|
| 171 |
+
if c.get("recall") is not None and overall_recall is not None else None),
|
| 172 |
+
})
|
| 173 |
+
shifts.sort(key=lambda s: abs(s["relative_change"]) * (1 + abs(s.get("recall_gap") or 0) * 5),
|
| 174 |
+
reverse=True)
|
| 175 |
+
|
| 176 |
+
# surface the worst shifts as alerts in the existing alerting system (once per run)
|
| 177 |
+
try:
|
| 178 |
+
from sensorflow.evaluation.records import Alert, get_store as get_legacy_store, new_id
|
| 179 |
+
legacy = get_legacy_store()
|
| 180 |
+
for s in shifts[:3]:
|
| 181 |
+
if s.get("recall_gap") is not None and s["recall_gap"] < -0.03:
|
| 182 |
+
legacy.put("alerts", Alert(
|
| 183 |
+
alert_id=new_id("alert"), kind="distribution_shift",
|
| 184 |
+
severity="critical" if s["recall_gap"] < -0.08 else "warning",
|
| 185 |
+
message=(f"Distribution shift {s['cohort']}: train {s['train_share']*100:.1f}% "
|
| 186 |
+
f"-> eval {s['eval_share']*100:.1f}% "
|
| 187 |
+
f"(rel {s['relative_change']*100:+.0f}%), cohort recall "
|
| 188 |
+
f"{(s['cohort_recall'] or 0)*100:.1f}% vs overall "
|
| 189 |
+
f"{(s['overall_recall'] or 0)*100:.1f}%"),
|
| 190 |
+
evidence_page="command", evidence_id=run.run_id))
|
| 191 |
+
legacy.save()
|
| 192 |
+
except Exception:
|
| 193 |
+
pass
|
| 194 |
+
|
| 195 |
+
result = {"run_id": run.run_id, "shifts": shifts[:20],
|
| 196 |
+
"method": "train mix vs eval mix over (class, weather, lighting); "
|
| 197 |
+
"relative share change with cube recall impact",
|
| 198 |
+
"thresholds": {"min_eval_count": min_eval_count, "rel_threshold": rel_threshold}}
|
| 199 |
+
with open(cache_path, "w") as f:
|
| 200 |
+
json.dump(result, f)
|
| 201 |
+
return result
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ------------------------------------------------------------------ the "why" layer
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
_FACTOR_ORDER = ["occlusion", "low_illumination", "long_range", "sensor_disagreement", "other"]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def why(store: MegaStore, run: EvaluationRun, filters: Optional[Dict] = None,
|
| 211 |
+
metric: str = "recall") -> Dict:
|
| 212 |
+
"""Decompose the failure population of a filtered slice by primary factor."""
|
| 213 |
+
art = store.artifacts(run.run_id)
|
| 214 |
+
errors = art.get("errors")
|
| 215 |
+
cube_df = art["cube"]
|
| 216 |
+
if errors is None or not len(errors):
|
| 217 |
+
return {"factors": [], "top_cohorts": [], "failure_count": 0}
|
| 218 |
+
|
| 219 |
+
mask = np.ones(len(errors), dtype=bool)
|
| 220 |
+
fail_types = [ERROR_TYPES.index(t) for t in ("FN", "FP", "LOCALIZATION")]
|
| 221 |
+
if metric == "recall":
|
| 222 |
+
fail_types = [ERROR_TYPES.index("FN"), ERROR_TYPES.index("LOCALIZATION")]
|
| 223 |
+
elif metric == "precision":
|
| 224 |
+
fail_types = [ERROR_TYPES.index("FP"), ERROR_TYPES.index("LOCALIZATION")]
|
| 225 |
+
mask &= errors["error_type"].isin(fail_types).to_numpy()
|
| 226 |
+
for dim, values in (filters or {}).items():
|
| 227 |
+
if dim not in DIM_NAMES:
|
| 228 |
+
continue
|
| 229 |
+
vocab = DIMENSIONS[dim]
|
| 230 |
+
codes = [vocab.index(v) for v in (values if isinstance(values, list) else [values])
|
| 231 |
+
if v in vocab]
|
| 232 |
+
mask &= errors[dim].isin(codes).to_numpy()
|
| 233 |
+
sub = errors[mask]
|
| 234 |
+
total = len(sub)
|
| 235 |
+
if total == 0:
|
| 236 |
+
return {"factors": [], "top_cohorts": [], "failure_count": 0}
|
| 237 |
+
|
| 238 |
+
occl = sub["occlusion"].to_numpy() >= 1
|
| 239 |
+
night = sub["lighting"].to_numpy() == 1
|
| 240 |
+
far = sub["distance_band"].to_numpy() >= 2
|
| 241 |
+
sensor = sub["sensor_disagree"].to_numpy().astype(bool)
|
| 242 |
+
factor = np.full(total, 4, dtype=np.int8) # other
|
| 243 |
+
factor[sensor] = 3
|
| 244 |
+
factor[far] = 2
|
| 245 |
+
factor[night] = 1
|
| 246 |
+
factor[occl] = 0 # priority: occlusion > low light > range > sensor > other
|
| 247 |
+
counts = np.bincount(factor, minlength=5)
|
| 248 |
+
factors = [{"factor": _FACTOR_ORDER[i], "count": int(counts[i]),
|
| 249 |
+
"share": round(float(counts[i]) / total, 4)}
|
| 250 |
+
for i in range(5) if counts[i] > 0]
|
| 251 |
+
factors.sort(key=lambda x: -x["count"])
|
| 252 |
+
|
| 253 |
+
rows, _ = cube_mod.aggregate(cube_df, filters, ["class", "lighting", "weather"],
|
| 254 |
+
["n", metric, "fn", "fp"], 2000)
|
| 255 |
+
rows = [r for r in rows if r["n"] >= 150 and r.get(metric) is not None]
|
| 256 |
+
rows.sort(key=lambda r: r[metric])
|
| 257 |
+
top = [{"cohort": f"{r['class']}/{r['lighting']}/{r['weather']}",
|
| 258 |
+
"n": r["n"], metric: r[metric], "fn": r.get("fn"), "fp": r.get("fp")}
|
| 259 |
+
for r in rows[:8]]
|
| 260 |
+
|
| 261 |
+
return {"metric": metric, "filters": filters or {}, "failure_count": int(total),
|
| 262 |
+
"factors": factors, "top_cohorts": top,
|
| 263 |
+
"method": "primary-factor attribution over the error index "
|
| 264 |
+
"(priority: occlusion > low illumination > long range > "
|
| 265 |
+
"sensor disagreement > other)"}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ------------------------------------------------------------------ hybrid similarity
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def similar_containers(store: MegaStore, run: EvaluationRun, container_id: int,
|
| 272 |
+
filters: Optional[Dict] = None, k: int = 12) -> Dict:
|
| 273 |
+
art = store.artifacts(run.run_id)
|
| 274 |
+
ids, emb = art["emb_ids"], art["emb"]
|
| 275 |
+
containers = art["containers"].set_index("container_id")
|
| 276 |
+
pos = np.where(ids == container_id)[0]
|
| 277 |
+
if pos.size == 0:
|
| 278 |
+
raise KeyError(f"Unknown container {container_id}")
|
| 279 |
+
q = emb[pos[0]]
|
| 280 |
+
sims = emb @ q
|
| 281 |
+
|
| 282 |
+
mask = np.ones(ids.shape[0], dtype=bool)
|
| 283 |
+
for dim, values in (filters or {}).items():
|
| 284 |
+
if dim not in pop_mod.CONTAINER_DIMS:
|
| 285 |
+
continue
|
| 286 |
+
vocab = DIMENSIONS[dim]
|
| 287 |
+
codes = [vocab.index(v) for v in (values if isinstance(values, list) else [values])
|
| 288 |
+
if v in vocab]
|
| 289 |
+
col = containers.loc[ids, dim].to_numpy()
|
| 290 |
+
mask &= np.isin(col, codes)
|
| 291 |
+
mask[pos[0]] = False
|
| 292 |
+
sims = np.where(mask, sims, -np.inf)
|
| 293 |
+
|
| 294 |
+
kk = min(k, int(mask.sum()))
|
| 295 |
+
if kk <= 0:
|
| 296 |
+
return {"query_container": container_id, "results": []}
|
| 297 |
+
top = np.argpartition(-sims, kk - 1)[:kk]
|
| 298 |
+
top = top[np.argsort(-sims[top])]
|
| 299 |
+
results = []
|
| 300 |
+
for i in top.tolist():
|
| 301 |
+
cid = int(ids[i])
|
| 302 |
+
row = containers.loc[cid]
|
| 303 |
+
results.append({
|
| 304 |
+
"container_id": cid,
|
| 305 |
+
"similarity": round(float(sims[i]), 4),
|
| 306 |
+
**{dim: DIMENSIONS[dim][int(row[dim])] for dim in pop_mod.CONTAINER_DIMS},
|
| 307 |
+
"n_objects": int(row["n_objects"]),
|
| 308 |
+
"fn": int(row["fn"]), "fp": int(row["fp"]),
|
| 309 |
+
"anomalies": int(row["anomalies"]),
|
| 310 |
+
"risk_score": round(float(row["risk_score"]), 4),
|
| 311 |
+
})
|
| 312 |
+
return {"query_container": container_id,
|
| 313 |
+
"retrieval": "cosine over 32-d structural embeddings + structured filters",
|
| 314 |
+
"results": results}
|
sensorflow/megaeval/api.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI router for the aggregate-first mega-scale evaluation layer."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from fastapi import APIRouter, HTTPException
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
from sensorflow.megaeval import analysis, errors as errors_mod, population as pop_mod
|
| 12 |
+
from sensorflow.megaeval import sampling
|
| 13 |
+
from sensorflow.megaeval.cube import SKETCH_METRICS
|
| 14 |
+
from sensorflow.megaeval.population import DIMENSIONS
|
| 15 |
+
from sensorflow.megaeval.runs import CONTAINER_STATUS, EvaluationRun, get_mega_store
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _run_or_404(run_id: str) -> EvaluationRun:
|
| 21 |
+
run = get_mega_store().runs.get(run_id)
|
| 22 |
+
if run is None:
|
| 23 |
+
raise HTTPException(404, f"Unknown evaluation run {run_id}")
|
| 24 |
+
return run
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _published_or_409(run_id: str) -> EvaluationRun:
|
| 28 |
+
run = _run_or_404(run_id)
|
| 29 |
+
if run.status != "published":
|
| 30 |
+
raise HTTPException(409, f"Run {run_id} is {run.status}; results are available "
|
| 31 |
+
"once it is published")
|
| 32 |
+
return run
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ------------------------------------------------------------------ requests
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class GeneratePopulationRequest(BaseModel):
|
| 39 |
+
name: str = "mega-perception"
|
| 40 |
+
num_objects: int = Field(320_000, ge=1_000, le=1_200_000)
|
| 41 |
+
seed: int = 42
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class CreateRunRequest(BaseModel):
|
| 45 |
+
population_id: str
|
| 46 |
+
model_version: str = "model-v42"
|
| 47 |
+
overrides: Dict = Field(default_factory=dict)
|
| 48 |
+
worker_delay_s: float = Field(0.5, ge=0.0, le=5.0)
|
| 49 |
+
workers: int = Field(4, ge=1, le=8)
|
| 50 |
+
seed: Optional[int] = None
|
| 51 |
+
label_version: str = "labels-v1"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class EvaluationQueryRequest(BaseModel):
|
| 55 |
+
evaluation_id: str
|
| 56 |
+
filters: Dict[str, List[str]] = Field(default_factory=dict)
|
| 57 |
+
metrics: List[str] = Field(default_factory=list)
|
| 58 |
+
group_by: List[str] = Field(default_factory=list)
|
| 59 |
+
limit: int = Field(200, ge=1, le=2000)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ErrorSearchRequest(BaseModel):
|
| 63 |
+
run_id: str
|
| 64 |
+
error_types: List[str] = Field(default_factory=list)
|
| 65 |
+
filters: Dict[str, List[str]] = Field(default_factory=dict)
|
| 66 |
+
confidence_max: Optional[float] = None
|
| 67 |
+
confidence_min: Optional[float] = None
|
| 68 |
+
risk_min: Optional[float] = None
|
| 69 |
+
severity_min: Optional[float] = None
|
| 70 |
+
safety_only: bool = False
|
| 71 |
+
limit_containers: int = Field(25, ge=1, le=100)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class CompareRequest(BaseModel):
|
| 75 |
+
candidate_run_id: str
|
| 76 |
+
baseline_run_id: str
|
| 77 |
+
policy: Dict = Field(default_factory=dict)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class WhyRequest(BaseModel):
|
| 81 |
+
run_id: str
|
| 82 |
+
filters: Dict[str, List[str]] = Field(default_factory=dict)
|
| 83 |
+
metric: str = "recall"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class SimilarityRequest(BaseModel):
|
| 87 |
+
run_id: str
|
| 88 |
+
container_id: int
|
| 89 |
+
filters: Dict[str, List[str]] = Field(default_factory=dict)
|
| 90 |
+
k: int = Field(12, ge=1, le=50)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class ReviewPlanRequest(BaseModel):
|
| 94 |
+
target_n: Optional[int] = Field(None, ge=100, le=20_000)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ------------------------------------------------------------------ populations
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post("/megaeval/population/generate")
|
| 101 |
+
def generate_population(req: GeneratePopulationRequest):
|
| 102 |
+
meta = pop_mod.generate_population(name=req.name, num_objects=req.num_objects,
|
| 103 |
+
seed=req.seed)
|
| 104 |
+
return meta
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.get("/megaeval/populations")
|
| 108 |
+
def list_populations():
|
| 109 |
+
return {"populations": pop_mod.list_populations()}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.get("/megaeval/dimensions")
|
| 113 |
+
def dimensions():
|
| 114 |
+
return {"dimensions": DIMENSIONS}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------ runs (async jobs)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.post("/megaeval/runs")
|
| 121 |
+
def create_run(req: CreateRunRequest):
|
| 122 |
+
store = get_mega_store()
|
| 123 |
+
try:
|
| 124 |
+
run = store.create_run(population_id=req.population_id,
|
| 125 |
+
model_version=req.model_version,
|
| 126 |
+
overrides=req.overrides,
|
| 127 |
+
seed=req.seed,
|
| 128 |
+
worker_delay_s=req.worker_delay_s,
|
| 129 |
+
workers=req.workers,
|
| 130 |
+
label_version=req.label_version)
|
| 131 |
+
except KeyError as e:
|
| 132 |
+
raise HTTPException(404, str(e))
|
| 133 |
+
store.start_async(run)
|
| 134 |
+
return run.to_dict()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@router.get("/megaeval/runs")
|
| 138 |
+
def list_runs():
|
| 139 |
+
store = get_mega_store()
|
| 140 |
+
runs = sorted(store.runs.values(), key=lambda r: r.created_at, reverse=True)
|
| 141 |
+
return {"runs": [r.to_dict() for r in runs]}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@router.get("/megaeval/runs/{run_id}")
|
| 145 |
+
def get_run(run_id: str):
|
| 146 |
+
return _run_or_404(run_id).to_dict()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@router.get("/megaeval/status")
|
| 150 |
+
def megaeval_status():
|
| 151 |
+
store = get_mega_store()
|
| 152 |
+
active = [r.progress_dict() for r in store.runs.values()
|
| 153 |
+
if r.status in ("queued", "running", "reducing", "materializing")]
|
| 154 |
+
return {"active_runs": active, "cache": store.router.cache.stats(),
|
| 155 |
+
"total_runs": len(store.runs)}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ------------------------------------------------------------------ THE query API
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@router.post("/evaluations/query")
|
| 162 |
+
def evaluations_query(req: EvaluationQueryRequest):
|
| 163 |
+
"""Aggregate-first query endpoint. Routed: cache -> cube -> record scan."""
|
| 164 |
+
store = get_mega_store()
|
| 165 |
+
run = _published_or_409(req.evaluation_id)
|
| 166 |
+
art = store.artifacts(run.run_id)
|
| 167 |
+
|
| 168 |
+
def scan_fn(filters, group_by, metrics, limit):
|
| 169 |
+
return store.scan_records(run, filters, group_by, metrics, limit)
|
| 170 |
+
|
| 171 |
+
return store.router.query(run, art["cube"], art.get("sketches", {}),
|
| 172 |
+
req.filters, req.metrics, req.group_by,
|
| 173 |
+
req.limit, scan_fn=scan_fn)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.get("/megaeval/cache")
|
| 177 |
+
def cache_stats():
|
| 178 |
+
return get_mega_store().router.cache.stats()
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ------------------------------------------------------------------ quality funnel
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.get("/megaeval/runs/{run_id}/funnel")
|
| 185 |
+
def quality_funnel(run_id: str):
|
| 186 |
+
store = get_mega_store()
|
| 187 |
+
run = _published_or_409(run_id)
|
| 188 |
+
art = store.artifacts(run_id)
|
| 189 |
+
errs = art.get("errors")
|
| 190 |
+
review = art.get("review") or {}
|
| 191 |
+
n_err = int(len(errs)) if errs is not None else 0
|
| 192 |
+
n_high = int((errs["risk_score"] >= 0.6).sum()) if errs is not None and len(errs) else 0
|
| 193 |
+
results = (review.get("results") or {})
|
| 194 |
+
prec = results.get("precision") or {}
|
| 195 |
+
stages = [
|
| 196 |
+
{"stage": "Objects Evaluated", "count": int(run.objects_total)},
|
| 197 |
+
{"stage": "Containers", "count": int(run.headline.get("containers", 0))},
|
| 198 |
+
{"stage": "Potential Errors", "count": n_err},
|
| 199 |
+
{"stage": "High-Confidence Errors", "count": n_high},
|
| 200 |
+
{"stage": "Review Sample", "count": int((review.get("funnel") or {}).get("statistically_selected", 0))},
|
| 201 |
+
{"stage": "Human Verified", "count": int((review.get("funnel") or {}).get("reviewed", 0))},
|
| 202 |
+
]
|
| 203 |
+
base = stages[0]["count"] or 1
|
| 204 |
+
for s in stages:
|
| 205 |
+
s["pct_of_population"] = round(s["count"] / base * 100, 3)
|
| 206 |
+
return {"stages": stages,
|
| 207 |
+
"estimated_precision": ({"estimate": prec.get("estimate"),
|
| 208 |
+
"ci_low": prec.get("ci_low"),
|
| 209 |
+
"ci_high": prec.get("ci_high"),
|
| 210 |
+
"n_reviewed": prec.get("n_reviewed")}
|
| 211 |
+
if prec else None)}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ------------------------------------------------------------------ containers
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
_SORT_PRESETS = {
|
| 218 |
+
"worst_recall": ("recall", True),
|
| 219 |
+
"worst_precision": ("precision", True),
|
| 220 |
+
"worst_iou": ("mean_iou", True),
|
| 221 |
+
"most_anomalies": ("anomalies", False),
|
| 222 |
+
"least_verified": ("reviewed", True),
|
| 223 |
+
"highest_risk": ("risk_score", False),
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@router.get("/megaeval/runs/{run_id}/containers")
|
| 228 |
+
def containers(run_id: str, sort: str = "highest_risk", limit: int = 50, offset: int = 0):
|
| 229 |
+
store = get_mega_store()
|
| 230 |
+
_published_or_409(run_id)
|
| 231 |
+
art = store.artifacts(run_id)
|
| 232 |
+
df = art["containers"].copy()
|
| 233 |
+
tp, fp, fn = df["tp"], df["fp"], df["fn"]
|
| 234 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 235 |
+
df["recall"] = np.where((tp + fn) > 0, tp / (tp + fn), np.nan)
|
| 236 |
+
df["precision"] = np.where((tp + fp) > 0, tp / (tp + fp), np.nan)
|
| 237 |
+
df["mean_iou"] = np.where(tp > 0, df["sum_iou"] / tp, np.nan)
|
| 238 |
+
metric, ascending = _SORT_PRESETS.get(sort, _SORT_PRESETS["highest_risk"])
|
| 239 |
+
df = df.sort_values(metric, ascending=ascending, na_position="last")
|
| 240 |
+
total = len(df)
|
| 241 |
+
page = df.iloc[offset:offset + max(1, min(limit, 500))]
|
| 242 |
+
rows = []
|
| 243 |
+
for _, r in page.iterrows():
|
| 244 |
+
rows.append({
|
| 245 |
+
"container_id": int(r["container_id"]),
|
| 246 |
+
**{dim: DIMENSIONS[dim][int(r[dim])] for dim in pop_mod.CONTAINER_DIMS},
|
| 247 |
+
"n_objects": int(r["n_objects"]),
|
| 248 |
+
"tp": int(r["tp"]), "fp": int(r["fp"]), "fn": int(r["fn"]),
|
| 249 |
+
"anomalies": int(r["anomalies"]),
|
| 250 |
+
"reviewed": int(r["reviewed"]), "verified": int(r["verified"]),
|
| 251 |
+
"recall": None if np.isnan(r["recall"]) else round(float(r["recall"]), 4),
|
| 252 |
+
"precision": None if np.isnan(r["precision"]) else round(float(r["precision"]), 4),
|
| 253 |
+
"mean_iou": None if np.isnan(r["mean_iou"]) else round(float(r["mean_iou"]), 4),
|
| 254 |
+
"risk_score": round(float(r["risk_score"]), 4),
|
| 255 |
+
"status": CONTAINER_STATUS[int(r["status"])],
|
| 256 |
+
})
|
| 257 |
+
return {"total": total, "sort": sort, "rows": rows}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@router.get("/megaeval/runs/{run_id}/containers/{container_id}/objects")
|
| 261 |
+
def container_objects(run_id: str, container_id: int):
|
| 262 |
+
store = get_mega_store()
|
| 263 |
+
run = _published_or_409(run_id)
|
| 264 |
+
try:
|
| 265 |
+
rows = store.container_objects(run, container_id)
|
| 266 |
+
except FileNotFoundError:
|
| 267 |
+
raise HTTPException(404, f"No forensic data for container {container_id}")
|
| 268 |
+
if not rows:
|
| 269 |
+
raise HTTPException(404, f"Unknown container {container_id}")
|
| 270 |
+
return {"container_id": container_id, "objects": rows}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ------------------------------------------------------------------ error index
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@router.post("/megaeval/errors/search")
|
| 277 |
+
def error_search(req: ErrorSearchRequest):
|
| 278 |
+
store = get_mega_store()
|
| 279 |
+
_published_or_409(req.run_id)
|
| 280 |
+
art = store.artifacts(req.run_id)
|
| 281 |
+
return errors_mod.search_errors(
|
| 282 |
+
art.get("errors"), art.get("containers"),
|
| 283 |
+
error_types=req.error_types or None, filters=req.filters or None,
|
| 284 |
+
confidence_max=req.confidence_max, confidence_min=req.confidence_min,
|
| 285 |
+
risk_min=req.risk_min, severity_min=req.severity_min,
|
| 286 |
+
safety_only=req.safety_only, limit_containers=req.limit_containers)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ------------------------------------------------------------------ compare / shift / why / similarity
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@router.post("/megaeval/compare")
|
| 293 |
+
def compare(req: CompareRequest):
|
| 294 |
+
store = get_mega_store()
|
| 295 |
+
cand = _published_or_409(req.candidate_run_id)
|
| 296 |
+
base = _published_or_409(req.baseline_run_id)
|
| 297 |
+
return analysis.compare_runs(store, cand, base, req.policy or None)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
@router.get("/megaeval/runs/{run_id}/shift")
|
| 301 |
+
def shift(run_id: str):
|
| 302 |
+
return analysis.distribution_shift(get_mega_store(), _published_or_409(run_id))
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@router.post("/megaeval/why")
|
| 306 |
+
def why(req: WhyRequest):
|
| 307 |
+
return analysis.why(get_mega_store(), _published_or_409(req.run_id),
|
| 308 |
+
req.filters or None, req.metric)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@router.post("/megaeval/similarity")
|
| 312 |
+
def similarity(req: SimilarityRequest):
|
| 313 |
+
try:
|
| 314 |
+
return analysis.similar_containers(get_mega_store(), _published_or_409(req.run_id),
|
| 315 |
+
req.container_id, req.filters or None, req.k)
|
| 316 |
+
except KeyError as e:
|
| 317 |
+
raise HTTPException(404, str(e))
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ------------------------------------------------------------------ review sampling
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
@router.get("/megaeval/runs/{run_id}/review")
|
| 324 |
+
def review_state(run_id: str):
|
| 325 |
+
store = get_mega_store()
|
| 326 |
+
_published_or_409(run_id)
|
| 327 |
+
art = store.artifacts(run_id)
|
| 328 |
+
review = art.get("review")
|
| 329 |
+
if review is None:
|
| 330 |
+
return {"run_id": run_id, "planned": False, "executed": False}
|
| 331 |
+
slim = {k: v for k, v in review.items() if k != "plans"}
|
| 332 |
+
slim["planned"] = True
|
| 333 |
+
slim["strata"] = {name: plan["strata"] for name, plan in review["plans"].items()}
|
| 334 |
+
return slim
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@router.post("/megaeval/runs/{run_id}/review/plan")
|
| 338 |
+
def review_plan(run_id: str, req: ReviewPlanRequest):
|
| 339 |
+
store = get_mega_store()
|
| 340 |
+
run = _published_or_409(run_id)
|
| 341 |
+
review = sampling.build_review_plan(store, run, target_n=req.target_n)
|
| 342 |
+
slim = {k: v for k, v in review.items() if k != "plans"}
|
| 343 |
+
slim["planned"] = True
|
| 344 |
+
slim["strata"] = {name: plan["strata"] for name, plan in review["plans"].items()}
|
| 345 |
+
return slim
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@router.post("/megaeval/runs/{run_id}/review/execute")
|
| 349 |
+
def review_execute(run_id: str):
|
| 350 |
+
store = get_mega_store()
|
| 351 |
+
run = _published_or_409(run_id)
|
| 352 |
+
art = store.artifacts(run_id)
|
| 353 |
+
if art.get("review") is None:
|
| 354 |
+
sampling.build_review_plan(store, run)
|
| 355 |
+
review = sampling.execute_reviews(store, run)
|
| 356 |
+
slim = {k: v for k, v in review.items() if k != "plans"}
|
| 357 |
+
slim["planned"] = True
|
| 358 |
+
slim["strata"] = {name: plan["strata"] for name, plan in review["plans"].items()}
|
| 359 |
+
return slim
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# ------------------------------------------------------------------ distributions (sketches)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@router.get("/megaeval/runs/{run_id}/distributions")
|
| 366 |
+
def distributions(run_id: str):
|
| 367 |
+
store = get_mega_store()
|
| 368 |
+
run = _published_or_409(run_id)
|
| 369 |
+
art = store.artifacts(run_id)
|
| 370 |
+
sk = art.get("sketches", {})
|
| 371 |
+
out = {"run_id": run_id, "exact": False,
|
| 372 |
+
"note": "distributions are sketch-based (fixed-bin quantile histograms); "
|
| 373 |
+
"counts in the cube are exact"}
|
| 374 |
+
for name in ("confidence", "iou"):
|
| 375 |
+
hist = sk.get(name)
|
| 376 |
+
if hist is None:
|
| 377 |
+
continue
|
| 378 |
+
out[name] = {
|
| 379 |
+
"bins": hist.bins, "lo": hist.lo, "hi": hist.hi,
|
| 380 |
+
"counts": hist.counts.tolist(),
|
| 381 |
+
"percentiles": {q: round(hist.percentile(float(q)), 4)
|
| 382 |
+
for q in ("10", "25", "50", "75", "90", "99")},
|
| 383 |
+
}
|
| 384 |
+
out["containers_hll_estimate"] = sk.get("container_hll_estimate")
|
| 385 |
+
out["containers_exact"] = run.headline.get("containers")
|
| 386 |
+
out["available_sketch_metrics"] = SKETCH_METRICS
|
| 387 |
+
return out
|
sensorflow/megaeval/cube.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metric cube: partial sufficient statistics -> reduce -> aggregate queries.
|
| 2 |
+
|
| 3 |
+
The cube is the heart of aggregate-first evaluation. During a run, each worker
|
| 4 |
+
computes *partial* additive statistics per partition, grouped by the 9 cube
|
| 5 |
+
dimensions. Reducing = concatenating partials and summing per cell (map-side
|
| 6 |
+
combine + shuffle reduce in Spark terms). Queries NEVER scan raw records unless
|
| 7 |
+
they ask for something outside the cube (then the router honestly reports
|
| 8 |
+
source="scan").
|
| 9 |
+
|
| 10 |
+
Cell key: class x weather x lighting x road_type x scenario x sensor x
|
| 11 |
+
distance_band x speed_band x occlusion (per evaluation run)
|
| 12 |
+
Cell stats (all additive): n, tp, fp, fn, loc_err, anomalies, sum_iou,
|
| 13 |
+
sum_conf, sum_conf2, safety_n, safety_tp, reviewed, verified
|
| 14 |
+
Derived on demand: precision, recall, f1, mean_iou, anomaly_rate,
|
| 15 |
+
conf_mean, conf_std, safety_recall, review_coverage, verified_rate
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import hashlib
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
from typing import Dict, List, Optional, Tuple
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
|
| 30 |
+
from sensorflow.megaeval.population import DIMENSIONS, DIM_NAMES
|
| 31 |
+
|
| 32 |
+
STAT_COLS = ["n", "tp", "fp", "fn", "loc_err", "anomalies", "sum_iou",
|
| 33 |
+
"sum_conf", "sum_conf2", "safety_n", "safety_tp", "reviewed", "verified"]
|
| 34 |
+
|
| 35 |
+
DERIVED_METRICS = ["precision", "recall", "f1", "mean_iou", "anomaly_rate",
|
| 36 |
+
"conf_mean", "conf_std", "safety_recall", "review_coverage",
|
| 37 |
+
"verified_rate", "error_rate"]
|
| 38 |
+
COUNT_METRICS = ["n", "tp", "fp", "fn", "loc_err", "anomalies", "reviewed", "verified",
|
| 39 |
+
"safety_n", "safety_tp"]
|
| 40 |
+
SKETCH_METRICS = ["confidence_p50", "confidence_p10", "confidence_p90",
|
| 41 |
+
"iou_p50", "iou_p10", "iou_p90"]
|
| 42 |
+
ALL_METRICS = COUNT_METRICS + DERIVED_METRICS + SKETCH_METRICS
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ------------------------------------------------------------------ partial stats
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def partial_stats(rows: pd.DataFrame) -> pd.DataFrame:
|
| 49 |
+
"""Group one partition's contribution rows by the 9 dims and sum stat columns.
|
| 50 |
+
|
| 51 |
+
`rows` must contain DIM_NAMES code columns plus STAT_COLS contribution columns
|
| 52 |
+
(e.g. a TP object contributes n=1, tp=1, sum_iou=iou; an FP row contributes
|
| 53 |
+
n=0, fp=1, sum_conf=conf).
|
| 54 |
+
"""
|
| 55 |
+
return rows.groupby(DIM_NAMES, as_index=False, observed=True, sort=False)[STAT_COLS].sum()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def reduce_partials(partials: List[pd.DataFrame]) -> pd.DataFrame:
|
| 59 |
+
"""Reduce partial stats into the final cube (concat + groupby-sum)."""
|
| 60 |
+
if not partials:
|
| 61 |
+
return pd.DataFrame(columns=DIM_NAMES + STAT_COLS)
|
| 62 |
+
return (pd.concat(partials, ignore_index=True)
|
| 63 |
+
.groupby(DIM_NAMES, as_index=False, observed=True, sort=False)[STAT_COLS]
|
| 64 |
+
.sum())
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def derive_metrics(agg: pd.DataFrame) -> pd.DataFrame:
|
| 68 |
+
"""Attach derived ratio metrics to an aggregated (already summed) frame."""
|
| 69 |
+
out = agg.copy()
|
| 70 |
+
tp, fp, fn = out["tp"], out["fp"], out["fn"]
|
| 71 |
+
pp = tp + fp
|
| 72 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 73 |
+
out["precision"] = np.where(pp > 0, tp / pp, np.nan)
|
| 74 |
+
out["recall"] = np.where((tp + fn) > 0, tp / (tp + fn), np.nan)
|
| 75 |
+
p, r = out["precision"], out["recall"]
|
| 76 |
+
out["f1"] = np.where((p + r) > 0, 2 * p * r / (p + r), np.nan)
|
| 77 |
+
out["mean_iou"] = np.where(tp > 0, out["sum_iou"] / tp, np.nan)
|
| 78 |
+
out["anomaly_rate"] = np.where(out["n"] > 0, out["anomalies"] / out["n"], np.nan)
|
| 79 |
+
out["conf_mean"] = np.where(pp > 0, out["sum_conf"] / pp, np.nan)
|
| 80 |
+
var = np.where(pp > 1,
|
| 81 |
+
(out["sum_conf2"] - out["sum_conf"] ** 2 / pp.replace(0, 1)) / (pp - 1).replace(0, 1),
|
| 82 |
+
np.nan)
|
| 83 |
+
out["conf_std"] = np.sqrt(np.clip(var, 0, None))
|
| 84 |
+
out["safety_recall"] = np.where(out["safety_n"] > 0, out["safety_tp"] / out["safety_n"], np.nan)
|
| 85 |
+
out["review_coverage"] = np.where(out["n"] > 0, out["reviewed"] / out["n"], np.nan)
|
| 86 |
+
out["verified_rate"] = np.where(out["reviewed"] > 0, out["verified"] / out["reviewed"], np.nan)
|
| 87 |
+
out["error_rate"] = np.where(out["n"] > 0, (fn + fp + out["loc_err"]) / out["n"], np.nan)
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ------------------------------------------------------------------ persistence
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def save_cube(path: str, cube: pd.DataFrame) -> None:
|
| 95 |
+
np.savez_compressed(path, **{c: cube[c].to_numpy() for c in cube.columns})
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def load_cube(path: str) -> pd.DataFrame:
|
| 99 |
+
with np.load(path) as z:
|
| 100 |
+
return pd.DataFrame({k: z[k] for k in z.files})
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ------------------------------------------------------------------ query router
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class QueryCache:
|
| 107 |
+
"""Evaluation cache: key = hash(dataset+model+filters+group_by+metrics)."""
|
| 108 |
+
|
| 109 |
+
def __init__(self, max_entries: int = 512):
|
| 110 |
+
self._data: Dict[str, Dict] = {}
|
| 111 |
+
self._lock = threading.Lock()
|
| 112 |
+
self.max_entries = max_entries
|
| 113 |
+
self.hits = 0
|
| 114 |
+
self.misses = 0
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def key(dataset_version: str, model_version: str, filters: Dict,
|
| 118 |
+
group_by: List[str], metrics: List[str]) -> str:
|
| 119 |
+
blob = json.dumps({
|
| 120 |
+
"d": dataset_version, "m": model_version,
|
| 121 |
+
"f": {k: sorted(v) for k, v in sorted((filters or {}).items())},
|
| 122 |
+
"g": list(group_by or []), "x": sorted(metrics or []),
|
| 123 |
+
}, sort_keys=True)
|
| 124 |
+
return hashlib.sha256(blob.encode()).hexdigest()[:24]
|
| 125 |
+
|
| 126 |
+
def get(self, key: str) -> Optional[Dict]:
|
| 127 |
+
with self._lock:
|
| 128 |
+
hit = self._data.get(key)
|
| 129 |
+
if hit is not None:
|
| 130 |
+
self.hits += 1
|
| 131 |
+
else:
|
| 132 |
+
self.misses += 1
|
| 133 |
+
return hit
|
| 134 |
+
|
| 135 |
+
def put(self, key: str, value: Dict) -> None:
|
| 136 |
+
with self._lock:
|
| 137 |
+
if len(self._data) >= self.max_entries:
|
| 138 |
+
self._data.pop(next(iter(self._data)))
|
| 139 |
+
self._data[key] = value
|
| 140 |
+
|
| 141 |
+
def invalidate(self) -> None:
|
| 142 |
+
with self._lock:
|
| 143 |
+
self._data.clear()
|
| 144 |
+
|
| 145 |
+
def stats(self) -> Dict:
|
| 146 |
+
with self._lock:
|
| 147 |
+
total = self.hits + self.misses
|
| 148 |
+
return {"entries": len(self._data), "hits": self.hits, "misses": self.misses,
|
| 149 |
+
"hit_rate": round(self.hits / total, 4) if total else None}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _codes_for(dim: str, values: List[str]) -> List[int]:
|
| 153 |
+
vocab = DIMENSIONS[dim]
|
| 154 |
+
out = []
|
| 155 |
+
for v in values:
|
| 156 |
+
if v in vocab:
|
| 157 |
+
out.append(vocab.index(v))
|
| 158 |
+
return out
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def apply_filters(cube: pd.DataFrame, filters: Optional[Dict[str, List[str]]]) -> pd.DataFrame:
|
| 162 |
+
if not filters:
|
| 163 |
+
return cube
|
| 164 |
+
mask = np.ones(len(cube), dtype=bool)
|
| 165 |
+
for dim, values in filters.items():
|
| 166 |
+
if dim not in DIM_NAMES:
|
| 167 |
+
continue
|
| 168 |
+
codes = _codes_for(dim, values if isinstance(values, list) else [values])
|
| 169 |
+
mask &= cube[dim].isin(codes).to_numpy()
|
| 170 |
+
return cube[mask]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def aggregate(cube: pd.DataFrame, filters: Optional[Dict], group_by: Optional[List[str]],
|
| 174 |
+
metrics: Optional[List[str]], limit: int = 200) -> Tuple[List[Dict], int]:
|
| 175 |
+
"""Aggregate-lookup path: filter cells, group, sum, derive. Never touches records."""
|
| 176 |
+
sub = apply_filters(cube, filters)
|
| 177 |
+
group_by = [g for g in (group_by or []) if g in DIM_NAMES]
|
| 178 |
+
metrics = [m for m in (metrics or []) if m in ALL_METRICS and m not in SKETCH_METRICS] \
|
| 179 |
+
or ["n", "precision", "recall", "f1", "mean_iou", "anomaly_rate"]
|
| 180 |
+
|
| 181 |
+
if group_by:
|
| 182 |
+
agg = sub.groupby(group_by, as_index=False, observed=True, sort=False)[STAT_COLS].sum()
|
| 183 |
+
else:
|
| 184 |
+
sums = sub[STAT_COLS].sum()
|
| 185 |
+
agg = pd.DataFrame([sums])
|
| 186 |
+
agg = derive_metrics(agg)
|
| 187 |
+
|
| 188 |
+
cell_count = len(sub)
|
| 189 |
+
sort_metric = next((m for m in metrics if m in agg.columns), None)
|
| 190 |
+
if group_by and sort_metric:
|
| 191 |
+
agg = agg.sort_values("n", ascending=False)
|
| 192 |
+
agg = agg.head(max(1, min(limit, 2000)))
|
| 193 |
+
|
| 194 |
+
rows: List[Dict] = []
|
| 195 |
+
for _, r in agg.iterrows():
|
| 196 |
+
row: Dict = {}
|
| 197 |
+
for g in group_by:
|
| 198 |
+
row[g] = DIMENSIONS[g][int(r[g])]
|
| 199 |
+
row["n"] = int(r["n"])
|
| 200 |
+
for m in metrics:
|
| 201 |
+
if m == "n":
|
| 202 |
+
continue
|
| 203 |
+
v = r.get(m)
|
| 204 |
+
if v is None or (isinstance(v, float) and np.isnan(v)):
|
| 205 |
+
row[m] = None
|
| 206 |
+
elif m in COUNT_METRICS:
|
| 207 |
+
row[m] = int(v)
|
| 208 |
+
else:
|
| 209 |
+
row[m] = round(float(v), 6)
|
| 210 |
+
rows.append(row)
|
| 211 |
+
return rows, cell_count
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class QueryRouter:
|
| 215 |
+
"""Routes a query: cache -> cube aggregate -> (only if unavoidable) record scan.
|
| 216 |
+
|
| 217 |
+
This is the OLAP-engine seam: callers never know how data is stored.
|
| 218 |
+
"""
|
| 219 |
+
|
| 220 |
+
def __init__(self):
|
| 221 |
+
self.cache = QueryCache()
|
| 222 |
+
|
| 223 |
+
def query(self, run: "object", cube: pd.DataFrame, sketches: Dict,
|
| 224 |
+
filters: Optional[Dict], metrics: Optional[List[str]],
|
| 225 |
+
group_by: Optional[List[str]], limit: int = 200,
|
| 226 |
+
scan_fn=None) -> Dict:
|
| 227 |
+
t0 = time.perf_counter()
|
| 228 |
+
metrics = metrics or []
|
| 229 |
+
key = QueryCache.key(run.population_id, run.model_version,
|
| 230 |
+
filters or {}, group_by or [], metrics)
|
| 231 |
+
cached = self.cache.get(key)
|
| 232 |
+
if cached is not None:
|
| 233 |
+
return {**cached,
|
| 234 |
+
"meta": {**cached["meta"], "cache_hit": True, "source": "cache",
|
| 235 |
+
"latency_ms": round((time.perf_counter() - t0) * 1000, 3)}}
|
| 236 |
+
|
| 237 |
+
sketch_wanted = [m for m in metrics if m in SKETCH_METRICS]
|
| 238 |
+
needs_scan = any(g not in DIM_NAMES for g in (group_by or []))
|
| 239 |
+
|
| 240 |
+
if needs_scan and scan_fn is not None:
|
| 241 |
+
rows, cell_count = scan_fn(filters, group_by, metrics, limit)
|
| 242 |
+
source, exact = "scan", True
|
| 243 |
+
else:
|
| 244 |
+
rows, cell_count = aggregate(cube, filters, group_by, metrics, limit)
|
| 245 |
+
source, exact = "cube", True
|
| 246 |
+
if sketch_wanted and not group_by and not filters:
|
| 247 |
+
# population-level percentile questions answered from sketches
|
| 248 |
+
for m in sketch_wanted:
|
| 249 |
+
metric_name, q = m.rsplit("_p", 1)
|
| 250 |
+
hist = sketches.get(metric_name)
|
| 251 |
+
if hist is not None and rows:
|
| 252 |
+
rows[0][m] = round(float(hist.percentile(float(q))), 6)
|
| 253 |
+
exact = False # percentiles are approximate
|
| 254 |
+
elif sketch_wanted:
|
| 255 |
+
exact = True # sketch metrics ignored on grouped/filtered queries
|
| 256 |
+
|
| 257 |
+
latency = round((time.perf_counter() - t0) * 1000, 3)
|
| 258 |
+
result = {
|
| 259 |
+
"rows": rows,
|
| 260 |
+
"meta": {
|
| 261 |
+
"source": source,
|
| 262 |
+
"cache_hit": False,
|
| 263 |
+
"latency_ms": latency,
|
| 264 |
+
"cells_touched": cell_count,
|
| 265 |
+
"exact": exact,
|
| 266 |
+
"approximate_fields": sketch_wanted if not exact else [],
|
| 267 |
+
},
|
| 268 |
+
}
|
| 269 |
+
self.cache.put(key, result)
|
| 270 |
+
return result
|
sensorflow/megaeval/errors.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Error index: fast multi-criteria search over evaluation errors.
|
| 2 |
+
|
| 3 |
+
The index is a columnar table (one row per error) built during the run:
|
| 4 |
+
error_id, object_id, container_id, error_type, severity, confidence,
|
| 5 |
+
risk_score, safety_critical, sensor_disagree, + the 9 cube dimensions.
|
| 6 |
+
|
| 7 |
+
Searches return the *worst-N containers* (aggregated), never raw record dumps —
|
| 8 |
+
this backs the Investigation UI. Individual exemplar errors are capped.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from sensorflow.megaeval.population import DIMENSIONS, DIM_NAMES
|
| 19 |
+
from sensorflow.megaeval.runs import ERROR_TYPES
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def search_errors(
|
| 23 |
+
errors: pd.DataFrame,
|
| 24 |
+
containers: pd.DataFrame,
|
| 25 |
+
error_types: Optional[List[str]] = None,
|
| 26 |
+
filters: Optional[Dict[str, List[str]]] = None,
|
| 27 |
+
confidence_max: Optional[float] = None,
|
| 28 |
+
confidence_min: Optional[float] = None,
|
| 29 |
+
risk_min: Optional[float] = None,
|
| 30 |
+
severity_min: Optional[float] = None,
|
| 31 |
+
safety_only: bool = False,
|
| 32 |
+
limit_containers: int = 25,
|
| 33 |
+
limit_examples: int = 50,
|
| 34 |
+
) -> Dict:
|
| 35 |
+
if errors is None or not len(errors):
|
| 36 |
+
return {"matched_errors": 0, "worst_containers": [], "examples": [],
|
| 37 |
+
"by_type": {}}
|
| 38 |
+
mask = np.ones(len(errors), dtype=bool)
|
| 39 |
+
if error_types:
|
| 40 |
+
codes = [ERROR_TYPES.index(t) for t in error_types if t in ERROR_TYPES]
|
| 41 |
+
mask &= errors["error_type"].isin(codes).to_numpy()
|
| 42 |
+
for dim, values in (filters or {}).items():
|
| 43 |
+
if dim not in DIM_NAMES:
|
| 44 |
+
continue
|
| 45 |
+
vocab = DIMENSIONS[dim]
|
| 46 |
+
codes = [vocab.index(v) for v in (values if isinstance(values, list) else [values])
|
| 47 |
+
if v in vocab]
|
| 48 |
+
mask &= errors[dim].isin(codes).to_numpy()
|
| 49 |
+
if confidence_max is not None:
|
| 50 |
+
mask &= errors["confidence"].to_numpy() <= confidence_max
|
| 51 |
+
if confidence_min is not None:
|
| 52 |
+
mask &= errors["confidence"].to_numpy() >= confidence_min
|
| 53 |
+
if risk_min is not None:
|
| 54 |
+
mask &= errors["risk_score"].to_numpy() >= risk_min
|
| 55 |
+
if severity_min is not None:
|
| 56 |
+
mask &= errors["severity"].to_numpy() >= severity_min
|
| 57 |
+
if safety_only:
|
| 58 |
+
mask &= errors["safety_critical"].to_numpy().astype(bool)
|
| 59 |
+
|
| 60 |
+
sub = errors[mask]
|
| 61 |
+
by_type = {ERROR_TYPES[int(c)]: int(n)
|
| 62 |
+
for c, n in sub["error_type"].value_counts().items()}
|
| 63 |
+
|
| 64 |
+
# aggregate to worst containers: rank by count x mean risk
|
| 65 |
+
grp = sub.groupby("container_id", as_index=False).agg(
|
| 66 |
+
error_count=("error_id", "size"), mean_risk=("risk_score", "mean"),
|
| 67 |
+
max_severity=("severity", "max"), safety_hits=("safety_critical", "sum"))
|
| 68 |
+
grp["rank_score"] = grp["error_count"] * grp["mean_risk"]
|
| 69 |
+
grp = grp.sort_values("rank_score", ascending=False).head(limit_containers)
|
| 70 |
+
cmeta = containers.set_index("container_id") if containers is not None else None
|
| 71 |
+
worst = []
|
| 72 |
+
for _, r in grp.iterrows():
|
| 73 |
+
cid = int(r["container_id"])
|
| 74 |
+
item = {"container_id": cid, "error_count": int(r["error_count"]),
|
| 75 |
+
"mean_risk": round(float(r["mean_risk"]), 4),
|
| 76 |
+
"max_severity": round(float(r["max_severity"]), 4),
|
| 77 |
+
"safety_hits": int(r["safety_hits"])}
|
| 78 |
+
if cmeta is not None and cid in cmeta.index:
|
| 79 |
+
row = cmeta.loc[cid]
|
| 80 |
+
for dim in ("weather", "lighting", "road_type", "scenario"):
|
| 81 |
+
item[dim] = DIMENSIONS[dim][int(row[dim])]
|
| 82 |
+
item["n_objects"] = int(row["n_objects"])
|
| 83 |
+
item["risk_score"] = round(float(row["risk_score"]), 4)
|
| 84 |
+
worst.append(item)
|
| 85 |
+
|
| 86 |
+
ex = sub.sort_values("risk_score", ascending=False).head(limit_examples)
|
| 87 |
+
examples = []
|
| 88 |
+
for _, r in ex.iterrows():
|
| 89 |
+
examples.append({
|
| 90 |
+
"error_id": int(r["error_id"]),
|
| 91 |
+
"annotation_id": (f"obj-{int(r['object_id'])}" if r["object_id"] >= 0
|
| 92 |
+
else f"fp-{int(r['error_id'])}"),
|
| 93 |
+
"container_id": int(r["container_id"]),
|
| 94 |
+
"error_type": ERROR_TYPES[int(r["error_type"])],
|
| 95 |
+
"class": DIMENSIONS["class"][int(r["class"])],
|
| 96 |
+
"severity": round(float(r["severity"]), 4),
|
| 97 |
+
"confidence": round(float(r["confidence"]), 4),
|
| 98 |
+
"risk_score": round(float(r["risk_score"]), 4),
|
| 99 |
+
"safety_critical": bool(r["safety_critical"]),
|
| 100 |
+
"scenario": DIMENSIONS["scenario"][int(r["scenario"])],
|
| 101 |
+
"lighting": DIMENSIONS["lighting"][int(r["lighting"])],
|
| 102 |
+
"weather": DIMENSIONS["weather"][int(r["weather"])],
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
return {"matched_errors": int(len(sub)), "by_type": by_type,
|
| 106 |
+
"worst_containers": worst, "examples": examples}
|
sensorflow/megaeval/population.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Immutable synthetic populations, generated vectorized and stored partitioned.
|
| 2 |
+
|
| 3 |
+
Storage layout (single-node stand-in for an Iceberg/parquet lakehouse — no parquet
|
| 4 |
+
engine is installed in this venv, so partitions are compressed npz column files):
|
| 5 |
+
|
| 6 |
+
runs/megaeval/populations/{pop_id}/
|
| 7 |
+
meta.json dataset card: dims vocab, counts (exact), train mix, lineage
|
| 8 |
+
part-0000.npz ... column arrays partitioned by container_id % NUM_PARTITIONS
|
| 9 |
+
|
| 10 |
+
Every object row carries:
|
| 11 |
+
- 9 low-cardinality dimension codes (the cube dimensions)
|
| 12 |
+
- a model-independent `difficulty` in [0,1] (occlusion/distance/lighting/weather/size)
|
| 13 |
+
- `safety_critical` flag (VRUs in high-risk scenarios)
|
| 14 |
+
|
| 15 |
+
The "training mix" is a deliberately shifted joint distribution over
|
| 16 |
+
(class, weather, lighting) used by shift detection: training saw less
|
| 17 |
+
night/rain/fog than this evaluation population.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import threading
|
| 25 |
+
import uuid
|
| 26 |
+
from datetime import datetime, timezone
|
| 27 |
+
from typing import Dict, List, Optional, Tuple
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
MEGA_ROOT = os.path.join("runs", "megaeval")
|
| 32 |
+
NUM_PARTITIONS = 32
|
| 33 |
+
|
| 34 |
+
DIMENSIONS: Dict[str, List[str]] = {
|
| 35 |
+
"class": ["vehicle", "pedestrian", "cyclist", "motorcycle", "truck", "bus"],
|
| 36 |
+
"weather": ["clear", "rain", "fog", "snow"],
|
| 37 |
+
"lighting": ["day", "night", "dusk"],
|
| 38 |
+
"road_type": ["highway", "urban", "rural", "intersection"],
|
| 39 |
+
"scenario": ["nominal", "near_miss", "occlusion_heavy", "night_glare",
|
| 40 |
+
"extreme_ttc", "sensor_degraded"],
|
| 41 |
+
"sensor": ["lidar", "camera", "fused"],
|
| 42 |
+
"distance_band": ["0-20m", "20-50m", "50-100m", "100m+"],
|
| 43 |
+
"speed_band": ["stationary", "slow", "medium", "fast"],
|
| 44 |
+
"occlusion": ["none", "partial", "heavy"],
|
| 45 |
+
}
|
| 46 |
+
DIM_NAMES = list(DIMENSIONS.keys())
|
| 47 |
+
CONTAINER_DIMS = ["weather", "lighting", "road_type", "scenario"]
|
| 48 |
+
OBJECT_DIMS = ["class", "sensor", "distance_band", "speed_band", "occlusion"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _now() -> str:
|
| 52 |
+
return datetime.now(timezone.utc).isoformat()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def pop_dir(pop_id: str) -> str:
|
| 56 |
+
return os.path.join(MEGA_ROOT, "populations", pop_id)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def set_mega_root(path: str) -> None:
|
| 60 |
+
"""Test hook: relocate all megaeval storage (and clear partition caches)."""
|
| 61 |
+
global MEGA_ROOT
|
| 62 |
+
MEGA_ROOT = str(path)
|
| 63 |
+
with _CACHE_LOCK:
|
| 64 |
+
_PART_CACHE.clear()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ------------------------------------------------------------------ generation
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _sample_container_dims(rng: np.random.Generator, n: int) -> Dict[str, np.ndarray]:
|
| 71 |
+
"""Correlated container-level context (weather/lighting/road/scenario)."""
|
| 72 |
+
weather = rng.choice(4, size=n, p=[0.62, 0.20, 0.11, 0.07]).astype(np.int8)
|
| 73 |
+
lighting = rng.choice(3, size=n, p=[0.60, 0.28, 0.12]).astype(np.int8)
|
| 74 |
+
road = rng.choice(4, size=n, p=[0.30, 0.38, 0.14, 0.18]).astype(np.int8)
|
| 75 |
+
scenario = rng.choice(6, size=n, p=[0.66, 0.09, 0.09, 0.06, 0.05, 0.05]).astype(np.int8)
|
| 76 |
+
# correlations: night_glare scenario forces night; fog/snow raise sensor_degraded odds
|
| 77 |
+
scenario_names = DIMENSIONS["scenario"]
|
| 78 |
+
lighting[scenario == scenario_names.index("night_glare")] = 1 # night
|
| 79 |
+
bad_wx = (weather >= 2)
|
| 80 |
+
flip = bad_wx & (rng.random(n) < 0.18)
|
| 81 |
+
scenario[flip] = scenario_names.index("sensor_degraded")
|
| 82 |
+
return {"weather": weather, "lighting": lighting, "road_type": road, "scenario": scenario}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
_CLASS_MIX_BY_ROAD = np.array([
|
| 86 |
+
# vehicle, pedestrian, cyclist, motorcycle, truck, bus
|
| 87 |
+
[0.62, 0.02, 0.01, 0.05, 0.24, 0.06], # highway
|
| 88 |
+
[0.44, 0.26, 0.12, 0.06, 0.07, 0.05], # urban
|
| 89 |
+
[0.55, 0.08, 0.06, 0.08, 0.18, 0.05], # rural
|
| 90 |
+
[0.40, 0.30, 0.14, 0.05, 0.06, 0.05], # intersection
|
| 91 |
+
])
|
| 92 |
+
|
| 93 |
+
_SPEED_MIX_BY_ROAD = np.array([
|
| 94 |
+
[0.02, 0.08, 0.35, 0.55], # highway
|
| 95 |
+
[0.22, 0.44, 0.28, 0.06], # urban
|
| 96 |
+
[0.10, 0.30, 0.40, 0.20], # rural
|
| 97 |
+
[0.30, 0.45, 0.20, 0.05], # intersection
|
| 98 |
+
])
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _object_difficulty(codes: Dict[str, np.ndarray], rng: np.random.Generator) -> np.ndarray:
|
| 102 |
+
"""Model-independent difficulty in [0,1] — the physical hardness of each object."""
|
| 103 |
+
n = codes["class"].shape[0]
|
| 104 |
+
d = np.full(n, 0.12, dtype=np.float32)
|
| 105 |
+
d += np.array([0.00, 0.14, 0.12, 0.10, 0.02, 0.02], dtype=np.float32)[codes["class"]]
|
| 106 |
+
d += np.array([0.00, 0.08, 0.12, 0.10], dtype=np.float32)[codes["weather"]]
|
| 107 |
+
d += np.array([0.00, 0.12, 0.05], dtype=np.float32)[codes["lighting"]]
|
| 108 |
+
d += np.array([0.00, 0.10, 0.24], dtype=np.float32)[codes["occlusion"]]
|
| 109 |
+
d += np.array([0.00, 0.05, 0.14, 0.26], dtype=np.float32)[codes["distance_band"]]
|
| 110 |
+
d += np.array([0.00, 0.04, 0.08, 0.06, 0.07, 0.12], dtype=np.float32)[codes["scenario"]]
|
| 111 |
+
d += rng.normal(0.0, 0.05, size=n).astype(np.float32)
|
| 112 |
+
return np.clip(d, 0.01, 0.99)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def generate_population(
|
| 116 |
+
name: str = "mega-perception",
|
| 117 |
+
num_objects: int = 320_000,
|
| 118 |
+
seed: int = 42,
|
| 119 |
+
avg_objects_per_container: float = 12.0,
|
| 120 |
+
) -> Dict:
|
| 121 |
+
"""Generate + persist a partitioned population. Vectorized; ~seconds for 320k."""
|
| 122 |
+
rng = np.random.default_rng(seed)
|
| 123 |
+
num_containers = max(64, int(num_objects / avg_objects_per_container))
|
| 124 |
+
|
| 125 |
+
cdims = _sample_container_dims(rng, num_containers)
|
| 126 |
+
per_container = np.clip(rng.poisson(avg_objects_per_container, size=num_containers),
|
| 127 |
+
3, 48).astype(np.int64)
|
| 128 |
+
total = int(per_container.sum())
|
| 129 |
+
container_id = np.repeat(np.arange(num_containers, dtype=np.int64), per_container)
|
| 130 |
+
|
| 131 |
+
codes: Dict[str, np.ndarray] = {k: cdims[k][container_id] for k in CONTAINER_DIMS}
|
| 132 |
+
|
| 133 |
+
road_obj = codes["road_type"].astype(np.int64)
|
| 134 |
+
u = rng.random(total)
|
| 135 |
+
cls_cdf = np.cumsum(_CLASS_MIX_BY_ROAD, axis=1)[road_obj]
|
| 136 |
+
codes["class"] = (u[:, None] > cls_cdf).sum(axis=1).astype(np.int8)
|
| 137 |
+
u2 = rng.random(total)
|
| 138 |
+
spd_cdf = np.cumsum(_SPEED_MIX_BY_ROAD, axis=1)[road_obj]
|
| 139 |
+
codes["speed_band"] = (u2[:, None] > spd_cdf).sum(axis=1).astype(np.int8)
|
| 140 |
+
codes["sensor"] = rng.choice(3, size=total, p=[0.38, 0.22, 0.40]).astype(np.int8)
|
| 141 |
+
codes["distance_band"] = rng.choice(4, size=total, p=[0.24, 0.40, 0.25, 0.11]).astype(np.int8)
|
| 142 |
+
|
| 143 |
+
occ_p = np.tile(np.array([0.62, 0.28, 0.10]), (total, 1))
|
| 144 |
+
heavy_scene = codes["scenario"] == DIMENSIONS["scenario"].index("occlusion_heavy")
|
| 145 |
+
occ_p[heavy_scene] = [0.20, 0.42, 0.38]
|
| 146 |
+
occ_cdf = np.cumsum(occ_p, axis=1)
|
| 147 |
+
codes["occlusion"] = (rng.random(total)[:, None] > occ_cdf).sum(axis=1).astype(np.int8)
|
| 148 |
+
|
| 149 |
+
difficulty = _object_difficulty(codes, rng)
|
| 150 |
+
vru = np.isin(codes["class"], [1, 2, 3])
|
| 151 |
+
risky_scene = np.isin(codes["scenario"], [1, 4]) # near_miss, extreme_ttc
|
| 152 |
+
near = codes["distance_band"] <= 1
|
| 153 |
+
safety_critical = (vru & (risky_scene | (near & (rng.random(total) < 0.15)))).astype(np.uint8)
|
| 154 |
+
|
| 155 |
+
object_id = np.arange(total, dtype=np.int64)
|
| 156 |
+
pop_id = f"pop-{uuid.uuid4().hex[:10]}"
|
| 157 |
+
d = pop_dir(pop_id)
|
| 158 |
+
os.makedirs(d, exist_ok=True)
|
| 159 |
+
|
| 160 |
+
part_of = (container_id % NUM_PARTITIONS).astype(np.int64)
|
| 161 |
+
order = np.argsort(part_of, kind="stable")
|
| 162 |
+
part_sorted = part_of[order]
|
| 163 |
+
bounds = np.searchsorted(part_sorted, np.arange(NUM_PARTITIONS + 1))
|
| 164 |
+
partition_meta = []
|
| 165 |
+
for p in range(NUM_PARTITIONS):
|
| 166 |
+
sl = order[bounds[p]:bounds[p + 1]]
|
| 167 |
+
cols = {k: codes[k][sl] for k in DIM_NAMES}
|
| 168 |
+
cols["object_id"] = object_id[sl]
|
| 169 |
+
cols["container_id"] = container_id[sl]
|
| 170 |
+
cols["difficulty"] = difficulty[sl]
|
| 171 |
+
cols["safety_critical"] = safety_critical[sl]
|
| 172 |
+
np.savez_compressed(os.path.join(d, f"part-{p:04d}.npz"), **cols)
|
| 173 |
+
partition_meta.append({"partition": p, "rows": int(sl.size)})
|
| 174 |
+
|
| 175 |
+
# exact per-dimension counts (dataset card)
|
| 176 |
+
dim_counts = {
|
| 177 |
+
k: {DIMENSIONS[k][i]: int(c) for i, c in enumerate(np.bincount(codes[k], minlength=len(DIMENSIONS[k])))}
|
| 178 |
+
for k in DIM_NAMES
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
train_mix = _training_mix(codes, rng)
|
| 182 |
+
|
| 183 |
+
meta = {
|
| 184 |
+
"population_id": pop_id,
|
| 185 |
+
"name": name,
|
| 186 |
+
"created_at": _now(),
|
| 187 |
+
"seed": seed,
|
| 188 |
+
"num_objects": total,
|
| 189 |
+
"num_containers": num_containers,
|
| 190 |
+
"num_partitions": NUM_PARTITIONS,
|
| 191 |
+
"avg_objects_per_container": round(total / num_containers, 2),
|
| 192 |
+
"dimensions": DIMENSIONS,
|
| 193 |
+
"dim_counts": dim_counts,
|
| 194 |
+
"partitions": partition_meta,
|
| 195 |
+
"safety_critical_count": int(safety_critical.sum()),
|
| 196 |
+
"train_mix": train_mix,
|
| 197 |
+
}
|
| 198 |
+
with open(os.path.join(d, "meta.json"), "w") as f:
|
| 199 |
+
json.dump(meta, f)
|
| 200 |
+
return meta
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _training_mix(codes: Dict[str, np.ndarray], rng: np.random.Generator) -> List[Dict]:
|
| 204 |
+
"""Joint (class, weather, lighting) proportions the *training* set had.
|
| 205 |
+
|
| 206 |
+
Derived from the eval mix with deliberate, deterministic under-representation of
|
| 207 |
+
hard conditions (night, rain/fog/snow, VRU at night) so shift detection has
|
| 208 |
+
honest signal: shift = eval share vs train share of the same cohort.
|
| 209 |
+
"""
|
| 210 |
+
n = codes["class"].shape[0]
|
| 211 |
+
key = (codes["class"].astype(np.int64) * 100
|
| 212 |
+
+ codes["weather"].astype(np.int64) * 10
|
| 213 |
+
+ codes["lighting"].astype(np.int64))
|
| 214 |
+
uniq, counts = np.unique(key, return_counts=True)
|
| 215 |
+
eval_share = counts / n
|
| 216 |
+
|
| 217 |
+
cls, wx, lt = uniq // 100, (uniq // 10) % 10, uniq % 10
|
| 218 |
+
factor = np.ones(uniq.shape)
|
| 219 |
+
factor[lt == 1] *= 0.55 # trained on less night
|
| 220 |
+
factor[wx >= 1] *= 0.70 # less rain/fog/snow
|
| 221 |
+
factor[(np.isin(cls, [1, 2])) & (lt == 1)] *= 0.75 # far fewer night VRUs
|
| 222 |
+
train_share = eval_share * factor
|
| 223 |
+
train_share = train_share / train_share.sum()
|
| 224 |
+
|
| 225 |
+
out = []
|
| 226 |
+
for i in range(uniq.size):
|
| 227 |
+
out.append({
|
| 228 |
+
"class": DIMENSIONS["class"][int(cls[i])],
|
| 229 |
+
"weather": DIMENSIONS["weather"][int(wx[i])],
|
| 230 |
+
"lighting": DIMENSIONS["lighting"][int(lt[i])],
|
| 231 |
+
"train_share": float(train_share[i]),
|
| 232 |
+
"eval_share": float(eval_share[i]),
|
| 233 |
+
"eval_count": int(counts[i]),
|
| 234 |
+
})
|
| 235 |
+
return out
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ------------------------------------------------------------------ loading
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
_PART_CACHE: Dict[Tuple[str, int], Dict[str, np.ndarray]] = {}
|
| 242 |
+
_CACHE_LOCK = threading.Lock()
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def load_meta(pop_id: str) -> Optional[Dict]:
|
| 246 |
+
path = os.path.join(pop_dir(pop_id), "meta.json")
|
| 247 |
+
if not os.path.exists(path):
|
| 248 |
+
return None
|
| 249 |
+
with open(path) as f:
|
| 250 |
+
return json.load(f)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def list_populations() -> List[Dict]:
|
| 254 |
+
base = os.path.join(MEGA_ROOT, "populations")
|
| 255 |
+
if not os.path.isdir(base):
|
| 256 |
+
return []
|
| 257 |
+
metas = []
|
| 258 |
+
for pid in sorted(os.listdir(base)):
|
| 259 |
+
m = load_meta(pid)
|
| 260 |
+
if m:
|
| 261 |
+
metas.append(m)
|
| 262 |
+
return metas
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def load_partition(pop_id: str, partition: int) -> Dict[str, np.ndarray]:
|
| 266 |
+
key = (pop_id, partition)
|
| 267 |
+
with _CACHE_LOCK:
|
| 268 |
+
if key in _PART_CACHE:
|
| 269 |
+
return _PART_CACHE[key]
|
| 270 |
+
path = os.path.join(pop_dir(pop_id), f"part-{partition:04d}.npz")
|
| 271 |
+
with np.load(path) as z:
|
| 272 |
+
cols = {k: z[k] for k in z.files}
|
| 273 |
+
with _CACHE_LOCK:
|
| 274 |
+
if len(_PART_CACHE) > 96: # bounded partition cache
|
| 275 |
+
_PART_CACHE.clear()
|
| 276 |
+
_PART_CACHE[key] = cols
|
| 277 |
+
return cols
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def partition_of_container(container_id: int) -> int:
|
| 281 |
+
return int(container_id) % NUM_PARTITIONS
|
sensorflow/megaeval/runs.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation runs: first-class async jobs producing cubes, error indexes and sketches.
|
| 2 |
+
|
| 3 |
+
Lifecycle: create -> queued -> running (workers over partitions, partial stats)
|
| 4 |
+
-> reducing -> materializing -> published (or failed)
|
| 5 |
+
|
| 6 |
+
Each worker processes one population partition (Spark-executor stand-in):
|
| 7 |
+
1. vectorized model simulation (deterministic given lineage seed)
|
| 8 |
+
2. partial sufficient statistics grouped by the 9 cube dims
|
| 9 |
+
3. per-object outputs persisted for forensic drill-down / honest scan fallback
|
| 10 |
+
4. error rows + sketch updates + container partial aggregates
|
| 11 |
+
|
| 12 |
+
Reduce = concat partials + groupby-sum (cube.reduce_partials).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import platform
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
import uuid
|
| 24 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 25 |
+
from datetime import datetime, timezone
|
| 26 |
+
from typing import Dict, List, Optional
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pandas as pd
|
| 30 |
+
|
| 31 |
+
from sensorflow.megaeval import cube as cube_mod
|
| 32 |
+
from sensorflow.megaeval import population as pop_mod
|
| 33 |
+
from sensorflow.megaeval.population import DIMENSIONS, DIM_NAMES
|
| 34 |
+
from sensorflow.megaeval.sketches import HyperLogLog, QuantileHistogram, Reservoir
|
| 35 |
+
|
| 36 |
+
ERROR_TYPES = ["FN", "FP", "LOCALIZATION", "ANOMALY", "LOW_CONF"]
|
| 37 |
+
CONTAINER_STATUS = ["ok", "warn", "critical"]
|
| 38 |
+
IOU_MATCH_THRESHOLD = 0.5
|
| 39 |
+
EVALUATOR_CODE_VERSION = "megaeval-1.0.0"
|
| 40 |
+
METRIC_VERSION = "metrics-1.0"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _now() -> str:
|
| 44 |
+
return datetime.now(timezone.utc).isoformat()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run_dir(run_id: str) -> str:
|
| 48 |
+
return os.path.join(pop_mod.MEGA_ROOT, "runs", run_id)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------------ model profiles
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def model_params(model_version: str, overrides: Optional[Dict] = None) -> Dict:
|
| 55 |
+
"""Deterministic model skill profile derived from the version string.
|
| 56 |
+
|
| 57 |
+
Overrides let tests/demos inject regressions (e.g. worse night detection).
|
| 58 |
+
"""
|
| 59 |
+
h = int(hashlib.sha256(model_version.encode()).hexdigest()[:8], 16)
|
| 60 |
+
r = np.random.default_rng(h)
|
| 61 |
+
params = {
|
| 62 |
+
"base_detect": 0.972 + float(r.uniform(-0.01, 0.015)),
|
| 63 |
+
"difficulty_weight": 0.18,
|
| 64 |
+
"night_penalty": 0.030 + float(r.uniform(0, 0.02)),
|
| 65 |
+
"rain_penalty": 0.020 + float(r.uniform(0, 0.015)),
|
| 66 |
+
"occlusion_penalty": 0.050,
|
| 67 |
+
"vru_penalty": 0.020 + float(r.uniform(0, 0.015)),
|
| 68 |
+
"far_penalty": 0.045,
|
| 69 |
+
"iou_base": 0.875 + float(r.uniform(-0.01, 0.015)),
|
| 70 |
+
"iou_difficulty": 0.28,
|
| 71 |
+
"fp_per_container": 0.30 + float(r.uniform(0, 0.08)),
|
| 72 |
+
"anomaly_base": 0.018,
|
| 73 |
+
"low_conf_threshold": 0.45,
|
| 74 |
+
}
|
| 75 |
+
params.update(overrides or {})
|
| 76 |
+
return params
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ------------------------------------------------------------------ run entity
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class EvaluationRun:
|
| 83 |
+
def __init__(self, population_id: str, model_version: str,
|
| 84 |
+
overrides: Optional[Dict] = None, seed: Optional[int] = None,
|
| 85 |
+
worker_delay_s: float = 0.0, workers: int = 4,
|
| 86 |
+
label_version: str = "labels-v1",
|
| 87 |
+
threshold_config: Optional[Dict] = None,
|
| 88 |
+
sampling_config: Optional[Dict] = None):
|
| 89 |
+
self.run_id = f"eval-{uuid.uuid4().hex[:10]}"
|
| 90 |
+
self.population_id = population_id
|
| 91 |
+
self.model_version = model_version
|
| 92 |
+
self.overrides = overrides or {}
|
| 93 |
+
self.seed = seed if seed is not None else int(
|
| 94 |
+
hashlib.sha256(f"{population_id}|{model_version}|{label_version}".encode()
|
| 95 |
+
).hexdigest()[:8], 16)
|
| 96 |
+
self.worker_delay_s = worker_delay_s
|
| 97 |
+
self.workers = workers
|
| 98 |
+
self.status = "created"
|
| 99 |
+
self.error: Optional[str] = None
|
| 100 |
+
self.created_at = _now()
|
| 101 |
+
self.started_at: Optional[str] = None
|
| 102 |
+
self.published_at: Optional[str] = None
|
| 103 |
+
self.partitions_total = 0
|
| 104 |
+
self.partitions_done = 0
|
| 105 |
+
self.objects_total = 0
|
| 106 |
+
self.objects_processed = 0
|
| 107 |
+
self.throughput_objs_per_s = 0.0
|
| 108 |
+
self.eta_s: Optional[float] = None
|
| 109 |
+
self.headline: Dict = {}
|
| 110 |
+
self.per_class: Dict = {}
|
| 111 |
+
self.lineage = {
|
| 112 |
+
"evaluation_id": self.run_id,
|
| 113 |
+
"dataset_version": population_id,
|
| 114 |
+
"model_version": model_version,
|
| 115 |
+
"model_checkpoint": f"ckpt-{hashlib.sha256(model_version.encode()).hexdigest()[:10]}",
|
| 116 |
+
"label_version": label_version,
|
| 117 |
+
"evaluator_code_version": EVALUATOR_CODE_VERSION,
|
| 118 |
+
"metric_version": METRIC_VERSION,
|
| 119 |
+
"threshold_config": {"iou_match": IOU_MATCH_THRESHOLD,
|
| 120 |
+
**(threshold_config or {})},
|
| 121 |
+
"sampling_config": sampling_config or {"method": "stratified_risk_weighted",
|
| 122 |
+
"target_n": 1500},
|
| 123 |
+
"seed": self.seed,
|
| 124 |
+
"hardware": f"{platform.machine()}/{platform.system()} single-node",
|
| 125 |
+
"timestamp": self.created_at,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
def progress_dict(self) -> Dict:
|
| 129 |
+
pct = (self.partitions_done / self.partitions_total * 100) if self.partitions_total else 0.0
|
| 130 |
+
return {
|
| 131 |
+
"run_id": self.run_id,
|
| 132 |
+
"population_id": self.population_id,
|
| 133 |
+
"model_version": self.model_version,
|
| 134 |
+
"status": self.status,
|
| 135 |
+
"percent": round(pct, 2),
|
| 136 |
+
"partitions_done": self.partitions_done,
|
| 137 |
+
"partitions_total": self.partitions_total,
|
| 138 |
+
"objects_processed": int(self.objects_processed),
|
| 139 |
+
"objects_total": int(self.objects_total),
|
| 140 |
+
"workers": self.workers,
|
| 141 |
+
"throughput_objs_per_s": round(self.throughput_objs_per_s, 1),
|
| 142 |
+
"eta_s": round(self.eta_s, 1) if self.eta_s is not None else None,
|
| 143 |
+
"error": self.error,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
def to_dict(self) -> Dict:
|
| 147 |
+
return {
|
| 148 |
+
**self.progress_dict(),
|
| 149 |
+
"created_at": self.created_at,
|
| 150 |
+
"started_at": self.started_at,
|
| 151 |
+
"published_at": self.published_at,
|
| 152 |
+
"lineage": self.lineage,
|
| 153 |
+
"headline": self.headline,
|
| 154 |
+
"per_class": self.per_class,
|
| 155 |
+
"overrides": self.overrides,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ------------------------------------------------------------------ partition evaluation
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def evaluate_partition(pop_id: str, partition: int, params: Dict, seed: int) -> Dict:
|
| 163 |
+
"""Simulate the model on one partition; return partials + per-object outputs.
|
| 164 |
+
|
| 165 |
+
Fully vectorized and deterministic for (lineage seed, partition).
|
| 166 |
+
"""
|
| 167 |
+
cols = pop_mod.load_partition(pop_id, partition)
|
| 168 |
+
n = cols["object_id"].shape[0]
|
| 169 |
+
rng = np.random.default_rng((seed * 1_000_003 + partition) % (2**63))
|
| 170 |
+
|
| 171 |
+
difficulty = cols["difficulty"].astype(np.float64)
|
| 172 |
+
p_det = params["base_detect"] - difficulty * params["difficulty_weight"]
|
| 173 |
+
p_det -= params["night_penalty"] * (cols["lighting"] == 1)
|
| 174 |
+
p_det -= params["rain_penalty"] * (cols["weather"] >= 1)
|
| 175 |
+
p_det -= params["occlusion_penalty"] * (cols["occlusion"] == 2)
|
| 176 |
+
p_det -= params["vru_penalty"] * np.isin(cols["class"], [1, 2, 3])
|
| 177 |
+
p_det -= params["far_penalty"] * (cols["distance_band"] == 3)
|
| 178 |
+
p_det = np.clip(p_det, 0.02, 0.995)
|
| 179 |
+
detected = rng.random(n) < p_det
|
| 180 |
+
|
| 181 |
+
iou = np.zeros(n, dtype=np.float64)
|
| 182 |
+
iou[detected] = np.clip(
|
| 183 |
+
rng.normal(params["iou_base"] - difficulty[detected] * params["iou_difficulty"], 0.07),
|
| 184 |
+
0.02, 0.985)
|
| 185 |
+
tp = detected & (iou >= IOU_MATCH_THRESHOLD)
|
| 186 |
+
loc = detected & ~tp
|
| 187 |
+
|
| 188 |
+
conf = np.zeros(n, dtype=np.float64)
|
| 189 |
+
conf[detected] = np.clip(
|
| 190 |
+
0.55 * iou[detected] + 0.25 * (1 - difficulty[detected])
|
| 191 |
+
+ rng.normal(0.05, 0.08, size=int(detected.sum())), 0.01, 0.999)
|
| 192 |
+
|
| 193 |
+
anomaly_p = params["anomaly_base"] * (1 + 2.0 * difficulty
|
| 194 |
+
+ 0.8 * (cols["scenario"] >= 4))
|
| 195 |
+
anomaly = rng.random(n) < anomaly_p
|
| 196 |
+
sensor_disagree = rng.random(n) < (0.04 + 0.25 * (cols["scenario"] == 5))
|
| 197 |
+
|
| 198 |
+
safety = cols["safety_critical"].astype(bool)
|
| 199 |
+
|
| 200 |
+
# ---- spurious FPs per container
|
| 201 |
+
uniq_containers, first_idx = np.unique(cols["container_id"], return_index=True)
|
| 202 |
+
ncont = uniq_containers.size
|
| 203 |
+
wx_c = cols["weather"][first_idx]
|
| 204 |
+
lt_c = cols["lighting"][first_idx]
|
| 205 |
+
lam = params["fp_per_container"] * (1 + 0.5 * (wx_c >= 1) + 0.6 * (lt_c == 1))
|
| 206 |
+
fp_counts = rng.poisson(lam)
|
| 207 |
+
total_fp = int(fp_counts.sum())
|
| 208 |
+
fp_container = np.repeat(uniq_containers, fp_counts)
|
| 209 |
+
fp_src = np.repeat(first_idx, fp_counts)
|
| 210 |
+
fp_cols = {k: cols[k][fp_src] for k in pop_mod.CONTAINER_DIMS}
|
| 211 |
+
fp_cols["class"] = rng.choice(6, size=total_fp,
|
| 212 |
+
p=[0.42, 0.18, 0.10, 0.08, 0.14, 0.08]).astype(np.int8)
|
| 213 |
+
fp_cols["sensor"] = rng.choice(3, size=total_fp).astype(np.int8)
|
| 214 |
+
fp_cols["distance_band"] = rng.choice(4, size=total_fp, p=[0.15, 0.35, 0.30, 0.20]).astype(np.int8)
|
| 215 |
+
fp_cols["speed_band"] = rng.choice(4, size=total_fp).astype(np.int8)
|
| 216 |
+
fp_cols["occlusion"] = rng.choice(3, size=total_fp, p=[0.5, 0.3, 0.2]).astype(np.int8)
|
| 217 |
+
fp_conf = np.clip(rng.beta(2.0, 4.5, size=total_fp) + 0.05, 0.01, 0.99)
|
| 218 |
+
|
| 219 |
+
# ---- contribution rows -> partial stats
|
| 220 |
+
gt = pd.DataFrame({k: cols[k] for k in DIM_NAMES})
|
| 221 |
+
gt["n"] = 1
|
| 222 |
+
gt["tp"] = tp.astype(np.int64)
|
| 223 |
+
gt["fp"] = loc.astype(np.int64) # badly localized box counts as FP too
|
| 224 |
+
gt["fn"] = (~tp).astype(np.int64)
|
| 225 |
+
gt["loc_err"] = loc.astype(np.int64)
|
| 226 |
+
gt["anomalies"] = anomaly.astype(np.int64)
|
| 227 |
+
gt["sum_iou"] = np.where(tp, iou, 0.0)
|
| 228 |
+
gt["sum_conf"] = np.where(detected, conf, 0.0)
|
| 229 |
+
gt["sum_conf2"] = np.where(detected, conf**2, 0.0)
|
| 230 |
+
gt["safety_n"] = safety.astype(np.int64)
|
| 231 |
+
gt["safety_tp"] = (safety & tp).astype(np.int64)
|
| 232 |
+
gt["reviewed"] = 0
|
| 233 |
+
gt["verified"] = 0
|
| 234 |
+
|
| 235 |
+
fpf = pd.DataFrame({k: fp_cols[k] for k in DIM_NAMES})
|
| 236 |
+
fpf["n"] = 0
|
| 237 |
+
fpf["tp"] = 0
|
| 238 |
+
fpf["fp"] = 1
|
| 239 |
+
fpf["fn"] = 0
|
| 240 |
+
fpf["loc_err"] = 0
|
| 241 |
+
fpf["anomalies"] = 0
|
| 242 |
+
fpf["sum_iou"] = 0.0
|
| 243 |
+
fpf["sum_conf"] = fp_conf
|
| 244 |
+
fpf["sum_conf2"] = fp_conf**2
|
| 245 |
+
fpf["safety_n"] = 0
|
| 246 |
+
fpf["safety_tp"] = 0
|
| 247 |
+
fpf["reviewed"] = 0
|
| 248 |
+
fpf["verified"] = 0
|
| 249 |
+
|
| 250 |
+
partial = cube_mod.partial_stats(pd.concat([gt, fpf], ignore_index=True))
|
| 251 |
+
|
| 252 |
+
# ---- error rows (error index)
|
| 253 |
+
err_frames = []
|
| 254 |
+
fn_mask = ~detected
|
| 255 |
+
for etype, mask, sev_base in (("FN", fn_mask, 0.75), ("LOCALIZATION", loc, 0.55)):
|
| 256 |
+
idx = np.where(mask)[0]
|
| 257 |
+
if idx.size == 0:
|
| 258 |
+
continue
|
| 259 |
+
code = ERROR_TYPES.index(etype)
|
| 260 |
+
err_frames.append(pd.DataFrame({
|
| 261 |
+
"object_id": cols["object_id"][idx],
|
| 262 |
+
"container_id": cols["container_id"][idx],
|
| 263 |
+
"error_type": np.full(idx.size, code, dtype=np.int8),
|
| 264 |
+
"severity": np.clip(sev_base + 0.25 * safety[idx] + 0.15 * difficulty[idx], 0, 1),
|
| 265 |
+
"confidence": conf[idx],
|
| 266 |
+
"safety_critical": safety[idx].astype(np.uint8),
|
| 267 |
+
"sensor_disagree": sensor_disagree[idx].astype(np.uint8),
|
| 268 |
+
**{k: cols[k][idx] for k in DIM_NAMES},
|
| 269 |
+
}))
|
| 270 |
+
an_idx = np.where(anomaly & tp)[0]
|
| 271 |
+
if an_idx.size:
|
| 272 |
+
err_frames.append(pd.DataFrame({
|
| 273 |
+
"object_id": cols["object_id"][an_idx],
|
| 274 |
+
"container_id": cols["container_id"][an_idx],
|
| 275 |
+
"error_type": np.full(an_idx.size, ERROR_TYPES.index("ANOMALY"), dtype=np.int8),
|
| 276 |
+
"severity": np.clip(0.4 + 0.3 * difficulty[an_idx], 0, 1),
|
| 277 |
+
"confidence": conf[an_idx],
|
| 278 |
+
"safety_critical": safety[an_idx].astype(np.uint8),
|
| 279 |
+
"sensor_disagree": sensor_disagree[an_idx].astype(np.uint8),
|
| 280 |
+
**{k: cols[k][an_idx] for k in DIM_NAMES},
|
| 281 |
+
}))
|
| 282 |
+
lc_idx = np.where(tp & (conf < params["low_conf_threshold"]))[0]
|
| 283 |
+
if lc_idx.size:
|
| 284 |
+
err_frames.append(pd.DataFrame({
|
| 285 |
+
"object_id": cols["object_id"][lc_idx],
|
| 286 |
+
"container_id": cols["container_id"][lc_idx],
|
| 287 |
+
"error_type": np.full(lc_idx.size, ERROR_TYPES.index("LOW_CONF"), dtype=np.int8),
|
| 288 |
+
"severity": np.clip(0.3 + 0.2 * difficulty[lc_idx], 0, 1),
|
| 289 |
+
"confidence": conf[lc_idx],
|
| 290 |
+
"safety_critical": safety[lc_idx].astype(np.uint8),
|
| 291 |
+
"sensor_disagree": sensor_disagree[lc_idx].astype(np.uint8),
|
| 292 |
+
**{k: cols[k][lc_idx] for k in DIM_NAMES},
|
| 293 |
+
}))
|
| 294 |
+
if total_fp:
|
| 295 |
+
err_frames.append(pd.DataFrame({
|
| 296 |
+
"object_id": np.full(total_fp, -1, dtype=np.int64),
|
| 297 |
+
"container_id": fp_container,
|
| 298 |
+
"error_type": np.full(total_fp, ERROR_TYPES.index("FP"), dtype=np.int8),
|
| 299 |
+
"severity": np.clip(0.45 + 0.4 * fp_conf, 0, 1),
|
| 300 |
+
"confidence": fp_conf,
|
| 301 |
+
"safety_critical": np.zeros(total_fp, dtype=np.uint8),
|
| 302 |
+
"sensor_disagree": (rng.random(total_fp) < 0.08).astype(np.uint8),
|
| 303 |
+
**{k: fp_cols[k] for k in DIM_NAMES},
|
| 304 |
+
}))
|
| 305 |
+
errors = (pd.concat(err_frames, ignore_index=True) if err_frames
|
| 306 |
+
else pd.DataFrame())
|
| 307 |
+
|
| 308 |
+
# ---- container partial aggregates
|
| 309 |
+
cdf = pd.DataFrame({
|
| 310 |
+
"container_id": cols["container_id"],
|
| 311 |
+
**{k: cols[k] for k in pop_mod.CONTAINER_DIMS},
|
| 312 |
+
"n_objects": 1,
|
| 313 |
+
"tp": tp.astype(np.int64),
|
| 314 |
+
"fp": loc.astype(np.int64),
|
| 315 |
+
"fn": (~tp).astype(np.int64),
|
| 316 |
+
"anomalies": anomaly.astype(np.int64),
|
| 317 |
+
"sum_iou": np.where(tp, iou, 0.0),
|
| 318 |
+
"safety_n": safety.astype(np.int64),
|
| 319 |
+
})
|
| 320 |
+
containers = cdf.groupby(["container_id"] + pop_mod.CONTAINER_DIMS,
|
| 321 |
+
as_index=False, observed=True).sum()
|
| 322 |
+
if total_fp:
|
| 323 |
+
fp_per = pd.Series(fp_container).value_counts()
|
| 324 |
+
containers["fp"] = containers["fp"] + containers["container_id"].map(fp_per).fillna(0).astype(np.int64)
|
| 325 |
+
|
| 326 |
+
# ---- sketches (per partition, merged at reduce)
|
| 327 |
+
conf_hist = QuantileHistogram(0, 1, 64)
|
| 328 |
+
conf_hist.add(np.concatenate([conf[detected], fp_conf]))
|
| 329 |
+
iou_hist = QuantileHistogram(0, 1, 64)
|
| 330 |
+
iou_hist.add(iou[tp])
|
| 331 |
+
hll = HyperLogLog(12)
|
| 332 |
+
hll.add_ids(uniq_containers)
|
| 333 |
+
reservoir = Reservoir(k=100, seed=partition)
|
| 334 |
+
if len(errors):
|
| 335 |
+
reservoir.add_ids(errors["object_id"].to_numpy()[errors["object_id"].to_numpy() >= 0][:2000])
|
| 336 |
+
|
| 337 |
+
# ---- per-object outputs (forensic view + scan fallback + brute-force tests)
|
| 338 |
+
obj_out = {
|
| 339 |
+
"object_id": cols["object_id"],
|
| 340 |
+
"container_id": cols["container_id"],
|
| 341 |
+
"detected": detected.astype(np.uint8),
|
| 342 |
+
"tp": tp.astype(np.uint8),
|
| 343 |
+
"iou": iou.astype(np.float32),
|
| 344 |
+
"confidence": conf.astype(np.float32),
|
| 345 |
+
"anomaly": anomaly.astype(np.uint8),
|
| 346 |
+
"sensor_disagree": sensor_disagree.astype(np.uint8),
|
| 347 |
+
}
|
| 348 |
+
fp_out = {
|
| 349 |
+
"container_id": fp_container,
|
| 350 |
+
"confidence": fp_conf.astype(np.float32),
|
| 351 |
+
**{k: fp_cols[k] for k in DIM_NAMES},
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
return {"partial": partial, "errors": errors, "containers": containers,
|
| 355 |
+
"conf_hist": conf_hist, "iou_hist": iou_hist, "hll": hll,
|
| 356 |
+
"reservoir": reservoir, "objects": obj_out, "fps": fp_out,
|
| 357 |
+
"rows": n}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# ------------------------------------------------------------------ store
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
class MegaStore:
|
| 364 |
+
"""Registry of populations + runs, with lazily-loaded run artifacts."""
|
| 365 |
+
|
| 366 |
+
def __init__(self):
|
| 367 |
+
self.lock = threading.RLock()
|
| 368 |
+
self.runs: Dict[str, EvaluationRun] = {}
|
| 369 |
+
self._artifacts: Dict[str, Dict] = {}
|
| 370 |
+
self.router = cube_mod.QueryRouter()
|
| 371 |
+
self._load_persisted_runs()
|
| 372 |
+
|
| 373 |
+
def _load_persisted_runs(self) -> None:
|
| 374 |
+
base = os.path.join(pop_mod.MEGA_ROOT, "runs")
|
| 375 |
+
if not os.path.isdir(base):
|
| 376 |
+
return
|
| 377 |
+
for rid in sorted(os.listdir(base)):
|
| 378 |
+
meta_path = os.path.join(base, rid, "run.json")
|
| 379 |
+
if not os.path.exists(meta_path):
|
| 380 |
+
continue
|
| 381 |
+
try:
|
| 382 |
+
with open(meta_path) as f:
|
| 383 |
+
d = json.load(f)
|
| 384 |
+
run = EvaluationRun.__new__(EvaluationRun)
|
| 385 |
+
run.__dict__.update(d["state"])
|
| 386 |
+
self.runs[run.run_id] = run
|
| 387 |
+
except Exception:
|
| 388 |
+
continue
|
| 389 |
+
|
| 390 |
+
def save_run(self, run: EvaluationRun) -> None:
|
| 391 |
+
d = run_dir(run.run_id)
|
| 392 |
+
os.makedirs(d, exist_ok=True)
|
| 393 |
+
state = {k: v for k, v in run.__dict__.items() if not k.startswith("_")}
|
| 394 |
+
with open(os.path.join(d, "run.json"), "w") as f:
|
| 395 |
+
json.dump({"state": state}, f)
|
| 396 |
+
|
| 397 |
+
# ---- artifacts
|
| 398 |
+
|
| 399 |
+
def artifacts(self, run_id: str) -> Dict:
|
| 400 |
+
with self.lock:
|
| 401 |
+
if run_id in self._artifacts:
|
| 402 |
+
return self._artifacts[run_id]
|
| 403 |
+
d = run_dir(run_id)
|
| 404 |
+
art: Dict = {}
|
| 405 |
+
cube_path = os.path.join(d, "cube.npz")
|
| 406 |
+
if os.path.exists(cube_path):
|
| 407 |
+
art["cube"] = cube_mod.load_cube(cube_path)
|
| 408 |
+
err_path = os.path.join(d, "errors.npz")
|
| 409 |
+
if os.path.exists(err_path):
|
| 410 |
+
with np.load(err_path) as z:
|
| 411 |
+
art["errors"] = pd.DataFrame({k: z[k] for k in z.files})
|
| 412 |
+
cont_path = os.path.join(d, "containers.npz")
|
| 413 |
+
if os.path.exists(cont_path):
|
| 414 |
+
with np.load(cont_path) as z:
|
| 415 |
+
art["containers"] = pd.DataFrame({k: z[k] for k in z.files})
|
| 416 |
+
emb_path = os.path.join(d, "embeddings.npz")
|
| 417 |
+
if os.path.exists(emb_path):
|
| 418 |
+
with np.load(emb_path) as z:
|
| 419 |
+
art["emb_ids"] = z["container_id"]
|
| 420 |
+
art["emb"] = z["emb"]
|
| 421 |
+
sk_path = os.path.join(d, "sketches.npz")
|
| 422 |
+
if os.path.exists(sk_path):
|
| 423 |
+
with np.load(sk_path) as z:
|
| 424 |
+
art["sketches"] = {
|
| 425 |
+
"confidence": QuantileHistogram.from_arrays(0, 1, z["conf_hist"]),
|
| 426 |
+
"iou": QuantileHistogram.from_arrays(0, 1, z["iou_hist"]),
|
| 427 |
+
"container_hll_estimate": float(z["hll_estimate"]),
|
| 428 |
+
"exemplar_ids": z["exemplars"].tolist(),
|
| 429 |
+
}
|
| 430 |
+
rev_path = os.path.join(d, "review.json")
|
| 431 |
+
if os.path.exists(rev_path):
|
| 432 |
+
with open(rev_path) as f:
|
| 433 |
+
art["review"] = json.load(f)
|
| 434 |
+
with self.lock:
|
| 435 |
+
if len(self._artifacts) > 6:
|
| 436 |
+
self._artifacts.pop(next(iter(self._artifacts)))
|
| 437 |
+
self._artifacts[run_id] = art
|
| 438 |
+
return art
|
| 439 |
+
|
| 440 |
+
def invalidate_artifacts(self, run_id: str) -> None:
|
| 441 |
+
with self.lock:
|
| 442 |
+
self._artifacts.pop(run_id, None)
|
| 443 |
+
self.router.cache.invalidate()
|
| 444 |
+
|
| 445 |
+
# ---- lifecycle
|
| 446 |
+
|
| 447 |
+
def create_run(self, **kwargs) -> EvaluationRun:
|
| 448 |
+
run = EvaluationRun(**kwargs)
|
| 449 |
+
meta = pop_mod.load_meta(run.population_id)
|
| 450 |
+
if meta is None:
|
| 451 |
+
raise KeyError(f"Unknown population {run.population_id}")
|
| 452 |
+
run.partitions_total = meta["num_partitions"]
|
| 453 |
+
run.objects_total = meta["num_objects"]
|
| 454 |
+
run.status = "queued"
|
| 455 |
+
with self.lock:
|
| 456 |
+
self.runs[run.run_id] = run
|
| 457 |
+
self.save_run(run)
|
| 458 |
+
return run
|
| 459 |
+
|
| 460 |
+
def start_async(self, run: EvaluationRun) -> None:
|
| 461 |
+
t = threading.Thread(target=self._execute, args=(run,), daemon=True)
|
| 462 |
+
t.start()
|
| 463 |
+
|
| 464 |
+
def execute_sync(self, run: EvaluationRun) -> None:
|
| 465 |
+
self._execute(run)
|
| 466 |
+
|
| 467 |
+
def _execute(self, run: EvaluationRun) -> None:
|
| 468 |
+
try:
|
| 469 |
+
run.status = "running"
|
| 470 |
+
run.started_at = _now()
|
| 471 |
+
t_start = time.perf_counter()
|
| 472 |
+
params = model_params(run.model_version, run.overrides)
|
| 473 |
+
d = run_dir(run.run_id)
|
| 474 |
+
os.makedirs(d, exist_ok=True)
|
| 475 |
+
|
| 476 |
+
partials: List[pd.DataFrame] = []
|
| 477 |
+
error_frames: List[pd.DataFrame] = []
|
| 478 |
+
container_frames: List[pd.DataFrame] = []
|
| 479 |
+
conf_hist = QuantileHistogram(0, 1, 64)
|
| 480 |
+
iou_hist = QuantileHistogram(0, 1, 64)
|
| 481 |
+
hll = HyperLogLog(12)
|
| 482 |
+
reservoir = Reservoir(k=200, seed=run.seed)
|
| 483 |
+
agg_lock = threading.Lock()
|
| 484 |
+
|
| 485 |
+
def work(p: int) -> None:
|
| 486 |
+
res = evaluate_partition(run.population_id, p, params, run.seed)
|
| 487 |
+
if run.worker_delay_s:
|
| 488 |
+
time.sleep(run.worker_delay_s) # simulated distributed I/O latency
|
| 489 |
+
# uncompressed npz: these are hot-path worker writes (few MB total)
|
| 490 |
+
np.savez(os.path.join(d, f"objects-part-{p:04d}.npz"), **res["objects"])
|
| 491 |
+
np.savez(os.path.join(d, f"fp-part-{p:04d}.npz"), **res["fps"])
|
| 492 |
+
nonlocal conf_hist, iou_hist, hll, reservoir
|
| 493 |
+
with agg_lock:
|
| 494 |
+
partials.append(res["partial"])
|
| 495 |
+
if len(res["errors"]):
|
| 496 |
+
error_frames.append(res["errors"])
|
| 497 |
+
container_frames.append(res["containers"])
|
| 498 |
+
conf_hist = conf_hist.merge(res["conf_hist"])
|
| 499 |
+
iou_hist = iou_hist.merge(res["iou_hist"])
|
| 500 |
+
hll = hll.merge(res["hll"])
|
| 501 |
+
reservoir = reservoir.merge(res["reservoir"])
|
| 502 |
+
run.partitions_done += 1
|
| 503 |
+
run.objects_processed += res["rows"]
|
| 504 |
+
elapsed = time.perf_counter() - t_start
|
| 505 |
+
run.throughput_objs_per_s = run.objects_processed / max(elapsed, 1e-6)
|
| 506 |
+
remaining = run.objects_total - run.objects_processed
|
| 507 |
+
run.eta_s = remaining / max(run.throughput_objs_per_s, 1e-6)
|
| 508 |
+
|
| 509 |
+
with ThreadPoolExecutor(max_workers=run.workers) as ex:
|
| 510 |
+
list(ex.map(work, range(run.partitions_total)))
|
| 511 |
+
|
| 512 |
+
run.status = "reducing"
|
| 513 |
+
cube_df = cube_mod.reduce_partials(partials)
|
| 514 |
+
|
| 515 |
+
run.status = "materializing"
|
| 516 |
+
cube_mod.save_cube(os.path.join(d, "cube.npz"), cube_df)
|
| 517 |
+
|
| 518 |
+
errors = (pd.concat(error_frames, ignore_index=True)
|
| 519 |
+
if error_frames else pd.DataFrame())
|
| 520 |
+
if len(errors):
|
| 521 |
+
errors = errors.reset_index(drop=True)
|
| 522 |
+
errors["error_id"] = np.arange(len(errors), dtype=np.int64)
|
| 523 |
+
risk = (errors["severity"] * 0.5
|
| 524 |
+
+ errors["safety_critical"] * 0.3
|
| 525 |
+
+ (1 - errors["confidence"]) * 0.15
|
| 526 |
+
+ errors["sensor_disagree"] * 0.05)
|
| 527 |
+
errors["risk_score"] = np.clip(risk, 0, 1)
|
| 528 |
+
np.savez_compressed(os.path.join(d, "errors.npz"),
|
| 529 |
+
**{c: errors[c].to_numpy() for c in errors.columns})
|
| 530 |
+
|
| 531 |
+
containers = (pd.concat(container_frames, ignore_index=True)
|
| 532 |
+
.groupby(["container_id"] + pop_mod.CONTAINER_DIMS,
|
| 533 |
+
as_index=False, observed=True).sum())
|
| 534 |
+
containers["reviewed"] = 0
|
| 535 |
+
containers["verified"] = 0
|
| 536 |
+
err_share = (containers["fn"] + containers["fp"]) / containers["n_objects"].clip(lower=1)
|
| 537 |
+
containers["risk_score"] = np.clip(
|
| 538 |
+
0.55 * err_share + 0.25 * (containers["anomalies"] / containers["n_objects"].clip(lower=1))
|
| 539 |
+
+ 0.20 * (containers["safety_n"] > 0), 0, 1).round(4)
|
| 540 |
+
containers["status"] = np.select(
|
| 541 |
+
[containers["risk_score"] >= 0.45, containers["risk_score"] >= 0.2],
|
| 542 |
+
[2, 1], default=0).astype(np.int8)
|
| 543 |
+
np.savez_compressed(os.path.join(d, "containers.npz"),
|
| 544 |
+
**{c: containers[c].to_numpy() for c in containers.columns})
|
| 545 |
+
|
| 546 |
+
self._build_embeddings(d, containers)
|
| 547 |
+
|
| 548 |
+
np.savez_compressed(os.path.join(d, "sketches.npz"),
|
| 549 |
+
conf_hist=conf_hist.counts, iou_hist=iou_hist.counts,
|
| 550 |
+
hll_estimate=np.array(hll.estimate()),
|
| 551 |
+
exemplars=np.array(reservoir.items, dtype=np.int64))
|
| 552 |
+
|
| 553 |
+
headline_rows, _ = cube_mod.aggregate(
|
| 554 |
+
cube_df, None, None,
|
| 555 |
+
["n", "tp", "fp", "fn", "loc_err", "anomalies", "precision", "recall", "f1",
|
| 556 |
+
"mean_iou", "anomaly_rate", "safety_recall", "conf_mean", "error_rate"], 1)
|
| 557 |
+
run.headline = headline_rows[0]
|
| 558 |
+
run.headline["containers"] = int(containers.shape[0])
|
| 559 |
+
run.headline["containers_hll_estimate"] = round(hll.estimate(), 1)
|
| 560 |
+
|
| 561 |
+
per_class_rows, _ = cube_mod.aggregate(
|
| 562 |
+
cube_df, None, ["class"],
|
| 563 |
+
["n", "precision", "recall", "f1", "mean_iou", "safety_recall", "anomaly_rate"], 50)
|
| 564 |
+
run.per_class = {r["class"]: r for r in per_class_rows}
|
| 565 |
+
|
| 566 |
+
run.status = "published"
|
| 567 |
+
run.published_at = _now()
|
| 568 |
+
run.eta_s = 0.0
|
| 569 |
+
self.save_run(run)
|
| 570 |
+
self.invalidate_artifacts(run.run_id)
|
| 571 |
+
except Exception as e: # pragma: no cover - defensive
|
| 572 |
+
run.status = "failed"
|
| 573 |
+
run.error = str(e)
|
| 574 |
+
self.save_run(run)
|
| 575 |
+
|
| 576 |
+
@staticmethod
|
| 577 |
+
def _build_embeddings(d: str, containers: pd.DataFrame) -> None:
|
| 578 |
+
"""32-d container embeddings: structural features + seeded random projection."""
|
| 579 |
+
feats = []
|
| 580 |
+
nobj = containers["n_objects"].clip(lower=1).to_numpy(dtype=np.float64)
|
| 581 |
+
for col in ("tp", "fp", "fn", "anomalies", "safety_n"):
|
| 582 |
+
feats.append(containers[col].to_numpy(dtype=np.float64) / nobj)
|
| 583 |
+
feats.append(containers["sum_iou"].to_numpy(dtype=np.float64)
|
| 584 |
+
/ containers["tp"].clip(lower=1).to_numpy(dtype=np.float64))
|
| 585 |
+
feats.append(np.log1p(nobj) / 4.0)
|
| 586 |
+
feats.append(containers["risk_score"].to_numpy(dtype=np.float64))
|
| 587 |
+
for dim in pop_mod.CONTAINER_DIMS:
|
| 588 |
+
k = len(DIMENSIONS[dim])
|
| 589 |
+
onehot = np.eye(k)[containers[dim].to_numpy(dtype=np.int64)]
|
| 590 |
+
feats.extend(onehot.T)
|
| 591 |
+
X = np.vstack(feats).T # (n, ~25)
|
| 592 |
+
rng = np.random.default_rng(1234)
|
| 593 |
+
proj = rng.normal(size=(X.shape[1], 32)) / np.sqrt(X.shape[1])
|
| 594 |
+
emb = X @ proj
|
| 595 |
+
emb = emb / np.clip(np.linalg.norm(emb, axis=1, keepdims=True), 1e-9, None)
|
| 596 |
+
np.savez_compressed(os.path.join(d, "embeddings.npz"),
|
| 597 |
+
container_id=containers["container_id"].to_numpy(),
|
| 598 |
+
emb=emb.astype(np.float32))
|
| 599 |
+
|
| 600 |
+
# ---- forensic drill-down (deepest level; loads exactly one partition)
|
| 601 |
+
|
| 602 |
+
def container_objects(self, run: EvaluationRun, container_id: int) -> List[Dict]:
|
| 603 |
+
p = pop_mod.partition_of_container(container_id)
|
| 604 |
+
pop_cols = pop_mod.load_partition(run.population_id, p)
|
| 605 |
+
d = run_dir(run.run_id)
|
| 606 |
+
with np.load(os.path.join(d, f"objects-part-{p:04d}.npz")) as z:
|
| 607 |
+
out_cols = {k: z[k] for k in z.files}
|
| 608 |
+
mask = pop_cols["container_id"] == container_id
|
| 609 |
+
idx = np.where(mask)[0]
|
| 610 |
+
rows = []
|
| 611 |
+
for i in idx.tolist():
|
| 612 |
+
det = bool(out_cols["detected"][i])
|
| 613 |
+
tp = bool(out_cols["tp"][i])
|
| 614 |
+
rows.append({
|
| 615 |
+
"annotation_id": f"obj-{int(pop_cols['object_id'][i])}",
|
| 616 |
+
"container_id": int(container_id),
|
| 617 |
+
**{k: DIMENSIONS[k][int(pop_cols[k][i])] for k in DIM_NAMES},
|
| 618 |
+
"safety_critical": bool(pop_cols["safety_critical"][i]),
|
| 619 |
+
"difficulty": round(float(pop_cols["difficulty"][i]), 4),
|
| 620 |
+
"detected": det,
|
| 621 |
+
"outcome": "TP" if tp else ("LOCALIZATION" if det else "FN"),
|
| 622 |
+
"iou": round(float(out_cols["iou"][i]), 4) if det else None,
|
| 623 |
+
"confidence": round(float(out_cols["confidence"][i]), 4) if det else None,
|
| 624 |
+
"anomaly": bool(out_cols["anomaly"][i]),
|
| 625 |
+
"sensor_disagree": bool(out_cols["sensor_disagree"][i]),
|
| 626 |
+
})
|
| 627 |
+
with np.load(os.path.join(d, f"fp-part-{p:04d}.npz")) as z:
|
| 628 |
+
fp_cols = {k: z[k] for k in z.files}
|
| 629 |
+
fp_mask = fp_cols["container_id"] == container_id
|
| 630 |
+
for j in np.where(fp_mask)[0].tolist():
|
| 631 |
+
rows.append({
|
| 632 |
+
"annotation_id": f"fp-{run.run_id[-4:]}-{p}-{j}",
|
| 633 |
+
"container_id": int(container_id),
|
| 634 |
+
**{k: DIMENSIONS[k][int(fp_cols[k][j])] for k in DIM_NAMES},
|
| 635 |
+
"safety_critical": False,
|
| 636 |
+
"difficulty": None,
|
| 637 |
+
"detected": True,
|
| 638 |
+
"outcome": "FP",
|
| 639 |
+
"iou": 0.0,
|
| 640 |
+
"confidence": round(float(fp_cols["confidence"][j]), 4),
|
| 641 |
+
"anomaly": False,
|
| 642 |
+
"sensor_disagree": False,
|
| 643 |
+
})
|
| 644 |
+
return rows
|
| 645 |
+
|
| 646 |
+
# ---- honest record-scan fallback for the query router
|
| 647 |
+
|
| 648 |
+
def scan_records(self, run: EvaluationRun, filters: Optional[Dict],
|
| 649 |
+
group_by: Optional[List[str]], metrics: Optional[List[str]],
|
| 650 |
+
limit: int) -> tuple:
|
| 651 |
+
frames = []
|
| 652 |
+
d = run_dir(run.run_id)
|
| 653 |
+
for p in range(run.partitions_total):
|
| 654 |
+
pop_cols = pop_mod.load_partition(run.population_id, p)
|
| 655 |
+
with np.load(os.path.join(d, f"objects-part-{p:04d}.npz")) as z:
|
| 656 |
+
df = pd.DataFrame({
|
| 657 |
+
**{k: pop_cols[k] for k in DIM_NAMES},
|
| 658 |
+
"container_id": pop_cols["container_id"],
|
| 659 |
+
"tp": z["tp"].astype(np.int64),
|
| 660 |
+
"fn": (1 - z["tp"]).astype(np.int64),
|
| 661 |
+
"fp": (z["detected"] & ~z["tp"].astype(bool)).astype(np.int64),
|
| 662 |
+
"anomalies": z["anomaly"].astype(np.int64),
|
| 663 |
+
"iou": z["iou"],
|
| 664 |
+
})
|
| 665 |
+
frames.append(df)
|
| 666 |
+
full = pd.concat(frames, ignore_index=True)
|
| 667 |
+
full = cube_mod.apply_filters(full, filters)
|
| 668 |
+
gb = [g for g in (group_by or []) if g in list(full.columns)]
|
| 669 |
+
if not gb:
|
| 670 |
+
gb = ["container_id"]
|
| 671 |
+
agg = full.groupby(gb, as_index=False, observed=True).agg(
|
| 672 |
+
n=("tp", "size"), tp=("tp", "sum"), fp=("fp", "sum"), fn=("fn", "sum"),
|
| 673 |
+
anomalies=("anomalies", "sum"), mean_iou=("iou", "mean"))
|
| 674 |
+
agg["recall"] = agg["tp"] / (agg["tp"] + agg["fn"]).clip(lower=1)
|
| 675 |
+
agg = agg.sort_values("n", ascending=False).head(limit)
|
| 676 |
+
rows = []
|
| 677 |
+
for _, r in agg.iterrows():
|
| 678 |
+
row = {}
|
| 679 |
+
for g in gb:
|
| 680 |
+
row[g] = DIMENSIONS[g][int(r[g])] if g in DIM_NAMES else int(r[g])
|
| 681 |
+
row.update({"n": int(r["n"]), "tp": int(r["tp"]), "fp": int(r["fp"]),
|
| 682 |
+
"fn": int(r["fn"]), "anomalies": int(r["anomalies"]),
|
| 683 |
+
"mean_iou": round(float(r["mean_iou"]), 6),
|
| 684 |
+
"recall": round(float(r["recall"]), 6)})
|
| 685 |
+
rows.append(row)
|
| 686 |
+
return rows, len(full)
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
_STORE: Optional[MegaStore] = None
|
| 690 |
+
_STORE_LOCK = threading.Lock()
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
def get_mega_store() -> MegaStore:
|
| 694 |
+
global _STORE
|
| 695 |
+
with _STORE_LOCK:
|
| 696 |
+
if _STORE is None:
|
| 697 |
+
_STORE = MegaStore()
|
| 698 |
+
return _STORE
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def reset_mega_store() -> None:
|
| 702 |
+
global _STORE
|
| 703 |
+
with _STORE_LOCK:
|
| 704 |
+
_STORE = None
|
sensorflow/megaeval/sampling.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human review as statistical sampling.
|
| 2 |
+
|
| 3 |
+
Instead of a flat "review N labels" queue, review effort is a designed sample:
|
| 4 |
+
|
| 5 |
+
population -> containers -> suspicious containers -> candidate pool
|
| 6 |
+
-> stratified risk-weighted selection -> reviews -> CIs
|
| 7 |
+
|
| 8 |
+
Two estimands, each with its own sampling frame:
|
| 9 |
+
- precision: frame = predicted positives, strata = confidence tier x safety
|
| 10 |
+
- recall: frame = ground-truth objects, strata = model outcome x safety
|
| 11 |
+
|
| 12 |
+
Risk weighting happens through *allocation* (risky strata get more samples);
|
| 13 |
+
estimates stay honest because strata are combined with their true population
|
| 14 |
+
weights (classic stratified estimator):
|
| 15 |
+
|
| 16 |
+
p_hat = sum_h W_h * p_hat_h
|
| 17 |
+
SE = sqrt( sum_h W_h^2 * p_hat_h (1 - p_hat_h) / n_h ) (normal approx)
|
| 18 |
+
|
| 19 |
+
Per-stratum intervals use Wilson. Simulated reviewers agree with ground truth
|
| 20 |
+
with 98.5% fidelity (the synthetic stand-in for human accuracy).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import math
|
| 27 |
+
import os
|
| 28 |
+
from typing import Dict, List, Optional, Tuple
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
import pandas as pd
|
| 32 |
+
|
| 33 |
+
from sensorflow.megaeval import population as pop_mod
|
| 34 |
+
from sensorflow.megaeval.population import DIM_NAMES
|
| 35 |
+
from sensorflow.megaeval.runs import EvaluationRun, MegaStore, run_dir
|
| 36 |
+
|
| 37 |
+
HUMAN_FIDELITY = 0.985
|
| 38 |
+
Z95 = 1.959964
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def wilson_interval(k: int, n: int, z: float = Z95) -> Tuple[float, float]:
|
| 42 |
+
"""Wilson score interval for a binomial proportion."""
|
| 43 |
+
if n == 0:
|
| 44 |
+
return (0.0, 1.0)
|
| 45 |
+
p = k / n
|
| 46 |
+
denom = 1 + z**2 / n
|
| 47 |
+
center = (p + z**2 / (2 * n)) / denom
|
| 48 |
+
margin = z * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / denom
|
| 49 |
+
return (max(0.0, center - margin), min(1.0, center + margin))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _load_unit_frames(store: MegaStore, run: EvaluationRun) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
| 53 |
+
"""One batch scan (NOT on the query path) to build the two sampling frames."""
|
| 54 |
+
d = run_dir(run.run_id)
|
| 55 |
+
gt_frames, pp_frames = [], []
|
| 56 |
+
for p in range(run.partitions_total):
|
| 57 |
+
pop_cols = pop_mod.load_partition(run.population_id, p)
|
| 58 |
+
with np.load(os.path.join(d, f"objects-part-{p:04d}.npz")) as z:
|
| 59 |
+
detected = z["detected"].astype(bool)
|
| 60 |
+
tp = z["tp"].astype(bool)
|
| 61 |
+
conf = z["confidence"]
|
| 62 |
+
base = pd.DataFrame({
|
| 63 |
+
"unit_id": pop_cols["object_id"],
|
| 64 |
+
"container_id": pop_cols["container_id"],
|
| 65 |
+
**{k: pop_cols[k] for k in DIM_NAMES},
|
| 66 |
+
"safety": pop_cols["safety_critical"].astype(bool),
|
| 67 |
+
"detected": detected, "tp": tp, "confidence": conf,
|
| 68 |
+
})
|
| 69 |
+
gt_frames.append(base)
|
| 70 |
+
pp = base[detected].copy()
|
| 71 |
+
pp["correct"] = pp["tp"]
|
| 72 |
+
pp_frames.append(pp)
|
| 73 |
+
with np.load(os.path.join(d, f"fp-part-{p:04d}.npz")) as z:
|
| 74 |
+
if z["container_id"].size:
|
| 75 |
+
fpdf = pd.DataFrame({
|
| 76 |
+
"unit_id": -np.arange(1, z["container_id"].size + 1) - p * 10_000_000,
|
| 77 |
+
"container_id": z["container_id"],
|
| 78 |
+
**{k: z[k] for k in DIM_NAMES},
|
| 79 |
+
"safety": False, "detected": True, "tp": False,
|
| 80 |
+
"confidence": z["confidence"], "correct": False,
|
| 81 |
+
})
|
| 82 |
+
pp_frames.append(fpdf)
|
| 83 |
+
return pd.concat(gt_frames, ignore_index=True), pd.concat(pp_frames, ignore_index=True)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _stratify_precision(pp: pd.DataFrame) -> pd.Series:
|
| 87 |
+
tier = np.select([pp["confidence"] < 0.45, pp["confidence"] < 0.75], [0, 1], default=2)
|
| 88 |
+
return pd.Series(tier * 2 + pp["safety"].astype(int).to_numpy(), index=pp.index)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _stratify_recall(gt: pd.DataFrame) -> pd.Series:
|
| 92 |
+
return pd.Series((~gt["detected"]).astype(int).to_numpy() * 2
|
| 93 |
+
+ gt["safety"].astype(int).to_numpy(), index=gt.index)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
_PRECISION_STRATA = {0: "low_conf", 1: "low_conf_safety", 2: "mid_conf", 3: "mid_conf_safety",
|
| 97 |
+
4: "high_conf", 5: "high_conf_safety"}
|
| 98 |
+
_RECALL_STRATA = {0: "detected", 1: "detected_safety", 2: "missed", 3: "missed_safety"}
|
| 99 |
+
# risk-weighted allocation multipliers (riskier strata get proportionally more reviews)
|
| 100 |
+
_PRECISION_RISK_W = {0: 3.0, 1: 4.0, 2: 1.5, 3: 2.5, 4: 1.0, 5: 2.0}
|
| 101 |
+
_RECALL_RISK_W = {0: 1.0, 1: 2.0, 2: 3.0, 3: 5.0}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _allocate(counts: Dict[int, int], risk_w: Dict[int, float], total_n: int) -> Dict[int, int]:
|
| 105 |
+
weights = {h: counts[h] * risk_w.get(h, 1.0) for h in counts}
|
| 106 |
+
wsum = sum(weights.values()) or 1.0
|
| 107 |
+
alloc = {}
|
| 108 |
+
for h, c in counts.items():
|
| 109 |
+
n_h = int(round(total_n * weights[h] / wsum))
|
| 110 |
+
alloc[h] = max(1, min(n_h, c)) if c > 0 else 0
|
| 111 |
+
return alloc
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def build_review_plan(store: MegaStore, run: EvaluationRun,
|
| 115 |
+
target_n: Optional[int] = None, seed: int = 99) -> Dict:
|
| 116 |
+
"""Build the sampling funnel + stratified selection. Persists review.json."""
|
| 117 |
+
target_n = target_n or run.lineage["sampling_config"].get("target_n", 1500)
|
| 118 |
+
rng = np.random.default_rng(seed + run.seed)
|
| 119 |
+
gt, pp = _load_unit_frames(store, run)
|
| 120 |
+
art = store.artifacts(run.run_id)
|
| 121 |
+
containers = art["containers"]
|
| 122 |
+
errors = art.get("errors", pd.DataFrame())
|
| 123 |
+
|
| 124 |
+
suspicious = int((containers["risk_score"] >= 0.2).sum())
|
| 125 |
+
candidates = int(len(errors)) if len(errors) else 0
|
| 126 |
+
|
| 127 |
+
plans = {}
|
| 128 |
+
for name, frame, strat_fn, labels, risk_w in (
|
| 129 |
+
("precision", pp, _stratify_precision, _PRECISION_STRATA, _PRECISION_RISK_W),
|
| 130 |
+
("recall", gt, _stratify_recall, _RECALL_STRATA, _RECALL_RISK_W)):
|
| 131 |
+
strata = strat_fn(frame)
|
| 132 |
+
counts = strata.value_counts().to_dict()
|
| 133 |
+
alloc = _allocate(counts, risk_w, target_n // 2)
|
| 134 |
+
selected = {}
|
| 135 |
+
for h, n_h in alloc.items():
|
| 136 |
+
if n_h <= 0:
|
| 137 |
+
continue
|
| 138 |
+
idx = frame.index[strata == h].to_numpy()
|
| 139 |
+
chosen = rng.choice(idx, size=min(n_h, idx.size), replace=False)
|
| 140 |
+
selected[str(h)] = frame.loc[chosen, "unit_id"].astype(np.int64).tolist()
|
| 141 |
+
plans[name] = {
|
| 142 |
+
"frame_size": int(len(frame)),
|
| 143 |
+
"strata": {str(h): {"label": labels[h], "N": int(counts.get(h, 0)),
|
| 144 |
+
"allocated": int(alloc.get(h, 0))}
|
| 145 |
+
for h in labels},
|
| 146 |
+
"selected_ids": selected,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
selected_total = sum(len(v) for p in plans.values() for v in p["selected_ids"].values())
|
| 150 |
+
review = {
|
| 151 |
+
"run_id": run.run_id,
|
| 152 |
+
"method": "stratified risk-weighted sampling; Wilson per-stratum, "
|
| 153 |
+
"stratified normal-approx combined CI",
|
| 154 |
+
"target_n": target_n,
|
| 155 |
+
"funnel": {
|
| 156 |
+
"population_objects": int(run.objects_total),
|
| 157 |
+
"containers": int(len(containers)),
|
| 158 |
+
"suspicious_containers": suspicious,
|
| 159 |
+
"candidate_pool": candidates,
|
| 160 |
+
"statistically_selected": int(selected_total),
|
| 161 |
+
"reviewed": 0,
|
| 162 |
+
},
|
| 163 |
+
"plans": plans,
|
| 164 |
+
"executed": False,
|
| 165 |
+
"results": None,
|
| 166 |
+
}
|
| 167 |
+
with open(os.path.join(run_dir(run.run_id), "review.json"), "w") as f:
|
| 168 |
+
json.dump(review, f)
|
| 169 |
+
store.invalidate_artifacts(run.run_id)
|
| 170 |
+
return review
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def execute_reviews(store: MegaStore, run: EvaluationRun, seed: int = 7) -> Dict:
|
| 174 |
+
"""Simulate human review of the selected sample; compute CIs; update the cube."""
|
| 175 |
+
d = run_dir(run.run_id)
|
| 176 |
+
with open(os.path.join(d, "review.json")) as f:
|
| 177 |
+
review = json.load(f)
|
| 178 |
+
rng = np.random.default_rng(seed + run.seed)
|
| 179 |
+
gt, pp = _load_unit_frames(store, run)
|
| 180 |
+
gt_idx = gt.set_index("unit_id")
|
| 181 |
+
pp_idx = pp.set_index("unit_id")
|
| 182 |
+
|
| 183 |
+
results = {}
|
| 184 |
+
reviewed_units = [] # (dims..., verified) for cube update — recall-frame units only
|
| 185 |
+
total_reviewed = 0
|
| 186 |
+
for name, frame_idx, truth_col in (("precision", pp_idx, "correct"),
|
| 187 |
+
("recall", gt_idx, "tp")):
|
| 188 |
+
plan = review["plans"][name]
|
| 189 |
+
strata_out = []
|
| 190 |
+
p_hat_comb, var_comb = 0.0, 0.0
|
| 191 |
+
frame_N = plan["frame_size"]
|
| 192 |
+
n_total = 0
|
| 193 |
+
for h, sinfo in plan["strata"].items():
|
| 194 |
+
ids = plan["selected_ids"].get(h, [])
|
| 195 |
+
if not ids:
|
| 196 |
+
continue
|
| 197 |
+
units = frame_idx.loc[[i for i in ids if i in frame_idx.index]]
|
| 198 |
+
if isinstance(units, pd.Series):
|
| 199 |
+
units = units.to_frame().T
|
| 200 |
+
truth = units[truth_col].astype(bool).to_numpy()
|
| 201 |
+
agrees = rng.random(truth.size) < HUMAN_FIDELITY
|
| 202 |
+
verdict = np.where(agrees, truth, ~truth)
|
| 203 |
+
k, n_h = int(verdict.sum()), int(verdict.size)
|
| 204 |
+
W_h = sinfo["N"] / frame_N
|
| 205 |
+
p_h = k / n_h if n_h else 0.0
|
| 206 |
+
lo, hi = wilson_interval(k, n_h)
|
| 207 |
+
strata_out.append({"stratum": sinfo["label"], "N": sinfo["N"], "n": n_h,
|
| 208 |
+
"correct": k, "p": round(p_h, 4),
|
| 209 |
+
"wilson_ci": [round(lo, 4), round(hi, 4)],
|
| 210 |
+
"weight": round(W_h, 4)})
|
| 211 |
+
p_hat_comb += W_h * p_h
|
| 212 |
+
var_comb += (W_h**2) * p_h * (1 - p_h) / max(n_h, 1)
|
| 213 |
+
n_total += n_h
|
| 214 |
+
total_reviewed += n_h
|
| 215 |
+
if name == "recall":
|
| 216 |
+
sub = units.copy()
|
| 217 |
+
sub["verified"] = verdict
|
| 218 |
+
reviewed_units.append(sub)
|
| 219 |
+
se = math.sqrt(var_comb)
|
| 220 |
+
results[name] = {
|
| 221 |
+
"estimate": round(p_hat_comb, 4),
|
| 222 |
+
"ci_low": round(max(0.0, p_hat_comb - Z95 * se), 4),
|
| 223 |
+
"ci_high": round(min(1.0, p_hat_comb + Z95 * se), 4),
|
| 224 |
+
"n_reviewed": n_total,
|
| 225 |
+
"frame_size": frame_N,
|
| 226 |
+
"method": review["method"],
|
| 227 |
+
"strata": strata_out,
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
# ---- fold reviews back into the cube + container table (reviewed/verified counters)
|
| 231 |
+
if reviewed_units:
|
| 232 |
+
ru = pd.concat(reviewed_units)
|
| 233 |
+
ru = ru.reset_index(drop=True)
|
| 234 |
+
upd = ru.groupby(DIM_NAMES, as_index=False, observed=True).agg(
|
| 235 |
+
add_reviewed=("verified", "size"), add_verified=("verified", "sum"))
|
| 236 |
+
from sensorflow.megaeval import cube as cube_mod
|
| 237 |
+
cube_path = os.path.join(d, "cube.npz")
|
| 238 |
+
cube_df = cube_mod.load_cube(cube_path)
|
| 239 |
+
merged = cube_df.merge(upd, on=DIM_NAMES, how="left")
|
| 240 |
+
merged["reviewed"] = merged["reviewed"] + merged["add_reviewed"].fillna(0).astype(np.int64)
|
| 241 |
+
merged["verified"] = merged["verified"] + merged["add_verified"].fillna(0).astype(np.int64)
|
| 242 |
+
cube_mod.save_cube(cube_path, merged.drop(columns=["add_reviewed", "add_verified"]))
|
| 243 |
+
|
| 244 |
+
cupd = ru.groupby("container_id", as_index=False).agg(
|
| 245 |
+
add_reviewed=("verified", "size"), add_verified=("verified", "sum"))
|
| 246 |
+
cont_path = os.path.join(d, "containers.npz")
|
| 247 |
+
with np.load(cont_path) as z:
|
| 248 |
+
cont = pd.DataFrame({k: z[k] for k in z.files})
|
| 249 |
+
cont = cont.merge(cupd, on="container_id", how="left")
|
| 250 |
+
cont["reviewed"] = cont["reviewed"] + cont["add_reviewed"].fillna(0).astype(np.int64)
|
| 251 |
+
cont["verified"] = cont["verified"] + cont["add_verified"].fillna(0).astype(np.int64)
|
| 252 |
+
cont = cont.drop(columns=["add_reviewed", "add_verified"])
|
| 253 |
+
np.savez_compressed(cont_path, **{c: cont[c].to_numpy() for c in cont.columns})
|
| 254 |
+
|
| 255 |
+
review["executed"] = True
|
| 256 |
+
review["funnel"]["reviewed"] = total_reviewed
|
| 257 |
+
review["results"] = results
|
| 258 |
+
with open(os.path.join(d, "review.json"), "w") as f:
|
| 259 |
+
json.dump(review, f)
|
| 260 |
+
store.invalidate_artifacts(run.run_id)
|
| 261 |
+
return review
|
sensorflow/megaeval/sketches.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight approximate data structures for population statistics.
|
| 2 |
+
|
| 3 |
+
All numpy-based, mergeable (so they can be maintained per partition and reduced),
|
| 4 |
+
and each clearly reports whether an answer is exact or approximate.
|
| 5 |
+
|
| 6 |
+
- QuantileHistogram: fixed-bin histogram over a known range; percentile queries by
|
| 7 |
+
linear interpolation inside the winning bin. Stand-in for a t-digest.
|
| 8 |
+
- HyperLogLog: classic HLL with 2^p registers and numpy vectorized updates.
|
| 9 |
+
- Reservoir: uniform reservoir sample of exemplar ids (Algorithm R, vectorized-ish).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------- hashing
|
| 19 |
+
|
| 20 |
+
_SPLITMIX_1 = np.uint64(0x9E3779B97F4A7C15)
|
| 21 |
+
_SPLITMIX_2 = np.uint64(0xBF58476D1CE4E5B9)
|
| 22 |
+
_SPLITMIX_3 = np.uint64(0x94D049BB133111EB)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _hash64(values: np.ndarray) -> np.ndarray:
|
| 26 |
+
"""Deterministic 64-bit mix (splitmix64) of integer identifiers."""
|
| 27 |
+
with np.errstate(over="ignore"):
|
| 28 |
+
z = values.astype(np.uint64) + _SPLITMIX_1
|
| 29 |
+
z = (z ^ (z >> np.uint64(30))) * _SPLITMIX_2
|
| 30 |
+
z = (z ^ (z >> np.uint64(27))) * _SPLITMIX_3
|
| 31 |
+
return z ^ (z >> np.uint64(31))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------- quantile histogram
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class QuantileHistogram:
|
| 38 |
+
"""Fixed-bin histogram over [lo, hi]; approximate percentiles via interpolation.
|
| 39 |
+
|
| 40 |
+
Mergeable: histograms with identical bin config add counts element-wise.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, lo: float = 0.0, hi: float = 1.0, bins: int = 64,
|
| 44 |
+
counts: Optional[np.ndarray] = None):
|
| 45 |
+
self.lo, self.hi, self.bins = float(lo), float(hi), int(bins)
|
| 46 |
+
self.counts = counts if counts is not None else np.zeros(bins, dtype=np.int64)
|
| 47 |
+
|
| 48 |
+
def add(self, values: np.ndarray) -> None:
|
| 49 |
+
if values.size == 0:
|
| 50 |
+
return
|
| 51 |
+
clipped = np.clip(values, self.lo, self.hi)
|
| 52 |
+
idx = np.minimum(
|
| 53 |
+
((clipped - self.lo) / (self.hi - self.lo) * self.bins).astype(np.int64),
|
| 54 |
+
self.bins - 1)
|
| 55 |
+
self.counts += np.bincount(idx, minlength=self.bins)
|
| 56 |
+
|
| 57 |
+
def merge(self, other: "QuantileHistogram") -> "QuantileHistogram":
|
| 58 |
+
assert (self.lo, self.hi, self.bins) == (other.lo, other.hi, other.bins)
|
| 59 |
+
return QuantileHistogram(self.lo, self.hi, self.bins, self.counts + other.counts)
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def total(self) -> int:
|
| 63 |
+
return int(self.counts.sum())
|
| 64 |
+
|
| 65 |
+
def percentile(self, q: float) -> Optional[float]:
|
| 66 |
+
"""Approximate q-th percentile (q in [0, 100])."""
|
| 67 |
+
total = self.total
|
| 68 |
+
if total == 0:
|
| 69 |
+
return None
|
| 70 |
+
target = total * (q / 100.0)
|
| 71 |
+
cum = np.cumsum(self.counts)
|
| 72 |
+
bin_idx = int(np.searchsorted(cum, target, side="left"))
|
| 73 |
+
bin_idx = min(bin_idx, self.bins - 1)
|
| 74 |
+
prev_cum = cum[bin_idx - 1] if bin_idx > 0 else 0
|
| 75 |
+
in_bin = self.counts[bin_idx]
|
| 76 |
+
frac = 0.5 if in_bin == 0 else float(np.clip((target - prev_cum) / in_bin, 0.0, 1.0))
|
| 77 |
+
width = (self.hi - self.lo) / self.bins
|
| 78 |
+
return self.lo + (bin_idx + frac) * width
|
| 79 |
+
|
| 80 |
+
def to_dict(self) -> Dict:
|
| 81 |
+
return {"lo": self.lo, "hi": self.hi, "bins": self.bins,
|
| 82 |
+
"counts": self.counts.tolist(), "exact": False}
|
| 83 |
+
|
| 84 |
+
@staticmethod
|
| 85 |
+
def from_arrays(lo: float, hi: float, counts: np.ndarray) -> "QuantileHistogram":
|
| 86 |
+
return QuantileHistogram(lo, hi, len(counts), counts.astype(np.int64))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------- HyperLogLog
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class HyperLogLog:
|
| 93 |
+
"""HLL cardinality estimator with 2^p registers (approximate).
|
| 94 |
+
|
| 95 |
+
Mergeable: element-wise max of registers.
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
def __init__(self, p: int = 12, registers: Optional[np.ndarray] = None):
|
| 99 |
+
self.p = int(p)
|
| 100 |
+
self.m = 1 << self.p
|
| 101 |
+
self.registers = registers if registers is not None else np.zeros(self.m, dtype=np.uint8)
|
| 102 |
+
|
| 103 |
+
def add_ids(self, ids: np.ndarray) -> None:
|
| 104 |
+
if ids.size == 0:
|
| 105 |
+
return
|
| 106 |
+
h = _hash64(np.asarray(ids))
|
| 107 |
+
reg_idx = (h >> np.uint64(64 - self.p)).astype(np.int64)
|
| 108 |
+
rest = (h << np.uint64(self.p)) | np.uint64((1 << self.p) - 1)
|
| 109 |
+
# rank = leading zeros of the remaining bits + 1
|
| 110 |
+
ranks = np.zeros(ids.shape, dtype=np.uint8)
|
| 111 |
+
remaining = rest.copy()
|
| 112 |
+
# 64-bit leading-zero count via float log2 is unsafe; loop over 64 bits max via vector ops
|
| 113 |
+
lz = np.full(ids.shape, 64, dtype=np.int64)
|
| 114 |
+
nonzero = remaining != 0
|
| 115 |
+
# bit_length via log2 on uint64 split into hi/lo 32-bit halves (exact for ints < 2^53 per half)
|
| 116 |
+
hi = (remaining >> np.uint64(32)).astype(np.float64)
|
| 117 |
+
lo = (remaining & np.uint64(0xFFFFFFFF)).astype(np.float64)
|
| 118 |
+
bl = np.zeros(ids.shape, dtype=np.int64)
|
| 119 |
+
hi_nz = hi > 0
|
| 120 |
+
bl[hi_nz] = 32 + np.floor(np.log2(hi[hi_nz])).astype(np.int64) + 1
|
| 121 |
+
lo_only = (~hi_nz) & (lo > 0)
|
| 122 |
+
bl[lo_only] = np.floor(np.log2(lo[lo_only])).astype(np.int64) + 1
|
| 123 |
+
lz[nonzero] = 64 - bl[nonzero]
|
| 124 |
+
ranks = np.minimum(lz + 1, 64 - self.p + 1).astype(np.uint8)
|
| 125 |
+
np.maximum.at(self.registers, reg_idx, ranks)
|
| 126 |
+
|
| 127 |
+
def merge(self, other: "HyperLogLog") -> "HyperLogLog":
|
| 128 |
+
assert self.p == other.p
|
| 129 |
+
return HyperLogLog(self.p, np.maximum(self.registers, other.registers))
|
| 130 |
+
|
| 131 |
+
def estimate(self) -> float:
|
| 132 |
+
m = float(self.m)
|
| 133 |
+
alpha = 0.7213 / (1.0 + 1.079 / m)
|
| 134 |
+
inv_sum = np.sum(np.exp2(-self.registers.astype(np.float64)))
|
| 135 |
+
raw = alpha * m * m / inv_sum
|
| 136 |
+
zeros = int(np.sum(self.registers == 0))
|
| 137 |
+
if raw <= 2.5 * m and zeros > 0: # small-range correction
|
| 138 |
+
return m * np.log(m / zeros)
|
| 139 |
+
return float(raw)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ---------------------------------------------------------------- reservoir sample
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class Reservoir:
|
| 146 |
+
"""Uniform reservoir sample of ids (exemplars for drill-down)."""
|
| 147 |
+
|
| 148 |
+
def __init__(self, k: int = 200, seed: int = 0):
|
| 149 |
+
self.k = int(k)
|
| 150 |
+
self.rng = np.random.default_rng(seed)
|
| 151 |
+
self.items: List[int] = []
|
| 152 |
+
self.seen = 0
|
| 153 |
+
|
| 154 |
+
def add_ids(self, ids: np.ndarray) -> None:
|
| 155 |
+
for v in np.asarray(ids).tolist():
|
| 156 |
+
self.seen += 1
|
| 157 |
+
if len(self.items) < self.k:
|
| 158 |
+
self.items.append(int(v))
|
| 159 |
+
else:
|
| 160 |
+
j = int(self.rng.integers(0, self.seen))
|
| 161 |
+
if j < self.k:
|
| 162 |
+
self.items[j] = int(v)
|
| 163 |
+
|
| 164 |
+
def merge(self, other: "Reservoir") -> "Reservoir":
|
| 165 |
+
merged = Reservoir(self.k, seed=self.seen + other.seen + 1)
|
| 166 |
+
pool = self.items + other.items
|
| 167 |
+
merged.seen = self.seen + other.seen
|
| 168 |
+
if len(pool) <= self.k:
|
| 169 |
+
merged.items = pool
|
| 170 |
+
else:
|
| 171 |
+
idx = merged.rng.choice(len(pool), size=self.k, replace=False)
|
| 172 |
+
merged.items = [pool[i] for i in idx]
|
| 173 |
+
return merged
|
src/App.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import ListSubheader from '@mui/material/ListSubheader';
|
|
| 11 |
import Toolbar from '@mui/material/Toolbar';
|
| 12 |
import Typography from '@mui/material/Typography';
|
| 13 |
import {
|
|
|
|
| 14 |
LayoutDashboard,
|
| 15 |
Database,
|
| 16 |
Tag,
|
|
@@ -44,6 +45,7 @@ import AuditPage from './pages/AuditPage';
|
|
| 44 |
import PipelineArchitecturePage from './pages/PipelineArchitecturePage';
|
| 45 |
import SSAMSafetyDashboard from './pages/SSAMSafetyDashboard';
|
| 46 |
import LegacyStudioPage from './pages/LegacyStudioPage';
|
|
|
|
| 47 |
|
| 48 |
const DRAWER_WIDTH = 230;
|
| 49 |
|
|
@@ -54,6 +56,7 @@ interface NavItem {
|
|
| 54 |
}
|
| 55 |
|
| 56 |
const PLATFORM_NAV: NavItem[] = [
|
|
|
|
| 57 |
{ id: 'overview', label: 'Overview', icon: <LayoutDashboard size={18} /> },
|
| 58 |
{ id: 'datasets', label: 'Datasets', icon: <Database size={18} /> },
|
| 59 |
{ id: 'label-generation', label: 'Label Generation', icon: <Tag size={18} /> },
|
|
@@ -75,6 +78,7 @@ const LEGACY_NAV: NavItem[] = [
|
|
| 75 |
];
|
| 76 |
|
| 77 |
const PAGE_TITLES: Record<PageId, string> = {
|
|
|
|
| 78 |
overview: 'Overview',
|
| 79 |
datasets: 'Datasets',
|
| 80 |
'label-generation': 'Label Generation',
|
|
@@ -95,7 +99,7 @@ const PAGE_TITLES: Record<PageId, string> = {
|
|
| 95 |
function parseHash(): { page: PageId; entityId: string | null } {
|
| 96 |
const raw = window.location.hash.replace(/^#\/?/, '');
|
| 97 |
const [pageRaw, entityRaw] = raw.split('/');
|
| 98 |
-
const page = (ALL_PAGE_IDS as string[]).includes(pageRaw ?? '') ? (pageRaw as PageId) : '
|
| 99 |
return { page, entityId: entityRaw ? decodeURIComponent(entityRaw) : null };
|
| 100 |
}
|
| 101 |
|
|
@@ -268,6 +272,7 @@ export default function App() {
|
|
| 268 |
<Typography variant="h5" sx={{ fontWeight: 800, mb: 2 }}>
|
| 269 |
{PAGE_TITLES[page]}
|
| 270 |
</Typography>
|
|
|
|
| 271 |
{page === 'overview' ? <OverviewPage /> : null}
|
| 272 |
{page === 'datasets' ? <DatasetsPage /> : null}
|
| 273 |
{page === 'label-generation' ? <LabelGenerationPage /> : null}
|
|
|
|
| 11 |
import Toolbar from '@mui/material/Toolbar';
|
| 12 |
import Typography from '@mui/material/Typography';
|
| 13 |
import {
|
| 14 |
+
Gauge,
|
| 15 |
LayoutDashboard,
|
| 16 |
Database,
|
| 17 |
Tag,
|
|
|
|
| 45 |
import PipelineArchitecturePage from './pages/PipelineArchitecturePage';
|
| 46 |
import SSAMSafetyDashboard from './pages/SSAMSafetyDashboard';
|
| 47 |
import LegacyStudioPage from './pages/LegacyStudioPage';
|
| 48 |
+
import CommandCenterPage from './pages/CommandCenterPage';
|
| 49 |
|
| 50 |
const DRAWER_WIDTH = 230;
|
| 51 |
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
const PLATFORM_NAV: NavItem[] = [
|
| 59 |
+
{ id: 'command', label: 'Command Center', icon: <Gauge size={18} /> },
|
| 60 |
{ id: 'overview', label: 'Overview', icon: <LayoutDashboard size={18} /> },
|
| 61 |
{ id: 'datasets', label: 'Datasets', icon: <Database size={18} /> },
|
| 62 |
{ id: 'label-generation', label: 'Label Generation', icon: <Tag size={18} /> },
|
|
|
|
| 78 |
];
|
| 79 |
|
| 80 |
const PAGE_TITLES: Record<PageId, string> = {
|
| 81 |
+
command: 'Evaluation Command Center',
|
| 82 |
overview: 'Overview',
|
| 83 |
datasets: 'Datasets',
|
| 84 |
'label-generation': 'Label Generation',
|
|
|
|
| 99 |
function parseHash(): { page: PageId; entityId: string | null } {
|
| 100 |
const raw = window.location.hash.replace(/^#\/?/, '');
|
| 101 |
const [pageRaw, entityRaw] = raw.split('/');
|
| 102 |
+
const page = (ALL_PAGE_IDS as string[]).includes(pageRaw ?? '') ? (pageRaw as PageId) : 'command';
|
| 103 |
return { page, entityId: entityRaw ? decodeURIComponent(entityRaw) : null };
|
| 104 |
}
|
| 105 |
|
|
|
|
| 272 |
<Typography variant="h5" sx={{ fontWeight: 800, mb: 2 }}>
|
| 273 |
{PAGE_TITLES[page]}
|
| 274 |
</Typography>
|
| 275 |
+
{page === 'command' ? <CommandCenterPage /> : null}
|
| 276 |
{page === 'overview' ? <OverviewPage /> : null}
|
| 277 |
{page === 'datasets' ? <DatasetsPage /> : null}
|
| 278 |
{page === 'label-generation' ? <LabelGenerationPage /> : null}
|
src/components/megaeval/CohortTab.tsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cohort explorer: pick a dimension tab, click a cohort value to drill —
|
| 3 |
+
* the value becomes a filter breadcrumb (e.g. Pedestrian → Night → Occluded)
|
| 4 |
+
* and the next level groups by the newly selected dimension. Every node shows
|
| 5 |
+
* cube metrics via POST /api/evaluations/query. "Why?" runs the factor
|
| 6 |
+
* decomposition for the current filter set.
|
| 7 |
+
*/
|
| 8 |
+
import { useMemo, useState } from 'react';
|
| 9 |
+
import Box from '@mui/material/Box';
|
| 10 |
+
import Button from '@mui/material/Button';
|
| 11 |
+
import Chip from '@mui/material/Chip';
|
| 12 |
+
import CircularProgress from '@mui/material/CircularProgress';
|
| 13 |
+
import Tab from '@mui/material/Tab';
|
| 14 |
+
import Table from '@mui/material/Table';
|
| 15 |
+
import TableBody from '@mui/material/TableBody';
|
| 16 |
+
import TableCell from '@mui/material/TableCell';
|
| 17 |
+
import TableHead from '@mui/material/TableHead';
|
| 18 |
+
import TableRow from '@mui/material/TableRow';
|
| 19 |
+
import Tabs from '@mui/material/Tabs';
|
| 20 |
+
import Typography from '@mui/material/Typography';
|
| 21 |
+
import { HelpCircle, ChevronRight } from 'lucide-react';
|
| 22 |
+
import type { DimName, WhyResponse } from '../../types/megaeval';
|
| 23 |
+
import { DIM_NAMES } from '../../types/megaeval';
|
| 24 |
+
import { fmtCompact, queryEvaluations, whyAnalysis } from '../../services/megaeval';
|
| 25 |
+
import { usePoll } from '../../services/labeleval';
|
| 26 |
+
import { ErrorNote, HBar, LoadingBox, SectionCard, fmtNum, fmtPct } from '../labeleval/shared';
|
| 27 |
+
import { PctBarCell, QueryBadge, rowNum, rowStr } from './shared';
|
| 28 |
+
|
| 29 |
+
interface Crumb {
|
| 30 |
+
dim: DimName;
|
| 31 |
+
value: string;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function crumbsToFilters(crumbs: Crumb[]): Partial<Record<DimName, string[]>> {
|
| 35 |
+
const filters: Partial<Record<DimName, string[]>> = {};
|
| 36 |
+
for (const c of crumbs) {
|
| 37 |
+
filters[c.dim] = [...(filters[c.dim] ?? []), c.value];
|
| 38 |
+
}
|
| 39 |
+
return filters;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function WhyPanel({ why }: { why: WhyResponse }) {
|
| 43 |
+
const maxCohortN = Math.max(1, ...why.top_cohorts.map((c) => c.n));
|
| 44 |
+
return (
|
| 45 |
+
<SectionCard
|
| 46 |
+
title={`Why is ${why.metric} degraded here?`}
|
| 47 |
+
sx={{ flex: '1 1 340px', borderColor: '#4a3b12' }}
|
| 48 |
+
>
|
| 49 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1 }}>
|
| 50 |
+
{fmtCompact(why.failure_count)} failures decomposed · {why.method}
|
| 51 |
+
</Typography>
|
| 52 |
+
{why.factors.map((f) => (
|
| 53 |
+
<HBar
|
| 54 |
+
key={f.factor}
|
| 55 |
+
label={f.factor.replace(/_/g, ' ')}
|
| 56 |
+
value={f.share <= 1 ? f.share * 100 : f.share}
|
| 57 |
+
max={100}
|
| 58 |
+
color="#ffd54f"
|
| 59 |
+
valueLabel={`${fmtPct(f.share)} (${fmtCompact(f.count)})`}
|
| 60 |
+
/>
|
| 61 |
+
))}
|
| 62 |
+
{why.top_cohorts.length ? (
|
| 63 |
+
<>
|
| 64 |
+
<Typography variant="caption" sx={{ color: '#aab4be', display: 'block', mt: 1.5, mb: 0.5 }}>
|
| 65 |
+
Top affected cohorts
|
| 66 |
+
</Typography>
|
| 67 |
+
{why.top_cohorts.map((c) => (
|
| 68 |
+
<HBar
|
| 69 |
+
key={c.cohort}
|
| 70 |
+
label={c.cohort}
|
| 71 |
+
value={c.n}
|
| 72 |
+
max={maxCohortN}
|
| 73 |
+
color="#ef5350"
|
| 74 |
+
valueLabel={`n=${fmtCompact(c.n)}${c.fn !== undefined && c.fn !== null ? ` · fn=${fmtCompact(c.fn)}` : ''}${
|
| 75 |
+
c.fp !== undefined && c.fp !== null ? ` · fp=${fmtCompact(c.fp)}` : ''
|
| 76 |
+
}`}
|
| 77 |
+
/>
|
| 78 |
+
))}
|
| 79 |
+
</>
|
| 80 |
+
) : null}
|
| 81 |
+
</SectionCard>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export default function CohortTab({ runId, refreshKey }: { runId: string; refreshKey: number }) {
|
| 86 |
+
const [crumbs, setCrumbs] = useState<Crumb[]>([]);
|
| 87 |
+
const [dim, setDim] = useState<DimName>('class');
|
| 88 |
+
const [why, setWhy] = useState<WhyResponse | null>(null);
|
| 89 |
+
const [whyBusy, setWhyBusy] = useState(false);
|
| 90 |
+
const [whyError, setWhyError] = useState<string | null>(null);
|
| 91 |
+
|
| 92 |
+
const filters = useMemo(() => crumbsToFilters(crumbs), [crumbs]);
|
| 93 |
+
const filtersKey = JSON.stringify(filters);
|
| 94 |
+
|
| 95 |
+
const query = usePoll(
|
| 96 |
+
() =>
|
| 97 |
+
queryEvaluations({
|
| 98 |
+
evaluation_id: runId,
|
| 99 |
+
filters,
|
| 100 |
+
group_by: [dim],
|
| 101 |
+
metrics: ['n', 'recall', 'precision', 'mean_iou', 'anomaly_rate'],
|
| 102 |
+
}),
|
| 103 |
+
null,
|
| 104 |
+
[runId, refreshKey, dim, filtersKey]
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
const usedDims = new Set(crumbs.map((c) => c.dim));
|
| 108 |
+
|
| 109 |
+
const drillInto = (value: string) => {
|
| 110 |
+
const next = [...crumbs, { dim, value }];
|
| 111 |
+
setCrumbs(next);
|
| 112 |
+
setWhy(null);
|
| 113 |
+
// Auto-advance the group-by dimension to the next unused one.
|
| 114 |
+
const nextUsed = new Set(next.map((c) => c.dim));
|
| 115 |
+
const nextDim = DIM_NAMES.find((d) => !nextUsed.has(d));
|
| 116 |
+
if (nextDim) setDim(nextDim);
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const removeCrumb = (idx: number) => {
|
| 120 |
+
setCrumbs((prev) => prev.filter((_, i) => i !== idx));
|
| 121 |
+
setWhy(null);
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const runWhy = async () => {
|
| 125 |
+
setWhyBusy(true);
|
| 126 |
+
setWhyError(null);
|
| 127 |
+
try {
|
| 128 |
+
setWhy(await whyAnalysis({ run_id: runId, filters, metric: 'recall' }));
|
| 129 |
+
} catch (err) {
|
| 130 |
+
setWhyError(err instanceof Error ? err.message : String(err));
|
| 131 |
+
} finally {
|
| 132 |
+
setWhyBusy(false);
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const rows = useMemo(
|
| 137 |
+
() =>
|
| 138 |
+
query.data
|
| 139 |
+
? // usePoll keeps the previous response while a new query is in flight;
|
| 140 |
+
// drop rows that don't carry the currently-selected dimension so stale
|
| 141 |
+
// groupings never render (and never collide on duplicate row keys).
|
| 142 |
+
query.data.rows
|
| 143 |
+
.filter((r) => typeof r[dim] === 'string')
|
| 144 |
+
.sort((a, b) => b.n - a.n)
|
| 145 |
+
: [],
|
| 146 |
+
[query.data, dim]
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
return (
|
| 150 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 151 |
+
<SectionCard
|
| 152 |
+
title="Cohort explorer"
|
| 153 |
+
action={
|
| 154 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 155 |
+
<QueryBadge meta={query.data?.meta} />
|
| 156 |
+
<Button
|
| 157 |
+
size="small"
|
| 158 |
+
variant="outlined"
|
| 159 |
+
color="warning"
|
| 160 |
+
startIcon={whyBusy ? <CircularProgress size={14} color="inherit" /> : <HelpCircle size={14} />}
|
| 161 |
+
disabled={crumbs.length === 0 || whyBusy}
|
| 162 |
+
onClick={() => void runWhy()}
|
| 163 |
+
>
|
| 164 |
+
Why?
|
| 165 |
+
</Button>
|
| 166 |
+
</Box>
|
| 167 |
+
}
|
| 168 |
+
sx={{ flex: '2 1 560px' }}
|
| 169 |
+
>
|
| 170 |
+
{/* Filter breadcrumb */}
|
| 171 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
| 172 |
+
<Chip
|
| 173 |
+
size="small"
|
| 174 |
+
label="All objects"
|
| 175 |
+
onClick={() => {
|
| 176 |
+
setCrumbs([]);
|
| 177 |
+
setWhy(null);
|
| 178 |
+
}}
|
| 179 |
+
sx={{ height: 22, bgcolor: crumbs.length ? '#232a31' : '#12303f', color: '#81d4fa', fontWeight: 700 }}
|
| 180 |
+
/>
|
| 181 |
+
{crumbs.map((c, i) => (
|
| 182 |
+
<Box key={`${c.dim}-${c.value}-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
| 183 |
+
<ChevronRight size={13} color="#8a949e" />
|
| 184 |
+
<Chip
|
| 185 |
+
size="small"
|
| 186 |
+
label={`${c.dim}: ${c.value}`}
|
| 187 |
+
onDelete={() => removeCrumb(i)}
|
| 188 |
+
sx={{ height: 22, bgcolor: '#12303f', color: '#81d4fa', fontFamily: 'monospace', fontSize: 11 }}
|
| 189 |
+
/>
|
| 190 |
+
</Box>
|
| 191 |
+
))}
|
| 192 |
+
</Box>
|
| 193 |
+
|
| 194 |
+
{/* Dimension tabs */}
|
| 195 |
+
<Tabs
|
| 196 |
+
value={dim}
|
| 197 |
+
onChange={(_, v: DimName) => setDim(v)}
|
| 198 |
+
variant="scrollable"
|
| 199 |
+
scrollButtons="auto"
|
| 200 |
+
sx={{ minHeight: 32, mb: 1, '& .MuiTab-root': { minHeight: 32, py: 0.5, fontSize: 12 } }}
|
| 201 |
+
>
|
| 202 |
+
{DIM_NAMES.map((d) => (
|
| 203 |
+
<Tab key={d} label={d.replace(/_/g, ' ')} value={d} disabled={usedDims.has(d)} />
|
| 204 |
+
))}
|
| 205 |
+
</Tabs>
|
| 206 |
+
|
| 207 |
+
{query.error ? <ErrorNote error={query.error} /> : null}
|
| 208 |
+
{query.loading && !query.data ? <LoadingBox /> : null}
|
| 209 |
+
{query.data ? (
|
| 210 |
+
<Table size="small">
|
| 211 |
+
<TableHead>
|
| 212 |
+
<TableRow>
|
| 213 |
+
<TableCell>{dim.replace(/_/g, ' ')}</TableCell>
|
| 214 |
+
<TableCell align="right">n</TableCell>
|
| 215 |
+
<TableCell>Recall</TableCell>
|
| 216 |
+
<TableCell>Precision</TableCell>
|
| 217 |
+
<TableCell align="right">Mean IoU</TableCell>
|
| 218 |
+
<TableCell align="right">Anomaly rate</TableCell>
|
| 219 |
+
</TableRow>
|
| 220 |
+
</TableHead>
|
| 221 |
+
<TableBody>
|
| 222 |
+
{rows.map((row, i) => {
|
| 223 |
+
const value = rowStr(row, dim);
|
| 224 |
+
return (
|
| 225 |
+
<TableRow
|
| 226 |
+
key={`${value}-${i}`}
|
| 227 |
+
hover
|
| 228 |
+
onClick={() => drillInto(value)}
|
| 229 |
+
sx={{ cursor: 'pointer' }}
|
| 230 |
+
>
|
| 231 |
+
<TableCell sx={{ fontWeight: 600, color: '#4fc3f7' }}>{value}</TableCell>
|
| 232 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 233 |
+
{fmtCompact(row.n)}
|
| 234 |
+
</TableCell>
|
| 235 |
+
<TableCell>
|
| 236 |
+
<PctBarCell value={rowNum(row, 'recall')} color="#66bb6a" />
|
| 237 |
+
</TableCell>
|
| 238 |
+
<TableCell>
|
| 239 |
+
<PctBarCell value={rowNum(row, 'precision')} color="#4fc3f7" />
|
| 240 |
+
</TableCell>
|
| 241 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 242 |
+
{fmtNum(rowNum(row, 'mean_iou'), 3)}
|
| 243 |
+
</TableCell>
|
| 244 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 245 |
+
{fmtPct(rowNum(row, 'anomaly_rate'))}
|
| 246 |
+
</TableCell>
|
| 247 |
+
</TableRow>
|
| 248 |
+
);
|
| 249 |
+
})}
|
| 250 |
+
</TableBody>
|
| 251 |
+
</Table>
|
| 252 |
+
) : null}
|
| 253 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1 }}>
|
| 254 |
+
Click a row to drill into that cohort; the next level groups by the selected dimension tab.
|
| 255 |
+
</Typography>
|
| 256 |
+
</SectionCard>
|
| 257 |
+
|
| 258 |
+
{whyError ? (
|
| 259 |
+
<Box sx={{ flex: '1 1 340px' }}>
|
| 260 |
+
<ErrorNote error={whyError} />
|
| 261 |
+
</Box>
|
| 262 |
+
) : null}
|
| 263 |
+
{why ? <WhyPanel why={why} /> : null}
|
| 264 |
+
</Box>
|
| 265 |
+
);
|
| 266 |
+
}
|
src/components/megaeval/CompareTab.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Model-vs-model comparison: candidate (selected run) against the baseline
|
| 3 |
+
* chosen in the header. Headline deltas, per-class deltas, top regressions,
|
| 4 |
+
* and the PROMOTE / DO_NOT_PROMOTE recommendation with blockers.
|
| 5 |
+
*/
|
| 6 |
+
import Alert from '@mui/material/Alert';
|
| 7 |
+
import AlertTitle from '@mui/material/AlertTitle';
|
| 8 |
+
import Box from '@mui/material/Box';
|
| 9 |
+
import Table from '@mui/material/Table';
|
| 10 |
+
import TableBody from '@mui/material/TableBody';
|
| 11 |
+
import TableCell from '@mui/material/TableCell';
|
| 12 |
+
import TableHead from '@mui/material/TableHead';
|
| 13 |
+
import TableRow from '@mui/material/TableRow';
|
| 14 |
+
import Typography from '@mui/material/Typography';
|
| 15 |
+
import { compareRuns, fmtCompact } from '../../services/megaeval';
|
| 16 |
+
import { usePoll } from '../../services/labeleval';
|
| 17 |
+
import { ErrorNote, LoadingBox, SectionCard, fmtPct } from '../labeleval/shared';
|
| 18 |
+
import { DeltaText } from './shared';
|
| 19 |
+
|
| 20 |
+
/** Metrics where a positive delta is a regression rather than an improvement. */
|
| 21 |
+
const LOWER_IS_BETTER = new Set(['anomaly_rate', 'error_rate', 'fp', 'fn', 'loc_err', 'anomalies']);
|
| 22 |
+
|
| 23 |
+
function fmtMetricValue(v: number): string {
|
| 24 |
+
// Rates arrive as fractions; counts as integers > 1.
|
| 25 |
+
return Math.abs(v) <= 1 ? fmtPct(v) : fmtCompact(v);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export default function CompareTab({
|
| 29 |
+
runId,
|
| 30 |
+
baselineRunId,
|
| 31 |
+
refreshKey,
|
| 32 |
+
}: {
|
| 33 |
+
runId: string;
|
| 34 |
+
baselineRunId: string | null;
|
| 35 |
+
refreshKey: number;
|
| 36 |
+
}) {
|
| 37 |
+
const cmp = usePoll(
|
| 38 |
+
() =>
|
| 39 |
+
baselineRunId
|
| 40 |
+
? compareRuns({ candidate_run_id: runId, baseline_run_id: baselineRunId })
|
| 41 |
+
: Promise.resolve(null),
|
| 42 |
+
null,
|
| 43 |
+
[runId, baselineRunId, refreshKey]
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
if (!baselineRunId) {
|
| 47 |
+
return (
|
| 48 |
+
<Alert severity="info" variant="outlined">
|
| 49 |
+
Select a <strong>baseline run</strong> in the header to compare the current candidate against it.
|
| 50 |
+
</Alert>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
if (cmp.error) return <ErrorNote error={cmp.error} />;
|
| 54 |
+
if (cmp.loading && !cmp.data) return <LoadingBox label="Comparing runs…" />;
|
| 55 |
+
const data = cmp.data;
|
| 56 |
+
if (!data) return null;
|
| 57 |
+
|
| 58 |
+
const promote = data.recommendation === 'PROMOTE';
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
| 62 |
+
<Alert severity={promote ? 'success' : 'error'} variant="filled" sx={{ fontWeight: 700 }}>
|
| 63 |
+
<AlertTitle sx={{ fontWeight: 800 }}>
|
| 64 |
+
{promote ? 'PROMOTE' : 'DO NOT PROMOTE'} — {data.candidate.model_version} vs baseline{' '}
|
| 65 |
+
{data.baseline.model_version}
|
| 66 |
+
</AlertTitle>
|
| 67 |
+
{data.blockers.length ? (
|
| 68 |
+
<Box component="ul" sx={{ m: 0, pl: 2.5 }}>
|
| 69 |
+
{data.blockers.map((b, i) => (
|
| 70 |
+
<li key={i}>
|
| 71 |
+
<Typography variant="body2">{b}</Typography>
|
| 72 |
+
</li>
|
| 73 |
+
))}
|
| 74 |
+
</Box>
|
| 75 |
+
) : (
|
| 76 |
+
<Typography variant="body2">No blockers under the current promotion policy.</Typography>
|
| 77 |
+
)}
|
| 78 |
+
</Alert>
|
| 79 |
+
|
| 80 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 81 |
+
<SectionCard title="Headline deltas" sx={{ flex: '1 1 380px' }}>
|
| 82 |
+
<Table size="small">
|
| 83 |
+
<TableHead>
|
| 84 |
+
<TableRow>
|
| 85 |
+
<TableCell>Metric</TableCell>
|
| 86 |
+
<TableCell align="right">Baseline</TableCell>
|
| 87 |
+
<TableCell align="right">Candidate</TableCell>
|
| 88 |
+
<TableCell align="right">Δ</TableCell>
|
| 89 |
+
</TableRow>
|
| 90 |
+
</TableHead>
|
| 91 |
+
<TableBody>
|
| 92 |
+
{data.headline_deltas.map((d) => (
|
| 93 |
+
<TableRow key={d.metric} hover>
|
| 94 |
+
<TableCell sx={{ fontFamily: 'monospace' }}>{d.metric}</TableCell>
|
| 95 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 96 |
+
{fmtMetricValue(d.baseline)}
|
| 97 |
+
</TableCell>
|
| 98 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 99 |
+
{fmtMetricValue(d.candidate)}
|
| 100 |
+
</TableCell>
|
| 101 |
+
<TableCell align="right">
|
| 102 |
+
<DeltaText
|
| 103 |
+
delta={d.delta}
|
| 104 |
+
higherIsBetter={!LOWER_IS_BETTER.has(d.metric)}
|
| 105 |
+
asPct={Math.abs(d.baseline) <= 1 && Math.abs(d.candidate) <= 1}
|
| 106 |
+
/>
|
| 107 |
+
</TableCell>
|
| 108 |
+
</TableRow>
|
| 109 |
+
))}
|
| 110 |
+
</TableBody>
|
| 111 |
+
</Table>
|
| 112 |
+
</SectionCard>
|
| 113 |
+
|
| 114 |
+
<SectionCard title="Per-class deltas" sx={{ flex: '1 1 480px' }}>
|
| 115 |
+
<Table size="small">
|
| 116 |
+
<TableHead>
|
| 117 |
+
<TableRow>
|
| 118 |
+
<TableCell>Class</TableCell>
|
| 119 |
+
<TableCell align="right">n</TableCell>
|
| 120 |
+
<TableCell align="right">Recall</TableCell>
|
| 121 |
+
<TableCell align="right">Δ recall</TableCell>
|
| 122 |
+
<TableCell align="right">Precision</TableCell>
|
| 123 |
+
<TableCell align="right">Δ precision</TableCell>
|
| 124 |
+
</TableRow>
|
| 125 |
+
</TableHead>
|
| 126 |
+
<TableBody>
|
| 127 |
+
{data.per_class.map((c) => (
|
| 128 |
+
<TableRow key={c.class} hover>
|
| 129 |
+
<TableCell sx={{ fontWeight: 600 }}>{c.class}</TableCell>
|
| 130 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 131 |
+
{fmtCompact(c.n)}
|
| 132 |
+
</TableCell>
|
| 133 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 134 |
+
{fmtPct(c.recall_baseline)} → {fmtPct(c.recall_candidate)}
|
| 135 |
+
</TableCell>
|
| 136 |
+
<TableCell align="right">
|
| 137 |
+
<DeltaText delta={c.recall_delta} />
|
| 138 |
+
</TableCell>
|
| 139 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 140 |
+
{fmtPct(c.precision_baseline)} → {fmtPct(c.precision_candidate)}
|
| 141 |
+
</TableCell>
|
| 142 |
+
<TableCell align="right">
|
| 143 |
+
<DeltaText delta={c.precision_delta} />
|
| 144 |
+
</TableCell>
|
| 145 |
+
</TableRow>
|
| 146 |
+
))}
|
| 147 |
+
</TableBody>
|
| 148 |
+
</Table>
|
| 149 |
+
</SectionCard>
|
| 150 |
+
</Box>
|
| 151 |
+
|
| 152 |
+
<SectionCard title={`Top regressions (${data.regressions.length})`}>
|
| 153 |
+
{data.regressions.length === 0 ? (
|
| 154 |
+
<Typography variant="body2" sx={{ color: '#8a949e' }}>
|
| 155 |
+
No cohort regressions detected against the baseline.
|
| 156 |
+
</Typography>
|
| 157 |
+
) : (
|
| 158 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
| 159 |
+
{data.regressions.map((r) => (
|
| 160 |
+
<Box
|
| 161 |
+
key={r.cohort}
|
| 162 |
+
sx={{
|
| 163 |
+
p: 1,
|
| 164 |
+
border: '1px solid #5c1a1a',
|
| 165 |
+
bgcolor: '#2a1214',
|
| 166 |
+
borderRadius: 1,
|
| 167 |
+
display: 'flex',
|
| 168 |
+
alignItems: 'center',
|
| 169 |
+
gap: 1,
|
| 170 |
+
flexWrap: 'wrap',
|
| 171 |
+
}}
|
| 172 |
+
>
|
| 173 |
+
<Typography variant="body2" sx={{ fontWeight: 700, color: '#ef9a9a' }}>
|
| 174 |
+
🚨 REGRESSION:
|
| 175 |
+
</Typography>
|
| 176 |
+
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
| 177 |
+
{r.cohort} recall {fmtPct(r.recall_baseline)} → {fmtPct(r.recall_candidate)} (Δ{' '}
|
| 178 |
+
<DeltaText delta={r.recall_delta} />)
|
| 179 |
+
</Typography>
|
| 180 |
+
<Typography variant="caption" sx={{ color: '#8a949e', ml: 'auto', fontFamily: 'monospace' }}>
|
| 181 |
+
n={fmtCompact(r.n)}
|
| 182 |
+
</Typography>
|
| 183 |
+
</Box>
|
| 184 |
+
))}
|
| 185 |
+
</Box>
|
| 186 |
+
)}
|
| 187 |
+
</SectionCard>
|
| 188 |
+
|
| 189 |
+
{data.worst_cohorts.length ? (
|
| 190 |
+
<SectionCard title="Worst cohorts (candidate)">
|
| 191 |
+
<Table size="small">
|
| 192 |
+
<TableHead>
|
| 193 |
+
<TableRow>
|
| 194 |
+
<TableCell>Cohort</TableCell>
|
| 195 |
+
<TableCell align="right">n</TableCell>
|
| 196 |
+
<TableCell align="right">Recall (baseline → candidate)</TableCell>
|
| 197 |
+
<TableCell align="right">Δ</TableCell>
|
| 198 |
+
</TableRow>
|
| 199 |
+
</TableHead>
|
| 200 |
+
<TableBody>
|
| 201 |
+
{data.worst_cohorts.map((c) => (
|
| 202 |
+
<TableRow key={c.cohort} hover>
|
| 203 |
+
<TableCell sx={{ fontFamily: 'monospace' }}>{c.cohort}</TableCell>
|
| 204 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 205 |
+
{fmtCompact(c.n)}
|
| 206 |
+
</TableCell>
|
| 207 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 208 |
+
{fmtPct(c.recall_baseline)} → {fmtPct(c.recall_candidate)}
|
| 209 |
+
</TableCell>
|
| 210 |
+
<TableCell align="right">
|
| 211 |
+
<DeltaText delta={c.recall_delta} />
|
| 212 |
+
</TableCell>
|
| 213 |
+
</TableRow>
|
| 214 |
+
))}
|
| 215 |
+
</TableBody>
|
| 216 |
+
</Table>
|
| 217 |
+
</SectionCard>
|
| 218 |
+
) : null}
|
| 219 |
+
</Box>
|
| 220 |
+
);
|
| 221 |
+
}
|
src/components/megaeval/ContainersTab.tsx
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Container explorer ("Quality by Container"): sortable container table with
|
| 3 |
+
* risk presets. Drilling into a container opens the forensic object table —
|
| 4 |
+
* intentionally the deepest level of the app — plus nearest-neighbor
|
| 5 |
+
* "similar containers" retrieval for casting a wider net.
|
| 6 |
+
*/
|
| 7 |
+
import { useState } from 'react';
|
| 8 |
+
import Box from '@mui/material/Box';
|
| 9 |
+
import Button from '@mui/material/Button';
|
| 10 |
+
import CircularProgress from '@mui/material/CircularProgress';
|
| 11 |
+
import MenuItem from '@mui/material/MenuItem';
|
| 12 |
+
import Table from '@mui/material/Table';
|
| 13 |
+
import TableBody from '@mui/material/TableBody';
|
| 14 |
+
import TableCell from '@mui/material/TableCell';
|
| 15 |
+
import TableHead from '@mui/material/TableHead';
|
| 16 |
+
import TableRow from '@mui/material/TableRow';
|
| 17 |
+
import TextField from '@mui/material/TextField';
|
| 18 |
+
import Typography from '@mui/material/Typography';
|
| 19 |
+
import { ArrowLeft, Search, ShieldAlert } from 'lucide-react';
|
| 20 |
+
import type { ContainerSortPreset, SimilarityResponse } from '../../types/megaeval';
|
| 21 |
+
import {
|
| 22 |
+
findSimilarContainers,
|
| 23 |
+
fmtCompact,
|
| 24 |
+
getContainerObjects,
|
| 25 |
+
getRunContainers,
|
| 26 |
+
} from '../../services/megaeval';
|
| 27 |
+
import { usePoll } from '../../services/labeleval';
|
| 28 |
+
import { ErrorNote, LoadingBox, SectionCard, fmtNum, fmtPct } from '../labeleval/shared';
|
| 29 |
+
import { ContainerStatusIcon, DimChips, OutcomeChip } from './shared';
|
| 30 |
+
|
| 31 |
+
const SORT_PRESETS: Array<{ value: ContainerSortPreset; label: string }> = [
|
| 32 |
+
{ value: 'highest_risk', label: 'Highest risk' },
|
| 33 |
+
{ value: 'worst_recall', label: 'Worst recall' },
|
| 34 |
+
{ value: 'worst_precision', label: 'Worst precision' },
|
| 35 |
+
{ value: 'worst_iou', label: 'Worst IoU' },
|
| 36 |
+
{ value: 'most_anomalies', label: 'Most anomalies' },
|
| 37 |
+
{ value: 'least_verified', label: 'Least verified' },
|
| 38 |
+
];
|
| 39 |
+
|
| 40 |
+
// ---------------------------------------------------------------- forensic view
|
| 41 |
+
|
| 42 |
+
function ForensicView({
|
| 43 |
+
runId,
|
| 44 |
+
containerId,
|
| 45 |
+
onBack,
|
| 46 |
+
onDrill,
|
| 47 |
+
}: {
|
| 48 |
+
runId: string;
|
| 49 |
+
containerId: number;
|
| 50 |
+
onBack: () => void;
|
| 51 |
+
onDrill: (cid: number) => void;
|
| 52 |
+
}) {
|
| 53 |
+
const objects = usePoll(() => getContainerObjects(runId, containerId), null, [runId, containerId]);
|
| 54 |
+
const [similar, setSimilar] = useState<SimilarityResponse | null>(null);
|
| 55 |
+
const [simBusy, setSimBusy] = useState(false);
|
| 56 |
+
const [simError, setSimError] = useState<string | null>(null);
|
| 57 |
+
|
| 58 |
+
const findSimilar = async () => {
|
| 59 |
+
setSimBusy(true);
|
| 60 |
+
setSimError(null);
|
| 61 |
+
try {
|
| 62 |
+
setSimilar(await findSimilarContainers({ run_id: runId, container_id: containerId, k: 10 }));
|
| 63 |
+
} catch (err) {
|
| 64 |
+
setSimError(err instanceof Error ? err.message : String(err));
|
| 65 |
+
} finally {
|
| 66 |
+
setSimBusy(false);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 72 |
+
<SectionCard
|
| 73 |
+
title={
|
| 74 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 75 |
+
<Button size="small" startIcon={<ArrowLeft size={14} />} onClick={onBack}>
|
| 76 |
+
Containers
|
| 77 |
+
</Button>
|
| 78 |
+
<span>Forensic object table · container #{containerId}</span>
|
| 79 |
+
</Box>
|
| 80 |
+
}
|
| 81 |
+
action={
|
| 82 |
+
<Button
|
| 83 |
+
size="small"
|
| 84 |
+
variant="outlined"
|
| 85 |
+
startIcon={simBusy ? <CircularProgress size={14} color="inherit" /> : <Search size={14} />}
|
| 86 |
+
disabled={simBusy}
|
| 87 |
+
onClick={() => void findSimilar()}
|
| 88 |
+
>
|
| 89 |
+
Find similar containers
|
| 90 |
+
</Button>
|
| 91 |
+
}
|
| 92 |
+
sx={{ flex: '2 1 620px' }}
|
| 93 |
+
>
|
| 94 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1 }}>
|
| 95 |
+
Deepest drill-down level: individual annotations, loaded on demand for this container only.
|
| 96 |
+
</Typography>
|
| 97 |
+
{objects.error ? <ErrorNote error={objects.error} /> : null}
|
| 98 |
+
{objects.loading && !objects.data ? <LoadingBox /> : null}
|
| 99 |
+
{objects.data ? (
|
| 100 |
+
<Box sx={{ maxHeight: 460, overflowY: 'auto' }}>
|
| 101 |
+
<Table size="small" stickyHeader>
|
| 102 |
+
<TableHead>
|
| 103 |
+
<TableRow>
|
| 104 |
+
<TableCell>Outcome</TableCell>
|
| 105 |
+
<TableCell>Class</TableCell>
|
| 106 |
+
<TableCell align="right">IoU</TableCell>
|
| 107 |
+
<TableCell align="right">Conf</TableCell>
|
| 108 |
+
<TableCell align="center">Anomaly</TableCell>
|
| 109 |
+
<TableCell>Dims</TableCell>
|
| 110 |
+
</TableRow>
|
| 111 |
+
</TableHead>
|
| 112 |
+
<TableBody>
|
| 113 |
+
{objects.data.objects.map((o) => (
|
| 114 |
+
<TableRow key={o.annotation_id} hover>
|
| 115 |
+
<TableCell>
|
| 116 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
| 117 |
+
<OutcomeChip outcome={o.outcome} />
|
| 118 |
+
{o.safety_critical ? <ShieldAlert size={14} color="#ef5350" /> : null}
|
| 119 |
+
</Box>
|
| 120 |
+
</TableCell>
|
| 121 |
+
<TableCell sx={{ fontWeight: 600 }}>{o.class}</TableCell>
|
| 122 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 123 |
+
{fmtNum(o.iou, 3)}
|
| 124 |
+
</TableCell>
|
| 125 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 126 |
+
{fmtNum(o.confidence, 2)}
|
| 127 |
+
</TableCell>
|
| 128 |
+
<TableCell align="center">
|
| 129 |
+
{o.anomaly ? (
|
| 130 |
+
<Typography component="span" sx={{ color: '#ec407a', fontSize: 13 }}>
|
| 131 |
+
●
|
| 132 |
+
</Typography>
|
| 133 |
+
) : (
|
| 134 |
+
<Typography component="span" sx={{ color: '#37474f', fontSize: 13 }}>
|
| 135 |
+
○
|
| 136 |
+
</Typography>
|
| 137 |
+
)}
|
| 138 |
+
</TableCell>
|
| 139 |
+
<TableCell>
|
| 140 |
+
<DimChips
|
| 141 |
+
values={[
|
| 142 |
+
o.weather,
|
| 143 |
+
o.lighting,
|
| 144 |
+
o.scenario,
|
| 145 |
+
o.sensor,
|
| 146 |
+
o.distance_band,
|
| 147 |
+
o.occlusion,
|
| 148 |
+
o.sensor_disagree ? 'sensor-disagree' : undefined,
|
| 149 |
+
]}
|
| 150 |
+
/>
|
| 151 |
+
</TableCell>
|
| 152 |
+
</TableRow>
|
| 153 |
+
))}
|
| 154 |
+
</TableBody>
|
| 155 |
+
</Table>
|
| 156 |
+
</Box>
|
| 157 |
+
) : null}
|
| 158 |
+
</SectionCard>
|
| 159 |
+
|
| 160 |
+
{simError ? (
|
| 161 |
+
<Box sx={{ flex: '1 1 340px' }}>
|
| 162 |
+
<ErrorNote error={simError} />
|
| 163 |
+
</Box>
|
| 164 |
+
) : null}
|
| 165 |
+
{similar ? (
|
| 166 |
+
<SectionCard
|
| 167 |
+
title={`Similar containers${similar.retrieval ? ` · ${similar.retrieval}` : ''}`}
|
| 168 |
+
sx={{ flex: '1 1 380px' }}
|
| 169 |
+
>
|
| 170 |
+
<Table size="small">
|
| 171 |
+
<TableHead>
|
| 172 |
+
<TableRow>
|
| 173 |
+
<TableCell>Container</TableCell>
|
| 174 |
+
<TableCell align="right">Similarity</TableCell>
|
| 175 |
+
<TableCell>Context</TableCell>
|
| 176 |
+
<TableCell align="right">FN/FP</TableCell>
|
| 177 |
+
<TableCell align="right">Risk</TableCell>
|
| 178 |
+
</TableRow>
|
| 179 |
+
</TableHead>
|
| 180 |
+
<TableBody>
|
| 181 |
+
{similar.results.map((s) => (
|
| 182 |
+
<TableRow key={s.container_id} hover onClick={() => onDrill(s.container_id)} sx={{ cursor: 'pointer' }}>
|
| 183 |
+
<TableCell sx={{ fontFamily: 'monospace', color: '#4fc3f7' }}>#{s.container_id}</TableCell>
|
| 184 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 185 |
+
{fmtPct(s.similarity)}
|
| 186 |
+
</TableCell>
|
| 187 |
+
<TableCell>
|
| 188 |
+
<DimChips values={[s.scenario, s.lighting, s.weather]} />
|
| 189 |
+
</TableCell>
|
| 190 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 191 |
+
{s.fn}/{s.fp}
|
| 192 |
+
</TableCell>
|
| 193 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 194 |
+
{fmtNum(s.risk_score, 2)}
|
| 195 |
+
</TableCell>
|
| 196 |
+
</TableRow>
|
| 197 |
+
))}
|
| 198 |
+
</TableBody>
|
| 199 |
+
</Table>
|
| 200 |
+
</SectionCard>
|
| 201 |
+
) : null}
|
| 202 |
+
</Box>
|
| 203 |
+
);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// ---------------------------------------------------------------- containers table
|
| 207 |
+
|
| 208 |
+
export default function ContainersTab({
|
| 209 |
+
runId,
|
| 210 |
+
refreshKey,
|
| 211 |
+
drillContainerId,
|
| 212 |
+
onDrill,
|
| 213 |
+
}: {
|
| 214 |
+
runId: string;
|
| 215 |
+
refreshKey: number;
|
| 216 |
+
drillContainerId: number | null;
|
| 217 |
+
onDrill: (cid: number | null) => void;
|
| 218 |
+
}) {
|
| 219 |
+
const [sort, setSort] = useState<ContainerSortPreset>('highest_risk');
|
| 220 |
+
const containers = usePoll(() => getRunContainers(runId, sort, 50), null, [runId, sort, refreshKey]);
|
| 221 |
+
|
| 222 |
+
if (drillContainerId !== null) {
|
| 223 |
+
return (
|
| 224 |
+
<ForensicView
|
| 225 |
+
runId={runId}
|
| 226 |
+
containerId={drillContainerId}
|
| 227 |
+
onBack={() => onDrill(null)}
|
| 228 |
+
onDrill={(cid) => onDrill(cid)}
|
| 229 |
+
/>
|
| 230 |
+
);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
return (
|
| 234 |
+
<SectionCard
|
| 235 |
+
title="Quality by container"
|
| 236 |
+
action={
|
| 237 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 238 |
+
{containers.data ? (
|
| 239 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 240 |
+
{fmtCompact(containers.data.total)} containers (exact) · showing top {containers.data.rows.length}
|
| 241 |
+
</Typography>
|
| 242 |
+
) : null}
|
| 243 |
+
<TextField
|
| 244 |
+
select
|
| 245 |
+
size="small"
|
| 246 |
+
label="Sort"
|
| 247 |
+
value={sort}
|
| 248 |
+
onChange={(e) => setSort(e.target.value as ContainerSortPreset)}
|
| 249 |
+
sx={{ minWidth: 180 }}
|
| 250 |
+
>
|
| 251 |
+
{SORT_PRESETS.map((p) => (
|
| 252 |
+
<MenuItem key={p.value} value={p.value}>
|
| 253 |
+
{p.label}
|
| 254 |
+
</MenuItem>
|
| 255 |
+
))}
|
| 256 |
+
</TextField>
|
| 257 |
+
</Box>
|
| 258 |
+
}
|
| 259 |
+
>
|
| 260 |
+
{containers.error ? <ErrorNote error={containers.error} /> : null}
|
| 261 |
+
{containers.loading && !containers.data ? <LoadingBox /> : null}
|
| 262 |
+
{containers.data ? (
|
| 263 |
+
<Box sx={{ maxHeight: 520, overflowY: 'auto' }}>
|
| 264 |
+
<Table size="small" stickyHeader>
|
| 265 |
+
<TableHead>
|
| 266 |
+
<TableRow>
|
| 267 |
+
<TableCell />
|
| 268 |
+
<TableCell>Container</TableCell>
|
| 269 |
+
<TableCell>Context</TableCell>
|
| 270 |
+
<TableCell align="right">Objects</TableCell>
|
| 271 |
+
<TableCell align="right">Verified</TableCell>
|
| 272 |
+
<TableCell align="right">Precision</TableCell>
|
| 273 |
+
<TableCell align="right">Recall</TableCell>
|
| 274 |
+
<TableCell align="right">Mean IoU</TableCell>
|
| 275 |
+
<TableCell align="right">Anomalies</TableCell>
|
| 276 |
+
<TableCell align="right">Risk</TableCell>
|
| 277 |
+
</TableRow>
|
| 278 |
+
</TableHead>
|
| 279 |
+
<TableBody>
|
| 280 |
+
{containers.data.rows.map((c) => (
|
| 281 |
+
<TableRow key={c.container_id} hover onClick={() => onDrill(c.container_id)} sx={{ cursor: 'pointer' }}>
|
| 282 |
+
<TableCell sx={{ width: 30 }}>
|
| 283 |
+
<ContainerStatusIcon status={c.status} />
|
| 284 |
+
</TableCell>
|
| 285 |
+
<TableCell sx={{ fontFamily: 'monospace', color: '#4fc3f7' }}>#{c.container_id}</TableCell>
|
| 286 |
+
<TableCell>
|
| 287 |
+
<DimChips values={[c.scenario, c.lighting, c.weather, c.road_type]} />
|
| 288 |
+
</TableCell>
|
| 289 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 290 |
+
{fmtCompact(c.n_objects)}
|
| 291 |
+
</TableCell>
|
| 292 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 293 |
+
{c.verified}
|
| 294 |
+
</TableCell>
|
| 295 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 296 |
+
{fmtPct(c.precision)}
|
| 297 |
+
</TableCell>
|
| 298 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 299 |
+
{fmtPct(c.recall)}
|
| 300 |
+
</TableCell>
|
| 301 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 302 |
+
{fmtNum(c.mean_iou, 3)}
|
| 303 |
+
</TableCell>
|
| 304 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace', color: c.anomalies > 0 ? '#ec407a' : undefined }}>
|
| 305 |
+
{c.anomalies}
|
| 306 |
+
</TableCell>
|
| 307 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 308 |
+
{fmtNum(c.risk_score, 2)}
|
| 309 |
+
</TableCell>
|
| 310 |
+
</TableRow>
|
| 311 |
+
))}
|
| 312 |
+
</TableBody>
|
| 313 |
+
</Table>
|
| 314 |
+
</Box>
|
| 315 |
+
) : null}
|
| 316 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1 }}>
|
| 317 |
+
Click a container to open its forensic object table (deepest drill-down).
|
| 318 |
+
</Typography>
|
| 319 |
+
</SectionCard>
|
| 320 |
+
);
|
| 321 |
+
}
|
src/components/megaeval/HeaderBar.tsx
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Command Center header: population + run + baseline selectors, run status,
|
| 3 |
+
* cache stats chip, and the two entry-point actions (generate population,
|
| 4 |
+
* launch evaluation run). The action buttons are exported so the page-level
|
| 5 |
+
* empty state can reuse them front and center.
|
| 6 |
+
*/
|
| 7 |
+
import { useMemo, useState } from 'react';
|
| 8 |
+
import Alert from '@mui/material/Alert';
|
| 9 |
+
import Box from '@mui/material/Box';
|
| 10 |
+
import Button from '@mui/material/Button';
|
| 11 |
+
import Chip from '@mui/material/Chip';
|
| 12 |
+
import CircularProgress from '@mui/material/CircularProgress';
|
| 13 |
+
import Dialog from '@mui/material/Dialog';
|
| 14 |
+
import DialogActions from '@mui/material/DialogActions';
|
| 15 |
+
import DialogContent from '@mui/material/DialogContent';
|
| 16 |
+
import DialogTitle from '@mui/material/DialogTitle';
|
| 17 |
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
| 18 |
+
import MenuItem from '@mui/material/MenuItem';
|
| 19 |
+
import Switch from '@mui/material/Switch';
|
| 20 |
+
import TextField from '@mui/material/TextField';
|
| 21 |
+
import Tooltip from '@mui/material/Tooltip';
|
| 22 |
+
import Typography from '@mui/material/Typography';
|
| 23 |
+
import { Database, FlaskConical } from 'lucide-react';
|
| 24 |
+
import type { CacheStats, EvaluationRunInfo, PopulationMeta } from '../../types/megaeval';
|
| 25 |
+
import { createRun, fmtCompact, generatePopulation } from '../../services/megaeval';
|
| 26 |
+
import { fmtPct } from '../labeleval/shared';
|
| 27 |
+
import { RunStatusChip } from './shared';
|
| 28 |
+
|
| 29 |
+
// ---------------------------------------------------------------- action buttons
|
| 30 |
+
|
| 31 |
+
export function GeneratePopulationButton({
|
| 32 |
+
onGenerated,
|
| 33 |
+
variant = 'outlined',
|
| 34 |
+
size = 'small',
|
| 35 |
+
}: {
|
| 36 |
+
onGenerated: (pop: PopulationMeta) => void;
|
| 37 |
+
variant?: 'outlined' | 'contained';
|
| 38 |
+
size?: 'small' | 'medium' | 'large';
|
| 39 |
+
}) {
|
| 40 |
+
const [open, setOpen] = useState(false);
|
| 41 |
+
const [numObjects, setNumObjects] = useState('320000');
|
| 42 |
+
const [name, setName] = useState('');
|
| 43 |
+
const [busy, setBusy] = useState(false);
|
| 44 |
+
const [error, setError] = useState<string | null>(null);
|
| 45 |
+
|
| 46 |
+
const submit = async () => {
|
| 47 |
+
setBusy(true);
|
| 48 |
+
setError(null);
|
| 49 |
+
try {
|
| 50 |
+
const parsed = Number(numObjects);
|
| 51 |
+
const pop = await generatePopulation({
|
| 52 |
+
...(name.trim() ? { name: name.trim() } : {}),
|
| 53 |
+
num_objects: Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 320000,
|
| 54 |
+
});
|
| 55 |
+
setOpen(false);
|
| 56 |
+
onGenerated(pop);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 59 |
+
} finally {
|
| 60 |
+
setBusy(false);
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<>
|
| 66 |
+
<Button variant={variant} size={size} startIcon={<Database size={16} />} onClick={() => setOpen(true)}>
|
| 67 |
+
Generate population
|
| 68 |
+
</Button>
|
| 69 |
+
<Dialog open={open} onClose={() => (busy ? undefined : setOpen(false))} maxWidth="xs" fullWidth>
|
| 70 |
+
<DialogTitle>Generate evaluation population</DialogTitle>
|
| 71 |
+
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '12px !important' }}>
|
| 72 |
+
{error ? (
|
| 73 |
+
<Alert severity="error" variant="outlined">
|
| 74 |
+
{error}
|
| 75 |
+
</Alert>
|
| 76 |
+
) : null}
|
| 77 |
+
<TextField
|
| 78 |
+
label="Name (optional)"
|
| 79 |
+
size="small"
|
| 80 |
+
value={name}
|
| 81 |
+
onChange={(e) => setName(e.target.value)}
|
| 82 |
+
placeholder="e.g. q3-highway-mix"
|
| 83 |
+
/>
|
| 84 |
+
<TextField
|
| 85 |
+
label="Objects"
|
| 86 |
+
size="small"
|
| 87 |
+
type="number"
|
| 88 |
+
value={numObjects}
|
| 89 |
+
onChange={(e) => setNumObjects(e.target.value)}
|
| 90 |
+
helperText="1K – 1.2M synthetic annotated objects (default 320K)"
|
| 91 |
+
/>
|
| 92 |
+
</DialogContent>
|
| 93 |
+
<DialogActions>
|
| 94 |
+
<Button onClick={() => setOpen(false)} disabled={busy}>
|
| 95 |
+
Cancel
|
| 96 |
+
</Button>
|
| 97 |
+
<Button
|
| 98 |
+
variant="contained"
|
| 99 |
+
onClick={() => void submit()}
|
| 100 |
+
disabled={busy}
|
| 101 |
+
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <Database size={16} />}
|
| 102 |
+
>
|
| 103 |
+
{busy ? 'Generating…' : 'Generate'}
|
| 104 |
+
</Button>
|
| 105 |
+
</DialogActions>
|
| 106 |
+
</Dialog>
|
| 107 |
+
</>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export function NewRunButton({
|
| 112 |
+
populationId,
|
| 113 |
+
onCreated,
|
| 114 |
+
variant = 'contained',
|
| 115 |
+
size = 'small',
|
| 116 |
+
}: {
|
| 117 |
+
populationId: string | null;
|
| 118 |
+
onCreated: (run: EvaluationRunInfo) => void;
|
| 119 |
+
variant?: 'outlined' | 'contained';
|
| 120 |
+
size?: 'small' | 'medium' | 'large';
|
| 121 |
+
}) {
|
| 122 |
+
const [open, setOpen] = useState(false);
|
| 123 |
+
const [modelVersion, setModelVersion] = useState('perception-v2.1');
|
| 124 |
+
const [injectRegression, setInjectRegression] = useState(false);
|
| 125 |
+
const [busy, setBusy] = useState(false);
|
| 126 |
+
const [error, setError] = useState<string | null>(null);
|
| 127 |
+
|
| 128 |
+
const submit = async () => {
|
| 129 |
+
if (!populationId) return;
|
| 130 |
+
setBusy(true);
|
| 131 |
+
setError(null);
|
| 132 |
+
try {
|
| 133 |
+
const run = await createRun({
|
| 134 |
+
population_id: populationId,
|
| 135 |
+
model_version: modelVersion.trim() || 'perception-v2.1',
|
| 136 |
+
...(injectRegression ? { overrides: { night_penalty: 0.3, vru_penalty: 0.1 } } : {}),
|
| 137 |
+
});
|
| 138 |
+
setOpen(false);
|
| 139 |
+
onCreated(run);
|
| 140 |
+
} catch (err) {
|
| 141 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 142 |
+
} finally {
|
| 143 |
+
setBusy(false);
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
<>
|
| 149 |
+
<Tooltip title={populationId ? '' : 'Generate a population first'}>
|
| 150 |
+
<span>
|
| 151 |
+
<Button
|
| 152 |
+
variant={variant}
|
| 153 |
+
size={size}
|
| 154 |
+
startIcon={<FlaskConical size={16} />}
|
| 155 |
+
disabled={!populationId}
|
| 156 |
+
onClick={() => setOpen(true)}
|
| 157 |
+
>
|
| 158 |
+
New evaluation run
|
| 159 |
+
</Button>
|
| 160 |
+
</span>
|
| 161 |
+
</Tooltip>
|
| 162 |
+
<Dialog open={open} onClose={() => (busy ? undefined : setOpen(false))} maxWidth="xs" fullWidth>
|
| 163 |
+
<DialogTitle>Launch evaluation run</DialogTitle>
|
| 164 |
+
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '12px !important' }}>
|
| 165 |
+
{error ? (
|
| 166 |
+
<Alert severity="error" variant="outlined">
|
| 167 |
+
{error}
|
| 168 |
+
</Alert>
|
| 169 |
+
) : null}
|
| 170 |
+
<TextField
|
| 171 |
+
label="Model version"
|
| 172 |
+
size="small"
|
| 173 |
+
value={modelVersion}
|
| 174 |
+
onChange={(e) => setModelVersion(e.target.value)}
|
| 175 |
+
/>
|
| 176 |
+
<FormControlLabel
|
| 177 |
+
control={
|
| 178 |
+
<Switch checked={injectRegression} onChange={(e) => setInjectRegression(e.target.checked)} />
|
| 179 |
+
}
|
| 180 |
+
label={
|
| 181 |
+
<Box>
|
| 182 |
+
<Typography variant="body2">Inject regression (demo)</Typography>
|
| 183 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 184 |
+
Applies overrides night_penalty=0.30, vru_penalty=0.10
|
| 185 |
+
</Typography>
|
| 186 |
+
</Box>
|
| 187 |
+
}
|
| 188 |
+
/>
|
| 189 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 190 |
+
Runs are asynchronous: the run is queued, evaluated across distributed workers, then published.
|
| 191 |
+
</Typography>
|
| 192 |
+
</DialogContent>
|
| 193 |
+
<DialogActions>
|
| 194 |
+
<Button onClick={() => setOpen(false)} disabled={busy}>
|
| 195 |
+
Cancel
|
| 196 |
+
</Button>
|
| 197 |
+
<Button
|
| 198 |
+
variant="contained"
|
| 199 |
+
onClick={() => void submit()}
|
| 200 |
+
disabled={busy || !populationId}
|
| 201 |
+
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <FlaskConical size={16} />}
|
| 202 |
+
>
|
| 203 |
+
{busy ? 'Queueing…' : 'Launch run'}
|
| 204 |
+
</Button>
|
| 205 |
+
</DialogActions>
|
| 206 |
+
</Dialog>
|
| 207 |
+
</>
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// ---------------------------------------------------------------- header bar
|
| 212 |
+
|
| 213 |
+
export default function HeaderBar({
|
| 214 |
+
populations,
|
| 215 |
+
populationId,
|
| 216 |
+
onPopulationChange,
|
| 217 |
+
runs,
|
| 218 |
+
runId,
|
| 219 |
+
onRunChange,
|
| 220 |
+
baselineRunId,
|
| 221 |
+
onBaselineChange,
|
| 222 |
+
selectedRun,
|
| 223 |
+
cache,
|
| 224 |
+
onGenerated,
|
| 225 |
+
onRunCreated,
|
| 226 |
+
}: {
|
| 227 |
+
populations: PopulationMeta[];
|
| 228 |
+
populationId: string | null;
|
| 229 |
+
onPopulationChange: (id: string) => void;
|
| 230 |
+
runs: EvaluationRunInfo[];
|
| 231 |
+
runId: string | null;
|
| 232 |
+
onRunChange: (id: string) => void;
|
| 233 |
+
baselineRunId: string | null;
|
| 234 |
+
onBaselineChange: (id: string | null) => void;
|
| 235 |
+
selectedRun: EvaluationRunInfo | null;
|
| 236 |
+
cache: CacheStats | null;
|
| 237 |
+
onGenerated: (pop: PopulationMeta) => void;
|
| 238 |
+
onRunCreated: (run: EvaluationRunInfo) => void;
|
| 239 |
+
}) {
|
| 240 |
+
const sortedRuns = useMemo(
|
| 241 |
+
() => [...runs].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '')),
|
| 242 |
+
[runs]
|
| 243 |
+
);
|
| 244 |
+
const publishedRuns = useMemo(() => sortedRuns.filter((r) => r.status === 'published'), [sortedRuns]);
|
| 245 |
+
|
| 246 |
+
const runLabel = (r: EvaluationRunInfo) =>
|
| 247 |
+
`${r.model_version} · ${r.run_id.slice(0, 8)}${r.status === 'published' ? '' : ` (${r.status})`}`;
|
| 248 |
+
|
| 249 |
+
return (
|
| 250 |
+
<Box
|
| 251 |
+
sx={{
|
| 252 |
+
display: 'flex',
|
| 253 |
+
alignItems: 'center',
|
| 254 |
+
gap: 1.25,
|
| 255 |
+
flexWrap: 'wrap',
|
| 256 |
+
p: 1.5,
|
| 257 |
+
bgcolor: '#161b21',
|
| 258 |
+
border: '1px solid #232a31',
|
| 259 |
+
borderRadius: 1,
|
| 260 |
+
}}
|
| 261 |
+
>
|
| 262 |
+
<TextField
|
| 263 |
+
select
|
| 264 |
+
size="small"
|
| 265 |
+
label="Population"
|
| 266 |
+
value={populationId ?? ''}
|
| 267 |
+
onChange={(e) => onPopulationChange(e.target.value)}
|
| 268 |
+
sx={{ minWidth: 220 }}
|
| 269 |
+
disabled={populations.length === 0}
|
| 270 |
+
>
|
| 271 |
+
{populations.map((p) => (
|
| 272 |
+
<MenuItem key={p.population_id} value={p.population_id}>
|
| 273 |
+
{p.name} · {fmtCompact(p.num_objects)} objects
|
| 274 |
+
</MenuItem>
|
| 275 |
+
))}
|
| 276 |
+
</TextField>
|
| 277 |
+
|
| 278 |
+
<TextField
|
| 279 |
+
select
|
| 280 |
+
size="small"
|
| 281 |
+
label="Evaluation run"
|
| 282 |
+
value={runId && sortedRuns.some((r) => r.run_id === runId) ? runId : ''}
|
| 283 |
+
onChange={(e) => onRunChange(e.target.value)}
|
| 284 |
+
sx={{ minWidth: 240 }}
|
| 285 |
+
disabled={sortedRuns.length === 0}
|
| 286 |
+
>
|
| 287 |
+
{sortedRuns.map((r) => (
|
| 288 |
+
<MenuItem key={r.run_id} value={r.run_id}>
|
| 289 |
+
{runLabel(r)}
|
| 290 |
+
</MenuItem>
|
| 291 |
+
))}
|
| 292 |
+
</TextField>
|
| 293 |
+
|
| 294 |
+
<TextField
|
| 295 |
+
select
|
| 296 |
+
size="small"
|
| 297 |
+
label="Baseline (compare)"
|
| 298 |
+
value={
|
| 299 |
+
baselineRunId && publishedRuns.some((r) => r.run_id === baselineRunId) ? baselineRunId : ''
|
| 300 |
+
}
|
| 301 |
+
onChange={(e) => onBaselineChange(e.target.value === '' ? null : e.target.value)}
|
| 302 |
+
sx={{ minWidth: 220 }}
|
| 303 |
+
disabled={publishedRuns.length === 0}
|
| 304 |
+
>
|
| 305 |
+
<MenuItem value="">
|
| 306 |
+
<em>None</em>
|
| 307 |
+
</MenuItem>
|
| 308 |
+
{publishedRuns
|
| 309 |
+
.filter((r) => r.run_id !== runId)
|
| 310 |
+
.map((r) => (
|
| 311 |
+
<MenuItem key={r.run_id} value={r.run_id}>
|
| 312 |
+
{runLabel(r)}
|
| 313 |
+
</MenuItem>
|
| 314 |
+
))}
|
| 315 |
+
</TextField>
|
| 316 |
+
|
| 317 |
+
{selectedRun ? <RunStatusChip status={selectedRun.status} /> : null}
|
| 318 |
+
|
| 319 |
+
<Box sx={{ flex: 1 }} />
|
| 320 |
+
|
| 321 |
+
{cache ? (
|
| 322 |
+
<Tooltip title={`Aggregate cache: ${cache.hits} hits / ${cache.misses} misses`}>
|
| 323 |
+
<Chip
|
| 324 |
+
size="small"
|
| 325 |
+
label={`cache ${cache.entries} · hit ${cache.hit_rate === null ? '—' : fmtPct(cache.hit_rate)}`}
|
| 326 |
+
sx={{ height: 20, fontSize: 10.5, fontFamily: 'monospace', bgcolor: '#232a31', color: '#aab4be' }}
|
| 327 |
+
/>
|
| 328 |
+
</Tooltip>
|
| 329 |
+
) : null}
|
| 330 |
+
|
| 331 |
+
<GeneratePopulationButton onGenerated={onGenerated} />
|
| 332 |
+
<NewRunButton populationId={populationId} onCreated={onRunCreated} />
|
| 333 |
+
</Box>
|
| 334 |
+
);
|
| 335 |
+
}
|
src/components/megaeval/HeroRow.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hero aggregates for a published run: population-scale counts followed by the
|
| 3 |
+
* headline quality metrics. Precision/recall are annotated with sampling-verified
|
| 4 |
+
* confidence intervals when review results exist.
|
| 5 |
+
*/
|
| 6 |
+
import Box from '@mui/material/Box';
|
| 7 |
+
import Chip from '@mui/material/Chip';
|
| 8 |
+
import type { EvaluationRunInfo, MetricEstimate, ReviewState } from '../../types/megaeval';
|
| 9 |
+
import { fmtCompact, queryEvaluations } from '../../services/megaeval';
|
| 10 |
+
import { usePoll } from '../../services/labeleval';
|
| 11 |
+
import { MetricCard, fmtNum, fmtPct } from '../labeleval/shared';
|
| 12 |
+
import { QueryBadge, rowNum } from './shared';
|
| 13 |
+
|
| 14 |
+
function ciSub(est: MetricEstimate | undefined): string | null {
|
| 15 |
+
if (!est) return null;
|
| 16 |
+
return `95% CI ${fmtPct(est.ci_low)}–${fmtPct(est.ci_high)}, n=${est.n_reviewed.toLocaleString()} reviewed`;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const VERIFIED_CHIP = (
|
| 20 |
+
<Chip
|
| 21 |
+
size="small"
|
| 22 |
+
label="sampling-verified"
|
| 23 |
+
sx={{ height: 16, fontSize: 9, fontWeight: 700, bgcolor: '#12303f', color: '#81d4fa', ml: 0.5 }}
|
| 24 |
+
/>
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
export default function HeroRow({
|
| 28 |
+
run,
|
| 29 |
+
review,
|
| 30 |
+
refreshKey,
|
| 31 |
+
}: {
|
| 32 |
+
run: EvaluationRunInfo;
|
| 33 |
+
review: ReviewState | null;
|
| 34 |
+
refreshKey: number;
|
| 35 |
+
}) {
|
| 36 |
+
const h = run.headline;
|
| 37 |
+
// Reviewed / verified totals come from the aggregate query API (also shows provenance).
|
| 38 |
+
const totals = usePoll(
|
| 39 |
+
() =>
|
| 40 |
+
queryEvaluations({
|
| 41 |
+
evaluation_id: run.run_id,
|
| 42 |
+
metrics: ['n', 'reviewed', 'verified', 'review_coverage'],
|
| 43 |
+
}),
|
| 44 |
+
null,
|
| 45 |
+
[run.run_id, refreshKey]
|
| 46 |
+
);
|
| 47 |
+
const totalsRow = totals.data?.rows[0];
|
| 48 |
+
const reviewed = totalsRow ? rowNum(totalsRow, 'reviewed') : null;
|
| 49 |
+
const verified = totalsRow ? rowNum(totalsRow, 'verified') : null;
|
| 50 |
+
|
| 51 |
+
const results = review?.results ?? null;
|
| 52 |
+
const precisionCi = ciSub(results?.precision);
|
| 53 |
+
const recallCi = ciSub(results?.recall);
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
| 57 |
+
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
| 58 |
+
<MetricCard label="Objects evaluated" value={fmtCompact(h.n)} sub="exact (cube)" />
|
| 59 |
+
<MetricCard
|
| 60 |
+
label="Containers"
|
| 61 |
+
value={fmtCompact(h.containers)}
|
| 62 |
+
sub={h.containers_hll_estimate !== undefined ? `HLL est ${fmtCompact(h.containers_hll_estimate)} (approx)` : 'exact'}
|
| 63 |
+
/>
|
| 64 |
+
<MetricCard
|
| 65 |
+
label="Reviewed"
|
| 66 |
+
value={fmtCompact(reviewed)}
|
| 67 |
+
sub={
|
| 68 |
+
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
| 69 |
+
<span>human reviews</span>
|
| 70 |
+
<QueryBadge meta={totals.data?.meta} />
|
| 71 |
+
</Box>
|
| 72 |
+
}
|
| 73 |
+
accent="#42a5f5"
|
| 74 |
+
/>
|
| 75 |
+
<MetricCard label="Verified" value={fmtCompact(verified)} accent="#66bb6a" sub="human-verified" />
|
| 76 |
+
</Box>
|
| 77 |
+
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
| 78 |
+
<MetricCard
|
| 79 |
+
label="Precision"
|
| 80 |
+
value={fmtPct(h.precision)}
|
| 81 |
+
sub={
|
| 82 |
+
precisionCi ? (
|
| 83 |
+
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap' }}>
|
| 84 |
+
<span>
|
| 85 |
+
{fmtPct(results?.precision.estimate)} ({precisionCi})
|
| 86 |
+
</span>
|
| 87 |
+
{VERIFIED_CHIP}
|
| 88 |
+
</Box>
|
| 89 |
+
) : (
|
| 90 |
+
'point estimate'
|
| 91 |
+
)
|
| 92 |
+
}
|
| 93 |
+
/>
|
| 94 |
+
<MetricCard
|
| 95 |
+
label="Recall"
|
| 96 |
+
value={fmtPct(h.recall)}
|
| 97 |
+
sub={
|
| 98 |
+
recallCi ? (
|
| 99 |
+
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap' }}>
|
| 100 |
+
<span>
|
| 101 |
+
{fmtPct(results?.recall.estimate)} ({recallCi})
|
| 102 |
+
</span>
|
| 103 |
+
{VERIFIED_CHIP}
|
| 104 |
+
</Box>
|
| 105 |
+
) : (
|
| 106 |
+
'point estimate'
|
| 107 |
+
)
|
| 108 |
+
}
|
| 109 |
+
/>
|
| 110 |
+
<MetricCard label="F1" value={fmtPct(h.f1)} />
|
| 111 |
+
<MetricCard label="Mean IoU" value={fmtNum(h.mean_iou, 3)} />
|
| 112 |
+
<MetricCard label="Anomaly rate" value={fmtPct(h.anomaly_rate)} accent="#ffa726" />
|
| 113 |
+
<MetricCard label="Safety recall" value={fmtPct(h.safety_recall)} accent="#ef5350" />
|
| 114 |
+
</Box>
|
| 115 |
+
</Box>
|
| 116 |
+
);
|
| 117 |
+
}
|
src/components/megaeval/InvestigationTab.tsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Investigation panel: multi-criteria error search against the inverted error
|
| 3 |
+
* index. Results stay aggregate-first (counts, by-type shares, worst
|
| 4 |
+
* containers) — clicking a container jumps to the forensic drill-down.
|
| 5 |
+
*/
|
| 6 |
+
import { useState } from 'react';
|
| 7 |
+
import Box from '@mui/material/Box';
|
| 8 |
+
import Button from '@mui/material/Button';
|
| 9 |
+
import Checkbox from '@mui/material/Checkbox';
|
| 10 |
+
import Chip from '@mui/material/Chip';
|
| 11 |
+
import CircularProgress from '@mui/material/CircularProgress';
|
| 12 |
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
| 13 |
+
import MenuItem from '@mui/material/MenuItem';
|
| 14 |
+
import Table from '@mui/material/Table';
|
| 15 |
+
import TableBody from '@mui/material/TableBody';
|
| 16 |
+
import TableCell from '@mui/material/TableCell';
|
| 17 |
+
import TableHead from '@mui/material/TableHead';
|
| 18 |
+
import TableRow from '@mui/material/TableRow';
|
| 19 |
+
import TextField from '@mui/material/TextField';
|
| 20 |
+
import Typography from '@mui/material/Typography';
|
| 21 |
+
import { SearchCode, ShieldAlert } from 'lucide-react';
|
| 22 |
+
import type { DimName, ErrorSearchResponse, ErrorType } from '../../types/megaeval';
|
| 23 |
+
import { fmtCompact, getDimensions, searchErrors } from '../../services/megaeval';
|
| 24 |
+
import { usePoll } from '../../services/labeleval';
|
| 25 |
+
import { ErrorNote, MetricCard, SectionCard, fmtNum } from '../labeleval/shared';
|
| 26 |
+
import { DimChips, ERROR_TYPE_COLORS, OutcomeChip } from './shared';
|
| 27 |
+
|
| 28 |
+
const ALL_ERROR_TYPES: ErrorType[] = ['FN', 'FP', 'LOCALIZATION', 'ANOMALY', 'LOW_CONF'];
|
| 29 |
+
|
| 30 |
+
function MultiSelect({
|
| 31 |
+
label,
|
| 32 |
+
options,
|
| 33 |
+
value,
|
| 34 |
+
onChange,
|
| 35 |
+
minWidth = 170,
|
| 36 |
+
}: {
|
| 37 |
+
label: string;
|
| 38 |
+
options: string[];
|
| 39 |
+
value: string[];
|
| 40 |
+
onChange: (v: string[]) => void;
|
| 41 |
+
minWidth?: number;
|
| 42 |
+
}) {
|
| 43 |
+
return (
|
| 44 |
+
<TextField
|
| 45 |
+
select
|
| 46 |
+
size="small"
|
| 47 |
+
label={label}
|
| 48 |
+
value={value}
|
| 49 |
+
onChange={(e) => {
|
| 50 |
+
const v = e.target.value as unknown;
|
| 51 |
+
onChange(typeof v === 'string' ? v.split(',').filter(Boolean) : (v as string[]));
|
| 52 |
+
}}
|
| 53 |
+
SelectProps={{ multiple: true }}
|
| 54 |
+
sx={{ minWidth }}
|
| 55 |
+
>
|
| 56 |
+
{options.map((o) => (
|
| 57 |
+
<MenuItem key={o} value={o}>
|
| 58 |
+
{o}
|
| 59 |
+
</MenuItem>
|
| 60 |
+
))}
|
| 61 |
+
</TextField>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export default function InvestigationTab({
|
| 66 |
+
runId,
|
| 67 |
+
onOpenContainer,
|
| 68 |
+
}: {
|
| 69 |
+
runId: string;
|
| 70 |
+
onOpenContainer: (cid: number) => void;
|
| 71 |
+
}) {
|
| 72 |
+
const dims = usePoll(getDimensions, null, []);
|
| 73 |
+
const dimOptions = (d: DimName): string[] => dims.data?.dimensions[d] ?? [];
|
| 74 |
+
|
| 75 |
+
const [errorTypes, setErrorTypes] = useState<string[]>([]);
|
| 76 |
+
const [classes, setClasses] = useState<string[]>([]);
|
| 77 |
+
const [lightings, setLightings] = useState<string[]>([]);
|
| 78 |
+
const [weathers, setWeathers] = useState<string[]>([]);
|
| 79 |
+
const [confMax, setConfMax] = useState('');
|
| 80 |
+
const [riskMin, setRiskMin] = useState('');
|
| 81 |
+
const [safetyOnly, setSafetyOnly] = useState(false);
|
| 82 |
+
|
| 83 |
+
const [result, setResult] = useState<ErrorSearchResponse | null>(null);
|
| 84 |
+
const [busy, setBusy] = useState(false);
|
| 85 |
+
const [error, setError] = useState<string | null>(null);
|
| 86 |
+
|
| 87 |
+
const runSearch = async () => {
|
| 88 |
+
setBusy(true);
|
| 89 |
+
setError(null);
|
| 90 |
+
try {
|
| 91 |
+
const filters: Partial<Record<DimName, string[]>> = {};
|
| 92 |
+
if (classes.length) filters.class = classes;
|
| 93 |
+
if (lightings.length) filters.lighting = lightings;
|
| 94 |
+
if (weathers.length) filters.weather = weathers;
|
| 95 |
+
const confParsed = Number(confMax);
|
| 96 |
+
const riskParsed = Number(riskMin);
|
| 97 |
+
setResult(
|
| 98 |
+
await searchErrors({
|
| 99 |
+
run_id: runId,
|
| 100 |
+
...(errorTypes.length ? { error_types: errorTypes as ErrorType[] } : {}),
|
| 101 |
+
...(Object.keys(filters).length ? { filters } : {}),
|
| 102 |
+
...(confMax.trim() !== '' && Number.isFinite(confParsed) ? { confidence_max: confParsed } : {}),
|
| 103 |
+
...(riskMin.trim() !== '' && Number.isFinite(riskParsed) ? { risk_min: riskParsed } : {}),
|
| 104 |
+
...(safetyOnly ? { safety_only: true } : {}),
|
| 105 |
+
limit_containers: 20,
|
| 106 |
+
})
|
| 107 |
+
);
|
| 108 |
+
} catch (err) {
|
| 109 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 110 |
+
} finally {
|
| 111 |
+
setBusy(false);
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
| 117 |
+
<SectionCard title="Error search">
|
| 118 |
+
<Box sx={{ display: 'flex', gap: 1.25, flexWrap: 'wrap', alignItems: 'center' }}>
|
| 119 |
+
<MultiSelect label="Error types" options={ALL_ERROR_TYPES} value={errorTypes} onChange={setErrorTypes} />
|
| 120 |
+
<MultiSelect label="Class" options={dimOptions('class')} value={classes} onChange={setClasses} />
|
| 121 |
+
<MultiSelect label="Lighting" options={dimOptions('lighting')} value={lightings} onChange={setLightings} />
|
| 122 |
+
<MultiSelect label="Weather" options={dimOptions('weather')} value={weathers} onChange={setWeathers} />
|
| 123 |
+
<TextField
|
| 124 |
+
size="small"
|
| 125 |
+
label="Max confidence"
|
| 126 |
+
type="number"
|
| 127 |
+
value={confMax}
|
| 128 |
+
onChange={(e) => setConfMax(e.target.value)}
|
| 129 |
+
sx={{ width: 130 }}
|
| 130 |
+
inputProps={{ step: 0.05, min: 0, max: 1 }}
|
| 131 |
+
/>
|
| 132 |
+
<TextField
|
| 133 |
+
size="small"
|
| 134 |
+
label="Min risk"
|
| 135 |
+
type="number"
|
| 136 |
+
value={riskMin}
|
| 137 |
+
onChange={(e) => setRiskMin(e.target.value)}
|
| 138 |
+
sx={{ width: 110 }}
|
| 139 |
+
inputProps={{ step: 0.1, min: 0 }}
|
| 140 |
+
/>
|
| 141 |
+
<FormControlLabel
|
| 142 |
+
control={<Checkbox size="small" checked={safetyOnly} onChange={(e) => setSafetyOnly(e.target.checked)} />}
|
| 143 |
+
label={<Typography variant="body2">Safety-critical only</Typography>}
|
| 144 |
+
/>
|
| 145 |
+
<Button
|
| 146 |
+
variant="contained"
|
| 147 |
+
size="small"
|
| 148 |
+
startIcon={busy ? <CircularProgress size={14} color="inherit" /> : <SearchCode size={15} />}
|
| 149 |
+
disabled={busy}
|
| 150 |
+
onClick={() => void runSearch()}
|
| 151 |
+
>
|
| 152 |
+
Search errors
|
| 153 |
+
</Button>
|
| 154 |
+
</Box>
|
| 155 |
+
{error ? <ErrorNote error={error} /> : null}
|
| 156 |
+
</SectionCard>
|
| 157 |
+
|
| 158 |
+
{result ? (
|
| 159 |
+
<>
|
| 160 |
+
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
| 161 |
+
<MetricCard label="Matched errors" value={fmtCompact(result.matched_errors)} accent="#ef5350" />
|
| 162 |
+
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
|
| 163 |
+
{ALL_ERROR_TYPES.filter((t) => (result.by_type[t] ?? 0) > 0).map((t) => (
|
| 164 |
+
<Chip
|
| 165 |
+
key={t}
|
| 166 |
+
size="small"
|
| 167 |
+
label={`${t}: ${fmtCompact(result.by_type[t] ?? 0)}`}
|
| 168 |
+
sx={{
|
| 169 |
+
fontFamily: 'monospace',
|
| 170 |
+
fontWeight: 700,
|
| 171 |
+
bgcolor: '#232a31',
|
| 172 |
+
color: ERROR_TYPE_COLORS[t],
|
| 173 |
+
}}
|
| 174 |
+
/>
|
| 175 |
+
))}
|
| 176 |
+
</Box>
|
| 177 |
+
</Box>
|
| 178 |
+
|
| 179 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 180 |
+
<SectionCard title={`Worst containers (${result.worst_containers.length})`} sx={{ flex: '1 1 480px' }}>
|
| 181 |
+
<Table size="small">
|
| 182 |
+
<TableHead>
|
| 183 |
+
<TableRow>
|
| 184 |
+
<TableCell>Container</TableCell>
|
| 185 |
+
<TableCell>Context</TableCell>
|
| 186 |
+
<TableCell align="right">Errors</TableCell>
|
| 187 |
+
<TableCell align="right">Mean risk</TableCell>
|
| 188 |
+
<TableCell align="right">Max severity</TableCell>
|
| 189 |
+
<TableCell align="right">Safety hits</TableCell>
|
| 190 |
+
</TableRow>
|
| 191 |
+
</TableHead>
|
| 192 |
+
<TableBody>
|
| 193 |
+
{result.worst_containers.map((c) => (
|
| 194 |
+
<TableRow
|
| 195 |
+
key={c.container_id}
|
| 196 |
+
hover
|
| 197 |
+
onClick={() => onOpenContainer(c.container_id)}
|
| 198 |
+
sx={{ cursor: 'pointer' }}
|
| 199 |
+
>
|
| 200 |
+
<TableCell sx={{ fontFamily: 'monospace', color: '#4fc3f7' }}>#{c.container_id}</TableCell>
|
| 201 |
+
<TableCell>
|
| 202 |
+
<DimChips values={[c.scenario, c.lighting, c.weather]} />
|
| 203 |
+
</TableCell>
|
| 204 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 205 |
+
{c.error_count}
|
| 206 |
+
</TableCell>
|
| 207 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 208 |
+
{fmtNum(c.mean_risk, 2)}
|
| 209 |
+
</TableCell>
|
| 210 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 211 |
+
{fmtNum(c.max_severity, 2)}
|
| 212 |
+
</TableCell>
|
| 213 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace', color: c.safety_hits > 0 ? '#ef5350' : undefined }}>
|
| 214 |
+
{c.safety_hits}
|
| 215 |
+
</TableCell>
|
| 216 |
+
</TableRow>
|
| 217 |
+
))}
|
| 218 |
+
</TableBody>
|
| 219 |
+
</Table>
|
| 220 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1 }}>
|
| 221 |
+
Click a container to open its forensic object table.
|
| 222 |
+
</Typography>
|
| 223 |
+
</SectionCard>
|
| 224 |
+
|
| 225 |
+
<SectionCard title={`Top examples (${result.examples.length})`} sx={{ flex: '1 1 420px' }}>
|
| 226 |
+
<Box sx={{ maxHeight: 420, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
| 227 |
+
{result.examples.map((ex) => (
|
| 228 |
+
<Box
|
| 229 |
+
key={ex.error_id}
|
| 230 |
+
sx={{
|
| 231 |
+
display: 'flex',
|
| 232 |
+
alignItems: 'center',
|
| 233 |
+
gap: 1,
|
| 234 |
+
p: 0.75,
|
| 235 |
+
border: '1px solid #232a31',
|
| 236 |
+
borderRadius: 1,
|
| 237 |
+
cursor: 'pointer',
|
| 238 |
+
'&:hover': { bgcolor: '#1b222a' },
|
| 239 |
+
}}
|
| 240 |
+
onClick={() => onOpenContainer(ex.container_id)}
|
| 241 |
+
>
|
| 242 |
+
<OutcomeChip outcome={ex.error_type} />
|
| 243 |
+
{ex.safety_critical ? <ShieldAlert size={14} color="#ef5350" /> : null}
|
| 244 |
+
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
| 245 |
+
{ex.class}
|
| 246 |
+
</Typography>
|
| 247 |
+
<DimChips values={[ex.scenario, ex.lighting, ex.weather]} />
|
| 248 |
+
<Box sx={{ flex: 1 }} />
|
| 249 |
+
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#8a949e' }}>
|
| 250 |
+
sev {fmtNum(ex.severity, 2)} · conf {fmtNum(ex.confidence, 2)} · risk {fmtNum(ex.risk_score, 2)} · #
|
| 251 |
+
{ex.container_id}
|
| 252 |
+
</Typography>
|
| 253 |
+
</Box>
|
| 254 |
+
))}
|
| 255 |
+
</Box>
|
| 256 |
+
</SectionCard>
|
| 257 |
+
</Box>
|
| 258 |
+
</>
|
| 259 |
+
) : (
|
| 260 |
+
<Typography variant="body2" sx={{ color: '#8a949e' }}>
|
| 261 |
+
Define criteria and search the error index — results are served from the inverted index, not a scan.
|
| 262 |
+
</Typography>
|
| 263 |
+
)}
|
| 264 |
+
</Box>
|
| 265 |
+
);
|
| 266 |
+
}
|
src/components/megaeval/LineageCard.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Collapsible reproducibility card: every version pin that produced this
|
| 3 |
+
* evaluation run (dataset, model, labels, evaluator code, metric definitions,
|
| 4 |
+
* thresholds, sampling config, seed, hardware).
|
| 5 |
+
*/
|
| 6 |
+
import { useState } from 'react';
|
| 7 |
+
import Box from '@mui/material/Box';
|
| 8 |
+
import Collapse from '@mui/material/Collapse';
|
| 9 |
+
import IconButton from '@mui/material/IconButton';
|
| 10 |
+
import Typography from '@mui/material/Typography';
|
| 11 |
+
import { ChevronDown, ChevronUp } from 'lucide-react';
|
| 12 |
+
import type { RunLineage } from '../../types/megaeval';
|
| 13 |
+
import { SectionCard } from '../labeleval/shared';
|
| 14 |
+
|
| 15 |
+
function Field({ label, value }: { label: string; value: string | number }) {
|
| 16 |
+
return (
|
| 17 |
+
<Box sx={{ minWidth: 220, flex: '1 1 220px' }}>
|
| 18 |
+
<Typography variant="caption" sx={{ color: '#8a949e', textTransform: 'uppercase', fontSize: 9.5 }}>
|
| 19 |
+
{label}
|
| 20 |
+
</Typography>
|
| 21 |
+
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
| 22 |
+
{value}
|
| 23 |
+
</Typography>
|
| 24 |
+
</Box>
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export default function LineageCard({ lineage }: { lineage: RunLineage }) {
|
| 29 |
+
const [open, setOpen] = useState(true);
|
| 30 |
+
return (
|
| 31 |
+
<SectionCard
|
| 32 |
+
title="Run lineage & reproducibility"
|
| 33 |
+
action={
|
| 34 |
+
<IconButton size="small" onClick={() => setOpen((o) => !o)}>
|
| 35 |
+
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
| 36 |
+
</IconButton>
|
| 37 |
+
}
|
| 38 |
+
>
|
| 39 |
+
<Collapse in={open}>
|
| 40 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
| 41 |
+
<Field label="Evaluation ID" value={lineage.evaluation_id} />
|
| 42 |
+
<Field label="Dataset version" value={lineage.dataset_version} />
|
| 43 |
+
<Field label="Model version" value={lineage.model_version} />
|
| 44 |
+
<Field label="Model checkpoint" value={lineage.model_checkpoint} />
|
| 45 |
+
<Field label="Label version" value={lineage.label_version} />
|
| 46 |
+
<Field label="Evaluator code version" value={lineage.evaluator_code_version} />
|
| 47 |
+
<Field label="Metric version" value={lineage.metric_version} />
|
| 48 |
+
<Field label="Seed" value={lineage.seed} />
|
| 49 |
+
<Field label="Hardware" value={lineage.hardware} />
|
| 50 |
+
<Field label="Timestamp" value={new Date(lineage.timestamp).toLocaleString()} />
|
| 51 |
+
</Box>
|
| 52 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
| 53 |
+
<Box sx={{ flex: '1 1 320px' }}>
|
| 54 |
+
<Typography variant="caption" sx={{ color: '#8a949e', textTransform: 'uppercase', fontSize: 9.5 }}>
|
| 55 |
+
Threshold config
|
| 56 |
+
</Typography>
|
| 57 |
+
<Box
|
| 58 |
+
component="pre"
|
| 59 |
+
sx={{
|
| 60 |
+
m: 0,
|
| 61 |
+
p: 1,
|
| 62 |
+
bgcolor: '#12171d',
|
| 63 |
+
border: '1px solid #232a31',
|
| 64 |
+
borderRadius: 1,
|
| 65 |
+
fontSize: 11.5,
|
| 66 |
+
fontFamily: 'monospace',
|
| 67 |
+
overflowX: 'auto',
|
| 68 |
+
}}
|
| 69 |
+
>
|
| 70 |
+
{JSON.stringify(lineage.threshold_config, null, 2)}
|
| 71 |
+
</Box>
|
| 72 |
+
</Box>
|
| 73 |
+
<Box sx={{ flex: '1 1 320px' }}>
|
| 74 |
+
<Typography variant="caption" sx={{ color: '#8a949e', textTransform: 'uppercase', fontSize: 9.5 }}>
|
| 75 |
+
Sampling config
|
| 76 |
+
</Typography>
|
| 77 |
+
<Box
|
| 78 |
+
component="pre"
|
| 79 |
+
sx={{
|
| 80 |
+
m: 0,
|
| 81 |
+
p: 1,
|
| 82 |
+
bgcolor: '#12171d',
|
| 83 |
+
border: '1px solid #232a31',
|
| 84 |
+
borderRadius: 1,
|
| 85 |
+
fontSize: 11.5,
|
| 86 |
+
fontFamily: 'monospace',
|
| 87 |
+
overflowX: 'auto',
|
| 88 |
+
}}
|
| 89 |
+
>
|
| 90 |
+
{JSON.stringify(lineage.sampling_config, null, 2)}
|
| 91 |
+
</Box>
|
| 92 |
+
</Box>
|
| 93 |
+
</Box>
|
| 94 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1.5 }}>
|
| 95 |
+
Identical inputs + seed reproduce this evaluation bit-for-bit.
|
| 96 |
+
</Typography>
|
| 97 |
+
</Collapse>
|
| 98 |
+
</SectionCard>
|
| 99 |
+
);
|
| 100 |
+
}
|
src/components/megaeval/QualityTab.tsx
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Quality tab: per-class quality bars, error-type distribution, the quality
|
| 3 |
+
* funnel, cross-run quality trend, and sketch-based distributions.
|
| 4 |
+
* Every aggregate-query panel surfaces its provenance badge.
|
| 5 |
+
*/
|
| 6 |
+
import { useMemo } from 'react';
|
| 7 |
+
import Box from '@mui/material/Box';
|
| 8 |
+
import Table from '@mui/material/Table';
|
| 9 |
+
import TableBody from '@mui/material/TableBody';
|
| 10 |
+
import TableCell from '@mui/material/TableCell';
|
| 11 |
+
import TableHead from '@mui/material/TableHead';
|
| 12 |
+
import TableRow from '@mui/material/TableRow';
|
| 13 |
+
import Typography from '@mui/material/Typography';
|
| 14 |
+
import type { ErrorType, EvaluationRunInfo } from '../../types/megaeval';
|
| 15 |
+
import {
|
| 16 |
+
fmtCompact,
|
| 17 |
+
getRunDistributions,
|
| 18 |
+
getRunFunnel,
|
| 19 |
+
queryEvaluations,
|
| 20 |
+
searchErrors,
|
| 21 |
+
} from '../../services/megaeval';
|
| 22 |
+
import { usePoll } from '../../services/labeleval';
|
| 23 |
+
import { ErrorNote, HBar, LoadingBox, SectionCard, fmtNum, fmtPct } from '../labeleval/shared';
|
| 24 |
+
import {
|
| 25 |
+
ERROR_TYPE_COLORS,
|
| 26 |
+
ExactnessTag,
|
| 27 |
+
HistogramSparkline,
|
| 28 |
+
PctBarCell,
|
| 29 |
+
QueryBadge,
|
| 30 |
+
rowNum,
|
| 31 |
+
rowStr,
|
| 32 |
+
} from './shared';
|
| 33 |
+
|
| 34 |
+
// ---------------------------------------------------------------- per-class quality
|
| 35 |
+
|
| 36 |
+
function QualityByClass({ runId, refreshKey }: { runId: string; refreshKey: number }) {
|
| 37 |
+
const q = usePoll(
|
| 38 |
+
() =>
|
| 39 |
+
queryEvaluations({
|
| 40 |
+
evaluation_id: runId,
|
| 41 |
+
group_by: ['class'],
|
| 42 |
+
metrics: ['n', 'precision', 'recall', 'f1', 'mean_iou'],
|
| 43 |
+
}),
|
| 44 |
+
null,
|
| 45 |
+
[runId, refreshKey]
|
| 46 |
+
);
|
| 47 |
+
return (
|
| 48 |
+
<SectionCard title="Quality by class" action={<QueryBadge meta={q.data?.meta} />} sx={{ flex: '1 1 460px' }}>
|
| 49 |
+
{q.error ? <ErrorNote error={q.error} /> : null}
|
| 50 |
+
{q.loading && !q.data ? <LoadingBox /> : null}
|
| 51 |
+
{q.data ? (
|
| 52 |
+
<Table size="small">
|
| 53 |
+
<TableHead>
|
| 54 |
+
<TableRow>
|
| 55 |
+
<TableCell>Class</TableCell>
|
| 56 |
+
<TableCell align="right">n</TableCell>
|
| 57 |
+
<TableCell>Recall</TableCell>
|
| 58 |
+
<TableCell>Precision</TableCell>
|
| 59 |
+
<TableCell align="right">F1</TableCell>
|
| 60 |
+
<TableCell align="right">Mean IoU</TableCell>
|
| 61 |
+
</TableRow>
|
| 62 |
+
</TableHead>
|
| 63 |
+
<TableBody>
|
| 64 |
+
{[...q.data.rows]
|
| 65 |
+
.sort((a, b) => b.n - a.n)
|
| 66 |
+
.map((row) => (
|
| 67 |
+
<TableRow key={rowStr(row, 'class')} hover>
|
| 68 |
+
<TableCell sx={{ fontWeight: 600 }}>{rowStr(row, 'class')}</TableCell>
|
| 69 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 70 |
+
{fmtCompact(row.n)}
|
| 71 |
+
</TableCell>
|
| 72 |
+
<TableCell>
|
| 73 |
+
<PctBarCell value={rowNum(row, 'recall')} color="#66bb6a" />
|
| 74 |
+
</TableCell>
|
| 75 |
+
<TableCell>
|
| 76 |
+
<PctBarCell value={rowNum(row, 'precision')} color="#4fc3f7" />
|
| 77 |
+
</TableCell>
|
| 78 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 79 |
+
{fmtPct(rowNum(row, 'f1'))}
|
| 80 |
+
</TableCell>
|
| 81 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 82 |
+
{fmtNum(rowNum(row, 'mean_iou'), 3)}
|
| 83 |
+
</TableCell>
|
| 84 |
+
</TableRow>
|
| 85 |
+
))}
|
| 86 |
+
</TableBody>
|
| 87 |
+
</Table>
|
| 88 |
+
) : null}
|
| 89 |
+
</SectionCard>
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// ---------------------------------------------------------------- error distribution
|
| 94 |
+
|
| 95 |
+
const ERROR_TYPE_ORDER: ErrorType[] = ['FN', 'FP', 'LOCALIZATION', 'ANOMALY', 'LOW_CONF'];
|
| 96 |
+
|
| 97 |
+
function ErrorDistribution({ runId, refreshKey }: { runId: string; refreshKey: number }) {
|
| 98 |
+
const res = usePoll(() => searchErrors({ run_id: runId }), null, [runId, refreshKey]);
|
| 99 |
+
const byType = res.data?.by_type ?? {};
|
| 100 |
+
const total = ERROR_TYPE_ORDER.reduce((acc, t) => acc + (byType[t] ?? 0), 0);
|
| 101 |
+
const max = Math.max(1, ...ERROR_TYPE_ORDER.map((t) => byType[t] ?? 0));
|
| 102 |
+
return (
|
| 103 |
+
<SectionCard title="Error distribution" sx={{ flex: '1 1 320px' }}>
|
| 104 |
+
{res.error ? <ErrorNote error={res.error} /> : null}
|
| 105 |
+
{res.loading && !res.data ? <LoadingBox /> : null}
|
| 106 |
+
{res.data ? (
|
| 107 |
+
<>
|
| 108 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1 }}>
|
| 109 |
+
{fmtCompact(res.data.matched_errors)} potential errors indexed (exact)
|
| 110 |
+
</Typography>
|
| 111 |
+
{ERROR_TYPE_ORDER.map((t) => {
|
| 112 |
+
const count = byType[t] ?? 0;
|
| 113 |
+
return (
|
| 114 |
+
<HBar
|
| 115 |
+
key={t}
|
| 116 |
+
label={t}
|
| 117 |
+
value={count}
|
| 118 |
+
max={max}
|
| 119 |
+
color={ERROR_TYPE_COLORS[t]}
|
| 120 |
+
valueLabel={`${fmtCompact(count)} (${total > 0 ? fmtPct(count / total) : '—'})`}
|
| 121 |
+
/>
|
| 122 |
+
);
|
| 123 |
+
})}
|
| 124 |
+
</>
|
| 125 |
+
) : null}
|
| 126 |
+
</SectionCard>
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// ---------------------------------------------------------------- quality funnel
|
| 131 |
+
|
| 132 |
+
function QualityFunnel({ runId, refreshKey }: { runId: string; refreshKey: number }) {
|
| 133 |
+
const funnel = usePoll(() => getRunFunnel(runId), null, [runId, refreshKey]);
|
| 134 |
+
const stages = funnel.data?.stages ?? [];
|
| 135 |
+
const max = stages.length ? Math.max(...stages.map((s) => s.count)) : 1;
|
| 136 |
+
const est = funnel.data?.estimated_precision ?? null;
|
| 137 |
+
return (
|
| 138 |
+
<SectionCard title="Quality funnel" sx={{ flex: '1 1 420px' }}>
|
| 139 |
+
{funnel.error ? <ErrorNote error={funnel.error} /> : null}
|
| 140 |
+
{funnel.loading && !funnel.data ? <LoadingBox /> : null}
|
| 141 |
+
{funnel.data ? (
|
| 142 |
+
<>
|
| 143 |
+
{stages.map((s) => (
|
| 144 |
+
<HBar
|
| 145 |
+
key={s.stage}
|
| 146 |
+
label={s.stage}
|
| 147 |
+
value={s.count}
|
| 148 |
+
max={max}
|
| 149 |
+
color="#4fc3f7"
|
| 150 |
+
valueLabel={`${fmtCompact(s.count)} (${fmtPct(s.pct_of_population)})`}
|
| 151 |
+
/>
|
| 152 |
+
))}
|
| 153 |
+
{est && est.estimate !== null ? (
|
| 154 |
+
<Typography variant="body2" sx={{ mt: 1.5, color: '#a5d6a7' }}>
|
| 155 |
+
Estimated precision {fmtPct(est.estimate)}
|
| 156 |
+
{est.ci_low !== null && est.ci_high !== null
|
| 157 |
+
? ` (95% CI ${fmtPct(est.ci_low)}–${fmtPct(est.ci_high)}${
|
| 158 |
+
est.n_reviewed !== null ? `, n=${est.n_reviewed.toLocaleString()} reviewed` : ''
|
| 159 |
+
})`
|
| 160 |
+
: ''}
|
| 161 |
+
</Typography>
|
| 162 |
+
) : null}
|
| 163 |
+
</>
|
| 164 |
+
) : null}
|
| 165 |
+
</SectionCard>
|
| 166 |
+
);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// ---------------------------------------------------------------- quality trend
|
| 170 |
+
|
| 171 |
+
const TREND_SERIES: Array<{ key: 'precision' | 'recall' | 'safety_recall'; label: string; color: string }> = [
|
| 172 |
+
{ key: 'precision', label: 'precision', color: '#4fc3f7' },
|
| 173 |
+
{ key: 'recall', label: 'recall', color: '#66bb6a' },
|
| 174 |
+
{ key: 'safety_recall', label: 'safety recall', color: '#ffa726' },
|
| 175 |
+
];
|
| 176 |
+
|
| 177 |
+
function QualityTrend({ runs }: { runs: EvaluationRunInfo[] }) {
|
| 178 |
+
const published = useMemo(
|
| 179 |
+
() =>
|
| 180 |
+
runs
|
| 181 |
+
.filter((r) => r.status === 'published')
|
| 182 |
+
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? '')),
|
| 183 |
+
[runs]
|
| 184 |
+
);
|
| 185 |
+
|
| 186 |
+
const width = 520;
|
| 187 |
+
const height = 150;
|
| 188 |
+
const padL = 40;
|
| 189 |
+
const padB = 30;
|
| 190 |
+
const padT = 10;
|
| 191 |
+
|
| 192 |
+
const values = published.flatMap((r) =>
|
| 193 |
+
TREND_SERIES.map((s) => r.headline[s.key]).filter((v): v is number => v !== null && v !== undefined)
|
| 194 |
+
);
|
| 195 |
+
const lo = values.length ? Math.max(0, Math.min(...values) - 0.05) : 0;
|
| 196 |
+
const hi = values.length ? Math.min(1, Math.max(...values) + 0.02) : 1;
|
| 197 |
+
const xFor = (i: number) =>
|
| 198 |
+
padL + (published.length <= 1 ? (width - padL) / 2 : (i / (published.length - 1)) * (width - padL - 10));
|
| 199 |
+
const yFor = (v: number) => padT + (1 - (v - lo) / Math.max(1e-9, hi - lo)) * (height - padT - padB);
|
| 200 |
+
|
| 201 |
+
return (
|
| 202 |
+
<SectionCard title="Quality trend across published runs" sx={{ flex: '1 1 460px' }}>
|
| 203 |
+
{published.length < 2 ? (
|
| 204 |
+
<Typography variant="body2" sx={{ color: '#8a949e' }}>
|
| 205 |
+
{published.length === 0
|
| 206 |
+
? 'No published runs yet.'
|
| 207 |
+
: 'Only one published run — launch another run to see the trend.'}
|
| 208 |
+
</Typography>
|
| 209 |
+
) : null}
|
| 210 |
+
{published.length >= 1 ? (
|
| 211 |
+
<>
|
| 212 |
+
<svg width={width} height={height}>
|
| 213 |
+
{[lo, (lo + hi) / 2, hi].map((v, i) => (
|
| 214 |
+
<g key={i}>
|
| 215 |
+
<line x1={padL} x2={width - 5} y1={yFor(v)} y2={yFor(v)} stroke="#232a31" />
|
| 216 |
+
<text x={2} y={yFor(v) + 3} fontSize={9} fill="#8a949e" fontFamily="monospace">
|
| 217 |
+
{fmtPct(v)}
|
| 218 |
+
</text>
|
| 219 |
+
</g>
|
| 220 |
+
))}
|
| 221 |
+
{TREND_SERIES.map((s) => {
|
| 222 |
+
const pts = published
|
| 223 |
+
.map((r, i) => ({ v: r.headline[s.key], i }))
|
| 224 |
+
.filter((p): p is { v: number; i: number } => p.v !== null && p.v !== undefined);
|
| 225 |
+
if (!pts.length) return null;
|
| 226 |
+
return (
|
| 227 |
+
<g key={s.key}>
|
| 228 |
+
<polyline
|
| 229 |
+
fill="none"
|
| 230 |
+
stroke={s.color}
|
| 231 |
+
strokeWidth={2}
|
| 232 |
+
points={pts.map((p) => `${xFor(p.i)},${yFor(p.v)}`).join(' ')}
|
| 233 |
+
/>
|
| 234 |
+
{pts.map((p) => (
|
| 235 |
+
<circle key={p.i} cx={xFor(p.i)} cy={yFor(p.v)} r={3} fill={s.color} />
|
| 236 |
+
))}
|
| 237 |
+
</g>
|
| 238 |
+
);
|
| 239 |
+
})}
|
| 240 |
+
{published.map((r, i) => (
|
| 241 |
+
<text
|
| 242 |
+
key={r.run_id}
|
| 243 |
+
x={xFor(i)}
|
| 244 |
+
y={height - 6}
|
| 245 |
+
fontSize={9}
|
| 246 |
+
fill="#aab4be"
|
| 247 |
+
textAnchor="middle"
|
| 248 |
+
fontFamily="monospace"
|
| 249 |
+
>
|
| 250 |
+
{r.model_version.length > 14 ? `${r.model_version.slice(0, 13)}…` : r.model_version}
|
| 251 |
+
</text>
|
| 252 |
+
))}
|
| 253 |
+
</svg>
|
| 254 |
+
<Box sx={{ display: 'flex', gap: 2 }}>
|
| 255 |
+
{TREND_SERIES.map((s) => (
|
| 256 |
+
<Box key={s.key} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
| 257 |
+
<Box sx={{ width: 10, height: 3, bgcolor: s.color, borderRadius: 1 }} />
|
| 258 |
+
<Typography variant="caption" sx={{ color: '#aab4be' }}>
|
| 259 |
+
{s.label}
|
| 260 |
+
</Typography>
|
| 261 |
+
</Box>
|
| 262 |
+
))}
|
| 263 |
+
</Box>
|
| 264 |
+
</>
|
| 265 |
+
) : null}
|
| 266 |
+
</SectionCard>
|
| 267 |
+
);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// ---------------------------------------------------------------- distributions (sketches)
|
| 271 |
+
|
| 272 |
+
function DistributionsCard({ runId, refreshKey }: { runId: string; refreshKey: number }) {
|
| 273 |
+
const dist = usePoll(() => getRunDistributions(runId), null, [runId, refreshKey]);
|
| 274 |
+
const d = dist.data;
|
| 275 |
+
return (
|
| 276 |
+
<SectionCard
|
| 277 |
+
title={
|
| 278 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 279 |
+
<span>Distributions</span>
|
| 280 |
+
<ExactnessTag approx />
|
| 281 |
+
</Box>
|
| 282 |
+
}
|
| 283 |
+
sx={{ flex: '1 1 320px' }}
|
| 284 |
+
>
|
| 285 |
+
{dist.error ? <ErrorNote error={dist.error} /> : null}
|
| 286 |
+
{dist.loading && !d ? <LoadingBox /> : null}
|
| 287 |
+
{d ? (
|
| 288 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
| 289 |
+
{d.confidence ? (
|
| 290 |
+
<Box>
|
| 291 |
+
<Typography variant="caption" sx={{ color: '#aab4be' }}>
|
| 292 |
+
Confidence — approximate (sketch)
|
| 293 |
+
</Typography>
|
| 294 |
+
<HistogramSparkline sketch={d.confidence} color="#4fc3f7" />
|
| 295 |
+
</Box>
|
| 296 |
+
) : null}
|
| 297 |
+
{d.iou ? (
|
| 298 |
+
<Box>
|
| 299 |
+
<Typography variant="caption" sx={{ color: '#aab4be' }}>
|
| 300 |
+
IoU — approximate (sketch)
|
| 301 |
+
</Typography>
|
| 302 |
+
<HistogramSparkline sketch={d.iou} color="#66bb6a" />
|
| 303 |
+
</Box>
|
| 304 |
+
) : null}
|
| 305 |
+
<Box sx={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
| 306 |
+
<Box>
|
| 307 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
| 308 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 309 |
+
Containers (HLL)
|
| 310 |
+
</Typography>
|
| 311 |
+
<ExactnessTag approx />
|
| 312 |
+
</Box>
|
| 313 |
+
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
| 314 |
+
{d.containers_hll_estimate === null ? '—' : fmtCompact(d.containers_hll_estimate)}
|
| 315 |
+
</Typography>
|
| 316 |
+
</Box>
|
| 317 |
+
<Box>
|
| 318 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
| 319 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 320 |
+
Containers (exact)
|
| 321 |
+
</Typography>
|
| 322 |
+
<ExactnessTag approx={false} />
|
| 323 |
+
</Box>
|
| 324 |
+
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
| 325 |
+
{d.containers_exact === null ? '—' : fmtCompact(d.containers_exact)}
|
| 326 |
+
</Typography>
|
| 327 |
+
</Box>
|
| 328 |
+
</Box>
|
| 329 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 330 |
+
{d.note}
|
| 331 |
+
</Typography>
|
| 332 |
+
</Box>
|
| 333 |
+
) : null}
|
| 334 |
+
</SectionCard>
|
| 335 |
+
);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// ---------------------------------------------------------------- tab
|
| 339 |
+
|
| 340 |
+
export default function QualityTab({
|
| 341 |
+
runId,
|
| 342 |
+
runs,
|
| 343 |
+
refreshKey,
|
| 344 |
+
}: {
|
| 345 |
+
runId: string;
|
| 346 |
+
runs: EvaluationRunInfo[];
|
| 347 |
+
refreshKey: number;
|
| 348 |
+
}) {
|
| 349 |
+
return (
|
| 350 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 351 |
+
<QualityByClass runId={runId} refreshKey={refreshKey} />
|
| 352 |
+
<ErrorDistribution runId={runId} refreshKey={refreshKey} />
|
| 353 |
+
<QualityFunnel runId={runId} refreshKey={refreshKey} />
|
| 354 |
+
<QualityTrend runs={runs} />
|
| 355 |
+
<DistributionsCard runId={runId} refreshKey={refreshKey} />
|
| 356 |
+
</Box>
|
| 357 |
+
);
|
| 358 |
+
}
|
src/components/megaeval/ReviewTab.tsx
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Review sampling panel: statistically grounded human review. Build a
|
| 3 |
+
* stratified sampling plan, execute (simulated) reviews, then read precision
|
| 4 |
+
* and recall estimates with Wilson confidence intervals per stratum.
|
| 5 |
+
*/
|
| 6 |
+
import { useState } from 'react';
|
| 7 |
+
import Box from '@mui/material/Box';
|
| 8 |
+
import Button from '@mui/material/Button';
|
| 9 |
+
import CircularProgress from '@mui/material/CircularProgress';
|
| 10 |
+
import Table from '@mui/material/Table';
|
| 11 |
+
import TableBody from '@mui/material/TableBody';
|
| 12 |
+
import TableCell from '@mui/material/TableCell';
|
| 13 |
+
import TableHead from '@mui/material/TableHead';
|
| 14 |
+
import TableRow from '@mui/material/TableRow';
|
| 15 |
+
import Typography from '@mui/material/Typography';
|
| 16 |
+
import { ClipboardList, UserCheck } from 'lucide-react';
|
| 17 |
+
import type { MetricEstimate, ReviewState, SamplingFunnel, StratumPlan } from '../../types/megaeval';
|
| 18 |
+
import { executeReview, fmtCompact, planReview } from '../../services/megaeval';
|
| 19 |
+
import { ErrorNote, HBar, SectionCard, fmtPct } from '../labeleval/shared';
|
| 20 |
+
|
| 21 |
+
const FUNNEL_STAGES: Array<{ key: keyof SamplingFunnel; label: string }> = [
|
| 22 |
+
{ key: 'population_objects', label: 'Population objects' },
|
| 23 |
+
{ key: 'containers', label: 'Containers' },
|
| 24 |
+
{ key: 'suspicious_containers', label: 'Suspicious containers' },
|
| 25 |
+
{ key: 'candidate_pool', label: 'Candidate pool' },
|
| 26 |
+
{ key: 'statistically_selected', label: 'Statistically selected' },
|
| 27 |
+
{ key: 'reviewed', label: 'Reviewed' },
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
function EstimateCard({ label, est }: { label: string; est: MetricEstimate }) {
|
| 31 |
+
return (
|
| 32 |
+
<SectionCard title={`${label} estimate`} sx={{ flex: '1 1 420px' }}>
|
| 33 |
+
<Typography variant="h5" sx={{ fontWeight: 800, color: '#a5d6a7' }}>
|
| 34 |
+
{fmtPct(est.estimate)}
|
| 35 |
+
<Typography component="span" variant="body2" sx={{ color: '#8a949e', ml: 1 }}>
|
| 36 |
+
95% CI {fmtPct(est.ci_low)}–{fmtPct(est.ci_high)} · n={est.n_reviewed.toLocaleString()} reviewed · frame{' '}
|
| 37 |
+
{fmtCompact(est.frame_size)}
|
| 38 |
+
</Typography>
|
| 39 |
+
</Typography>
|
| 40 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1 }}>
|
| 41 |
+
{est.method}
|
| 42 |
+
</Typography>
|
| 43 |
+
<Table size="small">
|
| 44 |
+
<TableHead>
|
| 45 |
+
<TableRow>
|
| 46 |
+
<TableCell>Stratum</TableCell>
|
| 47 |
+
<TableCell align="right">N</TableCell>
|
| 48 |
+
<TableCell align="right">n</TableCell>
|
| 49 |
+
<TableCell align="right">p</TableCell>
|
| 50 |
+
<TableCell align="right">Wilson 95% CI</TableCell>
|
| 51 |
+
</TableRow>
|
| 52 |
+
</TableHead>
|
| 53 |
+
<TableBody>
|
| 54 |
+
{est.strata.map((s) => (
|
| 55 |
+
<TableRow key={s.stratum} hover>
|
| 56 |
+
<TableCell sx={{ fontFamily: 'monospace', fontSize: 12 }}>{s.stratum}</TableCell>
|
| 57 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 58 |
+
{fmtCompact(s.N)}
|
| 59 |
+
</TableCell>
|
| 60 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 61 |
+
{s.n.toLocaleString()}
|
| 62 |
+
</TableCell>
|
| 63 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 64 |
+
{fmtPct(s.p)}
|
| 65 |
+
</TableCell>
|
| 66 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 67 |
+
{fmtPct(s.wilson_ci[0])}–{fmtPct(s.wilson_ci[1])}
|
| 68 |
+
</TableCell>
|
| 69 |
+
</TableRow>
|
| 70 |
+
))}
|
| 71 |
+
</TableBody>
|
| 72 |
+
</Table>
|
| 73 |
+
</SectionCard>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function PlanTable({ title, strata }: { title: string; strata: Record<string, StratumPlan> }) {
|
| 78 |
+
const entries = Object.entries(strata);
|
| 79 |
+
return (
|
| 80 |
+
<SectionCard title={title} sx={{ flex: '1 1 340px' }}>
|
| 81 |
+
<Table size="small">
|
| 82 |
+
<TableHead>
|
| 83 |
+
<TableRow>
|
| 84 |
+
<TableCell>Stratum</TableCell>
|
| 85 |
+
<TableCell align="right">N</TableCell>
|
| 86 |
+
<TableCell align="right">Allocated</TableCell>
|
| 87 |
+
</TableRow>
|
| 88 |
+
</TableHead>
|
| 89 |
+
<TableBody>
|
| 90 |
+
{entries.map(([key, plan]) => (
|
| 91 |
+
<TableRow key={key} hover>
|
| 92 |
+
<TableCell sx={{ fontFamily: 'monospace', fontSize: 12 }}>{plan.label || key}</TableCell>
|
| 93 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 94 |
+
{fmtCompact(plan.N)}
|
| 95 |
+
</TableCell>
|
| 96 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 97 |
+
{plan.allocated.toLocaleString()}
|
| 98 |
+
</TableCell>
|
| 99 |
+
</TableRow>
|
| 100 |
+
))}
|
| 101 |
+
</TableBody>
|
| 102 |
+
</Table>
|
| 103 |
+
</SectionCard>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export default function ReviewTab({
|
| 108 |
+
runId,
|
| 109 |
+
review,
|
| 110 |
+
onReview,
|
| 111 |
+
}: {
|
| 112 |
+
runId: string;
|
| 113 |
+
review: ReviewState | null;
|
| 114 |
+
onReview: (r: ReviewState) => void;
|
| 115 |
+
}) {
|
| 116 |
+
const [busy, setBusy] = useState<'plan' | 'execute' | null>(null);
|
| 117 |
+
const [error, setError] = useState<string | null>(null);
|
| 118 |
+
|
| 119 |
+
const act = async (kind: 'plan' | 'execute') => {
|
| 120 |
+
setBusy(kind);
|
| 121 |
+
setError(null);
|
| 122 |
+
try {
|
| 123 |
+
onReview(kind === 'plan' ? await planReview(runId) : await executeReview(runId));
|
| 124 |
+
} catch (err) {
|
| 125 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 126 |
+
} finally {
|
| 127 |
+
setBusy(null);
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const funnel = review?.funnel;
|
| 132 |
+
const max = funnel ? Math.max(1, funnel.population_objects) : 1;
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
| 136 |
+
<SectionCard
|
| 137 |
+
title="Statistical review sampling"
|
| 138 |
+
action={
|
| 139 |
+
<Box sx={{ display: 'flex', gap: 1 }}>
|
| 140 |
+
<Button
|
| 141 |
+
size="small"
|
| 142 |
+
variant="outlined"
|
| 143 |
+
startIcon={busy === 'plan' ? <CircularProgress size={14} color="inherit" /> : <ClipboardList size={14} />}
|
| 144 |
+
disabled={busy !== null}
|
| 145 |
+
onClick={() => void act('plan')}
|
| 146 |
+
>
|
| 147 |
+
Build sampling plan
|
| 148 |
+
</Button>
|
| 149 |
+
<Button
|
| 150 |
+
size="small"
|
| 151 |
+
variant="contained"
|
| 152 |
+
startIcon={busy === 'execute' ? <CircularProgress size={14} color="inherit" /> : <UserCheck size={14} />}
|
| 153 |
+
disabled={busy !== null || !review?.planned}
|
| 154 |
+
onClick={() => void act('execute')}
|
| 155 |
+
>
|
| 156 |
+
Execute reviews (simulated)
|
| 157 |
+
</Button>
|
| 158 |
+
</Box>
|
| 159 |
+
}
|
| 160 |
+
>
|
| 161 |
+
{error ? <ErrorNote error={error} /> : null}
|
| 162 |
+
{!review || !review.planned ? (
|
| 163 |
+
<Typography variant="body2" sx={{ color: '#8a949e' }}>
|
| 164 |
+
No sampling plan yet. Reviewing everything at this scale is impossible — build a stratified plan to
|
| 165 |
+
estimate precision and recall from a small, statistically selected sample.
|
| 166 |
+
</Typography>
|
| 167 |
+
) : (
|
| 168 |
+
<>
|
| 169 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1 }}>
|
| 170 |
+
{review.method ?? 'stratified sampling'}
|
| 171 |
+
{review.target_n !== undefined ? ` · target n=${review.target_n.toLocaleString()}` : ''}
|
| 172 |
+
{review.executed ? ' · executed' : ' · planned, not executed'}
|
| 173 |
+
</Typography>
|
| 174 |
+
{funnel
|
| 175 |
+
? FUNNEL_STAGES.map((s) => (
|
| 176 |
+
<HBar
|
| 177 |
+
key={s.key}
|
| 178 |
+
label={s.label}
|
| 179 |
+
value={funnel[s.key]}
|
| 180 |
+
max={max}
|
| 181 |
+
color={s.key === 'reviewed' ? '#66bb6a' : '#4fc3f7'}
|
| 182 |
+
valueLabel={`${fmtCompact(funnel[s.key])} (${fmtPct(funnel[s.key] / max)})`}
|
| 183 |
+
/>
|
| 184 |
+
))
|
| 185 |
+
: null}
|
| 186 |
+
</>
|
| 187 |
+
)}
|
| 188 |
+
</SectionCard>
|
| 189 |
+
|
| 190 |
+
{review?.planned && !review.executed && review.strata ? (
|
| 191 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 192 |
+
<PlanTable title="Precision strata plan" strata={review.strata.precision} />
|
| 193 |
+
<PlanTable title="Recall strata plan" strata={review.strata.recall} />
|
| 194 |
+
</Box>
|
| 195 |
+
) : null}
|
| 196 |
+
|
| 197 |
+
{review?.results ? (
|
| 198 |
+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 199 |
+
<EstimateCard label="Precision" est={review.results.precision} />
|
| 200 |
+
<EstimateCard label="Recall" est={review.results.recall} />
|
| 201 |
+
</Box>
|
| 202 |
+
) : null}
|
| 203 |
+
</Box>
|
| 204 |
+
);
|
| 205 |
+
}
|
src/components/megaeval/RunProgressCard.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Live progress card for an in-flight (non-published) evaluation run.
|
| 3 |
+
* Fed by the SSE stream when available, otherwise by 1.5s polling.
|
| 4 |
+
*/
|
| 5 |
+
import Box from '@mui/material/Box';
|
| 6 |
+
import LinearProgress from '@mui/material/LinearProgress';
|
| 7 |
+
import Typography from '@mui/material/Typography';
|
| 8 |
+
import type { RunProgress } from '../../types/megaeval';
|
| 9 |
+
import { fmtCompact } from '../../services/megaeval';
|
| 10 |
+
import { SectionCard } from '../labeleval/shared';
|
| 11 |
+
import { CompactStat, RunStatusChip } from './shared';
|
| 12 |
+
|
| 13 |
+
function fmtEta(s: number | null): string {
|
| 14 |
+
if (s === null || Number.isNaN(s)) return '—';
|
| 15 |
+
if (s < 60) return `${Math.max(0, Math.round(s))}s`;
|
| 16 |
+
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function RunProgressCard({ progress }: { progress: RunProgress }) {
|
| 20 |
+
// Tolerate either 0..1 or 0..100 percent encodings from the backend.
|
| 21 |
+
const pct = Math.max(0, Math.min(100, progress.percent <= 1 ? progress.percent * 100 : progress.percent));
|
| 22 |
+
return (
|
| 23 |
+
<SectionCard
|
| 24 |
+
title={
|
| 25 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 26 |
+
<span>
|
| 27 |
+
Evaluation run {progress.run_id.slice(0, 8)} · {progress.model_version}
|
| 28 |
+
</span>
|
| 29 |
+
<RunStatusChip status={progress.status} />
|
| 30 |
+
</Box>
|
| 31 |
+
}
|
| 32 |
+
>
|
| 33 |
+
<LinearProgress
|
| 34 |
+
variant="determinate"
|
| 35 |
+
value={pct}
|
| 36 |
+
sx={{ height: 10, borderRadius: 1, mb: 1.5, bgcolor: '#232a31' }}
|
| 37 |
+
/>
|
| 38 |
+
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
| 39 |
+
<CompactStat label="Progress" value={`${pct.toFixed(1)}%`} />
|
| 40 |
+
<CompactStat
|
| 41 |
+
label="Objects"
|
| 42 |
+
value={`${fmtCompact(progress.objects_processed)} / ${fmtCompact(progress.objects_total)}`}
|
| 43 |
+
/>
|
| 44 |
+
<CompactStat
|
| 45 |
+
label="Partitions"
|
| 46 |
+
value={`${progress.partitions_done} / ${progress.partitions_total}`}
|
| 47 |
+
/>
|
| 48 |
+
<CompactStat label="Workers" value={progress.workers} />
|
| 49 |
+
<CompactStat label="Throughput" value={`${fmtCompact(progress.throughput_objs_per_s)} objs/s`} />
|
| 50 |
+
<CompactStat label="ETA" value={fmtEta(progress.eta_s)} />
|
| 51 |
+
</Box>
|
| 52 |
+
{progress.error ? (
|
| 53 |
+
<Typography variant="body2" sx={{ color: '#ef5350', mt: 1.5, fontFamily: 'monospace' }}>
|
| 54 |
+
{progress.error}
|
| 55 |
+
</Typography>
|
| 56 |
+
) : (
|
| 57 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mt: 1.5 }}>
|
| 58 |
+
Map partitions across workers → reduce partial aggregates → materialize cube & indexes → publish.
|
| 59 |
+
</Typography>
|
| 60 |
+
)}
|
| 61 |
+
</SectionCard>
|
| 62 |
+
);
|
| 63 |
+
}
|
src/components/megaeval/ShiftTab.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Distribution shift panel: cohorts whose share of the evaluation population
|
| 3 |
+
* diverges from the training mix, annotated with the recall gap so a shift
|
| 4 |
+
* that also hurts quality stands out.
|
| 5 |
+
*/
|
| 6 |
+
import Box from '@mui/material/Box';
|
| 7 |
+
import Table from '@mui/material/Table';
|
| 8 |
+
import TableBody from '@mui/material/TableBody';
|
| 9 |
+
import TableCell from '@mui/material/TableCell';
|
| 10 |
+
import TableHead from '@mui/material/TableHead';
|
| 11 |
+
import TableRow from '@mui/material/TableRow';
|
| 12 |
+
import Typography from '@mui/material/Typography';
|
| 13 |
+
import { fmtCompact, getRunShift } from '../../services/megaeval';
|
| 14 |
+
import { usePoll } from '../../services/labeleval';
|
| 15 |
+
import { ErrorNote, LoadingBox, SectionCard, fmtPct } from '../labeleval/shared';
|
| 16 |
+
|
| 17 |
+
function gapColor(gap: number | null): string | undefined {
|
| 18 |
+
if (gap === null) return undefined;
|
| 19 |
+
const g = Math.abs(gap);
|
| 20 |
+
if (g >= 0.1) return '#ef5350';
|
| 21 |
+
if (g >= 0.05) return '#ffa726';
|
| 22 |
+
return undefined;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function ShiftTab({ runId, refreshKey }: { runId: string; refreshKey: number }) {
|
| 26 |
+
const shift = usePoll(() => getRunShift(runId), null, [runId, refreshKey]);
|
| 27 |
+
const data = shift.data;
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<SectionCard title="Distribution shift (train mix vs evaluation population)">
|
| 31 |
+
{shift.error ? <ErrorNote error={shift.error} /> : null}
|
| 32 |
+
{shift.loading && !data ? <LoadingBox /> : null}
|
| 33 |
+
{data ? (
|
| 34 |
+
<>
|
| 35 |
+
<Typography variant="caption" sx={{ color: '#8a949e', display: 'block', mb: 1 }}>
|
| 36 |
+
{data.method} · thresholds: min eval count {fmtCompact(data.thresholds.min_eval_count)}, relative change ≥{' '}
|
| 37 |
+
{fmtPct(data.thresholds.rel_threshold)}
|
| 38 |
+
</Typography>
|
| 39 |
+
{data.shifts.length === 0 ? (
|
| 40 |
+
<Typography variant="body2" sx={{ color: '#8a949e' }}>
|
| 41 |
+
No significant distribution shifts detected.
|
| 42 |
+
</Typography>
|
| 43 |
+
) : (
|
| 44 |
+
<Table size="small">
|
| 45 |
+
<TableHead>
|
| 46 |
+
<TableRow>
|
| 47 |
+
<TableCell>Cohort</TableCell>
|
| 48 |
+
<TableCell align="right">Train → eval share</TableCell>
|
| 49 |
+
<TableCell align="right">Relative change</TableCell>
|
| 50 |
+
<TableCell align="right">Eval count</TableCell>
|
| 51 |
+
<TableCell align="right">Cohort recall vs overall</TableCell>
|
| 52 |
+
<TableCell align="right">Recall gap</TableCell>
|
| 53 |
+
</TableRow>
|
| 54 |
+
</TableHead>
|
| 55 |
+
<TableBody>
|
| 56 |
+
{data.shifts.map((s) => {
|
| 57 |
+
const color = gapColor(s.recall_gap);
|
| 58 |
+
return (
|
| 59 |
+
<TableRow key={s.cohort} hover sx={color === '#ef5350' ? { bgcolor: '#2a1214' } : undefined}>
|
| 60 |
+
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600 }}>{s.cohort}</TableCell>
|
| 61 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 62 |
+
{fmtPct(s.train_share)} → {fmtPct(s.eval_share)}
|
| 63 |
+
</TableCell>
|
| 64 |
+
<TableCell
|
| 65 |
+
align="right"
|
| 66 |
+
sx={{ fontFamily: 'monospace', color: s.relative_change > 0 ? '#ffa726' : '#4fc3f7' }}
|
| 67 |
+
>
|
| 68 |
+
{s.relative_change > 0 ? '+' : ''}
|
| 69 |
+
{Math.round(s.relative_change * 100)}%
|
| 70 |
+
</TableCell>
|
| 71 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 72 |
+
{fmtCompact(s.eval_count)}
|
| 73 |
+
</TableCell>
|
| 74 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace' }}>
|
| 75 |
+
{fmtPct(s.cohort_recall)} vs {fmtPct(s.overall_recall)}
|
| 76 |
+
</TableCell>
|
| 77 |
+
<TableCell align="right" sx={{ fontFamily: 'monospace', fontWeight: 700, color }}>
|
| 78 |
+
{s.recall_gap === null ? '—' : `${(s.recall_gap * 100).toFixed(1)} pts`}
|
| 79 |
+
</TableCell>
|
| 80 |
+
</TableRow>
|
| 81 |
+
);
|
| 82 |
+
})}
|
| 83 |
+
</TableBody>
|
| 84 |
+
</Table>
|
| 85 |
+
)}
|
| 86 |
+
<Box sx={{ mt: 1 }}>
|
| 87 |
+
<Typography variant="caption" sx={{ color: '#8a949e' }}>
|
| 88 |
+
Highlighted rows are cohorts that are over-represented vs training <em>and</em> underperforming on
|
| 89 |
+
recall — prime candidates for targeted data collection.
|
| 90 |
+
</Typography>
|
| 91 |
+
</Box>
|
| 92 |
+
</>
|
| 93 |
+
) : null}
|
| 94 |
+
</SectionCard>
|
| 95 |
+
);
|
| 96 |
+
}
|
src/components/megaeval/shared.tsx
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared UI helpers for the Evaluation Command Center (megaeval).
|
| 3 |
+
* Complements components/labeleval/shared.tsx with aggregate-first idioms:
|
| 4 |
+
* query provenance badges, exact/approx honesty tags, delta coloring,
|
| 5 |
+
* outcome chips and sketch histogram sparklines.
|
| 6 |
+
*/
|
| 7 |
+
import type { ReactNode } from 'react';
|
| 8 |
+
import Box from '@mui/material/Box';
|
| 9 |
+
import Chip from '@mui/material/Chip';
|
| 10 |
+
import Tooltip from '@mui/material/Tooltip';
|
| 11 |
+
import Typography from '@mui/material/Typography';
|
| 12 |
+
import { CheckCircle2, AlertTriangle, OctagonAlert } from 'lucide-react';
|
| 13 |
+
import type { ErrorType, HistogramSketch, QueryMeta, QueryRow } from '../../types/megaeval';
|
| 14 |
+
import { fmtCompact } from '../../services/megaeval';
|
| 15 |
+
import { fmtPct } from '../labeleval/shared';
|
| 16 |
+
|
| 17 |
+
// ---------------------------------------------------------------- query provenance
|
| 18 |
+
|
| 19 |
+
const SOURCE_COLORS: Record<QueryMeta['source'], { bg: string; fg: string }> = {
|
| 20 |
+
cache: { bg: '#1b5e20', fg: '#a5d6a7' },
|
| 21 |
+
cube: { bg: '#0d47a1', fg: '#90caf9' },
|
| 22 |
+
scan: { bg: '#e65100', fg: '#ffe0b2' },
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
function fmtLatency(ms: number): string {
|
| 26 |
+
if (ms < 1) return `${ms.toFixed(1)}ms`;
|
| 27 |
+
if (ms < 100) return `${ms.toFixed(1)}ms`;
|
| 28 |
+
return `${Math.round(ms)}ms`;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/** "cube · 74ms" / "cache · 0.1ms" provenance badge for POST /api/evaluations/query panels. */
|
| 32 |
+
export function QueryBadge({ meta }: { meta: QueryMeta | null | undefined }) {
|
| 33 |
+
if (!meta) return null;
|
| 34 |
+
const colors = SOURCE_COLORS[meta.source] ?? SOURCE_COLORS.cube;
|
| 35 |
+
const label = `${meta.source} · ${fmtLatency(meta.latency_ms)}`;
|
| 36 |
+
return (
|
| 37 |
+
<Tooltip
|
| 38 |
+
title={`source=${meta.source} · cache_hit=${meta.cache_hit ? 'yes' : 'no'} · cells=${meta.cells_touched}${
|
| 39 |
+
meta.exact ? ' · exact' : ` · approx (${meta.approximate_fields.join(', ') || 'sketch'})`
|
| 40 |
+
}`}
|
| 41 |
+
>
|
| 42 |
+
<Chip
|
| 43 |
+
size="small"
|
| 44 |
+
label={meta.cache_hit ? `${label} ✓` : label}
|
| 45 |
+
sx={{
|
| 46 |
+
height: 18,
|
| 47 |
+
fontSize: 10,
|
| 48 |
+
fontFamily: 'monospace',
|
| 49 |
+
fontWeight: 700,
|
| 50 |
+
bgcolor: colors.bg,
|
| 51 |
+
color: colors.fg,
|
| 52 |
+
}}
|
| 53 |
+
/>
|
| 54 |
+
</Tooltip>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** Exact vs sketch-derived honesty tag. */
|
| 59 |
+
export function ExactnessTag({ approx, sx }: { approx: boolean; sx?: object }) {
|
| 60 |
+
return (
|
| 61 |
+
<Chip
|
| 62 |
+
size="small"
|
| 63 |
+
label={approx ? 'approx' : 'exact'}
|
| 64 |
+
sx={{
|
| 65 |
+
height: 16,
|
| 66 |
+
fontSize: 9.5,
|
| 67 |
+
fontFamily: 'monospace',
|
| 68 |
+
bgcolor: approx ? '#4a3b12' : '#12303f',
|
| 69 |
+
color: approx ? '#ffd54f' : '#81d4fa',
|
| 70 |
+
...sx,
|
| 71 |
+
}}
|
| 72 |
+
/>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// ---------------------------------------------------------------- query row helpers
|
| 77 |
+
|
| 78 |
+
/** Reads a numeric metric out of a QueryRow (values arrive as string | number | null). */
|
| 79 |
+
export function rowNum(row: QueryRow, key: string): number | null {
|
| 80 |
+
const v = row[key];
|
| 81 |
+
if (v === null || v === undefined) return null;
|
| 82 |
+
const n = typeof v === 'number' ? v : Number(v);
|
| 83 |
+
return Number.isNaN(n) ? null : n;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export function rowStr(row: QueryRow, key: string): string {
|
| 87 |
+
const v = row[key];
|
| 88 |
+
return v === null || v === undefined ? '—' : String(v);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// ---------------------------------------------------------------- inline percent bar
|
| 92 |
+
|
| 93 |
+
/** Compact percentage cell with an inline bar, for dense aggregate tables. */
|
| 94 |
+
export function PctBarCell({
|
| 95 |
+
value,
|
| 96 |
+
color = '#4fc3f7',
|
| 97 |
+
width = 84,
|
| 98 |
+
}: {
|
| 99 |
+
value: number | null | undefined;
|
| 100 |
+
color?: string;
|
| 101 |
+
width?: number;
|
| 102 |
+
}) {
|
| 103 |
+
if (value === null || value === undefined || Number.isNaN(value)) {
|
| 104 |
+
return (
|
| 105 |
+
<Typography variant="caption" sx={{ color: '#8a949e', fontFamily: 'monospace' }}>
|
| 106 |
+
—
|
| 107 |
+
</Typography>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
const frac = Math.max(0, Math.min(1, value <= 1 ? value : value / 100));
|
| 111 |
+
return (
|
| 112 |
+
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
|
| 113 |
+
<Box sx={{ width, height: 6, bgcolor: '#232a31', borderRadius: 1, overflow: 'hidden' }}>
|
| 114 |
+
<Box sx={{ height: '100%', width: `${frac * 100}%`, bgcolor: color, borderRadius: 1 }} />
|
| 115 |
+
</Box>
|
| 116 |
+
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#e6e9ec', minWidth: 44 }}>
|
| 117 |
+
{fmtPct(value)}
|
| 118 |
+
</Typography>
|
| 119 |
+
</Box>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// ---------------------------------------------------------------- outcome / error type chips
|
| 124 |
+
|
| 125 |
+
export const OUTCOME_COLORS: Record<string, { bg: string; fg: string }> = {
|
| 126 |
+
TP: { bg: '#1b5e20', fg: '#a5d6a7' },
|
| 127 |
+
FN: { bg: '#b71c1c', fg: '#ffcdd2' },
|
| 128 |
+
FP: { bg: '#e65100', fg: '#ffe0b2' },
|
| 129 |
+
LOCALIZATION: { bg: '#4a148c', fg: '#ce93d8' },
|
| 130 |
+
ANOMALY: { bg: '#880e4f', fg: '#f48fb1' },
|
| 131 |
+
LOW_CONF: { bg: '#f9a825', fg: '#212121' },
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
export function OutcomeChip({ outcome }: { outcome: string }) {
|
| 135 |
+
const colors = OUTCOME_COLORS[outcome] ?? { bg: '#37474f', fg: '#cfd8dc' };
|
| 136 |
+
return (
|
| 137 |
+
<Chip
|
| 138 |
+
size="small"
|
| 139 |
+
label={outcome}
|
| 140 |
+
sx={{ height: 18, fontSize: 10, fontWeight: 700, fontFamily: 'monospace', bgcolor: colors.bg, color: colors.fg }}
|
| 141 |
+
/>
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export const ERROR_TYPE_COLORS: Record<ErrorType, string> = {
|
| 146 |
+
FN: '#ef5350',
|
| 147 |
+
FP: '#ffa726',
|
| 148 |
+
LOCALIZATION: '#ab47bc',
|
| 149 |
+
ANOMALY: '#ec407a',
|
| 150 |
+
LOW_CONF: '#ffee58',
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
// ---------------------------------------------------------------- run status
|
| 154 |
+
|
| 155 |
+
const RUN_STATUS_COLORS: Record<string, { bg: string; fg: string }> = {
|
| 156 |
+
created: { bg: '#37474f', fg: '#cfd8dc' },
|
| 157 |
+
queued: { bg: '#37474f', fg: '#cfd8dc' },
|
| 158 |
+
running: { bg: '#0d47a1', fg: '#90caf9' },
|
| 159 |
+
reducing: { bg: '#4a148c', fg: '#ce93d8' },
|
| 160 |
+
materializing: { bg: '#004d40', fg: '#80cbc4' },
|
| 161 |
+
published: { bg: '#1b5e20', fg: '#a5d6a7' },
|
| 162 |
+
failed: { bg: '#b71c1c', fg: '#ffcdd2' },
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
export function RunStatusChip({ status }: { status: string }) {
|
| 166 |
+
const colors = RUN_STATUS_COLORS[status] ?? { bg: '#37474f', fg: '#cfd8dc' };
|
| 167 |
+
return (
|
| 168 |
+
<Chip
|
| 169 |
+
size="small"
|
| 170 |
+
label={status.toUpperCase()}
|
| 171 |
+
sx={{ height: 20, fontSize: 10, fontWeight: 700, letterSpacing: 0.5, bgcolor: colors.bg, color: colors.fg }}
|
| 172 |
+
/>
|
| 173 |
+
);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// ---------------------------------------------------------------- container status
|
| 177 |
+
|
| 178 |
+
export function ContainerStatusIcon({ status }: { status: 'ok' | 'warn' | 'critical' }) {
|
| 179 |
+
if (status === 'critical') return <OctagonAlert size={15} color="#ef5350" />;
|
| 180 |
+
if (status === 'warn') return <AlertTriangle size={15} color="#ffa726" />;
|
| 181 |
+
return <CheckCircle2 size={15} color="#66bb6a" />;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// ---------------------------------------------------------------- deltas
|
| 185 |
+
|
| 186 |
+
/** Colored delta text. Set higherIsBetter=false for metrics like anomaly_rate / error_rate / fp. */
|
| 187 |
+
export function DeltaText({
|
| 188 |
+
delta,
|
| 189 |
+
higherIsBetter = true,
|
| 190 |
+
asPct = true,
|
| 191 |
+
}: {
|
| 192 |
+
delta: number;
|
| 193 |
+
higherIsBetter?: boolean;
|
| 194 |
+
asPct?: boolean;
|
| 195 |
+
}) {
|
| 196 |
+
const good = higherIsBetter ? delta >= 0 : delta <= 0;
|
| 197 |
+
const neutral = Math.abs(delta) < 1e-9;
|
| 198 |
+
const magnitude = asPct ? (Math.abs(delta) * 100).toFixed(1) : Math.abs(delta).toFixed(3);
|
| 199 |
+
const sign = delta > 0 ? '+' : delta < 0 ? '−' : '±';
|
| 200 |
+
const text = `${sign}${magnitude}`;
|
| 201 |
+
return (
|
| 202 |
+
<Typography
|
| 203 |
+
component="span"
|
| 204 |
+
variant="caption"
|
| 205 |
+
sx={{
|
| 206 |
+
fontFamily: 'monospace',
|
| 207 |
+
fontWeight: 700,
|
| 208 |
+
color: neutral ? '#8a949e' : good ? '#66bb6a' : '#ef5350',
|
| 209 |
+
}}
|
| 210 |
+
>
|
| 211 |
+
{text}
|
| 212 |
+
</Typography>
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// ---------------------------------------------------------------- dimension chips
|
| 217 |
+
|
| 218 |
+
export function DimChips({ values, sx }: { values: (string | undefined)[]; sx?: object }) {
|
| 219 |
+
return (
|
| 220 |
+
<Box sx={{ display: 'inline-flex', gap: 0.5, flexWrap: 'wrap', ...sx }}>
|
| 221 |
+
{values
|
| 222 |
+
.filter((v): v is string => Boolean(v))
|
| 223 |
+
.map((v, i) => (
|
| 224 |
+
<Chip
|
| 225 |
+
key={`${v}-${i}`}
|
| 226 |
+
size="small"
|
| 227 |
+
label={v}
|
| 228 |
+
sx={{ height: 18, fontSize: 10, bgcolor: '#232a31', color: '#aab4be' }}
|
| 229 |
+
/>
|
| 230 |
+
))}
|
| 231 |
+
</Box>
|
| 232 |
+
);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// ---------------------------------------------------------------- histogram sparkline (sketch)
|
| 236 |
+
|
| 237 |
+
/** SVG histogram sparkline with p10 / p50 / p90 markers. Sketch-derived — always labeled approx. */
|
| 238 |
+
export function HistogramSparkline({
|
| 239 |
+
sketch,
|
| 240 |
+
color = '#4fc3f7',
|
| 241 |
+
width = 260,
|
| 242 |
+
height = 56,
|
| 243 |
+
}: {
|
| 244 |
+
sketch: HistogramSketch;
|
| 245 |
+
color?: string;
|
| 246 |
+
width?: number;
|
| 247 |
+
height?: number;
|
| 248 |
+
}) {
|
| 249 |
+
const maxCount = Math.max(1, ...sketch.counts);
|
| 250 |
+
const barW = width / Math.max(1, sketch.counts.length);
|
| 251 |
+
const span = sketch.hi - sketch.lo || 1;
|
| 252 |
+
const xFor = (v: number) => ((v - sketch.lo) / span) * width;
|
| 253 |
+
const markers: Array<{ key: string; v: number }> = ['p10', 'p50', 'p90']
|
| 254 |
+
.filter((k) => sketch.percentiles[k] !== undefined)
|
| 255 |
+
.map((k) => ({ key: k, v: sketch.percentiles[k] }));
|
| 256 |
+
return (
|
| 257 |
+
<Box>
|
| 258 |
+
<svg width={width} height={height + 14}>
|
| 259 |
+
{sketch.counts.map((c, i) => {
|
| 260 |
+
const h = (c / maxCount) * height;
|
| 261 |
+
return (
|
| 262 |
+
<rect
|
| 263 |
+
key={i}
|
| 264 |
+
x={i * barW + 0.5}
|
| 265 |
+
y={height - h}
|
| 266 |
+
width={Math.max(1, barW - 1)}
|
| 267 |
+
height={Math.max(0.5, h)}
|
| 268 |
+
fill={color}
|
| 269 |
+
opacity={0.75}
|
| 270 |
+
/>
|
| 271 |
+
);
|
| 272 |
+
})}
|
| 273 |
+
{markers.map((m) => (
|
| 274 |
+
<g key={m.key}>
|
| 275 |
+
<line x1={xFor(m.v)} x2={xFor(m.v)} y1={0} y2={height} stroke="#ffd54f" strokeDasharray="2,2" />
|
| 276 |
+
<text x={xFor(m.v)} y={height + 11} fontSize={9} fill="#ffd54f" textAnchor="middle" fontFamily="monospace">
|
| 277 |
+
{m.key} {m.v.toFixed(2)}
|
| 278 |
+
</text>
|
| 279 |
+
</g>
|
| 280 |
+
))}
|
| 281 |
+
</svg>
|
| 282 |
+
</Box>
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// ---------------------------------------------------------------- misc
|
| 287 |
+
|
| 288 |
+
export function CompactStat({ label, value }: { label: string; value: ReactNode }) {
|
| 289 |
+
return (
|
| 290 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: 90 }}>
|
| 291 |
+
<Typography variant="caption" sx={{ color: '#8a949e', textTransform: 'uppercase', fontSize: 9.5 }}>
|
| 292 |
+
{label}
|
| 293 |
+
</Typography>
|
| 294 |
+
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
| 295 |
+
{value}
|
| 296 |
+
</Typography>
|
| 297 |
+
</Box>
|
| 298 |
+
);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
export function compactWithExact(n: number | null | undefined): string {
|
| 302 |
+
if (n === null || n === undefined) return '—';
|
| 303 |
+
return `${fmtCompact(n)} (${Math.round(n).toLocaleString()})`;
|
| 304 |
+
}
|
src/context/LabelEvalContext.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createContext, useContext } from 'react';
|
|
| 2 |
import type { StreamEvent } from '../types/labeleval';
|
| 3 |
|
| 4 |
export type PageId =
|
|
|
|
| 5 |
| 'overview'
|
| 6 |
| 'datasets'
|
| 7 |
| 'label-generation'
|
|
@@ -19,6 +20,7 @@ export type PageId =
|
|
| 19 |
| 'legacy';
|
| 20 |
|
| 21 |
export const ALL_PAGE_IDS: PageId[] = [
|
|
|
|
| 22 |
'overview',
|
| 23 |
'datasets',
|
| 24 |
'label-generation',
|
|
|
|
| 2 |
import type { StreamEvent } from '../types/labeleval';
|
| 3 |
|
| 4 |
export type PageId =
|
| 5 |
+
| 'command'
|
| 6 |
| 'overview'
|
| 7 |
| 'datasets'
|
| 8 |
| 'label-generation'
|
|
|
|
| 20 |
| 'legacy';
|
| 21 |
|
| 22 |
export const ALL_PAGE_IDS: PageId[] = [
|
| 23 |
+
'command',
|
| 24 |
'overview',
|
| 25 |
'datasets',
|
| 26 |
'label-generation',
|
src/pages/CommandCenterPage.tsx
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Evaluation Command Center — the aggregate-first landing page.
|
| 3 |
+
*
|
| 4 |
+
* Mental model surfaced in the UI:
|
| 5 |
+
* Dataset (population) → Evaluation Run → Population → Cohort → Container → Annotation
|
| 6 |
+
* Aggregates come first; individual annotations are the deepest drill-down.
|
| 7 |
+
*
|
| 8 |
+
* Runs are async: launched runs are queued, progress streams over SSE
|
| 9 |
+
* (stream.megaeval.active_runs) with a 1.5s polling fallback on GET /runs/{id},
|
| 10 |
+
* and the page auto-refreshes when the run publishes.
|
| 11 |
+
*/
|
| 12 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 13 |
+
import Alert from '@mui/material/Alert';
|
| 14 |
+
import Box from '@mui/material/Box';
|
| 15 |
+
import Tab from '@mui/material/Tab';
|
| 16 |
+
import Tabs from '@mui/material/Tabs';
|
| 17 |
+
import Typography from '@mui/material/Typography';
|
| 18 |
+
import type { EvaluationRunInfo, ReviewState, RunStatus } from '../types/megaeval';
|
| 19 |
+
import {
|
| 20 |
+
getCacheStats,
|
| 21 |
+
getPopulations,
|
| 22 |
+
getRun,
|
| 23 |
+
getRunReview,
|
| 24 |
+
getRuns,
|
| 25 |
+
} from '../services/megaeval';
|
| 26 |
+
import { usePoll } from '../services/labeleval';
|
| 27 |
+
import { useLabelEval } from '../context/LabelEvalContext';
|
| 28 |
+
import { EmptyState, ErrorNote, LoadingBox, SectionCard } from '../components/labeleval/shared';
|
| 29 |
+
import HeaderBar, {
|
| 30 |
+
GeneratePopulationButton,
|
| 31 |
+
NewRunButton,
|
| 32 |
+
} from '../components/megaeval/HeaderBar';
|
| 33 |
+
import RunProgressCard from '../components/megaeval/RunProgressCard';
|
| 34 |
+
import HeroRow from '../components/megaeval/HeroRow';
|
| 35 |
+
import QualityTab from '../components/megaeval/QualityTab';
|
| 36 |
+
import CohortTab from '../components/megaeval/CohortTab';
|
| 37 |
+
import ContainersTab from '../components/megaeval/ContainersTab';
|
| 38 |
+
import InvestigationTab from '../components/megaeval/InvestigationTab';
|
| 39 |
+
import CompareTab from '../components/megaeval/CompareTab';
|
| 40 |
+
import ReviewTab from '../components/megaeval/ReviewTab';
|
| 41 |
+
import ShiftTab from '../components/megaeval/ShiftTab';
|
| 42 |
+
import LineageCard from '../components/megaeval/LineageCard';
|
| 43 |
+
|
| 44 |
+
const ACTIVE_STATUSES: RunStatus[] = ['created', 'queued', 'running', 'reducing', 'materializing'];
|
| 45 |
+
|
| 46 |
+
type TabId =
|
| 47 |
+
| 'quality'
|
| 48 |
+
| 'cohorts'
|
| 49 |
+
| 'containers'
|
| 50 |
+
| 'investigation'
|
| 51 |
+
| 'compare'
|
| 52 |
+
| 'review'
|
| 53 |
+
| 'shift'
|
| 54 |
+
| 'lineage';
|
| 55 |
+
|
| 56 |
+
export default function CommandCenterPage() {
|
| 57 |
+
const { stream, entityId, navigate } = useLabelEval();
|
| 58 |
+
|
| 59 |
+
const populationsPoll = usePoll(getPopulations, 15000);
|
| 60 |
+
const runsPoll = usePoll(getRuns, 8000);
|
| 61 |
+
const cachePoll = usePoll(getCacheStats, 10000);
|
| 62 |
+
|
| 63 |
+
const populations = useMemo(() => {
|
| 64 |
+
const list = populationsPoll.data?.populations ?? [];
|
| 65 |
+
return [...list].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''));
|
| 66 |
+
}, [populationsPoll.data]);
|
| 67 |
+
const runs = useMemo(() => runsPoll.data?.runs ?? [], [runsPoll.data]);
|
| 68 |
+
|
| 69 |
+
const [populationId, setPopulationId] = useState<string | null>(null);
|
| 70 |
+
const [runId, setRunId] = useState<string | null>(entityId);
|
| 71 |
+
const [baselineRunId, setBaselineRunId] = useState<string | null>(null);
|
| 72 |
+
const [tab, setTab] = useState<TabId>('quality');
|
| 73 |
+
const [drillContainerId, setDrillContainerId] = useState<number | null>(null);
|
| 74 |
+
const [publishTick, setPublishTick] = useState(0);
|
| 75 |
+
const [review, setReview] = useState<ReviewState | null>(null);
|
| 76 |
+
const [selectedRun, setSelectedRun] = useState<EvaluationRunInfo | null>(null);
|
| 77 |
+
const [runError, setRunError] = useState<string | null>(null);
|
| 78 |
+
|
| 79 |
+
// Explicit selection persists the run in the URL hash (#/command/<run_id>).
|
| 80 |
+
const selectRun = useCallback(
|
| 81 |
+
(id: string) => {
|
| 82 |
+
setRunId(id);
|
| 83 |
+
setDrillContainerId(null);
|
| 84 |
+
navigate('command', id);
|
| 85 |
+
},
|
| 86 |
+
[navigate]
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
// Follow hash changes (back/forward navigation, deep links).
|
| 90 |
+
useEffect(() => {
|
| 91 |
+
if (entityId && entityId !== runId) {
|
| 92 |
+
setRunId(entityId);
|
| 93 |
+
setDrillContainerId(null);
|
| 94 |
+
}
|
| 95 |
+
}, [entityId]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 96 |
+
|
| 97 |
+
// Default selections once data arrives.
|
| 98 |
+
useEffect(() => {
|
| 99 |
+
if (!populationId && populations.length) setPopulationId(populations[0].population_id);
|
| 100 |
+
}, [populations, populationId]);
|
| 101 |
+
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
if (!runId && runs.length) {
|
| 104 |
+
const byCreated = [...runs].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''));
|
| 105 |
+
const pick = byCreated.find((r) => r.status === 'published') ?? byCreated[0];
|
| 106 |
+
if (pick) setRunId(pick.run_id);
|
| 107 |
+
}
|
| 108 |
+
}, [runs, runId]);
|
| 109 |
+
|
| 110 |
+
// Selected run detail: 1.5s polling while the run is in flight (SSE-independent fallback).
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
setSelectedRun(null);
|
| 113 |
+
setRunError(null);
|
| 114 |
+
if (!runId) return;
|
| 115 |
+
let cancelled = false;
|
| 116 |
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
| 117 |
+
const load = async () => {
|
| 118 |
+
try {
|
| 119 |
+
const info = await getRun(runId);
|
| 120 |
+
if (cancelled) return;
|
| 121 |
+
setSelectedRun(info);
|
| 122 |
+
setRunError(null);
|
| 123 |
+
if (info.status !== 'published' && info.status !== 'failed') timer = setTimeout(() => void load(), 1500);
|
| 124 |
+
} catch (err) {
|
| 125 |
+
if (cancelled) return;
|
| 126 |
+
setRunError(err instanceof Error ? err.message : String(err));
|
| 127 |
+
timer = setTimeout(() => void load(), 3000);
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
void load();
|
| 131 |
+
return () => {
|
| 132 |
+
cancelled = true;
|
| 133 |
+
if (timer) clearTimeout(timer);
|
| 134 |
+
};
|
| 135 |
+
}, [runId]);
|
| 136 |
+
|
| 137 |
+
// Auto-refresh all panels when the selected run publishes.
|
| 138 |
+
const prevStatusRef = useRef<RunStatus | null>(null);
|
| 139 |
+
const runsRefresh = runsPoll.refresh;
|
| 140 |
+
useEffect(() => {
|
| 141 |
+
const status = selectedRun?.status ?? null;
|
| 142 |
+
if (status === 'published' && prevStatusRef.current && prevStatusRef.current !== 'published') {
|
| 143 |
+
setPublishTick((t) => t + 1);
|
| 144 |
+
runsRefresh();
|
| 145 |
+
}
|
| 146 |
+
prevStatusRef.current = status;
|
| 147 |
+
}, [selectedRun?.status, runsRefresh]);
|
| 148 |
+
|
| 149 |
+
const published = selectedRun?.status === 'published';
|
| 150 |
+
|
| 151 |
+
// Review state (shared by the hero CI annotations and the Review tab).
|
| 152 |
+
useEffect(() => {
|
| 153 |
+
setReview(null);
|
| 154 |
+
if (!runId || !published) return;
|
| 155 |
+
let cancelled = false;
|
| 156 |
+
getRunReview(runId)
|
| 157 |
+
.then((r) => {
|
| 158 |
+
if (!cancelled) setReview(r);
|
| 159 |
+
})
|
| 160 |
+
.catch(() => undefined);
|
| 161 |
+
return () => {
|
| 162 |
+
cancelled = true;
|
| 163 |
+
};
|
| 164 |
+
}, [runId, published, publishTick]);
|
| 165 |
+
|
| 166 |
+
const megaStatus = stream?.megaeval ?? null;
|
| 167 |
+
const cache = megaStatus?.cache ?? cachePoll.data ?? null;
|
| 168 |
+
|
| 169 |
+
// Prefer SSE progress for the selected run when it is fresher than the poll.
|
| 170 |
+
const streamProgress = megaStatus?.active_runs.find((r) => r.run_id === runId) ?? null;
|
| 171 |
+
const selectedInFlight =
|
| 172 |
+
selectedRun && ACTIVE_STATUSES.includes(selectedRun.status) ? streamProgress ?? selectedRun : null;
|
| 173 |
+
const otherActiveRuns = (megaStatus?.active_runs ?? []).filter((r) => r.run_id !== runId);
|
| 174 |
+
|
| 175 |
+
const openContainer = useCallback((cid: number) => {
|
| 176 |
+
setDrillContainerId(cid);
|
| 177 |
+
setTab('containers');
|
| 178 |
+
}, []);
|
| 179 |
+
|
| 180 |
+
const initialLoading =
|
| 181 |
+
populationsPoll.loading && !populationsPoll.data && runsPoll.loading && !runsPoll.data;
|
| 182 |
+
const noPopulation = populationsPoll.data !== null && populations.length === 0;
|
| 183 |
+
const noRuns = !noPopulation && runsPoll.data !== null && runs.length === 0;
|
| 184 |
+
|
| 185 |
+
const onPopulationGenerated = useCallback(
|
| 186 |
+
(pop: { population_id: string }) => {
|
| 187 |
+
populationsPoll.refresh();
|
| 188 |
+
setPopulationId(pop.population_id);
|
| 189 |
+
},
|
| 190 |
+
[populationsPoll]
|
| 191 |
+
);
|
| 192 |
+
|
| 193 |
+
const onRunCreated = useCallback(
|
| 194 |
+
(run: EvaluationRunInfo) => {
|
| 195 |
+
runsRefresh();
|
| 196 |
+
selectRun(run.run_id);
|
| 197 |
+
},
|
| 198 |
+
[runsRefresh, selectRun]
|
| 199 |
+
);
|
| 200 |
+
|
| 201 |
+
if (initialLoading) return <LoadingBox label="Loading command center…" />;
|
| 202 |
+
|
| 203 |
+
return (
|
| 204 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
| 205 |
+
{populationsPoll.error && !populationsPoll.data ? <ErrorNote error={populationsPoll.error} /> : null}
|
| 206 |
+
|
| 207 |
+
<HeaderBar
|
| 208 |
+
populations={populations}
|
| 209 |
+
populationId={populationId}
|
| 210 |
+
onPopulationChange={setPopulationId}
|
| 211 |
+
runs={runs}
|
| 212 |
+
runId={runId}
|
| 213 |
+
onRunChange={selectRun}
|
| 214 |
+
baselineRunId={baselineRunId}
|
| 215 |
+
onBaselineChange={setBaselineRunId}
|
| 216 |
+
selectedRun={selectedRun}
|
| 217 |
+
cache={cache}
|
| 218 |
+
onGenerated={onPopulationGenerated}
|
| 219 |
+
onRunCreated={onRunCreated}
|
| 220 |
+
/>
|
| 221 |
+
|
| 222 |
+
<Typography variant="caption" sx={{ color: '#8a949e', mt: -1 }}>
|
| 223 |
+
Dataset (population) → Evaluation run → Population → Cohort → Container → Annotation — aggregates first,
|
| 224 |
+
individual annotations only at the deepest drill-down.
|
| 225 |
+
</Typography>
|
| 226 |
+
|
| 227 |
+
{/* Empty states: nothing generated / no runs yet */}
|
| 228 |
+
{noPopulation ? (
|
| 229 |
+
<SectionCard title="Welcome to the Evaluation Command Center">
|
| 230 |
+
<EmptyState
|
| 231 |
+
title="No evaluation population yet"
|
| 232 |
+
message="Generate a synthetic mega-scale population (hundreds of thousands of annotated objects across weather, lighting, scenario and sensor cohorts), then launch an evaluation run against it."
|
| 233 |
+
action={
|
| 234 |
+
<Box sx={{ display: 'flex', gap: 1.5, justifyContent: 'center' }}>
|
| 235 |
+
<GeneratePopulationButton variant="contained" size="large" onGenerated={onPopulationGenerated} />
|
| 236 |
+
</Box>
|
| 237 |
+
}
|
| 238 |
+
/>
|
| 239 |
+
</SectionCard>
|
| 240 |
+
) : null}
|
| 241 |
+
|
| 242 |
+
{noRuns ? (
|
| 243 |
+
<SectionCard title="Population ready — no evaluation runs yet">
|
| 244 |
+
<EmptyState
|
| 245 |
+
title="Launch your first evaluation run"
|
| 246 |
+
message="An evaluation run scores every object in the population against a model version, then materializes the aggregate cube, error index and sketches that power this page."
|
| 247 |
+
action={
|
| 248 |
+
<Box sx={{ display: 'flex', gap: 1.5, justifyContent: 'center' }}>
|
| 249 |
+
<NewRunButton variant="contained" size="large" populationId={populationId} onCreated={onRunCreated} />
|
| 250 |
+
<GeneratePopulationButton variant="outlined" size="large" onGenerated={onPopulationGenerated} />
|
| 251 |
+
</Box>
|
| 252 |
+
}
|
| 253 |
+
/>
|
| 254 |
+
</SectionCard>
|
| 255 |
+
) : null}
|
| 256 |
+
|
| 257 |
+
{runError && !selectedRun ? <ErrorNote error={runError} /> : null}
|
| 258 |
+
|
| 259 |
+
{/* Live progress for the selected run + any other active runs */}
|
| 260 |
+
{selectedInFlight ? <RunProgressCard progress={selectedInFlight} /> : null}
|
| 261 |
+
{otherActiveRuns.map((r) => (
|
| 262 |
+
<RunProgressCard key={r.run_id} progress={r} />
|
| 263 |
+
))}
|
| 264 |
+
|
| 265 |
+
{selectedRun?.status === 'failed' ? (
|
| 266 |
+
<Alert severity="error" variant="outlined">
|
| 267 |
+
Run {selectedRun.run_id.slice(0, 8)} failed{selectedRun.error ? `: ${selectedRun.error}` : '.'} Launch a new
|
| 268 |
+
evaluation run from the header.
|
| 269 |
+
</Alert>
|
| 270 |
+
) : null}
|
| 271 |
+
|
| 272 |
+
{/* Hero aggregates + tab sections (published runs only) */}
|
| 273 |
+
{published && selectedRun ? (
|
| 274 |
+
<>
|
| 275 |
+
<HeroRow run={selectedRun} review={review} refreshKey={publishTick} />
|
| 276 |
+
|
| 277 |
+
<Tabs
|
| 278 |
+
value={tab}
|
| 279 |
+
onChange={(_, v: TabId) => setTab(v)}
|
| 280 |
+
variant="scrollable"
|
| 281 |
+
scrollButtons="auto"
|
| 282 |
+
sx={{
|
| 283 |
+
minHeight: 36,
|
| 284 |
+
borderBottom: '1px solid #232a31',
|
| 285 |
+
'& .MuiTab-root': { minHeight: 36, py: 0.5 },
|
| 286 |
+
}}
|
| 287 |
+
>
|
| 288 |
+
<Tab label="Quality" value="quality" />
|
| 289 |
+
<Tab label="Cohorts" value="cohorts" />
|
| 290 |
+
<Tab label="Containers" value="containers" />
|
| 291 |
+
<Tab label="Investigation" value="investigation" />
|
| 292 |
+
<Tab label="Compare" value="compare" />
|
| 293 |
+
<Tab label="Review" value="review" />
|
| 294 |
+
<Tab label="Shift" value="shift" />
|
| 295 |
+
<Tab label="Lineage" value="lineage" />
|
| 296 |
+
</Tabs>
|
| 297 |
+
|
| 298 |
+
{tab === 'quality' ? (
|
| 299 |
+
<QualityTab runId={selectedRun.run_id} runs={runs} refreshKey={publishTick} />
|
| 300 |
+
) : null}
|
| 301 |
+
{tab === 'cohorts' ? <CohortTab runId={selectedRun.run_id} refreshKey={publishTick} /> : null}
|
| 302 |
+
{tab === 'containers' ? (
|
| 303 |
+
<ContainersTab
|
| 304 |
+
runId={selectedRun.run_id}
|
| 305 |
+
refreshKey={publishTick}
|
| 306 |
+
drillContainerId={drillContainerId}
|
| 307 |
+
onDrill={setDrillContainerId}
|
| 308 |
+
/>
|
| 309 |
+
) : null}
|
| 310 |
+
{tab === 'investigation' ? (
|
| 311 |
+
<InvestigationTab runId={selectedRun.run_id} onOpenContainer={openContainer} />
|
| 312 |
+
) : null}
|
| 313 |
+
{tab === 'compare' ? (
|
| 314 |
+
<CompareTab runId={selectedRun.run_id} baselineRunId={baselineRunId} refreshKey={publishTick} />
|
| 315 |
+
) : null}
|
| 316 |
+
{tab === 'review' ? (
|
| 317 |
+
<ReviewTab runId={selectedRun.run_id} review={review} onReview={setReview} />
|
| 318 |
+
) : null}
|
| 319 |
+
{tab === 'shift' ? <ShiftTab runId={selectedRun.run_id} refreshKey={publishTick} /> : null}
|
| 320 |
+
{tab === 'lineage' ? <LineageCard lineage={selectedRun.lineage} /> : null}
|
| 321 |
+
</>
|
| 322 |
+
) : null}
|
| 323 |
+
</Box>
|
| 324 |
+
);
|
| 325 |
+
}
|
src/services/labeleval.ts
CHANGED
|
@@ -34,6 +34,8 @@ import type {
|
|
| 34 |
TrainRequest,
|
| 35 |
TrainResponse,
|
| 36 |
} from '../types/labeleval';
|
|
|
|
|
|
|
| 37 |
|
| 38 |
async function handle<T>(res: Response): Promise<T> {
|
| 39 |
if (!res.ok) {
|
|
@@ -232,12 +234,19 @@ export function useStream(): StreamEvent | null {
|
|
| 232 |
} catch {
|
| 233 |
/* keep previous alert count */
|
| 234 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
if (!disposed) {
|
| 236 |
setEvent({
|
| 237 |
ts: new Date().toISOString(),
|
| 238 |
pipeline,
|
| 239 |
training: null,
|
| 240 |
alerts_count: lastAlerts,
|
|
|
|
| 241 |
});
|
| 242 |
}
|
| 243 |
} catch {
|
|
|
|
| 34 |
TrainRequest,
|
| 35 |
TrainResponse,
|
| 36 |
} from '../types/labeleval';
|
| 37 |
+
import type { MegaevalStatus } from '../types/megaeval';
|
| 38 |
+
import { getMegaevalStatus } from './megaeval';
|
| 39 |
|
| 40 |
async function handle<T>(res: Response): Promise<T> {
|
| 41 |
if (!res.ok) {
|
|
|
|
| 234 |
} catch {
|
| 235 |
/* keep previous alert count */
|
| 236 |
}
|
| 237 |
+
let megaeval: MegaevalStatus | undefined;
|
| 238 |
+
try {
|
| 239 |
+
megaeval = await getMegaevalStatus();
|
| 240 |
+
} catch {
|
| 241 |
+
/* megaeval layer unavailable — leave the field absent */
|
| 242 |
+
}
|
| 243 |
if (!disposed) {
|
| 244 |
setEvent({
|
| 245 |
ts: new Date().toISOString(),
|
| 246 |
pipeline,
|
| 247 |
training: null,
|
| 248 |
alerts_count: lastAlerts,
|
| 249 |
+
...(megaeval ? { megaeval } : {}),
|
| 250 |
});
|
| 251 |
}
|
| 252 |
} catch {
|
src/services/megaeval.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Typed fetch wrappers for the aggregate-first mega-scale evaluation API
|
| 3 |
+
* ("megaeval"). Mirrors the style of services/labeleval.ts.
|
| 4 |
+
*
|
| 5 |
+
* Async run semantics: POST /runs returns immediately with status "queued";
|
| 6 |
+
* results endpoints return HTTP 409 until the run is "published".
|
| 7 |
+
*/
|
| 8 |
+
import type {
|
| 9 |
+
CacheStats,
|
| 10 |
+
CompareRequest,
|
| 11 |
+
CompareResponse,
|
| 12 |
+
ContainerObjectsResponse,
|
| 13 |
+
ContainerSortPreset,
|
| 14 |
+
ContainersResponse,
|
| 15 |
+
CreateRunRequest,
|
| 16 |
+
DimName,
|
| 17 |
+
DistributionsResponse,
|
| 18 |
+
ErrorSearchRequest,
|
| 19 |
+
ErrorSearchResponse,
|
| 20 |
+
EvaluationQueryRequest,
|
| 21 |
+
EvaluationQueryResponse,
|
| 22 |
+
EvaluationRunInfo,
|
| 23 |
+
GeneratePopulationRequest,
|
| 24 |
+
MegaevalStatus,
|
| 25 |
+
PopulationMeta,
|
| 26 |
+
QualityFunnelResponse,
|
| 27 |
+
ReviewState,
|
| 28 |
+
ShiftResponse,
|
| 29 |
+
SimilarityRequest,
|
| 30 |
+
SimilarityResponse,
|
| 31 |
+
WhyRequest,
|
| 32 |
+
WhyResponse,
|
| 33 |
+
} from '../types/megaeval';
|
| 34 |
+
|
| 35 |
+
async function handle<T>(res: Response): Promise<T> {
|
| 36 |
+
if (!res.ok) {
|
| 37 |
+
const body = await res.text().catch(() => '');
|
| 38 |
+
throw new Error(`API ${res.status}: ${body || res.statusText}`);
|
| 39 |
+
}
|
| 40 |
+
return res.json() as Promise<T>;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
| 44 |
+
|
| 45 |
+
function get<T>(url: string): Promise<T> {
|
| 46 |
+
return fetch(url).then((res) => handle<T>(res));
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function post<T>(url: string, body?: unknown): Promise<T> {
|
| 50 |
+
return fetch(url, {
|
| 51 |
+
method: 'POST',
|
| 52 |
+
headers: JSON_HEADERS,
|
| 53 |
+
body: body === undefined ? undefined : JSON.stringify(body),
|
| 54 |
+
}).then((res) => handle<T>(res));
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// ---------------------------------------------------------------- populations
|
| 58 |
+
|
| 59 |
+
export const generatePopulation = (body: GeneratePopulationRequest) =>
|
| 60 |
+
post<PopulationMeta>('/api/megaeval/population/generate', body);
|
| 61 |
+
|
| 62 |
+
export const getPopulations = () =>
|
| 63 |
+
get<{ populations: PopulationMeta[] }>('/api/megaeval/populations');
|
| 64 |
+
|
| 65 |
+
export const getDimensions = () =>
|
| 66 |
+
get<{ dimensions: Record<DimName, string[]> }>('/api/megaeval/dimensions');
|
| 67 |
+
|
| 68 |
+
// ---------------------------------------------------------------- runs
|
| 69 |
+
|
| 70 |
+
export const createRun = (body: CreateRunRequest) =>
|
| 71 |
+
post<EvaluationRunInfo>('/api/megaeval/runs', body);
|
| 72 |
+
|
| 73 |
+
export const getRuns = () => get<{ runs: EvaluationRunInfo[] }>('/api/megaeval/runs');
|
| 74 |
+
|
| 75 |
+
export const getRun = (runId: string) =>
|
| 76 |
+
get<EvaluationRunInfo>(`/api/megaeval/runs/${encodeURIComponent(runId)}`);
|
| 77 |
+
|
| 78 |
+
export const getMegaevalStatus = () => get<MegaevalStatus>('/api/megaeval/status');
|
| 79 |
+
|
| 80 |
+
// ---------------------------------------------------------------- query / cache
|
| 81 |
+
|
| 82 |
+
export const queryEvaluations = (body: EvaluationQueryRequest) =>
|
| 83 |
+
post<EvaluationQueryResponse>('/api/evaluations/query', body);
|
| 84 |
+
|
| 85 |
+
export const getCacheStats = () => get<CacheStats>('/api/megaeval/cache');
|
| 86 |
+
|
| 87 |
+
// ---------------------------------------------------------------- run results
|
| 88 |
+
|
| 89 |
+
export const getRunFunnel = (runId: string) =>
|
| 90 |
+
get<QualityFunnelResponse>(`/api/megaeval/runs/${encodeURIComponent(runId)}/funnel`);
|
| 91 |
+
|
| 92 |
+
export const getRunContainers = (
|
| 93 |
+
runId: string,
|
| 94 |
+
sort: ContainerSortPreset = 'highest_risk',
|
| 95 |
+
limit = 50,
|
| 96 |
+
offset = 0
|
| 97 |
+
) =>
|
| 98 |
+
get<ContainersResponse>(
|
| 99 |
+
`/api/megaeval/runs/${encodeURIComponent(runId)}/containers?sort=${encodeURIComponent(
|
| 100 |
+
sort
|
| 101 |
+
)}&limit=${limit}&offset=${offset}`
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
export const getContainerObjects = (runId: string, containerId: number) =>
|
| 105 |
+
get<ContainerObjectsResponse>(
|
| 106 |
+
`/api/megaeval/runs/${encodeURIComponent(runId)}/containers/${containerId}/objects`
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
export const searchErrors = (body: ErrorSearchRequest) =>
|
| 110 |
+
post<ErrorSearchResponse>('/api/megaeval/errors/search', body);
|
| 111 |
+
|
| 112 |
+
export const compareRuns = (body: CompareRequest) =>
|
| 113 |
+
post<CompareResponse>('/api/megaeval/compare', body);
|
| 114 |
+
|
| 115 |
+
export const getRunShift = (runId: string) =>
|
| 116 |
+
get<ShiftResponse>(`/api/megaeval/runs/${encodeURIComponent(runId)}/shift`);
|
| 117 |
+
|
| 118 |
+
export const whyAnalysis = (body: WhyRequest) => post<WhyResponse>('/api/megaeval/why', body);
|
| 119 |
+
|
| 120 |
+
export const findSimilarContainers = (body: SimilarityRequest) =>
|
| 121 |
+
post<SimilarityResponse>('/api/megaeval/similarity', body);
|
| 122 |
+
|
| 123 |
+
// ---------------------------------------------------------------- review sampling
|
| 124 |
+
|
| 125 |
+
export const getRunReview = (runId: string) =>
|
| 126 |
+
get<ReviewState>(`/api/megaeval/runs/${encodeURIComponent(runId)}/review`);
|
| 127 |
+
|
| 128 |
+
export const planReview = (runId: string, targetN?: number) =>
|
| 129 |
+
post<ReviewState>(
|
| 130 |
+
`/api/megaeval/runs/${encodeURIComponent(runId)}/review/plan`,
|
| 131 |
+
targetN !== undefined ? { target_n: targetN } : {}
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
export const executeReview = (runId: string) =>
|
| 135 |
+
post<ReviewState>(`/api/megaeval/runs/${encodeURIComponent(runId)}/review/execute`);
|
| 136 |
+
|
| 137 |
+
// ---------------------------------------------------------------- distributions
|
| 138 |
+
|
| 139 |
+
export const getRunDistributions = (runId: string) =>
|
| 140 |
+
get<DistributionsResponse>(`/api/megaeval/runs/${encodeURIComponent(runId)}/distributions`);
|
| 141 |
+
|
| 142 |
+
// ---------------------------------------------------------------- formatting
|
| 143 |
+
|
| 144 |
+
/** Compact count formatting for large aggregates: 950 -> "950", 214_700 -> "214.7K", 1_300_000 -> "1.3M". */
|
| 145 |
+
export function fmtCompact(n: number | null | undefined): string {
|
| 146 |
+
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
| 147 |
+
const abs = Math.abs(n);
|
| 148 |
+
if (abs >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
| 149 |
+
if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
| 150 |
+
if (abs >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
| 151 |
+
return Math.round(n).toLocaleString();
|
| 152 |
+
}
|
src/types/labeleval.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
| 4 |
* Keep field names snake_case to match the JSON wire format exactly.
|
| 5 |
*/
|
| 6 |
|
|
|
|
|
|
|
| 7 |
// ---------------------------------------------------------------- enums
|
| 8 |
|
| 9 |
export type GroundTruthType =
|
|
@@ -586,4 +588,6 @@ export interface StreamEvent {
|
|
| 586 |
pipeline: PipelineStateResponse;
|
| 587 |
training: TrainingJobStatus | null;
|
| 588 |
alerts_count: number;
|
|
|
|
|
|
|
| 589 |
}
|
|
|
|
| 4 |
* Keep field names snake_case to match the JSON wire format exactly.
|
| 5 |
*/
|
| 6 |
|
| 7 |
+
import type { MegaevalStatus } from './megaeval';
|
| 8 |
+
|
| 9 |
// ---------------------------------------------------------------- enums
|
| 10 |
|
| 11 |
export type GroundTruthType =
|
|
|
|
| 588 |
pipeline: PipelineStateResponse;
|
| 589 |
training: TrainingJobStatus | null;
|
| 590 |
alerts_count: number;
|
| 591 |
+
/** Mega-scale evaluation layer status; absent on older backends and in the polling fallback. */
|
| 592 |
+
megaeval?: MegaevalStatus;
|
| 593 |
}
|
src/types/megaeval.ts
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* API contract for the aggregate-first mega-scale evaluation layer (megaeval).
|
| 3 |
+
*
|
| 4 |
+
* Mental model: Dataset (population) -> Evaluation Run -> Population -> Cohort
|
| 5 |
+
* -> Container -> Annotation (deepest, forensic drill-down only).
|
| 6 |
+
*
|
| 7 |
+
* Endpoints (all JSON):
|
| 8 |
+
* POST /api/megaeval/population/generate GeneratePopulationRequest -> PopulationMeta
|
| 9 |
+
* GET /api/megaeval/populations -> { populations: PopulationMeta[] }
|
| 10 |
+
* GET /api/megaeval/dimensions -> { dimensions: Record<DimName, string[]> }
|
| 11 |
+
* POST /api/megaeval/runs CreateRunRequest -> EvaluationRunInfo (async job!)
|
| 12 |
+
* GET /api/megaeval/runs -> { runs: EvaluationRunInfo[] }
|
| 13 |
+
* GET /api/megaeval/runs/{run_id} -> EvaluationRunInfo
|
| 14 |
+
* GET /api/megaeval/status -> MegaevalStatus (also inside SSE stream)
|
| 15 |
+
* POST /api/evaluations/query EvaluationQueryRequest -> EvaluationQueryResponse
|
| 16 |
+
* GET /api/megaeval/cache -> CacheStats
|
| 17 |
+
* GET /api/megaeval/runs/{id}/funnel -> QualityFunnelResponse
|
| 18 |
+
* GET /api/megaeval/runs/{id}/containers?sort=&limit=&offset= -> ContainersResponse
|
| 19 |
+
* GET /api/megaeval/runs/{id}/containers/{cid}/objects -> ContainerObjectsResponse
|
| 20 |
+
* POST /api/megaeval/errors/search ErrorSearchRequest -> ErrorSearchResponse
|
| 21 |
+
* POST /api/megaeval/compare CompareRequest -> CompareResponse
|
| 22 |
+
* GET /api/megaeval/runs/{id}/shift -> ShiftResponse
|
| 23 |
+
* POST /api/megaeval/why WhyRequest -> WhyResponse
|
| 24 |
+
* POST /api/megaeval/similarity SimilarityRequest -> SimilarityResponse
|
| 25 |
+
* GET /api/megaeval/runs/{id}/review -> ReviewState
|
| 26 |
+
* POST /api/megaeval/runs/{id}/review/plan { target_n?: number } -> ReviewState
|
| 27 |
+
* POST /api/megaeval/runs/{id}/review/execute -> ReviewState
|
| 28 |
+
* GET /api/megaeval/runs/{id}/distributions -> DistributionsResponse
|
| 29 |
+
*
|
| 30 |
+
* Notes:
|
| 31 |
+
* - Runs are async: POST /runs returns immediately with status "queued"; poll
|
| 32 |
+
* GET /runs/{id} or use the SSE stream's `megaeval.active_runs` for progress.
|
| 33 |
+
* - Results endpoints return HTTP 409 until the run is "published".
|
| 34 |
+
* - Query responses carry meta.source ("cache"|"cube"|"scan"), meta.cache_hit and
|
| 35 |
+
* meta.latency_ms — the UI should surface these as badges.
|
| 36 |
+
* - meta.exact=false means sketch-derived (approximate) numbers are included.
|
| 37 |
+
*/
|
| 38 |
+
|
| 39 |
+
export type DimName =
|
| 40 |
+
| 'class' | 'weather' | 'lighting' | 'road_type' | 'scenario'
|
| 41 |
+
| 'sensor' | 'distance_band' | 'speed_band' | 'occlusion';
|
| 42 |
+
|
| 43 |
+
export const DIM_NAMES: DimName[] = [
|
| 44 |
+
'class', 'weather', 'lighting', 'road_type', 'scenario',
|
| 45 |
+
'sensor', 'distance_band', 'speed_band', 'occlusion',
|
| 46 |
+
];
|
| 47 |
+
|
| 48 |
+
export type RunStatus =
|
| 49 |
+
| 'created' | 'queued' | 'running' | 'reducing' | 'materializing'
|
| 50 |
+
| 'published' | 'failed';
|
| 51 |
+
|
| 52 |
+
// ---------------------------------------------------------------- populations
|
| 53 |
+
|
| 54 |
+
export interface GeneratePopulationRequest {
|
| 55 |
+
name?: string;
|
| 56 |
+
num_objects?: number; // 1_000 .. 1_200_000, default 320_000
|
| 57 |
+
seed?: number;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface TrainMixRow {
|
| 61 |
+
class: string;
|
| 62 |
+
weather: string;
|
| 63 |
+
lighting: string;
|
| 64 |
+
train_share: number;
|
| 65 |
+
eval_share: number;
|
| 66 |
+
eval_count: number;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export interface PopulationMeta {
|
| 70 |
+
population_id: string;
|
| 71 |
+
name: string;
|
| 72 |
+
created_at: string;
|
| 73 |
+
seed: number;
|
| 74 |
+
num_objects: number;
|
| 75 |
+
num_containers: number;
|
| 76 |
+
num_partitions: number;
|
| 77 |
+
avg_objects_per_container: number;
|
| 78 |
+
dimensions: Record<DimName, string[]>;
|
| 79 |
+
dim_counts: Record<DimName, Record<string, number>>;
|
| 80 |
+
safety_critical_count: number;
|
| 81 |
+
train_mix: TrainMixRow[];
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// ---------------------------------------------------------------- runs
|
| 85 |
+
|
| 86 |
+
export interface CreateRunRequest {
|
| 87 |
+
population_id: string;
|
| 88 |
+
model_version: string;
|
| 89 |
+
overrides?: Record<string, number>; // e.g. { night_penalty: 0.3 } to demo a regression
|
| 90 |
+
worker_delay_s?: number; // simulated distributed I/O per partition (default 0.5)
|
| 91 |
+
workers?: number; // default 4
|
| 92 |
+
seed?: number | null;
|
| 93 |
+
label_version?: string;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export interface RunProgress {
|
| 97 |
+
run_id: string;
|
| 98 |
+
population_id: string;
|
| 99 |
+
model_version: string;
|
| 100 |
+
status: RunStatus;
|
| 101 |
+
percent: number;
|
| 102 |
+
partitions_done: number;
|
| 103 |
+
partitions_total: number;
|
| 104 |
+
objects_processed: number;
|
| 105 |
+
objects_total: number;
|
| 106 |
+
workers: number;
|
| 107 |
+
throughput_objs_per_s: number;
|
| 108 |
+
eta_s: number | null;
|
| 109 |
+
error: string | null;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export interface RunLineage {
|
| 113 |
+
evaluation_id: string;
|
| 114 |
+
dataset_version: string;
|
| 115 |
+
model_version: string;
|
| 116 |
+
model_checkpoint: string;
|
| 117 |
+
label_version: string;
|
| 118 |
+
evaluator_code_version: string;
|
| 119 |
+
metric_version: string;
|
| 120 |
+
threshold_config: Record<string, number>;
|
| 121 |
+
sampling_config: Record<string, unknown>;
|
| 122 |
+
seed: number;
|
| 123 |
+
hardware: string;
|
| 124 |
+
timestamp: string;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export interface RunHeadline {
|
| 128 |
+
n: number;
|
| 129 |
+
tp?: number;
|
| 130 |
+
fp?: number;
|
| 131 |
+
fn?: number;
|
| 132 |
+
loc_err?: number;
|
| 133 |
+
anomalies?: number;
|
| 134 |
+
precision?: number | null;
|
| 135 |
+
recall?: number | null;
|
| 136 |
+
f1?: number | null;
|
| 137 |
+
mean_iou?: number | null;
|
| 138 |
+
anomaly_rate?: number | null;
|
| 139 |
+
safety_recall?: number | null;
|
| 140 |
+
conf_mean?: number | null;
|
| 141 |
+
error_rate?: number | null;
|
| 142 |
+
containers?: number;
|
| 143 |
+
containers_hll_estimate?: number;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
export interface EvaluationRunInfo extends RunProgress {
|
| 147 |
+
created_at: string;
|
| 148 |
+
started_at: string | null;
|
| 149 |
+
published_at: string | null;
|
| 150 |
+
lineage: RunLineage;
|
| 151 |
+
headline: RunHeadline; // {} until published
|
| 152 |
+
per_class: Record<string, QueryRow>; // {} until published
|
| 153 |
+
overrides: Record<string, number>;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
export interface CacheStats {
|
| 157 |
+
entries: number;
|
| 158 |
+
hits: number;
|
| 159 |
+
misses: number;
|
| 160 |
+
hit_rate: number | null;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
export interface MegaevalStatus {
|
| 164 |
+
active_runs: RunProgress[];
|
| 165 |
+
cache: CacheStats | null;
|
| 166 |
+
total_runs: number;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// ---------------------------------------------------------------- query API
|
| 170 |
+
|
| 171 |
+
export type MetricName =
|
| 172 |
+
| 'n' | 'tp' | 'fp' | 'fn' | 'loc_err' | 'anomalies' | 'reviewed' | 'verified'
|
| 173 |
+
| 'safety_n' | 'safety_tp'
|
| 174 |
+
| 'precision' | 'recall' | 'f1' | 'mean_iou' | 'anomaly_rate'
|
| 175 |
+
| 'conf_mean' | 'conf_std' | 'safety_recall' | 'review_coverage'
|
| 176 |
+
| 'verified_rate' | 'error_rate'
|
| 177 |
+
| 'confidence_p50' | 'confidence_p10' | 'confidence_p90'
|
| 178 |
+
| 'iou_p50' | 'iou_p10' | 'iou_p90';
|
| 179 |
+
|
| 180 |
+
export interface EvaluationQueryRequest {
|
| 181 |
+
evaluation_id: string; // run_id
|
| 182 |
+
filters?: Partial<Record<DimName, string[]>>;
|
| 183 |
+
metrics?: MetricName[];
|
| 184 |
+
group_by?: DimName[];
|
| 185 |
+
limit?: number;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/** One aggregate row: group_by dims as strings + requested metrics. n always present. */
|
| 189 |
+
export type QueryRow = { n: number } & Record<string, string | number | null>;
|
| 190 |
+
|
| 191 |
+
export interface QueryMeta {
|
| 192 |
+
source: 'cache' | 'cube' | 'scan';
|
| 193 |
+
cache_hit: boolean;
|
| 194 |
+
latency_ms: number;
|
| 195 |
+
cells_touched: number;
|
| 196 |
+
exact: boolean;
|
| 197 |
+
approximate_fields: string[];
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
export interface EvaluationQueryResponse {
|
| 201 |
+
rows: QueryRow[];
|
| 202 |
+
meta: QueryMeta;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// ---------------------------------------------------------------- funnel
|
| 206 |
+
|
| 207 |
+
export interface FunnelStage {
|
| 208 |
+
stage: string;
|
| 209 |
+
count: number;
|
| 210 |
+
pct_of_population: number;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
export interface QualityFunnelResponse {
|
| 214 |
+
stages: FunnelStage[];
|
| 215 |
+
estimated_precision: {
|
| 216 |
+
estimate: number | null;
|
| 217 |
+
ci_low: number | null;
|
| 218 |
+
ci_high: number | null;
|
| 219 |
+
n_reviewed: number | null;
|
| 220 |
+
} | null;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// ---------------------------------------------------------------- containers
|
| 224 |
+
|
| 225 |
+
export type ContainerSortPreset =
|
| 226 |
+
| 'worst_recall' | 'worst_precision' | 'worst_iou'
|
| 227 |
+
| 'most_anomalies' | 'least_verified' | 'highest_risk';
|
| 228 |
+
|
| 229 |
+
export interface ContainerRow {
|
| 230 |
+
container_id: number;
|
| 231 |
+
weather: string;
|
| 232 |
+
lighting: string;
|
| 233 |
+
road_type: string;
|
| 234 |
+
scenario: string;
|
| 235 |
+
n_objects: number;
|
| 236 |
+
tp: number;
|
| 237 |
+
fp: number;
|
| 238 |
+
fn: number;
|
| 239 |
+
anomalies: number;
|
| 240 |
+
reviewed: number;
|
| 241 |
+
verified: number;
|
| 242 |
+
recall: number | null;
|
| 243 |
+
precision: number | null;
|
| 244 |
+
mean_iou: number | null;
|
| 245 |
+
risk_score: number;
|
| 246 |
+
status: 'ok' | 'warn' | 'critical';
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
export interface ContainersResponse {
|
| 250 |
+
total: number;
|
| 251 |
+
sort: string;
|
| 252 |
+
rows: ContainerRow[];
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/** Forensic object row — the deepest drill-down (old annotation-table level). */
|
| 256 |
+
export interface ContainerObject {
|
| 257 |
+
annotation_id: string;
|
| 258 |
+
container_id: number;
|
| 259 |
+
class: string;
|
| 260 |
+
weather: string;
|
| 261 |
+
lighting: string;
|
| 262 |
+
road_type: string;
|
| 263 |
+
scenario: string;
|
| 264 |
+
sensor: string;
|
| 265 |
+
distance_band: string;
|
| 266 |
+
speed_band: string;
|
| 267 |
+
occlusion: string;
|
| 268 |
+
safety_critical: boolean;
|
| 269 |
+
difficulty: number | null;
|
| 270 |
+
detected: boolean;
|
| 271 |
+
outcome: 'TP' | 'FN' | 'FP' | 'LOCALIZATION';
|
| 272 |
+
iou: number | null;
|
| 273 |
+
confidence: number | null;
|
| 274 |
+
anomaly: boolean;
|
| 275 |
+
sensor_disagree: boolean;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
export interface ContainerObjectsResponse {
|
| 279 |
+
container_id: number;
|
| 280 |
+
objects: ContainerObject[];
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// ---------------------------------------------------------------- error index
|
| 284 |
+
|
| 285 |
+
export type ErrorType = 'FN' | 'FP' | 'LOCALIZATION' | 'ANOMALY' | 'LOW_CONF';
|
| 286 |
+
|
| 287 |
+
export interface ErrorSearchRequest {
|
| 288 |
+
run_id: string;
|
| 289 |
+
error_types?: ErrorType[];
|
| 290 |
+
filters?: Partial<Record<DimName, string[]>>;
|
| 291 |
+
confidence_max?: number | null;
|
| 292 |
+
confidence_min?: number | null;
|
| 293 |
+
risk_min?: number | null;
|
| 294 |
+
severity_min?: number | null;
|
| 295 |
+
safety_only?: boolean;
|
| 296 |
+
limit_containers?: number;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
export interface WorstContainer {
|
| 300 |
+
container_id: number;
|
| 301 |
+
error_count: number;
|
| 302 |
+
mean_risk: number;
|
| 303 |
+
max_severity: number;
|
| 304 |
+
safety_hits: number;
|
| 305 |
+
weather?: string;
|
| 306 |
+
lighting?: string;
|
| 307 |
+
road_type?: string;
|
| 308 |
+
scenario?: string;
|
| 309 |
+
n_objects?: number;
|
| 310 |
+
risk_score?: number;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
export interface ErrorExample {
|
| 314 |
+
error_id: number;
|
| 315 |
+
annotation_id: string;
|
| 316 |
+
container_id: number;
|
| 317 |
+
error_type: ErrorType;
|
| 318 |
+
class: string;
|
| 319 |
+
severity: number;
|
| 320 |
+
confidence: number;
|
| 321 |
+
risk_score: number;
|
| 322 |
+
safety_critical: boolean;
|
| 323 |
+
scenario: string;
|
| 324 |
+
lighting: string;
|
| 325 |
+
weather: string;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
export interface ErrorSearchResponse {
|
| 329 |
+
matched_errors: number;
|
| 330 |
+
by_type: Partial<Record<ErrorType, number>>;
|
| 331 |
+
worst_containers: WorstContainer[];
|
| 332 |
+
examples: ErrorExample[];
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// ---------------------------------------------------------------- compare
|
| 336 |
+
|
| 337 |
+
export interface HeadlineDelta {
|
| 338 |
+
metric: string;
|
| 339 |
+
baseline: number;
|
| 340 |
+
candidate: number;
|
| 341 |
+
delta: number;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
export interface PerClassDelta {
|
| 345 |
+
class: string;
|
| 346 |
+
n: number | null;
|
| 347 |
+
recall_baseline: number | null;
|
| 348 |
+
recall_candidate: number | null;
|
| 349 |
+
recall_delta: number;
|
| 350 |
+
precision_baseline: number | null;
|
| 351 |
+
precision_candidate: number | null;
|
| 352 |
+
precision_delta: number;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
export interface CohortDelta {
|
| 356 |
+
cohort: string; // e.g. "highway/night/cyclist"
|
| 357 |
+
road_type?: string;
|
| 358 |
+
lighting?: string;
|
| 359 |
+
class?: string;
|
| 360 |
+
n: number;
|
| 361 |
+
recall_baseline: number;
|
| 362 |
+
recall_candidate: number;
|
| 363 |
+
recall_delta: number;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
export interface CompareRequest {
|
| 367 |
+
candidate_run_id: string;
|
| 368 |
+
baseline_run_id: string;
|
| 369 |
+
policy?: Record<string, unknown>;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
export interface CompareResponse {
|
| 373 |
+
candidate: { run_id: string; model_version: string };
|
| 374 |
+
baseline: { run_id: string; model_version: string };
|
| 375 |
+
headline_deltas: HeadlineDelta[];
|
| 376 |
+
per_class: PerClassDelta[];
|
| 377 |
+
worst_cohorts: CohortDelta[];
|
| 378 |
+
regressions: CohortDelta[];
|
| 379 |
+
policy: Record<string, unknown>;
|
| 380 |
+
recommendation: 'PROMOTE' | 'DO_NOT_PROMOTE';
|
| 381 |
+
blockers: string[];
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// ---------------------------------------------------------------- review sampling
|
| 385 |
+
|
| 386 |
+
export interface StratumResult {
|
| 387 |
+
stratum: string;
|
| 388 |
+
N: number;
|
| 389 |
+
n: number;
|
| 390 |
+
correct: number;
|
| 391 |
+
p: number;
|
| 392 |
+
wilson_ci: [number, number];
|
| 393 |
+
weight: number;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
export interface MetricEstimate {
|
| 397 |
+
estimate: number;
|
| 398 |
+
ci_low: number;
|
| 399 |
+
ci_high: number;
|
| 400 |
+
n_reviewed: number;
|
| 401 |
+
frame_size: number;
|
| 402 |
+
method: string;
|
| 403 |
+
strata: StratumResult[];
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
export interface SamplingFunnel {
|
| 407 |
+
population_objects: number;
|
| 408 |
+
containers: number;
|
| 409 |
+
suspicious_containers: number;
|
| 410 |
+
candidate_pool: number;
|
| 411 |
+
statistically_selected: number;
|
| 412 |
+
reviewed: number;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
export interface StratumPlan {
|
| 416 |
+
label: string;
|
| 417 |
+
N: number;
|
| 418 |
+
allocated: number;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
export interface ReviewState {
|
| 422 |
+
run_id: string;
|
| 423 |
+
planned: boolean;
|
| 424 |
+
executed: boolean;
|
| 425 |
+
method?: string;
|
| 426 |
+
target_n?: number;
|
| 427 |
+
funnel?: SamplingFunnel;
|
| 428 |
+
strata?: {
|
| 429 |
+
precision: Record<string, StratumPlan>;
|
| 430 |
+
recall: Record<string, StratumPlan>;
|
| 431 |
+
};
|
| 432 |
+
results?: {
|
| 433 |
+
precision: MetricEstimate;
|
| 434 |
+
recall: MetricEstimate;
|
| 435 |
+
} | null;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
// ---------------------------------------------------------------- shift / why / similarity
|
| 439 |
+
|
| 440 |
+
export interface ShiftRow {
|
| 441 |
+
cohort: string;
|
| 442 |
+
class: string;
|
| 443 |
+
weather: string;
|
| 444 |
+
lighting: string;
|
| 445 |
+
train_share: number;
|
| 446 |
+
eval_share: number;
|
| 447 |
+
relative_change: number; // e.g. +0.90 == +90%
|
| 448 |
+
eval_count: number;
|
| 449 |
+
cohort_recall: number | null;
|
| 450 |
+
overall_recall: number | null;
|
| 451 |
+
recall_gap: number | null;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
export interface ShiftResponse {
|
| 455 |
+
run_id: string;
|
| 456 |
+
shifts: ShiftRow[];
|
| 457 |
+
method: string;
|
| 458 |
+
thresholds: { min_eval_count: number; rel_threshold: number };
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
export interface WhyRequest {
|
| 462 |
+
run_id: string;
|
| 463 |
+
filters?: Partial<Record<DimName, string[]>>;
|
| 464 |
+
metric?: 'recall' | 'precision';
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
export interface WhyFactor {
|
| 468 |
+
factor: 'occlusion' | 'low_illumination' | 'long_range' | 'sensor_disagreement' | 'other';
|
| 469 |
+
count: number;
|
| 470 |
+
share: number;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
export interface WhyCohort {
|
| 474 |
+
cohort: string;
|
| 475 |
+
n: number;
|
| 476 |
+
fn?: number | null;
|
| 477 |
+
fp?: number | null;
|
| 478 |
+
[metric: string]: string | number | null | undefined;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
export interface WhyResponse {
|
| 482 |
+
metric: string;
|
| 483 |
+
filters: Partial<Record<DimName, string[]>>;
|
| 484 |
+
failure_count: number;
|
| 485 |
+
factors: WhyFactor[];
|
| 486 |
+
top_cohorts: WhyCohort[];
|
| 487 |
+
method: string;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
export interface SimilarityRequest {
|
| 491 |
+
run_id: string;
|
| 492 |
+
container_id: number;
|
| 493 |
+
filters?: Partial<Record<DimName, string[]>>;
|
| 494 |
+
k?: number;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
export interface SimilarContainer {
|
| 498 |
+
container_id: number;
|
| 499 |
+
similarity: number;
|
| 500 |
+
weather: string;
|
| 501 |
+
lighting: string;
|
| 502 |
+
road_type: string;
|
| 503 |
+
scenario: string;
|
| 504 |
+
n_objects: number;
|
| 505 |
+
fn: number;
|
| 506 |
+
fp: number;
|
| 507 |
+
anomalies: number;
|
| 508 |
+
risk_score: number;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
export interface SimilarityResponse {
|
| 512 |
+
query_container: number;
|
| 513 |
+
retrieval?: string;
|
| 514 |
+
results: SimilarContainer[];
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// ---------------------------------------------------------------- distributions
|
| 518 |
+
|
| 519 |
+
export interface HistogramSketch {
|
| 520 |
+
bins: number;
|
| 521 |
+
lo: number;
|
| 522 |
+
hi: number;
|
| 523 |
+
counts: number[];
|
| 524 |
+
percentiles: Record<string, number>;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
export interface DistributionsResponse {
|
| 528 |
+
run_id: string;
|
| 529 |
+
exact: false;
|
| 530 |
+
note: string;
|
| 531 |
+
confidence?: HistogramSketch;
|
| 532 |
+
iou?: HistogramSketch;
|
| 533 |
+
containers_hll_estimate: number | null;
|
| 534 |
+
containers_exact: number | null;
|
| 535 |
+
available_sketch_metrics: string[];
|
| 536 |
+
}
|
tests/test_megaeval/__init__.py
ADDED
|
File without changes
|
tests/test_megaeval/conftest.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures: an isolated megaeval root with a small population and two
|
| 2 |
+
published runs (a good candidate and a deliberately regressed one)."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sensorflow.megaeval import population as pop_mod
|
| 9 |
+
from sensorflow.megaeval.runs import get_mega_store, reset_mega_store
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture(scope="session")
|
| 13 |
+
def mega_env(tmp_path_factory):
|
| 14 |
+
root = tmp_path_factory.mktemp("megaeval")
|
| 15 |
+
pop_mod.set_mega_root(str(root))
|
| 16 |
+
reset_mega_store()
|
| 17 |
+
meta = pop_mod.generate_population("test-pop", num_objects=8_000, seed=17)
|
| 18 |
+
store = get_mega_store()
|
| 19 |
+
|
| 20 |
+
good = store.create_run(population_id=meta["population_id"],
|
| 21 |
+
model_version="model-v41", worker_delay_s=0.0)
|
| 22 |
+
store.execute_sync(good)
|
| 23 |
+
assert good.status == "published", good.error
|
| 24 |
+
|
| 25 |
+
bad = store.create_run(population_id=meta["population_id"],
|
| 26 |
+
model_version="model-v42-regressed",
|
| 27 |
+
overrides={"night_penalty": 0.35, "vru_penalty": 0.12},
|
| 28 |
+
worker_delay_s=0.0)
|
| 29 |
+
store.execute_sync(bad)
|
| 30 |
+
assert bad.status == "published", bad.error
|
| 31 |
+
|
| 32 |
+
yield {"root": root, "meta": meta, "store": store, "good": good, "bad": bad}
|
| 33 |
+
|
| 34 |
+
# restore defaults for any later suites
|
| 35 |
+
pop_mod.set_mega_root("runs/megaeval")
|
| 36 |
+
reset_mega_store()
|
tests/test_megaeval/test_analysis.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model-vs-model comparison, distribution shift, why-layer, similarity."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from sensorflow.megaeval import analysis
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_compare_detects_injected_regression(mega_env):
|
| 9 |
+
store = mega_env["store"]
|
| 10 |
+
cmp = analysis.compare_runs(store, mega_env["bad"], mega_env["good"])
|
| 11 |
+
assert cmp["recommendation"] == "DO_NOT_PROMOTE"
|
| 12 |
+
assert cmp["blockers"]
|
| 13 |
+
hd = {h["metric"]: h["delta"] for h in cmp["headline_deltas"]}
|
| 14 |
+
assert hd["recall"] < 0
|
| 15 |
+
assert hd["safety_recall"] < 0
|
| 16 |
+
# injected night+VRU penalty must surface night cohorts among worst regressions
|
| 17 |
+
assert any("night" in c["cohort"] for c in cmp["worst_cohorts"][:8])
|
| 18 |
+
assert any("REGRESSION" in b for b in cmp["blockers"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_compare_promotes_equivalent_model(mega_env):
|
| 22 |
+
store, meta = mega_env["store"], mega_env["meta"]
|
| 23 |
+
good = mega_env["good"]
|
| 24 |
+
twin = store.create_run(population_id=meta["population_id"],
|
| 25 |
+
model_version=good.model_version, worker_delay_s=0.0)
|
| 26 |
+
store.execute_sync(twin)
|
| 27 |
+
cmp = analysis.compare_runs(store, twin, good)
|
| 28 |
+
assert cmp["recommendation"] == "PROMOTE"
|
| 29 |
+
assert cmp["blockers"] == []
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_compare_writes_legacy_regression_result(mega_env):
|
| 33 |
+
from sensorflow.evaluation.records import get_store as legacy_store
|
| 34 |
+
analysis.compare_runs(mega_env["store"], mega_env["bad"], mega_env["good"])
|
| 35 |
+
results = legacy_store().all("regressions")
|
| 36 |
+
ours = [r for r in results if r.model_version == mega_env["bad"].model_version]
|
| 37 |
+
assert ours and ours[-1].regression_detected
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_distribution_shift_flags_underrepresented_cohorts(mega_env):
|
| 41 |
+
sh = analysis.distribution_shift(mega_env["store"], mega_env["good"])
|
| 42 |
+
assert sh["shifts"], "training mix was deliberately shifted; must be detected"
|
| 43 |
+
top = sh["shifts"][0]
|
| 44 |
+
assert abs(top["relative_change"]) >= 0.35
|
| 45 |
+
assert top["eval_count"] >= 300
|
| 46 |
+
# the train mix under-represents night/bad weather, so eval share must be higher
|
| 47 |
+
assert any(s["relative_change"] > 0 and s["lighting"] == "night" for s in sh["shifts"])
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_why_decomposition_sums_to_one(mega_env):
|
| 51 |
+
w = analysis.why(mega_env["store"], mega_env["good"],
|
| 52 |
+
{"class": ["pedestrian"]}, "recall")
|
| 53 |
+
assert w["failure_count"] > 0
|
| 54 |
+
total_share = sum(f["share"] for f in w["factors"])
|
| 55 |
+
assert abs(total_share - 1.0) < 0.01
|
| 56 |
+
assert sum(f["count"] for f in w["factors"]) == w["failure_count"]
|
| 57 |
+
names = {f["factor"] for f in w["factors"]}
|
| 58 |
+
assert names <= {"occlusion", "low_illumination", "long_range",
|
| 59 |
+
"sensor_disagreement", "other"}
|
| 60 |
+
assert w["top_cohorts"], "must list affected cohorts with metric values"
|
| 61 |
+
assert all("recall" in c for c in w["top_cohorts"])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_similarity_hybrid_search(mega_env):
|
| 65 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 66 |
+
containers = store.artifacts(run.run_id)["containers"]
|
| 67 |
+
risky = containers.sort_values("risk_score", ascending=False).iloc[0]
|
| 68 |
+
cid = int(risky["container_id"])
|
| 69 |
+
res = analysis.similar_containers(store, run, cid, k=8)
|
| 70 |
+
assert len(res["results"]) == 8
|
| 71 |
+
sims = [r["similarity"] for r in res["results"]]
|
| 72 |
+
assert sims == sorted(sims, reverse=True)
|
| 73 |
+
assert all(r["container_id"] != cid for r in res["results"])
|
| 74 |
+
# hybrid: structured filter restricts results
|
| 75 |
+
filt = analysis.similar_containers(store, run, cid, filters={"lighting": ["night"]}, k=8)
|
| 76 |
+
assert all(r["lighting"] == "night" for r in filt["results"])
|
tests/test_megaeval/test_api_lifecycle.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async run lifecycle, the query API (cache behavior + routing), 409 gating,
|
| 2 |
+
containers/forensic drill-down, review endpoints and performance SLOs —
|
| 3 |
+
all through the real HTTP API."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
from fastapi.testclient import TestClient
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture(scope="module")
|
| 14 |
+
def client(mega_env):
|
| 15 |
+
from app_backend import app
|
| 16 |
+
with TestClient(app) as c:
|
| 17 |
+
yield c
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture(scope="module")
|
| 21 |
+
def api_run(client, mega_env):
|
| 22 |
+
"""Create a fresh run via the API and wait for the async lifecycle to publish."""
|
| 23 |
+
pop_id = mega_env["meta"]["population_id"]
|
| 24 |
+
res = client.post("/api/megaeval/runs",
|
| 25 |
+
json={"population_id": pop_id, "model_version": "model-api",
|
| 26 |
+
"worker_delay_s": 0.05, "workers": 4})
|
| 27 |
+
assert res.status_code == 200
|
| 28 |
+
run = res.json()
|
| 29 |
+
assert run["status"] in ("queued", "running")
|
| 30 |
+
|
| 31 |
+
seen_statuses = {run["status"]}
|
| 32 |
+
deadline = time.time() + 60
|
| 33 |
+
while time.time() < deadline:
|
| 34 |
+
r = client.get(f"/api/megaeval/runs/{run['run_id']}").json()
|
| 35 |
+
seen_statuses.add(r["status"])
|
| 36 |
+
if r["status"] in ("published", "failed"):
|
| 37 |
+
break
|
| 38 |
+
time.sleep(0.2)
|
| 39 |
+
assert r["status"] == "published", r.get("error")
|
| 40 |
+
return {"run": r, "seen_statuses": seen_statuses}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_async_lifecycle_and_progress(api_run):
|
| 44 |
+
r = api_run["run"]
|
| 45 |
+
assert api_run["seen_statuses"] & {"queued", "running", "reducing", "materializing"}
|
| 46 |
+
assert r["percent"] == 100.0
|
| 47 |
+
assert r["objects_processed"] == r["objects_total"] > 0
|
| 48 |
+
assert r["throughput_objs_per_s"] > 0
|
| 49 |
+
assert r["headline"]["n"] == r["objects_total"]
|
| 50 |
+
lin = r["lineage"]
|
| 51 |
+
for field in ("evaluation_id", "dataset_version", "model_version", "model_checkpoint",
|
| 52 |
+
"label_version", "evaluator_code_version", "metric_version",
|
| 53 |
+
"threshold_config", "sampling_config", "seed", "hardware", "timestamp"):
|
| 54 |
+
assert field in lin
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_results_blocked_until_published(client, mega_env):
|
| 58 |
+
pop_id = mega_env["meta"]["population_id"]
|
| 59 |
+
res = client.post("/api/megaeval/runs",
|
| 60 |
+
json={"population_id": pop_id, "model_version": "model-slow",
|
| 61 |
+
"worker_delay_s": 0.4, "workers": 2})
|
| 62 |
+
run_id = res.json()["run_id"]
|
| 63 |
+
blocked = client.get(f"/api/megaeval/runs/{run_id}/funnel")
|
| 64 |
+
assert blocked.status_code == 409
|
| 65 |
+
deadline = time.time() + 90
|
| 66 |
+
while time.time() < deadline:
|
| 67 |
+
if client.get(f"/api/megaeval/runs/{run_id}").json()["status"] == "published":
|
| 68 |
+
break
|
| 69 |
+
time.sleep(0.3)
|
| 70 |
+
assert client.get(f"/api/megaeval/runs/{run_id}/funnel").status_code == 200
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_query_api_cache_and_latency_slos(client, api_run):
|
| 74 |
+
run_id = api_run["run"]["run_id"]
|
| 75 |
+
body = {"evaluation_id": run_id,
|
| 76 |
+
"filters": {"class": ["pedestrian"], "lighting": ["night"]},
|
| 77 |
+
"metrics": ["n", "recall", "precision", "mean_iou"],
|
| 78 |
+
"group_by": []}
|
| 79 |
+
r1 = client.post("/api/evaluations/query", json=body).json()
|
| 80 |
+
assert r1["meta"]["source"] == "cube"
|
| 81 |
+
assert r1["meta"]["cache_hit"] is False
|
| 82 |
+
assert r1["meta"]["exact"] is True
|
| 83 |
+
assert r1["rows"][0]["n"] > 0
|
| 84 |
+
assert r1["meta"]["latency_ms"] < 3000 # filtered cohort query SLO
|
| 85 |
+
|
| 86 |
+
r2 = client.post("/api/evaluations/query", json=body).json()
|
| 87 |
+
assert r2["meta"]["cache_hit"] is True
|
| 88 |
+
assert r2["meta"]["source"] == "cache"
|
| 89 |
+
assert r2["meta"]["latency_ms"] < 100 # cached query SLO
|
| 90 |
+
assert r2["rows"] == r1["rows"]
|
| 91 |
+
|
| 92 |
+
dash = client.post("/api/evaluations/query",
|
| 93 |
+
json={"evaluation_id": run_id, "group_by": ["class"],
|
| 94 |
+
"metrics": ["n", "precision", "recall", "f1"]}).json()
|
| 95 |
+
assert dash["meta"]["latency_ms"] < 2000 # aggregate dashboard SLO
|
| 96 |
+
assert len(dash["rows"]) == 6
|
| 97 |
+
|
| 98 |
+
stats = client.get("/api/megaeval/cache").json()
|
| 99 |
+
assert stats["hits"] >= 1
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_query_sketch_metrics_marked_approximate(client, api_run):
|
| 103 |
+
run_id = api_run["run"]["run_id"]
|
| 104 |
+
res = client.post("/api/evaluations/query",
|
| 105 |
+
json={"evaluation_id": run_id,
|
| 106 |
+
"metrics": ["n", "recall", "confidence_p50", "iou_p90"]}).json()
|
| 107 |
+
assert res["meta"]["exact"] is False
|
| 108 |
+
assert set(res["meta"]["approximate_fields"]) == {"confidence_p50", "iou_p90"}
|
| 109 |
+
assert 0 <= res["rows"][0]["confidence_p50"] <= 1
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_containers_sort_presets_and_forensic_drilldown(client, api_run):
|
| 113 |
+
run_id = api_run["run"]["run_id"]
|
| 114 |
+
worst = client.get(f"/api/megaeval/runs/{run_id}/containers",
|
| 115 |
+
params={"sort": "worst_recall", "limit": 20}).json()
|
| 116 |
+
assert worst["total"] > 0 and len(worst["rows"]) == 20
|
| 117 |
+
recalls = [r["recall"] for r in worst["rows"] if r["recall"] is not None]
|
| 118 |
+
assert recalls == sorted(recalls)
|
| 119 |
+
|
| 120 |
+
risky = client.get(f"/api/megaeval/runs/{run_id}/containers",
|
| 121 |
+
params={"sort": "highest_risk", "limit": 5}).json()["rows"]
|
| 122 |
+
risks = [r["risk_score"] for r in risky]
|
| 123 |
+
assert risks == sorted(risks, reverse=True)
|
| 124 |
+
assert risky[0]["status"] in ("ok", "warn", "critical")
|
| 125 |
+
|
| 126 |
+
t0 = time.perf_counter()
|
| 127 |
+
objs = client.get(
|
| 128 |
+
f"/api/megaeval/runs/{run_id}/containers/{risky[0]['container_id']}/objects").json()
|
| 129 |
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
| 130 |
+
assert elapsed_ms < 1000 # object investigation SLO
|
| 131 |
+
assert objs["objects"], "forensic drill-down must return annotation-level rows"
|
| 132 |
+
outcomes = {o["outcome"] for o in objs["objects"]}
|
| 133 |
+
assert outcomes <= {"TP", "FN", "FP", "LOCALIZATION"}
|
| 134 |
+
gt_objs = [o for o in objs["objects"] if o["outcome"] != "FP"]
|
| 135 |
+
assert len(gt_objs) == risky[0]["n_objects"]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_review_endpoints_full_cycle(client, api_run):
|
| 139 |
+
run_id = api_run["run"]["run_id"]
|
| 140 |
+
state = client.get(f"/api/megaeval/runs/{run_id}/review").json()
|
| 141 |
+
assert state["planned"] is False
|
| 142 |
+
|
| 143 |
+
plan = client.post(f"/api/megaeval/runs/{run_id}/review/plan",
|
| 144 |
+
json={"target_n": 600}).json()
|
| 145 |
+
assert plan["planned"] and not plan["executed"]
|
| 146 |
+
assert plan["funnel"]["statistically_selected"] > 0
|
| 147 |
+
|
| 148 |
+
done = client.post(f"/api/megaeval/runs/{run_id}/review/execute").json()
|
| 149 |
+
assert done["executed"]
|
| 150 |
+
res = done["results"]
|
| 151 |
+
for metric in ("precision", "recall"):
|
| 152 |
+
est = res[metric]
|
| 153 |
+
assert est["ci_low"] < est["estimate"] < est["ci_high"]
|
| 154 |
+
|
| 155 |
+
funnel = client.get(f"/api/megaeval/runs/{run_id}/funnel").json()
|
| 156 |
+
stages = {s["stage"]: s["count"] for s in funnel["stages"]}
|
| 157 |
+
assert stages["Human Verified"] == done["funnel"]["reviewed"]
|
| 158 |
+
assert funnel["estimated_precision"]["estimate"] is not None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_error_search_compare_shift_why_similarity_endpoints(client, api_run, mega_env):
|
| 162 |
+
run_id = api_run["run"]["run_id"]
|
| 163 |
+
es = client.post("/api/megaeval/errors/search",
|
| 164 |
+
json={"run_id": run_id, "error_types": ["FN"],
|
| 165 |
+
"filters": {"class": ["pedestrian"]},
|
| 166 |
+
"risk_min": 0.5}).json()
|
| 167 |
+
assert es["matched_errors"] > 0 and es["worst_containers"]
|
| 168 |
+
|
| 169 |
+
cmp = client.post("/api/megaeval/compare",
|
| 170 |
+
json={"candidate_run_id": mega_env["bad"].run_id,
|
| 171 |
+
"baseline_run_id": run_id}).json()
|
| 172 |
+
assert cmp["recommendation"] == "DO_NOT_PROMOTE"
|
| 173 |
+
|
| 174 |
+
sh = client.get(f"/api/megaeval/runs/{run_id}/shift").json()
|
| 175 |
+
assert sh["shifts"]
|
| 176 |
+
|
| 177 |
+
why = client.post("/api/megaeval/why",
|
| 178 |
+
json={"run_id": run_id, "filters": {"lighting": ["night"]},
|
| 179 |
+
"metric": "recall"}).json()
|
| 180 |
+
assert why["failure_count"] > 0 and why["factors"]
|
| 181 |
+
|
| 182 |
+
cid = es["worst_containers"][0]["container_id"]
|
| 183 |
+
sim = client.post("/api/megaeval/similarity",
|
| 184 |
+
json={"run_id": run_id, "container_id": cid, "k": 5}).json()
|
| 185 |
+
assert len(sim["results"]) == 5
|
| 186 |
+
|
| 187 |
+
dist = client.get(f"/api/megaeval/runs/{run_id}/distributions").json()
|
| 188 |
+
assert dist["exact"] is False
|
| 189 |
+
assert "confidence" in dist and "50" in dist["confidence"]["percentiles"]
|
| 190 |
+
assert dist["containers_exact"] > 0
|
tests/test_megaeval/test_cube.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cube correctness: cube aggregates must EXACTLY match brute-force computation
|
| 2 |
+
over the raw per-object records, and re-running with the same lineage must be
|
| 3 |
+
bit-identical (deterministic seeds)."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
from sensorflow.megaeval import cube as cube_mod
|
| 14 |
+
from sensorflow.megaeval import population as pop_mod
|
| 15 |
+
from sensorflow.megaeval.population import DIM_NAMES, DIMENSIONS
|
| 16 |
+
from sensorflow.megaeval.runs import run_dir
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _brute_force(env, group_dims):
|
| 20 |
+
"""Recompute tp/fp/fn/n per group directly from raw per-object outputs."""
|
| 21 |
+
run = env["good"]
|
| 22 |
+
frames = []
|
| 23 |
+
d = run_dir(run.run_id)
|
| 24 |
+
for p in range(run.partitions_total):
|
| 25 |
+
pop_cols = pop_mod.load_partition(run.population_id, p)
|
| 26 |
+
with np.load(os.path.join(d, f"objects-part-{p:04d}.npz")) as z:
|
| 27 |
+
df = pd.DataFrame({
|
| 28 |
+
**{k: pop_cols[k] for k in DIM_NAMES},
|
| 29 |
+
"tp": z["tp"].astype(np.int64),
|
| 30 |
+
"detected": z["detected"].astype(bool),
|
| 31 |
+
})
|
| 32 |
+
df["fn"] = 1 - df["tp"]
|
| 33 |
+
df["fp"] = (df["detected"] & (df["tp"] == 0)).astype(np.int64)
|
| 34 |
+
df["n"] = 1
|
| 35 |
+
frames.append(df[group_dims + ["n", "tp", "fp", "fn"]])
|
| 36 |
+
with np.load(os.path.join(d, f"fp-part-{p:04d}.npz")) as z:
|
| 37 |
+
if z["container_id"].size:
|
| 38 |
+
fpdf = pd.DataFrame({k: z[k] for k in DIM_NAMES})
|
| 39 |
+
fpdf["n"] = 0
|
| 40 |
+
fpdf["tp"] = 0
|
| 41 |
+
fpdf["fp"] = 1
|
| 42 |
+
fpdf["fn"] = 0
|
| 43 |
+
frames.append(fpdf[group_dims + ["n", "tp", "fp", "fn"]])
|
| 44 |
+
full = pd.concat(frames, ignore_index=True)
|
| 45 |
+
return full.groupby(group_dims, as_index=False).sum()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@pytest.mark.parametrize("group_dims", [["class"], ["class", "lighting"], ["scenario"]])
|
| 49 |
+
def test_cube_matches_brute_force_exactly(mega_env, group_dims):
|
| 50 |
+
run = mega_env["good"]
|
| 51 |
+
cube = mega_env["store"].artifacts(run.run_id)["cube"]
|
| 52 |
+
rows, _ = cube_mod.aggregate(cube, None, group_dims,
|
| 53 |
+
["n", "tp", "fp", "fn", "precision", "recall"], 2000)
|
| 54 |
+
brute = _brute_force(mega_env, group_dims)
|
| 55 |
+
assert len(rows) == len(brute)
|
| 56 |
+
for _, b in brute.iterrows():
|
| 57 |
+
key = {d: DIMENSIONS[d][int(b[d])] for d in group_dims}
|
| 58 |
+
match = [r for r in rows if all(r[d] == key[d] for d in group_dims)]
|
| 59 |
+
assert len(match) == 1, f"missing cube row for {key}"
|
| 60 |
+
r = match[0]
|
| 61 |
+
assert r["n"] == int(b["n"])
|
| 62 |
+
assert r["tp"] == int(b["tp"])
|
| 63 |
+
assert r["fp"] == int(b["fp"])
|
| 64 |
+
assert r["fn"] == int(b["fn"])
|
| 65 |
+
# derived ratios match brute force (API rounds ratios to 6 decimals)
|
| 66 |
+
if b["tp"] + b["fp"] > 0:
|
| 67 |
+
assert r["precision"] == pytest.approx(b["tp"] / (b["tp"] + b["fp"]), abs=1e-6)
|
| 68 |
+
if b["tp"] + b["fn"] > 0:
|
| 69 |
+
assert r["recall"] == pytest.approx(b["tp"] / (b["tp"] + b["fn"]), abs=1e-6)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_rollup_consistency(mega_env):
|
| 73 |
+
"""Any grouped rollup must sum to the global totals (no double counting)."""
|
| 74 |
+
run = mega_env["good"]
|
| 75 |
+
cube = mega_env["store"].artifacts(run.run_id)["cube"]
|
| 76 |
+
total_rows, _ = cube_mod.aggregate(cube, None, None, ["n", "tp", "fp", "fn"], 1)
|
| 77 |
+
total = total_rows[0]
|
| 78 |
+
for dim in ("class", "weather", "occlusion", "distance_band"):
|
| 79 |
+
rows, _ = cube_mod.aggregate(cube, None, [dim], ["n", "tp", "fp", "fn"], 2000)
|
| 80 |
+
for stat in ("n", "tp", "fp", "fn"):
|
| 81 |
+
assert sum(r[stat] for r in rows) == total[stat], f"{dim}/{stat} rollup mismatch"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_filtered_aggregate_equals_manual_subset(mega_env):
|
| 85 |
+
run = mega_env["good"]
|
| 86 |
+
cube = mega_env["store"].artifacts(run.run_id)["cube"]
|
| 87 |
+
filt = {"class": ["pedestrian"], "lighting": ["night"]}
|
| 88 |
+
rows, _ = cube_mod.aggregate(cube, filt, None, ["n", "tp", "fn", "recall"], 1)
|
| 89 |
+
sub = cube[(cube["class"] == DIMENSIONS["class"].index("pedestrian"))
|
| 90 |
+
& (cube["lighting"] == DIMENSIONS["lighting"].index("night"))]
|
| 91 |
+
assert rows[0]["n"] == int(sub["n"].sum())
|
| 92 |
+
assert rows[0]["tp"] == int(sub["tp"].sum())
|
| 93 |
+
assert rows[0]["recall"] == pytest.approx(sub["tp"].sum() / max(sub["tp"].sum() + sub["fn"].sum(), 1), abs=1e-6)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_reproducible_run_same_lineage(mega_env):
|
| 97 |
+
"""Same population + model_version (same derived seed) -> identical cube."""
|
| 98 |
+
store, meta = mega_env["store"], mega_env["meta"]
|
| 99 |
+
r1 = store.create_run(population_id=meta["population_id"],
|
| 100 |
+
model_version="model-repro", worker_delay_s=0.0)
|
| 101 |
+
store.execute_sync(r1)
|
| 102 |
+
r2 = store.create_run(population_id=meta["population_id"],
|
| 103 |
+
model_version="model-repro", worker_delay_s=0.0)
|
| 104 |
+
store.execute_sync(r2)
|
| 105 |
+
assert r1.seed == r2.seed
|
| 106 |
+
c1 = mega_env["store"].artifacts(r1.run_id)["cube"].sort_values(DIM_NAMES).reset_index(drop=True)
|
| 107 |
+
c2 = mega_env["store"].artifacts(r2.run_id)["cube"].sort_values(DIM_NAMES).reset_index(drop=True)
|
| 108 |
+
pd.testing.assert_frame_equal(c1, c2)
|
| 109 |
+
assert r1.headline == {**r2.headline}
|
tests/test_megaeval/test_error_index.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Error index: multi-criteria search correctness and worst-container ranking."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from sensorflow.megaeval import errors as errors_mod
|
| 8 |
+
from sensorflow.megaeval.population import DIMENSIONS
|
| 9 |
+
from sensorflow.megaeval.runs import ERROR_TYPES
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_multi_criteria_search_matches_brute_filter(mega_env):
|
| 13 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 14 |
+
art = store.artifacts(run.run_id)
|
| 15 |
+
errs, containers = art["errors"], art["containers"]
|
| 16 |
+
|
| 17 |
+
res = errors_mod.search_errors(
|
| 18 |
+
errs, containers, error_types=["FN"],
|
| 19 |
+
filters={"class": ["pedestrian"], "lighting": ["night"]},
|
| 20 |
+
confidence_max=0.4, risk_min=0.5)
|
| 21 |
+
|
| 22 |
+
mask = ((errs["error_type"] == ERROR_TYPES.index("FN"))
|
| 23 |
+
& (errs["class"] == DIMENSIONS["class"].index("pedestrian"))
|
| 24 |
+
& (errs["lighting"] == DIMENSIONS["lighting"].index("night"))
|
| 25 |
+
& (errs["confidence"] <= 0.4)
|
| 26 |
+
& (errs["risk_score"] >= 0.5))
|
| 27 |
+
assert res["matched_errors"] == int(mask.sum())
|
| 28 |
+
assert res["matched_errors"] > 0
|
| 29 |
+
|
| 30 |
+
for ex in res["examples"]:
|
| 31 |
+
assert ex["error_type"] == "FN"
|
| 32 |
+
assert ex["class"] == "pedestrian"
|
| 33 |
+
assert ex["lighting"] == "night"
|
| 34 |
+
assert ex["confidence"] <= 0.4
|
| 35 |
+
assert ex["risk_score"] >= 0.5
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_worst_containers_ranked_and_capped(mega_env):
|
| 39 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 40 |
+
art = store.artifacts(run.run_id)
|
| 41 |
+
res = errors_mod.search_errors(art["errors"], art["containers"],
|
| 42 |
+
error_types=["FN", "FP"], limit_containers=10)
|
| 43 |
+
worst = res["worst_containers"]
|
| 44 |
+
assert 0 < len(worst) <= 10
|
| 45 |
+
scores = [w["error_count"] * w["mean_risk"] for w in worst]
|
| 46 |
+
assert scores == sorted(scores, reverse=True)
|
| 47 |
+
# returns aggregated containers with context dims, not raw record dumps
|
| 48 |
+
assert "weather" in worst[0] and "n_objects" in worst[0]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_safety_only_filter(mega_env):
|
| 52 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 53 |
+
art = store.artifacts(run.run_id)
|
| 54 |
+
res = errors_mod.search_errors(art["errors"], art["containers"],
|
| 55 |
+
error_types=["FN"], safety_only=True)
|
| 56 |
+
assert res["matched_errors"] > 0
|
| 57 |
+
assert all(ex["safety_critical"] for ex in res["examples"])
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_by_type_totals_are_consistent(mega_env):
|
| 61 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 62 |
+
art = store.artifacts(run.run_id)
|
| 63 |
+
res = errors_mod.search_errors(art["errors"], art["containers"])
|
| 64 |
+
assert sum(res["by_type"].values()) == res["matched_errors"] == len(art["errors"])
|
tests/test_megaeval/test_sampling.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Review sampling: funnel structure, CI correctness (must cover the true
|
| 2 |
+
population metric), and cube review-counter updates."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sensorflow.megaeval import cube as cube_mod
|
| 9 |
+
from sensorflow.megaeval import sampling
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture(scope="module")
|
| 13 |
+
def reviewed(mega_env):
|
| 14 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 15 |
+
plan = sampling.build_review_plan(store, run, target_n=1200)
|
| 16 |
+
result = sampling.execute_reviews(store, run)
|
| 17 |
+
return {"plan": plan, "result": result, "run": run, "store": store}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_sampling_funnel_shape(reviewed):
|
| 21 |
+
f = reviewed["result"]["funnel"]
|
| 22 |
+
assert f["population_objects"] > f["containers"] > 0
|
| 23 |
+
assert f["suspicious_containers"] <= f["containers"]
|
| 24 |
+
# per-stratum rounding can overshoot by at most one unit per stratum (10 strata)
|
| 25 |
+
assert f["statistically_selected"] <= reviewed["result"]["target_n"] + 10
|
| 26 |
+
assert f["reviewed"] == f["statistically_selected"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_stratified_ci_covers_true_metrics(reviewed):
|
| 30 |
+
run = reviewed["run"]
|
| 31 |
+
res = reviewed["result"]["results"]
|
| 32 |
+
slack = 0.02 # human-fidelity noise (98.5%) can bias estimates slightly
|
| 33 |
+
for metric in ("precision", "recall"):
|
| 34 |
+
true = run.headline[metric]
|
| 35 |
+
est = res[metric]
|
| 36 |
+
assert est["ci_low"] < est["estimate"] < est["ci_high"]
|
| 37 |
+
assert est["ci_low"] - slack <= true <= est["ci_high"] + slack, (
|
| 38 |
+
f"{metric}: true {true} outside CI [{est['ci_low']}, {est['ci_high']}]")
|
| 39 |
+
assert abs(est["estimate"] - true) < 0.06
|
| 40 |
+
assert (est["ci_high"] - est["ci_low"]) < 0.25
|
| 41 |
+
assert est["n_reviewed"] > 0
|
| 42 |
+
assert "stratified" in est["method"]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_strata_are_risk_weighted(reviewed):
|
| 46 |
+
"""Missed/safety strata must be oversampled relative to their share."""
|
| 47 |
+
rec = reviewed["result"]["results"]["recall"]
|
| 48 |
+
by_label = {s["stratum"]: s for s in rec["strata"]}
|
| 49 |
+
detected = by_label.get("detected")
|
| 50 |
+
missed = by_label.get("missed")
|
| 51 |
+
assert detected and missed
|
| 52 |
+
assert (missed["n"] / max(missed["N"], 1)) > (detected["n"] / max(detected["N"], 1))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_cube_review_counters_updated(reviewed):
|
| 56 |
+
store, run = reviewed["store"], reviewed["run"]
|
| 57 |
+
cube = store.artifacts(run.run_id)["cube"]
|
| 58 |
+
rows, _ = cube_mod.aggregate(cube, None, None, ["n", "reviewed", "verified"], 1)
|
| 59 |
+
total = rows[0]
|
| 60 |
+
assert total["reviewed"] > 0
|
| 61 |
+
assert 0 < total["verified"] <= total["reviewed"]
|
| 62 |
+
# containers table too
|
| 63 |
+
containers = store.artifacts(run.run_id)["containers"]
|
| 64 |
+
assert int(containers["reviewed"].sum()) == total["reviewed"]
|
tests/test_megaeval/test_sketches.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sketch accuracy: quantile histograms, HyperLogLog, reservoir, Wilson CIs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sensorflow.megaeval.sketches import HyperLogLog, QuantileHistogram, Reservoir
|
| 9 |
+
from sensorflow.megaeval.sampling import wilson_interval
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_quantile_histogram_percentiles_close_to_exact():
|
| 13 |
+
rng = np.random.default_rng(3)
|
| 14 |
+
data = np.clip(rng.beta(5, 2, size=50_000), 0, 1)
|
| 15 |
+
h = QuantileHistogram(0, 1, 64)
|
| 16 |
+
h.add(data)
|
| 17 |
+
bin_width = 1 / 64
|
| 18 |
+
for q in (10, 25, 50, 75, 90, 99):
|
| 19 |
+
approx = h.percentile(q)
|
| 20 |
+
exact = float(np.percentile(data, q))
|
| 21 |
+
assert abs(approx - exact) <= bin_width + 1e-9, f"p{q}: {approx} vs {exact}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_quantile_histogram_merge_equals_combined():
|
| 25 |
+
rng = np.random.default_rng(4)
|
| 26 |
+
a, b = rng.random(10_000), rng.random(10_000)
|
| 27 |
+
ha, hb, hc = QuantileHistogram(), QuantileHistogram(), QuantileHistogram()
|
| 28 |
+
ha.add(a)
|
| 29 |
+
hb.add(b)
|
| 30 |
+
hc.add(np.concatenate([a, b]))
|
| 31 |
+
merged = ha.merge(hb)
|
| 32 |
+
assert np.array_equal(merged.counts, hc.counts)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_hll_estimate_within_five_percent():
|
| 36 |
+
hll = HyperLogLog(12)
|
| 37 |
+
n = 20_000
|
| 38 |
+
hll.add_ids(np.arange(n, dtype=np.int64))
|
| 39 |
+
est = hll.estimate()
|
| 40 |
+
assert abs(est - n) / n < 0.05, est
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_hll_merge_is_union():
|
| 44 |
+
h1, h2 = HyperLogLog(12), HyperLogLog(12)
|
| 45 |
+
h1.add_ids(np.arange(0, 8_000, dtype=np.int64))
|
| 46 |
+
h2.add_ids(np.arange(4_000, 12_000, dtype=np.int64))
|
| 47 |
+
merged = h1.merge(h2)
|
| 48 |
+
est = merged.estimate()
|
| 49 |
+
assert abs(est - 12_000) / 12_000 < 0.06, est
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_reservoir_bounded_and_uniformish():
|
| 53 |
+
r = Reservoir(k=100, seed=1)
|
| 54 |
+
r.add_ids(np.arange(10_000))
|
| 55 |
+
assert len(r.items) == 100
|
| 56 |
+
assert r.seen == 10_000
|
| 57 |
+
# sampled ids should span the range, not just the head
|
| 58 |
+
assert max(r.items) > 5_000 and min(r.items) < 5_000
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_wilson_interval_known_value():
|
| 62 |
+
lo, hi = wilson_interval(8, 10)
|
| 63 |
+
assert lo == pytest.approx(0.490, abs=0.01)
|
| 64 |
+
assert hi == pytest.approx(0.943, abs=0.01)
|
| 65 |
+
lo0, hi0 = wilson_interval(0, 0)
|
| 66 |
+
assert (lo0, hi0) == (0.0, 1.0)
|
| 67 |
+
# Wilson upper bound at p=1 is mathematically < 1 (that's the point of Wilson)
|
| 68 |
+
lo1, hi1 = wilson_interval(50, 50)
|
| 69 |
+
assert 0.9 < lo1 < hi1 <= 1.0
|
| 70 |
+
assert hi1 == pytest.approx(0.9928, abs=0.01)
|