190 lines
7.7 KiB
Go
190 lines
7.7 KiB
Go
package credits
|
|
|
|
import "time"
|
|
|
|
// How sure we are, and when that is sure enough to act.
|
|
//
|
|
// The governing rule is the brief's: prefer no marker to a wrong marker. A missing Skip
|
|
// Credits button is an absence nobody notices; a button that appears during the final scene
|
|
// and throws somebody past the ending is a fault they will remember, and one they cannot
|
|
// undo without seeking back and finding their place. Every threshold here is set on that
|
|
// asymmetry rather than on getting the most coverage.
|
|
|
|
const (
|
|
// ConfidenceThreshold is the bar for storing a marker at all. Anything under it is
|
|
// discarded — not stored with a low score for something later to filter, because a
|
|
// stored marker is one a future season estimate will narrow a scan onto.
|
|
ConfidenceThreshold = 0.70
|
|
|
|
// agreementTolerance is how far apart two independent findings can be and still be
|
|
// describing the same transition. A visual detector reads the first frame of the roll;
|
|
// a viewer presses stop a few seconds into it.
|
|
agreementTolerance = 20 * time.Second
|
|
|
|
// disagreementPenalty is what a contradiction costs. Two signals pointing at different
|
|
// places are not one strong finding and a weak one — they are evidence that at least one
|
|
// detector is wrong about this file, and usually the right answer is to store nothing.
|
|
disagreementPenalty = 0.3
|
|
|
|
// combinedCeiling caps agreement. Nothing here observes the credits directly, so a
|
|
// certainty of 1 would be a claim the method cannot support.
|
|
combinedCeiling = 0.98
|
|
|
|
// behaviourCeiling caps behaviour on its own. A household can agree precisely and still
|
|
// be agreeing about the moment the last line of dialogue lands rather than the cut.
|
|
behaviourCeiling = 0.90
|
|
|
|
// RewriteTolerance is how much a new reading may differ from a stored one before it is
|
|
// worth a write. Readings of the same episode wobble by a few seconds; rewriting the row
|
|
// each time would turn a subsystem whose whole claim is "one write per episode, ever"
|
|
// into one that writes on every playback.
|
|
RewriteTolerance = 12 * time.Second
|
|
|
|
// rewriteImprovement is how much better the evidence has to be for a rewrite *within*
|
|
// tolerance to be worth doing. Inside the tolerance the position is not meaningfully
|
|
// different, so the only reason to write is that the confidence changed enough to matter
|
|
// to a later season estimate.
|
|
rewriteImprovement = 0.15
|
|
)
|
|
|
|
// BehaviourConfidence scores a stop cluster.
|
|
//
|
|
// Three things move it and they are independent: how many people agreed, how tightly they
|
|
// agreed, and whether they pressed next rather than simply stopping. A next-episode
|
|
// transition is the only one of the three that is unambiguous about *why* they left.
|
|
func BehaviourConfidence(evidence BehaviourEvidence) float64 {
|
|
if !evidence.Found || evidence.UserCount < behaviourNarrowMinUsers {
|
|
return 0
|
|
}
|
|
score := 0.45
|
|
// Each viewer past the second is worth less than the one before it: the step from two to
|
|
// three is the one that rules out a coincidence, and the step from six to seven adds
|
|
// almost nothing.
|
|
for extra := 0; extra < evidence.UserCount-behaviourNarrowMinUsers; extra++ {
|
|
score += 0.12 / float64(extra+1)
|
|
}
|
|
if evidence.NextEpisodeCount > 0 {
|
|
score += 0.10 * float64(evidence.NextEpisodeCount) / float64(evidence.UserCount)
|
|
}
|
|
// Tightness, measured against the cluster tolerance: agreeing to within three seconds
|
|
// earns nearly all of this, agreeing to within the full half-minute earns none of it.
|
|
if tolerance := behaviourCluster.Milliseconds(); tolerance > 0 {
|
|
tightness := 1 - float64(evidence.SpreadMs)/float64(tolerance)
|
|
if tightness > 0 {
|
|
score += 0.15 * tightness
|
|
}
|
|
}
|
|
if score > behaviourCeiling {
|
|
score = behaviourCeiling
|
|
}
|
|
return score
|
|
}
|
|
|
|
// Combine reconciles a visual detection with behavioural evidence into the marker that will
|
|
// be stored, or into nothing.
|
|
//
|
|
// The interesting case is agreement, and the reason to want it is that the two signals fail
|
|
// in unrelated ways: a visual detector is fooled by a dark, static final scene, and a stop
|
|
// cluster is fooled by an episode everybody happened to abandon at the same point. Neither
|
|
// failure makes the other more likely, so agreement between them is worth far more than
|
|
// either on its own — which is why the combination is a probabilistic union rather than an
|
|
// average, and why an average would have been the wrong shape entirely.
|
|
func Combine(visual Detection, evidence BehaviourEvidence) (Detection, bool) {
|
|
behaviourScore := BehaviourConfidence(evidence)
|
|
hasBehaviour := evidence.Found && behaviourScore > 0
|
|
|
|
switch {
|
|
case visual.Found && hasBehaviour:
|
|
gap := visual.StartMs - evidence.StartMs
|
|
if gap < 0 {
|
|
gap = -gap
|
|
}
|
|
if gap <= agreementTolerance.Milliseconds() {
|
|
combined := visual
|
|
combined.Method = MethodCombined
|
|
// The earlier of the two. A skip that begins at the first frame of the roll is
|
|
// correct; one that begins a few seconds in has already shown the viewer the
|
|
// thing they asked to skip.
|
|
if evidence.StartMs < combined.StartMs {
|
|
combined.StartMs = evidence.StartMs
|
|
}
|
|
combined.Confidence = union(visual.Confidence, behaviourScore)
|
|
if combined.Confidence > combinedCeiling {
|
|
combined.Confidence = combinedCeiling
|
|
}
|
|
return combined, combined.Confidence >= ConfidenceThreshold
|
|
}
|
|
// They disagree. Take whichever is stronger, pay the penalty, and let the threshold
|
|
// decide — which, with a penalty this size, usually means storing nothing.
|
|
stronger := visual
|
|
stronger.Method = MethodVisual
|
|
if behaviourScore > visual.Confidence {
|
|
stronger = Detection{
|
|
Found: true,
|
|
StartMs: evidence.StartMs,
|
|
Confidence: behaviourScore,
|
|
Method: MethodBehaviour,
|
|
}
|
|
}
|
|
stronger.Confidence -= disagreementPenalty
|
|
return stronger, stronger.Confidence >= ConfidenceThreshold
|
|
|
|
case visual.Found:
|
|
visual.Method = MethodVisual
|
|
return visual, visual.Confidence >= ConfidenceThreshold
|
|
|
|
case hasBehaviour && evidence.StandaloneMarker():
|
|
detection := Detection{
|
|
Found: true,
|
|
StartMs: evidence.StartMs,
|
|
Confidence: behaviourScore,
|
|
Method: MethodBehaviour,
|
|
}
|
|
return detection, detection.Confidence >= ConfidenceThreshold
|
|
|
|
default:
|
|
return Detection{}, false
|
|
}
|
|
}
|
|
|
|
// union combines two independent probabilities: the chance that at least one of them is
|
|
// right, which is what independence buys.
|
|
func union(a, b float64) float64 {
|
|
if a < 0 {
|
|
a = 0
|
|
}
|
|
if b < 0 {
|
|
b = 0
|
|
}
|
|
return 1 - (1-a)*(1-b)
|
|
}
|
|
|
|
// ShouldRewrite decides whether new evidence justifies touching an existing row.
|
|
//
|
|
// The default answer is no. Once an episode has a good marker it should never be written
|
|
// again, and this function is the only thing standing between that promise and a row that is
|
|
// updated every time somebody watches the episode.
|
|
func ShouldRewrite(existing Marker, candidate Detection) bool {
|
|
// An operator's correction is final. Nothing automatic may overwrite a position somebody
|
|
// set by hand, whatever it thinks it has found.
|
|
if existing.DetectionMethod == MethodManual {
|
|
return false
|
|
}
|
|
if !candidate.Found || candidate.Confidence < ConfidenceThreshold {
|
|
return false
|
|
}
|
|
gap := candidate.StartMs - existing.CreditsStartMs
|
|
if gap < 0 {
|
|
gap = -gap
|
|
}
|
|
if gap <= RewriteTolerance.Milliseconds() {
|
|
// Same position, within the wobble. Only a materially better score is worth a write,
|
|
// and only because a later season estimate will weigh this row by its confidence.
|
|
return candidate.Confidence >= existing.Confidence+rewriteImprovement
|
|
}
|
|
// A genuinely different position. It has to be better evidence than what is already
|
|
// there, not merely different — otherwise two detectors that disagree would rewrite the
|
|
// row past each other for ever.
|
|
return candidate.Confidence > existing.Confidence
|
|
}
|