summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-30 08:37:15 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-30 08:37:15 +0000
commitd7da85860bde910a182df2fc65ce284750272a78 (patch)
treeb8a563bdf20b6269de1e514d0355bf7f720534c3 /server
parentb78b846189124ede389a25aee2a3b45c98b528ec (diff)
Publish the advertising the words measured, and only that
Subtraction has earned it. Eighteen episodes, 92% to 104% of what the declared-versus-served length gap implies, every pod found, edges pulled back off the programme, and the one class of mistake that mattered -- a break opening on the host's own sentence -- traced and fixed. **Only subtraction is published.** Reading the ad copy measured 0%, 0%, 15% and 29% on the same kind of episode, and on a show that bakes its reads in it found 2:13 where the lengths can see nothing at all. That stays a hint beside the words, dimmed on the transcript screen, and never becomes a seek. **The length gap is the guard as well as the measure.** It comes from the feed and the file rather than from any detector, so it is the one number able to contradict one. Nothing is published for an episode with no gap -- there would be nothing to check it against -- and a candidate set claiming half again as much as was inserted is refused with its reason, because it has found something other than advertising. They land in their own field rather than beside the audio scan's, which rewrites its list from scratch on every detection pass and would wipe them. The API serves the union, merging ranges that overlap or touch: playback seeks to a break's end, so two overlapping breaks would seek out of the first and straight into the second, and two touching ones would resume for no time at all before skipping again. Advertising wins where merged ranges disagree about what they are -- the phone skips advertising unconditionally and a show's own audio only by preference, and being wrong the other way means sitting through an advertisement the server had already found. Nothing about playback changes. They arrive in the field the phone has read since the on-device detector existed. **Refetching is now gated on age**, which is Peter's steer and what the numbers say. A second fetch made minutes after the first came back twenty seconds different and found 21% of the advertising; another came back the same length to the second and found none. Insertion varies the amount per request, not the campaign. So it is for shows whose episodes are still worth hearing four weeks later -- a slow-drip archive, a leisure show -- and not for daily news, which is pruned long before a campaign rotates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server')
-rw-r--r--server/cmd/listend/main.go28
-rw-r--r--server/internal/api/api.go5
-rw-r--r--server/internal/api/transcript_test.go37
-rw-r--r--server/internal/store/store.go66
-rw-r--r--server/internal/store/store_test.go129
-rw-r--r--server/internal/transcript/publish.go61
-rw-r--r--server/internal/transcript/publish_test.go118
7 files changed, 432 insertions, 12 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index 8620d0d..6a76f08 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -574,6 +574,7 @@ func (w *worker) rederiveAds() {
log.Printf("re-deriving %q: %v", e.Title, err)
continue
}
+ w.publishWordBreaks(e, found)
changed++
}
}
@@ -595,6 +596,29 @@ func sameCandidates(left, right []transcript.Candidate) bool {
}
/*
+publishWordBreaks turns what the words found into stretches the phone will actually seek past.
+
+The only place anything read out of a transcript reaches playback. Everything else in this
+pipeline produces candidates to look at; transcript.Publishable decides which of them are fit to
+act on, and refuses the rest with a reason rather than silently dropping them.
+
+Kept in their own field so the ad scan, which rewrites its own list from scratch on every
+detection pass, cannot wipe them -- and so that which detector found a break stays answerable.
+*/
+func (w *worker) publishWordBreaks(e *store.Episode, found []transcript.Candidate) {
+ fit, why := transcript.Publishable(found, e.DurationMs, e.DeclaredDurationMs)
+ e.WordAdsRefused = why
+ e.WordBreaks = nil
+ for _, c := range fit {
+ e.WordBreaks = append(e.WordBreaks, store.Break{
+ StartMs: c.StartMs,
+ EndMs: c.EndMs,
+ Kind: string(skips.KindAd),
+ })
+ }
+}
+
+/*
adsIn works out which stretches of a recognised episode are advertising.
By subtraction where the publisher's ad-free master is held, and by reading the ad copy where it
@@ -781,6 +805,7 @@ func (w *worker) refetchOne(
if err := w.transcripts.Put(held); err != nil {
return nil, second.DurationMs, err
}
+ w.publishWordBreaks(e, held.Ads)
return held.Ads, second.DurationMs, nil
}
@@ -791,10 +816,11 @@ Newest first: an episode about to be played is worth the work before one from th
catalogue.
*/
func (w *worker) pendingRefetch() []*store.Episode {
+ now := time.Now().Unix()
var pending []*store.Episode
for _, show := range w.state.Shows() {
for _, e := range show.Episodes {
- if !e.NeedsRefetch() {
+ if !e.NeedsRefetch(now) {
continue
}
// Without a length gap nothing was inserted, so a second fetch would be the same
diff --git a/server/internal/api/api.go b/server/internal/api/api.go
index 0d18c0f..367a1c6 100644
--- a/server/internal/api/api.go
+++ b/server/internal/api/api.go
@@ -207,8 +207,9 @@ func (s *Server) episode(w http.ResponseWriter, r *http.Request) {
return
}
- breaks := make([]breakRange, 0, len(found.Breaks))
- for _, br := range found.Breaks {
+ all := found.AllBreaks()
+ breaks := make([]breakRange, 0, len(all))
+ for _, br := range all {
breaks = append(breaks, breakRange{
StartMs: br.StartMs,
EndMs: br.EndMs,
diff --git a/server/internal/api/transcript_test.go b/server/internal/api/transcript_test.go
index 6d0ad7f..e6da3ff 100644
--- a/server/internal/api/transcript_test.go
+++ b/server/internal/api/transcript_test.go
@@ -301,3 +301,40 @@ func TestAnEpisodeWithNoAdvertisingInItsWordsSaysNothing(t *testing.T) {
t.Errorf("got %+v, want nothing claimed", *got.Transcript)
}
}
+
+/*
+Advertising read out of the words reaches the phone as a break like any other.
+
+It arrives in the same field the audio scan's breaks do, because the phone has skipped those
+since the on-device detector existed and nothing about playback needs to change. Which detector
+found it is kept on the server; what the phone needs to know is where not to listen.
+*/
+func TestBreaksFoundInTheWordsAreServedWithTheRest(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ st.Merge("https://f", "A Show", []feed.Episode{{
+ GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100,
+ }})
+ e := st.Match("https://f", "g1", "", "")
+ e.Path = e.ID + ".mp3"
+ writeAudio(t, audioDir, e.Path)
+ e.Breaks = []store.Break{{StartMs: 60_000, EndMs: 90_000, Kind: "ad"}}
+ e.WordBreaks = []store.Break{{StartMs: 600_000, EndMs: 660_000, Kind: "ad"}}
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1")
+ var got struct {
+ Breaks []struct {
+ StartMs int64 `json:"startMs"`
+ EndMs int64 `json:"endMs"`
+ Kind string `json:"kind"`
+ } `json:"breaks"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got.Breaks) != 2 {
+ t.Fatalf("got %+v, want both", got.Breaks)
+ }
+ if got.Breaks[1].StartMs != 600_000 {
+ t.Errorf("got %+v, want the one from the words included", got.Breaks)
+ }
+}
diff --git a/server/internal/store/store.go b/server/internal/store/store.go
index 2a6992b..b86474d 100644
--- a/server/internal/store/store.go
+++ b/server/internal/store/store.go
@@ -38,6 +38,16 @@ type Episode struct {
// which for simultaneously-mirrored episodes is the advertising inserted into them.
Breaks []Break `json:"breaks,omitempty"`
+ // WordBreaks are the stretches read out of this episode's words and judged fit to skip --
+ // see transcript.Publishable. Their own field because the two passes each rewrite their own
+ // list wholesale: the ad scan sets Breaks from scratch every time it runs, and would wipe
+ // anything the transcript had put there.
+ WordBreaks []Break `json:"wordBreaks,omitempty"`
+ // WordAdsRefused records that the words turned up advertising which was not fit to publish.
+ // A different state from having found none, and kept apart for the same reason as
+ // AdsRefused.
+ WordAdsRefused string `json:"wordAdsRefused,omitempty"`
+
// FoundAdMs and InsertedAdMs are what the scan accounted for against what the feed's
// declared length implies is there. Kept because the second number does not come from the
// detector and so is the only thing able to contradict it.
@@ -118,14 +128,30 @@ func (e *Episode) NeedsMaster() bool {
e.MasterCues == 0 && e.MasterNote == ""
}
+// refetchAfterSeconds is how old an episode must be before a second fetch is worth making.
+// Four weeks: long enough that whatever campaign was running at mirroring has given way, and
+// short enough that the episode is usually still being served.
+const refetchAfterSeconds = 28 * 24 * 60 * 60
+
// NeedsRefetch reports whether a second copy of this episode is worth fetching.
//
// Only for an episode that has its own words but no master to subtract them from, and only
// where the lengths say advertising was inserted: without a gap the two fetches would be the
// same recording and there would be nothing to find.
-func (e *Episode) NeedsRefetch() bool {
- return e.Mirrored() && e.HasTranscript() && e.EnclosureURL != "" &&
- e.MasterCues == 0 && e.MasterNote == "" && e.RefetchAttemptedAtUnix == 0
+func (e *Episode) NeedsRefetch(now int64) bool {
+ if !e.Mirrored() || !e.HasTranscript() || e.EnclosureURL == "" ||
+ e.MasterCues != 0 || e.MasterNote != "" || e.RefetchAttemptedAtUnix != 0 {
+ return false
+ }
+ // Old enough that the campaign has had time to turn over. Measured: a second fetch of a
+ // Tangle episode minutes after the first came back 20 seconds different and subtraction
+ // found 21% of the advertising; another came back the same length to the second and found
+ // none at all. Insertion varies the amount per request, not the campaign.
+ //
+ // So this is for the shows whose episodes stay worth listening to -- a slow-drip archive, a
+ // leisure show -- and not for daily news, whose episodes are pruned long before any
+ // campaign rotates.
+ return e.PublishedUnix > 0 && now-e.PublishedUnix >= refetchAfterSeconds
}
// NeedsRecognising reports whether speech recognition should be run over this episode.
@@ -150,6 +176,40 @@ type Break struct {
Kind string `json:"kind,omitempty"`
}
+// AllBreaks is every stretch to skip, from both detectors, as one ordered list.
+//
+// Overlapping and touching ranges are merged, because playback seeks to a break's end: two
+// overlapping breaks would seek out of the first and straight into the second, and two touching
+// ones would resume for no time at all before skipping again.
+//
+// Advertising wins where two merged ranges disagree about what they are. The phone skips
+// advertising unconditionally and a show's own audio only by preference, and being wrong the
+// other way means sitting through an advertisement the server had already found.
+func (e *Episode) AllBreaks() []Break {
+ all := make([]Break, 0, len(e.Breaks)+len(e.WordBreaks))
+ all = append(all, e.Breaks...)
+ all = append(all, e.WordBreaks...)
+ sort.Slice(all, func(i, j int) bool { return all[i].StartMs < all[j].StartMs })
+
+ var merged []Break
+ for _, br := range all {
+ if last := len(merged) - 1; last >= 0 && br.StartMs <= merged[last].EndMs {
+ if br.EndMs > merged[last].EndMs {
+ merged[last].EndMs = br.EndMs
+ }
+ if br.Kind == "ad" {
+ merged[last].Kind = "ad"
+ }
+ if br.Partners > merged[last].Partners {
+ merged[last].Partners = br.Partners
+ }
+ continue
+ }
+ merged = append(merged, br)
+ }
+ return merged
+}
+
// Mirrored reports whether this episode's audio is on disk.
func (e *Episode) Mirrored() bool { return e.Path != "" }
diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go
index 9020b17..c4eafdd 100644
--- a/server/internal/store/store_test.go
+++ b/server/internal/store/store_test.go
@@ -448,20 +448,29 @@ Only worth doing where there is advertising to find. An episode whose served len
the feed declares has none inserted, and the second fetch would be the same recording.
*/
+// now and longAgo frame the age rule: a second fetch is only worth making once the campaign
+// that was running at mirroring has had time to turn over.
+const (
+ now = int64(1_800_000_000)
+ longAgo = now - 60*24*60*60
+ lately = now - 2*24*60*60
+)
+
func TestAnEpisodeWithItsOwnWordsAndNoMasterIsWorthRefetching(t *testing.T) {
e := Episode{
Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400,
+ PublishedUnix: longAgo,
}
- if !e.NeedsRefetch() {
+ if !e.NeedsRefetch(now) {
t.Error("an episode with words and nothing to subtract them from was passed over")
}
}
func TestAnEpisodeThatAlreadyHasAMasterIsNotRefetched(t *testing.T) {
e := Episode{
- Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400, MasterCues: 300,
+ Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400, MasterCues: 300, PublishedUnix: longAgo,
}
- if e.NeedsRefetch() {
+ if e.NeedsRefetch(now) {
t.Error("would fetch a second copy for an episode that already has a master")
}
}
@@ -469,7 +478,7 @@ func TestAnEpisodeThatAlreadyHasAMasterIsNotRefetched(t *testing.T) {
// Nothing to subtract from until its own words exist.
func TestAnEpisodeWithNoWordsYetIsNotRefetched(t *testing.T) {
e := Episode{Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3"}
- if e.NeedsRefetch() {
+ if e.NeedsRefetch(now) {
t.Error("would fetch a second copy before there was a first to compare it against")
}
}
@@ -479,9 +488,117 @@ func TestAnEpisodeWithNoWordsYetIsNotRefetched(t *testing.T) {
func TestAFinishedRefetchIsNotRepeated(t *testing.T) {
e := Episode{
Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400,
- RefetchAttemptedAtUnix: 1_700_000_000,
+ RefetchAttemptedAtUnix: 1_700_000_000, PublishedUnix: longAgo,
}
- if e.NeedsRefetch() {
+ if e.NeedsRefetch(now) {
t.Error("would fetch a second copy again")
}
}
+
+/*
+Breaks from the words and breaks from the audio are kept apart and served together.
+
+They are found by different means, on different schedules, and each is rewritten wholesale by
+its own pass -- the ad scan sets Breaks from scratch on every detection, and would wipe anything
+the transcript put there. Keeping them in separate fields is what lets both exist.
+
+The phone wants one list. Overlapping ranges are merged rather than handed over as two, because
+playback seeks to a break's end: two overlapping breaks would seek out of the first and straight
+into the second.
+*/
+
+func TestBreaksFromBothSourcesAreServedAsOneList(t *testing.T) {
+ e := Episode{
+ Breaks: []Break{{StartMs: 60_000, EndMs: 90_000, Kind: "ad"}},
+ WordBreaks: []Break{{StartMs: 600_000, EndMs: 660_000, Kind: "ad"}},
+ }
+
+ got := e.AllBreaks()
+ if len(got) != 2 {
+ t.Fatalf("got %+v, want both", got)
+ }
+ if got[0].StartMs != 60_000 || got[1].StartMs != 600_000 {
+ t.Errorf("got %+v, want them in order", got)
+ }
+}
+
+func TestOverlappingBreaksBecomeOne(t *testing.T) {
+ e := Episode{
+ Breaks: []Break{{StartMs: 60_000, EndMs: 90_000, Kind: "ad"}},
+ WordBreaks: []Break{{StartMs: 80_000, EndMs: 120_000, Kind: "ad"}},
+ }
+
+ got := e.AllBreaks()
+ if len(got) != 1 {
+ t.Fatalf("got %+v, want one merged break", got)
+ }
+ if got[0].StartMs != 60_000 || got[0].EndMs != 120_000 {
+ t.Errorf("got %d-%d, want 60000-120000", got[0].StartMs, got[0].EndMs)
+ }
+}
+
+// Touching ranges are one stretch of audio, and leaving a gap of nothing between them would
+// have playback resume for no time at all and skip again.
+func TestTouchingBreaksBecomeOne(t *testing.T) {
+ e := Episode{
+ Breaks: []Break{{StartMs: 60_000, EndMs: 90_000, Kind: "ad"}},
+ WordBreaks: []Break{{StartMs: 90_000, EndMs: 120_000, Kind: "ad"}},
+ }
+
+ if got := e.AllBreaks(); len(got) != 1 {
+ t.Errorf("got %+v, want one", got)
+ }
+}
+
+/*
+Advertising wins when two merged breaks disagree about what they are.
+
+The phone skips advertising unconditionally and a show's own audio only by preference, so a
+stretch that either detector called advertising is advertising. Being wrong the other way means
+sitting through an advertisement the server already found.
+*/
+func TestAdvertisingWinsOverFurnitureWhenBreaksMerge(t *testing.T) {
+ e := Episode{
+ Breaks: []Break{{StartMs: 60_000, EndMs: 90_000, Kind: "intro"}},
+ WordBreaks: []Break{{StartMs: 80_000, EndMs: 120_000, Kind: "ad"}},
+ }
+
+ got := e.AllBreaks()
+ if len(got) != 1 || got[0].Kind != "ad" {
+ t.Errorf("got %+v, want one break called advertising", got)
+ }
+}
+
+func TestAnEpisodeWithNeitherKindOfBreakHasNone(t *testing.T) {
+ var e Episode
+ if got := e.AllBreaks(); len(got) != 0 {
+ t.Errorf("got %+v", got)
+ }
+}
+
+/*
+A fresh episode is not refetched.
+
+Measured on the live library: a second fetch made minutes after the first came back twenty
+seconds different and subtraction found 21% of the advertising; another came back the same
+length to the second and found none. Insertion varies the amount per request, not the campaign,
+so there is nothing to subtract until the campaign has turned over.
+*/
+func TestAFreshEpisodeIsNotRefetched(t *testing.T) {
+ e := Episode{
+ Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400,
+ PublishedUnix: lately,
+ }
+ if e.NeedsRefetch(now) {
+ t.Error("would spend a download on a copy carrying the same campaign")
+ }
+}
+
+// Without a publish date there is no way to tell how old it is, and guessing wrong costs a
+// download and five minutes of recognition for nothing.
+func TestAnEpisodeOfUnknownAgeIsNotRefetched(t *testing.T) {
+ e := Episode{Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400}
+ if e.NeedsRefetch(now) {
+ t.Error("would refetch an episode of unknown age")
+ }
+}
diff --git a/server/internal/transcript/publish.go b/server/internal/transcript/publish.go
new file mode 100644
index 0000000..5d6958c
--- /dev/null
+++ b/server/internal/transcript/publish.go
@@ -0,0 +1,61 @@
+package transcript
+
+import "fmt"
+
+/*
+Which of the advertising read out of an episode's words is fit to skip.
+
+Everything else in this package produces candidates to look at. This is the one function that
+decides what the phone will seek past, so it is deliberately narrow.
+
+Two detectors produce candidates and they are not close in quality. Subtraction -- comparing an
+episode's words against a second description of the same programme -- measured 92% to 104% of
+what the declared-versus-served length gap implies across eighteen episodes, finding every pod,
+with its edges pulled back off the programme. Reading the ad copy measured 0%, 0%, 15% and 29%
+on the same kind of episode. Only the first is acted on.
+
+The length gap is both the measure and the guard. It comes from the feed and the file rather
+than from any detector, which makes it the one number able to contradict one.
+*/
+
+// overshootLimit is how much more than the lengths imply a candidate set may claim and still be
+// published, as a percentage. The measured spread runs to 104%; 150 leaves room for a music
+// sting at the edge of a pod without leaving room for having found the wrong thing entirely.
+const overshootLimit = 150
+
+// Publishable returns the candidates fit to skip, or nothing and the reason.
+//
+// A refusal is a different state from having found nothing, and the caller keeps them apart --
+// the same distinction the ad scan's own refusals have needed twice before.
+func Publishable(found []Candidate, servedMs, declaredMs int64) ([]Candidate, string) {
+ if len(found) == 0 {
+ return nil, ""
+ }
+
+ measured := make([]Candidate, 0, len(found))
+ for _, c := range found {
+ if len(c.Signals) == 1 && c.Signals[0] == SignalDifference {
+ measured = append(measured, c)
+ }
+ }
+ if len(measured) == 0 {
+ return nil, "found by reading the ad copy, which has never agreed with the lengths"
+ }
+
+ gap := servedMs - declaredMs
+ if !HasInsertedAdvertising(servedMs, declaredMs) {
+ return nil, "the lengths say nothing was inserted into this copy, so there is " +
+ "nothing to check these against"
+ }
+
+ var total int64
+ for _, c := range measured {
+ total += c.EndMs - c.StartMs
+ }
+ if total*100 > gap*overshootLimit {
+ return nil, fmt.Sprintf(
+ "claims %s against the %s the lengths say was inserted, so it has found "+
+ "something other than advertising", clock(total), clock(gap))
+ }
+ return measured, ""
+}
diff --git a/server/internal/transcript/publish_test.go b/server/internal/transcript/publish_test.go
new file mode 100644
index 0000000..edcf9c9
--- /dev/null
+++ b/server/internal/transcript/publish_test.go
@@ -0,0 +1,118 @@
+package transcript
+
+import "testing"
+
+/*
+Which of the advertising read out of an episode's words is fit to skip.
+
+Two detectors produce candidates and they are not close in quality. Subtraction -- comparing the
+words against a second description of the same programme -- measures 92% to 104% of what the
+declared-versus-served length gap implies, across eighteen episodes, finding every pod. Reading
+the ad copy measures 0% to 29% on the same kind of episode. Only the first is fit to act on.
+
+The length gap is the guard as well as the measure. It comes from the feed and the file rather
+than from any detector, so it is the one number able to contradict one -- and a candidate set
+that claims half again as much advertising as the lengths say was inserted has found something
+other than advertising.
+*/
+
+func candidates(signal string, spans ...[2]int64) []Candidate {
+ out := make([]Candidate, 0, len(spans))
+ for _, s := range spans {
+ out = append(out, Candidate{StartMs: s[0], EndMs: s[1], Signals: []string{signal}})
+ }
+ return out
+}
+
+func TestSubtractedAdvertisingIsPublished(t *testing.T) {
+ // Darknet Diaries "Conti": 4:15 inserted, 4:20 found across three pods.
+ found := candidates(SignalDifference, [2]int64{0, 57_000}, [2]int64{164_000, 304_000},
+ [2]int64{1_928_000, 1_992_000})
+
+ got, why := Publishable(found, 3_813_000, 3_557_000)
+ if why != "" {
+ t.Fatalf("refused: %s", why)
+ }
+ if len(got) != 3 {
+ t.Errorf("got %d breaks, want 3", len(got))
+ }
+}
+
+/*
+Advertising recognised only by its copy is not published.
+
+It measured 0%, 0%, 15% and 29% of the length gap across the first real episodes, and on a show
+that bakes its reads in it found 2:13 where the lengths can see nothing at all. Useful as a
+hint beside the words; not something to seek past.
+*/
+func TestAdvertisingReadFromTheCopyIsNotPublished(t *testing.T) {
+ found := candidates("sponsor", [2]int64{60_000, 120_000})
+
+ got, why := Publishable(found, 1_480_000, 1_216_000)
+ if len(got) != 0 {
+ t.Errorf("published %d breaks found only by their wording", len(got))
+ }
+ if why == "" {
+ t.Error("said nothing about why")
+ }
+}
+
+/*
+Nothing is published for an episode the lengths say carries no inserted advertising.
+
+Without a gap there is no independent figure to check against, so a candidate set could claim
+anything. Those episodes do carry advertising -- baked into what the publisher serves -- and
+finding it is what reading the copy is for. Acting on it is a different matter.
+*/
+func TestNothingIsPublishedWithoutALengthGapToCheckAgainst(t *testing.T) {
+ found := candidates(SignalDifference, [2]int64{60_000, 120_000})
+
+ got, why := Publishable(found, 1_216_500, 1_216_000)
+ if len(got) != 0 {
+ t.Errorf("published %d breaks with nothing to check them against", len(got))
+ }
+ if why == "" {
+ t.Error("said nothing about why")
+ }
+}
+
+/*
+A candidate set claiming far more than was inserted has found something else.
+
+The one check the detector cannot talk its way out of: the feed says how long the programme is
+and the file says how long this copy is, and neither came from reading any words.
+*/
+func TestClaimingHalfAgainAsMuchAsWasInsertedIsRefused(t *testing.T) {
+ // 4:15 inserted, but the candidates come to 9:00.
+ found := candidates(SignalDifference, [2]int64{0, 540_000})
+
+ got, why := Publishable(found, 3_813_000, 3_557_000)
+ if len(got) != 0 {
+ t.Errorf("published %d breaks claiming more than twice what was inserted", len(got))
+ }
+ if why == "" {
+ t.Error("said nothing about why")
+ }
+}
+
+// The measured spread runs to 104%, and a little over is the music sting at the edge of a pod,
+// not a different kind of mistake.
+func TestAModestOvershootIsStillPublished(t *testing.T) {
+ // 4:15 inserted, 4:40 found -- 110%.
+ found := candidates(SignalDifference, [2]int64{0, 280_000})
+
+ got, why := Publishable(found, 3_813_000, 3_557_000)
+ if len(got) != 1 || why != "" {
+ t.Errorf("got %d breaks, refused %q", len(got), why)
+ }
+}
+
+func TestNothingFoundPublishesNothingAndSaysNothing(t *testing.T) {
+ got, why := Publishable(nil, 3_813_000, 3_557_000)
+ if len(got) != 0 {
+ t.Errorf("got %d breaks from nothing", len(got))
+ }
+ if why != "" {
+ t.Errorf("refused %q, but there was nothing to refuse", why)
+ }
+}