Skip to content
About
Main content

The ranking feedback loop

Simulate a modelNeeds Python

Problem

A data that ranks tables by how often they are read creates a feedback loop: the top result is read most because it is on top, so it stays on top. A better table that starts behind gains only from the minority of users who compare, which may never be enough. Breaking the loop needs a signal that does not come from reads, such as certification by an owner.

Simulate 52 weeks of search with two tables. Table A starts with 60 reads and B with 40. Each week 100 new users arrive. 70% take the top-ranked result. The other 30% compare both tables and 60% of them pick B (B is the better table). Rank by total reads. Then add a damping term (rank = reads + a certification bonus, or weight recent reads more) and find the smallest bonus at which B takes over.

The question. Does the better table ever reach the top, and what does it take to get it there?


Predict

Ranked by reads, with recent reads weighted more heavily (decay as low as 0.5), in what share of seeds does the better table reach the top?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    the-ranking-feedback-loop.py56 linesDownload
    View the code
    the-ranking-feedback-loop.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """The ranking feedback loop.
    
    52 weeks of catalog search with two tables. A starts with 60 reads, B with 40.
    Each week 100 users arrive: 70% take the top-ranked result; the other 30%
    compare both and 60% of them pick B, the better table. Ranking by total reads,
    then with a certification bonus for B (swept to find the smallest that lets B
    take over), then with recent reads weighted more (an exponential decay).
    """
    import argparse, json, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--weeks", type=int, default=52, help="Weeks to simulate.")
    p.add_argument("--users", type=int, default=100, help="New users per week.")
    p.add_argument("--top-share", type=float, default=0.7, help="Share of users who take the top result.")
    p.add_argument("--b-share", type=float, default=0.6, help="Share of comparing users who pick B.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    def simulate(bonus=0.0, decay=1.0, seed=args.seed, trace=None):
        rng = random.Random(seed); reads = {"A": 60.0, "B": 40.0}; score = dict(reads)
        for week in range(1, args.weeks + 1):
            top = "B" if score["B"] + bonus > score["A"] else "A"
            got = {"A": 0, "B": 0}
            for _ in range(args.users):
                if rng.random() < args.top_share: got[top] += 1
                else: got["B" if rng.random() < args.b_share else "A"] += 1
            for t in reads: reads[t] += got[t]; score[t] = score[t] * decay + got[t]
            if trace is not None: trace.append({"week": week, "A": reads["A"], "B": reads["B"], "top": top})
        return reads, top
    
    timeline = []; reads, top = simulate(trace=timeline)
    sweep = []
    for bonus in range(0, 41):
        wins = sum(simulate(bonus=bonus, seed=s)[1] == "B" for s in range(20))
        sweep.append({"bonus": bonus, "b_on_top_pct": wins / 20 * 100})
    smallest = next((r["bonus"] for r in sweep if r["b_on_top_pct"] == 100), None)
    decays = []
    for decay in (1.0, 0.9, 0.7, 0.5):
        wins = sum(simulate(decay=decay, seed=s)[1] == "B" for s in range(20))
        decays.append({"decay": decay, "b_on_top_pct": wins / 20 * 100})
    summary = {"a_reads": reads["A"], "b_reads": reads["B"], "top": top, "smallest_bonus": smallest, "initial_gap": 20,
               "a_share_pct": reads["A"] / (reads["A"] + reads["B"]) * 100}
    print(f"ranked by total reads, after {args.weeks} weeks: A {reads['A']:.0f} reads, B {reads['B']:.0f}; on top: {top}")
    print(f"smallest certification bonus for B that puts B on top in all 20 seeds: {smallest}")
    for r in decays: print(f"recency weighting (weekly decay {r['decay']}): B on top in {r['b_on_top_pct']:.0f}% of seeds")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"timeline": timeline, "bonus": sweep, "decay": decays}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run the-ranking-feedback-loop.py
    uv run the-ranking-feedback-loop.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--weeks52 Weeks to simulate.
--users100 New users per week.
--top-share0.7 Share of users who take the top result.
--b-share0.6 Share of comparing users who pick B.
--seed0 Random seed.

Our run

Total reads over 52 weeksApple M2 · 8 cores · 8 GB
Table A (worse, starts ahead)Table B (better)
Data table
Total reads over 52 weeks
WeekTable A (worse, starts ahead)Table B (better)
113961
222179
330793
4388112
5471129
6549151
7630170
8709191
9787213
10868232
11951249
121,030269
131,120284
141,200305
151,280318
161,360342
171,440359
181,520378
191,600397
201,690411
211,780422
221,850447
231,940456
242,030471
252,100497
262,180524
272,270533
282,350550
292,430567
302,520584
312,590608
322,680623
332,760639
342,850654
352,930669
363,010686
373,100702
383,180721
393,260739
403,340763
413,420780
423,500797
433,580816
443,670834
453,750847
463,840861
473,920880
484,000896
494,090911
504,170929
514,250949
524,330966
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run the-ranking-feedback-loop.py
ranked by total reads, after 52 weeks: A 4334 reads, B 966; on top: A
smallest certification bonus for B that puts B on top in all 20 seeds: 21
recency weighting (weekly decay 1.0): B on top in 0% of seeds
recency weighting (weekly decay 0.9): B on top in 0% of seeds
recency weighting (weekly decay 0.7): B on top in 0% of seeds
recency weighting (weekly decay 0.5): B on top in 0% of seeds

What we found

Ranked by reads, A stayed on top for all 52 weeks and finished with 4.33K reads against 966 for B. The smallest certification bonus that put B on top in every seed was 21, just over the initial gap of 20 reads: once B is ranked first it collects the 70% and never gives the top back. Weighting recent reads more did not help at any decay tried (B on top in 0% of seeds even at 0.5), because A's weekly reads are larger than B's every week.


Read it

Popularity ranking measures position as much as quality. A recency weighting cannot fix that, because the leader's advantage is renewed every week; only a signal from outside the loop can. The bonus needed is set by the gap at the moment it is applied, so certification works best early, before the loop has amplified the gap.