Files
esh-pfi-infrastructure/stacks/chatterbox-fast/test_scheduler.py
T
vh 090e70aed5 revert(chatterbox-fast): drop context-priming (§1.6) — discard-cut leaks context
Revert the priming feature from d707439. Live A/B caught an audible artifact: the
context-priming discard-cut left part of the throwaway prefix in the output, so a
clause ("...without a trace of sarcasm,") was spoken an extra time.

Root cause is structural: generate() returns one finished waveform with no marker
for where the prefix ends, and the model renders the same prefix with different
timing when followed by content than when generated solo — so the duration-estimate
+ energy-minimum cut is a guess and can leave a sliver (or a whole clause) of prefix
in. A reliable cut would need token-level access (the abandoned native-streaming
arc) or a per-chunk ASR/alignment pass (heavy, still imperfect, eats the latency
budget). Fails the agreed bar: "keep only if it closes the gap without a seam."

Kept from d707439: the .gitignore (build artifacts). NOT re-applied: the bundled
margin_first fix — wiring it would shrink chunk 1 (more joins = worse coherence),
against the operator's priority, and margin=0.8 there is already starvation-safe.

Coherence loss at joins stays an accepted limitation; cold streaming was judged
"really good". Phase 1 + Phase 2 parity/perf untouched. Next: Phase 3 deploy.
2026-06-01 23:26:56 -07:00

203 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""GPU-free simulation of the adaptive-chunk scheduler.
Validates the two acceptance properties from the plan (§6) without a GPU:
* NO STARVATION — the stream stays ahead of 1× playback.
* RATCHET — chunks grow after the latency-critical first one.
Run directly (``python test_scheduler.py``) or under pytest.
"""
from __future__ import annotations
from scheduler import (
ChunkConfig,
plan_chunk,
protect_first_audio,
split_sentences,
stream_chunks,
)
# A multi-sentence paragraph with varied lengths. The long sentences carry
# commas (as natural prose does), so the clause-split starvation relief has
# boundaries to work with.
PARAGRAPH = (
"The cake is a lie. "
"I am being entirely sincere, without a trace of sarcasm, when I say "
"that this is the single most important scientific breakthrough in the "
"entire history of this facility. "
"You will be baked, and then there will be cake. "
"It is delicious and moist, assuming you survive the testing protocol, "
"which the available data suggests you almost certainly will not. "
"Goodbye."
)
class FakeClock:
"""A clock the fake generator advances, so gen_time reflects simulated work."""
def __init__(self) -> None:
self.t = 0.0
def __call__(self) -> float:
return self.t
def make_generator(clock: FakeClock, *, true_rtf: float, sec_per_char: float):
"""A fake generate() that costs realistic wall-clock and returns audio_sec.
Audio duration is proportional to text length; generation costs
``audio_sec / true_rtf`` of (simulated) wall-clock, advancing the clock.
"""
def generate(text: str):
audio_sec = len(text) * sec_per_char
clock.t += audio_sec / true_rtf
return None, audio_sec
return generate
def run(true_rtf: float = 3.8, sec_per_char: float = 0.060, cfg: ChunkConfig | None = None):
clock = FakeClock()
gen = make_generator(clock, true_rtf=true_rtf, sec_per_char=sec_per_char)
return list(stream_chunks(PARAGRAPH, generate=gen, clock=clock, cfg=cfg))
# ── splitting ─────────────────────────────────────────────────────────────
def test_split_sentences_count():
units = split_sentences(PARAGRAPH)
assert units[0] == "The cake is a lie."
assert units[-1] == "Goodbye."
assert len(units) == 5
def test_split_preserves_inline_tags():
units = split_sentences("Hello there. [laugh] That was funny. Bye.")
# The tag must not be torn from its sentence nor split on.
assert any("[laugh]" in u for u in units)
assert len(units) == 3
def test_protect_first_audio_clause_splits_long_opener():
cfg = ChunkConfig(max_first_sec=0.5) # force the guard
long_open = ["This is a long opener, with a clause, and another clause.", "Short."]
out = protect_first_audio(long_open, cfg)
assert len(out) > len(long_open)
assert out[0] == "This is a long opener,"
def test_protect_first_audio_keeps_unsplittable_opener_whole():
cfg = ChunkConfig(max_first_sec=0.1)
out = protect_first_audio(["No clause boundaries here at all."], cfg)
assert out == ["No clause boundaries here at all."]
# ── chunk planning ────────────────────────────────────────────────────────
def test_first_chunk_is_single_unit():
# Zero buffer ⇒ exactly the first unit, regardless of margin/rtf.
units = split_sentences(PARAGRAPH)
chunk, rest = plan_chunk(units, 0.0, margin=0.65, rtf=3.8, sec_per_char=0.06)
assert chunk == units[0]
assert len(rest) == len(units) - 1
def test_plan_never_returns_empty():
chunk, rest = plan_chunk(["Only one."], 0.0, margin=0.8, rtf=3.8, sec_per_char=0.06)
assert chunk == "Only one."
assert rest == []
# ── the acceptance properties ─────────────────────────────────────────────
def test_no_starvation_nominal():
"""Stream never starves when the RTF prior matches reality."""
results = run(true_rtf=3.8)
assert all(not r.starved for r in results), [
(r.index, r.drained) for r in results if r.starved
]
def test_no_starvation_when_rtf_overestimated():
"""Prior says 3.8× but the GPU only delivers 3.0× — live correction + margin
must still keep the stream fed. This is the property that matters: the
scheduler must be robust to an optimistic prior, not just a matching one."""
results = run(true_rtf=3.0)
assert all(not r.starved for r in results), [
(r.index, r.drained) for r in results if r.starved
]
def test_no_starvation_slow_gpu():
"""Even at 2.5× (well below prior) the margin absorbs it for this text."""
results = run(true_rtf=2.5)
assert all(not r.starved for r in results), [
(r.index, r.drained) for r in results if r.starved
]
def test_chunks_ratchet_up():
"""After the latency-critical first chunk, chunks grow (buffer-ratchet)."""
results = run(true_rtf=3.8)
assert len(results) >= 3, "paragraph should not collapse to one chunk"
# Chunk 1 is the smallest (single sentence); the second chunk is larger.
assert len(results[1].text) > len(results[0].text)
# The paragraph consolidates: the last chunk carries multiple sentences.
assert results[-1].audio_sec >= results[0].audio_sec
def test_first_chunk_low_latency():
"""First chunk is one short sentence ⇒ smallest gen time ⇒ fast first audio."""
results = run(true_rtf=3.8)
assert results[0].text == "The cake is a lie."
# In the sim, chunk 0's gen_time IS the time-to-first-audio, and it must be
# the smallest of all chunks (everything after it is ≥ a clause).
assert results[0].gen_time == min(r.gen_time for r in results)
assert results[0].gen_time < 0.4
def test_unsplittable_long_sentence_flags_starvation():
"""KNOWN LIMITATION (surfaced, not hidden): a long COMMA-LESS sentence right
after a short opener cannot be clause-split, so we honor the never-split-mid-
sentence rule and accept a brief gap — which MUST show up as drained>0 in
telemetry so it is measurable, never silent."""
clock = FakeClock()
gen = make_generator(clock, true_rtf=3.8, sec_per_char=0.060)
text = (
"Hi. "
"I am now going to speak one extremely long sentence with no clause "
"boundaries at all so that nothing in here can ever be split apart by "
"the scheduler no matter how hard it tries to find a comma."
)
results = list(stream_chunks(text, generate=gen, clock=clock))
assert any(r.starved for r in results), "expected the gap to be flagged"
assert any(r.drained > 0 for r in results)
def test_full_text_reconstructed():
"""Every sentence is emitted exactly once, in order."""
results = run(true_rtf=3.8)
joined = " ".join(r.text for r in results)
assert joined == " ".join(split_sentences(PARAGRAPH))
def _main():
results = run(true_rtf=3.8)
print(f"{'idx':>3} {'chars':>5} {'audio_s':>8} {'gen_s':>7} "
f"{'est_s':>6} {'buf_before':>10} {'buf_after':>9} {'rtf':>5} {'drain':>6}")
for r in results:
print(f"{r.index:>3} {len(r.text):>5} {r.audio_sec:>8.2f} {r.gen_time:>7.3f} "
f"{r.est_gen:>6.3f} {r.buffer_before:>10.2f} {r.buffer_after:>9.2f} "
f"{r.rtf:>5.2f} {r.drained:>6.3f}")
starved = [r.index for r in results if r.starved]
print(f"\nchunks={len(results)} starved={starved or 'none'} "
f"ttfa≈{results[0].gen_time:.3f}s")
if __name__ == "__main__":
_main()