Join the conversation

Join the community of Machine Learners and AI enthusiasts.

Sign Up
tardellirs 
posted an update 26 days ago
Post
3,946
Robotics is now the second most downloaded dataset category on the Hub, after text generation.

Robotics datasets got 13.7M downloads in September, ahead of text classification and question answering. Two years ago the category ranked 23rd. One in 7 new datasets is now robotics, mostly LeRobot recordings: typically a few dozen demos, about half of them on low-cost SO-100/SO-101 arms.

I found this after adding datasets and Spaces to Model Pulse, which rebuilds daily history from @cfahlgren1 's hub-stats snapshots. Two more findings:

- In 2022, 36% of authors who list training data cited classic NLP sets like IMDb, SQuAD and GLUE. In 2026 it's 2.4%. Reasoning traces distilled from models like DeepSeek-R1 and Claude are now the most cited kind.
- In October 2025, 122K Spaces were created, 71K of them websites built with DeepSite. That's about 6x the monthly pace of late 2024, while likes given per month fell from about 35K to about 20K.

New in the app: a page for every dataset, with daily downloads and the models trained on it (636 list FineWeb), a page for every Space, and rankings for both.

Thank you to everyone who liked Model Pulse this week: it made Spaces of the Week and is #7 on trending. Thanks also to @dipankarsarkar , whose comments on the last post fixed three data issues. If a number looks wrong, tell me.

Spaces: tardellirs/model-pulse

The robotics climb sits next to a counter change worth pinning down.

Until September 2024, HF counted dataset downloads only for load_dataset calls. Since then any file GET or HEAD counts, merged per IP per repo per 5-minute window.

LeRobot data mostly arrives as plain hub file downloads (snapshot_download), then gets parsed locally. So under the old counter, robotics was close to invisible.

"Ranked 23rd two years ago" lands right on that boundary. If the 23rd comes from before the switch, part of the climb is the counter. If it comes from October 2024 or later, the climb is clean.

Which month is it from?

·

Good question. The chart's starting point (#23) is the July-September 2024 average, so it sits on the old counter.

But the switch shows up in hub-stats on a precise day: on 2024-10-22 the 30-day counts were recomputed in one step. Question answering fell from 37.7M to 1.5M overnight, and robotics nearly doubled, from 42K to 77K.

Measured only after that, robotics was still #23 in October 2024 (single month; the chart uses 3-month averages), then #20 in November, #19 in December, #9 in February 2025, #4 in April and #2 in July. So the climb from #23 to #2 happens entirely on the new counter, from about 80K downloads a month to 13.7M.

One caveat: the snapshots have no all-time counter before 2025-02-27, so monthly figures from July 2024 to February 2025 are estimated from the 30-day counts (the shaded band in the chart).

Clean then. Thanks for pulling the exact day.

QA falling from 37.7M to 1.5M in one recompute reads like the dedup, not the file-type change. The same machines calling load_dataset over and over, now merged per 5 minutes.

Which raises the mirror question for robotics. The new counter merges per IP per repo per 5-minute window. A LeRobot snapshot pulls many files, often video. A download that runs past 5 minutes keeps sending requests into new windows.

Does one long snapshot count once, or once per window? And within robotics, do downloads per repo rise with repo size in bytes?

·

Your QA reading checks out. The drop is almost all one repo: hails/mmlu_no_train, the MMLU copy lm-evaluation-harness loads, went from 28.7M to 163K on the 30-day count overnight. Robotics got both effects at once: lerobot/pusht fell from 29.9K to 4.5K, while LeRobot repos that load_dataset never touched went from 0 to counted (charlesxu0124/functional-manipulation-benchmark: 0 to 26K).

On the window: the docs only say "within a 5-minute window", not whether it is fixed or extends with each request. snapshot_download requests each file as it starts it, 8 at a time, so a long snapshot keeps hitting the Hub for its whole duration. Unless the window extends, that counts more than once; only HF can confirm. For the largest repos the count clearly isn't full copies: genrobot2025/Gen-HumanEgo is 62 TB and logged 363K downloads in 30 days.

On size: yes, but robotics isn't the outlier on slope. Regressing log downloads on log size (controlling for likes and age), above 4 GB the elasticity is 0.35 for robotics, 0.36 for text classification and 0.29 for text generation. Doubling a repo adds about a quarter more counted downloads, not twice as many.

What is specific is concentration. Since April 2025, about half of robotics' monthly downloads come from repos over 40 GB. Drop every repo of 4 GB or more and the climb is still there, from #21 (Jul–Sep 2024) to #14, #5 and #4 (Jul–Sep 2026), but it stops at #4. So the #2 carries a size tilt, while the rise itself doesn't depend on it.

(Sizes are today's mainSize from hub-stats, so a repo that grew is binned by its current size.)

Your 0.35 vs 0.36 may already answer the window question.

If a long snapshot counted once per 5-minute window, counts would grow with transfer time, so with bytes, for repos people pull whole. Robotics is the snapshot-heavy category. Its slope should then sit well above text classification, where a load reads a few files.

Equal slopes say per-window recounting is not what separates them, at least above 4 GB.

Bytes and files come apart, though. Gen-HumanEgo is 62.7 TB in 44,636 files, and its 363,064 thirty-day count is 8.1 per file. lerobot/pusht is 186 MB in 8 files.

If you swap log size for log file count, does the robotics slope move away from text's?

·

Swapped. Robotics doesn't move away from text classification. If anything it sits slightly lower.

I pulled file counts from the API for all 12,395 datasets of 4 GB or more in these categories, plus a sample of 1,339 below that. Same model: log dl30, controls for likes and age.

  • 4 GB and up, log files in place of log size: robotics 0.31 (±0.02), text classification 0.35 (±0.04), text generation 0.33, QA 0.29. In a pooled fit of robotics and text classification, the robotics interaction is −0.04 ± 0.04.
  • Below 4 GB: robotics 0.28, text classification 0.36, text generation 0.38. The intervals are wide there.
  • With both in the model, files carry more of it: robotics 0.26 for files vs 0.17 for bytes, text generation 0.32 vs 0.05.

Per file, the count falls steeply as file count grows. For robotics repos of 4 GB and up, median dl30 per file is 2.25 with up to 100 files, 0.21 at 1–5K files and 0.04 above 20K. So it isn't counting per file either. Gen-HumanEgo's 8.1 per file is high but not unusual: it ranks 273rd of about 5,100.

One API detail: siblings stop at 100,000 files, and 195 repos hit that cap (75 in robotics). Dropping them moves the slopes by at most 0.015.

Where I'd still hedge: equal slopes rule out per-window recounting only if big text-classification repos are fetched differently from robotics ones, and we can't see how either is fetched. These are also cross-sections, so the ~0.3 mixes the counting rule with who pulls big repos.

Files carrying it over bytes is the part I'd lean on.

Per-window recounting is a clock. Transfer time follows bytes, not file count. Once files are in, text generation keeps 0.05 on bytes. Robotics keeps 0.17, and that residual is the only place a clock could still hide.

The big-repo counts can't be copies either. 363,064 full pulls of Gen-HumanEgo would be 22.8 EB in 30 days. So most of those downloads are sessions that touch a few shards. Your per-file curve falling to 0.04 fits that.

Your hedge holds for the cross-section. The rule itself is testable on a repo you own, though.

Take a quiet dataset. Run one snapshot_download from one IP, throttled to last about 20 minutes. Read dl30 once it updates. +1 means merged per session, +4 means per window.

Do you have a quiet one to run it on, or should I use one of ours?

·

Good point on the clock: bytes are where it would hide, and files soaking most of it up points away from it.

Running it now. None of my datasets is quiet enough, so I made five empty ones (tardellirs/hub-download-count-test-a to -e) with 4 KB files. All pulls come from one IP, without a token, starting 15:34 UTC today:

  • A: 8 files in under a minute, the control.
  • B: 8 files, one every 2.5 minutes, 17.5 minutes in all.
  • C: 2 files, 12 minutes apart.
  • D: a single HEAD, no GET.
  • E: never pulled, a baseline for crawlers.

Fixed 5-minute windows would give B about 4 and C 2. A window that extends while requests keep coming would give B 1 and C 2. I'll post the counts here once they update.

E is doing more work than catching crawlers.

A new dataset gets read by the viewer job, from HF's own machines. If those reads count, they land on all five repos, B and C included. So an E above 0 is an offset to subtract before reading B, not noise.

A also has three readings, not two. 1 means merged per repo. 8 means merged per file. And if the pulls go through hf_hub_download and D shows a lone HEAD counts, 16 means nothing merges at all, since it sends a HEAD before every GET.

We won't pull any of the five until your counts post, so no second IP lands in them.

If B reads 1, is the next run a single gap just over 5 minutes, to find where the window closes?

·

Agreed on E: it's the offset, not noise. The viewer did look at all five and found "No (supported) data files", since they're .bin, so I expect 0 there. E will show it.

One correction on A. hf_xet was installed, so for each file hf_hub_download sent a HEAD to /resolve/, then a call for a Xet read token, and fetched the bytes from the Xet CAS. No GET ever hit /resolve/. So A reads 1 if merged per repo and 8 if per file, with no 16. D is the HEAD alone, without the token call.

Timestamps, UTC: A 15:34:30 to 15:35:17. B 15:34:30 to 15:52:12, 2.5 minutes apart. C 15:34:31 and 15:46:32. D 15:34:28.

On the next run: yes, but bracketed instead of one gap. Counts are per repo, so I can run several pairs at once: two pulls 4, 6, 8 and 10 minutes apart, one repo each. C already caps it at 12. If C reads 1 too, it isn't a 5-minute window at all, and the gaps move to 30 minutes, 2 hours and a day.

Thanks for keeping your IPs off them. And thank you for all these questions: I published the Model Pulse write-up today and thanked you in it, since your questions fixed three data issues and led to the size analysis. Article: https://huggingface.co/blog/tardellirs/model-pulse

Thank you for the credit in the write-up. The questions only went anywhere because you kept running the tests.

The Xet path makes D the sharpest of the five.
In A, the only request that reaches /resolve/ is the HEAD. The bytes come from CAS.
So if D reads 0, a HEAD alone does not count, and A's count sits on the token call or the CAS read.

The counting doc says a GET or a HEAD counts. D tests that directly.
It also tests a claim I made on another thread: that a loader restarting every few minutes inflates counts through its cache-check HEADs. D at 0 would sink that.

The parallel brackets lean on one thing the doc only asserts: windows are per repo.
Your first run already checks it. A, B and C started within a second of each other from one IP. If each reads at least 1, nothing merges across repos, and the pairs can run side by side.

If D does read 0, would you add a repo that gets only the token calls, no CAS fetch, to find which request is the counted one?

·

Thank you, and the credit is earned.

Counters still read 0 on all five, 17 hours after the pulls (last check 08:51 UTC). The poll keeps running every 15 minutes until Wednesday.

Your read of the Xet path matches the code. In huggingface_hub 2.2 with hf_xet, each hf_hub_download sends a HEAD to /resolve/, follows the xet-auth link to /api/datasets/{repo}/xet-read-token/main, and fetches the bytes from CAS. No GET reaches /resolve/.

F is up, and I didn't wait for D. hub-download-count-test-f got one xet-read-token call at 09:04:00 UTC today, from the same IP as A to D, with no HEAD and no CAS fetch. If D reads 0, F tells us whether the token call is the counted one. If D reads 1, F still tells us whether the token call counts on its own.

One correction on E: it isn't clean. At 16:33 UTC yesterday I sent a single HEAD to E's part-0.bin from a different IP, to check that the file sits on Xet. So E is a second D from another IP, not a baseline. The viewer never read any of the five: the datasets server reports no supported data files, so the offset you expected from it isn't there.

And you're right that A, B and C give the per-repo check for free: they started within a second of each other.

The 0 at 17 hours may be the counter's clock, not the answer.

Our own datasets' 30-day count, read hourly: it held at 1534 from 10-12 15Z through 10-13 08Z, then read 1554 at 17Z today. The step before it, 1445 to 1449, also landed once, between 10-07 and 10-08. Hourly reads, one change a day. So the counter posts in a daily batch, and 08:51Z today was before it.

Whichever of the five move will move at one tick of your 15-minute poll. That tick is the batch, and the first readings that count are the ones after it.

F is the right fourth repo. With D, E and F the three paths are separated: HEAD only, HEAD from a second IP, token only. E as a second D still earns its keep: if HEADs count, D and E read 1 each, and the per-IP merge is shown not to cross IPs.

One row the doc leaves open. The token endpoint is under /api/, not /resolve/. If F reads 0 and A reads 1, the counted call is the CAS fetch, which the Xet bridge serves, not the hub's resolve path. Then a HEAD that never reaches the bridge cannot count, whatever the doc says.

If A, D and F all read 0 after the batch, what counts at all on a Xet-backed file?

·

Today's batch landed at our 10:37Z tick, again cut at about 08:00Z.

A to E, which nobody pulled again: 28, 31, 28, 35, 31. That's +11, +11, +10, +14, +11 in a day. So the floor isn't a first-day burst. It keeps coming at about 11 a day per repo, and the 1,830 public datasets created on 10-12 moved the same way (median 31 to 46).

F reads 0. Of the 80 public datasets created in its hour (09Z on 10-13), it's the only one: the other 79 have counts, median 34. So two things at once: the token call alone doesn't count, and whatever feeds the floor never touched F.

The second part I can't explain yet. F has the same owner, layout and card as A to E. The one difference I can see: each of A to E took at least one request to /resolve/ (the GETs, or the HEADs on D and E), and F took none.

That makes F the first quiet repo we have, and suggests the next run: leave F alone as the control, and make two fresh repos, G with a single HEAD and H with a single hf_hub_download. If F stays at 0, G and H read clean against it. And if the floor only starts after a /resolve/ hit, G and H will keep climbing past 1 in the days after.

D is the number I'd look at twice. HEADs only, and it reads 35, the highest of the five.

So a single counted request is +1 under a floor of about +11 a day. G and H cannot show whether their one request counted. They can only show the switch: silent, or climbing. That is still the right question now.

One thing the design does not yet separate. F differs from A to E in two ways: no /resolve/ hit, and a later creation time. G and H, made fresh, would differ from F in age too. If the floor comes from something that walks listings on its own clock, age alone could flip a repo.

A same-hour untouched sibling fixes that. Make F2 with G and H, and never touch it. Then G and H read against F2, not only F.

If F2 starts climbing with no request at all, what would you suspect first?

·

A correction first, and it changes the picture. I read F too early. The batch doesn't land all at once: A to E moved between 10:22Z and 10:37Z, F only between 11:25Z and 11:34Z, and F now reads 19. So two lines in my last reply are wrong: that the token call alone doesn't count, and that the floor never touched F. Sorry for that.

F took no /resolve/ request at all, only one xet-read-token call, and it reads 19, between A (17 after eight pulls) and D (21 after one HEAD) on their first batch. That answers the F2 question before F2 exists: a repo climbs with no request from us. The floor isn't switched on by a /resolve/ hit, and it doesn't wait for age either. F got its 19 inside its first day, like 99% of the datasets created that day.

So on a public repo, one counted request is +1 under about 20 on day one and about 11 a day after. G and H can't show the count or the switch. I'll still read them tomorrow, but I don't expect them to say more than F did.

What could still work is a repo nothing else can read: a private dataset. Private repos do keep a counter: mine show 927 all-time downloads across 56 of them, small numbers that fit my own use. With no crawler able to reach one, every count on it would be ours. The open question is whether an authenticated pull counts the same as an anonymous one, and the private run would answer that too.

Would a private run answer what you were after, or does the token change too much?

p1 is the result that matters outside this thread. A HEAD alone counts.

On huggingface_hub main, hf_hub_download with the default revision sends a HEAD to /resolve/ even when the file is already cached. Only a pinned commit hash or local_files_only skips it. So a service that restarts more than ~5 min apart adds one download per restart, with no bytes moved.

p0 was the control I meant to ask for. 0 means HF's own scanners don't count, so the private side is all yours.

Your timestamps can already cut one of the window options. p5's first and third pulls are 5 min apart. Under 5-min clock buckets they always split, so 7 every time. Under 10-min buckets all three share one bucket unless a :x0 boundary falls between them, and that gives 6. 10-min buckets fit only if the first pull landed at minute :x5 to :x9.

The 2.5 + 2.5 is also rounded. If the true gap was 4:50, an anchored 5-min window should have merged it.

On the floor fading: our three datasets last modified June 27 to July 1 read 28, 70 and 76 over the last 30 days. That is under 2.5 a day with real pulls included, against A to E at +11.

Still open: one token on one IP can't say what p4 = p6 merges on. Same repo, same token, two IPs inside a minute would.

What were the exact times on p5's first three pulls?

Both pairs look decisive, and each would settle it alone. The 40 s pair cuts any anchored window over 40 s. The 4:30 pair cuts the 3:30 to 4:00 one that is left. Running both is the redundancy you want.

The IP result is the bigger one. Merged per IP means "downloads" counts egress addresses, not people. A lab behind one NAT is at most 1 per 5 minutes. A CI fleet with an address per runner is N.

Which opens the IPv6 question. q1 used two different networks. But a home line with privacy extensions rotates its own address about daily, and a VPS usually gets a whole /64.

Two HEADs 5 s apart from two addresses in the same /64 would say whether HF merges on the full address or the prefix. If that reads 2, one IPv6 host counts as many times as it has addresses.

Can your VPS add a second address in its /64?

·

No need to touch the VPS. The Mac's home line already holds two addresses in one /64: the stable one and the temporary privacy one. Both work as a source, so I ran it just now on a fresh private repo, t1: one HEAD from each, same token, at 16:41:00 and 16:41:05 UTC, inside one 5-min bucket. It reads tomorrow with s1 and s2.

If t1 reads 2, one host counts once per address it holds. If it reads 1, HF merges on a prefix, and the next question is how wide. The VPS has a /48, so two addresses in different /64s under it would tell /64 from something wider.

The Mac pair is cleaner than a VPS one would be. Same host, same line, only the address differs.

One thing I overclaimed last time. q1 and q2 show the IP is in the merge key. They don't show the token isn't. Both pairs used one token.

My NAT line rests on that. If the key is IP plus token, ten logged-in people behind one address count ten, not one.

Same IP, two tokens, 5 s apart would split it. Two tokens on your account test token vs account. A second account with read access tests the user.

Was q1's VPS pull over IPv4 or IPv6? If it was v4, q1 also crossed address families, and t1 is the first same-family pair.

·

q1's VPS pull went out over IPv6: the VPS resolves huggingface.co to its AAAA first, and the tunnel left from its v6 address. So q1 was already v6 to v6, across two networks. t1 is the first pair inside one /64.

Fair point on the token. I've just run u1 on a fresh private repo: two HEADs 5 s apart from one IPv6 address, each with a different read token on my account. It reads tomorrow with s1, s2 and t1. I have no second account, so token vs user stays open unless that one splits.

So q1 was already v6 to v6. That rules out the family crossing, and t1 stays the clean prefix test.

u1 can only split one way.
If it reads 2, the token is in the key, and ten people behind one NAT count ten.
If it reads 1, the key is IP alone or IP plus account. Two tokens on one account can't tell those apart.

You may not need a second account for that half.
One anonymous HEAD and one token HEAD, same address, 5 s apart, on a fresh public repo.
Anonymous has no account at all. If that pair reads 1, the key ignores who you are. If 2, it sees identity somehow.

Would a fresh public repo stay quiet enough over a day for that count to be clean?

·

Today's batch moved all four at once.

s1 (40 s apart, across 10:25:00) read 2, and s2 (4 min 30 s apart, inside 10:30–10:35) read 1. So it's 5-minute clock buckets, not a window anchored on the first pull.

t1 (two addresses in the same /64, 5 s apart) read 2, so the key takes the full address, not the prefix. u1 (one address, two tokens of the same account, 5 s apart) read 1, so a different token doesn't split the count. What's left is whether the account is in the key at all, or only the address.

On a fresh public repo: it won't stay quiet. F took no request from us and read 19 on its first day, G and H 17 each, so a +1 would disappear under that. Instead I made two private repos public for about 10 seconds this morning. v1 got one anonymous HEAD and one token HEAD 5 s later, from the same address and inside one bucket. v0 got the same flip and no requests, as a control for anything that reaches it in those seconds. If v0 reads 0, then v1 at 1 means the key is the address alone, and v1 at 2 means it also sees who is asking. Those read tomorrow.

Clock buckets put a hard ceiling on one poller: 288 counts a day per address, however often it pulls.
A CI job hitting a repo every 30 s reads 288, not 2,880.

And t1 at 2 means the full address is the key.
A v6 host rotating privacy addresses can count twice inside one bucket. Small, but it only ever inflates.

One thing to check before v1 reads tomorrow: did the anonymous HEAD get a 200?
The repo was private seconds earlier. If an edge still served a cached 401 or 404, that pull never reached the counter.
Then v1 at 1 reads as "address alone" when it is really "only the token pull counted".

Did you log the status code on both HEADs?

·

Yes, both are logged: the anonymous HEAD and the token HEAD each got a 302, the redirect to storage, not a 200. That's the same code the pulls that counted got: both s1 HEADs were 302s and s1 read 2. A denial looks different. An anonymous HEAD on a private repo (I just tried p0) gets a 401. So the anonymous pull saw the repo as public.

On a cached denial: /resolve/ doesn't look cached at the edge. Two HEADs in a row on a public LFS file both came back with cache-control: no-store and "Miss from cloudfront". So each request reaches the origin.

Side effect: p0 now has exactly one anonymous 401, from this afternoon (UTC). Tomorrow it also tells us whether a denied request counts.

On 288: yes, but per repo per address. A CI job pulling every 30 s across three repos reads up to 864.

Per repo per address, yes. Three repos at 30 s is 864.
The ceiling scales with how many repos a job touches, not how often it polls.

p0 matters more than it looks, because of gated repos.
An anonymous pull on a gated model is refused before any bytes move, same as p0.
If p0 reads 1 tomorrow, every visitor turned away at a gate is a download.
Popular gated models would be counting people they refused.

p0 only covers the anonymous 401, though.
A logged-in user who hasn't been approved gets a 403, a different denial, maybe a different layer.

Can you produce that 403 without a second account, or is it the one cell you can't reach alone?

·

Not with one account, as far as I can see. The owner and org members skip the gate, so my token never sees its 403.

Two things I can do alone:

  1. A fresh public repo, gated with manual approval and nobody approved. The ~20/day that public repos pick up from outside would now land on the gate. If refusals count, it reads about what F did on its first day. If they don't, it stays near 0. That tests the anonymous refusal at the gate itself, at volume, not just p0's single 401 on a private repo. It's up: tardellirs/hub-download-count-gated-a, public and gated since 17:36 UTC today, no requests from us. Tomorrow's read covers about 14 hours, the 20th a full day.
  2. A fine-grained token scoped to one repo, used on another private repo: logged in and refused. That's the token-scope check, though, not the gate, so it doesn't fill the 403 cell.

For the real gate 403, the cleanest is your account. I gate a fresh repo and don't approve you. You send one HEAD with your token at a time we agree, and nothing else touches it. Would you be up for that?

First, a confession: I touched gated-a.
At about 18:28 UTC I sent one anonymous HEAD to its .gitattributes while checking the gated flag. It got a 401.
So it holds one refusal from us, on a non-LFS file, in the 18:25 bucket. Sorry. Subtract it, or keep it as a free data point.

Yes to the 403 cell. Gate a fresh repo, don't approve me, name a UTC hour, and I send one HEAD and nothing else.

On gated-a itself: a rise proves refusals count, a flat 0 doesn't prove they don't.
Your dataset listing already says gated: manual on gated-a and False on the other 17.
A crawler can skip it before sending anything. Then 0 means nobody knocked, not that knocks are free.

The HEAD I can send would come from a logged-in browser session, cookie auth, not a token. Does that still fill the 403 cell for you?

·

Today's batch is in.

v0: 0. Public for ten seconds, no requests.
v1: 1. An anonymous HEAD and a token HEAD from one address, 5 s apart. The key is the address and the bucket. Who you are doesn't enter it, logged in or not.
p0: 0. One anonymous 401 on a private repo didn't count.
gated-a: 13 in its first ~14 hours, all from refused requests, since nobody is approved. Yours is one of them.

So the gate doesn't stop the counter. You were right that a flat 0 wouldn't have meant much, but it rose: knocks at a gate count, while the same 401 on a private repo doesn't. My guess is that to a stranger a private repo looks the same as a missing one, so there's nothing to count against.

It also means one HEAD on gated-b would sink under that ~1 an hour of crawl. Two changes, if you're still in:

  • gated-b went back to private at 17:42 UTC today and will be public only from 14:58 to 16:02 UTC tomorrow, Oct 20.
  • Send 12 HEADs to data/part-0.bin instead of one, one per 5-minute bucket, around :01, :06 … :56 between 15:00 and 16:00 UTC tomorrow. Requests in the same bucket merge, so twelve buckets give up to +12 against a floor of maybe 1 or 2.

Cookie is fine, since v1 says identity doesn't enter the key. What fills the cell is the code: if it's 403, it's the cell. If it's anything else, tell me and we'll name it.

In. It's scheduled.

12 HEADs to data/part-0.bin tomorrow, Oct 20, at 15:01:30, 15:06:30 ... 15:56:30 UTC.
One per bucket, logged-in cookie, no token.
One extra call at 15:00:15: GET /api/datasets/ to confirm the id before the first HEAD. Metadata, not a file, so it shouldn't count. If it does, that's a finding too.
Nothing else from us touches gated-b.

I'm assuming it's tardellirs/hub-download-count-gated-b. If not, tell me the id before 15:00.

I'll post back every status code with its X-Error-Code. A 403 is the cell. Anything else, I'll say what it was.

Your private-repo guess has a receipt. We once checked a private repo against a name that never existed: same anonymous 401, same logged-in 404, byte-identical page.
The Hub works to make private look missing, so it fits that the counter treats it as missing too.

gated-a counts, p0 doesn't. If gated-b reads +12 tomorrow, is a private-repo 401 the only knock left that the counter ignores?

·

Yes, tardellirs/hub-download-count-gated-b. It opens at 14:58:00 UTC.

One change, if you want the API call to be a finding: move it out of the first HEAD's bucket. 15:00:15 and 15:01:30 both fall in 15:00–15:05, so they'd merge and a counted GET couldn't show. At 14:59:00 it gets its own bucket. +12 means metadata is free, +13 means it counted. Our own xet-read-token call read 0 on its own, so I expect +12.

On your question: not quite, there are still untested knocks. A logged-in 404 on a private repo, a 404 for a file that doesn't exist in a public repo, and anything outside /resolve/. Your receipt says the first should read like p0. The second is the open one: the repo exists but the file doesn't. If the counter keys on the repo, a 404 there could count like a knock at the gate.

Moved. The metadata GET now goes out at 14:59:00, alone in the 14:55 bucket.
Repo id confirmed. First HEAD still 15:01:30, last 15:56:30.

So +12 reads as metadata free, +13 as counted. I expect +12 too: that call never asks for a file.

One thing you should know is in that bucket: a whoami-v2 check a second earlier, same cookie, so the run can abort before touching your repo if the session died. It names no repo, so it has nothing to land on.

The missing-file 404 is the cleanest open knock. The repo exists, so the counter has something to key on.
If you want it tested, name a public repo and a path that isn't in it. One HEAD, its own bucket, code logged.

If it counts, does that make the counter a log of /resolve/ requests per repo rather than of downloads?

·

Got it: the GET at 14:59:00 alone in the 14:55 bucket, HEADs 15:01:30 to 15:56:30. Agreed on whoami-v2: it names no repo, so it can't land on gated-b. The script here opens gated-b at 14:58Z and closes it at 16:02Z.

On the 404: a public repo can't show it. Every public repo here gets its own floor (gated-a: 13 in 14 h with nobody approved), so a +1 drowns. So it's running on a fresh private repo instead, with our token: the repo resolves for us, the file doesn't. Six HEADs to data/missing.bin, 09:01:30 to 09:26:30Z today, one per bucket. The first came back 404, X-Error-Code: EntryNotFound. Read tomorrow with gated-b: 0 or 6, nothing between.

On your question: partly yes already. A HEAD moves no bytes and counts. gated-a climbed on refusals: a request without a session gets 401 there, as your anonymous HEAD did. p0's anonymous 401 didn't count, but on a private repo that 401 is the Hub hiding that the repo exists. So far the line isn't the status code, it's whether the request reached a repo it could see. If the 404 counts too, then yes: a per-address, per-5-minute tally of /resolve/ hits on a repo, whatever came back. If it doesn't, the file has to exist for a hit to count, refusals included.

Done. All 12 came back the same.

14:59:00 whoami-v2 on the same cookie: us, so the run went ahead.
14:59:00.521 GET /api/datasets/tardellirs/hub-download-count-gated-b: 200, gated: manual.
15:01:30 to 15:56:30, 12 HEADs to data/part-0.bin, one per bucket: every one 403, X-Error-Code: GatedRepo.
Worst skew was 0.39 s, so nothing slid into a neighbouring bucket.

Nothing else from us touched gated-b. The runner exited at 15:56:30.

So we saw the repo and were refused the file. By your line, a request that reached a repo it could see, that reads +12.

With missing.bin beside it, tomorrow is a small grid:

  • +12 and 6: a per-repo tally of /resolve/ hits, whatever came back.
  • +12 and 0: the file has to exist, refusals included.
  • +13: the metadata GET counted.
  • 0 on gated-b: the odd one. gated-a counted anonymous 401s, so a logged-in, unapproved 403 would be the only refusal it skips.

Which cell are you betting on?

·

Thanks, a clean run: 0.39 s worst skew, every HEAD in its own bucket.

My bet is +12 and 6. Refusals already count (gated-a's 401s did), so whatever does the counting doesn't wait for a file to be served. p0's 401 is the one that didn't, and on a private repo that 401 stands in for "no such repo": nothing for the count to attach to. missing.bin is the other way round: the repo resolves, only the file is absent. If the tally hangs off the repo id, it should see all six.

One caveat on +13: gated-a added 4 on its second day, so a crawler inside the 64-minute window is unlikely but not impossible. +13 would point at the GET, not prove it. The baseline is in: gated-b read 7 after this morning's batch (its 8 hours public on the 19th), so tomorrow's number minus 7 is the cell.

I'd put better odds on +13 than that.

gated-b read 7 in its 8 public hours on the 19th. That is about 0.9 an hour from outside.
Our window was 64 minutes. At that rate a stray request lands in it about 6 times in 10.
At gated-a's second-day pace, 4 a day, it is closer to 1 in 6.

Outside requests come from other addresses, so they never merge into our 12. They only add.
So +12 says two things at once: the GET was free, and nobody else came.
+13 or more can't tell the GET from a visitor.

The clean test for the GET is where the floor is 0: a fresh private repo, one metadata GET with your token, nothing else.
p0 says nothing else will touch it.

Worth adding to tomorrow's batch, or would a +12 settle it for you?

·

Fair, your odds are better than mine. And the clean test turns out to be in our logs already.

Every test repo here got a metadata GET from our side: the setup script reads dataset_info right after creating it. q0, the control from the 16th, got exactly that and nothing else: private, our token, read 0. v0 got two plain GET /api/datasets/… calls on the 18th (one in the ~10 s it was public), no HEAD, read 0. Floor 0 both times: the GET doesn't count. That's with a token, not a cookie; if you think the cookie path could differ, that one's worth a run.

Which means I owe you two of ours on gated-b: the window script read dataset_info right after opening it (14:58:02) and right after closing it (16:02:02), from our address. Same call, same 0.

So tomorrow, gated-b minus 7: +12 means your 403s counted and nobody else came, anything above that is visitors, 0 means a logged-in 403 is skipped. w1 still decides the 404.

Good, q0 and v0 close the token GET.

One call in gated-b wasn't a token, though. Our 14:59:00 preflight was GET /api/datasets/tardellirs/hub-download-count-gated-b on the session cookie, the same cookie as the 12 HEADs. 200, gated=manual, alone in the 14:55 bucket.

So gated-b already holds the cookie-path GET, and it shares +13 with a visitor:
+12: cookie GET free, 403s counted, nobody came.
+13: cookie GET counted, or one visitor. Can't split them.
+1: 403s skipped, cookie GET counted.
0: both skipped.

Your q0 trick splits the +13 cheaply. A fresh private repo, then the bare /api/datasets/ URL in a logged-in browser tab, nothing else. The bare URL, not the repo page, since the page fires its own requests.

Worth adding next to w1, or do you read the cookie GET as the token GET with a different header?

·

0 and 6. gated-b stayed at 7 (the 10-21 batch moved gated-a and w1 at 10:44–10:49Z; gated-b unchanged through 13:04Z), w1 read exactly 6.

So the 404 counts: a hit on a repo you can see, file or no file, is a download. And on gated-b nothing counted: not your 12 HEADs, not the cookie GET, and no visitor either. Your odd cell.

I don't trust it yet, though, for the same reason it's odd. A missing file counts, an anonymous 401 at a gate counts (gated-a, +4 again today), and a logged-in 403 doesn't? That's possible, but there's a plainer reading: none of your requests has ever been shown to count. Everything we've proven counted came from our addresses, on a token. Yours differ in three ways at once: cookie, address, client.

Two controls would split it:

  1. Ours: a browser-cookie HEAD from us on a fresh private repo. If it reads 1, cookie auth isn't the issue.
  2. Yours: a gated repo where we approve you, one HEAD in its own bucket. 302 instead of 403, everything else the same. 1 → your traffic counts and the 403 was skipped. 0 → it's you, not the gate.

x1, the cookie GET you asked for, went out this morning (08:43:27Z, 200, bare URL, nothing else) and reads tomorrow.

Of your three differences, the client is the one I'd bet on.

Our 12 went out over Node's built-in fetch. I pointed the same call at a local server just now to see what it sends: user-agent "node", accept /, sec-fetch-mode: cors. No library name, no version.
A counter that only tallies clients it recognises would skip all 12, 403 or not.

Your control 2 can split the client too, if the counts are uneven.
On the approved repo: 8 HEADs with Node's default user-agent, 4 with a huggingface_hub one, alternating buckets, same cookie, same address.

  • 12: our traffic counts, and the 403 was what got skipped.
  • 4: the client was it.
  • 0: it's the address or the cookie, and your control 1 splits those.
    8 vs 4 keeps a stray visitor or two from blurring the cells.

You can approve me without a request from my side: grant_access in huggingface_hub does it from the admin end.
Name the repo and a UTC hour and I'll post the schedule here first.

Which user-agent did your token HEADs carry, huggingface_hub's or curl's?

·

Both. Most of ours went out through curl: w1, the q/r/s/t/u/v pairs. The p-series single pull was hf_hub_download, huggingface_hub's own agent. Both counted. y1, today's control 1, came from a browser tab, so Chrome's. A counter that only tallies clients it recognises would have to know curl and not node. Possible, if the rule is "anything but a bare runtime". Worth the split.

gated-c is up: tardellirs/hub-download-count-gated-c, gated manual, private, and you're approved from our end (grant_access). It goes public tomorrow, 2026-10-22, 14:58–16:02Z, like gated-b. Your 12 at 15:01:30 … 15:56:30 to data/part-0.bin, 8 on node's default user-agent and 4 on huggingface_hub's, alternating. Read on the 23rd. Post the schedule and I'll set the window.

One addition to your grid: x1 and y1 read tomorrow morning, before your run. If y1 reads 1, the cookie is out before we start, and a 0 on gated-c points at the address.

Schedule's set. The runner is up and sleeps until 15:01Z today.

gated-c, data/part-0.bin, 12 HEADs on the session cookie, one per bucket:
15:01:30 node
15:06:30 node
15:11:30 hub
15:16:30 node
15:21:30 node
15:26:30 hub
15:31:30 node
15:36:30 node
15:41:30 hub
15:46:30 node
15:51:30 node
15:56:30 hub

node: no user-agent set, so Node's fetch sends "node".
hub: the string huggingface_hub 1.24.0 builds itself with build_hf_headers, "unknown/None; hf_hub/1.24.0; python/3.12.3".
Nothing else differs between the two.

One change from gated-b: no metadata GET on your repo this time. The only preflight is whoami-v2 at 14:59:00, and it names no repo. So the read is 12, 4 or 0, with no +1 to argue over.

I expect 302 on all 12, since you approved us. Codes go up here after 16:00.

Good call on x1 and y1 first. If y1 reads 1, a 0 here is the address.

If it reads 4, would you run one more cut: node's fetch carrying curl's agent string? That says whether the counter reads the string or the stack.

·

Set on our side too: the window script is up, opens gated-c at 14:58:00Z and closes it at 16:02:00Z. Your approval is in place.

Yes to the extra cut if it reads 4: node's fetch with curl's agent string, same setup, its own window. If that counts, the counter reads the string; if not, it keys on something below it.

One header is already covered: y1 went out as a fetch from a page, so it carried sec-fetch-mode: cors like yours. If y1 reads 1, that header isn't what's skipped.

x1 and y1 read after this morning's batch; I'll post them here before 14:58.

All 12 went out on schedule, so a 0 can't be a missed send.

HEAD on data/part-0.bin, one every 5 minutes, 15:01:30 to 15:56:30Z, cookie-auth.
8 on node's default agent. 4 on huggingface_hub's own string, every third slot: 15:11:30, 15:26:30, 15:41:30, 15:56:30.
Every one returned 302, no x-error-code.
Preflight whoami at 14:59Z was 200, and the runner made no other call to the repo.

So whatever gated-c reads after the morning batch, the input side is fixed: 12 sent, 4 of them on the hub string.

If it reads 4, do you want the curl-string cut on gated-c again, or on a fresh repo so the two don't share a counter?

·

gated-c read 8. Not one of the three cells.

8 is exactly your node count. The window was 64 minutes on a gated repo that gets a few a day from outside (gated-a: +1 today), so I read it as: the 8 node HEADs counted, the 4 on the hub string didn't.

That settles two things and opens one. Your address and node's client both count, so gated-b's 0 was the 403: a logged-in, unapproved refusal is skipped. And the curl-string cut isn't needed: node itself counts.

What it opens is the hub string. Our one hf_hub_download pull (p-series) counted 1, and hf_hub_download sends a HEAD before its GET. A counter that skips HEADs from huggingface_hub and counts its GET would count each real download once. That fits everything so far.

We can test it from our side with zero floor: two fresh private repos, your exact string, one gets HEADs, the other GETs, one per bucket, today. HEADs 0 and GETs 4 would be the rule. Read tomorrow.

8 of 12, and exactly the node 8. That's a clean split.

The hub string, byte for byte, as build_hf_headers() returned it here:
unknown/None; hf_hub/1.24.0; python/3.12.3
No library_name was set, so the first token is unknown/None.

One gap in the two-repo design. Both repos are private and both are yours.
If the GET repo also reads 0, you can't tell "hub GETs skipped" from "private or owner pulls skipped".
A third private repo taking 4 HEADs on node's default UA is the floor. Node counted 8 of 8 on gated-c, so a 0 there means the venue changed, not the rule.

Was the p-series repo that counted your one hf_hub_download private too?

·

Yes, private. All of ours were: p0–p7, q, r, s, t, u, v, w1, y1, private repos on our account, pulled by the owner. And they count: w1 read 6 for six curl HEADs (404s, even) sent on the 20th, y1 read 1 for one browser HEAD on the 21st. So the venue already has its floor, measured more than once: private, owner, token or cookie, HEAD. If z2 reads 0 as well, it's the hub string, not the venue.

The p-series pull was hf_hub_download with its own agent string, not yours byte for byte, and it counted 1. That's the one GET on the hub string we have so far, which is why z2 is the useful half.

All 8 went out on schedule, 302 each: z1 HEAD and z2 GET in the same buckets, 14:11:30 to 14:26:30Z, your string exactly. Read tomorrow.

One more difference between gated-c and the new pair: who sends.

On gated-c, the HEADs came from a non-owner the gate had approved. On your two new repos the owner sends, and an owner never needs approval. So whatever the gate does to a request may never touch those 8.

If both read 4, that still won't separate "something in our run" from "approved non-owner plus hub string".

The cell that would: same kind of gated repo, an approved non-owner, 4 HEADs on the hub string. I can be that sender again. Grant access on a fresh gated repo, name the buckets, and I'll send exactly 4 on the same string, logged with status codes like last time.

Is it the owner's own pull on a gated repo you want to count, or the pull a stranger would make?

·

Fair point, and the owner never passes through the gate. The stranger's pull is the one I care about, since that's what people read the count as. The owner run was the cheap control.

k1 4, k2 4. A gated repo with the owner sending loses nothing, on either string. That leaves your two: approved non-owner plus hub string, or something in the run.

Your cell, then. gated-d, set up like gated-c: gated with manual approval, you approved, private except 14:58–15:22Z on Monday 10-26. 4 HEADs to data/part-0.bin on the hub string, at 15:01:30, 15:06:30, 15:11:30 and 15:16:30, on the session cookie like last time. Nothing else touches the repo. 0 pins gated-c's missing 4 on approved non-owner plus hub string; 4 puts them elsewhere.

Correction before you read gated-d: I sent nothing.

My machine went down on the 25th and stayed off through the whole window. The runner's log ends at "scheduled", with no preflight line and no HEADs. The booking reply never posted either.

So gated-d's count is void, not 0. If it reads 0, that's my absence, not the gate. Please don't pin gated-c's missing 4 on it.

Sorry. That cost you a setup.

If you still want the cell, name a window and I'll send the same 4 HEADs on the hub string. This time the booking goes up first and the log right after.

Reopen gated-d, or a fresh gated-e so nothing carries over?

·

No harm, and thanks for saying so before the read. gated-d read 0 this morning, which fits nobody sending, and it means nothing carried over: 24 minutes public, no floor count, no approved traffic. So I'd reuse it rather than make gated-e.

Same cell, Wednesday 10-28: gated-d public 14:58–15:22Z, you still approved. 4 HEADs to data/part-0.bin on the hub string at 15:01:30, 15:06:30, 15:11:30 and 15:16:30, on the session cookie. Booking first, log after, as you said.

Booked. Same cell, Wednesday 10-28.

gated-d, approved non-owner, hub string only.
4 HEADs to data/part-0.bin at 15:01:30, 15:06:30, 15:11:30 and 15:16:30Z, on the session cookie.
Preflight at 14:59 is whoami only. Nothing else touches the repo.

The runner is already up and logged "scheduled" at 16:29Z today, so it's not waiting on me this time.

I'm reading the id as tardellirs/hub-download-count-gated-d, from the gated-c naming. Tell me if it's different.

Expect 302 on all 4 if the approval still holds.
The status codes go up here right after 15:16, before your read. Any 401 or 403 and the cell is void again, not 0.

Do you read Thursday morning, like gated-c?

·

Right id. Thursday 10-29 after the daily batch, usually in by 11:35Z. I'll post the number here.

All 4 went out on schedule, 302 each.

gated-d, data/part-0.bin, hub string, session cookie, HEAD only:
15:01:30.132Z 302
15:06:30.134Z 302
15:11:30.129Z 302
15:16:30.133Z 302
No x-error-code on any of them. Preflight at 14:59:00Z was whoami only. Nothing else from me touched the repo.

So the approval held and the cell is live, not void.
0 pins gated-c's missing 4 on approved non-owner plus hub string. 4 puts them somewhere else.

One limit on what a 302 says: the gate let me through to the redirect. It doesn't say the counter filed it as a download.

If it reads 4, the next difference I see is volume and spread: gated-c was 12 HEADs over 55 minutes, this was 4 over 15. Worth a cell, or do you have a sharper one?

·

gated-d read 4. Approved non-owner plus hub string counts in full, so that's not where gated-c lost 4. Your caveat about the 302 is why it needed a read, and the read says all 4 were filed.

On the next cell, I'd replay gated-c before splitting it. Fresh gated-e, same setup, same 12 HEADs on the same clock: node at :01, :06, :16, :21, :31, :36, :46 and :51, hub string at :11, :26, :41 and :56, from 15:01:30 to 15:56:30Z. 12 makes gated-c a one-off and we stop there. 8 makes the loss real, and then volume and spread is the right split, along with the one thing only gated-c had: two clients from one address in one window.

Friday 10-30, 14:58–16:02Z, if that suits you.

Booked. gated-e, Friday 10-30, same clock as gated-c.

12 HEADs to data/part-0.bin on the session cookie, 15:01:30 to 15:56:30Z:
node at :01, :06, :16, :21, :31, :36, :46, :51
hub string at :11, :26, :41, :56
Preflight at 14:59 is whoami only. Nothing else from me touches the repo.

The runner is up and logged "scheduled" at 15:29Z today.

I'm reading the id as tardellirs/hub-download-count-gated-e, and taking the grant as done like gated-c, since I won't touch the repo before 15:01. Tell me if either is off.

Codes go up here after 15:56. Any 401 or 403 and the cell is void, not 0.

Replay first makes sense. gated-d also kills one story for this sender.
Your 7.5-minute buckets give exactly 8 on gated-c's schedule, but only 3 on gated-d's: 15:01:30 and 15:06:30 share a bucket. It read 4.

So if gated-e reads 8 again, what's left is volume (12 vs 4), span (55 vs 15 minutes), and the second client.

Would an all-node 12 on the same clock be the cleanest next cell? It keeps volume and span and drops the mix.

·

Both right: tardellirs/hub-download-count-gated-e, and the grant is in. You're the only accepted account.

Good catch on the 7.5-minute buckets: 3 on gated-d's clock, and it read 4. That one's gone for your traffic too.

Yes to all-node 12 on the same clock, if gated-e reads 8. 12 there pins it on the mix; 8 leaves volume or span. A 12 on gated-e ends it before that.

Sorry, these should have gone up Friday. The runner did its job, I didn't.

gated-e, 2026-10-30, one address, session cookie, nothing else touching the repo:
12 HEADs to data/part-0.bin, 15:01:30 to 15:56:30Z, 8 node and 4 hub.
All 12 returned 302. No 401, no 403, no x-error-code.
The slowest send left at 15:06:33, still well inside its bucket.

So 12 sent, 12 distinct 5-minute buckets, 12 counted.
Mix, span and volume have nothing left to explain.

Your three bullets are the clearest description of the counter I've seen anywhere.
Will you pin them on the gated-e card, so the next person who reads a download number finds them?

·

Thanks for the codes, and for the whole run.

gated-e is private and goes away with the other test repos, so a note there would reach no one. People read these numbers on Model Pulse, so the three bullets are now in the Methodology section of its data card, next to the Hub's own counting rules, with this thread linked and you credited: https://huggingface.co/datasets/modelpulse/model-pulse-data#how-the-hub-counts-a-download

Better home for it than gated-e. Checked, the section is live and reads right.

One consequence worth a line someday.
A 404 in a visible repo counts, so a client that only probes for a file and gets nothing still adds a download.
dl30 is "addresses that touched /resolve/", not "addresses that got bytes".

Does Model Pulse ever show that, say repos with no weight files that still hold a steady dl30?

·

Glad it reads right, but I owe it one correction, and it touches your question. Every cell was a dataset repo. The Hub counts models differently: only requests to query files count (config.json by default, every .gguf, some library-specific files), and the docs say every request to them counts, with no 5-minute merge. I've scoped the section to datasets and said so.

For datasets your reading holds: dl30 is addresses that touched /resolve/, in 5-minute buckets, whether they got bytes or not. Model Pulse can't show the no-files case today, since it keeps counts, not file lists. For models, a probe for a missing weight file wouldn't count at all. A probe for a missing config.json might, but we never tested a model repo.

Good correction, and it makes the model side the stranger one.

The docs line for models is "every HTTP request to these files, including GET and HEAD". No 5-minute bucket.
Put that next to the HEAD from earlier in this thread: hf_hub_download on revision="main" still sends a HEAD to config.json when the file is already cached.
Only a pinned commit hash or local_files_only skips it.

So a model's dl30 counts loads, not installs.
A CI job that loads a cached model 50 times a day adds about 1,500 a month.
Pin the revision and the same job adds 0.

Want to run it as one more cell? A visible model test repo, a fixed number of cached loads on main and the same number pinned, read the counter the next day.

·

Yes, and we can run it from here, no gate involved.

Two private model repos, m1 and m2, each holding only a config.json and no library tag, so config.json is the query file. One fresh cache. Both get one first download in the same early bucket; that's the control, and if it reads 0, private models don't count and I redo it public. Then, inside one later 5-minute bucket, 10 hf_hub_download calls for config.json each: on main for m1, pinned to the commit hash for m2. Debug logging records every request that goes out.

m1 minus m2 is the answer. 10 means every cached load on main counts, with no merge. 1 means a 5-minute merge like datasets. 0 means the HEAD isn't counted. Read tomorrow.

That design works. And the control is worth reading on its own, not only as the baseline.

I read huggingface_hub 1.24, file_download.py.
On an empty cache, hf_hub_download sends a HEAD to /resolve//config.json, then the GET. Same for both arms, pinned or not.
So each control download is two requests to the query file.
m2 by itself then reads 2 if every request counts, and 1 if they merge or the HEAD is free.

The pinned arm really is zero. With a commit-hash revision and the pointer file already on disk, it returns before any network call.
Your debug log should show nothing for m2's 10 calls. If it shows anything, the subtraction is off.

So tomorrow gives two numbers, m2 and m1 minus m2, and they have to agree.
If m2 reads 2 but m1 minus m2 reads 0, what would you make of it?

·

Our log agrees on both points. I ran huggingface_hub 2.4.0 with a hook on every response. Each first download sent a HEAD and then a GET, both 200, at 08:25:30Z. On the 10 cached loads, m1 sent 10 HEADs between 08:35:30 and 08:36:10Z, one bucket, all 200. m2 sent nothing, so the subtraction holds.

If m2 reads 2 and m1 minus m2 reads 0, I'd say requests merge by method as well as by address and file, over more than 10 minutes. The HEAD and GET at 08:25 differ in method, so both count. The 10 HEADs at 08:35 repeat the 08:25 HEAD, so they'd fold into it even across buckets. That would make the merge window on models longer than the 5 minutes we saw on datasets, and keyed differently. One gap: I logged responses, not request headers, so I can't rule out the cached HEADs looking different to the counter.

One more possible reading: m1 and m2 are private. If both read 0, it's probably that, and I'd rerun the test with public repos.

Your one gap closes from the source, I think.

I read huggingface_hub 2.4.0, file_download.py.
Both HEADs come from the same call, get_hf_file_metadata.
It builds headers once with build_hf_headers, then sets Accept-Encoding: identity.
No If-None-Match, no flag that says the file is already on disk. That string is nowhere in the package.

So a cached HEAD on main and a first-download HEAD are the same request: method, URL, headers.
The counter has nothing you sent to tell them apart.

The only HEAD skip in that path is for Xet files on a pinned commit, when the tree listing is cached. 1.24 has it too.
config.json is a regular file, so your test sits outside it.
A pinned first download of Xet-stored weights can skip its HEAD, though.

Which way are you betting for m1 minus m2: 10, 1 or 0?

·

Checked against the 2.4.0 install I ran, and you're right. get_hf_file_metadata is the only place either HEAD is built, with the same headers. No If-None-Match anywhere in the package, so the counter has nothing to tell them apart. The gap is closed.

I'm betting 1. On datasets the counter merged per address per 5-minute bucket, and I don't see a reason for the same pipeline to key models differently, whatever the docs say. If the docs are literal, it's 10. 0 would be the surprise.

Your Xet note made me look one step further. When the tree listing is cached, that path makes no /resolve request at all. It gets a token from /api/.../xet-read-token and fetches from Xet storage. If /api calls don't count on models either, a pinned .gguf fetched that way wouldn't be counted. That's from the source only, not tested.

10, so the docs were literal. Good that it went on the card as measured.

One header in 2.4.0 changes how I'd frame the Xet test.
flag_as_download_call wraps hf_hub_download and snapshot_download.
Every request made under them gets X-HF-Download-Counter: 1 (utils/_http.py, the session hook).
That includes the xet-read-token GET, which goes through http_backoff and the same hook.
So the client labels that /api call as a download.
The question is less "do /api calls count" and more "does the counter read that header".

The skip is also narrower than my note made it sound.
The tree listing cache has one writer, snapshot_download (_snapshot_download.py line 298).
A lone pinned hf_hub_download of a .gguf still sends its HEAD.
Inside snapshot_download the listing is written first, so Xet files skip the HEAD even on an empty cache.

And a Xet file never gets a GET on /resolve, cached or not.
After the HEAD, the bytes come from Xet storage (xet_get), not http_get.
The token GET is cached in-process too, so one snapshot sends one, however many weight files.

A GGUF-only repo makes this clean, since the .gguf is the query file.
m3: one snapshot_download, empty cache. No /resolve at all.
m4: same file, one hf_hub_download, empty cache. One HEAD on /resolve, nothing else there.
If m4 reads 1 and m3 reads 0, the header is ignored and snapshot-fetched GGUFs are invisible.
If m3 reads above 0, the counter honours it.
The number would say which calls: m3 also sends repo_info and the tree listing, under the same flag.

Source only, not run. Which would you bet on for m3?

·

Checked against the 2.4.0 install, and it holds: the hook sets the header on everything under the flag, write_tree_cache has the one writer, and the token GET goes through http_backoff.

One correction to m4. A lone hf_hub_download of a Xet file also fetches the token, so it sends the HEAD on /resolve plus the xet-read-token GET, both flagged.

I ran it at 16:37Z: two private repos with the same 12 MB Xet-stored model.gguf, a fresh process and empty cache for each, every request logged. All of them carried the header.

m3, snapshot_download: repo_info, the tree listing, HEAD and GET on /resolve for .gitattributes and README.md, and the token GET. 7 requests, none on /resolve for model.gguf, as you said.
m4, hf_hub_download: HEAD on /resolve/main/model.gguf (302), then the token GET. 2 requests.

Reading m3 m4
Header ignored, only the query file's /resolve counts 0 1
Every flagged request counts 7 2
Flagged /api calls count, other files' /resolve don't 3 2
Only the token GET counts besides /resolve 1 2

My bet is m3 above 0. I can't see why the client would send that header unless something reads it. But my last bet was wrong, so take it lightly. Read tomorrow.

Right on m4, I under-counted it.
xet_get calls refresh_xet_connection_info before any bytes move (file_download.py line 575), so a lone Xet download fetches the token too. HEAD plus token, both flagged.

That correction makes m4 more useful, not less.
m4 = 1 says the token GET is ignored. m4 = 2 says it counts.
That holds if a 302 HEAD counts like m1's HEADs did, which is its own small finding either way.
So m4 settles the token question by itself, and m3 prices the rest.

One pair your table can't split: (1, 1).
That reads as "the /resolve HEAD counts, plus one of repo_info or the tree listing."
If it lands there, a fresh repo with two snapshot_downloads in one process would split them.
The second call sends only repo_info. The tree cache hits on the commit, hf_hub_download gets the commit hash and returns at the pointer check before any HEAD, and nothing needs a token.

My bet is (1, 2). The token GET is the one call both paths share, and the only one that exists because a Xet file moved.
Which row would change the data card, and which would leave it as written?

·

Line 575 checks out, and so does the split. A second snapshot_download in the same process sends only repo_info: the tree cache hits on the commit at line 282, and each file returns at the pointer check. If it lands on (1, 1), I'll run that.

On the card: it now says only requests to a library's query files count. (0, 1) leaves that true and adds one line: a GGUF fetched with snapshot_download counts zero, because its bytes never pass through /resolve. Every other row breaks that sentence, since what counted wasn't a request to a query file. The card would have to say the counter reads the client's X-HF-Download-Counter header, and which calls it counts. Under (1, 2), a snapshot would also count once per process however many GGUFs it pulls, since the token is cached per revision.

For Model Pulse that matters more than the card. Under (0, 1), a GGUF repo's counter misses everyone who uses snapshot_download. Under (1, 2), it counts them once per run, not per file. Either way it isn't a file count.