The ranking feedback loop
Simulate a modelNeeds Python
Problem
A data catalog 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
Expect A to stay on top and widen its lead, even though B is better.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
Data table
| Week | Table A (worse, starts ahead) | Table B (better) |
|---|---|---|
| 1 | 139 | 61 |
| 2 | 221 | 79 |
| 3 | 307 | 93 |
| 4 | 388 | 112 |
| 5 | 471 | 129 |
| 6 | 549 | 151 |
| 7 | 630 | 170 |
| 8 | 709 | 191 |
| 9 | 787 | 213 |
| 10 | 868 | 232 |
| 11 | 951 | 249 |
| 12 | 1,030 | 269 |
| 13 | 1,120 | 284 |
| 14 | 1,200 | 305 |
| 15 | 1,280 | 318 |
| 16 | 1,360 | 342 |
| 17 | 1,440 | 359 |
| 18 | 1,520 | 378 |
| 19 | 1,600 | 397 |
| 20 | 1,690 | 411 |
| 21 | 1,780 | 422 |
| 22 | 1,850 | 447 |
| 23 | 1,940 | 456 |
| 24 | 2,030 | 471 |
| 25 | 2,100 | 497 |
| 26 | 2,180 | 524 |
| 27 | 2,270 | 533 |
| 28 | 2,350 | 550 |
| 29 | 2,430 | 567 |
| 30 | 2,520 | 584 |
| 31 | 2,590 | 608 |
| 32 | 2,680 | 623 |
| 33 | 2,760 | 639 |
| 34 | 2,850 | 654 |
| 35 | 2,930 | 669 |
| 36 | 3,010 | 686 |
| 37 | 3,100 | 702 |
| 38 | 3,180 | 721 |
| 39 | 3,260 | 739 |
| 40 | 3,340 | 763 |
| 41 | 3,420 | 780 |
| 42 | 3,500 | 797 |
| 43 | 3,580 | 816 |
| 44 | 3,670 | 834 |
| 45 | 3,750 | 847 |
| 46 | 3,840 | 861 |
| 47 | 3,920 | 880 |
| 48 | 4,000 | 896 |
| 49 | 4,090 | 911 |
| 50 | 4,170 | 929 |
| 51 | 4,250 | 949 |
| 52 | 4,330 | 966 |
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 seedsWhat 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.