File size: 11,630 Bytes
c0bce59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""S8: dataset statistics (duration, tag/emotion distributions, characters per second).

Pure functions over small typed rows; no I/O. Durations are integer milliseconds.
Nothing is dropped silently: duration values outside the histogram edges land in explicit
underflow/overflow bins, and segments without tags or emotion are counted separately.
"""

from __future__ import annotations

import math
from bisect import bisect_right
from collections import Counter
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass
from itertools import pairwise

import numpy as np

__all__ = [
    "DEFAULT_CPS_PERCENTILES",
    "MS_PER_HOUR",
    "SegmentStat",
    "StatsSummary",
    "duration_histogram",
    "render_stats_markdown",
    "summarize",
]

MS_PER_HOUR = 3_600_000
_MS_PER_SECOND = 1000
DEFAULT_CPS_PERCENTILES: tuple[float, ...] = (1.0, 5.0, 50.0, 95.0, 99.0)


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


@dataclass(frozen=True)
class SegmentStat:
    """The per-segment facts S8 needs.

    Attributes:
        duration_ms: Segment length in milliseconds, > 0.
        num_chars: Characters of the final transcript used for the chars-per-second rate
            (the caller decides whether tags count), >= 0.
        tags: Tags occurring in the segment, one entry per occurrence.
        emotion: Emotion label of the segment, or ``None`` when unlabelled.
    """

    duration_ms: int
    num_chars: int
    tags: tuple[str, ...] = ()
    emotion: str | None = None

    def __post_init__(self) -> None:
        if not _is_int(self.duration_ms) or self.duration_ms <= 0:
            raise ValueError(f"duration_ms must be a positive int, got {self.duration_ms!r}")
        if not _is_int(self.num_chars) or self.num_chars < 0:
            raise ValueError(f"num_chars must be a non-negative int, got {self.num_chars!r}")
        if any(not isinstance(tag, str) or not tag for tag in self.tags):
            raise ValueError(f"tags must be non-empty strings, got {self.tags!r}")
        if self.emotion is not None and (not isinstance(self.emotion, str) or not self.emotion):
            raise ValueError(f"emotion must be None or a non-empty string, got {self.emotion!r}")

    @property
    def chars_per_second(self) -> float:
        return self.num_chars / (self.duration_ms / _MS_PER_SECOND)

    @staticmethod
    def from_mapping(
        row: Mapping[str, object],
        *,
        duration_key: str = "duration_ms",
        chars_key: str = "num_chars",
        tags_key: str = "tags",
        emotion_key: str = "emotion",
    ) -> SegmentStat:
        """Build from a manifest row, validating the types strictly.

        Raises:
            ValueError: if a key is missing, or a value has the wrong type or range.
        """
        for key in (duration_key, chars_key, tags_key, emotion_key):
            if key not in row:
                raise ValueError(f"row has no column {key!r}")
        duration, chars, tags, emotion = (
            row[duration_key],
            row[chars_key],
            row[tags_key],
            row[emotion_key],
        )
        if not isinstance(duration, int) or not isinstance(chars, int):
            raise ValueError(f"{duration_key!r} and {chars_key!r} must be ints")
        if not isinstance(tags, list | tuple) or any(not isinstance(t, str) for t in tags):
            raise ValueError(f"{tags_key!r} must be a list of strings, got {tags!r}")
        if emotion is not None and not isinstance(emotion, str):
            raise ValueError(f"{emotion_key!r} must be a string or None, got {emotion!r}")
        return SegmentStat(duration_ms=duration, num_chars=chars, tags=tuple(tags), emotion=emotion)


def _check_edges(bin_edges_ms: Sequence[int]) -> None:
    if not bin_edges_ms:
        raise ValueError("bin_edges_ms must not be empty")
    if any(not _is_int(edge) or edge < 0 for edge in bin_edges_ms):
        raise ValueError(f"bin edges must be non-negative ints, got {list(bin_edges_ms)!r}")
    if any(b <= a for a, b in pairwise(bin_edges_ms)):
        raise ValueError(f"bin edges must be strictly increasing, got {list(bin_edges_ms)!r}")


def duration_histogram(durations_ms: Iterable[int], bin_edges_ms: Sequence[int]) -> list[int]:
    """Histogram of durations over ``n`` strictly increasing edges.

    Returns ``n + 1`` counts: index 0 is the underflow bin ``(-inf, e0)``, index ``k`` for
    ``1 <= k < n`` is ``[e(k-1), e(k))``, and index ``n`` is the overflow bin ``[e(n-1), inf)``.
    Every duration is counted exactly once, so ``sum(result) == len(durations_ms)``.

    Raises:
        ValueError: if the edges are empty, negative or not strictly increasing, or a
            duration is not a non-negative int.
    """
    _check_edges(bin_edges_ms)
    counts = [0] * (len(bin_edges_ms) + 1)
    for duration in durations_ms:
        if not _is_int(duration) or duration < 0:
            raise ValueError(f"duration must be a non-negative int in ms, got {duration!r}")
        counts[bisect_right(bin_edges_ms, duration)] += 1
    return counts


def _percentile_name(percentile: float) -> str:
    return f"p{percentile:g}"


@dataclass(frozen=True)
class StatsSummary:
    """Result of :func:`summarize`.

    ``duration_histogram`` has ``len(duration_bin_edges_ms) + 1`` entries (see
    :func:`duration_histogram`). ``cps_percentiles`` maps names such as ``"p5"`` to
    characters per second and is empty when there are no segments. Count mappings are
    ordered most frequent first (ties alphabetically).
    """

    num_segments: int
    total_duration_ms: int
    total_hours: float
    duration_bin_edges_ms: tuple[int, ...]
    duration_histogram: tuple[int, ...]
    tag_counts: dict[str, int]
    tag_segment_counts: dict[str, int]
    num_segments_without_tags: int
    emotion_counts: dict[str, int]
    num_segments_without_emotion: int
    cps_percentiles: dict[str, float]

    def to_dict(self) -> dict[str, object]:
        """JSON-compatible dict (for ``metrics.json``)."""
        return asdict(self)


def _ranked(counter: Mapping[str, int]) -> dict[str, int]:
    return dict(sorted(counter.items(), key=lambda item: (-item[1], item[0])))


def summarize(
    rows: Iterable[SegmentStat],
    *,
    duration_bin_edges_ms: Sequence[int],
    percentiles: Sequence[float] = DEFAULT_CPS_PERCENTILES,
) -> StatsSummary:
    """Aggregate segment statistics.

    Args:
        rows: Segments to describe (consumed once).
        duration_bin_edges_ms: Edges for the duration histogram.
        percentiles: Percentiles in [0, 100] of the chars-per-second distribution
            (linear interpolation, ``numpy.percentile`` default); no duplicates.

    Raises:
        ValueError: on invalid edges or percentiles.
    """
    _check_edges(duration_bin_edges_ms)
    if any(not (math.isfinite(p) and 0.0 <= p <= 100.0) for p in percentiles):
        raise ValueError(f"percentiles must be in [0, 100], got {list(percentiles)!r}")
    if len(set(percentiles)) != len(percentiles):
        raise ValueError(f"duplicate percentiles: {list(percentiles)!r}")

    durations: list[int] = []
    rates: list[float] = []
    tag_counts: Counter[str] = Counter()
    tag_segment_counts: Counter[str] = Counter()
    emotion_counts: Counter[str] = Counter()
    without_tags = 0
    without_emotion = 0
    for row in rows:
        durations.append(row.duration_ms)
        rates.append(row.chars_per_second)
        tag_counts.update(row.tags)
        tag_segment_counts.update(set(row.tags))
        if not row.tags:
            without_tags += 1
        if row.emotion is None:
            without_emotion += 1
        else:
            emotion_counts[row.emotion] += 1

    total_ms = sum(durations)
    cps: dict[str, float] = {}
    if rates:
        values = np.percentile(np.asarray(rates, dtype=np.float64), list(percentiles))
        cps = {_percentile_name(p): float(v) for p, v in zip(percentiles, values, strict=True)}
    return StatsSummary(
        num_segments=len(durations),
        total_duration_ms=total_ms,
        total_hours=total_ms / MS_PER_HOUR,
        duration_bin_edges_ms=tuple(duration_bin_edges_ms),
        duration_histogram=tuple(duration_histogram(durations, duration_bin_edges_ms)),
        tag_counts=_ranked(tag_counts),
        tag_segment_counts=_ranked(tag_segment_counts),
        num_segments_without_tags=without_tags,
        emotion_counts=_ranked(emotion_counts),
        num_segments_without_emotion=without_emotion,
        cps_percentiles=cps,
    )


def _backtick_runs(text: str) -> list[int]:
    """Lengths of the maximal runs of backticks in ``text``."""
    runs: list[int] = []
    current = 0
    for char in text:
        if char == "`":
            current += 1
        elif current:
            runs.append(current)
            current = 0
    if current:
        runs.append(current)
    return runs


def _code(text: str) -> str:
    """Inline-code span that survives pipes, angle brackets and backticks in ``text``.

    The fence is one backtick longer than the longest backtick run inside the text.
    """
    runs = _backtick_runs(text)
    fence = "`" * ((max(runs) + 1) if runs else 1)
    return f"{fence} {text} {fence}".replace("|", r"\|")


def _seconds(edge_ms: int) -> str:
    return f"{edge_ms / _MS_PER_SECOND:g}"


def _bin_labels(edges_ms: Sequence[int]) -> list[str]:
    labels = [f"< {_seconds(edges_ms[0])} s"]
    labels += [f"[{_seconds(lo)}, {_seconds(hi)}) s" for lo, hi in pairwise(edges_ms)]
    labels.append(f">= {_seconds(edges_ms[-1])} s")
    return labels


def _share(count: int, total: int) -> str:
    return f"{100.0 * count / total:.1f}%" if total else "-"


def render_stats_markdown(summary: StatsSummary, *, title: str = "Veri istatistikleri") -> str:
    """Render a summary as a Markdown report section (Turkish headings)."""
    n = summary.num_segments
    lines: list[str] = [f"## {title}", ""]
    lines += [
        f"- Segment sayısı: {n}",
        f"- Toplam süre: {summary.total_hours:.2f} saat ({summary.total_duration_ms} ms)",
        f"- Etiketsiz segment: {summary.num_segments_without_tags}",
        f"- Duygu etiketsiz segment: {summary.num_segments_without_emotion}",
        "",
        "### Süre dağılımı",
        "",
        "| Aralık | Sayı | Oran |",
        "|---|---:|---:|",
    ]
    labels = _bin_labels(summary.duration_bin_edges_ms)
    for label, count in zip(labels, summary.duration_histogram, strict=True):
        lines.append(f"| {label} | {count} | {_share(count, n)} |")

    lines += ["", "### Etiketler", ""]
    if summary.tag_counts:
        lines += ["| Etiket | Geçiş | Segment |", "|---|---:|---:|"]
        for tag, count in summary.tag_counts.items():
            lines.append(f"| {_code(tag)} | {count} | {summary.tag_segment_counts[tag]} |")
    else:
        lines.append("Etiket yok.")

    lines += ["", "### Duygular", ""]
    if summary.emotion_counts:
        lines += ["| Duygu | Segment | Oran |", "|---|---:|---:|"]
        for emotion, count in summary.emotion_counts.items():
            lines.append(f"| {_code(emotion)} | {count} | {_share(count, n)} |")
    else:
        lines.append("Duygu etiketi yok.")

    lines += ["", "### Harf/saniye", ""]
    if summary.cps_percentiles:
        lines += ["| Yüzdelik | Harf/sn |", "|---|---:|"]
        for name, value in summary.cps_percentiles.items():
            lines.append(f"| {name} | {value:.2f} |")
    else:
        lines.append("Veri yok.")
    return "\n".join(lines) + "\n"