Skip to content
C.W.K.
Stream
Lesson 02 of 05 · published

Affinity With a Half-Life, and a Threshold Off the Round Number

~13 min · ranking, decay, tuning, bugs

Level 0Unsorted
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Interest Is Current, Not Cumulative

The naive profile sums every act ever taken. It is wrong in a specific and permanent way: a fortnight spent reading intensively about one subject becomes a fixture, and every signal afterwards has to out-shout it. The profile stops tracking what someone cares about and starts recording what they once cared about most.

Exponential decay fixes this with one multiplier. Each contribution is scaled by one half raised to the age in half-lives, so a two-week-old save counts half, a month-old save a quarter. Nothing is deleted and no threshold is crossed — old interests fade continuously, which means a shelf recovers on its own when attention moves, with no reset button and no explaining to the reader why their history was thrown away.

Two Different Half-Lives, Doing Two Different Jobs

The profile's half-life governs how fast taste is allowed to change. It should be slow — a couple of weeks — because taste genuinely is stable and a jumpy profile chases noise.

Freshness is a separate term with a much shorter half-life, and it answers a different question: not "do you care about this subject" but "is this still news." Collapsing the two into one number is a common shortcut and it produces a shelf that either forgets your interests in two days or shows you last week's headlines. They are independent axes and deserve independent constants.

Weight the Acts by What They Cost

Opening an article is weak evidence — it costs a tap and is often regretted. Saving is strong: it is a deliberate claim of future value. Sharing is comparable, since publishing something is a public commitment. An explicit dislike is the strongest signal available and deserves a magnitude to match, because a reader who takes the trouble to press it is telling you something more precise than any amount of inferred behavior.

The ordering matters more than the exact numbers. What you must not do is weight by how easy the signal is to collect, which is how systems end up optimizing for the tap and calling it engagement.

The Afternoon a Round Number Cost

Here is a bug worth more than its size. Muting fires when a weight falls below a threshold, and the threshold was parked on a round number that matched the dislike weight exactly. It never fired.

The reason is decay. A contribution is only at its full nominal value at the instant it is recorded; a microsecond later the multiplier has shaved it. So a single dislike, weighted at exactly the negative of the threshold, produces a weight that is always fractionally above the bar, forever, no matter how obvious the intent. Moving the threshold to sit between the natural steps rather than on one fixed it. The general form: in any system with continuous decay, never place a threshold exactly on a value the system can produce — comparison against a decayed quantity is a comparison against something strictly smaller than what you wrote down.

Decay is not a tuning knob, it is a statement about what your data means. A weight that does not decay claims interest is permanent; one that decays too fast claims it is momentary. Pick each half-life by asking how long the thing it measures actually stays true.

Code

Two half-lives for two questions, and a threshold placed off the round number·python
PROFILE_HALF_LIFE_DAYS = 14.0     # how fast taste may change
FRESHNESS_HALF_LIFE_DAYS = 2.0    # how fast news stops being news


def _decay(stamp: str, now, half_life_days: float) -> float:
    age_days = (now - parse(stamp)).total_seconds() / 86400.0
    return 0.5 ** (age_days / half_life_days)


def build_profile(con) -> dict[str, float]:
    """Sum decayed contributions over the CURRENT state projection --
    the reversal-applied view of the log, so an unsaved article stops
    contributing without anything being deleted."""
    now, profile = _now(), {}
    for row in con.execute(_PROFILE_SELECT):
        article = dict(row)
        feats = features(article)
        for state_col, weight in EVENT_WEIGHTS.items():
            stamp = article.get(state_col)
            if not stamp:
                continue
            contribution = weight * _decay(stamp, now, PROFILE_HALF_LIFE_DAYS)
            for key, scale in feats.items():
                profile[key] = profile.get(key, 0.0) + contribution * scale
    return profile


# The afternoon a round number cost.
#
# EVENT_WEIGHTS["disliked_at"] is -4.0, so a threshold of -4.0 looks
# like "one dislike is enough". It never fires: decay shaves every
# weight the instant it is recorded, so a single dislike is -3.9998...
# and rising. The bar must sit BETWEEN the steps the system can
# actually produce, never ON one.
MUTE_THRESHOLD = -3.5

External links

Exercise

Find a threshold comparison in code you own where the constant on one side equals a constant used to produce the value on the other. Work out whether anything between them — decay, rounding, a scaling factor, a floating-point sum — can make the produced value fall short. Then decide whether the threshold should move, or whether the comparison should be inclusive.
Hint
Floating-point accumulation causes the same class of miss even without decay: summing three contributions of 1.1 and comparing against 3.3 is not reliably true. If the bar is meant to mean 'three of these', either move the bar off the exact value or compare with a tolerance — but say which you chose in a comment, because the next reader will assume the round number was deliberate.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.