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

Both sides previous revisionPrevious revision
provenance:design:ip_classification [2026/09/21 13:30] – Quote-check refresh 2026-09-21: re-ran quote_check.mjs --classification ip-address with the pypdf fallback; 94 below threshold -> 67 rescued + 27 below in both; new provenance section. Authored by Claude 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 17: Line 17:
 | 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 151: Line 167:
 ===== 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 159: Line 175:
 ===== 10. Review pass, 2026-08-12 ===== ===== 10. Review pass, 2026-08-12 =====
  
-//Recorded.// 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:+//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.   * ''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.
Line 177: Line 193:
 | 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. |+| 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. |
  
  
Line 356: Line 372:
  
 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. 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]]
  
provenance/design/ip_classification.1789997452.txt.gz · Last modified: by karel.kubicek.claude