summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/cmd/listen-score/main.go105
-rw-r--r--server/cmd/listend/main.go7
-rw-r--r--server/internal/score/score.go117
-rw-r--r--server/internal/score/score_test.go146
4 files changed, 374 insertions, 1 deletions
diff --git a/server/cmd/listen-score/main.go b/server/cmd/listen-score/main.go
new file mode 100644
index 0000000..f4b0ba7
--- /dev/null
+++ b/server/cmd/listen-score/main.go
@@ -0,0 +1,105 @@
+/*
+Command listen-score measures the detectors against what the listener actually heard.
+
+Every other number in this project comes from the declared-versus-served length gap, which says
+how much advertising a copy carries and nothing about where. This is the only check on *where*:
+each label 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.
+
+Read-only. It opens the server's state file, scores what is published against the labels in it,
+and prints. It changes nothing and is safe to run against a live server.
+*/
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/terst/listen/server/internal/score"
+ "github.com/terst/listen/server/internal/store"
+)
+
+func main() {
+ data := flag.String("data", "/site/listen.terst.org/data", "server data directory")
+ verbose := flag.Bool("v", false, "print every label and how it scored")
+ asJSON := flag.Bool("json", false, "print the summary as JSON")
+ flag.Parse()
+
+ state, err := store.Load(filepath.Join(*data, "state.json"))
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "listen-score: %v\n", err)
+ os.Exit(1)
+ }
+
+ var total score.Summary
+ matched, unmatched := 0, 0
+
+ for _, label := range state.Labels() {
+ episode := state.Match(label.FeedURL, label.GUID, "", "")
+ if episode == nil {
+ // The mirror keeps eight episodes a show, so a label outlives the audio it
+ // describes. Counted rather than ignored: a corpus that has mostly aged out is a
+ // different thing from a corpus the detectors do badly on.
+ unmatched++
+ continue
+ }
+ matched++
+
+ // Scored against this episode's own breaks. Pooling them would credit one episode's
+ // detection to another episode's label.
+ breaks := make([]score.Span, 0, 4)
+ for _, b := range episode.AllBreaks() {
+ breaks = append(breaks, score.Span{StartMs: b.StartMs, EndMs: b.EndMs})
+ }
+ one := score.Score([]score.Label{{
+ Span: score.Span{StartMs: label.StartMs, EndMs: label.EndMs},
+ IsAd: label.Verdict == store.LabelAd,
+ EndMeasured: label.EndMeasured,
+ }}, breaks)
+
+ total.AdsFound += one.AdsFound
+ total.AdsMissed += one.AdsMissed
+ total.ProgrammeCut += one.ProgrammeCut
+ total.ProgrammeKept += one.ProgrammeKept
+
+ if *verbose {
+ fmt.Printf("%-6s %-9s %6.0fs-%-6.0fs %d break(s) %.44s\n",
+ outcome(one), label.Verdict,
+ float64(label.StartMs)/1000, float64(label.EndMs)/1000,
+ len(breaks), episode.Title)
+ }
+ }
+
+ if *asJSON {
+ out := struct {
+ score.Summary
+ Labels int `json:"labels"`
+ Matched int `json:"matched"`
+ Unmatched int `json:"unmatched"`
+ }{total, matched + unmatched, matched, unmatched}
+ blob, _ := json.MarshalIndent(out, "", " ")
+ fmt.Println(string(blob))
+ return
+ }
+
+ fmt.Printf("\n%d label(s): %d against an episode still mirrored, %d outlived their audio\n",
+ matched+unmatched, matched, unmatched)
+ fmt.Println(total.String())
+}
+
+func outcome(s score.Summary) string {
+ switch {
+ case s.AdsFound > 0:
+ return "found"
+ case s.AdsMissed > 0:
+ return "MISSED"
+ case s.ProgrammeCut > 0:
+ return "CUT"
+ default:
+ return "kept"
+ }
+}
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index 6cbfb65..377fedc 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -656,7 +656,12 @@ func (w *worker) findRecurring(show *store.Show) {
published, refused := 0, 0
for i, target := range corpus {
- if target.episode.MasterCues > 0 {
+ // Skipped only where subtraction actually produced something, not merely where a
+ // master exists. A refetch master that carried the same campaign yields no candidates
+ // at all, and treating "has a master" as "has a better answer" left those episodes
+ // with nothing -- Tangle's "The Israeli elections" has a 229-second gap, a refetch
+ // master, no subtraction candidates, and was excluded from this.
+ if len(target.episode.WordBreaks) > 0 {
continue
}
others := make([][]transcript.Cue, 0, len(corpus)-1)
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
+}