File size: 23,513 Bytes
35d483e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
#!/usr/bin/env python3
"""Evaluate a checkpoint with product-correct metrics and subgroup slices."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any

REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = REPOSITORY_ROOT / "src"
if str(SOURCE_ROOT) not in sys.path:
    sys.path.insert(0, str(SOURCE_ROOT))

OFFICIAL_TEST_DATASET_ID = "pipecat-ai/smart-turn-data-v3.2-test"
OFFICIAL_TEST_REVISION = "0500378e8ed6d38e37b016e24d261e8e6c6a6859"
OFFICIAL_TEST_EXPECTED_ROWS = 31_527
OFFICIAL_TEST_LOCAL_DIR = REPOSITORY_ROOT / "data/raw/smart-turn-data-v3.2-test"
OFFICIAL_TEST_HF_SOURCES = {
    OFFICIAL_TEST_DATASET_ID,
    f"hf://datasets/{OFFICIAL_TEST_DATASET_ID}",
}
OFFICIAL_TEST_SHARDS = {
    "data/train-00000-of-00010.parquet": (
        486_502_678,
        "a87c75806b814ee7379998b6f9dc65a6433c01bfec2875e62c5d1ccd2b37257a",
    ),
    "data/train-00001-of-00010.parquet": (
        489_429_742,
        "c408bd3b31cc3cb907280fa5d3186f0f5ba08c6beb84532a3828d685b967b7d8",
    ),
    "data/train-00002-of-00010.parquet": (
        479_920_042,
        "2b50ff3346f8aecc6c4b0b706b593c5b3b174b73ce84c795b515b68e6abb3788",
    ),
    "data/train-00003-of-00010.parquet": (
        486_565_988,
        "0afd86b7d1cdf03ffb804a00278fb76477d29ef008b71ad1d7a746bb1b25850c",
    ),
    "data/train-00004-of-00010.parquet": (
        477_831_330,
        "4c600774512010880f72dd86cc8abe5d46df594615e472351bd270cc94e1ff66",
    ),
    "data/train-00005-of-00010.parquet": (
        495_015_303,
        "b4537a3b96498481b98c5d60b8d84ad05109ae0acbeafc18759b15ff1d0d9335",
    ),
    "data/train-00006-of-00010.parquet": (
        481_054_312,
        "ef0eb0085b55e05fc5594f035c8011afb78d039e1b3e50c579a96e9295d9acec",
    ),
    "data/train-00007-of-00010.parquet": (
        478_266_840,
        "eddd1db2f95fff2f08e18ca1fe73fe0c5d9eb8030bea97da2d109297a0158b67",
    ),
    "data/train-00008-of-00010.parquet": (
        479_859_534,
        "96314dc8bb77515a5d1d02e8cb7c1410da54169d33d6f73ca2f87738ec1269f0",
    ),
    "data/train-00009-of-00010.parquet": (
        483_305_860,
        "769283c79bea4ae6eebdfe7d09fe481f8a154c53b5e3c29fe2c71d7b55dfc862",
    ),
}


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def _portable_path(path: Path) -> str:
    try:
        return path.resolve().relative_to(REPOSITORY_ROOT).as_posix()
    except ValueError:
        return path.name


def _evaluation_source_evidence(
    source: str | None,
    *,
    dataset_id: str | None,
    revision: str | None,
) -> dict[str, Any]:
    local = _local_source_path(source)
    if local is not None and local.is_file() and not local.is_symlink():
        return {
            "kind": "file",
            "path": _portable_path(local),
            "bytes": local.stat().st_size,
            "sha256": _sha256(local),
            "dataset_id": dataset_id,
            "revision": revision,
        }
    if local is not None:
        return {
            "kind": "directory",
            "path": _portable_path(local),
            "dataset_id": dataset_id,
            "revision": revision,
        }
    return {
        "kind": "huggingface",
        "identifier": dataset_id or source,
        "revision": revision,
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--checkpoint", required=True)
    parser.add_argument("--source", help="manifest, local dataset, or HF dataset ID")
    parser.add_argument(
        "--dataset-id",
        help=(
            "source provenance identity; required and checked for a local official-test snapshot"
        ),
    )
    parser.add_argument("--revision", help="immutable dataset revision")
    parser.add_argument("--split", default="test")
    parser.add_argument("--source-root", help="base directory for manifest source_file entries")
    parser.add_argument("--output", default="artifacts/evaluation/metrics.json")
    parser.add_argument(
        "--predictions-output",
        help="optional JSONL path (default: beside metrics); keep raw record IDs out of reports/",
    )
    parser.add_argument("--batch-size", type=int, default=32)
    parser.add_argument("--num-workers", type=int, default=0)
    parser.add_argument("--max-examples", type=int)
    parser.add_argument("--threshold", type=float, help="default: calibrated checkpoint threshold")
    parser.add_argument("--min-slice-count", type=int, default=25)
    parser.add_argument("--bootstrap-samples", type=int, default=1_000)
    parser.add_argument("--device", default="auto")
    parser.add_argument("--smoke-test", action="store_true")
    parser.add_argument(
        "--allow-sealed-test",
        action="store_true",
        help="explicitly permit the official smart-turn test after model/threshold freeze",
    )
    parser.add_argument(
        "--frozen-manifest",
        help="hash-bound manifest produced by scripts/freeze_candidate.py",
    )
    return parser.parse_args()


def _device(torch: Any, requested: str) -> Any:
    if requested != "auto":
        return torch.device(requested)
    return torch.device("cuda" if torch.cuda.is_available() else "cpu")


def _hf_dataset_id_from_source(source: str | None) -> str | None:
    """Return an exact canonical HF identity; never infer one from a path basename."""

    if not source:
        return None
    normalized = source.rstrip("/")
    if normalized in OFFICIAL_TEST_HF_SOURCES:
        return OFFICIAL_TEST_DATASET_ID
    return None


def _is_official_test_source(
    source: str | None,
    dataset_id: str | None = None,
) -> bool:
    inferred = _hf_dataset_id_from_source(source)
    if dataset_id is not None and inferred is not None and dataset_id != inferred:
        raise ValueError("--dataset-id conflicts with the exact Hugging Face source identity")
    return (dataset_id or inferred) == OFFICIAL_TEST_DATASET_ID


def _local_source_path(source: str | None) -> Path | None:
    if not source:
        return None
    candidate = Path(source).expanduser()
    if not candidate.is_absolute():
        candidate = REPOSITORY_ROOT / candidate
    return candidate.resolve() if candidate.exists() else None


def _validate_official_test_request(args: argparse.Namespace) -> bool:
    """Fail closed when any official-test control is incomplete or contradictory."""

    try:
        official = _is_official_test_source(args.source, args.dataset_id)
    except ValueError as exc:
        raise SystemExit(str(exc)) from exc
    local_source = _local_source_path(args.source)
    canonical_local_source = (
        local_source is not None and local_source == OFFICIAL_TEST_LOCAL_DIR.resolve()
    )
    official_controls_used = bool(args.allow_sealed_test or args.frozen_manifest)
    if canonical_local_source and args.dataset_id != OFFICIAL_TEST_DATASET_ID:
        raise SystemExit(
            f"the local official-test snapshot requires --dataset-id {OFFICIAL_TEST_DATASET_ID}"
        )
    if official_controls_used and not official:
        raise SystemExit(
            "official-test controls require the exact dataset identity "
            f"--dataset-id {OFFICIAL_TEST_DATASET_ID} (or that exact HF source)"
        )
    if not official:
        return False
    if args.smoke_test:
        raise SystemExit("official-test provenance cannot be combined with --smoke-test")
    if args.revision != OFFICIAL_TEST_REVISION:
        raise SystemExit(
            "official test requires the exact pinned revision: " + OFFICIAL_TEST_REVISION
        )
    if not args.allow_sealed_test or not args.frozen_manifest:
        raise SystemExit("official test requires both --allow-sealed-test and --frozen-manifest")
    if args.max_examples is not None:
        raise SystemExit("--max-examples is forbidden for official-test evaluation")
    if args.split not in {"test", "train"}:
        raise SystemExit(
            "official-test --split must be semantic 'test' or physical Hugging Face 'train'"
        )
    return True


def _verify_local_official_snapshot(source: Path) -> int:
    """Verify the immutable local snapshot before loading any official-test rows."""

    if not source.is_dir() or source.is_symlink():
        raise ValueError("local official-test source must be a non-symlink directory")
    actual = {
        path.relative_to(source).as_posix() for path in source.rglob("*.parquet") if path.is_file()
    }
    expected = set(OFFICIAL_TEST_SHARDS)
    if actual != expected:
        missing = sorted(expected - actual)
        unexpected = sorted(actual - expected)
        raise ValueError(
            f"official-test shard inventory mismatch: missing={missing}, unexpected={unexpected}"
        )
    try:
        import pyarrow.parquet as parquet
    except ImportError as exc:
        raise ValueError("official-test snapshot verification requires pyarrow") from exc
    rows = 0
    for relative, (expected_bytes, expected_sha256) in OFFICIAL_TEST_SHARDS.items():
        path = source / relative
        if path.is_symlink() or not path.is_file():
            raise ValueError(f"official-test shard is not a regular file: {relative}")
        if path.stat().st_size != expected_bytes:
            raise ValueError(f"official-test shard size mismatch: {relative}")
        if _sha256(path) != expected_sha256:
            raise ValueError(f"official-test shard SHA-256 mismatch: {relative}")
        try:
            rows += int(parquet.ParquetFile(path).metadata.num_rows)
        except Exception as exc:
            raise ValueError(f"cannot read official-test Parquet metadata: {relative}") from exc
    if rows != OFFICIAL_TEST_EXPECTED_ROWS:
        raise ValueError(
            "official-test row-count mismatch: "
            f"expected {OFFICIAL_TEST_EXPECTED_ROWS}, found {rows}"
        )
    return rows


def main() -> int:
    args = parse_args()
    official_test = _validate_official_test_request(args)
    # The dedicated repository is semantically the official test set, but its
    # sole physical Hugging Face split is named ``train``. Keep reports honest
    # while loading the upstream layout correctly.
    source_split = "train" if official_test else args.split
    report_split = "test" if official_test else args.split
    try:
        import torch
    except ImportError as exc:
        raise SystemExit("Evaluation requires PyTorch") from exc

    from turn_detection.models import LogMelConfig, LogMelFrontend, load_model_checkpoint
    from turn_detection.provenance import verify_freeze_manifest
    from turn_detection.training.datasets import (
        AudioFeatureCollator,
        SyntheticFeatureDataset,
        build_record_dataloader,
    )
    from turn_detection.training.metrics import (
        binary_classification_metrics,
        grouped_bootstrap_interval,
        metrics_at_fpr_budgets,
        operational_metrics,
        reliability_bins,
        sliced_metrics,
    )

    checkpoint_path = Path(args.checkpoint)
    if not checkpoint_path.is_absolute():
        checkpoint_path = REPOSITORY_ROOT / checkpoint_path
    device = _device(torch, args.device)
    model, checkpoint = load_model_checkpoint(checkpoint_path, map_location=device)
    model.to(device).eval()
    metadata = dict(checkpoint.get("metadata", {}))
    run_metadata = metadata.get("run_metadata", {})
    if not isinstance(run_metadata, dict):
        run_metadata = {}
    smoke_checkpoint = bool(metadata.get("smoke_test", False))
    training_status = str(
        run_metadata.get("status", "smoke" if smoke_checkpoint else "development")
    )
    development_only = smoke_checkpoint or training_status.lower() != "final"
    feature_config = LogMelConfig.from_mapping(metadata.get("feature_config", {}))
    frontend = LogMelFrontend(feature_config)
    max_seconds = float(metadata.get("max_seconds", 8.0))
    threshold = float(
        args.threshold if args.threshold is not None else checkpoint.get("threshold", 0.5)
    )
    dataset_revision = args.revision
    freeze_manifest_sha256: str | None = None
    if official_test:
        if not args.allow_sealed_test or not args.frozen_manifest:
            raise SystemExit(
                "official test requires both --allow-sealed-test and --frozen-manifest"
            )
        frozen_path = Path(args.frozen_manifest)
        if not frozen_path.is_absolute():
            frozen_path = REPOSITORY_ROOT / frozen_path
        try:
            frozen = verify_freeze_manifest(
                frozen_path,
                REPOSITORY_ROOT,
                checkpoint_path=checkpoint_path,
                threshold=threshold,
            )
        except ValueError as exc:
            raise SystemExit(f"official-test freeze validation failed: {exc}") from exc
        frozen_dataset = frozen["official_test"]
        if frozen_dataset.get("dataset_id") != OFFICIAL_TEST_DATASET_ID:
            raise SystemExit("official-test freeze targets the wrong dataset identity")
        if frozen_dataset.get("revision") != args.revision:
            raise SystemExit("official-test revision differs from frozen manifest")
        freeze_manifest_sha256 = _sha256(frozen_path)
        local_source = _local_source_path(args.source)
        if local_source is not None:
            try:
                _verify_local_official_snapshot(local_source)
            except ValueError as exc:
                raise SystemExit(f"official-test snapshot validation failed: {exc}") from exc
        elif _hf_dataset_id_from_source(args.source) is None:
            raise SystemExit("declared local official-test source does not exist")

    if args.smoke_test:
        from torch.utils.data import DataLoader

        dataset = SyntheticFeatureDataset(32, feature_config.n_mels, 96, seed=18)
        loader = DataLoader(
            dataset,
            batch_size=args.batch_size,
            collate_fn=AudioFeatureCollator(frontend, max_seconds=1.0),
        )
    else:
        if not args.source:
            raise SystemExit("--source is required unless --smoke-test is used")
        loader = build_record_dataloader(
            args.source,
            split=source_split,
            frontend=frontend,
            batch_size=args.batch_size,
            max_seconds=max_seconds,
            shuffle=False,
            num_workers=args.num_workers,
            token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"),
            revision=dataset_revision,
            max_examples=args.max_examples,
            source_root=args.source_root,
        )
    labels: list[int] = []
    probabilities: list[float] = []
    records: list[dict[str, Any]] = []
    languages: list[Any] = []
    datasets: list[Any] = []
    synthetic_values: list[Any] = []
    filler_types: list[str] = []
    duration_bins: list[str] = []
    turn_ids: list[str] = []
    turn_id_observed: list[bool] = []
    group_ids: list[str] = []
    durations: list[float] = []

    with torch.inference_mode():
        for batch in loader:
            features = batch["log_mel"].to(device)
            mask = batch["attention_mask"].to(device)
            output = model(features, mask)
            batch_probabilities = torch.sigmoid(output.endpoint_logits).cpu().tolist()
            batch_labels = [int(value) for value in batch["endpoint"].tolist()]
            for index, (target, probability) in enumerate(
                zip(batch_labels, batch_probabilities, strict=True)
            ):
                midfiller = float(batch["midfiller"][index])
                endfiller = float(batch["endfiller"][index])
                if midfiller == 1.0:
                    filler_type = "midfiller"
                elif endfiller == 1.0:
                    filler_type = "endfiller"
                elif midfiller == 0.0 and endfiller == 0.0:
                    filler_type = "no_filler"
                else:
                    filler_type = "unknown"
                duration = batch["duration_seconds"][index]
                if duration is None:
                    duration_bin = "unknown"
                elif float(duration) < 2.0:
                    duration_bin = "<2s"
                elif float(duration) < 4.0:
                    duration_bin = "2-4s"
                elif float(duration) < 8.0:
                    duration_bin = "4-8s"
                else:
                    duration_bin = ">=8s"
                record = {
                    "record_id": batch["record_id"][index],
                    "turn_id": batch["turn_id"][index],
                    "group_id": batch["group_id"][index],
                    "label": target,
                    "probability": float(probability),
                    "prediction": int(probability >= threshold),
                    "language": batch["language"][index],
                    "dataset": batch["dataset"][index],
                    "synthetic": batch["synthetic"][index],
                    "filler_type": filler_type,
                    "duration_bin": duration_bin,
                }
                records.append(record)
                filler_types.append(filler_type)
                duration_bins.append(duration_bin)
            labels.extend(batch_labels)
            probabilities.extend(float(value) for value in batch_probabilities)
            languages.extend(batch["language"])
            datasets.extend(batch["dataset"])
            synthetic_values.extend(batch["synthetic"])
            turn_ids.extend(batch["turn_id"])
            turn_id_observed.extend(bool(value) for value in batch["turn_id_observed"])
            group_ids.extend(batch["group_id"])
            durations.extend(
                float(value) for value in batch["duration_seconds"] if value is not None
            )

    if not labels:
        raise SystemExit("evaluation source produced no examples")
    if official_test and len(labels) != OFFICIAL_TEST_EXPECTED_ROWS:
        raise SystemExit(
            "official-test evaluation was incomplete: "
            f"expected {OFFICIAL_TEST_EXPECTED_ROWS} examples, evaluated {len(labels)}"
        )
    total_audio_seconds = sum(durations) if len(durations) == len(labels) else None
    slice_report = sliced_metrics(
        labels,
        probabilities,
        {
            "language": languages,
            "dataset": datasets,
            "synthetic": synthetic_values,
            "filler_type": filler_types,
            "duration_bin": duration_bins,
        },
        threshold=threshold,
        min_count=args.min_slice_count,
    )

    def worst_group(metric: str, maximize: bool) -> dict[str, Any] | None:
        candidates: list[dict[str, Any]] = []
        for dimension, values in slice_report.items():
            for value, metrics in values.items():
                score = metrics.get(metric)
                if score is not None:
                    candidates.append(
                        {
                            "dimension": dimension,
                            "value": value,
                            "count": metrics["count"],
                            metric: score,
                        }
                    )
        if not candidates:
            return None
        return sorted(candidates, key=lambda item: float(item[metric]), reverse=maximize)[0]

    has_observed_turn_ids = bool(turn_id_observed) and all(turn_id_observed)
    operational = operational_metrics(
        labels,
        probabilities,
        threshold,
        turn_ids=turn_ids if has_observed_turn_ids else None,
        total_audio_seconds=total_audio_seconds,
    )
    operational["sequence_metrics_available"] = has_observed_turn_ids
    operational["scope"] = (
        "observed turn/conversation sequences"
        if has_observed_turn_ids
        else "independent labeled clips; per-hour rate is a clip-normalized proxy, not an "
        "online conversation measurement"
    )
    if not has_observed_turn_ids:
        operational["sequence_metrics_unavailable_reason"] = (
            "source records contain no genuine turn_id or conversation_id"
        )

    report = {
        "checkpoint": _portable_path(checkpoint_path),
        "checkpoint_sha256": _sha256(checkpoint_path),
        "dataset_revision": dataset_revision,
        "official_test": official_test,
        "freeze_manifest_sha256": freeze_manifest_sha256,
        "split": report_split,
        "source_split": source_split,
        "evaluation_source": _evaluation_source_evidence(
            args.source,
            dataset_id=args.dataset_id,
            revision=args.revision or metadata.get("data_revision"),
        ),
        "development_only": development_only,
        "training_status": training_status,
        "data_scope": metadata.get("data_scope"),
        "data_revision": metadata.get("data_revision"),
        "threshold": threshold,
        "metrics": binary_classification_metrics(labels, probabilities, threshold),
        "operating_points": metrics_at_fpr_budgets(labels, probabilities),
        "operating_points_note": (
            "Label-dependent threshold sweep for curve analysis only. The deployed result is "
            "`metrics` at the checkpoint's frozen validation-selected threshold."
        ),
        "operational": operational,
        "slices": slice_report,
        "worst_groups": {
            "highest_false_positive_rate": worst_group("false_positive_rate", True),
            "lowest_recall": worst_group("recall", False),
        },
        "grouped_bootstrap_95ci": {
            "false_positive_rate": grouped_bootstrap_interval(
                labels,
                probabilities,
                group_ids,
                threshold,
                metric="false_positive_rate",
                samples=args.bootstrap_samples,
                seed=17,
            ),
            "recall": grouped_bootstrap_interval(
                labels,
                probabilities,
                group_ids,
                threshold,
                metric="recall",
                samples=args.bootstrap_samples,
                seed=17,
            ),
        },
        "reliability": reliability_bins(labels, probabilities),
    }

    output_path = Path(args.output)
    if not output_path.is_absolute():
        output_path = REPOSITORY_ROOT / output_path
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(
        json.dumps(report, indent=2, sort_keys=True, allow_nan=False), encoding="utf-8"
    )
    prediction_path = (
        Path(args.predictions_output)
        if args.predictions_output
        else output_path.with_name(output_path.stem + ".predictions.jsonl")
    )
    if not prediction_path.is_absolute():
        prediction_path = REPOSITORY_ROOT / prediction_path
    prediction_path.parent.mkdir(parents=True, exist_ok=True)
    with prediction_path.open("w", encoding="utf-8") as handle:
        for record in records:
            handle.write(json.dumps(record, ensure_ascii=False, allow_nan=False) + "\n")
    print(json.dumps({"metrics": report["metrics"], "output": str(output_path)}, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())