summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/signature/library.go45
-rw-r--r--server/internal/signature/library_test.go102
-rw-r--r--server/internal/signature/shadow.go138
-rw-r--r--server/internal/signature/shadow_test.go134
4 files changed, 410 insertions, 9 deletions
diff --git a/server/internal/signature/library.go b/server/internal/signature/library.go
index 92bf77b..0606c95 100644
--- a/server/internal/signature/library.go
+++ b/server/internal/signature/library.go
@@ -42,6 +42,10 @@ type Observation struct {
// Partners is how many episodes in this one batch carried the audio, as detection
// measured it. Within-batch corroboration, which is not the same thing as independence.
Partners int
+ // Confirmed is set when the listener said so himself -- pressing "this is an ad", or
+ // marking a break the detector found as right. The strongest evidence there is: detection
+ // infers from repetition, and he heard it.
+ Confirmed bool
// Occasion is what makes two sightings independent. A whole batch of episodes mirrored in
// one minute is one occasion, however many of them carried the audio -- they all ran
// whatever campaign was running that minute, which is exactly why eight agreeing episodes
@@ -57,6 +61,9 @@ type Entry struct {
DurationMs int64 `json:"durationMs"`
// Partners is the most episodes ever seen sharing this in a single batch.
Partners int `json:"partners,omitempty"`
+ // Confirmed is whether the listener ever said this is what it is. Never unset once set --
+ // he does not un-hear it next week.
+ Confirmed bool `json:"confirmed,omitempty"`
Fingerprint Fingerprint `json:"fingerprint"`
@@ -83,6 +90,11 @@ func (e *Entry) Confidence() int { return len(e.Occasions) }
// only ever propose advertising that detection had already found twice, which is precisely when
// it has nothing to add.
func (e *Entry) Trusted(minOccasions int) bool {
+ // Nothing outranks the listener. Detection and the occasion count are both ways of
+ // inferring what this audio is; he was there.
+ if e.Confirmed {
+ return true
+ }
if e.Confidence() >= minOccasions {
return true
}
@@ -126,6 +138,9 @@ func (l *Library) Observe(o Observation, atUnix int64) (*Entry, bool) {
if o.Partners > existing.Partners {
existing.Partners = o.Partners
}
+ if o.Confirmed {
+ existing.Confirmed = true
+ }
return existing, false
}
e := &Entry{
@@ -133,6 +148,7 @@ func (l *Library) Observe(o Observation, atUnix int64) (*Entry, bool) {
Kind: o.Kind,
DurationMs: o.DurationMs,
Partners: o.Partners,
+ Confirmed: o.Confirmed,
Fingerprint: o.Fingerprint,
Occasions: []string{o.Occasion},
FirstSeenUnix: atUnix,
@@ -213,6 +229,35 @@ func (l *Library) Age(nowUnix int64) int {
return dropped
}
+// Disown drops every signature matching this audio, and returns how many went.
+//
+// For audio the listener says is the programme. It is the worst failure this system has: a
+// signature built from programme would cut the programme, in every episode carrying it, until
+// somebody noticed. So his "no" removes it outright rather than lowering a score.
+func (l *Library) Disown(fp Fingerprint) int {
+ if len(fp.Hashes) == 0 {
+ return 0
+ }
+ doomed := map[string]bool{}
+ for _, hit := range l.index.Search(fp) {
+ if hit.Votes >= matchVotes {
+ doomed[hit.ID] = true
+ }
+ }
+ if len(doomed) == 0 {
+ return 0
+ }
+ kept := l.entries[:0]
+ for _, e := range l.entries {
+ if !doomed[e.ID] {
+ kept = append(kept, e)
+ }
+ }
+ l.entries = kept
+ l.reindex()
+ return len(doomed)
+}
+
// Forget drops the cues belonging to a show, for when it is unsubscribed. Copies are untouched:
// an advertisement heard in that show is not the show's.
func (l *Library) Forget(feedURL string) int {
diff --git a/server/internal/signature/library_test.go b/server/internal/signature/library_test.go
index 8290b19..8498c8d 100644
--- a/server/internal/signature/library_test.go
+++ b/server/internal/signature/library_test.go
@@ -269,3 +269,105 @@ func TestTheMostEpisodesEverSharingItIsWhatCounts(t *testing.T) {
t.Fatalf("partners %d -- a quieter week does not unmake what was measured before", e.Partners)
}
}
+
+// --- what the listener said -----------------------------------------------------------------
+
+// A listener pointing at advertising is the strongest evidence in the system. Detection infers
+// from repetition; he heard it.
+func TestAdvertisingTheListenerPointedAtIsTrustedAtOnce(t *testing.T) {
+ l := signature.NewLibrary()
+
+ e := observe(l, signature.Observation{
+ Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000,
+ Occasion: "batch-1", Confirmed: true,
+ }, day)
+
+ if !e.Trusted(2) {
+ t.Fatal("he heard it; there is nothing stronger to wait for")
+ }
+}
+
+func TestBeingPointedAtOutranksHavingNoCorroborationAtAll(t *testing.T) {
+ l := signature.NewLibrary()
+ e := observe(l, signature.Observation{
+ Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000,
+ Occasion: "batch-1",
+ }, day)
+ if e.Trusted(2) {
+ t.Fatal("precondition: an uncorroborated sighting is not trusted")
+ }
+
+ observe(l, signature.Observation{
+ Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000,
+ Occasion: "batch-2", Confirmed: true,
+ }, 8*day)
+
+ if !e.Trusted(2) {
+ t.Fatal("confirming a signature already held should make it trusted")
+ }
+}
+
+func TestConfirmationIsNotForgottenOnALaterUnconfirmedSighting(t *testing.T) {
+ l := signature.NewLibrary()
+ observe(l, signature.Observation{
+ Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000,
+ Occasion: "batch-1", Confirmed: true,
+ }, day)
+
+ e := observe(l, signature.Observation{
+ Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000,
+ Occasion: "batch-2",
+ }, 8*day)
+
+ if !e.Confirmed {
+ t.Fatal("he does not un-hear it next week")
+ }
+}
+
+// Furniture gets no shortcut from partners; being pointed at is different, because the listener
+// is not inferring from a batch that shares one campaign -- he is reporting what he heard.
+func TestFurnitureTheListenerPointedAtIsAlsoTrusted(t *testing.T) {
+ l := signature.NewLibrary()
+
+ e := observe(l, signature.Observation{
+ Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://x/f.xml",
+ DurationMs: 8_000, Occasion: "batch-1", Confirmed: true,
+ }, day)
+
+ if !e.Trusted(2) {
+ t.Fatal("a listener saying 'this is the show's own' is evidence, not inference")
+ }
+}
+
+// --- taking something back ------------------------------------------------------------------
+
+// The worst failure this system has: a signature built from audio that turns out to be the
+// programme would cut the programme, everywhere it appears, forever.
+func TestAudioTheListenerSaysIsTheProgrammeIsForgotten(t *testing.T) {
+ programme := tone(42, seconds(20))
+ l := signature.NewLibrary()
+ observe(l, signature.Observation{
+ Fingerprint: signature.Take(programme), Kind: signature.KindCopy,
+ DurationMs: 20_000, Occasion: "batch-1", Partners: 6,
+ }, day)
+
+ dropped := l.Disown(signature.Take(programme))
+
+ if dropped != 1 || l.Len() != 0 {
+ t.Fatalf("dropped %d, %d left -- a signature he says is the programme must go", dropped, l.Len())
+ }
+}
+
+func TestDisowningDoesNotTouchUnrelatedSignatures(t *testing.T) {
+ l := signature.NewLibrary()
+ observe(l, signature.Observation{
+ Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000,
+ Occasion: "batch-1", Partners: 6,
+ }, day)
+
+ dropped := l.Disown(fpOf(99, 20))
+
+ if dropped != 0 || l.Len() != 1 {
+ t.Fatalf("dropped %d of something else", dropped)
+ }
+}
diff --git a/server/internal/signature/shadow.go b/server/internal/signature/shadow.go
index 412597c..928743a 100644
--- a/server/internal/signature/shadow.go
+++ b/server/internal/signature/shadow.go
@@ -22,11 +22,25 @@ type ShadowBreak struct {
Kind string
}
-// ShadowEpisode is one episode of a batch and what detection made of it.
+// ShadowLabel is something the listener said about a stretch of this episode.
+type ShadowLabel struct {
+ StartMs int64
+ EndMs int64
+ // Advertising is true when he said this is an ad -- the notification button, or marking a
+ // break the detector found as right. False when he said it is the programme.
+ Advertising bool
+ // EndMeasured is false when the end is wherever the jump landed rather than where he heard
+ // the advertising stop. See [GuessedEndSeedMs].
+ EndMeasured bool
+}
+
+// ShadowEpisode is one episode of a batch, what detection made of it, and what the listener
+// said about it.
type ShadowEpisode struct {
ID string
FeedURL string
Breaks []ShadowBreak
+ Labels []ShadowLabel
}
// ShadowBatch is one detection pass over one show.
@@ -51,11 +65,22 @@ type Proposal struct {
Novel bool
}
+// GuessedEndSeedMs is how much of a reported advertisement is kept when its end was never
+// measured.
+//
+// An uncorrected end is wherever the 25-second jump happened to land, not where the advertising
+// stopped, so the tail of that range may be programme -- and a signature carrying programme is
+// the one thing this must never build. One ad unit from the start is far more audio than a
+// landmark fingerprint needs to be distinctive, and it is the part most certainly advertising.
+const GuessedEndSeedMs = int64(15_000)
+
// Report is what one shadow run saw.
type Report struct {
Harvested int
Reinforced int
- WouldCut []Proposal
+ // Disowned counts signatures dropped because the listener said that audio is the programme.
+ Disowned int
+ WouldCut []Proposal
// Skipped counts episodes whose audio could not be read. Reported rather than failing the
// run: one unreadable file must not cost the whole batch's learning.
Skipped int
@@ -116,11 +141,43 @@ func (s *Shadow) Run(ctx context.Context, batch ShadowBatch) (Report, error) {
report.Skipped = len(unreadable)
return report, ctx.Err()
}
+ // His "no" first, and it does two things: it stops the harvest below from storing
+ // audio he says is the programme, and it takes back anything already held that matches
+ // it. A signature built from programme would cut the programme in every episode
+ // carrying it, until somebody noticed.
+ for _, label := range ep.Labels {
+ if label.Advertising {
+ continue
+ }
+ if !s.disown(ctx, ep, label, &report) {
+ unreadable[ep.ID] = true
+ }
+ }
for _, br := range ep.Breaks {
+ if disowned(ep.Labels, br.StartMs, br.EndMs) {
+ continue
+ }
// A break this cannot read is counted and stepped over. Every other per-item loop
// in this server isolates a single failure the same way, and the first real run
// lost a whole show's learning to one episode whose path was wrong.
- if !s.harvest(ctx, batch, ep, br, &report) {
+ if !s.harvest(ctx, batch, ep, seed{
+ startMs: br.StartMs,
+ endMs: br.EndMs,
+ kind: kindFor(br.Kind),
+ partners: br.Partners,
+ confirmed: vouchedFor(ep.Labels, br.StartMs, br.EndMs),
+ }, &report) {
+ unreadable[ep.ID] = true
+ break
+ }
+ }
+ // Then what he pointed at himself. Advertising detection missed is the gap the library
+ // exists to close, so a report is harvested whether or not a break was found there.
+ for _, label := range ep.Labels {
+ if !label.Advertising || coveredByBreak(ep.Breaks, label) {
+ continue
+ }
+ if !s.harvest(ctx, batch, ep, seedFromLabel(label), &report) {
unreadable[ep.ID] = true
break
}
@@ -130,14 +187,76 @@ func (s *Shadow) Run(ctx context.Context, batch ShadowBatch) (Report, error) {
return report, nil
}
-// harvest stores one break's audio as a signature, and reports whether the episode could be
+// seed is one stretch about to become a signature.
+type seed struct {
+ startMs int64
+ endMs int64
+ kind string
+ partners int
+ confirmed bool
+}
+
+func seedFromLabel(label ShadowLabel) seed {
+ end := label.EndMs
+ if !label.EndMeasured && end-label.StartMs > GuessedEndSeedMs {
+ end = label.StartMs + GuessedEndSeedMs
+ }
+ // Always a copy: he presses the button when he hears an advertisement, and a show's own
+ // furniture is not what that button is for.
+ return seed{startMs: label.StartMs, endMs: end, kind: KindCopy, confirmed: true}
+}
+
+func overlaps(aStart, aEnd, bStart, bEnd int64) bool { return aStart < bEnd && aEnd > bStart }
+
+// disowned says whether the listener called this stretch the programme.
+func disowned(labels []ShadowLabel, startMs, endMs int64) bool {
+ for _, l := range labels {
+ if !l.Advertising && overlaps(startMs, endMs, l.StartMs, l.EndMs) {
+ return true
+ }
+ }
+ return false
+}
+
+// vouchedFor says whether the listener marked this break right.
+func vouchedFor(labels []ShadowLabel, startMs, endMs int64) bool {
+ for _, l := range labels {
+ if l.Advertising && overlaps(startMs, endMs, l.StartMs, l.EndMs) {
+ return true
+ }
+ }
+ return false
+}
+
+// coveredByBreak says whether detection already found this label's audio, in which case
+// harvesting the break covered it and the label only had to vouch for it.
+func coveredByBreak(breaks []ShadowBreak, label ShadowLabel) bool {
+ for _, br := range breaks {
+ if overlaps(label.StartMs, label.EndMs, br.StartMs, br.EndMs) {
+ return true
+ }
+ }
+ return false
+}
+
+// disown forgets every signature matching audio the listener says is the programme.
+func (s *Shadow) disown(ctx context.Context, ep ShadowEpisode, label ShadowLabel, report *Report) bool {
+ samples, err := s.audio.Range(ctx, ep.ID, label.StartMs, label.EndMs-label.StartMs)
+ if err != nil {
+ return false
+ }
+ report.Disowned += s.library.Disown(Take(samples))
+ return true
+}
+
+// harvest stores one stretch of audio as a signature, and reports whether the episode could be
// read at all. A false means give up on this episode, not on the batch.
-func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, br ShadowBreak, report *Report) bool {
- duration := br.EndMs - br.StartMs
+func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, sd seed, report *Report) bool {
+ duration := sd.endMs - sd.startMs
if duration <= 0 {
return true
}
- samples, err := s.audio.Range(ctx, ep.ID, br.StartMs, duration)
+ samples, err := s.audio.Range(ctx, ep.ID, sd.startMs, duration)
if err != nil {
return false
}
@@ -147,10 +266,11 @@ func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisod
}
_, isNew := s.library.Observe(Observation{
Fingerprint: fp,
- Kind: kindFor(br.Kind),
+ Kind: sd.kind,
FeedURL: ep.FeedURL,
DurationMs: duration,
- Partners: br.Partners,
+ Partners: sd.partners,
+ Confirmed: sd.confirmed,
Occasion: batch.Occasion,
}, batch.AtUnix)
if isNew {
diff --git a/server/internal/signature/shadow_test.go b/server/internal/signature/shadow_test.go
index f6c6676..03fcab9 100644
--- a/server/internal/signature/shadow_test.go
+++ b/server/internal/signature/shadow_test.go
@@ -233,3 +233,137 @@ func TestOneUnreadableEpisodeDoesNotCostTheBatchItsLearning(t *testing.T) {
t.Fatalf("skipped %d, want 1 -- an unreadable file should be counted, not silent", report.Skipped)
}
}
+
+// --- acting on what the listener said --------------------------------------------------------
+
+// The gap the library exists to close. Detection accounts for roughly 38% of the advertising
+// the feeds imply, so most of what gets heard was never found -- and a report is the listener
+// pointing straight at a piece of it.
+func TestHarvestsAdvertisingTheListenerReportedWhereDetectionFoundNothing(t *testing.T) {
+ ad := tone(42, seconds(20))
+ audio := fakeAudio{"e1": planted(1, ad, 300, 60)}
+ l := signature.NewLibrary()
+
+ report, err := signature.NewShadow(l, audio).Run(context.Background(), batch(
+ signature.ShadowEpisode{ID: "e1", Labels: []signature.ShadowLabel{
+ {StartMs: 60_000, EndMs: 80_000, Advertising: true, EndMeasured: true},
+ }},
+ ))
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+
+ if report.Harvested != 1 {
+ t.Fatalf("harvested %d from a report, want 1", report.Harvested)
+ }
+ if !l.Confident(1)[0].Confirmed {
+ t.Fatal("a reported advertisement is confirmed, which is what makes it trusted at once")
+ }
+}
+
+// An uncorrected end is wherever the 25-second jump happened to land, not where the advertising
+// stopped. A signature cut to it would carry programme audio into the thing meant to identify
+// advertising, so only the opening is taken -- enough to recognise, short enough to be safe.
+func TestAReportWithAGuessedEndIsTrimmedToItsOpening(t *testing.T) {
+ ad := tone(42, seconds(25))
+ audio := fakeAudio{"e1": planted(1, ad, 300, 60)}
+ l := signature.NewLibrary()
+
+ signature.NewShadow(l, audio).Run(context.Background(), batch(
+ signature.ShadowEpisode{ID: "e1", Labels: []signature.ShadowLabel{
+ {StartMs: 60_000, EndMs: 85_000, Advertising: true, EndMeasured: false},
+ }},
+ ))
+
+ got := l.Confident(1)[0].DurationMs
+ if got != signature.GuessedEndSeedMs {
+ t.Fatalf("stored %dms of a guessed range, want the %dms opening", got, signature.GuessedEndSeedMs)
+ }
+}
+
+func TestAReportWhoseEndWasHeardIsTakenWhole(t *testing.T) {
+ ad := tone(42, seconds(20))
+ audio := fakeAudio{"e1": planted(1, ad, 300, 60)}
+ l := signature.NewLibrary()
+
+ signature.NewShadow(l, audio).Run(context.Background(), batch(
+ signature.ShadowEpisode{ID: "e1", Labels: []signature.ShadowLabel{
+ {StartMs: 60_000, EndMs: 80_000, Advertising: true, EndMeasured: true},
+ }},
+ ))
+
+ if got := l.Confident(1)[0].DurationMs; got != 20_000 {
+ t.Fatalf("stored %dms, want the whole 20000ms he measured", got)
+ }
+}
+
+// The worst failure this system has. A signature built from programme would cut the programme
+// in every episode carrying it, so his "no" has to stop the harvest, not merely score it down.
+func TestABreakTheListenerCalledTheProgrammeIsNotHarvested(t *testing.T) {
+ programme := tone(42, seconds(20))
+ audio := fakeAudio{"e1": planted(1, programme, 300, 60)}
+ l := signature.NewLibrary()
+
+ report, _ := signature.NewShadow(l, audio).Run(context.Background(), batch(
+ signature.ShadowEpisode{
+ ID: "e1",
+ Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad", Partners: 6}},
+ Labels: []signature.ShadowLabel{{StartMs: 60_000, EndMs: 80_000, Advertising: false}},
+ },
+ ))
+
+ if report.Harvested != 0 || l.Len() != 0 {
+ t.Fatalf("harvested %d and holds %d -- he said it is the programme", report.Harvested, l.Len())
+ }
+}
+
+func TestSayingABreakIsTheProgrammeTakesBackASignatureAlreadyHeld(t *testing.T) {
+ programme := tone(42, seconds(20))
+ l := signature.NewLibrary()
+
+ // Week one: detection finds it and the library learns it.
+ signature.NewShadow(l, fakeAudio{"e1": planted(1, programme, 300, 60)}).Run(
+ context.Background(), signature.ShadowBatch{Occasion: "week-1", AtUnix: day,
+ Episodes: []signature.ShadowEpisode{{ID: "e1",
+ Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad", Partners: 6}}}}})
+ if l.Len() != 1 {
+ t.Fatalf("precondition: expected one signature, got %d", l.Len())
+ }
+
+ // Week two: he taps the mark and says it is the programme.
+ report, _ := signature.NewShadow(l, fakeAudio{"e2": planted(3, programme, 300, 120)}).Run(
+ context.Background(), signature.ShadowBatch{Occasion: "week-2", AtUnix: 8 * day,
+ Episodes: []signature.ShadowEpisode{{ID: "e2",
+ Labels: []signature.ShadowLabel{{StartMs: 120_000, EndMs: 140_000, Advertising: false}}}}})
+
+ if l.Len() != 0 {
+ t.Fatalf("still holds %d signatures he has disowned", l.Len())
+ }
+ if report.Disowned != 1 {
+ t.Fatalf("reported %d disowned, want 1", report.Disowned)
+ }
+}
+
+// Marking a break right is agreement, not new audio: the break is harvested anyway, and what
+// the mark adds is that a person vouched for it.
+func TestMarkingABreakRightConfirmsTheSignatureItCameFrom(t *testing.T) {
+ ad := tone(42, seconds(20))
+ audio := fakeAudio{"e1": planted(1, ad, 300, 60)}
+ l := signature.NewLibrary()
+
+ signature.NewShadow(l, audio).Run(context.Background(), batch(
+ signature.ShadowEpisode{
+ ID: "e1",
+ Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad", Partners: 1}},
+ Labels: []signature.ShadowLabel{{StartMs: 60_000, EndMs: 80_000, Advertising: true, EndMeasured: true}},
+ },
+ ))
+
+ entries := l.Confident(1)
+ if len(entries) != 1 {
+ t.Fatalf("got %d signatures, want one", len(entries))
+ }
+ if !entries[0].Confirmed {
+ t.Fatal("he vouched for this break; that is what the mark is for")
+ }
+}