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 }