diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/internal/skips/ads.go | 61 | ||||
| -rw-r--r-- | server/internal/skips/ads_test.go | 112 | ||||
| -rw-r--r-- | server/internal/skips/skips_test.go | 23 |
3 files changed, 176 insertions, 20 deletions
diff --git a/server/internal/skips/ads.go b/server/internal/skips/ads.go index b3c1f3b..05259c3 100644 --- a/server/internal/skips/ads.go +++ b/server/internal/skips/ads.go @@ -83,8 +83,23 @@ type AdConfig struct { // re-aired episode, a two-parter with a long recap -- and skipping all of it would eat the // programme rather than the advertising. MaxSkipFraction float64 - // MaxBreakMs caps a single break. Longer than any plausible ad pod means shared programme. + // MaxBreakMs caps a single *match*. Longer than any plausible ad pod means the episodes + // share programme -- a re-aired episode, a two-parter's recap -- rather than advertising. + // + // Deliberately applied before bridging, not after. One enormous match is evidence of shared + // programme; a chain of ordinary matches a beat apart is evidence of an ad pod, and fusing + // them must not manufacture a refusal. Radiolab's Grass Apes carries a 3:55 pre-roll and an + // 0:18 tag two seconds later, and capping the bridged 4:15 would publish nothing for the + // best-validated episode in the corpus. MaxBreakMs int64 + // MergeGapMs bridges matches separated by less than this. + // + // A pod of several advertisements is found as several matches, because agreement lapses + // briefly between them. Published unbridged, 45% of the gaps between consecutive breaks came + // in under ten seconds and 152 of 262 multi-break episodes had one -- which plays as a skip, + // a short burst of advertisement, then another skip. Bridging also improves agreement with + // the declared length gap, because what was bridged was advertising too. + MergeGapMs int64 // Runs is what counts as agreement within an alignment. Runs detect.RunOptions } @@ -96,6 +111,7 @@ func DefaultAdConfig() AdConfig { LagsPerPair: 8, MaxSkipFraction: 0.35, MaxBreakMs: 240_000, + MergeGapMs: 10_000, Runs: detect.DefaultRunOptions(), } } @@ -161,7 +177,19 @@ func DetectAds(ctx context.Context, src AudioSource, episodes []Episode, cfg AdC // episode that publishes no breaks because its matches were implausible is a different // state from one where nothing matched, and the two looking identical is the mistake // the app's own skip readout already made once. - matched := merge(found[i]) + // A single match longer than any real ad pod is shared programme -- a recap, or a + // repeat -- and cutting it would remove the episode rather than the advertising. Judged + // on the matches, before bridging fuses neighbours into something longer. + kept := make([]AdBreak, 0, len(found[i])) + for _, br := range found[i] { + if cfg.MaxBreakMs > 0 && br.DurationMs() > cfg.MaxBreakMs { + result.Refused[clip.episode.ID] = true + continue + } + kept = append(kept, br) + } + + matched := merge(kept, cfg.MergeGapMs) total := int64(0) for _, br := range matched { total += br.DurationMs() @@ -179,18 +207,8 @@ func DetectAds(ctx context.Context, src AudioSource, episodes []Episode, cfg AdC continue } - breaks := make([]AdBreak, 0, len(matched)) - for _, br := range matched { - // A single match longer than any real ad pod is shared programme -- a recap, or a - // repeat -- and cutting it would remove the episode rather than the advertising. - if cfg.MaxBreakMs > 0 && br.DurationMs() > cfg.MaxBreakMs { - result.Refused[clip.episode.ID] = true - continue - } - breaks = append(breaks, br) - } - if len(breaks) > 0 { - result.Breaks[clip.episode.ID] = breaks + if len(matched) > 0 { + result.Breaks[clip.episode.ID] = matched } } return result, nil @@ -205,12 +223,15 @@ func breakOf(startSamples, endSamples int, score float64, rate int) AdBreak { } } -// merge folds overlapping and touching matches into single breaks, chronologically. +// merge folds matches into single breaks, chronologically, bridging gaps up to gapMs. +// +// Two things at once. The same advertisement is found once per partner that carries it, so +// without merging an ad in three episodes becomes three identical breaks. And a pod of several +// advertisements is found as several matches with brief lapses between them, which bridging +// turns back into the one stretch a listener experiences. // -// The same advertisement is found once per partner that carries it, so without merging an ad in -// three episodes becomes three identical breaks. The partner count is what survives: it says how -// much agreement a break rests on. -func merge(raw []AdBreak) []AdBreak { +// The partner count is what survives: it says how much agreement a break rests on. +func merge(raw []AdBreak, gapMs int64) []AdBreak { if len(raw) == 0 { return nil } @@ -219,7 +240,7 @@ func merge(raw []AdBreak) []AdBreak { out := make([]AdBreak, 0, len(raw)) current := raw[0] for _, next := range raw[1:] { - if next.StartMs <= current.EndMs { + if next.StartMs <= current.EndMs+gapMs { if next.EndMs > current.EndMs { current.EndMs = next.EndMs } diff --git a/server/internal/skips/ads_test.go b/server/internal/skips/ads_test.go index 476b819..baaec43 100644 --- a/server/internal/skips/ads_test.go +++ b/server/internal/skips/ads_test.go @@ -314,3 +314,115 @@ func TestOneEpisodeHasNothingToCompareAgainst(t *testing.T) { t.Errorf("found breaks with a single episode: %+v", got.Breaks) } } + +/* +Fragmentation, and why bridging it is not cosmetic. + +A pod of several advertisements is found as several separate matches, because agreement lapses +briefly between them -- a beat of near-silence, a jingle one episode's ad server trimmed +differently. Published as-is, 45% of the gaps between consecutive breaks measured under ten +seconds, and 152 of 262 multi-break episodes contained at least one. That plays as a skip, a +one-to-nine-second burst of advertisement, then another skip. + +Bridging also moves agreement with the declared length gap the right way -- 69% to 73% median -- +because the bridged gap was advertising too. +*/ + +func TestAdsSeparatedByABriefGapBecomeOneBreak(t *testing.T) { + first := adNoise(910, 12*adRate) + second := adNoise(911, 15*adRate) + // Two different advertisements six seconds apart, carried by both episodes. + withPod := func(seed int64, at int) []float64 { + out := put(adNoise(seed, 600*adRate), first, at*adRate) + return put(out, second, (at+18)*adRate) + } + stub := &adStub{ + whole: map[string][]float64{"a": withPod(1, 40), "b": withPod(2, 200)}, + durations: map[string]int64{"a": 600_000, "b": 600_000}, + } + eps := []Episode{{ID: "e1", Path: "a"}, {ID: "e2", Path: "b"}} + + got, err := DetectAds(context.Background(), stub, eps, adConfig()) + if err != nil { + t.Fatal(err) + } + + if len(got.Breaks["e1"]) != 1 { + t.Fatalf("e1 breaks %+v, want the pod bridged into 1", got.Breaks["e1"]) + } + br := got.Breaks["e1"][0] + if br.StartMs > 41_000 || br.EndMs < 72_000 { + t.Errorf("bridged break %d-%dms, want roughly 40000-73000", br.StartMs, br.EndMs) + } +} + +// The counterpart: a real stretch of programme between two pods must survive as programme. +func TestAdsSeparatedByProgrammeStaySeparate(t *testing.T) { + first := adNoise(920, 12*adRate) + second := adNoise(921, 12*adRate) + withTwo := func(seed int64, at int) []float64 { + out := put(adNoise(seed, 900*adRate), first, at*adRate) + return put(out, second, (at+300)*adRate) + } + stub := &adStub{ + whole: map[string][]float64{"a": withTwo(3, 60), "b": withTwo(4, 400)}, + durations: map[string]int64{"a": 900_000, "b": 900_000}, + } + eps := []Episode{{ID: "e1", Path: "a"}, {ID: "e2", Path: "b"}} + + got, err := DetectAds(context.Background(), stub, eps, adConfig()) + if err != nil { + t.Fatal(err) + } + + if len(got.Breaks["e1"]) != 2 { + t.Errorf("e1 breaks %+v, want 2 -- five minutes of programme is not a gap", got.Breaks["e1"]) + } +} + +/* +The regression this nearly shipped. + +MaxBreakMs refuses a single match longer than any plausible ad pod, which is how a re-aired +episode or a long shared recap is kept from eating the programme. Applied to breaks *after* +bridging, it also refuses legitimate pods that merging made long: Radiolab's Grass Apes carries +a 3:55 pre-roll and an 0:18 tag two seconds later, and fusing them to 4:15 would have put the +best-validated episode in the corpus over the cap and published nothing for it. Five episodes +regressed that way. + +So the cap belongs on the matches, not on what bridging makes of them: one enormous match is +evidence of shared programme, whereas a chain of ordinary matches a beat apart is evidence of an +ad pod. MaxSkipFraction remains the guard against an episode that matches too much overall. +*/ +func TestAPodBridgedPastTheSingleMatchCapIsStillPublished(t *testing.T) { + preroll := adNoise(930, 200*adRate) + tag := adNoise(931, 60*adRate) + withPreroll := func(seed int64, at int) []float64 { + out := put(adNoise(seed, 1800*adRate), preroll, at*adRate) + return put(out, tag, (at+202)*adRate) + } + stub := &adStub{ + whole: map[string][]float64{"a": withPreroll(5, 10), "b": withPreroll(6, 900)}, + durations: map[string]int64{"a": 1_800_000, "b": 1_800_000}, + } + eps := []Episode{{ID: "e1", Path: "a"}, {ID: "e2", Path: "b"}} + + cfg := adConfig() + cfg.MaxBreakMs = 240_000 // production's cap; the bridged pod exceeds it, each match does not + got, err := DetectAds(context.Background(), stub, eps, cfg) + if err != nil { + t.Fatal(err) + } + + if got.Refused["e1"] { + t.Error("refused an episode whose only long break is a bridged pod of short matches") + } + if len(got.Breaks["e1"]) != 1 { + t.Fatalf("e1 breaks %+v, want the pod published as 1", got.Breaks["e1"]) + } + // 200s + 2s gap + 60s is 262s of continuous advertising: over the cap as one break, while + // neither match that composes it comes close. + if d := got.Breaks["e1"][0].DurationMs(); d < 255_000 { + t.Errorf("bridged pod is %dms, want the whole ~262000 of it", d) + } +} diff --git a/server/internal/skips/skips_test.go b/server/internal/skips/skips_test.go index bfddcd3..936723f 100644 --- a/server/internal/skips/skips_test.go +++ b/server/internal/skips/skips_test.go @@ -329,6 +329,29 @@ func TestDefaultConfigUsesTheValidatedValues(t *testing.T) { } } +// Likewise for the ad scan, whose numbers were each chosen against measured corpus behaviour +// rather than picked: 250Hz is what makes a whole-episode correlation affordable, 0.35 and +// 240s are the two refusals, and 10s is the bridging tolerance that removed the +// skip-burst-skip stutter from 152 of 262 multi-break episodes. +func TestDefaultAdConfigUsesTheValidatedValues(t *testing.T) { + cfg := DefaultAdConfig() + if cfg.DiscoveryRate != 250 { + t.Errorf("DiscoveryRate = %d, want 250", cfg.DiscoveryRate) + } + if cfg.LagsPerPair != 8 { + t.Errorf("LagsPerPair = %d, want 8", cfg.LagsPerPair) + } + if cfg.MaxSkipFraction != 0.35 { + t.Errorf("MaxSkipFraction = %v, want 0.35", cfg.MaxSkipFraction) + } + if cfg.MaxBreakMs != 240_000 { + t.Errorf("MaxBreakMs = %d, want 240000", cfg.MaxBreakMs) + } + if cfg.MergeGapMs != 10_000 { + t.Errorf("MergeGapMs = %d, want 10000", cfg.MergeGapMs) + } +} + // The wiring that makes the self-check matter: a confirmation that cannot verify itself must // not produce skip offsets. Forced here by demanding near-perfect agreement, which a genuine // match between two separately-encoded episodes does not reach -- so this exercises the |
