diff options
Diffstat (limited to 'server/internal')
| -rw-r--r-- | server/internal/signature/library.go | 45 | ||||
| -rw-r--r-- | server/internal/signature/library_test.go | 102 | ||||
| -rw-r--r-- | server/internal/signature/shadow.go | 138 | ||||
| -rw-r--r-- | server/internal/signature/shadow_test.go | 134 |
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") + } +} |
