Files
memby/server/internal/credits/confidence.go
T
2026-08-15 09:23:26 +12:00

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
}