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.
- rtpbench/.dockerignore +4 -0
- rtpbench/Dockerfile +12 -0
- rtpbench/__init__.py +6 -0
- rtpbench/__main__.py +3 -0
- rtpbench/agent.py +746 -0
- rtpbench/analyze.py +520 -0
- rtpbench/bargein.py +272 -0
- rtpbench/bench.py +1069 -0
- rtpbench/caller.py +781 -0
- rtpbench/capture.py +33 -0
- rtpbench/carrier/compose.yaml +68 -0
- rtpbench/carrier/opensips.cfg +226 -0
- rtpbench/carrier/tls.cfg +25 -0
- rtpbench/carrier/tls.yaml +9 -0
- rtpbench/cases.py +1989 -0
- rtpbench/check.py +369 -0
- rtpbench/cli.py +616 -0
- rtpbench/clock.py +174 -0
- rtpbench/compose.yaml +55 -0
- rtpbench/corpus/README.md +55 -0
- rtpbench/corpus/cough-c1-0.wav +0 -0
- rtpbench/corpus/cough-c1-1.wav +0 -0
- rtpbench/corpus/cough-c2-0.wav +0 -0
- rtpbench/corpus/cough-c2-1.wav +0 -0
- rtpbench/corpus/cough-man-0.wav +0 -0
- rtpbench/corpus/cough-man-1.wav +0 -0
- rtpbench/corpus/cough-short-0.wav +0 -0
- rtpbench/corpus/cough-short-1.wav +0 -0
- rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
- rtpbench/corpus/human-en-dr-0.wav +0 -0
- rtpbench/corpus/human-en-dr-1.wav +0 -0
- rtpbench/corpus/human-en-sc-0.wav +0 -0
- rtpbench/corpus/human-en-sc-1.wav +0 -0
- rtpbench/corpus/human-en-sc-2.wav +0 -0
- rtpbench/corpus/human-es-f012-0.wav +0 -0
- rtpbench/corpus/human-es-f012-1.wav +0 -0
- rtpbench/corpus/human-es-f012-2.wav +0 -0
- rtpbench/corpus/human-es-f016-0.wav +0 -0
- rtpbench/corpus/human-es-f016-1.wav +0 -0
- rtpbench/corpus/human-es-f016-2.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
- rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-00.wav +0 -0
- rtpbench/corpus/lines/en/prompt-01.wav +0 -0
- rtpbench/corpus/lines/en/prompt-02.wav +0 -0
- rtpbench/corpus/lines/en/prompt-03.wav +0 -0
- rtpbench/corpus/lines/en/prompt-04.wav +0 -0
- rtpbench/corpus/lines/en/prompt-05.wav +0 -0
- rtpbench/corpus/lines/en/prompt-06.wav +0 -0
- rtpbench/corpus/lines/en/prompt-07.wav +0 -0
- rtpbench/corpus/lines/en/prompt-08.wav +0 -0
- rtpbench/corpus/lines/en/prompt-09.wav +0 -0
- rtpbench/corpus/lines/en/prompt.wav +0 -0
- rtpbench/corpus/lines/en/transfer.wav +0 -0
- rtpbench/corpus/lines/en/turn-00.wav +0 -0
- rtpbench/corpus/lines/en/turn-01.wav +0 -0
- rtpbench/corpus/lines/en/turn-02.wav +0 -0
- rtpbench/corpus/lines/en/turn-03.wav +0 -0
- rtpbench/corpus/lines/en/turn-04.wav +0 -0
- rtpbench/corpus/lines/en/turn-05.wav +0 -0
- rtpbench/corpus/lines/en/turn-06.wav +0 -0
- rtpbench/corpus/lines/en/turn-07.wav +0 -0
- rtpbench/corpus/lines/en/turn-08.wav +0 -0
- rtpbench/corpus/lines/en/turn-09.wav +0 -0
- rtpbench/corpus/lines/en/turn-10.wav +0 -0
- rtpbench/corpus/lines/en/turn-11.wav +0 -0
- rtpbench/corpus/tts-ald-bc.wav +0 -0
- rtpbench/corpus/tts-ald-long.wav +0 -0
- rtpbench/corpus/tts-ald-s03.wav +0 -0
- rtpbench/corpus/tts-ald-s07.wav +0 -0
- rtpbench/corpus/tts-ald-s11.wav +0 -0
- rtpbench/corpus/tts-cori-bc.wav +0 -0
- rtpbench/corpus/tts-cori-long.wav +0 -0
- rtpbench/corpus/tts-cori-s01.wav +0 -0
- rtpbench/corpus/tts-cori-s05.wav +0 -0
- rtpbench/corpus/tts-cori-s09.wav +0 -0
- rtpbench/corpus/tts-davefx-bc.wav +0 -0
- rtpbench/corpus/tts-davefx-long.wav +0 -0
- rtpbench/corpus/tts-davefx-s00.wav +0 -0
- rtpbench/corpus/tts-davefx-s04.wav +0 -0
- rtpbench/corpus/tts-davefx-s08.wav +0 -0
- rtpbench/corpus/tts-libritts0-bc.wav +0 -0
- rtpbench/corpus/tts-libritts0-long.wav +0 -0
- rtpbench/corpus/tts-libritts0-s02.wav +0 -0
- rtpbench/corpus/tts-libritts0-s06.wav +0 -0
- rtpbench/corpus/tts-libritts0-s10.wav +0 -0
- rtpbench/corpus/tts-libritts3-bc.wav +0 -0
- rtpbench/corpus/tts-libritts3-long.wav +0 -0
- rtpbench/corpus/tts-libritts3-s03.wav +0 -0
- rtpbench/corpus/tts-libritts3-s07.wav +0 -0
- rtpbench/corpus/tts-libritts3-s11.wav +0 -0
- rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
- rtpbench/corpus/tts-ljspeech-long.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
- rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
- rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardf-long.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
- rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
- rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
- rtpbench/corpus/tts-sharvardm-long.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
- rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
- rtpbench/detect.py +145 -0
- rtpbench/g711.py +82 -0
- rtpbench/mock/compose.yaml +18 -0
- rtpbench/pcap.py +106 -0
- rtpbench/progress.py +184 -0
- rtpbench/realtime.py +1015 -0
- rtpbench/report.py +450 -0
- rtpbench/rtp.py +166 -0
- rtpbench/sip.py +653 -0
- rtpbench/speech.py +100 -0
- rtpbench/speechcal.py +367 -0
- rtpbench/srtp.py +191 -0
- rtpbench/targets/aivc/aivc.ini +25 -0
- rtpbench/targets/aivc/compose.yaml +72 -0
- rtpbench/targets/aivc/constraints.txt +52 -0
- rtpbench/targets/aivc/opensips.cfg +45 -0
- rtpbench/targets/ava/ai-agent.yaml +275 -0
- rtpbench/targets/ava/ari.conf +7 -0
- rtpbench/targets/ava/compose.yaml +128 -0
- rtpbench/targets/ava/constraints.txt +75 -0
- rtpbench/targets/ava/extensions.conf +10 -0
- rtpbench/targets/ava/http.conf +4 -0
- rtpbench/targets/ava/pjsip.conf +19 -0
- rtpbench/targets/ava/websocket_client.conf +15 -0
- rtpbench/targets/livekit/agent.py +111 -0
- rtpbench/targets/livekit/compose.yaml +86 -0
- rtpbench/targets/livekit/constraints.txt +72 -0
- rtpbench/tls.py +284 -0
- rtpbench/uri/advertise.yaml +7 -0
- rtpbench/uri/network.yaml +14 -0
- rtpbench/voice.py +18 -0
- rtpbench-0.3.0.dist-info/METADATA +123 -0
- rtpbench-0.3.0.dist-info/RECORD +144 -0
- rtpbench-0.3.0.dist-info/WHEEL +4 -0
- rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|