0.2.64 update
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the household already told us, without anything having to read a media file.
|
||||
//
|
||||
// This is the cheapest evidence in the package and on a well-watched show it is also the
|
||||
// best. Where three people independently stopped an episode within a few seconds of each
|
||||
// other, near the end but not at it, they stopped for a reason, and the reason is that the
|
||||
// story finished and the credits started. Tracearr has recorded every one of those stops
|
||||
// already — so this costs one indexed query, no file access, and no new writes.
|
||||
|
||||
const (
|
||||
// behaviourFloor is how far into the file a stop has to be before it says anything about
|
||||
// credits. The same three-quarters the television's own chapter-name rule uses, and for
|
||||
// the same reason: somebody abandoning an episode twenty minutes in is telling us they
|
||||
// did not like it, not where the credits are.
|
||||
behaviourFloor = 0.75
|
||||
|
||||
// behaviourTailGuard is how close to the end counts as "watched it out". Those sessions
|
||||
// are the majority and they carry no positional information at all — a viewer who sat
|
||||
// through the credits stopped at the end of the file, wherever the credits began.
|
||||
behaviourTailGuard = 15 * time.Second
|
||||
|
||||
// behaviourCluster is how far apart two stops can be and still be the same moment.
|
||||
// Players report progress on a timer, so two people leaving at the same cut are
|
||||
// routinely a few seconds apart in the record.
|
||||
behaviourCluster = 30 * time.Second
|
||||
|
||||
// BehaviourMinUsers is what it takes to write a marker on behaviour alone. Three
|
||||
// independent people agreeing to within half a minute is not a coincidence; two might
|
||||
// be a couple watching one television twice, or one person on two devices.
|
||||
BehaviourMinUsers = 3
|
||||
|
||||
// behaviourNarrowMinUsers is the weaker bar for merely *narrowing* a scan. Being wrong
|
||||
// about where to look costs a wider scan; being wrong about a marker costs somebody the
|
||||
// end of the episode, so the two bars are deliberately different.
|
||||
behaviourNarrowMinUsers = 2
|
||||
)
|
||||
|
||||
// StopEvent is one viewer leaving one episode, reduced to what matters. Built from Tracearr
|
||||
// sessions that are already in Postgres — this subsystem records nothing of its own.
|
||||
type StopEvent struct {
|
||||
UserKey string
|
||||
PositionMs int64
|
||||
RuntimeMs int64
|
||||
// NextEpisode marks a stop that ran straight into the following episode. It is the
|
||||
// strongest form of this signal: somebody who pressed next was unambiguously looking at
|
||||
// credits rather than deciding to go to bed.
|
||||
NextEpisode bool
|
||||
}
|
||||
|
||||
// BehaviourEvidence is what a set of stops came to.
|
||||
type BehaviourEvidence struct {
|
||||
Found bool
|
||||
StartMs int64
|
||||
// UserCount is distinct viewers in the winning cluster, which is the whole strength of
|
||||
// the finding: the same person stopping in the same place four times is one observation.
|
||||
UserCount int
|
||||
// NextEpisodeCount is how many of them rolled into the next episode.
|
||||
NextEpisodeCount int
|
||||
// SpreadMs is how tightly they agreed. A cluster three seconds wide is worth more than
|
||||
// one twenty-eight seconds wide, and confidence reads it.
|
||||
SpreadMs int64
|
||||
}
|
||||
|
||||
// Usable means this is good enough to steer a scan.
|
||||
func (e BehaviourEvidence) Usable() bool {
|
||||
return e.Found && e.UserCount >= behaviourNarrowMinUsers && e.StartMs > 0
|
||||
}
|
||||
|
||||
// StandaloneMarker means this is good enough to *be* a marker, with no media read at all.
|
||||
// The bar is higher than Usable's, and a next-episode transition is what lets a two-viewer
|
||||
// cluster clear it: pressing next is an explicit statement that the episode had ended.
|
||||
func (e BehaviourEvidence) StandaloneMarker() bool {
|
||||
if !e.Found || e.StartMs <= 0 {
|
||||
return false
|
||||
}
|
||||
if e.UserCount >= BehaviourMinUsers {
|
||||
return true
|
||||
}
|
||||
return e.UserCount >= behaviourNarrowMinUsers && e.NextEpisodeCount >= e.UserCount
|
||||
}
|
||||
|
||||
// AnalyseStops finds the moment a household agrees an episode ends.
|
||||
//
|
||||
// A sliding cluster rather than an average of everything: the tail of an episode contains
|
||||
// two quite different populations — people who left at the credits and people who left part
|
||||
// way through them — and averaging the two produces a position that is inside the credits
|
||||
// but later than they began, which is the one failure mode that shows a Skip Credits button
|
||||
// too late to be useful. The tightest agreement is the transition; the stragglers are noise.
|
||||
func AnalyseStops(stops []StopEvent, runtimeMs int64) BehaviourEvidence {
|
||||
if runtimeMs <= 0 || len(stops) == 0 {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
floor := int64(float64(runtimeMs) * behaviourFloor)
|
||||
ceiling := runtimeMs - behaviourTailGuard.Milliseconds()
|
||||
if ceiling <= floor {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
|
||||
// One observation per viewer: the earliest qualifying stop they made. Somebody who
|
||||
// stopped at the credits, resumed, and stopped again at the very end has told us where
|
||||
// the credits were exactly once.
|
||||
earliest := map[string]StopEvent{}
|
||||
for _, stop := range stops {
|
||||
if stop.PositionMs < floor || stop.PositionMs > ceiling {
|
||||
continue
|
||||
}
|
||||
previous, seen := earliest[stop.UserKey]
|
||||
if !seen || stop.PositionMs < previous.PositionMs {
|
||||
// A next-episode transition is never downgraded by a later plain stop from the
|
||||
// same viewer, but an earlier plain stop still wins on position.
|
||||
stop.NextEpisode = stop.NextEpisode || (seen && previous.NextEpisode &&
|
||||
previous.PositionMs == stop.PositionMs)
|
||||
earliest[stop.UserKey] = stop
|
||||
} else if stop.NextEpisode && previous.PositionMs-stop.PositionMs <= 0 {
|
||||
previous.NextEpisode = true
|
||||
earliest[stop.UserKey] = previous
|
||||
}
|
||||
}
|
||||
if len(earliest) < behaviourNarrowMinUsers {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
|
||||
observations := make([]StopEvent, 0, len(earliest))
|
||||
for _, stop := range earliest {
|
||||
observations = append(observations, stop)
|
||||
}
|
||||
sort.Slice(observations, func(a, b int) bool {
|
||||
return observations[a].PositionMs < observations[b].PositionMs
|
||||
})
|
||||
|
||||
// The widest cluster wins, and the earliest of equally wide ones — the credits began at
|
||||
// the first moment the household agreed on, not the last.
|
||||
tolerance := behaviourCluster.Milliseconds()
|
||||
best := BehaviourEvidence{}
|
||||
for start := range observations {
|
||||
end := start
|
||||
for end+1 < len(observations) &&
|
||||
observations[end+1].PositionMs-observations[start].PositionMs <= tolerance {
|
||||
end++
|
||||
}
|
||||
count := end - start + 1
|
||||
if count < behaviourNarrowMinUsers || count < best.UserCount {
|
||||
continue
|
||||
}
|
||||
spread := observations[end].PositionMs - observations[start].PositionMs
|
||||
if count == best.UserCount && spread >= best.SpreadMs {
|
||||
continue
|
||||
}
|
||||
nextCount := 0
|
||||
for _, stop := range observations[start : end+1] {
|
||||
if stop.NextEpisode {
|
||||
nextCount++
|
||||
}
|
||||
}
|
||||
best = BehaviourEvidence{
|
||||
Found: true,
|
||||
StartMs: medianPosition(observations[start : end+1]),
|
||||
UserCount: count,
|
||||
NextEpisodeCount: nextCount,
|
||||
SpreadMs: spread,
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// medianPosition is the middle of a cluster. The median rather than the mean for the reason
|
||||
// the season estimate uses one: a single straggler inside the tolerance must not move the
|
||||
// answer, and with an even count the lower middle is taken because looking slightly early is
|
||||
// the harmless direction to be wrong in.
|
||||
func medianPosition(stops []StopEvent) int64 {
|
||||
if len(stops) == 0 {
|
||||
return 0
|
||||
}
|
||||
positions := make([]int64, 0, len(stops))
|
||||
for _, stop := range stops {
|
||||
positions = append(positions, stop.PositionMs)
|
||||
}
|
||||
sort.Slice(positions, func(a, b int) bool { return positions[a] < positions[b] })
|
||||
return positions[(len(positions)-1)/2]
|
||||
}
|
||||
Reference in New Issue
Block a user