rtpbench 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. rtpbench/.dockerignore +4 -0
  2. rtpbench/Dockerfile +12 -0
  3. rtpbench/__init__.py +6 -0
  4. rtpbench/__main__.py +3 -0
  5. rtpbench/agent.py +746 -0
  6. rtpbench/analyze.py +520 -0
  7. rtpbench/bargein.py +272 -0
  8. rtpbench/bench.py +1069 -0
  9. rtpbench/caller.py +781 -0
  10. rtpbench/capture.py +33 -0
  11. rtpbench/carrier/compose.yaml +68 -0
  12. rtpbench/carrier/opensips.cfg +226 -0
  13. rtpbench/carrier/tls.cfg +25 -0
  14. rtpbench/carrier/tls.yaml +9 -0
  15. rtpbench/cases.py +1989 -0
  16. rtpbench/check.py +369 -0
  17. rtpbench/cli.py +616 -0
  18. rtpbench/clock.py +174 -0
  19. rtpbench/compose.yaml +55 -0
  20. rtpbench/corpus/README.md +55 -0
  21. rtpbench/corpus/cough-c1-0.wav +0 -0
  22. rtpbench/corpus/cough-c1-1.wav +0 -0
  23. rtpbench/corpus/cough-c2-0.wav +0 -0
  24. rtpbench/corpus/cough-c2-1.wav +0 -0
  25. rtpbench/corpus/cough-man-0.wav +0 -0
  26. rtpbench/corpus/cough-man-1.wav +0 -0
  27. rtpbench/corpus/cough-short-0.wav +0 -0
  28. rtpbench/corpus/cough-short-1.wav +0 -0
  29. rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
  30. rtpbench/corpus/human-en-dr-0.wav +0 -0
  31. rtpbench/corpus/human-en-dr-1.wav +0 -0
  32. rtpbench/corpus/human-en-sc-0.wav +0 -0
  33. rtpbench/corpus/human-en-sc-1.wav +0 -0
  34. rtpbench/corpus/human-en-sc-2.wav +0 -0
  35. rtpbench/corpus/human-es-f012-0.wav +0 -0
  36. rtpbench/corpus/human-es-f012-1.wav +0 -0
  37. rtpbench/corpus/human-es-f012-2.wav +0 -0
  38. rtpbench/corpus/human-es-f016-0.wav +0 -0
  39. rtpbench/corpus/human-es-f016-1.wav +0 -0
  40. rtpbench/corpus/human-es-f016-2.wav +0 -0
  41. rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
  42. rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
  43. rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
  44. rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
  45. rtpbench/corpus/lines/en/prompt-00.wav +0 -0
  46. rtpbench/corpus/lines/en/prompt-01.wav +0 -0
  47. rtpbench/corpus/lines/en/prompt-02.wav +0 -0
  48. rtpbench/corpus/lines/en/prompt-03.wav +0 -0
  49. rtpbench/corpus/lines/en/prompt-04.wav +0 -0
  50. rtpbench/corpus/lines/en/prompt-05.wav +0 -0
  51. rtpbench/corpus/lines/en/prompt-06.wav +0 -0
  52. rtpbench/corpus/lines/en/prompt-07.wav +0 -0
  53. rtpbench/corpus/lines/en/prompt-08.wav +0 -0
  54. rtpbench/corpus/lines/en/prompt-09.wav +0 -0
  55. rtpbench/corpus/lines/en/prompt.wav +0 -0
  56. rtpbench/corpus/lines/en/transfer.wav +0 -0
  57. rtpbench/corpus/lines/en/turn-00.wav +0 -0
  58. rtpbench/corpus/lines/en/turn-01.wav +0 -0
  59. rtpbench/corpus/lines/en/turn-02.wav +0 -0
  60. rtpbench/corpus/lines/en/turn-03.wav +0 -0
  61. rtpbench/corpus/lines/en/turn-04.wav +0 -0
  62. rtpbench/corpus/lines/en/turn-05.wav +0 -0
  63. rtpbench/corpus/lines/en/turn-06.wav +0 -0
  64. rtpbench/corpus/lines/en/turn-07.wav +0 -0
  65. rtpbench/corpus/lines/en/turn-08.wav +0 -0
  66. rtpbench/corpus/lines/en/turn-09.wav +0 -0
  67. rtpbench/corpus/lines/en/turn-10.wav +0 -0
  68. rtpbench/corpus/lines/en/turn-11.wav +0 -0
  69. rtpbench/corpus/tts-ald-bc.wav +0 -0
  70. rtpbench/corpus/tts-ald-long.wav +0 -0
  71. rtpbench/corpus/tts-ald-s03.wav +0 -0
  72. rtpbench/corpus/tts-ald-s07.wav +0 -0
  73. rtpbench/corpus/tts-ald-s11.wav +0 -0
  74. rtpbench/corpus/tts-cori-bc.wav +0 -0
  75. rtpbench/corpus/tts-cori-long.wav +0 -0
  76. rtpbench/corpus/tts-cori-s01.wav +0 -0
  77. rtpbench/corpus/tts-cori-s05.wav +0 -0
  78. rtpbench/corpus/tts-cori-s09.wav +0 -0
  79. rtpbench/corpus/tts-davefx-bc.wav +0 -0
  80. rtpbench/corpus/tts-davefx-long.wav +0 -0
  81. rtpbench/corpus/tts-davefx-s00.wav +0 -0
  82. rtpbench/corpus/tts-davefx-s04.wav +0 -0
  83. rtpbench/corpus/tts-davefx-s08.wav +0 -0
  84. rtpbench/corpus/tts-libritts0-bc.wav +0 -0
  85. rtpbench/corpus/tts-libritts0-long.wav +0 -0
  86. rtpbench/corpus/tts-libritts0-s02.wav +0 -0
  87. rtpbench/corpus/tts-libritts0-s06.wav +0 -0
  88. rtpbench/corpus/tts-libritts0-s10.wav +0 -0
  89. rtpbench/corpus/tts-libritts3-bc.wav +0 -0
  90. rtpbench/corpus/tts-libritts3-long.wav +0 -0
  91. rtpbench/corpus/tts-libritts3-s03.wav +0 -0
  92. rtpbench/corpus/tts-libritts3-s07.wav +0 -0
  93. rtpbench/corpus/tts-libritts3-s11.wav +0 -0
  94. rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
  95. rtpbench/corpus/tts-ljspeech-long.wav +0 -0
  96. rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
  97. rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
  98. rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
  99. rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
  100. rtpbench/corpus/tts-sharvardf-long.wav +0 -0
  101. rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
  102. rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
  103. rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
  104. rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
  105. rtpbench/corpus/tts-sharvardm-long.wav +0 -0
  106. rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
  107. rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
  108. rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
  109. rtpbench/detect.py +145 -0
  110. rtpbench/g711.py +82 -0
  111. rtpbench/mock/compose.yaml +18 -0
  112. rtpbench/pcap.py +106 -0
  113. rtpbench/progress.py +184 -0
  114. rtpbench/realtime.py +1015 -0
  115. rtpbench/report.py +450 -0
  116. rtpbench/rtp.py +166 -0
  117. rtpbench/sip.py +653 -0
  118. rtpbench/speech.py +100 -0
  119. rtpbench/speechcal.py +367 -0
  120. rtpbench/srtp.py +191 -0
  121. rtpbench/targets/aivc/aivc.ini +25 -0
  122. rtpbench/targets/aivc/compose.yaml +72 -0
  123. rtpbench/targets/aivc/constraints.txt +52 -0
  124. rtpbench/targets/aivc/opensips.cfg +45 -0
  125. rtpbench/targets/ava/ai-agent.yaml +275 -0
  126. rtpbench/targets/ava/ari.conf +7 -0
  127. rtpbench/targets/ava/compose.yaml +128 -0
  128. rtpbench/targets/ava/constraints.txt +75 -0
  129. rtpbench/targets/ava/extensions.conf +10 -0
  130. rtpbench/targets/ava/http.conf +4 -0
  131. rtpbench/targets/ava/pjsip.conf +19 -0
  132. rtpbench/targets/ava/websocket_client.conf +15 -0
  133. rtpbench/targets/livekit/agent.py +111 -0
  134. rtpbench/targets/livekit/compose.yaml +86 -0
  135. rtpbench/targets/livekit/constraints.txt +72 -0
  136. rtpbench/tls.py +284 -0
  137. rtpbench/uri/advertise.yaml +7 -0
  138. rtpbench/uri/network.yaml +14 -0
  139. rtpbench/voice.py +18 -0
  140. rtpbench-0.3.0.dist-info/METADATA +123 -0
  141. rtpbench-0.3.0.dist-info/RECORD +144 -0
  142. rtpbench-0.3.0.dist-info/WHEEL +4 -0
  143. rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
  144. rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/check.py ADDED
@@ -0,0 +1,369 @@
1
+ """`rtpbench check`: pass or fail a target run against a policy, for CI.
2
+
3
+ A policy (TOML) sets which cases may fail or decline, limits on turn latency and barge-in per
4
+ profile, and how far they may grow from an earlier run's check.json. Limits read the error bar
5
+ in the agent's favour: a percentile fails only when even the lowest true value its bar allows
6
+ is over. A regression fails only when the change in median is over the tolerance across its
7
+ whole 95% interval, resampling calls and then turns within them: turn-to-turn and call-to-call
8
+ noise stays inside it, and the bars, the same for both runs of a profile and codec, cancel.
9
+ """
10
+
11
+ import json
12
+ import math
13
+ import random
14
+ import statistics
15
+ import tomllib
16
+ from collections.abc import Callable, Iterator
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from rtpbench import analyze, bargein, cases, clock
21
+
22
+ PROFILE_KEYS = {"p50": 0.5, "p95": 0.95}
23
+ RESAMPLES = 2000
24
+ # fewer turns a side and the interval is too narrow for the noise: compared on the baseline,
25
+ # single barge-in calls (2-5 of each interjection) failed often with nothing changed
26
+ MIN_TURNS = 10
27
+ # a case's policy value: the verdicts it allows (None: any), and what the detail calls them
28
+ ALLOW = {
29
+ "pass": ({"pass"}, ""),
30
+ "unsupported": ({"pass", "unsupported"}, "decline"),
31
+ "fail": (None, "fail"),
32
+ }
33
+
34
+
35
+ class InputError(ValueError):
36
+ """A policy, run directory or previous check.json that cannot be used: exit 2, not 1."""
37
+
38
+
39
+ def load(path: Path) -> dict[str, Any]:
40
+ """The policy at `path`, refused whole on any unknown name: a typo must not turn a limit
41
+ off."""
42
+ from rtpbench.bench import PROFILES
43
+
44
+ try:
45
+ policy = tomllib.loads(path.read_text())
46
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
47
+ raise InputError(f"{path}: {e}") from e
48
+ _known(policy, {"cases", "latency", "barge-in", "regression"}, "policy")
49
+ _known(policy.get("cases", {}), set(cases.CASES), "[cases]")
50
+ for case, want in policy.get("cases", {}).items():
51
+ if want not in ALLOW:
52
+ raise InputError(f"[cases] {case} = {want!r}: {', '.join(ALLOW)}")
53
+ _known(policy.get("latency", {}), set(PROFILES), "[latency]")
54
+ for profile, limits in policy.get("latency", {}).items():
55
+ _limits(limits, PROFILE_KEYS, f"[latency.{profile}]")
56
+ _known(policy.get("barge-in", {}), set(PROFILES), "[barge-in]")
57
+ for profile, kinds in policy.get("barge-in", {}).items():
58
+ _known(kinds, set(bargein.KINDS), f"[barge-in.{profile}]")
59
+ for kind, limits in kinds.items():
60
+ _limits(limits, {**PROFILE_KEYS, "stopped": 1}, f"[barge-in.{profile}.{kind}]")
61
+ if not 0 <= limits.get("stopped", 0) <= 1:
62
+ raise InputError(f"[barge-in.{profile}.{kind}] stopped: a share, 0 to 1")
63
+ _limits(policy.get("regression", {}), {"latency": 1, "barge-in": 1}, "[regression]")
64
+ return policy
65
+
66
+
67
+ def _known(table: Any, names: set[str], where: str) -> None:
68
+ if not isinstance(table, dict):
69
+ raise InputError(f"{where}: a table")
70
+ if unknown := sorted(set(table) - names):
71
+ known = ", ".join(sorted(names))
72
+ raise InputError(f"{where}: {', '.join(unknown)} unknown (known: {known})")
73
+
74
+
75
+ def _limits(limits: Any, keys: dict[str, float], where: str) -> None:
76
+ _known(limits, set(keys), where)
77
+ for key, v in limits.items():
78
+ if isinstance(v, bool) or not isinstance(v, int | float) or not 0 <= v < math.inf:
79
+ raise InputError(f"{where} {key} = {v!r}: a finite number, 0 or more")
80
+
81
+
82
+ def _lines(path: Path) -> Iterator[dict[str, Any]]:
83
+ return (json.loads(line) for line in path.read_text().splitlines() if line)
84
+
85
+
86
+ def measure(out: Path) -> dict[str, Any]:
87
+ """What a judged target run directory holds that a policy reads: verdicts per profile and
88
+ case over the repeats, and per profile each latency and barge-in call's turns (ms; None
89
+ unanswered or not stopped), leaving out turns timed across a clock step; check.json.
90
+
91
+ A latency or barge-in call has its directory before it is placed, and a run cut short
92
+ writes no conditions.json: a profile with no such directory did not request the call."""
93
+ conds = json.loads((out / "conditions.json").read_text())
94
+ records = json.loads((out / "results.json").read_text())
95
+ if any(r["behaviour"] for r in records):
96
+ raise InputError(f"{out}: a cases run (the reference agent); check judges a target run")
97
+ steps = clock.steps(out) or []
98
+ verdicts: dict[str, dict[str, list[str]]] = {}
99
+ for r in sorted(records, key=lambda r: r["repeat"]):
100
+ verdicts.setdefault(r["profile"], {}).setdefault(r["case"], []).append(r["verdict"])
101
+ latency: dict[str, dict[str, Any]] = {}
102
+ barge: dict[str, dict[str, Any]] = {}
103
+ for sub, into in (("latency", latency), ("barge-in", barge)):
104
+ for run in sorted(out.glob(f"*/*/{sub}")):
105
+ entry = into.setdefault(
106
+ run.parts[-3], {"codecs": [], "stepped": 0, "ended": 0, "broken": 0}
107
+ )
108
+ if not (run / "turns.jsonl").exists(): # the call did not complete (error.txt)
109
+ entry["broken"] += 1
110
+ continue
111
+ pt = json.loads((run / "caller.json").read_text())["pt"]
112
+ entry["codecs"].append(pt)
113
+ bar = cases.bar(cases.ONSET if sub == "latency" else cases.STOP, run.parts[-3], pt)
114
+ width = bar.hi - bar.lo if bar else 0
115
+ if sub == "latency":
116
+ turns = []
117
+ for t in _lines(run / "turns.jsonl"):
118
+ ms = t["webrtcvad_ms"]
119
+ end = t["end_ns"]
120
+ if ms is not None and clock.spans(end, end + ms * 1e6, steps, width):
121
+ entry["stepped"] += 1
122
+ else:
123
+ turns.append(ms)
124
+ entry.setdefault("calls", []).append(turns)
125
+ continue
126
+ kinds: dict[str, list[float | None]] = {k: [] for k in bargein.KINDS}
127
+ for t in _lines(run / "turns.jsonl"):
128
+ ms = t["measured_ms"]["wire/gate"] if bargein._stopped(t, "wire/gate") else None
129
+ onset = t["onset_ns"]
130
+ if bargein.ended(t, bar.lo if bar else 0):
131
+ entry["ended"] += 1
132
+ elif ms is not None and clock.spans(onset, onset + ms * 1e6, steps, width):
133
+ entry["stepped"] += 1
134
+ else:
135
+ kinds[t["kind"]].append(ms)
136
+ for kind, stops in kinds.items():
137
+ entry.setdefault("kinds", {}).setdefault(kind, []).append(stops)
138
+ return {
139
+ "target": conds.get("target"),
140
+ "image": conds.get("image"),
141
+ "lines": conds.get("lines"),
142
+ "verdicts": verdicts,
143
+ "latency": latency,
144
+ "barge-in": barge,
145
+ }
146
+
147
+
148
+ def _hi(table: dict[tuple[str, int], cases.Bar], profile: str, codecs: list[int]) -> float:
149
+ """The error bar's upper end for the profile, the widest over the calls' codecs."""
150
+ return max((b.hi for pt in set(codecs) if (b := cases.bar(table, profile, pt))), default=0.0)
151
+
152
+
153
+ def _flat(calls: list[list[float | None]], missing: float | None) -> list[float]:
154
+ """Every turn's value; None as `missing`, or left out when `missing` is None."""
155
+ out = [missing if v is None else v for c in calls for v in c]
156
+ return [v for v in out if v is not None]
157
+
158
+
159
+ def _over(values: list[float], limits: dict[str, float], hi: float) -> Iterator[tuple[bool, str]]:
160
+ """Each percentile limit: whether it holds, with the measurement as the detail."""
161
+ for key, limit in limits.items():
162
+ if key not in PROFILE_KEYS:
163
+ continue
164
+ if not values:
165
+ yield False, f"{key}: no measurement"
166
+ continue
167
+ v = analyze._pct(values, PROFILE_KEYS[key])
168
+ least = v - hi # the lowest true value the error bar allows
169
+ yield least <= limit, f"{key} {v:.0f} ms (at least {least:.0f}), limit {limit:g}"
170
+
171
+
172
+ def _shift(
173
+ before: list[list[float]], now: list[list[float]], rng: random.Random
174
+ ) -> tuple[float, float, float]:
175
+ """The change in median turn from `before` to `now` (lists of calls' turns), and its 95%
176
+ interval: each resample draws calls with replacement, then turns within each drawn call."""
177
+
178
+ def draw(calls: list[list[float]]) -> float:
179
+ picked = [rng.choice(calls) for _ in calls]
180
+ return statistics.median(rng.choice(c) for c in picked for _ in c)
181
+
182
+ d = statistics.median(x for c in now for x in c) - statistics.median(
183
+ x for c in before for x in c
184
+ )
185
+ shifts = sorted(draw(now) - draw(before) for _ in range(RESAMPLES))
186
+ return d, analyze._pct(shifts, 0.025), analyze._pct(shifts, 0.975)
187
+
188
+
189
+ def _series(entry: dict[str, Any], sub: str) -> dict[str, list[list[float | None]]]:
190
+ """A latency entry's calls, or a barge-in entry's per interjection."""
191
+ return {"": entry.get("calls", [])} if sub == "latency" else entry.get("kinds", {})
192
+
193
+
194
+ def _calls(calls: list[list[float | None]], missing: float | None) -> list[list[float]]:
195
+ return [t for c in calls if (t := _flat([c], missing))]
196
+
197
+
198
+ def _stepped(
199
+ row: Callable[[str, str, bool | None, str], None], profile: str, sub: str, entry: dict[str, Any]
200
+ ) -> None:
201
+ if entry["stepped"]:
202
+ row(profile, sub, None, f"{entry['stepped']} turn(s) across a clock step, left out")
203
+
204
+
205
+ def evaluate(
206
+ now: dict[str, Any], policy: dict[str, Any], before: dict[str, Any] | None = None
207
+ ) -> tuple[list[list[str]], bool]:
208
+ """Rows (profile, check, result, detail) and whether the run passes the policy."""
209
+ rows: list[list[str]] = []
210
+
211
+ def row(profile: str, what: str, ok: bool | None, detail: str) -> None:
212
+ rows.append([profile, what, {True: "pass", False: "FAIL", None: "note"}[ok], detail])
213
+
214
+ expect = policy.get("cases", {})
215
+ was = (before or {}).get("verdicts", {})
216
+ for profile, by_case in now["verdicts"].items():
217
+ for case, got in by_case.items():
218
+ mix = cases.verdicts([{"verdict": v} for v in got])
219
+ passed = all(v == "pass" for v in got)
220
+ allowed, verb = ALLOW[expect.get(case, "pass")]
221
+ if not verb:
222
+ row(profile, case, passed, mix + ("" if passed else ", expected to pass"))
223
+ elif passed:
224
+ row(profile, case, None, f"{mix}, allowed to {verb}: the policy can expect a pass")
225
+ elif allowed is not None and not set(got) <= allowed:
226
+ row(profile, case, False, f"{mix}, only allowed to {verb}")
227
+ elif (prev := was.get(profile, {}).get(case)) and all(v == "pass" for v in prev):
228
+ was_pass = "but passed in the previous run"
229
+ row(profile, case, False, f"{mix}, allowed to {verb}, {was_pass}")
230
+ else:
231
+ row(profile, case, True, f"{mix}, allowed to {verb}")
232
+
233
+ for profile, limits in policy.get("latency", {}).items():
234
+ entry = now["latency"].get(profile)
235
+ if not entry:
236
+ row(profile, "latency", None, "not requested in this run")
237
+ continue
238
+ if entry["broken"]:
239
+ row(profile, "latency", False, f"{entry['broken']} call(s) did not complete")
240
+ _stepped(row, profile, "latency", entry)
241
+ values = _flat(entry.get("calls", []), math.inf) # unanswered: later than any limit
242
+ hi = _hi(cases.ONSET, profile, entry["codecs"])
243
+ for ok, detail in _over(values, limits, hi):
244
+ row(profile, "latency", ok, detail)
245
+
246
+ for profile, kinds in policy.get("barge-in", {}).items():
247
+ entry = now["barge-in"].get(profile)
248
+ if not entry:
249
+ row(profile, "barge-in", None, "not requested in this run")
250
+ continue
251
+ if entry["broken"]:
252
+ row(profile, "barge-in", False, f"{entry['broken']} call(s) did not complete")
253
+ _stepped(row, profile, "barge-in", entry)
254
+ if entry.get("ended"):
255
+ what = "turn(s) whose agent speech ended before the interjection (gate), left out"
256
+ row(profile, "barge-in", None, f"{entry['ended']} {what}")
257
+ hi = _hi(cases.STOP, profile, entry["codecs"])
258
+ for kind, limits in kinds.items():
259
+ turns = entry.get("kinds", {}).get(kind, [])
260
+ said, stops = _flat(turns, math.inf), _flat(turns, None)
261
+ if "stopped" in limits:
262
+ share = len(stops) / len(said) if said else 0.0
263
+ detail = f"stopped {len(stops)}/{len(said)}, at least {limits['stopped']:.0%}"
264
+ row(profile, f"barge-in {kind}", bool(said) and share >= limits["stopped"], detail)
265
+ for ok, detail in _over(stops, limits, hi):
266
+ row(profile, f"barge-in {kind}", ok, f"stop time {detail}")
267
+
268
+ tolerance = policy.get("regression", {})
269
+ if tolerance and before is None:
270
+ row("", "regression", None, "no --previous to compare with")
271
+ if before is not None and tolerance:
272
+ rng = random.Random(1)
273
+ differ = [k for k in ("image", "lines") if now.get(k) != before.get(k)]
274
+ for sub in ("latency", "barge-in"):
275
+ if sub not in tolerance:
276
+ continue
277
+ for profile, entry in now[sub].items():
278
+ prev = before[sub].get(profile)
279
+ if not prev:
280
+ row(profile, f"{sub} regression", None, "not in the previous run")
281
+ continue
282
+ if differ or set(entry["codecs"]) != set(prev["codecs"]):
283
+ diff = ", ".join(differ) or "codec"
284
+ row(profile, f"{sub} regression", None, f"not compared: {diff} differs")
285
+ continue
286
+ missing = math.inf if sub == "latency" else None
287
+ old = _series(prev, sub)
288
+ for kind, calls in _series(entry, sub).items():
289
+ a, b = _calls(old.get(kind, []), missing), _calls(calls, missing)
290
+ what = " ".join(filter(None, (sub, kind, "regression")))
291
+ n = [sum(map(len, a)), sum(map(len, b))]
292
+ if min(n) < MIN_TURNS:
293
+ detail = f"not compared: {n[0]} and {n[1]} turns, under {MIN_TURNS} a side"
294
+ row(profile, what, None, detail)
295
+ continue
296
+ d, lo, hi = _shift(a, b, rng)
297
+ ok = not lo > tolerance[sub]
298
+ detail = (
299
+ f"p50 {d:+.0f} ms (95% interval {lo:+.0f} .. {hi:+.0f}), "
300
+ f"tolerance +{tolerance[sub]:g}"
301
+ )
302
+ row(profile, what, ok, detail)
303
+ return rows, all(r[2] != "FAIL" for r in rows)
304
+
305
+
306
+ def _shaped(before: Any) -> bool:
307
+ """Whether `before` holds what `evaluate` reads of a previous run, as `measure` writes it
308
+ (a profile may be missing: it was not run)."""
309
+
310
+ def number(v: Any) -> bool:
311
+ return isinstance(v, int | float) and not isinstance(v, bool) and math.isfinite(v)
312
+
313
+ def turns(calls: Any) -> bool: # None: unanswered, or not stopped
314
+ return isinstance(calls, list) and all(
315
+ isinstance(c, list) and all(v is None or number(v) for v in c) for c in calls
316
+ )
317
+
318
+ def entry(e: Any, series: str) -> bool:
319
+ return (
320
+ isinstance(e, dict)
321
+ and isinstance(e.get("codecs"), list)
322
+ and all(isinstance(pt, int) and not isinstance(pt, bool) for pt in e["codecs"])
323
+ and (
324
+ turns(e.get("calls", []))
325
+ if series == "calls"
326
+ else isinstance(e.get("kinds", {}), dict)
327
+ and all(turns(c) for c in e.get("kinds", {}).values())
328
+ )
329
+ )
330
+
331
+ if not isinstance(before, dict):
332
+ return False
333
+ verdicts, latency, barge = (before.get(k) for k in ("verdicts", "latency", "barge-in"))
334
+ return (
335
+ isinstance(verdicts, dict)
336
+ and all(
337
+ isinstance(cs, dict) and all(isinstance(v, list) for v in cs.values())
338
+ for cs in verdicts.values()
339
+ )
340
+ and isinstance(latency, dict)
341
+ and all(entry(e, "calls") for e in latency.values())
342
+ and isinstance(barge, dict)
343
+ and all(entry(e, "kinds") for e in barge.values())
344
+ )
345
+
346
+
347
+ def check(out: Path, policy: Path | None, previous: Path | None) -> tuple[str, bool]:
348
+ """Judge the target run in `out` against `policy` (default: every case passes) and the
349
+ check.json `previous`: writes check.md and check.json in `out`; returns check.md and
350
+ whether it passed."""
351
+ rules = load(policy) if policy else {}
352
+ try:
353
+ now = measure(out)
354
+ before = json.loads(previous.read_text()) if previous else None
355
+ if previous and not _shaped(before): # null too: it must not pass as no previous run
356
+ raise InputError(f"{previous}: not a check.json")
357
+ except InputError:
358
+ raise
359
+ except (OSError, ValueError, KeyError, TypeError, AttributeError) as e: # reads only
360
+ raise InputError(f"{type(e).__name__}: {e}") from e
361
+ rows, ok = evaluate(now, rules, before)
362
+ failed = sum(r[2] == "FAIL" for r in rows)
363
+ text = "## Check\n\n" + f"Policy: {policy.name if policy else 'every case passes'}"
364
+ text += f"; previous run: {previous.name}.\n\n" if previous else ".\n\n"
365
+ text += analyze._table(["profile", "check", "result", "detail"], rows)
366
+ text += "\nPassed.\n" if ok else f"\nFAILED: {failed} of {len(rows)}.\n"
367
+ (out / "check.md").write_text(text)
368
+ (out / "check.json").write_text(json.dumps(now, indent=1))
369
+ return text, ok