From 50b52d58e9865ac01e5364ec68247edb323db9b7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 27 Oct 2026 07:54:25 +0000 Subject: Use the labels: seed signatures from them, and take them back The labels have been arriving and sitting there. Now the shadow run acts on all three kinds. A report is advertising pointed at where detection found nothing, so it becomes a signature in its own right -- that being the whole gap the library exists to close, since the scan accounts for about 38% of what the feeds imply is there. A mark saying a break is right adds no audio, because the break is harvested anyway. What it adds is that a person vouched for it. A mark saying a break is wrong does two things. It stops that audio being harvested, and it takes back any signature already held that matches it. That second half is the one that matters: 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. Being pointed at outranks partners and occasions both. Those are ways of inferring what a stretch of audio is; he was there. A confirmed signature is trusted at once and stays confirmed -- he does not un-hear it next week. A report whose end was never corrected is trimmed to its first fifteen seconds. An uncorrected end is wherever the 25-second jump landed rather than where the advertising stopped, so its tail may be programme -- and one ad unit is already far more audio than a landmark fingerprint needs to be distinctive. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- ...2026-10-20-corrections-and-signature-library.md | 23 ++++ server/cmd/listend/shadow.go | 36 +++++- server/internal/signature/library.go | 45 +++++++ server/internal/signature/library_test.go | 102 +++++++++++++++ server/internal/signature/shadow.go | 138 +++++++++++++++++++-- server/internal/signature/shadow_test.go | 134 ++++++++++++++++++++ 6 files changed, 464 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md index 687a293..289ffc8 100644 --- a/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md +++ b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md @@ -206,6 +206,29 @@ on the wrong side of a kill. Gated on `audioFromServer`, as the design note required. +**The labels are used now — 2026-10-27.** The shadow run reads them and acts on three things. + +A **report** is advertising the listener pointed at where detection found nothing, so it is +harvested as a signature in its own right. That is the gap the library exists to close: the scan +accounts for roughly 38% of what the feeds imply, and a report is a finger on a piece of the rest. + +A **mark saying a break is right** does not add audio — the break is harvested anyway — so what +it adds is that a person vouched for it. + +A **mark saying a break is wrong** does two things, and both matter. It stops that audio being +harvested, and it takes back any signature already held that matches it. A signature built from +programme would cut the programme in every episode carrying it until somebody noticed, which is +the worst failure this system has; his "no" removes it rather than lowering a score. + +**Being pointed at outranks everything else.** Detection and the occasion count are both ways of +*inferring* what a stretch of audio is. He was there. So a confirmed signature is trusted at +once, and the confirmation is never unset — he does not un-hear it next week. + +**A report with a guessed end is trimmed to its first 15 seconds.** An uncorrected end is +wherever the jump landed, not where the advertising stopped, so its tail may be programme. One ad +unit is far more audio than a landmark fingerprint needs and is the part most certainly +advertising. + ### 3. The index spike — DONE 2026-10-24. **It passes.** **Do this before anything else in the library, because it decides whether the library is diff --git a/server/cmd/listend/shadow.go b/server/cmd/listend/shadow.go index 7531115..03f4b71 100644 --- a/server/cmd/listend/shadow.go +++ b/server/cmd/listend/shadow.go @@ -56,8 +56,24 @@ func (w *worker) runShadow(ctx context.Context, show *store.Show, episodes []*st Occasion: show.FeedURL + "@" + show.DetectedBatch, AtUnix: time.Now().Unix(), } + // What the listener has said, indexed so each episode can pick up its own. Labels outlive + // the episodes they were given on, so most of these belong to batches long since rotated + // out; only the ones matching this batch's guids are of any use here. + labels := map[string][]signature.ShadowLabel{} + for _, l := range w.state.Labels() { + if l.FeedURL != show.FeedURL { + continue + } + labels[l.GUID] = append(labels[l.GUID], signature.ShadowLabel{ + StartMs: l.StartMs, + EndMs: l.EndMs, + Advertising: l.Verdict == store.LabelAd, + EndMeasured: l.EndMeasured, + }) + } + for _, e := range episodes { - ep := signature.ShadowEpisode{ID: e.ID, FeedURL: show.FeedURL} + ep := signature.ShadowEpisode{ID: e.ID, FeedURL: show.FeedURL, Labels: labels[e.GUID]} for _, br := range e.Breaks { ep.Breaks = append(ep.Breaks, signature.ShadowBreak{ StartMs: br.StartMs, @@ -81,11 +97,12 @@ func (w *worker) runShadow(ctx context.Context, show *store.Show, episodes []*st novel++ } } - log.Printf("%s: shadow -- learned %d, reinforced %d, library %d (%d worth matching); "+ + log.Printf("%s: shadow -- learned %d, reinforced %d%s, library %d (%d worth matching); "+ "would cut %d, %d of them new%s, took %s", - show.Title, report.Harvested, report.Reinforced, w.signatures.Len(), - len(w.signatures.Trusted(shadowMinOccasions)), len(report.WouldCut), novel, - skippedNote(report.Skipped), time.Since(started).Round(time.Second)) + show.Title, report.Harvested, report.Reinforced, disownedNote(report.Disowned), + w.signatures.Len(), len(w.signatures.Trusted(shadowMinOccasions)), + len(report.WouldCut), novel, skippedNote(report.Skipped), + time.Since(started).Round(time.Second)) // The novel ones are the whole point: what the library would add to what detection already // publishes. Logged individually so they can be listened to and judged. @@ -104,6 +121,15 @@ func (w *worker) runShadow(ctx context.Context, show *store.Show, episodes []*st } } +// disownedNote reports signatures taken back because the listener said that audio is the +// programme. Always logged when it happens: it is the library being told it was wrong. +func disownedNote(n int) string { + if n == 0 { + return "" + } + return fmt.Sprintf(", disowned %d", n) +} + func skippedNote(n int) string { if n == 0 { return "" 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") + } +} -- cgit v1.2.3