User Tools

Site Tools


provenance:design:ip_classification

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Next revision
Previous revision
provenance:design:ip_classification [2026/08/12 09:23] – Create provenance page for design:ip_classification: queries, denominators, the 18-string fold residue and where each went, two report-script bugs, quote verification, and the 4,322 -> 5,859 refresh diff. Partly reconstructed; marked as such. Authored by karel.kubicek.claudeprovenance:design:ip_classification [2026/09/22 22:27] (current) – Add §14: the 2026-09-22 Fable review (findings on disk), Sonnet currency pass, settlement of the 2026-09-03 self-served pass, revision ledger, classify_ips.py change with the preserved 2026-08-06 output, vendor re-check, embedded report script/fold/output karel.kubicek.claude
Line 11: Line 11:
 ^ Item ^ Value ^ ^ Item ^ Value ^
 | Content page | [[design:ip_classification]] | | Content page | [[design:ip_classification]] |
-| Report script | ''scripts/report_ip_classification.mjs'' (''--wiki'' emits DokuWiki tables) |+| Report script | ''scripts/report_ip_classification.mjs'' (''%%--wiki%%'' emits DokuWiki tables) |
 | Folds | ''scripts/ip_fold.mjs'' — ordered regex families, each tagged with the //question// it answers | | Folds | ''scripts/ip_fold.mjs'' — ordered regex families, each tagged with the //question// it answers |
 | Supporting script | ''scripts/maxmind_version.mjs'' — full-text pass, not a schema field | | Supporting script | ''scripts/maxmind_version.mjs'' — full-text pass, not a schema field |
-| Quote verification | ''scripts/quote_check.mjs --classification ip-address'' |+| Quote verification | ''scripts/quote_check.mjs %%--classification%% ip-address'' |
 | Data | ''data/extract/run1/extractions.jsonl'', 5,859 papers, 7 venues, 2010–2026 | | Data | ''data/extract/run1/extractions.jsonl'', 5,859 papers, 7 venues, 2010–2026 |
 | Refreshed | 2026-08-12 | | Refreshed | 2026-08-12 |
 +| Reviewed | 2026-09-22, Fable plus a Sonnet currency pass — §14 |
  
 ===== 2. Populations and denominators ===== ===== 2. Populations and denominators =====
Line 35: Line 36:
 <code bash> <code bash>
 cd /workspace/artifacts/wiki cd /workspace/artifacts/wiki
-node scripts/ip_fold.mjs                                  # self-test, prints residue 
 node scripts/report_ip_classification.mjs                 # every figure node scripts/report_ip_classification.mjs                 # every figure
 node scripts/report_ip_classification.mjs --wiki node scripts/report_ip_classification.mjs --wiki
Line 45: Line 45:
 </code> </code>
  
-''check_page_numbers.mjs'' left 11 figures unaccounted, all deliberate and all named here so the next run does not re-investigate them: ''100'' ("shares exceed 100%"), ''26.9'' (from ''maxmind_version.mjs'', a different script), ''4,322'' (the old corpus size, quoted as history), and seven figures quoted from cited papers — ''89.4'' and ''95.8'' from Gharaibeh et al., ''93'' from Urban et al., ''4,286'' / ''72'' / ''87'' / ''98.3'' from Kumar et al., and ''2012'' inside the Benson et al. quote.+The fold has no self-test of its own; its residue is printed at the foot of the report script's output (§14.8). 
 + 
 +Re-run 2026-09-22 on the corrected page, ''check_page_numbers.mjs'' leaves **17** figures unaccounted, all deliberate and all named here so the next run does not re-investigate them: 
 + 
 +^ Figures ^ Where they come from ^ 
 +| ''100'' | "shares exceed 100%" | 
 +| ''18'' | not a figure: the guard strips ISO dates but not the year range "2018–2021" | 
 +| ''26.9'', ''52'' | ''maxmind_version.mjs'', a different script (output in §14.8) | 
 +| ''34'' | derived on the page from Darwich et al.'s 89% and 55% | 
 +| ''4,322'' | the old corpus size, quoted as history | 
 +| ''177'' | ''report_llm_currency.mjs'' (§13) | 
 +| ''4,439'', ''6,483'', ''60.1'' | ''report_annotation.mjs'', owned by [[statistics:annotation]] (§14.1) | 
 +| ''89.4'', ''95.8'' | quoted from Gharaibeh et al. | 
 +| ''93'' | quoted from Urban et al. | 
 +| ''4,286'', ''72'', ''87'', ''98.3'' | quoted from Kumar et al. | 
 + 
 +Before 2026-09-22 this paragraph said 11, then was three edits behind at 16; the list above replaces it. ''2012'' (inside the Benson et al. quote) no longer falls in the window.
  
 ===== 4. What the refresh changed ===== ===== 4. What the refresh changed =====
Line 115: Line 131:
 ===== 6. Quotes checked ===== ===== 6. Quotes checked =====
  
-//Recorded, 2026-08-12.//+//Recorded, 2026-08-12. Re-run 2026-09-21 with the PDF fallback — see //Quote-check refresh, 2026-09-21// at the foot of this page.//
  
 <code> <code>
-$ node scripts/quote_check.mjs --classification ip-address +$ node scripts/quote_check.mjs --classification ip-address --show 400 
-484 quotes checked: 232 exact, 158 partial (>=60% of 5-word windows), +484 quotes checked: 232 exact, 158 partial (>=60% of 5-word windows), 67 rescued from the PDF, 27 below threshold in both renderings, 0 with no full text on disk.
-94 below threshold, 0 with no full text on disk.+
 </code> </code>
  
-**94 below threshold is too many to read individually and they were not all read.** Six were sampled and checked by hand against ''paper.cols.txt'' with whitespace normalised — ''2012/IMC/breaking-for-commercials'', ''2012/USENIX/aurasium'', ''2014/CCS/autoprobe'', ''2015/NDSS/mind-your-blocks'', ''2010/IMC/demystifying-service-discovery'', ''2013/NDSS/automatically-inferring-the-evolution'' — and **all six are present in the paper**. The failure mode is always the column repair, e.g. the MaxMind quote in ''breaking-for-commercials'' reads in the source as:+**The old figure was 94 below threshold, and 67 of those 94 are a defect in the stored text rather than in the extraction** — below threshold against the rendering the extractor read, at or above it against an independent ''pypdf'' rendering of the same ''paper.pdf''. ''exact'' (232) and ''partial'' (158) did not move. **27** are below threshold in both renderings, and those are what has to be read by hand. 
 + 
 +Six of the then-94 were sampled and checked by hand against ''paper.cols.txt'' with whitespace normalised, before the fallback existed — ''2012/IMC/breaking-for-commercials'', ''2012/USENIX/aurasium'', ''2014/CCS/autoprobe'', ''2015/NDSS/mind-your-blocks'', ''2010/IMC/demystifying-service-discovery'', ''2013/NDSS/automatically-inferring-the-evolution'' — and **all six are present in the paper**. The fallback now reaches three of the six on its own (''breaking-for-commercials'', ''aurasium'', ''mind-your-blocks''); the other three (''autoprobe'', ''demystifying-service-discovery'', ''automatically-inferring-the-evolution'') are still below threshold in both renderings and sit inside the 27, so the hand-read result is the only evidence for them. The failure mode is always the column repair, e.g. the MaxMind quote in ''breaking-for-commercials'' reads in the source as:
  
 <code> <code>
Line 131: Line 148:
 </code> </code>
  
-**Open, and stated as such on this page rather than on the content page:** the other 88 have not been read. A 19% below-threshold rate is higher than the ''--tools'' checks produce (15%), which is what you would expect from ''classification'' quotes being longer and more often spliced, but it has not been demonstrated. Reading them is the obvious next piece of work on this page.+**Open, and stated as such on this page rather than on the content page:** **24** of the 27 have not been read. The below-threshold-in-both rate is **5.6%** (27 of 484), down from the pre-fallback 19% (94 of 484); the comparable ''%%--tools%%'' figure on [[:provenance:programming:crawler]] is 2.8% (3 of 106), also re-run on 2026-09-21. ''classification'' quotes are still the worse of the two, which is what you would expect from their being longer and more often spliced, but the gap is now 2.8 points rather than 4 and it still has not been demonstrated. Reading the remaining 24 is the obvious next piece of work on this page.
  
 //Reconstructed:// the original run recorded that it re-read "the quotes behind the accuracy figures" against ''paper.cols.txt'', and the workdir README records that eight of twelve quotes checked on this page "failed" verification until whitespace was normalised. Which twelve is not recoverable. //Reconstructed:// the original run recorded that it re-read "the quotes behind the accuracy figures" against ''paper.cols.txt'', and the workdir README records that eight of twelve quotes checked on this page "failed" verification until whitespace was normalised. Which twelve is not recoverable.
Line 145: Line 162:
  
 **Rejected:** not recorded for the original run. **Rejected:** not recorded for the original run.
 +
 +  * **DynamIPs** ({[padmanabhan2020_dynamips]}, CoNEXT 2020) — the one external paper the original run could not read. It was fetched and its wording verified on 2026-09-03; see §12.
  
 ===== 8. What could not be established ===== ===== 8. What could not be established =====
  
-  * **Whether the 88 unread below-threshold quotes check out.** See §6.+  * **Whether the 24 unread below-threshold quotes check out.** See §6 and //Quote-check refresh, 2026-09-21// (88 before the PDF fallback).
   * **Free vs paid MaxMind.** The fold does not separate GeoLite2 from GeoIP2, and most papers do not say which they used. The page says so. Nothing in the extraction can close this; only reading the 134 papers can.   * **Free vs paid MaxMind.** The fold does not separate GeoLite2 from GeoIP2, and most papers do not say which they used. The page says so. Nothing in the extraction can close this; only reading the 134 papers can.
   * **Whether "no validation" is a reporting gap or a real one.** 41.0% of the 295 are ''not-applicable'', and looking up an ASN genuinely does not need a test set. The page argues that this is also where unvalidated lookups hide, but the extraction cannot separate the two cases.   * **Whether "no validation" is a reporting gap or a real one.** 41.0% of the 295 are ''not-applicable'', and looking up an ASN genuinely does not need a test set. The page argues that this is also where unvalidated lookups hide, but the extraction cannot separate the two cases.
Line 154: Line 173:
   * **Criminal IP's family.** See §5.1. One paper, and it could reasonably go under //Is it known-bad?//.   * **Criminal IP's family.** See §5.1. One paper, and it could reasonably go under //Is it known-bad?//.
  
-===== 9. Run log =====+===== 10. Review pass, 2026-08-12 ===== 
 + 
 +//Recorded.// (On whether this review reported, see §14.4.) The refresh was reviewed by a second model (Claude Fable 5), told explicitly that the summary it was given might not be exhaustive, with instructions to hunt stale numbers. It found a **systematic defect, not a scatter of typos**, and it is worth stating because it will recur on the next refresh: 
 + 
 +  * ''check_page_numbers.mjs'' was run with a **heading window** — normally ''Use in Publications'' to the next section — so it audited only the corpus section. Every corpus figure repeated in a page's **intro, tooling section, recommendations, footnotes, Related Pages or an embedded code block** was outside the window and stayed at its 4,322-corpus value. Across the six pages **29 such figures** survived the first pass. 
 +  * The matcher was **substring**, not word-boundary, so ''report.includes('59')'' was satisfied by ''11.59 bits''. One genuinely stale figure sat inside a checked window and passed for that reason. 
 + 
 +Both are fixed in ''scripts/check_page_numbers.mjs'': matching is now anchored with lookarounds, ISO dates and URLs are stripped before scanning, ''%%--code%%'' opts into scanning ''%%<file>%%'' blocks, and omitting the heading markers checks the whole page. **Run it windowed //and// whole-page.** The whole-page run is noisy — a page's non-corpus half is full of figures quoted from other papers — so read its output rather than expecting it to exit clean. 
 +Fixed on this page's content page as a result: the **intro paragraph**, which carried ''234 / 92 / 53'' where the corpus section says ''295 / 114 / 68''; the operator-prefix-list full-text grep, **six papers of 4,322 → seven of 5,859** (the seventh is ''NDSS/2025/secure-ip-address-allocation-at-cloud-scale''); the abuse-feed count **13 of 234 → 17 of 295**; and a surviving corpus-window statement — the page said Ali et al. (NDSS 2026) was "published after our corpus closes", and it is now **in** the corpus at ''NDSS/2026/beyond-rtt-an-adversarially-robust-two-tiered-approach-for-residential-proxy-detection''. 
 + 
 +===== 11. Run log =====
  
 ^ ^ ^ ^ ^ ^
Line 161: Line 190:
 | Model | Claude Opus 5, no sub-agents used for this page | | Model | Claude Opus 5, no sub-agents used for this page |
 | Scope | Mechanical re-derivation. Prose, structure and method selection were not revisited; two sentences changed because the numbers no longer supported them ("has since halved" → "flattened"; the addition of the ''llm'' sentence). | | Scope | Mechanical re-derivation. Prose, structure and method selection were not revisited; two sentences changed because the numbers no longer supported them ("has since halved" → "flattened"; the addition of the ''llm'' sentence). |
-| Script changes | ''ip_fold.mjs'' (5 new families, 6 extended patterns, §5.1), ''report_ip_classification.mjs'' (two bugs fixed, §5.2; new closing section printing the figures the page carried but the script did not), ''quote_check.mjs'' (gained ''--classification'') |+| Script changes | ''ip_fold.mjs'' (5 new families, 6 extended patterns, §5.1), ''report_ip_classification.mjs'' (two bugs fixed, §5.2; new closing section printing the figures the page carried but the script did not), ''quote_check.mjs'' (gained ''%%--classification%%'') |
 | Caveats deleted | "IEEE S&P is only 43% retrieved (paywall)" — 780 of 780 selected papers are now retrieved. "The corpus ends in 2024." | | Caveats deleted | "IEEE S&P is only 43% retrieved (paywall)" — 780 of 780 selected papers are now retrieved. "The corpus ends in 2024." |
 | Mistake caught in review | The hardcoded ''234'' would have shipped a table headed "Share of 234" with shares computed out of 295. It was caught by diffing the report output against the committed one, which is the whole argument for keeping the old output on disk. | | Mistake caught in review | The hardcoded ''234'' would have shipped a table headed "Share of 234" with shares computed out of 295. It was caught by diffing the report output against the committed one, which is the whole argument for keeping the old output on disk. |
 +| Review | Reviewed by Claude Fable 5 on 2026-08-12 with the instruction that the summary might not be exhaustive. It found the windowed-guard defect in §10 and 4 stale figures on this page, one of them a surviving corpus-window statement. All fixes were applied and re-saved the same day. Later content-page revisions are in §14.3. |
 +
 +
 +===== 12. DynamIPs wording verification, 2026-09-03 =====
 +
 +//Recorded as it happened.// The content page cited {[padmanabhan2020_dynamips]} twice (the //Churn// and //IPv6 is different// paragraphs of "IP as an Identifier"). The original run had verified venue, authors and topic against Crossref but could not read the paper: ACM DL returned 403 and the CAIDA PDF "would not extract with the tools available". The second citation therefore carried a footnote saying the finding was paraphrased and unverified. This pass closes that.
 +
 +**Getting the paper.**
 +
 +^ Source ^ Result ^
 +| ''https://dl.acm.org/doi/pdf/10.1145/3386367.3431314'' | still HTTP 403 with a browser User-Agent |
 +| ''https://www.caida.org/catalog/papers/2020_dynamips/dynamips.pdf'' | HTTP 200, ''application/pdf'', 615,785 bytes, sha256 ''ac51460c…3b06eb''; cached at ''review_ip/dynamips_conext2020.pdf'' |
 +| ''catalog.caida.org/paper/2020_dynamips'' | HTTP 200 landing page, confirms the PDF above is the authors' copy |
 +
 +pypdf 6.16.2 extracted all 16 pages (95,238 characters) without trouble; the earlier "would not extract" is not reproducible today. The only artefacts are ''fi''/''fl'' ligatures, curly apostrophes, and run-together words in the Conclusion, which is why the checker below matches in three modes (whitespace collapsed; whitespace removed; whitespace and hyphens removed) and prints which one hit. Running page numbers 55–70 in the PDF agree with the bibliography entry.
 +
 +**Quotes checked.** ''scripts/dynamips_quote_check.py'' re-fetches or reuses the PDF and asserts every quote below is present; it exits non-zero otherwise. Its unedited output:
 +
 +<file text dynamips_quote_check-output.txt>
 +using cached review_ip/dynamips_conext2020.pdf
 +sha256 ac51460ca1ca5a3b40fad31cde7d8229b7fed9dab8d56609e4a59ca2c23b06eb  bytes 615785
 +pages 16  chars 95238  pypdf 6.16.2
 +first line: DynamIPs: Analyzing address assignment practices in IPv4 and
 +
 +[1] ws      abstract
 +    "IPv6 assignments have longer durations than IPv4 assignments—often remaining stable for months—thereby allowing the possibility of long-term fingerprinting of IPv6 subscribers"
 +    …ajor CDN. Our investigation of temporal dynamics with these datasets shows that IPv6 assignments have longer durations than IPv4 assignments—often remaining stable for months—thereby allowing the possibility of long-term fingerprinting of IPv6 subscribers. Our analysis of spatial dynamics reveals IPv6 addressassignment patterns that …
 +
 +[2] ws      §1 contributions
 +    "IPv6 prefixes delegated to residential subscribers can remain stable for months, permitting long-term use of IPv6 prefixes to identify individual subscribers (at the CPE granularity), even if subscribers' devices are using privacy addresses"
 +    …ress assignments in IPv4 and IPv6 on over 3,000 dual-stack probes. We find that IPv6 prefixes delegated to residential subscribers can remain stable for months, permitting long-term use of IPv6 prefixes to identify individual subscribers (at the CPE granularity), even if subscribers' devices are using privacy addresses. IPv4-IPv6 interplay: Using a dataset from a major CDN capturing 32.7 billion I…
 +
 +[3] ws      §3 assignment durations
 +    "Well-defined modes—at 1 day (DTAG), 1.5 days (Proximus), 1 week (Orange), and 2 weeks (BT)—in IPv4 non dual-stack address durations suggest that ISPs renumber addresses periodically"
 +    …v4 address durations tend to be shorter, particularly for DTAG, Orange, and BT. Well-defined modes—at 1 day (DTAG), 1.5 days (Proximus), 1 week (Orange), and 2 weeks (BT)—in IPv4 non dual-stack address durations suggest that ISPs renumber addresses periodically. This result, using 6 years' worth of "IP echo" data, is consistent with observ…
 +
 +[4] ws      §3 assignment durations
 +    "we observe evidence of consistent periodic renumbering on 35 networks when considering non-dual-stack probes"
 +    …or work that also noted periodic renumbering within these ISPs [ 34]. In total, we observe evidence of consistent periodic renumbering on 35 networks when considering non-dual-stack probes. DTAG appears to renumber IPv6 prefixes after 1-day durations as well but this …
 +
 +[5] ws      §3 assignment durations
 +    "renumbering every 24 hours in IPv6 mainly in the following German ASes: DTAG, Versatel (AS8881), Netcologne (AS8422), Telefonica DE (AS6805), and M-net (AS8767)"
 +    …arentheses is the total assignment duration in years from all probes in the AS. renumbering every 24 hours in IPv6 mainly in the following German ASes: DTAG, Versatel (AS8881), Netcologne (AS8422), Telefonica DE (AS6805), and M-net (AS8767). We also observe consistent period renumbering with a 12-hour period in ANTEL (…
 +
 +[6] ws      §3 assignment durations
 +    "12-hour period in ANTEL (AS6057) in Uruguay and with a 48-hour period in Global Village (AS18881) in Brazil"
 +    …6805), and M-net (AS8767). We also observe consistent period renumbering with a 12-hour period in ANTEL (AS6057) in Uruguay and with a 48-hour period in Global Village (AS18881) in Brazil. Long IPv6 /64 durations in most ASes suggest that a /64 can be used to identif…
 +
 +[7] nows    §3 evolution over time
 +    "IPv6 durations have consistently been longer than IPv4 durations and address durations in dual-stack networks tend to be longer than durations in non-dual-stack IPv4 networks"
 +    …(matched in mode nows: pypdf line-break artefact) …mefractionsperyear.Theyear-to-yeartrendsconfirmourinsightsfromtheoveralldataset:IPv6durationshaveconsistentlybeenlongerthanIPv4durationsandaddressdurationsindual-stacknetworkstendtobelongerthandurationsinnon-dual-stackIPv4networks[40].However,wealsofindthatassignment60AnalyzingaddressassignmentpracticesinIPv4…
 +
 +[8] ws      §3 related results
 +    "IPv6 /64 prefixes tend to be stable for months and years in various ASNs, although we find evidence of periodic renumbering in a handful of ISPs"
 +    …2014 to March 2015) [36]. Our results using the RIPE Atlas dataset confirm that IPv6 /64 prefixes tend to be stable for months and years in various ASNs, although we find evidence of periodic renumbering in a handful of ISPs. 4 IPV4-IPV6 INTERPLAY So far, we have studied temporal properties of IPv4 and …
 +
 +[9] nows    §7 conclusion
 +    "IPv6 assignments typically last longer than IPv4 assignments and can persist for months in several large residential ISPs"
 +    …(matched in mode nows: pypdf line-break artefact) …investigatetemporalandspatialdynamicsofIPv4andIPv6addressassignments.WefoundthatIPv6assignmentstypicallylastlongerthanIPv4assignmentsandcanpersistformonthsinseverallargeresidentialISPs.WestudiedspatialaspectsofIPv6addressesindetail,identifyingsubscriberpoolboundar…
 +
 +[10] ws      abstract (datasets)
 +    "over 3,000 RIPE Atlas probes in dual-stack networks"
 +    …mics. We present finegrained observations of dynamics using data collected from over 3,000 RIPE Atlas probes in dual-stack networks. RIPE Atlas probes in these networks report both their IPv4 and their IPv6 addr…
 +
 +[11] ws      abstract (datasets)
 +    "32.7 billion IPv4 and IPv6 address associations observed by a major CDN"
 +    …space. To corroborate and extend our findings, we also use a dataset containing 32.7 billion IPv4 and IPv6 address associations observed by a major CDN. Our investigation of temporal dynamics with these datasets shows that IPv6 ass…
 +
 +11 quotes, 11 present, 0 missing
 +</file>
 +
 +**What the paper actually says, against what the page said.**
 +
 +^ Page wording before ^ Verdict ^ Page wording now ^
 +| "IPv6 assignments last //longer// than IPv4 ones, often remaining stable for months" (paraphrase, footnoted as unverified) | **Accurate.** The abstract says //"IPv6 assignments have longer durations than IPv4 assignments—often remaining stable for months—thereby allowing the possibility of long-term fingerprinting of IPv6 subscribers"//; the conclusion repeats it as "typically last longer … can persist for months in several large residential ISPs". | The abstract sentence, quoted verbatim; footnote dropped. Added the paper's own qualifier that the prefix identifies the subscriber //"even if subscribers' devices are using privacy addresses"//, which is exactly the point the paragraph makes about RFC 8981. |
 +| "found the distribution spans orders of magnitude between ISPs — some reassign on a fixed daily cycle, others leave an address in place for months" | **Supported but not their phrase.** "Orders of magnitude" is our summary of 12-hour cycles (ANTEL) at one end and /64s "stable for months and years" at the other; the paper does not use the words. "Daily cycle" is right: 24-hour IPv6 renumbering in DTAG, Versatel, Netcologne, Telefonica DE and M-net. | Rewritten so the specifics are the paper's, and split by protocol as the paper splits them: IPv4 (non-dual-stack probes) — periodic renumbering on 35 networks, modes at 1 day (DTAG), 1.5 days (Proximus), 1 week (Orange), 2 weeks (BT); IPv6 — 12 h (ANTEL), 24 h (DTAG and four other German ASes), 48 h (Global Village); and the "stable for months and years" clause quoted. "Orders of magnitude" kept, but now visibly ours. |
 +| "RIPE Atlas dual-stack probes plus 32.7 billion address associations observed by a CDN" | **Accurate.** "over 3,000 RIPE Atlas probes in dual-stack networks"; "32.7 billion IPv4 and IPv6 address associations observed by a major CDN". Added "six years", which is the paper's own description of the Atlas window. | unchanged apart from "six years" |
 +
 +**Judgement calls.**
 +
 +  * The "35 networks" figure is for **non-dual-stack IPv4 probes**, and the 12 h / 24 h / 48 h cycles come from a separate, IPv6-only sentence. The first draft of the page sentence ran the two together as if ANTEL and Global Village were among the 35; the reviewer caught it (see the run table) and the page now names the paper's own IPv4 modes and keeps the IPv6 examples on their side of a semicolon. DTAG is the only ISP the paper shows renumbering daily in both protocols, so a first-draft "in IPv6 too" attached to all five German ASes was dropped.
 +  * Not published: the CDN-side figures (median association duration 61 days; 20% of associations lasting more than 143 of a possible 150 days; 75% of mobile associations lasting a day or less). They are about IPv4–IPv6 address //associations//, not assignment durations, and the paragraph is about assignment lifetime. They are noted here so the next run does not have to re-read the paper to decide.
 +  * The 45% / 44% of probes that saw no change in over a year are **excluded** from the paper's duration analysis (probably static assignments) and must not be read as "45% of assignments are stable for a year". Not published.
 +
 +**Run.**
 +
 +^ ^ ^
 +| Date | 2026-09-03 |
 +| Scope | One citation's wording. No corpus figure touched; ''report_ip_classification.mjs'' not re-run. |
 +| Model | Claude Fable 5.1, no sub-agents |
 +| Content page change | Two sentences in "IP as an Identifier: Four Ways It Breaks"; one footnote removed. Bibliography entry unchanged (it was already correct). |
 +| Mistake caught | The first version of the checker re-joined every hyphen at a line break and so reported the "non-dual-stack" quote as missing; a real hyphen and a line-break hyphen are indistinguishable in pypdf output, hence the three matching modes. |
 +| Review | One focused pass (Claude Sonnet, citations and quotes), given the flattened paper text, the two paragraphs and this section, told its context might not be exhaustive. It confirmed all quotes and every other attributed specific, and returned three findings, **all accepted**: (1) blocker — the page sentence presented the IPv6 12 h / 24 h / 48 h cycles as instances of the IPv4 "35 networks" finding; (2) should-fix — this section claimed the page scoped "35 networks" in parentheses when the delivered sentence did not; (3) nit — "in IPv6 too" was demonstrable only for DTAG. All three fixed before publishing; the IPv4 modes sentence was added to the checker (quote 3) at the same time. |
 +
 +
 +===== 13. LLM-classification currency, 2026-09-03 =====
 +
 +//Recorded during the run.// Shared numbers, the script, its unedited output, the folds and the quote check are on **[[provenance:design:website_classification]] §12**. This section records only what is specific to this page — including the one sentence on it that was simply wrong.
 +
 +==== 13.1 A retracted sentence ====
 +
 +The [[design:ip_classification#Open Questions|Open Questions]] bullet read:
 +
 +> LLMs have reached AS-to-organisation mapping {[selmo2025_borges]} but not IP classification. We found nothing peer-reviewed applying an LLM to geolocation, host typing or residential/VPN/datacenter labelling as of August 2026 — **unlike cookie and policy classification, where LLM methods are now routine.**
 +
 +The first two sentences hold. The clause in bold does not, and nothing on this site owned it: there was no script behind it, no paper cited for it, and [[privacy:cookies]] — the page it is a claim about — **does not mention LLMs at all**. It was a plausible aside that no reviewer brief had a reason to check.
 +
 +Measured per target, as the LLM share of the papers that classify that target at all:
 +
 +^ Target ^ LLM papers ^ Papers classifying it at all ^ Share ^
 +| ''privacy-policy'' | 12 | 102 | **11.8%** |
 +| ''cookie'' | **1** | **53** | **1.9%** |
 +| ''ip-address'' | 1 | 295 | 0.3% |
 +
 +So "routine" is defensible for neither. ''privacy-policy'' is the highest share of any target in the corpus and is the only one that comes close; ''cookie'' is a single TheWebConf 2025 paper, {[chen2025_semantics]}. And the comparison the old sentence was making — that IP classification is unusually untouched — is weaker than it claimed: at 1 of 295 it is low, but ''web-request'' is 1 of 258 and ''javascript'' and ''fingerprinting-script'' are at zero. The page now states the measured shares, says the old clause was wrong, and links the full table.
 +
 +==== 13.2 Two different papers, both described as "the one LLM paper" ====
 +
 +The page said both of these, five hundred lines apart:
 +
 +  * //"{[selmo2025_borges]} … reports a 7% improvement in sibling-ASN identification … That is, as of 2026, **the one place in IP classification where an LLM method has cleared peer review**"//
 +  * //"the ''llm'' method fires exactly **once** — one paper, GPT-4o, in the 2025–2026 window"//
 +
 +They are different papers, and a reader would reasonably merge them into one:
 +
 +^ ^ {[selmo2025_borges]} ^ {[schwartz2025_llmcloudhunter]} ^
 +| Venue | IMC 2025 | TheWebConf 2025 |
 +| Model | **GPT-4o-mini**, temperature 0 | **GPT-4o** |
 +| ''classification.target'' | ''other'' | ''ip-address'' |
 +| In this page's method table? | **no** — its tuples are filed under ''other'' | yes, it //is// the single ''llm'' row |
 +| What it does | few-shot extraction over PeeringDB notes and //aka// fields for sibling-ASN mapping | extracts IP indicators and user agents from threat-intelligence prose |
 +
 +Both sentences are now explicit about which paper they mean, and the first says that Borges does not appear in the method table at all. ''schwartz2025_llmcloudhunter'' was added to [[literature:bibliography]] in this run so the second sentence can name its paper; it was generated by ''scripts/bibgen.mjs'' from the venue index (DOI 10.1145/3696410.3714798, OpenAlex metadata).
 +
 +==== 13.3 Quotes checked ====
 +
 +  * ''selmo2025_borges'' — //"utilizing OpenAI's GPT-4o-mini [40] with a temperature set to 0 and a Top P probability mass of 1"//, located in ''data/fulltext/2025/IMC/learning-as-to-organization-mappings-with-borges/paper.cols.txt'' on 2026-09-03. This is the source for the ''GPT-4o-mini'' correction; the page previously named no model here and the sentence five hundred lines later named GPT-4o, which is how the two papers got conflated.
 +  * ''schwartz2025_llmcloudhunter'' — its ''ip-address'' tuple's evidence quote passes the shared quote check at the **PASS-ELID** tier: the extractor wrote //"This component ... parses OSCTIs to identify and extract IoCs, notably IP addresses and user agents pertinent to AWS CloudTrail logs"//, and both fragments either side of the elision are present. The elided middle is unverified, which is why the page describes what the paper does rather than quoting it.
 +
 +==== 13.4 What could not be established ====
 +
 +  * **Whether ''other'' hides an LLM IP-classification paper — probed, and Borges is the reason it had to be.** Borges is the proof that the bucket can hide one: an LLM paper squarely about AS-and-organisation mapping sits under ''other'' and therefore outside this page's method table. 116 of the 177 corpus LLM papers are in that bucket. ''other'' does carry a free-text ''targetDetail'', stated on all 157 such tuples, and ''report_llm_currency.mjs'' now probes it; Borges's reads //"favicon and associated final-URL groups"//, which is exactly the kind of string a keyword probe for //IP, geolocation, ASN// would miss. **So treat the probe as evidence about scripts and website topics, where the strings are unambiguous, and as weak evidence here.** The page's "fires exactly once" is a statement about the ''ip-address'' target, not about the topic, and it now says so. Full probe output on [[provenance:design:website_classification]] §12.9.
 +  * **Nothing else on this page was re-derived.** The 295-paper population, the geolocation figures and the fold residue are unchanged from the 2026-08-12 refresh (§4) and from §12; only the two LLM sentences were touched.
 +
 +===== Quote-check refresh, 2026-09-21 =====
 +
 +The 2026-09-04 ''cols''-vs-PDF audit on [[:provenance:literature:corpus]] showed that 73.1% of evidence quotes that cannot be located in ''paper.cols.txt'' **are** present in an independent ''pypdf'' rendering of the same ''paper.pdf''. ''scripts/quote_check.mjs'' was patched the same day to re-check everything below threshold against that second rendering and report a fourth verdict, **RESCUED**. §6's figure predates the patch and overstated this page's quote-failure rate by a factor of about three and a half. Re-run, unedited first line:
 +
 +<code>
 +$ node scripts/quote_check.mjs --classification ip-address --show 400
 +484 quotes checked: 232 exact, 158 partial (>=60% of 5-word windows), 67 rescued from the PDF, 27 below threshold in both renderings, 0 with no full text on disk.
 +</code>
 +
 +^ Figure ^ Was ^ Is ^ Why ^
 +| quotes checked | 484 | 484 | population unchanged — the corpus has not moved |
 +| exact | 232 | 232 | unchanged |
 +| partial (≥60% of 5-word windows) | 158 | 158 | unchanged |
 +| rescued from the PDF | — | **67** | new verdict; these were inside the old 94 |
 +| below threshold | **94** | **27** (in both renderings) | 94 = 67 + 27 exactly; nothing else moved |
 +| below-threshold rate | 19% | **5.6%** | 27 of 484 |
 +| unread below-threshold quotes | 88 | **24** | 3 of the 6 hand-read rows are still below threshold in both |
 +
 +**What this does and does not say.** It does not say 67 extractions were wrong and are now right — the quotes were always in the papers. It says the //stored text// could not locate them and a second rendering of the same PDF can, so counting them as quote failures measured ''decolumn.mjs'', not the extraction.
 +
 +**Scope of this edit.** §6 only. ''report_ip_classification.mjs'' was **not** re-run and no fold, report figure or citation was touched: every figure outside §6 on this page and on [[:design:ip_classification]] stands as published. The content page carries no quote-check figure — its provenance bullet says the accuracy-figure quotes were re-read by hand against ''paper.cols.txt'', which is a description of that hand pass and is still accurate — so it was not edited.
 +
 +^ Item ^ Value ^
 +| Date | 2026-09-21, unsupervised |
 +| Command | ''%%node scripts/quote_check.mjs --classification ip-address --show 400%%'' |
 +| Artifact | ''out/qc0921/class_ip.txt'' (full run, 67 RESCUED rows and 27 below-threshold rows listed) |
 +| Script changes | none — ''quote_check.mjs'' was already patched on 2026-09-04 |
 +| Reviewers | one ''sonnet'' figures-vs-script pass |
 +| Pages saved | this page only |
 +
 +[[design:ip_classification|← back to the content page]] · [[literature:corpus|corpus-level provenance]]
 +
 +===== Markup sweep, 2026-09-17 =====
 +
 +Mechanical rendering repair only: a fresh live raw/XHTML export of 188 pages was checked with ''check_wrap.mjs'' and ''check_typography.mjs''. Affected plugin tags, CLI flags and heading markup were repaired; no figures or substantive prose were changed. The resulting source and rendered DOM were re-checked after saving.
 +
 +===== 14. Fable review and fixes, 2026-09-22 =====
 +
 +//Recorded.// The independent higher-tier review this page had been waiting for. Two earlier attempts at it never reported before their sessions ended (the one in the drain logs ran on 2026-09-03; the other is not logged by name); this time the reviewer wrote each finding to a file as it went, and the findings file is the artefact behind every row below.
 +
 +^ Item ^ Value ^
 +| Date | 2026-09-22, unsupervised |
 +| Revisions reviewed | content page rev ''1789093871'' (2026-09-11), this page rev ''1789997452'' (2026-09-21) — the live revisions, not the older ''review_ip/'' snapshot |
 +| Corpus | ''data/extract/run1'', 5,859 papers, unchanged since 2026-08-12 |
 +| Reviewers | ''fable'' (generic plus the four focus areas: corpus figures, quotes, the dated vendor tables, the ''classify_ips.py'' conclusions; it delegated the vendor fetches to one sub-agent of its own); ''sonnet'' (external currency of the two vendor tables, run in parallel as a second opinion on the part that rots). Both told their context might not be exhaustive. |
 +| Author of the fixes | Claude Opus 5.5, which also settled the 2026-09-03 self-served pass (F1–F6) against the live revisions |
 +| Artefacts | ''review_ip/r20260922/'' in the workdir: ''fable_findings.md'', ''area_c_subagent.md'', ''sonnet_currency_findings.md'', ''own_settlement.md'', ''_qcheck.py'' and ''_qcheck_out1.txt'' (quote checker and its output), ''classify_ips_run_20260922.txt'' |
 +| Script changes | ''classify_ips.py'' (§14.5); ''report_ip_classification.mjs'' header comments only, output byte-identical before and after |
 +| Pages saved | [[design:ip_classification]] and this page, 2026-09-22, after the re-review in §14.9 |
 +
 +§9 was never used; the numbering is kept so that revision summaries citing §10–§13 still resolve.
 +
 +==== 14.1 Corpus figures ====
 +
 +**No stale or mis-denominated corpus figure on the content page.** ''report_ip_classification.mjs'' output today is byte-identical to ''out/new/report_ip_classification.txt'' and to the 2026-09-03 run, and ''maxmind_version.mjs'' reproduces 134 / 134 / 36 (26.9%) / 52. The reviewer checked every corpus figure inside //and// outside the ''Use in Publications'' window, and for each one checked that the value names the same population the script computes. The ''llm'' figures (1 of 295, 177 corpus-wide, the five zero targets, 11.8% / 1.9%) match ''report_llm_currency.mjs''.
 +
 +One figure was **true but compared against the wrong base** (found by the fix author, not by a reviewer): "196 of 295 (66.4%) report no validation … against 29.9% across all 4,439 papers". The 66.4% counts only a paper's IP tuples. The 29.9% is paper-level across //every// tuple a paper has, so a paper whose IP lookup went unvalidated but whose other classifier was validated counts as validating. ''report_annotation.mjs'' prints that row as "//any tuple of the paper — NOT comparable to the rows//". The like-for-like pair is now on the page: **33.6% of the 295 validate their IP classification, against 60.1% of all 6,483 (paper, target) cells**, which is the pair [[statistics:annotation]] itself uses.
 +
 +Guard caveat for the next run: the page's new ''33.6%'' is "accounted" by ''check_page_numbers.mjs'' because the report prints ''none-reported 99 33.6%'' — a different quantity that happens to share the count 99 (295 − 196 = 99 papers that validate; 99 papers with a ''none-reported'' tuple). The value on the page is derived from ''196 / 295'', which the report does print (''no validation on any IP tuple: 196 / 295 (66.4%)'').
 +
 +==== 14.2 Findings and what was done with each ====
 +
 +Severity is the reviewer's own; "Action" is the fix author's decision. Rejections are listed as carefully as fixes — they are the record of whether a reviewer is worth its slot.
 +
 +^ ID ^ From ^ Sev. ^ Finding ^ Verdict ^ Action ^
 +| D1 | fable | MAJOR | The published ''classify_ips.py'' crashes today (''%%KeyError: 'cc'%%''): ipapi.is changed its keyless response on 2026-09-01 | CONFIRMED (fix author reproduced the new response shape) | **Fixed** — §14.5. The reviewer suggested catching the ''KeyError'' per service and carrying on; **rejected in that form**, because a vendor silently changing shape is exactly the failure this site's scripts are meant to surface. The script now raises with the service, the address and the keys it did receive. |
 +| D2 | fable, sonnet | MAJOR | ipapi.is no longer returns ''is_*'' flags keylessly; keyless cap is 30/day, 1,000/day needs a free key | CONFIRMED independently by both, and by the fix author against ''https://ipapi.is/free-tier.html'' ("the detection flags moved behind a key") | **Fixed**: vendor bullet, the ''WRAP important'' box, conclusion 5 and the Open Questions bullet now date the flags to the 2026-08-06 run and point here for its output (§14.5). |
 +| E1 | fable | MAJOR | The content page promises "the report script and its unedited output" here; neither was on this page | CONFIRMED | **Fixed**: §14.8 embeds the report script, the fold, the MaxMind snapshot script and both outputs, whole. |
 +| P1 / F4 | fable, own 09-03 | MAJOR | §3's "11 figures unaccounted" was stale (16 on the live page); "seven figures" listed eight | CONFIRMED | **Fixed** in §3, with the source of each figure. |
 +| P2 / F2 | fable, own 09-03 | MAJOR | The run log omitted four content-page revisions, including 2026-09-11, which added the cross-page ''29.9%'' / ''4,439'' figures | CONFIRMED (fable's heading said five; the three 2026-09-03 revisions were already in §12/§13) | **Fixed**: revision ledger in §14.3. |
 +| P3 | fable | MAJOR | §8 said 88 unread below-threshold quotes; §6's 2026-09-21 refresh says 24 | CONFIRMED | **Fixed** in §8. |
 +| C1 | fable | MAJOR | "the older TorDNSEL service was retired in April 2020" is misstated: a DNS exit list still answers | PARTLY. The Tor Project's own post (//Changes to the Tor Exit List Service//, 9 March 2020) says the old system "will be retired on the 1st of April" and that the new one "will return an A record of 127.0.0.2" — so the date was right and the implication that nothing replaced it was wrong. | **Fixed** and downgraded to MINOR: the page now says the DNS exit list answers for current exits only and its implementation was replaced in April 2020. |
 +| P6 / F5 | fable (PLAUSIBLE), own 09-03 | MAJOR | §10/§11 credit a Fable review on 2026-08-12 that may never have reported | **Not established — rejected as stated.** See §14.4. | §10 and §11 annotated, not rewritten. |
 +| N1 | fix author | MAJOR | 66.4% (per-target) compared against 29.9% (paper-level) | CONFIRMED from ''report_annotation.mjs'' | **Fixed** — §14.1. |
 +| — | sonnet | MAJOR ("MISSTATED") | IP2Location LITE is not CC BY-SA 4.0: its data-licence terms forbid redistribution and resale | **Conflict between reviewers.** Fable's sub-agent found a CC BY-SA 4.0 badge (''rel="license"'') on the DB1 LITE page and called the row fair. The fix author fetched both: the badge is on the DB1 page and absent from the DB3 and DB11 pages (copies saved as ''db1.html'', ''t.html'', ''ip2l_db11.html''); ''https://lite.ip2location.com/data-license'' (saved as ''ip2l_data_license.html'') says "You are not permitted to redistribute or resell this product" and never mentions Creative Commons. Both reviewers were right about the page each read. | **Fixed**: the row now says the licence is stated two ways and tells the reader to read the terms before redistributing. |
 +| A1 | fable | MINOR | Report script's header comment still said ''crawled — 859'', ''measuredFrom — 2,909'', "4,322 papers" | CONFIRMED | **Fixed**; comments made non-numeric so they cannot go stale again. Output byte-identical. |
 +| B1 | fable | MINOR | Chiapponi et al. do not "attribute" the 4G result to a "transparent proxy"; they write "This discrepancy seems to suggest that sfr utilizes mttps" | CONFIRMED against ''paper.cols.txt'' | **Fixed**: "suggest … mobile TCP-terminating proxies (MTTPs)". |
 +| B4 | fable | MINOR (PLAUSIBLE) | Shavitt & Zilberman are characterised more strongly than their text supports: no country-accuracy-vs-claim measurement and no MaxMind-to-US default | CONFIRMED on the arXiv version (1005.5674v3, fetched by the fix author): "the vast majority of location information replies are correct. However, in some cases there are errors … thousands of kilometers and countries apart"; the headquarters case is IP2Location placing 2,365 of 2,879 Cogent addresses in Washington DC. The JSAC version was not read. | **Fixed**: both sentences (period table and the Khan paragraph) now say what the arXiv text says. |
 +| L1 | fable | MINOR (PLAUSIBLE) | CJEU //EDPS v SRB// quotation not verified verbatim | **Resolved**: the fix author fetched the judgment from EUR-Lex (CELEX 62023CJ0413) with Playwright. The sentence is verbatim at **paragraph 82**; the operative part "Sets aside the judgment of the General Court … Refers Case T‑557/20 back to the General Court". | Paragraph number added to the page. |
 +| E2 | fable | MINOR | "the big five clouds" is undefined; the page lists six operators | CONFIRMED | **Fixed**. |
 +| E3 | fable | MINOR (PLAUSIBLE) | Three "we found no …" sentences have no recorded search behind them | CONFIRMED that no search protocol is recorded | **Recorded, not fixed** — §14.7. |
 +| E4 | fable | MINOR | For the stated reader, the only runnable artefact was broken | = D1/D2 | Fixed with D1/D2. |
 +| P4 / F3 | fable, own 09-03 | MINOR | §3 documented ''node scripts/ip_fold.mjs # self-test, prints residue''; it prints nothing | CONFIRMED (exit 0, no output) | **Fixed**: line removed; §3 says the residue is printed by the report script. |
 +| P5 | fable | MINOR | Section numbering skips §9 | CONFIRMED | Noted at the head of §14, numbering kept. |
 +| P7 | fable | MINOR | Nothing recorded a re-check of the vendor half since 2026-08-06 | CONFIRMED | **Fixed**: §14.6. |
 +| C2 | fable | MINOR | IP2Proxy's eight-category list is the commercial edition's; LITE covers public proxies only | CONFIRMED by its sub-agent's fetch of ''lite.ip2location.com/ip2proxy-lite'' | **Fixed**. |
 +| C3 | fable | MINOR | MaxMind dropped the "2" (GeoLite, GeoIP City); EULA updated 12 February 2026 | CONFIRMED by the fix author (''dev.maxmind.com'' product names are "GeoLite City" / "GeoLite Country") | **Fixed** in the vendor rows, the accuracy sentence and //What to Report//. Corpus sections keep "GeoLite2", which is what the papers say. |
 +| C4 | fable | MINOR | ''ipinfo.io/developers/privacy-detection'' 404s | CONFIRMED | **Fixed**: the page now links the live ''privacy-standard-api'' page and says the field names differ between IPinfo products. |
 +| C5 | fable | MINOR | Seven cited vendor URLs redirect | CONFIRMED; all still reach the right content except the IPinfo one (C4) | **Not changed** apart from C4: a redirecting URL still resolves, and the content page links only two of the seven. |
 +| C6 | fable, sonnet | MINOR | DB-IP quote: "database" where the source says "databases" | CONFIRMED by both | **Fixed**. |
 +| C7 | fable, sonnet | — | DigitalOcean CSV "404s intermittently" could not be reproduced (6 of 6 fetches served) | UNVERIFIABLE | **Reworded**: the page names the URL, says the intermittent 404s were this page's first version's observation, and that it served on 2026-09-22. |
 +| N2 | fix author | MINOR | Related Pages "second-worst-validated target on this wiki" drops the ">50 papers" rule it depends on (''website-popularity'' is lower at 25.0% of 16) | CONFIRMED; fable judged the sentence consistent with statistics:annotation's rule, which is true — the gloss just did not state the rule | **Fixed**: qualifier added. |
 +| F1 | own 09-03 | MAJOR | Related Pages marks ''Design:Sampling'' "not yet written" and promises "why an IP is a bad sampling unit" | CONFIRMED: the page exists (55,740 B) and does not cover IP addresses as a sampling unit | **Fixed**: marker dropped, gloss re-worded to what that page covers. |
 +| F6 | own 09-03 | MINOR | ''design:crawling_location'' did not link back | **No longer holds**: it links this page three times | none |
 +| — | sonnet | PLAUSIBLE | NetAcuity "no academic programme", Azure "~4 week" API lag | Fable's sub-agent confirmed the Azure lag ("up to four weeks"); the NetAcuity negative stays a negative claim (§14.7) | none |
 +
 +==== 14.3 Revision ledger for the content page since this page's 2026-08-12 run log ====
 +
 +§11 records the 2026-08-12 refresh only. Every later revision of [[design:ip_classification]], and where it is recorded:
 +
 +^ Rev ^ When (UTC) ^ What ^ Recorded in ^
 +| ''1786549519'' | 2026-08-12 15:45 | Methodology section: generic corpus caveats replaced by a pointer to [[literature:corpus]] | here only |
 +| ''1786677608'' | 2026-08-14 03:20 | ''programming:traffic_files'' "not yet written" marker dropped | here only |
 +| ''1787323815'' | 2026-08-21 14:50 | lowercase ''%%<wrap>%%'' → ''%%<WRAP>%%'', one box per list | here only |
 +| ''1788465140'' | 2026-09-03 19:52 | DynamIPs quotation verified | §12 |
 +| ''1788471670'' | 2026-09-03 21:41 | LLM-currency sentence retracted and replaced by per-target shares | §13 |
 +| ''1788473633'' | 2026-09-03 22:13 | ''ip-address'' at 0.3% is the lowest //non-zero// row | §13 |
 +| ''1789093871'' | 2026-09-11 02:31 | Pointer to [[statistics:annotation]] from the validation section and Related Pages. Added the ''29.9%'' / ''4,439'' comparator (replaced on 2026-09-22, §14.1) and the "second-worst-validated" gloss (qualified on 2026-09-22) | here only; the figures come from ''scripts/report_annotation.mjs'', whose output is on [[provenance:statistics:annotation]] |
 +| 1790116015 | 2026-09-22 | §14.2 fixes | §14 |
 +
 +==== 14.4 Was there a Fable review on 2026-08-12? ====
 +
 +The 2026-09-03 self-served pass (F5) said §10/§11 credit a Fable review that "did not report", on the strength of the work item's own wording. The record says otherwise, or at least not that:
 +
 +  * ''/workspace/drain/tasks.md'', the 2026-08-06 creation item: "Fable review landed late and found real errors (obsoleted RFC 9092, three mis-scoped paper figures, a published script bug); all fixed and verified." Content-page revision ''1786053246'' carries exactly those corrections.
 +  * Content-page revision ''1786529131'' (2026-08-12 10:05) is summarised "Review pass (Fable): fix stale figures that sat OUTSIDE the corpus section …", which is the finding §10 describes.
 +  * The attempts recorded as not reporting are attempts at **this review item**; the one in the drain logs ran on 2026-09-03 ("Fable reviewer still running at cutoff — again did not report").
 +
 +So §10's attribution is consistent with the record and is left standing. What it lacks is an artefact: neither the 2026-08-06 nor the 2026-08-12 review left a findings file that this run could find, so the attribution rests on those runs' own summaries. Session transcripts were not searched. This 2026-09-22 review is the first of this page whose findings are on disk.
 +
 +==== 14.5 classify_ips.py: what changed, and the 2026-08-06 output ====
 +
 +ipapi.is changed its keyless response on 1 September 2026 (''https://ipapi.is/free-tier.html'', fetched 2026-09-22: "Some fields were renamed, the geolocation fields were expanded, and the detection flags moved behind a key"). The keyless object now has no ''cc'', no ''location'' object, a full country //name// rather than an ISO code, and no ''is_*'' flags; the script's ''d["cc"]'' fallback raised ''KeyError''. The fix:
 +
 +  * ipapi.is is queried only when ''IPAPI_IS_KEY'' is set, and then parsed as the documented keyed object (''location.country_code'', ''location.city'', ''asn.org''). Without a key it is skipped and the run says so. Parsing the keyless object instead was considered and rejected: its only contribution the other three services do not make is the flags, and its full country names would have made the country vote report a spurious ''DISPUTED'' (''US'' vs ''United States'').
 +  * An extractor that meets an unexpected response shape now raises with the service, the address and the keys received, instead of a bare ''KeyError''.
 +  * **Tested**: keyless on the five addresses (output on the content page); the keyed extractor against the full example response in ipapi.is's own developer documentation (''("US", "Buffalo", "HostPapa")'', flags ''is_datacenter, is_tor, is_abuser''); a dummy key (the service returns HTTP 403, printed as ''unavailable''). **Not tested**: a live keyed query — this run had no ipapi.is key and did not register for one.
 +
 +What today's keyless run changed on the page's six conclusions: (1) routing — identical to 2026-08-06, byte for byte; (2) ''1.1.1.1'' is ''AU'' from three services instead of four; (3) still two of five disputed, now ''US x2/CA x1'' and ''DE x2/US x1''; (4) the Swiss city set is now Zürich / Samnaun / Munchenbuchsee; (5) the flags cannot be shown keylessly, so the conclusion now cites the 2026-08-06 run below; (6) layer 2 — same four verdicts, 11,551 prefixes loaded instead of 11,628.
 +
 +Diff:
 +
 +<code diff>
 +--- classify_ips_20260806.py 2026-09-22 22:10:42.163464117 +0000
 ++++ classify_ips.py 2026-09-22 22:10:42.172383420 +0000
 +@@ -12,8 +12,9 @@
 +   2. OPERATOR-PUBLISHED PREFIXES (AWS, Google Cloud, Cloudflare). If the operator
 +      says the prefix is theirs, it is theirs. Free, authoritative, and better
 +      than any commercial "is_datacenter" flag for the networks it covers.
 +-  3. GEOLOCATION ESTIMATES (four free services). These are inferences. The script
 +-     prints them side by side and flags disagreement rather than picking one.
 ++  3. GEOLOCATION ESTIMATES (three free keyless services, four with an ipapi.is
 ++     key in IPAPI_IS_KEY). These are inferences. The script prints them side by
 ++     side and flags disagreement rather than picking one.
 + 
 + Stdlib only. Free tiers are rate-limited (ip-api.com: 45 requests/minute from one
 + address), so this is for tens of addresses, not for a crawl's worth. For bulk
 +@@ -23,6 +24,7 @@
 + import argparse
 + import ipaddress
 + import json
 ++import os
 + import socket
 + import sys
 + import urllib.error
 +@@ -48,13 +50,15 @@
 +     "ip-api.com": ("http://ip-api.com/json/{ip}",   lambda d: (d["countryCode"], d.get("city"), d.get("as"))),
 +     "ipwho.is":   ("https://ipwho.is/{ip}",         lambda d: (d["country_code"], d.get("city"),
 +                                                                d.get("connection", {}).get("isp"))),
 +-    # ipapi.is returns a REDUCED object (cc, flags, asn_org, no city) for keyless
 +-    # queries about a third-party address, and the full object with a key. Handle
 +-    # both rather than crashing on the free tier.
 +-    "ipapi.is":   ("https://api.ipapi.is?q={ip}",   lambda d: (d.get("location", {}).get("country_code") or d["cc"],
 +-                                                               d.get("location", {}).get("city"),
 +-                                                               d.get("asn", {}).get("org") or d.get("asn_org"))),
 + }
 ++# ipapi.is carries the risk flags (is_datacenter, is_vpn, ...). Since 2026-09-01 a
 ++# keyless query returns neither the flags nor an ISO country code, so without a
 ++# (free) key it is skipped rather than half-used: https://ipapi.is/free-tier.html
 ++IPAPI_IS_KEY = os.environ.get("IPAPI_IS_KEY")
 ++if IPAPI_IS_KEY:
 ++    GEO_SERVICES["ipapi.is"] = ("https://api.ipapi.is?q={ip}&key=" + IPAPI_IS_KEY,
 ++                                lambda d: (d["location"]["country_code"], d["location"].get("city"),
 ++                                           d["asn"]["org"]))
 + FLAGS = ["is_datacenter", "is_vpn", "is_proxy", "is_tor", "is_abuser", "is_mobile"]
 + 
 + 
 +@@ -132,7 +136,11 @@
 +             errors[name] = "service error"
 +             continue
 +         raw[name] = data
 +-        results[name] = extract(data)
 ++        try:
 ++            results[name] = extract(data)
 ++        except KeyError as exc:
 ++            raise RuntimeError(f"{name} answered for {ip} without {exc}; keys present: "
 ++                               f"{sorted(data)}. The service changed its response shape.") from exc
 +     return results, raw, errors
 + 
 + 
 +@@ -184,6 +192,8 @@
 +         return 0
 + 
 +     print("\nlayer 3  geolocation estimates -- these are inferences, not facts\n")
 ++    if not IPAPI_IS_KEY:
 ++        print("  ipapi.is skipped: its risk flags need a free key since 2026-09-01 (set IPAPI_IS_KEY)\n")
 +     disputed = 0
 +     for ip in ips:
 +         results, raw, errors = geolocate(ip)
 +</code>
 +
 +Output of the **2026-08-06** version of the script, as published on the content page until 2026-09-22. This is the run the risk-flag conclusion cites; it cannot be reproduced keylessly today.
 +
 +<code>
 +layer 1  routing (Team Cymru bulk whois, 5/5 answered)
 +
 +  IP               ASN      BGP prefix           reg-CC  RIR       allocated   AS name
 +  8.8.8.8          15169    8.8.8.0/24           US      arin      2023-12-28  GOOGLE - Google LLC, US
 +  1.1.1.1          13335    1.1.1.0/24           AU      apnic     2011-08-11  CLOUDFLARENET - Cloudflare, Inc., US
 +  104.16.132.229   13335    104.16.128.0/20      US      arin      2014-03-28  CLOUDFLARENET - Cloudflare, Inc., US
 +  13.32.99.63      16509    13.32.98.0/23        US      arin      2020-08-05  AMAZON-02 - Amazon.com, Inc., US
 +  82.220.84.43     9044     82.220.0.0/16        CH      ripencc   2003-12-02  SOLNET - BSE Software GmbH, CH
 +
 +layer 2  operator-published prefixes (AWS, Google Cloud, Cloudflare)
 +
 +  11628 prefixes loaded
 +  8.8.8.8          not in any loaded operator list
 +  1.1.1.1          not in any loaded operator list
 +  104.16.132.229   Cloudflare 104.16.0.0/13  
 +  13.32.99.63      AWS 13.32.0.0/15  AMAZON/GLOBAL
 +  82.220.84.43     not in any loaded operator list
 +
 +layer 3  geolocation estimates -- these are inferences, not facts
 +
 +  8.8.8.8   country US x4   agreed
 +                   city    Mountain View x1/Ashburn x1/San Jose x1   <-- services disagree
 +                     ip-api.com   US  Ashburn          AS15169 Google LLC
 +                     ipapi.is     US  None             Google LLC
 +                     ipinfo.io    US  Mountain View    AS15169 Google LLC
 +                     ipwho.is     US  San Jose         Google LLC
 +                     ipapi.is flags is_datacenter, is_vpn, is_abuser
 +                     ipinfo.io says ANYCAST: this address has no single location
 +
 +  1.1.1.1   country AU x4   agreed
 +                   city    Brisbane x2/South Brisbane x1   <-- services disagree
 +                     ip-api.com   AU  South Brisbane   AS13335 Cloudflare, Inc.
 +                     ipapi.is     AU  None             Cloudflare, Inc.
 +                     ipinfo.io    AU  Brisbane         AS13335 Cloudflare, Inc.
 +                     ipwho.is     AU  Brisbane         Cloudflare, Inc.
 +                     ipapi.is flags is_vpn, is_abuser
 +                     ipinfo.io says ANYCAST: this address has no single location
 +
 +  104.16.132.229   country US x3/CA x1   DISPUTED
 +                   city    San Francisco x2/Toronto x1   <-- services disagree
 +                     ip-api.com   CA  Toronto          AS13335 Cloudflare, Inc.
 +                     ipapi.is     US  None             Cloudflare, Inc.
 +                     ipinfo.io    US  San Francisco    AS13335 Cloudflare, Inc.
 +                     ipwho.is     US  San Francisco    Cloudflare, Inc.
 +                     ipapi.is flags is_datacenter, is_abuser
 +                     ipinfo.io says ANYCAST: this address has no single location
 +
 +  13.32.99.63   country DE x3/US x1   DISPUTED
 +                   city    Frankfurt am Main x2/New York x1   <-- services disagree
 +                     ip-api.com   US  New York         AS16509 Amazon.com, Inc.
 +                     ipapi.is     DE  None             Amazon.com, Inc.
 +                     ipinfo.io    DE  Frankfurt am Main AS16509 Amazon.com, Inc.
 +                     ipwho.is     DE  Frankfurt am Main Amazon.com, Inc.
 +                     ipapi.is flags is_datacenter
 +
 +  82.220.84.43   country CH x4   agreed
 +                   city    Biel/Bienne x1/Samnaun x1/Zurich x1   <-- services disagree
 +                     ip-api.com   CH  Samnaun          AS9044 BSE Software GmbH
 +                     ipapi.is     CH  None             BSE Software GmbH
 +                     ipinfo.io    CH  Biel/Bienne      AS9044 BSE Software GmbH
 +                     ipwho.is     CH  Zurich           BSE Software GmbH
 +                     ipapi.is flags none set
 +
 +summary: 2 of 5 addresses had a cross-service country disagreement
 +</code>
 +
 +==== 14.6 Vendor and URL re-check, 2026-09-22 ====
 +
 +Two independent passes fetched the vendor, licence and URL claims in //Which geolocation source to use in 2026//, //Ask the operator first// and //Commercial VPN, proxy and abuse feeds// that ''fable_findings.md'' area (c) and ''sonnet_currency_findings.md'' enumerate — not every claim: Livadariu et al.'s 1.50% was not fetched, and NetAcuity's academic programme and the IP2Proxy LITE field set were left PLAUSIBLE. Of what they checked, everything not listed below held, with live evidence: all eleven operator-list URLs (and the ''cloud.json''/''goog.json'' and ''ips-v4'' containment claims for 8.8.8.8 and 1.1.1.1), MaxMind's account gate and prices, IPinfo Lite, DB-IP Lite's CC BY 4.0 licence, ip-api.com's terms, RIPE IPmap, GreyNoise's Research Community programme, the MaxMind Anonymous IP and Connection Type field names, and RFC 6269, 6598, 8805, 8981 and 9632 (none obsoleted per ''rfc-index.xml'').
 +
 +^ Claim ^ 2026-08-06 ^ 2026-09-22 ^ Kind ^
 +| ipapi.is flags | keyless, 1,000/day | free key needed; keyless 30/day, no flags | rot (vendor change 2026-09-01) |
 +| MaxMind product names | GeoLite2, GeoIP2 | GeoLite, GeoIP (EULA of 12 February 2026: "'GeoLite' may be used with the same meaning as 'GeoLite2'") | already stale on 2026-08-06 |
 +| IP2Location LITE licence | "CC BY-SA 4.0" | CC BY-SA badge on the DB1 page; data-licence terms forbid redistribution and resale | stated two ways by the vendor |
 +| IP2Proxy LITE | eight categories | public proxies only; the eight (plus EPN) are the commercial edition's | wrong from the start |
 +| TorDNSEL | "retired in April 2020" | old implementation retired April 2020; the DNS exit list still answers | incomplete from the start |
 +| IPinfo Privacy Detection docs | ''/developers/privacy-detection'' | 404; ''/developers/privacy-standard-api'' | rot |
 +| DB-IP quote | "commercial database" | "commercial databases" | transcription slip |
 +| DigitalOcean CSV | "404s intermittently" | served 6 of 6 | not reproducible |
 +
 +==== 14.7 What could not be established ====
 +
 +  * **The three "we found no …" sentences** — no peer-reviewed prefix-granularity hosting classifier; no NetAcuity academic programme; nothing peer-reviewed applying an LLM to geolocation or host typing. None has a search protocol on this page. The LLM one rests partly on the corpus query in §13; the other two rest on the 2026-08-06 author's search, which was not recorded. They are left on the page as the author's claims, and are the first thing to re-check.
 +  * **Shavitt & Zilberman's JSAC 2011 text** was not read; §14.2 B4 used the arXiv version.
 +  * **Whether the DigitalOcean CSV was ever intermittent.** Nothing on disk records the original observation.
 +  * **A live keyed ipapi.is response** (§14.5).
 +  * **The //EDPS v SRB// ECLI** (''ECLI:EU:C:2025:645'') is not in the EUR-Lex HTML body text that was fetched; case number, date, paragraph 82 and the operative part are.
 +
 +==== 14.8 The report script, the fold, and their unedited output ====
 +
 +The scripts exactly as in ''scripts/'' on 2026-09-22, and their output from a run the same day against ''data/extract/run1''. ''report_ip_classification.mjs'' imports ''lib.mjs'' (shared across the site's report scripts, not reproduced here). Regenerate these blocks whole whenever a script changes.
 +
 +<file javascript report_ip_classification.mjs>
 +// Every figure on design:ip_classification, with its denominator.
 +//
 +//   node scripts/report_ip_classification.mjs            # plain text
 +//   node scripts/report_ip_classification.mjs --wiki      # DokuWiki tables
 +//
 +// Populations used here (each query names its own; "of <corpus size> papers" is never
 +// the answer):
 +//   ipClassified   — paper has >=1 classification tuple with target 'ip-address'
 +//   crawled        — as defined in data/extract/OVERVIEW.md (count printed at the top of the output)
 +//   measuredFrom   — paper has >=1 vantage tuple (count printed at the top of the output)
 +//
 +// Free-text resource names are folded through scripts/ip_fold.mjs and the
 +// residue is printed at the bottom.
 +
 +import { loadExtractions, POPULATIONS, isSentinel, pct, table, wikiTable } from './lib.mjs';
 +import { foldIpResource, TASK_LABEL } from './ip_fold.mjs';
 +
 +const WIKI = process.argv.includes('--wiki');
 +const T = (h, r) => (WIKI ? wikiTable(h, r) : table(h, r));
 +const key = (p) => `${p.venue}/${p.year}/${p.slug}`;
 +const head = (s) => console.log(`\n${WIKI ? '==== ' + s + ' ====' : '### ' + s}\n`);
 +
 +const rows = loadExtractions();
 +const crawled = rows.filter(POPULATIONS.crawled);
 +const measuredFrom = rows.filter(POPULATIONS.measuredFrom);
 +const ipTuples = (p) => p.classification.filter((t) => t.target === 'ip-address');
 +const ipClassified = rows.filter((p) => ipTuples(p).length > 0);
 +
 +console.log(`corpus                        ${rows.length}`);
 +console.log(`crawled                       ${crawled.length}`);
 +console.log(`measuredFrom                  ${measuredFrom.length}`);
 +console.log(`ipClassified                  ${ipClassified.length}  (${pct(ipClassified.length, rows.length)} of corpus)`);
 +
 +// ---------------------------------------------------------------- reach ----
 +head('Papers classifying an IP address, by venue and period');
 +{
 +  const byVenue = new Map();
 +  for (const p of ipClassified) byVenue.set(p.venue, (byVenue.get(p.venue) ?? 0) + 1);
 +  const venueTotal = new Map();
 +  for (const p of rows) venueTotal.set(p.venue, (venueTotal.get(p.venue) ?? 0) + 1);
 +  console.log(
 +    T(
 +      ['Venue', 'Papers classifying an IP', 'Papers in corpus', 'Share of venue'],
 +      [...byVenue.entries()]
 +        .sort((a, b) => b[1] - a[1])
 +        .map(([v, n]) => [v, n, venueTotal.get(v), pct(n, venueTotal.get(v))])
 +    )
 +  );
 +
 +  const buckets = [
 +    ['2010–2013', (y) => y <= 2013],
 +    ['2014–2017', (y) => y >= 2014 && y <= 2017],
 +    ['2018–2021', (y) => y >= 2018 && y <= 2021],
 +    ['2022–2024', (y) => y >= 2022 && y <= 2024],
 +    // 2025–2026 is provisional: CCS/IMC 2026 not held, IEEE S&P/WWW 2026
 +    // incompletely selected. Labelled, not dropped.
 +    ['2025–2026*', (y) => y >= 2025],
 +  ];
 +  console.log();
 +  console.log(
 +    T(
 +      ['Period', 'Papers classifying an IP', 'Papers in corpus', 'Share'],
 +      buckets.map(([label, f]) => {
 +        const a = ipClassified.filter((p) => f(p.year)).length;
 +        const b = rows.filter((p) => f(p.year)).length;
 +        return [label, a, b, pct(a, b)];
 +      })
 +    )
 +  );
 +}
 +
 +// -------------------------------------------------- what method, enum'd ----
 +head(`How the IP was classified (enum, papers of ${ipClassified.length})`);
 +{
 +  const m = new Map();
 +  for (const p of ipClassified)
 +    for (const t of ipTuples(p)) {
 +      if (isSentinel(t.method)) continue;
 +      if (!m.has(t.method)) m.set(t.method, new Set());
 +      m.get(t.method).add(key(p));
 +    }
 +  console.log(
 +    T(
 +      ['Method', 'Papers', `Share of ${ipClassified.length}`],
 +      [...m.entries()]
 +        .sort((a, b) => b[1].size - a[1].size)
 +        .map(([k, s]) => [k, s.size, pct(s.size, ipClassified.length)])
 +    )
 +  );
 +}
 +
 +// ------------------------------------------------------------ validation ----
 +head(`Whether the IP classification was validated (papers of ${ipClassified.length})`);
 +{
 +  const m = new Map();
 +  for (const p of ipClassified)
 +    for (const t of ipTuples(p)) {
 +      const v = t.validation ?? 'not-stated';
 +      if (!m.has(v)) m.set(v, new Set());
 +      m.get(v).add(key(p));
 +    }
 +  console.log(
 +    T(
 +      ['Validation', 'Papers', `Share of ${ipClassified.length}`],
 +      [...m.entries()]
 +        .sort((a, b) => b[1].size - a[1].size)
 +        .map(([k, s]) => [k, s.size, pct(s.size, ipClassified.length)])
 +    )
 +  );
 +
 +  const gt = new Set();
 +  for (const p of ipClassified)
 +    for (const t of ipTuples(p)) if (!isSentinel(t.groundTruthSource) && t.groundTruthSource) gt.add(key(p));
 +  console.log(`\nnames a ground-truth source:  ${gt.size} / ${ipClassified.length}  (${pct(gt.size, ipClassified.length)})`);
 +
 +  // Papers whose *only* validation value is none-reported or not-applicable.
 +  const weak = ipClassified.filter((p) =>
 +    ipTuples(p).every((t) => ['none-reported', 'not-applicable', null, 'not-stated'].includes(t.validation))
 +  );
 +  console.log(`no validation on any IP tuple: ${weak.length} / ${ipClassified.length}  (${pct(weak.length, ipClassified.length)})`);
 +}
 +
 +// ------------------------------------------------- the named resources -----
 +head(`Which resources, folded (papers of ${ipClassified.length})`);
 +{
 +  const fam = new Map(); // family -> {task, set}
 +  const residue = new Map(); // raw -> Set(paper)
 +  for (const p of ipClassified)
 +    for (const t of ipTuples(p)) {
 +      if (isSentinel(t.resourceName) || !t.resourceName) continue;
 +      const f = foldIpResource(t.resourceName);
 +      if (!f) {
 +        if (!residue.has(t.resourceName)) residue.set(t.resourceName, new Set());
 +        residue.get(t.resourceName).add(key(p));
 +        continue;
 +      }
 +      const id = f.family;
 +      if (!fam.has(id)) fam.set(id, { task: f.task, set: new Set() });
 +      fam.get(id).set.add(key(p));
 +    }
 +  console.log(
 +    T(
 +      ['Resource family', 'Question it answers', 'Papers', `Share of ${ipClassified.length}`],
 +      [...fam.entries()]
 +        .sort((a, b) => b[1].set.size - a[1].set.size)
 +        .filter(([, v]) => v.set.size >= 2)
 +        .map(([k, v]) => [k, TASK_LABEL[v.task], v.set.size, pct(v.set.size, ipClassified.length)])
 +    )
 +  );
 +  const singles = [...fam.entries()].filter(([, v]) => v.set.size === 1);
 +  console.log(`\nfamilies named by exactly one paper: ${singles.length} (${singles.map(([k]) => k).join('; ')})`);
 +  console.log(`unfolded residue: ${residue.size} distinct strings`);
 +  for (const [k, v] of residue) console.log(`  ${k}  (${v.size})`);
 +}
 +
 +// ---------------------------------- MaxMind spelling count, the headline ----
 +head('How badly exact-string counting undercounts (MaxMind)');
 +{
 +  const spellings = new Set();
 +  const folded = new Set();
 +  const exact = new Map();
 +  const scope = [];
 +  for (const p of rows) {
 +    for (const t of p.classification)
 +      if (t.target === 'ip-address' && t.resourceName && !isSentinel(t.resourceName))
 +        scope.push([p, t.resourceName]);
 +    for (const t of p.vantage)
 +      if (t.geolocationService && !isSentinel(t.geolocationService)) scope.push([p, t.geolocationService]);
 +  }
 +  for (const [p, name] of scope) {
 +    const f = foldIpResource(name);
 +    if (f?.family !== 'MaxMind') continue;
 +    spellings.add(name);
 +    folded.add(key(p));
 +    if (!exact.has(name)) exact.set(name, new Set());
 +    exact.get(name).add(key(p));
 +  }
 +  const best = [...exact.entries()].sort((a, b) => b[1].size - a[1].size)[0];
 +  console.log(`distinct spellings of MaxMind:      ${spellings.size}`);
 +  console.log(`papers, folded:                     ${folded.size}`);
 +  console.log(`papers under the commonest spelling ("${best[0]}"): ${best[1].size}`);
 +  console.log(`undercount if you count exact strings: ${(100 * (1 - best[1].size / folded.size)).toFixed(0)}%`);
 +}
 +
 +// -------------------------------------- who says which service they used ----
 +head('Papers that geolocate their own vantage point');
 +{
 +  const named = (pop) => {
 +    const s = new Set();
 +    for (const p of pop)
 +      for (const t of p.vantage)
 +        if (t.geolocationService && !isSentinel(t.geolocationService)) s.add(key(p));
 +    return s;
 +  };
 +  console.log(
 +    T(
 +      ['Population', 'N', 'Names a geolocation service', 'Share'],
 +      [
 +        ['crawled', crawled.length, named(crawled).size, pct(named(crawled).size, crawled.length)],
 +        [
 +          'measuredFrom',
 +          measuredFrom.length,
 +          named(measuredFrom).size,
 +          pct(named(measuredFrom).size, measuredFrom.length),
 +        ],
 +      ]
 +    )
 +  );
 +
 +  const fam = new Map();
 +  for (const p of rows)
 +    for (const t of p.vantage) {
 +      if (!t.geolocationService || isSentinel(t.geolocationService)) continue;
 +      const f = foldIpResource(t.geolocationService);
 +      const id = f ? f.family : `UNFOLDED: ${t.geolocationService}`;
 +      if (!fam.has(id)) fam.set(id, new Set());
 +      fam.get(id).add(key(p));
 +    }
 +  const total = new Set([...fam.values()].flatMap((s) => [...s])).size;
 +  console.log(`\nof the ${total} papers naming one:`);
 +  console.log(
 +    T(
 +      ['Service family', 'Papers', 'Share of ' + total],
 +      [...fam.entries()]
 +        .sort((a, b) => b[1].size - a[1].size)
 +        .filter(([, s]) => s.size >= 2)
 +        .map(([k, s]) => [k, s.size, pct(s.size, total)])
 +    )
 +  );
 +  console.log(
 +    `named by one paper each: ${[...fam.entries()].filter(([, s]) => s.size === 1).length} families`
 +  );
 +}
 +
 +// ------------------------------------------------------- version stated ----
 +head('Do the papers say which snapshot of the database they used?');
 +{
 +  // A geolocation database is versioned by date. The extraction does not carry a
 +  // version field for classification resources, so this is a text proxy: does
 +  // the evidence quote or the resource name mention a date, month or version?
 +  const dated = /\b(20\d\d|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|version|v\d|snapshot|as of)\b/i;
 +  const users = ipClassified.filter((p) =>
 +    ipTuples(p).some((t) => {
 +      const f = t.resourceName ? foldIpResource(t.resourceName) : null;
 +      return f && (f.task === 'geolocation' || f.task === 'routing') && t.usedOrMentioned === 'used';
 +    })
 +  );
 +  const withDate = users.filter((p) =>
 +    ipTuples(p).some((t) => dated.test(`${t.resourceName ?? ''} ${t.evidence?.quote ?? ''}`))
 +  );
 +  console.log(
 +    `papers using a third-party geo or routing dataset: ${users.length}`
 +  );
 +  console.log(
 +    `  ...whose evidence quote carries any date/version token: ${withDate.length} (${pct(withDate.length, users.length)})`
 +  );
 +  console.log('  (text proxy, not a schema field — treat as an upper bound)');
 +}
 +
 +// ------------------------------------------- measured results, verbatim ----
 +head('Measured figures on geolocation-database accuracy (detection[].prevalence)');
 +{
 +  const re =
 +    /geoloc|maxmind|ip2location|ipinfo|netacuity|geolite|cgn|carrier.?grade|residential prox|\basn?\b|autonomous system|anycast/i;
 +  const seen = [];
 +  for (const p of rows)
 +    for (const t of p.detection) {
 +      if (!t.prevalence) continue;
 +      if (!re.test(`${t.phenomenon} ${t.technique} ${t.metric}`)) continue;
 +      seen.push([p, t]);
 +    }
 +  console.log(`${seen.length} prevalence-bearing detection tuples match the IP-classification regex`);
 +  console.log('(full dump written to out/ip_detection_sweep.txt by the ad-hoc sweep)');
 +}
 +
 +console.log('\ndone.');
 +
 +// ------------------------------ operator-published prefix lists, full text ----
 +// Not a schema field: does anyone in this literature cite the cloud operators'
 +// own IP-range files? Full-text grep over every paper in the corpus.
 +{
 +  const fs = await import('node:fs');
 +  const path = await import('node:path');
 +  const { dataRoot } = await import('./lib.mjs');
 +  const re =
 +    /ip-ranges\.amazonaws|gstatic\.com\/ipranges|cloudflare\.com\/ips|public_ip_ranges\.json|ServiceTags_Public/i;
 +  const hits = [];
 +  for (const p of rows) {
 +    const f = path.join(dataRoot(), 'fulltext', String(p.year), p.venue, p.slug, 'paper.cols.txt');
 +    if (!fs.existsSync(f)) continue;
 +    if (re.test(fs.readFileSync(f, 'utf8'))) hits.push(`${p.year} ${p.venue} ${p.slug}`);
 +  }
 +  head('Papers citing an operator-published cloud prefix-list URL (full-text grep)');
 +  console.log(`${hits.length} of ${rows.length} papers`);
 +  for (const h of hits) console.log(`  ${h}`);
 +  console.log('(a floor: undercounts papers that used a list without citing its URL)');
 +}
 +
 +// ---------------------------------------------- cross-checking behaviour ----
 +head('Papers naming more than one geolocation source');
 +{
 +  const famsFor = (p, task) =>
 +    new Set(
 +      ipTuples(p)
 +        .map((t) => t.resourceName)
 +        .filter((n) => n && !isSentinel(n))
 +        .map(foldIpResource)
 +        .filter((f) => f && f.task === task)
 +        .map((f) => f.family)
 +    );
 +  const geoUsers = ipClassified.filter((p) => famsFor(p, 'geolocation').size > 0);
 +  const multi = geoUsers.filter((p) => famsFor(p, 'geolocation').size >= 2);
 +  console.log(`name >=1 geolocation source:  ${geoUsers.length} of ${ipClassified.length}  (${pct(geoUsers.length, ipClassified.length)})`);
 +  console.log(`  ...of which name >=2:       ${multi.length}  (${pct(multi.length, geoUsers.length)} of ${geoUsers.length})`);
 +  for (const p of multi.sort((a, b) => a.year - b.year))
 +    console.log(`    ${p.year} ${p.venue} ${p.title.slice(0, 78)}`);
 +  for (const task of ['routing', 'network-type']) {
 +    const n = ipClassified.filter((p) => famsFor(p, task).size > 0);
 +    console.log(`name >=1 '${task}' source:      ${n.length} of ${ipClassified.length}`);
 +    if (task === 'network-type')
 +      console.log(`  earliest ${Math.min(...n.map((p) => p.year))}, ${n.filter((p) => p.year >= 2019).length} from 2019 or later`);
 +  }
 +}
 +
 +// --------------------------------------- size of the folded name universe ----
 +head('Free-text name universe that ip_fold.mjs covers');
 +{
 +  const names = new Set();
 +  for (const p of rows) {
 +    for (const t of p.classification)
 +      if (t.target === 'ip-address' && t.resourceName && !isSentinel(t.resourceName))
 +        names.add(t.resourceName);
 +    for (const t of p.vantage)
 +      if (t.geolocationService && !isSentinel(t.geolocationService)) names.add(t.geolocationService);
 +  }
 +  const unmapped = [...names].filter((n) => !foldIpResource(n));
 +  console.log(`distinct strings: ${names.size}`);
 +  console.log(`unmapped residue: ${unmapped.length}${unmapped.length ? ' -> ' + unmapped.join('; ') : ''}`);
 +}
 +
 +// ----------------------------------------------- figures the page carried ----
 +// Added 2026-08-12. Each of these was on design:ip_classification and in no
 +// script, so none of them could be re-derived when the corpus grew. If a figure
 +// is on the page it belongs here.
 +head('Figures the page carries that were not previously printed');
 +{
 +  const imc = rows.filter((p) => p.venue === 'IMC').length;
 +  const imcIp = ipClassified.filter((p) => p.venue === 'IMC').length;
 +  console.log(`IMC: ${imcIp} of ${imc}. Other six venues: ${ipClassified.length - imcIp} of ${rows.length - imc}.`);
 +
 +  // MaxMind, across BOTH fields the fold covers, vs within the IP-classifying set.
 +  const mmAll = new Set();
 +  const mmIp = new Set();
 +  const mmSpellings = new Set();
 +  for (const p of rows) {
 +    let hitIp = false;
 +    let hit = false;
 +    for (const t of p.classification) {
 +      if (t.target !== 'ip-address' || isSentinel(t.resourceName)) continue;
 +      const f = foldIpResource(t.resourceName);
 +      if (f && f.family === 'MaxMind') { hit = true; hitIp = true; mmSpellings.add(t.resourceName); }
 +    }
 +    for (const t of p.vantage) {
 +      if (isSentinel(t.geolocationService)) continue;
 +      const f = foldIpResource(t.geolocationService);
 +      if (f && f.family === 'MaxMind') { hit = true; mmSpellings.add(t.geolocationService); }
 +    }
 +    if (hit) mmAll.add(key(p));
 +    if (hitIp) mmIp.add(key(p));
 +  }
 +  console.log(`MaxMind: ${mmAll.size} papers across both fields, ${mmIp.size} of them inside the ${ipClassified.length} IP-classifying papers, ` +
 +    `so ${mmAll.size - mmIp.size} name it only for their own vantage point. ${mmSpellings.size} distinct spellings.`);
 +
 +  // Crawling papers: classify an observed address vs geolocate their own vantage.
 +  const crawlIp = crawled.filter((p) => ipTuples(p).length > 0).length;
 +  const crawlGeo = crawled.filter((p) => p.vantage.some((t) => !isSentinel(t.geolocationService))).length;
 +  const crawlEither = crawled.filter(
 +    (p) => ipTuples(p).length > 0 || p.vantage.some((t) => !isSentinel(t.geolocationService))
 +  ).length;
 +  console.log(`Crawling papers: ${crawlIp} classify an observed address, ${crawlGeo} geolocate their own vantage point, ` +
 +    `union ${crawlEither} of ${crawled.length} (${pct(crawlEither, crawled.length)}).`);
 +}
 +</file>
 +
 +Output (''node scripts/report_ip_classification.mjs'', 2026-09-22):
 +
 +<file text report_ip_classification-output.txt>
 +corpus                        5859
 +crawled                       1120
 +measuredFrom                  3908
 +ipClassified                  295  (5.0% of corpus)
 +
 +### Papers classifying an IP address, by venue and period
 +
 +Venue    Papers classifying an IP  Papers in corpus  Share of venue
 +-------  ------------------------  ----------------  --------------
 +IMC      124                       638               19.4%
 +USENIX   55                        1410              3.9%
 +NDSS     38                        701               5.4%
 +CCS      24                        990               2.4%
 +WWW      24                        843               2.8%
 +IEEE-SP  21                        767               2.7%
 +PETS     9                         510               1.8%
 +
 +Period      Papers classifying an IP  Papers in corpus  Share
 +----------  ------------------------  ----------------  -----
 +2010–2013   31                        511               6.1%
 +2014–2017   61                        769               7.9%
 +2018–2021   68                        1439              4.7%
 +2022–2024   84                        1955              4.3%
 +2025–2026*  51                        1185              4.3%
 +
 +### How the IP was classified (enum, papers of 295)
 +
 +Method               Papers  Share of 295
 +-------------------  ------  ------------
 +third-party-service  124     42.0%
 +curated-database     109     36.9%
 +heuristic-rules      88      29.8%
 +blocklist            16      5.4%
 +regex-or-signature   12      4.1%
 +other                9       3.1%
 +manual-labelling     8       2.7%
 +supervised-ml        7       2.4%
 +graph-analysis       5       1.7%
 +dynamic-analysis     4       1.4%
 +static-analysis      2       0.7%
 +llm                  1       0.3%
 +
 +### Whether the IP classification was validated (papers of 295)
 +
 +Validation                  Papers  Share of 295
 +--------------------------  ------  ------------
 +not-applicable              121     41.0%
 +none-reported               99      33.6%
 +manual-validation           54      18.3%
 +comparison-to-other-method  49      16.6%
 +held-out-test-set           3       1.0%
 +cross-validation            1       0.3%
 +
 +names a ground-truth source:  127 / 295  (43.1%)
 +no validation on any IP tuple: 196 / 295  (66.4%)
 +
 +### Which resources, folded (papers of 295)
 +
 +Resource family                                                       Question it answers                     Papers  Share of 295
 +--------------------------------------------------------------------  --------------------------------------  ------  ------------
 +Home-grown heuristic or classifier                                    Home-grown                              104     35.3%
 +MaxMind                                                               Where is it?                            65      22.0%
 +Other IP blocklists (DShield, FireHOL, CBL, AbuseIPDB, Honey Pot, …)  Is it known-bad?                        33      11.2%
 +IPinfo                                                                Where is it?                            26      8.8%
 +Router alias / router-to-AS (bdrmapIT, MAP-IT, MIDAR, Hoiho)          Is it a router, and which one?          16      5.4%
 +Team Cymru IP-to-ASN                                                  Whose network is it?                    16      5.4%
 +Censys / Shodan / Nmap / Snort / Suricata                             What is running on it?                  15      5.1%
 +IP2Location                                                           Where is it?                            14      4.7%
 +VirusTotal / Google Safe Browsing                                     Is it known-bad?                        14      4.7%
 +RouteViews                                                            Whose network is it?                    14      4.7%
 +WHOIS / IRR / RIR delegation files                                    Whose network is it?                    13      4.4%
 +Spamhaus                                                              Is it known-bad?                        12      4.1%
 +CAIDA datasets (prefix2as, AS2Org, ITDK)                              Whose network is it?                    11      3.7%
 +Raw BGP feeds and IX data                                             Whose network is it?                    7       2.4%
 +RIPE RIS / RIPEstat / RIPE Atlas                                      Whose network is it?                    7       2.4%
 +Free geo-lookup APIs (freegeoip, ipstack, HostIP, IPInfoDB, …)        Where is it?                            6       2.0%
 +PeeringDB                                                             Whose network is it?                    6       2.0%
 +NetAcuity (Digital Element)                                           Where is it?                            5       1.7%
 +RIPE IPmap                                                            Where is it?                            5       1.7%
 +Fraud-score APIs (IPQualityScore, IPHub, Scamalytics, VPNAPI, Udger)  What kind of host is it?                5       1.7%
 +ASdb                                                                  Whose network is it?                    5       1.7%
 +GreyNoise                                                             What kind of host is it?                5       1.7%
 +Unnamed commercial geo database                                       Where is it?                            4       1.4%
 +Geocoding / positioning reference (GeoNames, Google, Skyhook, WiGLE)  Location reference data (not IP-based)  4       1.4%
 +Chinese geo databases (Chunzhen/QQWry, AIWEN, QAX, 360, Tencent)      Where is it?                            3       1.0%
 +MaxMind Anonymous IP / minFraud                                       What kind of host is it?                3       1.0%
 +Quova / Neustar                                                       Where is it?                            2       0.7%
 +Chainalysis                                                           Is it known-bad?                        2       0.7%
 +IP2Proxy                                                              What kind of host is it?                2       0.7%
 +pyasn / iptoasn.com                                                   Whose network is it?                    2       0.7%
 +Spur                                                                  What kind of host is it?                2       0.7%
 +
 +families named by exactly one paper: 9 (ip-api.com; Akamai EdgeScape; CDN / platform internal geo; bgp.tools; Phone-number reference (Twilio, OpenCNAM); Cloud/hosting provider published IP ranges; Email authentication (SPF/DMARC); DB-IP; LLM (GPT-4o))
 +unfolded residue: 0 distinct strings
 +
 +### How badly exact-string counting undercounts (MaxMind)
 +
 +distinct spellings of MaxMind:      50
 +papers, folded:                     134
 +papers under the commonest spelling ("MaxMind"): 28
 +undercount if you count exact strings: 79%
 +
 +### Papers that geolocate their own vantage point
 +
 +Population    N     Names a geolocation service  Share
 +------------  ----  ---------------------------  -----
 +crawled       1120  45                           4.0%
 +measuredFrom  3908  194                          5.0%
 +
 +of the 194 papers naming one:
 +Service family                                                        Papers  Share of 194
 +--------------------------------------------------------------------  ------  ------------
 +MaxMind                                                               108     55.7%
 +IPinfo                                                                17      8.8%
 +Geocoding / positioning reference (GeoNames, Google, Skyhook, WiGLE)  10      5.2%
 +IP2Location                                                           8       4.1%
 +Free geo-lookup APIs (freegeoip, ipstack, HostIP, IPInfoDB, …)        7       3.6%
 +ip-api.com                                                            7       3.6%
 +RIPE IPmap                                                            7       3.6%
 +Unnamed commercial geo database                                       5       2.6%
 +NetAcuity (Digital Element)                                           5       2.6%
 +CDN / platform internal geo                                           5       2.6%
 +Chinese geo databases (Chunzhen/QQWry, AIWEN, QAX, 360, Tencent)      2       1.0%
 +Akamai EdgeScape                                                      2       1.0%
 +Team Cymru IP-to-ASN                                                  2       1.0%
 +Quova / Neustar                                                       2       1.0%
 +Home-grown heuristic or classifier                                    2       1.0%
 +WHOIS / IRR / RIR delegation files                                    2       1.0%
 +named by one paper each: 4 families
 +
 +### Do the papers say which snapshot of the database they used?
 +
 +papers using a third-party geo or routing dataset: 162
 +  ...whose evidence quote carries any date/version token: 9 (5.6%)
 +  (text proxy, not a schema field — treat as an upper bound)
 +
 +### Measured figures on geolocation-database accuracy (detection[].prevalence)
 +
 +630 prevalence-bearing detection tuples match the IP-classification regex
 +(full dump written to out/ip_detection_sweep.txt by the ad-hoc sweep)
 +
 +done.
 +
 +### Papers citing an operator-published cloud prefix-list URL (full-text grep)
 +
 +7 of 5859 papers
 +  2017 IMC large-scale-scanning-of-tcps-initial-window
 +  2019 IEEE-SP resident-evil-understanding-residential-ip-proxy-as-a-dark-service
 +  2020 CCS censored-planet-an-internet-wide-longitudinal-censorship-observatory
 +  2020 NDSS cdn-judo-breaking-the-cdn-dos-protection-with-itself
 +  2021 CCS warmonger-inflicting-denial-of-service-via-serverless-functions-in-the-cloud
 +  2023 USENIX dscope-a-cloud-native-internet-telescope
 +  2025 NDSS secure-ip-address-allocation-at-cloud-scale
 +(a floor: undercounts papers that used a list without citing its URL)
 +
 +### Papers naming more than one geolocation source
 +
 +name >=1 geolocation source:  114 of 295  (38.6%)
 +  ...of which name >=2:       14  (12.3% of 114)
 +    2010 IMC Eyeball ASes: from geography to connectivity.
 +    2017 IMC A look at router geolocation in public and commercial databases.
 +    2018 IMC An Empirical Analysis of the Commercial VPN Ecosystem.
 +    2018 IMC Tracing Cross Border Web Tracking.
 +    2022 CCS An Extensive Study of Residential Proxies in China.
 +    2022 IMC Are we ready for metaverse?: a measurement study of social virtual reality pla
 +    2022 USENIX Gossamer: Securely Measuring Password-based Logins
 +    2023 IEEE-SP IPvSeeYou: Exploiting Leaked Identifiers in IPv6 for Street-Level Geolocation.
 +    2023 IMC How to Operate a Meta-Telescope in your Spare Time.
 +    2023 IMC Replication: Towards a Publicly Available Internet Scale IP Geolocation Datase
 +    2024 IMC A First Look at Immersive Telepresence on Apple Vision Pro.
 +    2024 IMC Watching TV with the Second-Party: A First Look at Automatic Content Recogniti
 +    2025 NDSS Wallbleed: A Memory Disclosure Vulnerability in the Great Firewall of China
 +    2025 USENIX eSIMplicity or eSIMplification? Privacy and Security Risks in the eSIM Ecosyst
 +name >=1 'routing' source:      68 of 295
 +name >=1 'network-type' source:      17 of 295
 +  earliest 2018, 16 from 2019 or later
 +
 +### Free-text name universe that ip_fold.mjs covers
 +
 +distinct strings: 443
 +unmapped residue: 0
 +
 +### Figures the page carries that were not previously printed
 +
 +IMC: 124 of 638. Other six venues: 171 of 5221.
 +MaxMind: 134 papers across both fields, 65 of them inside the 295 IP-classifying papers, so 69 name it only for their own vantage point. 50 distinct spellings.
 +Crawling papers: 54 classify an observed address, 45 geolocate their own vantage point, union 76 of 1120 (6.8%).
 +</file>
 +
 +<file javascript ip_fold.mjs>
 +// Fold the free-text names that appear when a paper classifies an IP address.
 +//
 +// Scope: the union of
 +//   classification[].resourceName  where target === 'ip-address'
 +//   vantage[].geolocationService
 +// 363 distinct strings in run1. MaxMind alone appears under 24 spellings
 +// ("MaxMind", "Maxmind geoip", "Max-Mind GeoIP2 Precision Insights", "GeoIP:
 +// MaxMind", "MAXMIND GeoIP2", …), so counting exact strings understates the
 +// single most-used resource in the field by a factor of about four.
 +//
 +// Rules, same as scripts/tool_fold.mjs:
 +//   * explicit ordered regex families, first match wins
 +//   * specific products before the generic term they contain
 +//     (IP2Proxy before IP2Location; GeoLite before the bare "GeoIP")
 +//   * everything unmatched is returned as residue and PRINTED, never dropped
 +//
 +// Each family also carries the *question* it answers, because "classify an IP"
 +// is five different measurements with five different error budgets.
 +
 +/** @typedef {'geolocation'|'routing'|'network-type'|'reputation'|'topology'|'scanning'|'reference'|'custom'} Task */
 +
 +/** @type {{family: string, task: Task, re: RegExp}[]} */
 +export const FAMILIES = [
 +  // ---- proxy / VPN / hosting detection: match before their parent vendors ----
 +  { family: 'IP2Proxy', task: 'network-type', re: /\bip\s*-?\s*2\s*-?\s*proxy\b/i },
 +  {
 +    family: 'MaxMind Anonymous IP / minFraud',
 +    task: 'network-type',
 +    re: /anonymous[\s-]*ip|minfraud/i,
 +  },
 +  { family: 'Spur', task: 'network-type', re: /\bspur\b/i },
 +  { family: 'GreyNoise', task: 'network-type', re: /grey\s*-?\s*noise/i },
 +  {
 +    family: 'Fraud-score APIs (IPQualityScore, IPHub, Scamalytics, VPNAPI, Udger)',
 +    task: 'network-type',
 +    re: /ipqualityscore|iphub|scamalytics|vpnapi|udger|access\.watch|vpnchecker/i,
 +  },
 +
 +  // ---- geolocation databases and services ----
 +  { family: 'MaxMind', task: 'geolocation', re: /max\s*-?\s*mind|geolite|geo\s*-?ip\s*2?\b|geoip/i },
 +  { family: 'IP2Location', task: 'geolocation', re: /ip\s*-?\s*2\s*-?\s*location/i },
 +  { family: 'IPinfo', task: 'geolocation', re: /\bip\s*-?\s*info\b|ipinfo/i },
 +  {
 +    family: 'NetAcuity (Digital Element)',
 +    task: 'geolocation',
 +    re: /net\s*-?\s*acuity|digital\s+(element|envoy)/i,
 +  },
 +  { family: 'DB-IP', task: 'geolocation', re: /\bdb\s*-?\s*ip\b/i },
 +  { family: 'Quova / Neustar', task: 'geolocation', re: /quova|neustar/i },
 +  { family: 'Akamai EdgeScape', task: 'geolocation', re: /edge\s*-?\s*scape/i },
 +  { family: 'ip-api.com', task: 'geolocation', re: /\bip\s*-?\s*api\b/i },
 +  { family: 'RIPE IPmap', task: 'geolocation', re: /ip\s*-?\s*map/i },
 +  {
 +    family: 'Free geo-lookup APIs (freegeoip, ipstack, HostIP, IPInfoDB, …)',
 +    task: 'geolocation',
 +    re: /freegeoip|ipstack|hostip|ipinfodb|ipvigilante|findip|ipregistry|iplocation|keycdn|ifconfig\.co|ipaddressapi|blackbox api|ipgeolocation\.io/i,
 +  },
 +  {
 +    family: 'Chinese geo databases (Chunzhen/QQWry, AIWEN, QAX, 360, Tencent)',
 +    task: 'geolocation',
 +    re: /chunzhen|qqwry|aiwen|\bqax\b|360 security|tencent/i,
 +  },
 +  {
 +    family: 'CDN / platform internal geo',
 +    task: 'geolocation',
 +    re: /cloudflare|cdn'?s? (proprietary )?geoloc|instagram ip geolocation|starlink location/i,
 +  },
 +  {
 +    family: 'Unnamed commercial geo database',
 +    task: 'geolocation',
 +    re: /^(commercial (ip )?geolocation (services|databases)|third[- ]party (commercial service|mapping database)|ip geolocation (database|lookup)|free geoip databases|majority vote across 10( geolocation)? databases)$/i,
 +  },
 +
 +  // ---- routing / ASN ----
 +  { family: 'Team Cymru IP-to-ASN', task: 'routing', re: /team\s*-?\s*cymru/i },
 +  { family: 'RouteViews', task: 'routing', re: /route\s*-?\s*views/i },
 +  {
 +    family: 'RIPE RIS / RIPEstat / RIPE Atlas',
 +    task: 'routing',
 +    re: /\bripe(?!n)|riswhois/i, // "RIPEstat" has no word boundary after RIPE
 +  },
 +  { family: 'CAIDA datasets (prefix2as, AS2Org, ITDK)', task: 'routing', re: /caida/i },
 +  { family: 'PeeringDB', task: 'routing', re: /peering\s*-?\s*db/i },
 +  { family: 'bgp.tools', task: 'routing', re: /bgp\.tools/i },
 +  { family: 'ASdb', task: 'routing', re: /\basdb\b|as names dataset|as-name string/i },
 +  { family: 'pyasn / iptoasn.com', task: 'routing', re: /py\s*-?\s*asn|ip\s*-?\s*to\s*-?\s*asn/i },
 +  {
 +    family: 'WHOIS / IRR / RIR delegation files',
 +    task: 'routing',
 +    re: /whois|internet routing registry|\birr\b|regional internet registry|routinator|iana [a-z0-9 ]*registry|special-purpose address registry/i,
 +  },
 +  {
 +    family: 'Raw BGP feeds and IX data',
 +    task: 'routing',
 +    re: /\bbgp\b|euroix|packet clearing house|longest prefix match|majority-as ownership|telegeography/i,
 +  },
 +
 +  // ---- reputation / abuse blocklists ----
 +  {
 +    family: 'Spamhaus',
 +    task: 'reputation',
 +    re: /spamhaus/i,
 +  },
 +  {
 +    family: 'Other IP blocklists (DShield, FireHOL, CBL, AbuseIPDB, Honey Pot, …)',
 +    task: 'reputation',
 +    re: /dshield|firehol|composite blocking list|\bcbl\b|abuseipdb|project honey ?pot|uceprotect|\bblag\b|emergingthreats|urlhaus|dnsbl|all\.s5h|manitu|blacklist|blocklist|malwareworld|barracuda|brightmail|botscout|bothunter|cymon|\bnotos\b|blacklisting services|spamcop|sinkdb|\bmisp\b|block lists|^bl-[a-z]$|institutional list by/i,
 +  },
 +  {
 +    family: 'VirusTotal / Google Safe Browsing',
 +    task: 'reputation',
 +    re: /virustotal|safe\s*-?\s*browsing/i,
 +  },
 +  { family: 'Chainalysis', task: 'reputation', re: /chainalysis/i },
 +  // Criminal IP is an attack-surface search engine in the Censys/Shodan mould,
 +  // marketed as threat intelligence. Filed under scanning, which is what it does.
 +  { family: 'Censys / Shodan / Nmap / Snort / Suricata', task: 'scanning', re: /criminal\s*-?\s*ip/i },
 +  // Published hosting/cloud address ranges (AWS ip-ranges.json and friends).
 +  // Distinct from a fraud-score API: this is the provider's own list.
 +  { family: 'Cloud/hosting provider published IP ranges', task: 'network-type', re: /cloud[- ]provider[- ]ip[- ]addresses|cloud provider (published )?(ip )?ranges|ip-ranges\.json/i },
 +  // 2026-08-12: the first LLM in this field. One paper, GPT-4o. Kept as its own
 +  // family rather than folded into 'Home-grown' so it stays countable.
 +  { family: 'LLM (GPT-4o)', task: 'custom', re: /\bgpt-?\s?[0-9]|\bllm\b|claude|gemini/i },
 +  // Email-authentication records: not an IP classifier, but the extraction files
 +  // them here because the unit of analysis is the sending IP.
 +  { family: 'Email authentication (SPF/DMARC)', task: 'reputation', re: /\bspf\b|\bdmarc\b|\bdkim\b/i },
 +  // Phone-number reference services, from one paper whose unit was a phone
 +  // number rather than an IP. Non-IP reference data, like the geocoding row.
 +  { family: 'Phone-number reference (Twilio, OpenCNAM)', task: 'reference', re: /twilio|opencnam/i },
 +
 +  // ---- active scanning / host fingerprinting ----
 +  {
 +    family: 'Censys / Shodan / Nmap / Snort / Suricata',
 +    task: 'scanning',
 +    re: /censys|shodan|\bnmap\b|\bsnort\b|suricata|whatweb|\bhershel\b|snacktime|\bfpdns\b/i,
 +  },
 +
 +  // ---- router topology: alias resolution and router-to-AS ----
 +  {
 +    family: 'Router alias / router-to-AS (bdrmapIT, MAP-IT, MIDAR, Hoiho)',
 +    task: 'topology',
 +    re: /bdrmap|map\s*-?\s*it\b|midar|hoiho|alias|addr6|\bmbt\b|monotonic bounds|manycast/i,
 +  },
 +
 +  // ---- non-IP location reference data ----
 +  {
 +    family: 'Geocoding / positioning reference (GeoNames, Google, Skyhook, WiGLE)',
 +    task: 'reference',
 +    re: /geonames|google (geocoding|maps|location|geolocation)|skyhook|wigle|\bgps\b|html5 geolocation|ieee oui|iata|nominatim|apple'?s? wps|wi-?fi positioning/i,
 +  },
 +
 +  // ---- home-grown ----
 +  {
 +    family: 'Home-grown heuristic or classifier',
 +    task: 'custom',
 +    re: /\(custom\)|^custom\b|heuristic|hand-crafted|regular expressions|regexes|random forest|xgboost|\blstm\b|support vector|k-nearest|belief propagation|threshold random walk|network[- ]aware clusters|reverse[- ]?dns|rdns|ptr and soa|manual dns|reverse ip address lookup|network fingerprinting|tcp fingerprint|http fingerprinting|ttl-based|mpls labeling|eui-64|snmp|ssh identifier|tor (tls|consensus)|collector network status|recursion-available|too big trick|passport|trackiptree|traffic distribution system|cyberprobe|great firewall|prediction functions|apple server naming|client-side ip address map|30-day unresponsive|gasser et al|ipv6 hitlist|cbg|shortest-ping|port-variation|packet-pattern|multi-stage scoring|multi-level|trained classifier|scam address list|forrester|compiled|who is my isp|mobile carrier database|back-to-the-future|bttf|dnsdb|\blfp\b|\bhoiho\b|dns suffix|r&e\/commodity|neighbor classification|perception live behavior|asn matching against|architecture_detection/i,
 +  },
 +];
 +
 +/**
 + * @param {string} name
 + * @returns {{family: string, task: Task} | null}
 + */
 +export function foldIpResource(name) {
 +  const s = String(name).trim();
 +  for (const f of FAMILIES) {
 +    if (f.re.test(s)) return { family: f.family, task: f.task };
 +  }
 +  return null;
 +}
 +
 +export const TASK_LABEL = {
 +  geolocation: 'Where is it?',
 +  routing: 'Whose network is it?',
 +  'network-type': 'What kind of host is it?',
 +  reputation: 'Is it known-bad?',
 +  topology: 'Is it a router, and which one?',
 +  scanning: 'What is running on it?',
 +  reference: 'Location reference data (not IP-based)',
 +  custom: 'Home-grown',
 +};
 +</file>
 +
 +<file javascript maxmind_version.mjs>
 +// Do papers that use MaxMind say WHICH snapshot of MaxMind they used?
 +//
 +// A geolocation database is a moving target: MaxMind reissues GeoLite2 twice a
 +// week. "We used MaxMind" is therefore not a reproducible statement. The
 +// extraction schema has no version field for a classification resource, so this
 +// measures it directly against the full text: for every paper whose extraction
 +// names MaxMind, find every sentence in paper.cols.txt that mentions MaxMind and
 +// ask whether any of them carries a date, a month, or a version/edition token.
 +//
 +//   node scripts/maxmind_version.mjs [--dump]
 +//
 +// This is a generous test. A sentence saying "we crawled in March 2019 using
 +// MaxMind" counts as dated even though the crawl date is not the database date.
 +// Read the figure as an UPPER BOUND on how often the snapshot is identifiable.
 +
 +import fs from 'node:fs';
 +import path from 'node:path';
 +import { loadExtractions, isSentinel, pct, dataRoot } from './lib.mjs';
 +import { foldIpResource } from './ip_fold.mjs';
 +
 +const DUMP = process.argv.includes('--dump');
 +const root = dataRoot();
 +const rows = loadExtractions();
 +
 +const isMaxMind = (s) => s && !isSentinel(s) && foldIpResource(s)?.family === 'MaxMind';
 +const users = rows.filter(
 +  (p) =>
 +    p.classification.some((t) => t.target === 'ip-address' && isMaxMind(t.resourceName)) ||
 +    p.vantage.some((t) => isMaxMind(t.geolocationService))
 +);
 +
 +// A month name, a year, or an explicit version/edition word within the sentence.
 +const DATED =
 +  /\b(19|20)\d\d\b|\b(jan(uary)?|feb(ruary)?|mar(ch)?|apr(il)?|may|jun(e)?|jul(y)?|aug(ust)?|sep(t|tember)?|oct(ober)?|nov(ember)?|dec(ember)?)\b|\bversion\b|\bv\d|\brelease[sd]?\b|\bsnapshot\b|\bedition\b|\bas of\b|\bdated\b|\bvintage\b/i;
 +
 +// A bibliography entry ("MaxMind GeoIP. http://maxmind.com/, 2014.") carries a
 +// year but tells the reader nothing about which snapshot was queried. Drop
 +// sentences that look like reference-list entries before the strict count.
 +const BIBLIKE = /https?:\/\//i;
 +
 +let read = 0,
 +  missing = 0,
 +  mentioned = 0,
 +  dated = 0,
 +  datedStrict = 0;
 +const examples = [];
 +
 +for (const p of users) {
 +  const file = path.join(root, 'fulltext', String(p.year), p.venue, p.slug, 'paper.cols.txt');
 +  if (!fs.existsSync(file)) {
 +    missing += 1;
 +    continue;
 +  }
 +  read += 1;
 +  const text = fs.readFileSync(file, 'utf8').replace(/\s+/g, ' ');
 +  // Split on sentence-ish boundaries; keep it crude, the unit is "nearby text".
 +  const sentences = text.split(/(?<=[.;:])\s+/);
 +  const hits = sentences.filter((s) => /max\s*-?\s*mind|geolite|geoip/i.test(s));
 +  if (hits.length === 0) continue; // extraction says MaxMind, text does not — see note
 +  mentioned += 1;
 +  const withDate = hits.filter((s) => DATED.test(s));
 +  const strict = withDate.filter((s) => !BIBLIKE.test(s));
 +  if (strict.length > 0) datedStrict += 1;
 +  if (withDate.length > 0) {
 +    dated += 1;
 +    if (strict.length > 0 && examples.length < 8)
 +      examples.push([`${p.year}/${p.venue}/${p.slug}`, strict[0].slice(0, 240)]);
 +  } else if (DUMP) {
 +    console.log(`UNDATED  ${p.year}/${p.venue}/${p.slug}\n    ${hits[0].slice(0, 200)}`);
 +  }
 +}
 +
 +console.log(`papers whose extraction names MaxMind:        ${users.length}`);
 +console.log(`  full text available:                        ${read}  (missing ${missing})`);
 +console.log(`  MaxMind/GeoIP/GeoLite found in the text:    ${mentioned}  (${pct(mentioned, read)})`);
 +console.log(
 +  `  ...with a date/version token in the same sentence: ${dated}  (${pct(dated, mentioned)} of ${mentioned})`
 +);
 +console.log(
 +  `  ...excluding sentences that are bibliography entries:  ${datedStrict}  (${pct(datedStrict, mentioned)} of ${mentioned})`
 +);
 +console.log('\nStill an upper bound: any year token in the sentence counts, including crawl dates.\n');
 +for (const [k, s] of examples) console.log(`  ${k}\n    ${s}\n`);
 +</file>
 +
 +Output (''node scripts/maxmind_version.mjs'', 2026-09-22):
 +
 +<file text maxmind_version-output.txt>
 +papers whose extraction names MaxMind:        134
 +  full text available:                        134  (missing 0)
 +  MaxMind/GeoIP/GeoLite found in the text:    134  (100.0%)
 +  ...with a date/version token in the same sentence: 52  (38.8% of 134)
 +  ...excluding sentences that are bibliography entries:  36  (26.9% of 134)
 +
 +Still an upper bound: any year token in the sentence counts, including crawl dates.
 +
 +  2011/IMC/analysis-of-country-wide-internet-outages-caused-by-censorship
 +    IPv4 address space delegated to Egypt (as of January 24, 2011) and Libya (as of February 15, 2011) by AfriNIC (top half) as well as additional IPv4 address ranges associated with the two countries based on MaxMind GeoLite database (as of Ja
 +
 +  2013/WWW/the-anatomy-of-ldns-clusters-findings-and-implications-for-web-content-delivery
 +    Using the database dated from February 1, 2011 (so number of HTTP requests for all LDNS across all their TTL that our analysis would reflect the GeoIP map at the time intervals.
 +
 +  2014/IMC/censorship-in-the-wild-analyzing-internet-filtering-in-syria
 +    We geolocalize each IP address in DIP v4 using the Maxmind GeoIP database.9 We then introduce, for each identified country, the cor- 6.
 +
 +  2015/WWW/cookies-that-give-you-away-the-surveillance-implications-of-web-tracking
 +    All IPs returned in each hop of the traceroute were We use a simplified version of this check when examining geo-located with the MaxMind GeoLite27 country databases.
 +
 +  2014/USENIX/an-internet-wide-view-of-internet-wide-scanning
 +    GeoIP, 2013.
 +
 +  2015/IMC/leveraging-internet-background-radiation-for-opportunistic-network-analysis
 +    Since MaxMind updates the database regularly (to reflect changes in the address space), we use the databases produced on August 1, 2012 and August 16, 2013 for the 2012 census and 2013 census periods, respectively.
 +
 +  2015/PETS/defending-tor-from-network-adversaries-a-case-study-of-network-path-prediction
 +    However, 49% reach the AS of the destination, as determined by the Max-Mind GeoIP database [2].
 +
 +  2017/IMC/a-look-at-router-geolocation-in-public-and-commercial-databases
 +    First, our recommendations 467 IMC '17, November 1-3, 2017, London, United Kingdom 0.0 0.2 0.4 0.6 0.8 1.0 ARIN (4761) APNIC (468) AFRINIC (58) LACNIC (38) RIPENCC (1523) CDF AFRINIC APNIC −4 −3 −2 −1 0 10 10 10 10 10 (a) MaxMind-Paid (41.2
 +
 +</file>
 +
 +==== 14.9 Re-review of the fixes ====
 +
 +Both reviewers whose findings were acted on re-read the drafted fixes **before** they were saved, told again that their context might not be exhaustive, and wrote to ''fable_rereview.md'' and ''sonnet_rereview.md''.
 +
 +^ ID ^ From ^ Sev. ^ Finding ^ Action ^
 +| R1 | fable | MAJOR | The vendor-table date line said MaxMind dropped the "2" //between// the two checks; the rename predates 6 August (EULA of 12 February 2026), as §14.6 itself says | **Fixed**: the line now says the rename had already happened and the first check missed it. |
 +| R2 | fable | MINOR | ''%%''KeyError: 'cc'''%%'' in §14.2 closes the monospace one apostrophe early | **Fixed** with ''%%…%%''. |
 +| R3 | fable | MINOR | §14.6 said the two passes "fetched every vendor, licence and URL claim"; neither pass claimed that (Livadariu's 1.50% was not fetched, NetAcuity and the IP2Proxy LITE field set were left PLAUSIBLE) | **Fixed**: §14.6 now scopes itself to what the two findings files enumerate. |
 +| R4 | fable | MINOR (PLAUSIBLE) | The IP2Location data-licence quote had no fetched copy in the run directory | **Fixed**: ''ip2l_data_license.html'' and ''ip2l_db11.html'' saved (2026-09-22); the first contains "redistribute or resell", neither contains a Creative Commons link. Sonnet's re-check found DB5 also carries no badge. |
 +| R5 | fable | MINOR | The flags box said "this page's 2026-08-06 run of the script below", but the script below is now the fixed version, which cannot print those lines keylessly | **Fixed**: "the previous version of the script below". |
 +| R6 | fable | note | §14.2 says four unrecorded revisions where fable's P2 heading said five | Correct as written: the three 2026-09-03 revisions were already in §12/§13. |
 +| — | sonnet | — | 11 changed external claims re-fetched and CONFIRMED (MaxMind EULA date and prices, IP2Location badge only on DB1, IP2Proxy LITE = ''PUB'', ipapi.is limits, a live ''dnsel.torproject.org'' lookup of a current exit returning ''127.0.0.2'', DigitalOcean 4 of 4, CJEU para. 82); IPinfo's ''service'' field PLAUSIBLE | none |
 +
 +The fixes to R1–R5 were not re-reviewed a third time.
  
 [[design:ip_classification|← back to the content page]] · [[literature:corpus|corpus-level provenance]] [[design:ip_classification|← back to the content page]] · [[literature:corpus|corpus-level provenance]]
  
provenance/design/ip_classification.1786526634.txt.gz · Last modified: by karel.kubicek.claude