Skip to content

feat(orchestration): patter-duration estimator + boundary-fit selection — land handoffs near the boundary (gh-#253, gh-#254) - #287

Merged
genwave-radio merged 3 commits into
mainfrom
feat/gh-253-254-boundary-fit
Jul 30, 2026
Merged

feat(orchestration): patter-duration estimator + boundary-fit selection — land handoffs near the boundary (gh-#253, gh-#254)#287
genwave-radio merged 3 commits into
mainfrom
feat/gh-253-254-boundary-fit

Conversation

@genwave-radio

Copy link
Copy Markdown
Collaborator

Fixes #253, fixes #254 — the estimator seam, then the boundary fit that consumes it. Direct answer to the live 4:03 handover drift.

⏱️ gh-#253 — IPatterDurationEstimator, honest tiers

  • Exact: StationId clips (deterministic copy per station×voice — the cached render replays verbatim), last measured duration memoized.
  • Historical: per-persona × per-kind ring of the last 20 measured F66.1 durations; ≥3 samples earns the tier, 1–2 samples still average but honestly report Heuristic.
  • Heuristic: ~15 chars/s over expected copy, bounded by live Llm:MaxCopyChars, 2s floor.
  • Fed by the render loop observing every successful render's measured duration — never fabricated, null cue observes nothing. Confidence rides the estimate.

⚠️ Premise correction (issue said Booth log holds 14 days of durations — it doesn't): station.booth_log patter rows carry kind/summary/persona but no duration, and SegmentGenerated never carried one. A DB-backed average needs a migration + event change, so per house rules none was added: the historical tier is in-process and warms within a few units after boot; cold tier covers restarts honestly. Restart-surviving history filed as a rider.

🕓 gh-#254 — boundary-fit selection

  • Fit engages only inside the existing F74.3 boundary window (pending deferral due within the 10-min lookahead); outside it the pick is byte-for-byte the old behavior — proven by a spec asserting exactly one catalog call.
  • Desired final-track length = time-until-due − expected break patter (back-announce + sign-off, estimated from the deferral's own HandoffContext) − what's already queued ahead (new QueuedAheadMs: on-air remaining + pending backlog, computed once per refill, zero extra I/O — this is the actual drift source from the 4:03 handover) − this unit's own pre-music patter − expected crossfade trim.
  • Tolerance widens by estimator confidence: ±30s Exact / 45s Historical / 60s Heuristic, keyed to the worst contributing estimate.
  • Degenerate-pick guard: first rotation-tiered random sample within tolerance wins and sampling stops — no closest-fit leaderboard, so hourly approaches don't converge on the same short tracks. Soft preference on the same pool query: every existing predicate (posture, rotation, envelope, never-play) intact; never a filter, never dead air.

✅ Verification

dotnet build 0 warnings. Orchestration 186 (18 new specs), Core 140 (2 new), Host 1228, Tts 249 — all green. MediaLibrary suite untouched/not run (its fixture spins dockerized Postgres; no MediaLibrary code changed).

🔭 Live-only

Watch the demo box across a few real boundaries: handover lateness (the 5–6 min repro should collapse toward ±tolerance), no repetitive-short-track feel near hours, estimator warm-up quality, and the 5s crossfade-trim constant vs the box's real GW_XFADE settings.

…idence tiers (gh-#253)

IPatterDurationEstimator + PatterDurationEstimate/PatterEstimateConfidence
(GenWave.Abstractions): planning-time estimates for not-yet-rendered patter,
tiered Exact / Historical / Heuristic so a consumer can widen tolerance as
confidence drops. No behavior change — the seam only exposes numbers.

RollingPatterDurationEstimator (GenWave.Orchestration), in-memory only:
- Exact: StationId's cache-stable clip (deterministic template per station
  voice) replays verbatim, so its last measured duration IS the next one.
- Historical: per-persona x per-kind rolling average (last 20 measured F66.1
  durations, >=3 samples). NOT booth-log-backed: station.booth_log patter
  rows carry no duration column, so a 14-day DB average would need a schema
  migration AND an event change — the in-process ring warms within a few
  units instead; no migration added.
- Heuristic: ~15 chars/s over the kind's expected copy, live Llm:MaxCopyChars
  (new ICopyBoundsProvider seam, Tts-side OptionsMonitor adapter) bounding
  the LLM worst case. <3 real samples use the average but report Heuristic.

Orchestrator feeds the seam: every successful render's MEASURED DurationMs
(null cue = nothing observed, never fabricated) flows back via
ObserveRendered, keyed by the request's own kind/persona/voice.
…ff track near the boundary (gh-#254)

As a schedule boundary enters the F74.3 lookahead window, the in-window
music pick becomes a genuine duration FIT instead of a raw duration-vs-due
comparison. BuildBoundaryFit accounts, in air order, for:
- queued-ahead drift: new PlayoutContext.QueuedAheadMs (additive, null =
  unknown = zero), measured by the feeder from state it already holds —
  on-air remaining time + any still-queued pushed backlog, zero extra I/O;
- this unit's own pre-music patter (back-announce / station-id trigger /
  lead-in), estimated via the gh-#253 seam under the unit's one cadence +
  identity snapshot (reads hoisted to GetNextAsync, still once per unit);
- the candidate itself, minus a 5s expected crossfade trim (midpoint of the
  engine's GW_XFADE_MIN..MAX 2..8s defaults — judged constant, error well
  inside tolerance);
- the break's pre-boundary patter: next unit's back-announce + the sign-off
  piece (persona/voice from the deferral's own captured HandoffContext;
  boundary recovered as Due + SignOffLeadTime). Sign-on airs on the far
  side of the boundary and never counts.

Tolerance-by-confidence: +-30s is a WIN, widened to 45s (historical) / 60s
(heuristic) by the worst contributing estimate tier. Degenerate-pick guard:
the FIRST rotation-tiered random sample inside the tolerance is kept as-is
(sampling stops — no closest-fit leaderboard converging on one track every
hour); min-diff engages only when nothing lands, ranking only this pick's
up-to-5 random samples, so even inevitable overshoot picks the least-late
of a rotating handful. Soft preference throughout: same pool query, every
predicate (posture/rotation/envelope/never-play) untouched, never a filter,
never dead air. No pending in-window deferral = the single plain pick,
byte-for-byte the pre-fit behavior.

SpeechDeferralQueue gains read-only PeekNextDue() (same Due-asc, kind-tiebreak
contract as TryDequeueDue) so the fit can see WHAT is coming, not just when.
@genwave-radio
genwave-radio merged commit a47a77c into main Jul 30, 2026
11 checks passed
@genwave-radio
genwave-radio deleted the feat/gh-253-254-boundary-fit branch July 30, 2026 15:32
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

1 participant