File size: 15,705 Bytes
d1ecac0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Export the Phase 3 dashboard data from committed results.

The SPA's comparison dashboard reads one static JSON file, imported at build
time (ADR-021). This module builds it from two committed sources and nothing
else:

- the cross-run quality summary, ``results/<comparison_id>/comparison.json``
  (TASK-013, ``docs/EVAL_METHODOLOGY.md`` § 8);
- every latency run, ``results/<prefix><model_id>-<decoding>-<device>/latency.json``
  (TASK-015, § 9).

Values are copied verbatim, never recomputed or rounded. The only hand-written
content is each model's display name and the caveat notes, which restate
§§ 8 and 9. The sources are checked before anything is exported: quality and
latency must share the slice, every latency run must use the same protocol,
inputs, settings and timing, every summary must recompute from its raw
samples, and a model's Hub id and revision must agree across all its runs.
Any disagreement raises :class:`DashboardExportError` naming the run.
"""

from __future__ import annotations

import json
from collections.abc import Sequence
from pathlib import Path
from typing import Any, TypeVar

from pydantic import BaseModel, ConfigDict, ValidationError

from captioning.evaluation.comparison import METRIC_KEYS, METRIC_LABELS
from captioning.evaluation.latency import summarize

DASHBOARD_SCHEMA_VERSION = 1
DEFAULT_COMPARISON_ID = "phase3-comparison"
DEFAULT_LATENCY_PREFIX = "phase3-latency-"
EXPORT_COMMAND = "python -m scripts.export_dashboard_data"

# Names shown in the dashboard, keyed by the § 8.1 model id.
DISPLAY_NAMES = {
    "blip-base": "BLIP-base",
    "git-base-coco": "GIT-base-coco",
    "inceptionv3-transformer-stabilized": "CNN + Transformer (InceptionV3)",
    "vit-gpt2": "ViT-GPT2",
}
# How each adapter handles a batch (§ 9.5): one generate() call for the whole
# batch, or one single-image prediction after another.
BATCH_MODES = {"CNNCaptioner": "sequential", "HFCaptioner": "batched"}

QUALITY_NOTES = (
    "Scores use the slice's stored references (about {rpi:.2f} per image), not the "
    "five-reference COCO setup, so they can't be compared with published COCO results "
    "(docs/EVAL_METHODOLOGY.md § 8.8).",
    'Rows of kind "reference" are committed CNN + Transformer runs made before the comparison '
    "harness, so they record no revision (null). The greedy one is reproduced exactly by a "
    "harness run at the pinned revision (docs/EVAL_METHODOLOGY.md § 8.8). The beam-search row "
    "is a labelled reference and isn't compared with the greedy baselines "
    "(docs/EVAL_METHODOLOGY.md § 8.4).",
    "Models are listed by model id. The order is not a ranking.",
)
LATENCY_NOTES = (
    "Values are seconds per call, and one call captions one whole batch, so a batch-8 value is "
    "the time for eight images, not per image (docs/EVAL_METHODOLOGY.md § 9.4).",
    'Runs with batch_mode "sequential" (the CNN + Transformer) caption a batch one image at a '
    "time: their batch-8 values are eight single-image calls in a row, not batched inference, "
    "and aren't like-for-like with the batched Hugging Face values (docs/EVAL_METHODOLOGY.md "
    "§ 9.5).",
    "Each device's runs come from a different host, operating system and framework build (see "
    "each run's environment). Figures compare only within one environment and device; together "
    "they are not a controlled CPU-versus-GPU comparison (docs/EVAL_METHODOLOGY.md §§ 9.5, 9.10).",
    "load_seconds is the cold-start cost on that host: captioner construction plus load(), "
    "including framework imports and any download. It is never part of a sample "
    "(docs/EVAL_METHODOLOGY.md § 9.1).",
    "Latency says nothing about caption quality (docs/EVAL_METHODOLOGY.md § 9.4).",
)


class DashboardExportError(ValueError):
    """The committed results can't be exported: a file is missing, malformed or inconsistent."""


class _Artefact(BaseModel):
    # Strict: a malformed value (a number stored as a string) is refused, not converted.
    model_config = ConfigDict(extra="forbid", strict=True, protected_namespaces=())


# A copied number keeps its JSON type: an int is never widened to a float.
_Number = int | float


class _ComparisonSlice(_Artefact):
    fingerprint_sha256: str
    images: int
    references: int
    references_per_image: _Number


class _ComparisonRow(_Artefact):
    run_id: str
    kind: str
    model_id: str
    backend: str | None
    captioner: str | None
    hub_repo: str | None
    revision: str | None
    weights_path: str
    decode_strategy: str
    decode_settings: dict[str, Any]
    n_samples: int
    metrics: dict[str, _Number | None]
    metric_errors: dict[str, str]


class _Comparison(_Artefact):
    protocol: str | None
    normalisation: str | None
    slice: _ComparisonSlice
    caveat: str
    metric_keys: list[str]
    rows: list[_ComparisonRow]


class _LatencySlice(_Artefact):
    source: str
    fingerprint_sha256: str
    images: int
    references: int


class _LatencyInputs(_Artefact):
    slice: _LatencySlice
    images: list[str]


class _LatencySettings(_Artefact):
    num_images: int
    batch_sizes: list[int]
    warmup_passes: int
    measured_passes: int


class _Timing(_Artefact):
    clock: str
    sample: str
    load: str


class _Summary(_Artefact):
    count: int
    mean: _Number
    median: _Number
    min: _Number
    max: _Number


class _Batch(_Artefact):
    batch_size: int
    calls_per_pass: int
    samples_seconds: list[_Number]
    summary_seconds: _Summary


class _Runtime(_Artefact):
    python: str
    platform: str
    packages: dict[str, str | None]
    tensorflow_gpus: list[str] | None


class _Latency(_Artefact):
    protocol: str
    model_id: str
    backend: str
    captioner: str
    hub_repo: str | None
    revision: str | None
    decode_strategy: str
    decode_settings: dict[str, Any]
    device: str
    environment: str
    inputs: _LatencyInputs
    settings: _LatencySettings
    timing: _Timing
    load_seconds: _Number
    batches: list[_Batch]
    seed: int
    runtime: _Runtime


_ModelT = TypeVar("_ModelT", bound=BaseModel)


def find_latency_runs(results_root: str | Path, prefix: str = DEFAULT_LATENCY_PREFIX) -> list[Path]:
    """Return every ``<results_root>/<prefix>*/`` latency run directory, sorted by name."""
    return sorted(p for p in Path(results_root).glob(f"{prefix}*") if p.is_dir())


def build_dashboard_data(
    results_root: str | Path,
    *,
    comparison_id: str = DEFAULT_COMPARISON_ID,
    latency_prefix: str = DEFAULT_LATENCY_PREFIX,
) -> dict[str, Any]:
    """Check the committed Phase 3 results and return the dashboard data.

    Args:
        results_root: The ``results/`` directory.
        comparison_id: The quality summary's directory under ``results_root``.
        latency_prefix: Every directory under ``results_root`` whose name starts
            with this is read as a latency run.

    Raises:
        DashboardExportError: If a source is missing, malformed or inconsistent.
    """
    results_root = Path(results_root)
    comparison = _read(results_root / comparison_id / "comparison.json", _Comparison, comparison_id)
    if comparison.metric_keys != list(METRIC_KEYS):
        raise DashboardExportError(
            f"{comparison_id}: metric_keys {comparison.metric_keys} differ from {list(METRIC_KEYS)}"
        )
    latency_dirs = find_latency_runs(results_root, latency_prefix)
    if not latency_dirs:
        raise DashboardExportError(
            f"no latency runs found: no {latency_prefix}* directory under {results_root.as_posix()}"
        )
    runs = [(d.name, _read(d / "latency.json", _Latency, d.name)) for d in latency_dirs]
    base_id, base = runs[0]
    for run_id, run in runs:
        _check_latency_run(run_id, run, latency_prefix)
        for label, value, expected in (
            ("protocol", run.protocol, base.protocol),
            ("inputs", run.inputs, base.inputs),
            ("settings", run.settings, base.settings),
            ("timing", run.timing, base.timing),
            ("seed", run.seed, base.seed),
        ):
            if value != expected:
                raise DashboardExportError(f"{run_id}: {label} doesn't match {base_id}")
    latency_slice = base.inputs.slice
    for label, value, expected in (
        (
            "slice fingerprint",
            latency_slice.fingerprint_sha256,
            comparison.slice.fingerprint_sha256,
        ),
        ("slice image count", latency_slice.images, comparison.slice.images),
        ("slice reference count", latency_slice.references, comparison.slice.references),
    ):
        if value != expected:
            raise DashboardExportError(
                f"{base_id}: {label} {value} differs from {comparison_id} ({expected})"
            )
    for run_id in [row.run_id for row in comparison.rows]:
        if not (results_root / run_id).is_dir():
            raise DashboardExportError(
                f"{comparison_id}: source run {run_id} has no directory under "
                f"{results_root.as_posix()}"
            )

    model_ids = sorted({row.model_id for row in comparison.rows} | {r.model_id for _, r in runs})
    unnamed = [model_id for model_id in model_ids if model_id not in DISPLAY_NAMES]
    if unnamed:
        raise DashboardExportError(
            f"no display name for model(s) {unnamed}; add it to DISPLAY_NAMES"
        )

    s = comparison.slice
    return {
        "schema_version": DASHBOARD_SCHEMA_VERSION,
        "generated_by": EXPORT_COMMAND,
        "slice": {
            "description": (
                f"{s.images} COCO train2017 images, in the order of {latency_slice.source}, each "
                f"scored against its stored references: {s.references} in total, about "
                f"{s.references_per_image:.2f} per image. The latency runs time the first "
                f"{base.settings.num_images} of these images."
            ),
            "source": latency_slice.source,
            "fingerprint_sha256": s.fingerprint_sha256,
            "images": s.images,
            "references": s.references,
            "references_per_image": s.references_per_image,
        },
        "overlap_caveat": comparison.caveat,
        "quality": {
            "protocol": comparison.protocol,
            "normalisation": comparison.normalisation,
            "summary_run_id": comparison_id,
            "metrics": [
                {"key": key, "label": label}
                for key, label in zip(METRIC_KEYS, METRIC_LABELS, strict=True)
            ],
            "notes": [note.format(rpi=s.references_per_image) for note in QUALITY_NOTES],
        },
        "latency": {
            "protocol": base.protocol,
            "unit": "seconds per call; one call captions one batch",
            "statistics": list(_Summary.model_fields),
            "settings": base.settings.model_dump(),
            "timing": base.timing.model_dump(),
            "notes": list(LATENCY_NOTES),
        },
        "models": [_model_entry(model_id, comparison, runs) for model_id in model_ids],
    }


def render_dashboard_json(data: dict[str, Any]) -> str:
    """Serialise :func:`build_dashboard_data` output exactly as it is committed."""
    return json.dumps(data, indent=2, ensure_ascii=False) + "\n"


def _check_latency_run(run_id: str, run: _Latency, prefix: str) -> None:
    expected_id = f"{prefix}{run.model_id}-{run.decode_strategy}-{run.device}"
    if run_id != expected_id:
        raise DashboardExportError(
            f"{run_id}: latency.json describes {expected_id} (model, decoding and device)"
        )
    if run.captioner not in BATCH_MODES:
        raise DashboardExportError(f"{run_id}: unknown captioner {run.captioner!r}")
    batch_sizes = [batch.batch_size for batch in run.batches]
    if batch_sizes != run.settings.batch_sizes:
        raise DashboardExportError(
            f"{run_id}: batches {batch_sizes} differ from settings.batch_sizes "
            f"{run.settings.batch_sizes}"
        )
    for batch in run.batches:
        expected_count = batch.calls_per_pass * run.settings.measured_passes
        if len(batch.samples_seconds) != expected_count:
            raise DashboardExportError(
                f"{run_id}: batch size {batch.batch_size} has {len(batch.samples_seconds)} "
                f"samples, expected {expected_count}"
            )
        if summarize(batch.samples_seconds) != batch.summary_seconds.model_dump():
            raise DashboardExportError(
                f"{run_id}: batch size {batch.batch_size} summary_seconds doesn't match its "
                "samples_seconds"
            )


def _model_entry(
    model_id: str, comparison: _Comparison, runs: Sequence[tuple[str, _Latency]]
) -> dict[str, Any]:
    rows = [row for row in comparison.rows if row.model_id == model_id]
    latency = sorted(
        ((run_id, run) for run_id, run in runs if run.model_id == model_id),
        key=lambda item: (item[1].device, item[1].decode_strategy, item[0]),
    )
    identity = {
        field: _agreed(
            model_id,
            field,
            [(row.run_id, getattr(row, field)) for row in rows]
            + [(run_id, getattr(run, field)) for run_id, run in latency],
        )
        for field in ("backend", "hub_repo", "revision")
    }
    quality = [
        {
            "run_id": row.run_id,
            "kind": row.kind,
            "revision": row.revision,
            "decode_strategy": row.decode_strategy,
            "decode_settings": row.decode_settings,
            "n_samples": row.n_samples,
            "metrics": row.metrics,
            "metric_errors": row.metric_errors,
        }
        for row in rows
    ]
    latency_entries = [
        {
            "run_id": run_id,
            "device": run.device,
            "environment": run.environment,
            "decode_strategy": run.decode_strategy,
            "batch_mode": BATCH_MODES[run.captioner],
            "load_seconds": run.load_seconds,
            "batches": [
                {
                    "batch_size": batch.batch_size,
                    "calls_per_pass": batch.calls_per_pass,
                    "summary_seconds": batch.summary_seconds.model_dump(),
                }
                for batch in run.batches
            ],
        }
        for run_id, run in latency
    ]
    return {
        "model_id": model_id,
        "display_name": DISPLAY_NAMES[model_id],
        **identity,
        "source_run_ids": [entry["run_id"] for entry in quality + latency_entries],
        "quality": quality,
        "latency": latency_entries,
    }


def _agreed(model_id: str, field: str, values: list[tuple[str, str | None]]) -> str:
    """Return the one value every run that records ``field`` gives for ``model_id``."""
    recorded = {value for _, value in values if value is not None}
    if not recorded:
        raise DashboardExportError(f"{model_id}: no run records its {field}")
    if len(recorded) > 1:
        detail = ", ".join(f"{run_id}={value}" for run_id, value in values if value is not None)
        raise DashboardExportError(f"{model_id}: runs disagree on {field}: {detail}")
    return recorded.pop()


def _read(path: Path, model: type[_ModelT], run_id: str) -> _ModelT:
    if not path.is_file():
        raise DashboardExportError(f"{run_id}: missing {path.name}")
    try:
        return model.model_validate(json.loads(path.read_text(encoding="utf-8")))
    except (ValueError, ValidationError) as exc:
        detail = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
        raise DashboardExportError(f"{run_id}: {path.name} is malformed: {detail}") from exc