From d69581b59619c93f2de0eab7813bdd1a864e5e2f Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Fri, 31 Jul 2026 07:53:51 -0600 Subject: [PATCH 1/2] fix(orchestration): the last unit before a ceremony IS the ceremony (gh-#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2PM handoff aired at 2:05. gh-#254's fit biases WHICH track fills a unit; it had no move for "plan no unit at all". At the last pull before the sign-off was due, the queued audio already ran past the boundary, so desired length was deeply negative and the overshot branch still answered with a full least-late track — one extra ~3.5-minute unit inside the last minute IS the entire slip. Under a 90s floor the ceremony now becomes the unit. That number is the break-even, not a guess: planning length L into D of room lands the ceremony (L - D) late, declining lands it D early, so declining wins exactly while D < L/2 — ninety seconds for a typical three-minute unit. Two details that make it correct rather than merely earlier: - The drain runs as-of the BOUNDARY, not "now". A SignOff comes due at boundary - 15s, so an as-of-now drain returns nothing at the moment the decision is taken — which is precisely the bug. As-of-boundary also keeps both halves in one TryDequeueDue call, the documented common shape. - Planning early is not airing early: the ceremony queues behind audio still draining, so it reaches air at the boundary. Handoff kinds only — an ident is imaging that can ride the next seam, and skipping a whole track for one trades a small blemish for a large one. Never-silent is preserved: a ceremony that renders nothing at all returns null and the pull plans an ordinary music unit exactly as before. Also fixes the observability half. Every fit now writes one line carrying each term it reasoned from, at INFORMATION — the fleet ships Information and above (zero dbug: lines exist in Loki), so the Debug line this issue asked for would have been exactly as invisible as no line at all. 12 new specs. Negative control run: disabling the floor fails 6 of them and leaves the 6 that should hold regardless passing. Full suite green (2,559). --- src/GenWave.Orchestration/BoundaryFitPlan.cs | 29 +- src/GenWave.Orchestration/Orchestrator.cs | 242 +++++++++++++-- .../Specs/Gh300_DeclineTheFinalUnit.cs | 288 ++++++++++++++++++ 3 files changed, 527 insertions(+), 32 deletions(-) create mode 100644 tests/GenWave.Orchestration.Tests/Specs/Gh300_DeclineTheFinalUnit.cs diff --git a/src/GenWave.Orchestration/BoundaryFitPlan.cs b/src/GenWave.Orchestration/BoundaryFitPlan.cs index f32ccec0..6096ce09 100644 --- a/src/GenWave.Orchestration/BoundaryFitPlan.cs +++ b/src/GenWave.Orchestration/BoundaryFitPlan.cs @@ -1,19 +1,44 @@ namespace GenWave.Orchestration; +using GenWave.Core.Domain; + /// /// gh-#254 — one boundary fit, computed by Orchestrator.BuildBoundaryFit per in-window music /// pick: the effective track length (post-crossfade-trim) the sampler should aim for, and how far a /// sample may miss it while still counting as a win. Internal planning state only — never rides an /// item or crosses a seam. +/// +/// +/// gh-#300 carries the fit's own INPUTS alongside its two answers. They exist for one reason: the +/// 2:05 handoff could not be reconstructed from logs at all — only from render archaeology — because +/// nothing here was ever written down. See Orchestrator.LogBoundaryFit for the line they feed, +/// and note it is INFORMATION, not Debug: the demo fleet ships Information and above, so a Debug fit +/// line would have been exactly as invisible as no line at all. +/// /// /// /// The candidate length that lands its end (and the break patter that follows) exactly on the /// boundary, after subtracting queued-ahead drift and this unit's own pre-music patter. Can be -/// negative when the approach has already overshot — the sampler then prefers the least-late pick. +/// negative when the approach has already overshot — the sampler then prefers the least-late pick, +/// unless gh-#300's floor says no music unit belongs here at all. /// /// /// The win window (gh-#254: ±30s base, widened as the worst contributing gh-#253 estimate's /// confidence tier drops). The first sample landing inside it is kept as-is — the degenerate-pick /// guard. /// -sealed record BoundaryFitPlan(TimeSpan DesiredEffectiveLength, TimeSpan Tolerance); +/// The pending deferral this fit aims at — a handoff reads very differently from an ident. +/// Now to the boundary instant itself (for a SignOff, its due plus the lead time). +/// The feeder's own measurement of runtime already committed ahead of this pass. +/// Estimated patter this unit plans between now and the candidate's first note. +/// Estimated patter between the candidate's last note and the boundary. +/// The WORST contributing gh-#253 estimate tier — what set . +sealed record BoundaryFitPlan( + TimeSpan DesiredEffectiveLength, + TimeSpan Tolerance, + SpeechDeferralKind Kind, + TimeSpan UntilBoundary, + TimeSpan QueuedAhead, + TimeSpan PreMusicPatter, + TimeSpan BreakPatter, + PatterEstimateConfidence Confidence); diff --git a/src/GenWave.Orchestration/Orchestrator.cs b/src/GenWave.Orchestration/Orchestrator.cs index beb8f22a..08a938d0 100644 --- a/src/GenWave.Orchestration/Orchestrator.cs +++ b/src/GenWave.Orchestration/Orchestrator.cs @@ -215,6 +215,28 @@ public sealed class Orchestrator( /// static readonly TimeSpan SignOffLeadTime = TimeSpan.FromSeconds(15); + /// + /// gh-#300 — below this much room left in front of a handoff boundary, no music unit is planned + /// at all: the ceremony itself becomes the unit. + /// + /// + /// Where the number comes from. Planning a track of length L into D of remaining room + /// lands the ceremony L - D LATE; declining lands it D EARLY. Declining is + /// therefore the better trade exactly while D < L / 2. With a typical unit around + /// three minutes that break-even sits at ninety seconds, and this is that number. Above it the + /// gh-#254 fit keeps its existing least-late behavior, which is still the right answer there. + /// + /// + /// + /// The 2:05 incident sat far below this line — the queued audio already ran PAST the boundary, + /// so desired was deeply negative and every candidate was hopeless. A judged constant in + /// the spirit of and , not a + /// live knob: gh-#300's own fit logging is what makes promoting it to one an argument from + /// field data rather than taste, and that data does not exist yet. + /// + /// + static readonly TimeSpan MusicUnitFloor = TimeSpan.FromSeconds(90); + // Defaults (SPEC F81.2/F81.3): every pre-F81 test/module construction site keeps compiling and // behaving exactly as before — no envelope constraint, no persona layer — mirrors the // IStationEventSink? events = null → NoOpStationEventSink.Instance idiom used elsewhere in this @@ -282,9 +304,34 @@ public sealed class Orchestrator( // either — so a live scope edit (SPEC F30) or rotation edit (F41.6) takes effect on the // very next pull with no process restart. var artistSeparation = rotationProvider.Current.ArtistSeparation; + + // gh-#254's fit, built ONCE per unit and read twice: first by the gh-#300 decline check + // immediately below, then by the sampler it was originally written for. Null whenever no + // deferral sits strictly-future inside the F74.3 lookahead window — the common case, in + // which both readers degrade to exactly their pre-boundary-awareness behavior. + var pending = deferralQueue.PeekNextDue(); + var untilDue = pending is null ? default : pending.Due - timeProvider.GetUtcNow(); + var fit = pending is not null && untilDue > TimeSpan.Zero && untilDue <= boundaryBiasProvider.Current + ? BuildBoundaryFit(pending, untilDue, cadence, identity, unitDjName, ctx.QueuedAheadMs) + : null; + + // gh-#300 — the last unit before a due ceremony IS the ceremony. When no music unit can fit + // in front of the boundary, plan the ceremony instead of a full track nobody has room for; + // a returned segment ends this pull with no music planned at all. + // + // This sits ABOVE rung -1 (SPEC F87.6's request fulfillment, consulted inside + // SelectMusicCandidateAsync), which is deliberate and safe: a declined unit plays no music, + // so there is no slot for a requested track either. Rung -1's "exactly once per pick" + // contract is about never CAS-stamping twice — a pick that never happens stamps nothing, so + // the pending request simply waits for the next unit with its row untouched. + if (fit is not null && ShouldDeclineFinalUnit(fit) + && await TryServeCeremonyOnlyUnitAsync(fit, unitDjName, cadence, identity, ct) is { } ceremony) + { + return ceremony; + } + var candidate = await SelectMusicCandidateAsync( - scopeProvider.Current, orderedRecentIds, artistSeparation, - cadence, identity, unitDjName, ctx.QueuedAheadMs, ct); + scopeProvider.Current, orderedRecentIds, artistSeparation, fit, ct); if (candidate is null) { // F41.2: null now means a GENUINE drain — zero playable rows in scope, never merely @@ -344,14 +391,22 @@ public sealed class Orchestrator( /// /// Picks the next music candidate — SPEC F41.1/F41.3 tiering is unchanged and still governs - /// which candidates are even eligible — and, when a pending deferral - /// () is due strictly in the future within - /// 's lookahead window (SPEC F74.3, STORY-198), softly biases - /// that pick toward whichever sampled candidate's end lands closest to the boundary — a full - /// duration FIT as of gh-#254, no longer a raw duration-vs-due comparison. See - /// for the accounting (queued-ahead drift, this unit's own - /// pre-music patter, crossfade trim, and the break's expected patter via the gh-#253 estimator) - /// and for how tolerance widens with the estimator's confidence tier. + /// which candidates are even eligible — and, when is non-null (a pending + /// deferral is due strictly in the future within 's lookahead + /// window — SPEC F74.3, STORY-198), softly biases that pick toward whichever sampled candidate's + /// end lands closest to the boundary — a full duration FIT as of gh-#254, no longer a raw + /// duration-vs-due comparison. See for the accounting + /// (queued-ahead drift, this unit's own pre-music patter, crossfade trim, and the break's + /// expected patter via the gh-#253 estimator) and for how tolerance widens with the estimator's + /// confidence tier. + /// + /// + /// The peek and the fit build itself moved UP to in gh-#300, which + /// needs the very same fit one decision earlier — to ask whether a music unit belongs in front + /// of the boundary at all (). One fit per unit, built once, + /// read twice; this method's four ex-parameters (cadence, identity, unit DJ name, queued-ahead) + /// existed only to feed that build and went with it. + /// /// /// /// Degenerate-pick guard (gh-#254): landing within the fit's tolerance is a WIN, not a @@ -413,29 +468,25 @@ public sealed class Orchestrator( LibraryScope scope, IReadOnlyList orderedRecentIds, int artistSeparation, - CadenceConfig cadence, - StationIdentity identity, - string? unitDjName, - int? queuedAheadMs, + BoundaryFitPlan? fit, CancellationToken ct) { // Rung -1, once per pick (SPEC F87.6, PLAN T90 review) — see this method's own remarks for - // why this sits above the due/bias branch rather than inside the sampler it guards. + // why this sits above the bias branch rather than inside the sampler it guards. var envelope = envelopeProvider.Current; if (await TryFulfillPendingRequestAsync(envelope, ct) is { } fulfilledCandidate) return fulfilledCandidate; - var pending = deferralQueue.PeekNextDue(); - var untilDue = pending is null ? default : pending.Due - timeProvider.GetUtcNow(); - - if (pending is null || untilDue <= TimeSpan.Zero || untilDue > boundaryBiasProvider.Current) + // No in-window deferral to aim at (gh-#300 hoisted the peek and the fit build up to + // GetNextAsync, which needs the same fit to decide whether a music unit belongs here at + // all) — the no-imminent-boundary common case, exactly one catalog call as always. + if (fit is null) return await SelectEnvelopeAwareCandidateAsync(scope, orderedRecentIds, artistSeparation, ct); - var fit = BuildBoundaryFit(pending, untilDue, cadence, identity, unitDjName, queuedAheadMs); - RotationCandidate? best = null; TimeSpan? bestDiff = null; RotationCandidate? firstUnscored = null; + var sampled = new List(BoundarySampleAttempts); for (var attempt = 0; attempt < BoundarySampleAttempts; attempt++) { @@ -443,7 +494,12 @@ public sealed class Orchestrator( if (sample is null) { // Nothing sampled yet at all — a genuine drain (F41.2), not a bias artifact. - if (best is null && firstUnscored is null) return null; + if (best is null && firstUnscored is null) + { + LogBoundaryFit(fit, "drained", sampled, chosenDiff: null); + return null; + } + break; // the pool emptied mid-sample; keep whatever was already sampled. } @@ -453,11 +509,16 @@ public sealed class Orchestrator( // overlap into whatever follows (gh-#254 — "minus crossfade trim"). var effective = TimeSpan.FromMilliseconds(durationMs) - ExpectedCrossfadeTrim; var diff = (effective - fit.DesiredEffectiveLength).Duration(); + sampled.Add(effective); // Within tolerance is a WIN (gh-#254, ±30s widened by confidence) — keep THIS // rotation-tiered random sample and stop sampling: see this method's remarks for // why first-inside-the-window, not closest-fit, is the degenerate-pick guard. - if (diff <= fit.Tolerance) return sample; + if (diff <= fit.Tolerance) + { + LogBoundaryFit(fit, "win", sampled, diff); + return sample; + } if (bestDiff is null || diff < bestDiff) { @@ -471,6 +532,10 @@ public sealed class Orchestrator( } } + // No sample landed inside the tolerance. "least-late" is the min-diff pick; "unscored" is + // the last-resort duration-less candidate that carried no score at all (F74.3 keeps it + // eligible — an un-enriched row is never penalized for enrichment lag). + LogBoundaryFit(fit, best is not null ? "least-late" : "unscored", sampled, bestDiff); return best ?? firstUnscored; } @@ -551,7 +616,105 @@ TimeSpan Estimate(SegmentKind kind, string? personaName, string voice) _ => FitToleranceHeuristic, }; - return new BoundaryFitPlan(desired, tolerance); + return new BoundaryFitPlan( + desired, tolerance, pending.Kind, untilBoundary, queuedAhead, preMusicPatter, breakPatter, + worstConfidence); + } + + /// + /// gh-#300 — "the last unit before a due ceremony IS the ceremony". True when the room left in + /// front of the boundary is under , in which case planning one more + /// full track is strictly worse than planning none (see that constant for the arithmetic). + /// + /// + /// Handoff kinds only. A show boundary is an appointment the audience can hear being + /// missed — the incoming DJ announcing "it's Thursday two o'clock" at 2:05 is the whole issue. + /// A station ID is not: it is imaging that can ride the next seam quite happily, and skipping a + /// whole track for one would trade a small blemish for a large one. Today's ident producer only + /// ever enqueues due-NOW deferrals, so such a fit is never even built (a due-now deferral takes + /// the plain unbiased path) — this guard is what keeps that true if a future producer ever + /// future-dates one. + /// + /// + bool ShouldDeclineFinalUnit(BoundaryFitPlan fit) => + fit.Kind is SpeechDeferralKind.SignOff or SpeechDeferralKind.SignOn + && fit.DesiredEffectiveLength < MusicUnitFloor; + + /// + /// gh-#300 — plans the ceremony as a unit of its own: back-announce (the fit already reserved + /// it) plus whatever the drain yields, and no music. + /// + /// + /// The drain runs as-of the BOUNDARY, not "now". A SignOff comes due at + /// boundary - SignOffLeadTime, so at the moment this decision is taken it is still a few + /// seconds in the future and an as-of-now drain would return nothing — which is precisely the + /// bug: the ceremony then waited for a pull that a freshly-planned three-and-a-half-minute track + /// had just pushed past the hour. Draining as-of the boundary also keeps both halves together in + /// ONE call, the shape + /// 's own remarks describe as the overwhelmingly common case. + /// + /// + /// + /// Planning early is not airing early. The ceremony is appended behind + /// QueuedAheadMs of audio that is still draining, so it reaches air roughly when that + /// audio runs out — at the boundary. Never-silent (F6.3) is untouched either way: this method + /// only ever ADDS segments, and a unit that renders nothing at all returns + /// so the caller plans an ordinary music unit instead, exactly as if the + /// decline had never fired. + /// + /// + /// + /// and are deliberately NOT advanced — no + /// music played, so the next unit's back-announce still refers to the track that really did, + /// and the station-ID cadence still counts music units rather than being nudged by a ceremony. + /// + /// + async Task TryServeCeremonyOnlyUnitAsync( + BoundaryFitPlan fit, string? unitDjName, CadenceConfig cadence, StationIdentity identity, + CancellationToken ct) + { + // ONE line, not two: the fit line already carries every term (desired, queuedAhead, the + // lot), so a second human-readable "declining because…" would restate it. The floor is the + // only fact the fit itself does not know, so it rides the outcome. + LogBoundaryFit( + fit, + $"declined (floor={MusicUnitFloor.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture)}s)", + sampled: [], + chosenDiff: null); + + var boundary = timeProvider.GetUtcNow() + fit.UntilBoundary; + await EnqueuePatterAsync(previousTrack, next: null, unitDjName, cadence, identity, ct, boundary); + + return buffer.Count > 0 ? buffer.Dequeue() : null; + } + + /// + /// gh-#300 — the one line that makes a boundary fit arguable after the fact. The 2:05 handoff + /// was reconstructible only from kokoro's own render timestamps because this method did not + /// exist; every term the fit reasoned from is now on the record, alongside what the sampler did + /// with it. + /// + /// + /// INFORMATION, deliberately. The sibling per-pick "Pick —" line is Debug, and the demo + /// fleet ships Information and above — a fact confirmed by querying it: zero dbug: lines + /// exist in Loki. A Debug fit line would satisfy the issue's letter and none of its purpose. + /// The volume is affordable because this fires only while a deferral sits inside the F74.3 + /// lookahead window — a handful of lines per boundary, not one per pick. + /// + /// + void LogBoundaryFit(BoundaryFitPlan fit, string outcome, IReadOnlyList sampled, TimeSpan? chosenDiff) + { + static string Secs(TimeSpan value) => value.TotalSeconds.ToString("F1", CultureInfo.InvariantCulture); + + logger.LogInformation( + "Boundary fit ({Kind}) — untilBoundary={UntilBoundary}s queuedAhead={QueuedAhead}s " + + "preMusicPatter={PreMusicPatter}s breakPatter={BreakPatter}s desired={Desired}s " + + "tolerance=±{Tolerance}s confidence={Confidence} sampled=[{Sampled}] " + + "chosenDiff={ChosenDiff} outcome={Outcome}", + fit.Kind, Secs(fit.UntilBoundary), Secs(fit.QueuedAhead), Secs(fit.PreMusicPatter), + Secs(fit.BreakPatter), Secs(fit.DesiredEffectiveLength), Secs(fit.Tolerance), + fit.Confidence, string.Join(", ", sampled.Select(Secs)), + chosenDiff is { } diff ? Secs(diff) + "s" : "n/a", outcome); } /// @@ -884,9 +1047,24 @@ static string FormatFiredRule(TasteRule rule) => /// gh-#254 hoist as : a live Station:Name/Station:Voice edit must not /// straddle a single unit's segment builds. Never read in here. /// + /// + /// The music this unit is planning patter around — on gh-#300's + /// ceremony-only unit, where there is no music to lead into. Null suppresses exactly two things: + /// the lead-in (nothing to introduce) and the station-ID cadence check (a ceremony is not a + /// music unit, and firing that check here would both wedge an ident into the handoff and, since + /// deliberately does not advance, fire it again on the very next unit). + /// The back-announce still runs: the track that just played deserves its outro, and + /// has already reserved the time for it. + /// + /// + /// The instant the deferral drain is evaluated against — means "now", + /// every pre-gh-#300 caller's behavior. The ceremony-only unit passes the BOUNDARY instead; see + /// for why an as-of-now drain is the exact shape of + /// the bug. + /// async Task EnqueuePatterAsync( - MediaItem? prev, MediaItem next, string? unitDjName, CadenceConfig cadence, StationIdentity identity, - CancellationToken ct) + MediaItem? prev, MediaItem? next, string? unitDjName, CadenceConfig cadence, StationIdentity identity, + CancellationToken ct, DateTimeOffset? drainAsOf = null) { // Read the render budget ONCE per unit, up front (SPEC F44.2, gitea-#197) — the same // per-unit-snapshot discipline cadence/identity arrive under (see the params above): a @@ -939,7 +1117,10 @@ async Task EnqueuePatterAsync( // pass (see class remarks for why that is still "never mid-track"). Supersede (F74.2) is // the queue's job, not this check's — a second same-kind enqueue before the next drain // would simply replace this one. - if (cadence.StationIdEveryNUnits > 0 + // next is null on gh-#300's ceremony-only unit — see this method's own param remarks for why + // an ident must not be triggered by a unit that plans no music. + if (next is not null + && cadence.StationIdEveryNUnits > 0 && unitCount > 0 && unitCount % cadence.StationIdEveryNUnits == 0) { @@ -951,7 +1132,7 @@ async Task EnqueuePatterAsync( // against (SPEC F74.3) — one clock for both halves of this seam, never a mix of a real and a // fake one. Written for ANY due deferral, including one enqueued several units ago (SPEC // F74.1 — "regardless of wall-clock slip"). - foreach (var deferral in deferralQueue.TryDequeueDue(timeProvider.GetUtcNow())) + foreach (var deferral in deferralQueue.TryDequeueDue(drainAsOf ?? timeProvider.GetUtcNow())) { switch (deferral.Kind) { @@ -1013,8 +1194,9 @@ async Task EnqueuePatterAsync( // ahead. See the method's own remarks for the F92.3 dedupe rules and the window-exit clear. await EnqueueHandoffCeremonyAsync(identity.Voice, ct); - // 3. Lead-in for the next track - if (cadence.LeadInBeforeEachTrack) + // 3. Lead-in for the next track — skipped entirely on a ceremony-only unit (gh-#300): there + // is no next track to introduce, and the sign-on's own copy is the handoff's lead-in. + if (next is not null && cadence.LeadInBeforeEachTrack) { var (voice, personaName) = await ResolvePersonaAsync(identity.Voice, ct); var req = new SegmentRequest( diff --git a/tests/GenWave.Orchestration.Tests/Specs/Gh300_DeclineTheFinalUnit.cs b/tests/GenWave.Orchestration.Tests/Specs/Gh300_DeclineTheFinalUnit.cs new file mode 100644 index 00000000..4d84ee4f --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Gh300_DeclineTheFinalUnit.cs @@ -0,0 +1,288 @@ +// gh-#300 — Boundary-fit can't decline the final unit: a full track planned 30s before the sign-off +// was due, and the 2PM handoff aired at 2:05. +// +// BDD specification — xUnit. Drives the real Orchestrator.GetNextAsync through the same fakes +// Gh254_BoundaryFitSelection established. The fit gh-#254 shipped biases WHICH track fills a unit; +// it had no move for "plan no unit at all", so at the last pull before the ceremony every candidate +// overshot and the least-late one — a full ~3.5-minute track — went in front of the boundary anyway. +// +// Two halves, both specified here: +// 1. The decline. Under gh-#300's floor the ceremony becomes the unit, and no music is planned. +// Planning early is not AIRING early: the ceremony queues behind audio still draining, so it +// reaches air at the boundary. Never-silent is preserved by falling through to an ordinary +// music unit whenever the ceremony renders nothing at all. +// 2. The record. The incident was reconstructible only from kokoro's render timestamps, because +// BuildBoundaryFit logged nothing. Every fit now writes one INFORMATION line — Debug would +// have been useless, since the fleet ships Information and above. + +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Orchestration.Tests.Fakes; +using Microsoft.Extensions.Logging; + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureDeclineTheFinalUnit +{ + static readonly DateTimeOffset ClockStart = DateTimeOffset.Parse("2030-01-01T00:00:00Z"); + + /// The incident's own shape: a pool of ordinary ~3.5-minute tracks, none of which fits. + static MediaReference MakeTrack(string id, TimeSpan duration) => new( + MediaId: id, + Locator: $"/media/{id}.mp3", + Title: $"Track {id}", + Loudness: new Loudness(-23.0, -1.0, true), + DurationMs: (int)duration.TotalMilliseconds, + SampleRate: null, + Channels: null, + BitrateKbps: null, + Artist: null, + Album: null, + Genre: null, + Year: null); + + static CadenceConfig CadenceOff => new() + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = 0, + }; + + static readonly HandoffContext Handoff = new("af_flip", "Flip", "Mic Cardioid"); + + static Orchestrator BuildOrchestrator( + FakeMediaCatalog catalog, + SpeechDeferralQueue deferralQueue, + TimeProvider clock, + FakeTtsSegmentSource tts, + ILogger logger) => + new( + new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")), + new FakeStationScopeProvider(new LibraryScope([1L])), + new FakeCadenceProvider(CadenceOff), + new FakeRotationSettingsProvider(new RotationSettings()), + catalog, + tts, + new FakeActivePersonaAccessor(), + logger, + new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), + deferralQueue, + clock, + new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10))); + + /// + /// The 19:59:29 pull, reproduced: the sign-off comes due in 30s (boundary 45s out, given the + /// 15s lead time) while 200s of audio is already committed ahead of this pass. Desired room is + /// therefore deeply NEGATIVE — the approach has already overshot the boundary — and the pool + /// holds nothing but full-length tracks. + /// + static (Orchestrator Orchestrator, FakeMediaCatalog Catalog, FakeTtsSegmentSource Tts, + CapturingLogger Logger, PlayoutContext Ctx) ArrangeTheIncident( + bool ceremonyRendersNothing = false) + { + var clock = new FakeTimeProvider(ClockStart); + var queue = new SpeechDeferralQueue(clock); + queue.Enqueue( + SpeechDeferralKind.SignOff, "test: handoff armed", + clock.GetUtcNow() + TimeSpan.FromSeconds(30), Handoff); + queue.Enqueue( + SpeechDeferralKind.SignOn, "test: handoff armed", + clock.GetUtcNow() + TimeSpan.FromSeconds(45), Handoff); + + var catalog = FakeMediaCatalog.WithPool([ + MakeTrack("full-length", TimeSpan.FromMinutes(3.5)), + MakeTrack("also-full", TimeSpan.FromMinutes(4)), + ]); + var tts = new FakeTtsSegmentSource { AlwaysReturnNull = ceremonyRendersNothing }; + var logger = new CapturingLogger(); + + return ( + BuildOrchestrator(catalog, queue, clock, tts, logger), + catalog, + tts, + logger, + new PlayoutContext([], QueuedAheadMs: 200_000)); + } + + public static class ScenarioTheLastUnitBeforeACeremonyIsTheCeremony + { + [Fact] + public static async Task The_pull_returns_a_spoken_segment_rather_than_a_track() + { + var (orchestrator, _, _, _, ctx) = ArrangeTheIncident(); + + var next = await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.NotNull(next); + Assert.StartsWith("tts:", next.MediaId, StringComparison.Ordinal); + } + + [Fact] + public static async Task The_segment_is_the_sign_off() + { + var (orchestrator, _, tts, _, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.Contains(tts.Requests, request => request.Kind == SegmentKind.SignOff); + } + + [Fact] + public static async Task No_music_is_planned_at_all() + { + // The whole bug in one assertion: a full extra unit inside the last minute IS the slip. + var (orchestrator, catalog, _, _, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.Empty(catalog.RotationCallScopes); + } + + [Fact] + public static async Task Both_halves_of_the_ceremony_drain_together() + { + // The drain runs as-of the BOUNDARY, so the sign-on (due at it, 15s after the sign-off) + // rides the same unit — the shape SignOffLeadTime's own remarks call the common case. + var (orchestrator, _, tts, _, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.Contains(tts.Requests, request => request.Kind == SegmentKind.SignOn); + } + + [Fact] + public static async Task The_sign_on_is_served_next_still_ahead_of_any_music() + { + var (orchestrator, catalog, _, _, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + var second = await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.NotNull(second); + Assert.StartsWith("tts:", second.MediaId, StringComparison.Ordinal); + Assert.Empty(catalog.RotationCallScopes); + } + } + + public static class ScenarioTheFitIsOnTheRecord + { + [Fact] + public static async Task A_declined_fit_writes_its_own_line() + { + var (orchestrator, _, _, logger, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.Contains(logger.Entries, entry => entry.Message.Contains("outcome=declined", StringComparison.Ordinal)); + } + + [Fact] + public static async Task The_fit_line_is_information_not_debug() + { + // The fleet ships Information and above — a Debug line would be as invisible as none. + var (orchestrator, _, _, logger, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.Contains( + logger.Entries, + entry => entry.Level == LogLevel.Information + && entry.Message.Contains("Boundary fit", StringComparison.Ordinal)); + } + + [Fact] + public static async Task The_line_carries_the_terms_the_fit_reasoned_from() + { + var (orchestrator, _, _, logger, ctx) = ArrangeTheIncident(); + + await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + var fitLine = Assert.Single( + logger.Entries, entry => entry.Message.Contains("Boundary fit", StringComparison.Ordinal)); + Assert.Contains("queuedAhead=200.0s", fitLine.Message, StringComparison.Ordinal); + } + + [Fact] + public static async Task An_engaged_fit_records_what_the_sampler_did() + { + // Six minutes of room, nothing queued ahead: the fit engages and a track is chosen. + var clock = new FakeTimeProvider(ClockStart); + var queue = new SpeechDeferralQueue(clock); + queue.Enqueue( + SpeechDeferralKind.SignOff, "test: handoff armed", + clock.GetUtcNow() + TimeSpan.FromMinutes(6), Handoff); + var catalog = FakeMediaCatalog.WithPool([MakeTrack("six-min", TimeSpan.FromMinutes(6))]); + var logger = new CapturingLogger(); + var orchestrator = BuildOrchestrator(catalog, queue, clock, new FakeTtsSegmentSource(), logger); + + await orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); + + Assert.Contains( + logger.Entries, + entry => entry.Message.Contains("outcome=win", StringComparison.Ordinal) + || entry.Message.Contains("outcome=least-late", StringComparison.Ordinal)); + } + } + + public static class ScenarioRoomForAUnitIsLeftAlone + { + [Fact] + public static async Task A_boundary_six_minutes_out_still_gets_its_track() + { + // Above the floor the gh-#254 fit keeps its existing behavior, untouched. + var clock = new FakeTimeProvider(ClockStart); + var queue = new SpeechDeferralQueue(clock); + queue.Enqueue( + SpeechDeferralKind.SignOff, "test: handoff armed", + clock.GetUtcNow() + TimeSpan.FromMinutes(6), Handoff); + var catalog = FakeMediaCatalog.WithPool([MakeTrack("six-min", TimeSpan.FromMinutes(6))]); + var orchestrator = BuildOrchestrator( + catalog, queue, clock, new FakeTtsSegmentSource(), new CapturingLogger()); + + var next = await orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); + + Assert.NotNull(next); + Assert.Equal("six-min", next.MediaId); + } + } + + public static class ScenarioOnlyAHandoffIsWorthATrack + { + [Fact] + public static async Task A_future_dated_station_id_never_costs_a_music_unit() + { + // An ident is imaging — it can ride the next seam. Skipping a whole track for one would + // trade a small blemish for a large one. + var clock = new FakeTimeProvider(ClockStart); + var queue = new SpeechDeferralQueue(clock); + queue.Enqueue( + SpeechDeferralKind.StationId, "test: future-dated ident", + clock.GetUtcNow() + TimeSpan.FromSeconds(30)); + var catalog = FakeMediaCatalog.WithPool([MakeTrack("full-length", TimeSpan.FromMinutes(3.5))]); + var orchestrator = BuildOrchestrator( + catalog, queue, clock, new FakeTtsSegmentSource(), new CapturingLogger()); + + var next = await orchestrator.GetNextAsync( + new PlayoutContext([], QueuedAheadMs: 200_000), CancellationToken.None); + + Assert.NotNull(next); + Assert.Equal("full-length", next.MediaId); + } + } + + public static class ScenarioNeverSilentSurvivesTheDecline + { + [Fact] + public static async Task A_ceremony_that_renders_nothing_falls_through_to_music() + { + // F6.3 stands: the decline may only ever ADD segments. If the whole ceremony drops, the + // pull plans an ordinary music unit exactly as though the decline had never fired. + var (orchestrator, _, _, _, ctx) = ArrangeTheIncident(ceremonyRendersNothing: true); + + var next = await orchestrator.GetNextAsync(ctx, CancellationToken.None); + + Assert.NotNull(next); + Assert.DoesNotContain("tts:", next.MediaId, StringComparison.Ordinal); + } + } +} From fefc48ce7e2214b76e7a18156ed85d189da43c24 Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Fri, 31 Jul 2026 08:16:22 -0600 Subject: [PATCH 2/2] docs(orchestration): scope the gh-#300 floor as the bottom rung of three (gh-#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor is only ever reached because an earlier unit overshot — it limits damage rather than repairing it. Name the rung above it (the straddle handoff, gh-#320) as the real answer for the band where room is positive but no track fits, and record that this floor should collapse toward zero once that lands. --- src/GenWave.Orchestration/Orchestrator.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/GenWave.Orchestration/Orchestrator.cs b/src/GenWave.Orchestration/Orchestrator.cs index 08a938d0..0ded118c 100644 --- a/src/GenWave.Orchestration/Orchestrator.cs +++ b/src/GenWave.Orchestration/Orchestrator.cs @@ -234,6 +234,18 @@ public sealed class Orchestrator( /// live knob: gh-#300's own fit logging is what makes promoting it to one an argument from /// field data rather than taste, and that data does not exist yet. /// + /// + /// + /// Interim, and known to be. This floor is the bottom rung of three, and it only ever + /// gets reached because an EARLIER unit overshot — it limits the damage rather than repairing + /// it. The rung above (gh-#320, the straddle handoff) is the real answer for the band where + /// room is positive but no track fits: sign off into a track that crosses the hour and sign on + /// after it, which is what a live DJ does when the rotation traps them. Until that exists, this + /// floor holds the middle band at "up to 90s early" instead of "up to ~2 minutes late", and the + /// trade above 90s is still lateness — the honest bound, not a fix. Once gh-#320 lands, the + /// straddle owns that band and this floor should collapse toward zero: bare-ceremony is right + /// only once the boundary is genuinely unreachable. + /// /// static readonly TimeSpan MusicUnitFloor = TimeSpan.FromSeconds(90);