File size: 8,653 Bytes
51e84d2
 
 
 
 
 
 
 
fc1dcbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61fc10e
fc1dcbc
 
 
61fc10e
 
 
 
 
fc1dcbc
 
 
 
 
 
 
 
61fc10e
fc1dcbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# License: Apache-2.0 License
# Created by: Patrick Lumbantobing, VertoX-AI
# Copyright (c) 2026 VertoX-AI. All rights reserved.
#
# This work is licensed under the Apache-2.0 License.
# To view a copy of this license, visit
# https://www.apache.org/licenses/LICENSE-2.0

"""Benchmark identical ordered metadata requests through injected neutral backends."""

from __future__ import annotations

import itertools
import math
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path

from speech_text_data_aligners.application.annotate import (
    _execute_backend,
    _prepare_rows,
)
from speech_text_data_aligners.benchmarking.metrics import (
    BenchmarkObservation,
    GoldReference,
    RowKey,
    calculate_gold_metrics,
    calculate_pairwise_metrics,
)
from speech_text_data_aligners.benchmarking.reports import (
    BackendReport,
    BenchmarkReport,
)
from speech_text_data_aligners.core.errors import BenchmarkError
from speech_text_data_aligners.core.extraction import extract_boundary_fields
from speech_text_data_aligners.core.protocols import AlignmentBackend
from speech_text_data_aligners.data.metadata import MetadataTable, read_metadata


@dataclass(frozen=True, slots=True)
class BenchmarkOptions:
    """Control benchmark row-failure behavior.

    Parameters
    ----------
        strict: Abort on the first preflight, backend, mapping, or validation failure.

    """

    strict: bool = False


_DEFAULT_BENCHMARK_OPTIONS = BenchmarkOptions()


def _parse_optional_seconds(value: str, *, row_ordinal: int) -> float | None:
    """Parse one optional finite, non-negative gold timestamp.

    Parameters
    ----------
        value: Exact CSV cell; empty means missing reference.
        row_ordinal: Zero-based reference row ordinal used in diagnostics.

    Returns
    -------
        Parsed seconds or ``None`` for an empty cell.

    Raises
    ------
        BenchmarkError: If a non-empty value is invalid, negative, NaN, or infinite.

    """
    if value == "":
        return None
    try:
        seconds = float(value)
    except ValueError as error:
        raise BenchmarkError(
            f"reference row {row_ordinal} has an invalid first_word_end_s"
        ) from error
    if not math.isfinite(seconds) or seconds < 0:
        raise BenchmarkError(
            f"reference row {row_ordinal} has an invalid first_word_end_s"
        )
    return seconds


def _read_references(table: MetadataTable) -> dict[RowKey, GoldReference]:
    """Parse the supported sparse reference fields with unambiguous joins.

    Parameters
    ----------
        table: Reference table preserving exact CSV cells.

    Returns
    -------
        References keyed by ``(line_id, audio_id)``.

    Raises
    ------
        BenchmarkError: If fields are missing, keys repeat, or timestamps are invalid.

    """
    required = ("first_word_text", "first_word_end_s")
    if any(table.fieldnames.count(name) != 1 for name in required):
        raise BenchmarkError("reference CSV lacks the supported first-word fields")
    references: dict[RowKey, GoldReference] = {}
    for ordinal in range(len(table.rows)):
        row = table.row_dict(ordinal)
        reference = GoldReference(
            line_id=row["line_id"],
            audio_id=row["audio_id"],
            first_word_text=row["first_word_text"] or None,
            first_word_end_s=_parse_optional_seconds(
                row["first_word_end_s"], row_ordinal=ordinal
            ),
        )
        if reference.key in references:
            raise BenchmarkError("reference CSV contains a duplicate join key")
        references[reference.key] = reference
    return references


def benchmark_backends(
    *,
    backends: Mapping[str, AlignmentBackend],
    metadata_csv: Path,
    reference_csv: Path,
    audio_dir: Path,
    options: BenchmarkOptions = _DEFAULT_BENCHMARK_OPTIONS,
) -> BenchmarkReport:
    """Run identical ordered requests through each injected backend and score results.

    Parameters
    ----------
        backends: Insertion-ordered report name to protocol instance mapping.
        metadata_csv: Metadata rows to align.
        reference_csv: Sparse first-word gold/reference CSV.
        audio_dir: Directory containing WAVs resolved from ``audio_id``.
        options: Strict or continue-on-error policy.

    Returns
    -------
        Typed per-backend gold/runtime and pairwise-disagreement report.

    Raises
    ------
        BenchmarkError: If backend names/reference joins are invalid.
        SpeechTextAlignerError: If strict preparation/alignment fails.

    Side Effects:
        Probes source WAVs and calls each backend exactly once with the same request
        tuple; it does not write reports or mutate metadata.

    """
    if not backends:
        raise BenchmarkError("at least one backend is required")
    if any(not name for name in backends):
        raise BenchmarkError("benchmark backend report names cannot be empty")
    prepared = _prepare_rows(metadata_csv, audio_dir, strict=options.strict)
    references = _read_references(read_metadata(reference_csv))
    metadata_keys = {
        (
            prepared.table.row_dict(index)["line_id"],
            prepared.table.row_dict(index)["audio_id"],
        )
        for index in range(len(prepared.table.rows))
    }
    if set(references).difference(metadata_keys):
        raise BenchmarkError("reference CSV contains rows absent from metadata")

    request_by_id = {request.request_id: request for request in prepared.requests}
    backend_reports: list[BackendReport] = []
    for report_name, backend in backends.items():
        outcomes = _execute_backend(prepared, backend, strict=options.strict)
        observations: list[BenchmarkObservation] = []
        for ordinal, outcome in enumerate(outcomes):
            row = prepared.table.row_dict(ordinal)
            boundaries = None
            runtime = None
            failure_code = None
            if outcome.result is not None:
                request = request_by_id[outcome.request_id]
                boundaries = extract_boundary_fields(request, outcome.result)
                runtime = outcome.result.runtime_s
                native_artifacts = outcome.result.provenance.artifacts
            else:
                assert outcome.failure is not None
                failure_code = outcome.failure.code
                native_artifacts = (
                    outcome.failure.provenance.artifacts
                    if outcome.failure.provenance is not None
                    else ()
                )
            observations.append(
                BenchmarkObservation(
                    backend_name=report_name,
                    line_id=row["line_id"],
                    audio_id=row["audio_id"],
                    boundaries=boundaries,
                    runtime_s=runtime,
                    failure_code=failure_code,
                    native_artifacts=native_artifacts,
                )
            )
        observations_tuple = tuple(observations)
        backend_reports.append(
            BackendReport(
                backend_name=report_name,
                observations=observations_tuple,
                gold_metrics=calculate_gold_metrics(observations_tuple, references),
            )
        )

    pairwise = tuple(
        calculate_pairwise_metrics(left.observations, right.observations)
        for left, right in itertools.combinations(backend_reports, 2)
    )
    return BenchmarkReport(tuple(backend_reports), pairwise)


def benchmark_backend(
    *,
    backend: AlignmentBackend,
    metadata_csv: Path,
    reference_csv: Path,
    audio_dir: Path,
    options: BenchmarkOptions = _DEFAULT_BENCHMARK_OPTIONS,
) -> BenchmarkReport:
    """Benchmark one injected backend as a convenience wrapper.

    Parameters
    ----------
        backend: Backend-neutral protocol instance.
        metadata_csv: Metadata rows to align.
        reference_csv: Sparse first-word gold/reference CSV.
        audio_dir: Directory containing WAVs resolved from ``audio_id``.
        options: Strict or continue-on-error policy.

    Returns
    -------
        A typed report containing one backend and no pairwise comparisons.

    Raises
    ------
        SpeechTextAlignerError: If validation/preparation fails as documented by
            :func:`benchmark_backends`.

    """
    return benchmark_backends(
        backends={backend.name: backend},
        metadata_csv=metadata_csv,
        reference_csv=reference_csv,
        audio_dir=audio_dir,
        options=options,
    )