summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/score/score.go117
-rw-r--r--server/internal/score/score_test.go146
2 files changed, 263 insertions, 0 deletions
diff --git a/server/internal/score/score.go b/server/internal/score/score.go
new file mode 100644
index 0000000..34f3e0c
--- /dev/null
+++ b/server/internal/score/score.go
@@ -0,0 +1,117 @@
+// Package score measures the detectors against what the listener actually heard.
+//
+// Everything else in this project is checked against the declared-versus-served length gap,
+// which says how *much* advertising a copy carries and nothing about where. A detector could
+// find the right total in the wrong places and score 100%.
+//
+// Labels are the only statement about *where* that no detector produced: each one is the
+// listener pressing a button at a moment in an episode, saying "this is an advertisement" where
+// the detectors missed one, or "this is not" where they cut something that was not.
+package score
+
+import "fmt"
+
+// Span is a stretch of an episode, in milliseconds.
+type Span struct {
+ StartMs int64
+ EndMs int64
+}
+
+// Label is what the listener said about one span.
+type Label struct {
+ Span
+ // IsAd is true for "this is an advertisement", false for "this is the programme".
+ IsAd bool
+ // EndMeasured says whether the end of the span is something the listener heard or where a
+ // jump happened to land. A report starts as a guess at the end and only becomes measured
+ // if he comes back and trims it -- the store documents this, and scoring that ignores it
+ // reads a break ending one second early as a complete miss.
+ EndMeasured bool
+}
+
+// Summary is how a set of breaks did against a set of labels.
+//
+// The two kinds of mistake are counted separately and never netted off, because they are not
+// equally bad: an advertisement nobody found means sitting through it, and a break over the
+// programme means losing part of the show with no undo.
+type Summary struct {
+ // AdsFound is advertising the listener pointed at that a break now covers entirely.
+ AdsFound int
+ // AdsMissed is advertising still not covered, or covered only in part -- the listener
+ // hears whatever is left either way.
+ AdsMissed int
+ // ProgrammeCut is programme the listener said was programme that a break runs into.
+ ProgrammeCut int
+ // ProgrammeKept is programme no break touches.
+ ProgrammeKept int
+}
+
+// Score measures breaks against labels.
+func Score(labels []Label, breaks []Span) Summary {
+ var out Summary
+ for _, label := range labels {
+ if label.IsAd {
+ if coveredBy(label, breaks) {
+ out.AdsFound++
+ } else {
+ out.AdsMissed++
+ }
+ continue
+ }
+ if overlapsAny(label.Span, breaks) {
+ out.ProgrammeCut++
+ } else {
+ out.ProgrammeKept++
+ }
+ }
+ return out
+}
+
+/*
+coveredBy reports whether a break covers an advertisement the listener pointed at.
+
+What "covers" means depends on whether the label's end was measured. The start always is: it is
+where he pressed the button, so an advertisement was certainly playing there. The end often is
+not -- it is where the jump happened to land -- and requiring a break to reach a guessed end
+reads a one-second shortfall as a complete miss. On a real episode that turned four correct
+detections into one.
+
+So a measured label must be covered end to end, and a guessed one must cover the moment he
+actually heard it.
+*/
+func coveredBy(l Label, breaks []Span) bool {
+ for _, b := range breaks {
+ if b.StartMs > l.StartMs {
+ continue
+ }
+ if l.EndMeasured && b.EndMs < l.EndMs {
+ continue
+ }
+ if b.EndMs <= l.StartMs {
+ continue
+ }
+ return true
+ }
+ return false
+}
+
+// overlapsAny reports whether any break runs into a span. Touching at an edge does not count: a
+// break that ends exactly where the programme resumes is correct, and is the common case once
+// its edges have been trimmed.
+func overlapsAny(s Span, breaks []Span) bool {
+ for _, b := range breaks {
+ if b.StartMs < s.EndMs && b.EndMs > s.StartMs {
+ return true
+ }
+ }
+ return false
+}
+
+// String reads the score as a sentence, so a run can be understood without counting.
+func (s Summary) String() string {
+ ads := s.AdsFound + s.AdsMissed
+ programme := s.ProgrammeCut + s.ProgrammeKept
+ return fmt.Sprintf(
+ "%d of %d reported advertisements are now covered; %d of %d stretches called programme "+
+ "are cut into", s.AdsFound, ads, s.ProgrammeCut, programme)
+}
diff --git a/server/internal/score/score_test.go b/server/internal/score/score_test.go
new file mode 100644
index 0000000..afa0039
--- /dev/null
+++ b/server/internal/score/score_test.go
@@ -0,0 +1,146 @@
+package score
+
+import "testing"
+
+/*
+Scoring the detectors against what the listener actually heard.
+
+Everything else in this project is checked against the declared-versus-served length gap, which
+says how *much* advertising a copy carries and nothing about where. A detector could find the
+right total in the wrong places and score 100%. The position check I wrote earlier used the same
+signal that builds the candidates, so it was partly circular and structurally blind to the one
+failure it most needed to see.
+
+Labels are not. Each one is the listener pressing a button at a moment in an episode: "this is
+an advertisement" when the detectors missed one, or "this is not" when they cut something that
+was not. They are the only statement about *where* that no detector produced.
+
+The arithmetic is deliberately asymmetric, because the two mistakes are not equally bad. An
+advertisement nobody found means sitting through it. A break over the programme means losing
+part of the show, and there is no undo.
+*/
+
+func span(startMs, endMs int64) Span { return Span{StartMs: startMs, EndMs: endMs} }
+
+func TestAnAdvertisingLabelCoveredByABreakIsFound(t *testing.T) {
+ got := Score([]Label{{Span: span(60_000, 90_000), IsAd: true, EndMeasured: true}},
+ []Span{span(55_000, 95_000)})
+
+ if got.AdsFound != 1 || got.AdsMissed != 0 {
+ t.Errorf("got %+v, want the advertisement counted as found", got)
+ }
+}
+
+// A measured end is one the listener heard, so a break stopping short of it leaves him hearing
+// the rest.
+func TestAMeasuredAdvertisingLabelOnlyPartlyCoveredIsMissed(t *testing.T) {
+ got := Score([]Label{{Span: span(60_000, 90_000), IsAd: true, EndMeasured: true}},
+ []Span{span(55_000, 70_000)})
+
+ if got.AdsFound != 0 || got.AdsMissed != 1 {
+ t.Errorf("got %+v, want it missed -- twenty seconds of it still play", got)
+ }
+}
+
+/*
+A guessed end is not something to score against.
+
+Twelve of the nineteen real labels have one: the listener pressed "this is an advertisement",
+the player jumped, and the end of the label is wherever it landed. Requiring a break to reach
+that point read a break ending one second early as a complete miss -- on Heavyweight's "#70
+Cayden", breaks at 829-999 and 1360-1519 against labels at 853-1000 and 1489-1540, three
+correct detections scored as failures.
+
+What the start says is solid: an advertisement was playing at that moment.
+*/
+func TestAGuessedEndIsNotRequiredToBeCovered(t *testing.T) {
+ got := Score([]Label{{Span: span(853_000, 1_000_000), IsAd: true}},
+ []Span{span(829_000, 999_000)})
+
+ if got.AdsFound != 1 {
+ t.Errorf("got %+v, want it found -- the break covers where he heard it", got)
+ }
+}
+
+// The start still has to be covered. A break that ends before the listener heard anything has
+// not found what he pointed at.
+func TestABreakEndingBeforeTheListenerHeardItIsStillAMiss(t *testing.T) {
+ got := Score([]Label{{Span: span(853_000, 1_000_000), IsAd: true}},
+ []Span{span(600_000, 700_000)})
+
+ if got.AdsMissed != 1 {
+ t.Errorf("got %+v, want it missed", got)
+ }
+}
+
+func TestAnAdvertisingLabelWithNoBreakNearItIsMissed(t *testing.T) {
+ got := Score([]Label{{Span: span(60_000, 90_000), IsAd: true, EndMeasured: true}}, nil)
+
+ if got.AdsMissed != 1 {
+ t.Errorf("got %+v", got)
+ }
+}
+
+/*
+A break over something the listener said was the programme is the mistake that matters.
+
+Counted separately and never netted off against advertising found. A detector that skips ten
+advertisements and one minute of the show has not scored ten out of eleven.
+*/
+func TestABreakOverTheProgrammeIsCountedAgainstTheDetector(t *testing.T) {
+ got := Score([]Label{{Span: span(600_000, 660_000), IsAd: false}},
+ []Span{span(590_000, 700_000)})
+
+ if got.ProgrammeCut != 1 {
+ t.Errorf("got %+v, want the programme cut counted", got)
+ }
+}
+
+// Touching a programme label at its very edge is not cutting into it. A break that ends exactly
+// where the programme begins is correct, and the common case after trimming.
+func TestABreakThatOnlyTouchesTheProgrammeIsNotCountingAgainstIt(t *testing.T) {
+ got := Score([]Label{{Span: span(600_000, 660_000), IsAd: false}},
+ []Span{span(540_000, 600_000)})
+
+ if got.ProgrammeCut != 0 {
+ t.Errorf("got %+v, want no cut -- the break ends where the programme starts", got)
+ }
+}
+
+func TestAProgrammeLabelNoBreakTouchesIsLeftAlone(t *testing.T) {
+ got := Score([]Label{{Span: span(600_000, 660_000), IsAd: false}},
+ []Span{span(60_000, 90_000)})
+
+ if got.ProgrammeCut != 0 || got.ProgrammeKept != 1 {
+ t.Errorf("got %+v", got)
+ }
+}
+
+func TestScoringNothingSaysNothing(t *testing.T) {
+ got := Score(nil, []Span{span(0, 1000)})
+ if got.AdsFound+got.AdsMissed+got.ProgrammeCut+got.ProgrammeKept != 0 {
+ t.Errorf("got %+v, want an empty score", got)
+ }
+}
+
+// What the summary line says, so a run can be read without counting.
+func TestTheScoreReadsAsASentence(t *testing.T) {
+ got := Summary{AdsFound: 3, AdsMissed: 1, ProgrammeCut: 0, ProgrammeKept: 2}.String()
+ if got == "" {
+ t.Fatal("no summary")
+ }
+ for _, want := range []string{"3", "4", "advertis"} {
+ if !contains(got, want) {
+ t.Errorf("summary %q does not mention %q", got, want)
+ }
+ }
+}
+
+func contains(haystack, needle string) bool {
+ for i := 0; i+len(needle) <= len(haystack); i++ {
+ if haystack[i:i+len(needle)] == needle {
+ return true
+ }
+ }
+ return false
+}