relaton 3.0.0.pre.alpha.17 → 3.0.0.pre.alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/data/equivalence/adoption-chains.yaml +38 -0
  3. data/lib/relaton/3gpp/CLAUDE.md +187 -0
  4. data/lib/relaton/3gpp/README.adoc +149 -0
  5. data/lib/relaton/adobe/CLAUDE.md +104 -0
  6. data/lib/relaton/bib/CLAUDE.md +130 -0
  7. data/lib/relaton/bib/README.adoc +330 -0
  8. data/lib/relaton/bib/model/series.rb +1 -1
  9. data/lib/relaton/bipm/CLAUDE.md +173 -0
  10. data/lib/relaton/bipm/README.adoc +352 -0
  11. data/lib/relaton/bipm/data_fetcher.rb +3 -2
  12. data/lib/relaton/bsi/CLAUDE.md +35 -0
  13. data/lib/relaton/bsi/README.adoc +112 -0
  14. data/lib/relaton/calconnect/CLAUDE.md +177 -0
  15. data/lib/relaton/calconnect/README.adoc +140 -0
  16. data/lib/relaton/ccsds/CLAUDE.md +74 -0
  17. data/lib/relaton/ccsds/README.adoc +154 -0
  18. data/lib/relaton/cen/CLAUDE.md +219 -0
  19. data/lib/relaton/cen/README.adoc +155 -0
  20. data/lib/relaton/cie/CLAUDE.md +90 -0
  21. data/lib/relaton/cie/README.adoc +121 -0
  22. data/lib/relaton/core/CLAUDE.md +140 -0
  23. data/lib/relaton/core/README.adoc +64 -0
  24. data/lib/relaton/core/data_fetcher.rb +50 -3
  25. data/lib/relaton/db/CLAUDE.md +373 -0
  26. data/lib/relaton/db.rb +19 -1
  27. data/lib/relaton/doi/CLAUDE.md +105 -0
  28. data/lib/relaton/doi/README.adoc +89 -0
  29. data/lib/relaton/easc/CLAUDE.md +85 -0
  30. data/lib/relaton/ecma/CLAUDE.md +217 -0
  31. data/lib/relaton/ecma/README.adoc +178 -0
  32. data/lib/relaton/equivalence.rb +89 -0
  33. data/lib/relaton/etsi/CLAUDE.md +219 -0
  34. data/lib/relaton/etsi/README.adoc +137 -0
  35. data/lib/relaton/evs/CLAUDE.md +120 -0
  36. data/lib/relaton/evs/README.adoc +27 -0
  37. data/lib/relaton/gb/CLAUDE.md +47 -0
  38. data/lib/relaton/gb/README.adoc +169 -0
  39. data/lib/relaton/gost/CLAUDE.md +100 -0
  40. data/lib/relaton/iala/CLAUDE.md +97 -0
  41. data/lib/relaton/iana/CLAUDE.md +105 -0
  42. data/lib/relaton/iana/README.adoc +107 -0
  43. data/lib/relaton/iec/CLAUDE.md +13 -0
  44. data/lib/relaton/iec/README.adoc +280 -0
  45. data/lib/relaton/iec/data_fetcher.rb +24 -8
  46. data/lib/relaton/iec/project_fetcher.rb +48 -0
  47. data/lib/relaton/iec/project_parser.rb +111 -0
  48. data/lib/relaton/iec.rb +5 -0
  49. data/lib/relaton/ieee/CLAUDE.md +176 -0
  50. data/lib/relaton/ieee/README.adoc +131 -0
  51. data/lib/relaton/ietf/CLAUDE.md +357 -0
  52. data/lib/relaton/ietf/README.adoc +167 -0
  53. data/lib/relaton/iho/CLAUDE.md +70 -0
  54. data/lib/relaton/iho/README.adoc +119 -0
  55. data/lib/relaton/index/CLAUDE.md +207 -0
  56. data/lib/relaton/index/README.adoc +221 -0
  57. data/lib/relaton/index/dataset_lock.rb +122 -0
  58. data/lib/relaton/index/family.rb +63 -0
  59. data/lib/relaton/index/type.rb +32 -0
  60. data/lib/relaton/index.rb +3 -0
  61. data/lib/relaton/isbn/CLAUDE.md +47 -0
  62. data/lib/relaton/isbn/README.adoc +91 -0
  63. data/lib/relaton/iso/CLAUDE.md +38 -0
  64. data/lib/relaton/iso/README.adoc +370 -0
  65. data/lib/relaton/iso/data_fetcher.rb +16 -1
  66. data/lib/relaton/itu/CLAUDE.md +685 -0
  67. data/lib/relaton/itu/README.adoc +188 -0
  68. data/lib/relaton/jcgm/CLAUDE.md +107 -0
  69. data/lib/relaton/jis/CLAUDE.md +64 -0
  70. data/lib/relaton/jis/README.adoc +167 -0
  71. data/lib/relaton/logger/CLAUDE.md +37 -0
  72. data/lib/relaton/logger/README.adoc +120 -0
  73. data/lib/relaton/nist/CLAUDE.md +180 -0
  74. data/lib/relaton/nist/README.adoc +389 -0
  75. data/lib/relaton/nist/bibliography.rb +17 -1
  76. data/lib/relaton/nist/data_fetcher.rb +60 -4
  77. data/lib/relaton/nist/hit_collection.rb +121 -59
  78. data/lib/relaton/nist/scraper.rb +21 -7
  79. data/lib/relaton/oasis/CLAUDE.md +251 -0
  80. data/lib/relaton/oasis/README.adoc +132 -0
  81. data/lib/relaton/ogc/CLAUDE.md +242 -0
  82. data/lib/relaton/ogc/README.adoc +134 -0
  83. data/lib/relaton/oiml/CLAUDE.md +47 -0
  84. data/lib/relaton/oiml/README.adoc +70 -0
  85. data/lib/relaton/omg/CLAUDE.md +137 -0
  86. data/lib/relaton/omg/README.adoc +101 -0
  87. data/lib/relaton/plateau/CLAUDE.md +126 -0
  88. data/lib/relaton/plateau/README.adoc +139 -0
  89. data/lib/relaton/sdo/CLAUDE.md +81 -0
  90. data/lib/relaton/un/CLAUDE.md +91 -0
  91. data/lib/relaton/un/README.adoc +125 -0
  92. data/lib/relaton/version.rb +1 -1
  93. data/lib/relaton/w3c/CLAUDE.md +369 -0
  94. data/lib/relaton/w3c/README.adoc +140 -0
  95. data/lib/relaton/w3c/data_fetcher.rb +1 -1
  96. data/lib/relaton/xsf/CLAUDE.md +189 -0
  97. data/lib/relaton/xsf/README.adoc +127 -0
  98. data/lib/relaton.rb +1 -0
  99. metadata +83 -2
@@ -0,0 +1,685 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ The ITU (International Telecommunication Union) flavor of the combined `relaton`
8
+ gem — retrieval and modelling of ITU-T and ITU-R bibliographic metadata. All of it
9
+ lives under `Relaton::Itu` in `lib/relaton/itu/`; the old flat `RelatonItu`
10
+ namespace is gone.
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ cd spec/itu && bundle exec rspec -I . . # the flavor's suite (run from its own dir)
16
+ bundle exec rake spec:itu # the same, from the repo root
17
+ cd spec/itu && bundle exec rspec -I . relaton/itu/item_spec.rb # one file
18
+ ```
19
+
20
+ ## Architecture
21
+
22
+ ### Model Layer (Lutaml::Model)
23
+
24
+ All model classes use `Lutaml::Model::Serializable` for XML/YAML serialization:
25
+
26
+ - **`Item`** → extends `Bib::Item` (main bibliographic item)
27
+ - **`ItemData`** → extends `Bib::ItemData` (used by DataParserR for parsed documents)
28
+ - **`Bibitem`** / **`Bibdata`** → extend `Item`, mix in shared behavior from `Bib`
29
+ - **`Ext`** → extends `Bib::Ext` with ITU-specific fields (doctype, structuredidentifier, question, recommendationstatus, ip_notice_received, meeting, meeting_place, meeting_date, intended_type, source)
30
+ - **`Docidentifier`** (`model/docidentifier.rb`) → extends `Bib::Docidentifier`. `#pubid` parses `content` with `Pubid::Itu` on first use (lazy, memoized, soft: nil if it does not parse, no log — the `ISO` co-identifier never parses). `#remove_date!` is `pubid.exclude(:year, :month)` rendered back, so it also clears the dates of the `base` chain and keeps the version (`ITU-T H.264 (V14)`). The result is the pubid canonical spelling (`v10` → `(V10)`). Content that does not parse is left unchanged; there is no regex fallback. `remove_part!`/`to_all_parts!` stay no-ops.
31
+ - **`Doctype`**, **`StructuredIdentifier`**, **`EditorialGroup`**, **`Bureau`**, **`Group`**, **`ApprovalStage`**, **`RecommendationStatus`**, **`Question`**, **`Meeting`**, **`MeetingDate`** — ITU-specific metadata types
32
+
33
+ ### Runtime lookup (`HitCollection#search`)
34
+
35
+ ITU removed the `net4/.../GlobalSearch/RunSearch` endpoint (F5 WAF, HTTP 500) that
36
+ used to *discover* every reference. The reference reaches `HitCollection` as a
37
+ `Pubid::Itu` identifier, and its **class and sector** pick one of three routes
38
+ (`#search`; an annex or appendix reads its sector from `root`):
39
+
40
+ | reference | route | source |
41
+ |---|---|---|
42
+ | `RadioRegulations` (`ITU-R RR (2020)`), `SpecialPublication` (`ITU-T OB.1096 (2016)`) | `#request_publication` | live `www.itu.int/pub/{R-REG-RR-YYYY \| T-SP-OB.N-YYYY}` |
43
+ | sector `T` | `#request_recommendation` | combined `index-v2`, live fallback |
44
+ | sector `R` (recommendation, report, handbook, question) | `#request_document` | combined `index-v2` |
45
+
46
+ The caller's reference is parsed with `::Pubid::Itu.parse` in
47
+ `Bibliography.get`/`.search`, and a `Pubid::Errors::ParseError` **propagates**
48
+ (repo rule "An unrecognized query reference raises"). This replaced a local
49
+ Parslet grammar, `Relaton::Itu::Pubid`, which ended in `any.repeat` and so
50
+ silently dropped what it could not read (`ITU-T H.222.0 v10 (04/2025)` became
51
+ `ITU-T H.222.0`). pubid #460 added the forms the local grammar had and pubid
52
+ lacked: `RadioRegulations`, the OB sector and its ` - 15.III.2016` date, ITU's
53
+ publication ids (`T-REC-T.4-200307-I`, `ITU-T REC T.4`) and `-YYYYMM` read as
54
+ a date rather than a part. Forms neither parses well now **raise**: `ITU G.191`
55
+ (no sector), `ITU-T G.Suppl.47` (the "Incorrect reference" hint is still
56
+ logged first), `ITU-T OB. (2016)`.
57
+
58
+ - **Publications** (Radio Regulations, Operational Bulletins) are **not** in the
59
+ dataset, but their landing-page id is derivable from the reference, so no search
60
+ is needed. No `ref.year` → no hit; a `notfound` redirect or 404 → no hit. The page
61
+ is parsed by `RadioRegulationsParser` as before (its `#doc_url` `dest=` unwrap is
62
+ a no-op for these direct URLs, kept for the legacy shape).
63
+ - **Recommendations** prefer the index (offline, every edition, no scraping) and
64
+ fall back to the live `rec.aspx?rec={code}` → `11.1002/1000/{idrec}` →
65
+ `getRecEditions` pair, which is the discovery half of relaton-itu#89. The
66
+ fallback is a **narrowing** safety net. It used to be load-bearing for 706
67
+ ITU-T records whose ids `Pubid::Itu` could not parse; pubid #325 parses every
68
+ one of them, so after the next crawl **no ITU-T record is unindexed for that
69
+ reason** (see **Index/data gap** below). What still needs it: Implementers'
70
+ Guides (`ITU-T G.Imp712`) aren't harvested at all, and newly approved editions
71
+ appear on `rec.aspx` before the next crawl. It triggers when the index has **no** row *or*
72
+ when the reference names a year no indexed edition has (a heuristic — a hit that
73
+ `Bibliography#search_filter` later discards still counts as satisfying); if the
74
+ live path then finds nothing, the indexed editions are kept so the "no match for
75
+ `<year>` year, though there were matches found for …" hint survives.
76
+ **Known gaps:** a `(V##)` reference resolves live (`getRecEditions` returns the
77
+ versioned `rec_name`) and will resolve from the index after the next crawl; an
78
+ `Annex` reference resolves through **neither** route until then — the published
79
+ index has no annex rows and `getRecEditions` returns no `Annex` entries. Undated
80
+ `ITU-R RR` / `OB.` references are also unresolvable: the `/pub` id needs a year
81
+ and there is no enumeration source left to find the latest.
82
+ - **Selecting the hits (`Bibliography.search_filter`)** keeps a hit when
83
+ `refid.matches?(hit_pubid, ignore: %i[year month day] (+ :version when neither
84
+ the reference nor the recommendation it amends names one))` **and**, for an
85
+ amending supplement, the supplement's **own** stated date equals the hit's
86
+ (`ignore:` drops the date at every level, so without that check
87
+ `ITU-T G.989.2 Amd. 1 (04/2016)` also kept the `(2019) Amd. 1 (10/2020)` row
88
+ and returned it). **Not `===`**: it reads a missing `subseries` as "any
89
+ value", so `ITU-T H.264` would keep `ITU-T H.264.1`. Measured against the old
90
+ local match over the 5,361-row index fixture (every row id as a hit, queried
91
+ by every row id and its undated and version-less forms, per `root.number`
92
+ bucket): the **only** changes are 194 `Cor.`/`Err.` pairs — a base reference
93
+ no longer keeps its own corrigenda and errata, and `Cor. 1` to one edition no
94
+ longer matches `Cor. 1` to another (the old grammar did not model `Cor.`/`Err.`
95
+ and stopped at the base's date — the ETSI-style defect). A hit code that does
96
+ not parse is data and is dropped. ITU-R hits carry no `:code`, so the filter
97
+ never sees them.
98
+ - **The year a reference selects by (`Bibliography.edition_year`)** mirrors the
99
+ hit side, which reads the **first** date of the hit code: for an
100
+ `Amendment`/`Corrigendum`/`Errata`/`Addendum` it is the **base's** year
101
+ (`ITU-T G.989.2 (2014) Amd. 1 (04/2016)` → 2014; `ITU-T G.989.2 Amd. 1
102
+ (04/2016)` → none); for an `AnnexOfRecommendation`/`AppendixOfRecommendation`
103
+ the base's year when it has one, else its own (`ITU-T Z.100 (06/2021) Annex
104
+ F1` and `ITU-T Z.100 Annex F2 (06/2021)` → 2021); else the identifier's own.
105
+ `get(code, year)` adds `year` to a reference that has none, on the base for an
106
+ amending supplement. `Bibliography.document` names the recommendation an
107
+ amending supplement, annex or appendix belongs to — `Amendment` is a
108
+ `Supplement` subclass, so the lists are explicit: a supplement to a
109
+ recommendation (`ITU-T G.989 Suppl. 1`) is a document of its own, and
110
+ `rec.aspx` is asked for it, not for `G.989`.
111
+ - **Other routes that must not reach a search:** an annex to a bulletin
112
+ (`Annex to ITU-T OB.1096 (2016)`, root a `SpecialPublication`) returns no
113
+ hit. A live edition's `rec_name` gets the crawler's
114
+ `DataParserT.normalize_rec_name` (`G 231` → `G.231`) before it becomes a hit
115
+ code, and `Scraper#createdocid` keeps a code pubid cannot parse as ITU wrote
116
+ it — both are data, so a parse failure there must not fail the record.
117
+ - **Index hits are lazy.** `#index_hits` builds one `Hit` per matching row carrying
118
+ `code:` (`row[:id].to_s` — the same dated docidentifier the data record holds),
119
+ `url:` and `file:`; `Hit#item` branches on `:file` and calls
120
+ `HitCollection#fetch_item` only for the edition `Bibliography` actually selects.
121
+ Because the hit's `code` is the dated docid, `Bibliography#search_filter` /
122
+ `#isobib_results_filter` year selection needed no change. Hits are sorted
123
+ newest-first (`#edition_key`, the **first** date in the code, so an amendment
124
+ sorts by the year of the recommendation it amends) because
125
+ `isobib_results_filter` takes the first hit for a year-less reference.
126
+ - Live recommendation hits deliberately carry **no `:ref` key** — that drives
127
+ `Hit#gi_imp`, which would route an Implementers' Guide onto the `getImplGuides`
128
+ endpoints; the live `G.Imp712` record is built from `getRecHdrDetail` like any
129
+ other. (`gi_imp` has been inert since the RunSearch hits never set `:ref`.)
130
+ - **Metadata parity:** an indexed Recommendation is only as rich as the published
131
+ data record. `relaton-data-itu` was re-crawled with the enriched `DataParserT`
132
+ (below) on 2026-08-12, so an indexed record now carries the same abstract, ISO
133
+ co-identifier, contributors, status, edition and relations a live-fallback
134
+ record does — `itu_t_y_3500` and `itu_t_a_13` assert exactly that. A crawl
135
+ predating the enrichment would silently reintroduce the asymmetry, so the data
136
+ repo's own suite guards against republishing thin records.
137
+
138
+ ### Data Fetching
139
+
140
+ - **`DataFetcher`** extends `Core::DataFetcher`. `#fetch(source)` routes on the
141
+ dataset name (from `Processor#@datasets = %w[itu-r itu-t]`): `"itu-t"` →
142
+ `#fetch_recommendations`, anything else (`"itu-r"`, `nil`) →
143
+ `#fetch_publications`, then `index.save` + `report_errors` for either.
144
+ - **ITU-R harvester** (`#fetch_publications`) — restored on the crawl that
145
+ replaced the decommissioned RunSearch enumeration (issue #75). It walks
146
+ `DataCrawlerR::FAMILIES` series by series, so a series ITU throttles costs
147
+ that series rather than the run. **The series loop stays serial on purpose**:
148
+ `#harvest_family` rescues per series *and* per family, `DataMergeR.write_all`
149
+ runs per series, and the data repo's `guard_itur_harvest` is calibrated on
150
+ exactly that granularity. Parallelism lives *inside* a series, in
151
+ `DataCrawlerR#in_parallel`, over the two request-heavy levels.
152
+ `RELATON_ITU_MODE` picks the mode, so two scheduled jobs differ
153
+ by environment rather than by code:
154
+
155
+ | mode | cost | what it is for |
156
+ |---|---|---|
157
+ | `:full` (default) | ~7k requests, ~2 h | the weekly rebuild: wipe `data/itu-r-*` first and let ITU be the sole author of the result |
158
+ | `:top_up` | enumeration + one request per **new** edition | the daily job: same document walk, but `#held?` answers from the level-2 row whether the dataset already has an edition, so the expensive per-edition page is never fetched for one we hold |
159
+
160
+ **A top-up assumes a previous `:full` run built the dataset.** Against the
161
+ pre-#110 dataset it would add every Report a second time under its `Report …`
162
+ name, so the data repo runs the rebuild first and enables the daily job after.
163
+ That ordering lives in the schedule, next to the wipe it depends on, rather
164
+ than in the gem: it can only go wrong in the single window before the first
165
+ rebuild, and it announces itself as a commit adding ~650 files. The rebuild
166
+ *is* the migration, which is why the harvester carries no migration step.
167
+
168
+ **Pacing, and why the numbers changed.** `RELATON_ITU_DELAY` (default 1 s) is
169
+ the **minimum gap between request _starts_**, enforced by one
170
+ `Relaton::Core::Pacer` shared by the pool — not a `sleep` before each request.
171
+ The distinction is the whole speed-up: a sleep *adds* to ITU's own ~1.1 s
172
+ latency, so a 1 s delay yielded one request every ~2.1 s and a ~7k-request
173
+ crawl spent ~2 h of a ~4 h run idle. Under the pacer the latency counts
174
+ *toward* the gap, so www.itu.int sees at most 1/delay req/s however many
175
+ workers there are. Stated plainly: peak rate rises from the old ~0.48 req/s to
176
+ at most 1.0 req/s — the rate this file already records as safe, and 0.4 s as
177
+ not. `RELATON_ITU_R_CONCURRENCY` (default 4) sizes the pool; it is a separate
178
+ knob from ITU-T's `RELATON_ITU_CONCURRENCY` because the bottlenecks differ —
179
+ ITU-R throughput is `min(1/delay, concurrency/latency)`, so **the pacer, not
180
+ the pool, sets the rate** and two workers already saturate it.
181
+
182
+ **Rollback:** `RELATON_ITU_R_CONCURRENCY=1 RELATON_ITU_PACE=fixed` reproduces
183
+ the pre-pool request pattern exactly — one request per (delay + latency), one
184
+ thread.
185
+
186
+ A **scale model** of the crawl (300 requests, gap and latency both scaled by
187
+ 1/100 to 0.01 s / 0.011 s, so it is the measured 1.0 s-against-~1.1 s shape)
188
+ gives 39.9 req/s for the old sleep-then-request loop against **99.8 req/s**
189
+ for the shared pacer at two or more workers — a **2.5x** speed-up, with the
190
+ rate pinned exactly at the 1/gap ceiling and never above it. Four workers
191
+ measure the same as two, which is the point: **the pacer, not the pool, sets
192
+ the rate.** This is a model, not ITU: confirm it with a timed dry run of one
193
+ series per family before trusting the projection.
194
+
195
+ **Throttling is a pool event.** `Relaton::Itu::Governor` (a
196
+ `Relaton::Core::Governor` binding) gives the workers one shared cooldown: any
197
+ thread seeing a throttle opens it, every other thread observes it, and it
198
+ escalates per round (60 s → 900 s) on the minute scale a WAF ban actually
199
+ needs, instead of four workers each running their own 5/10/15 s ladder and
200
+ keeping the limiter engaged. After five barren rounds the crawl is declared
201
+ rate-limited and gives up, so a banned run fails in ~15 min rather than
202
+ burning the job's remaining hours. Which failures count: **503** (what `/rec`
203
+ answers) and the gateway codes, plus the `/pub` soft block, which
204
+ `DataCrawlerR::SoftBlock` marks because Mechanize follows the 302 to a
205
+ perfectly good 200 that is otherwise indistinguishable from a 404. **404 and
206
+ a per-resource 500 deliberately do not** — a broken record must cost that
207
+ record, not a pool-wide cooldown.
208
+
209
+ **Measured** on the BO slice of R-REC + R-REP, back to back (2026-08-19): a
210
+ full rebuild is **221 requests / 548 s** for 127 records; the top-up
211
+ immediately after is **94 requests / 268 s** and adds nothing — it saves
212
+ exactly the 127 per-edition pages, 57% of the requests. Corpus-wide that
213
+ scales to roughly **7k requests full** against **~2.5k top-up**. Those request
214
+ counts are unchanged by the pacer; the wall clock is not — at the 1 s gap a full
215
+ ITU-R crawl is ~2 h rather than ~4 h, which is what took the combined
216
+ relaton-data-itu job back under GitHub Actions' 6 h cap (run 32420518511 was
217
+ cancelled at 6h00m19s). A top-up is cheaper, but not cheap — the enumeration still costs one request per
218
+ document, because a new edition cannot be noticed without reading the document
219
+ page. (`/rec/new.asp?lang=en`, linked from every series page, may be a real
220
+ delta feed; unexplored.) Earlier corpus figures still hold: ~2.1 s per request
221
+ at the 1 s delay, and 0 dates rewritten / 0 records lost across 3,600 merged
222
+ records.
223
+
224
+ Pair the modes with the data repo's existing collapse guard
225
+ (`guard_itut_harvest`, which aborts a publish whose file count halves) — a
226
+ wipe makes that guard the only thing between a throttled crawl and a
227
+ published hole, and ITU-R wants its own, per family.
228
+ - **`#index_files(glob)`** indexes records already on disk through the same pubid
229
+ guard and unparseable-id reporting as the harvests. It is what
230
+ `relaton-data-itu`'s crawler used while ITU-R had no harvester; a run that now
231
+ calls `#fetch "itu-r"` gets that indexing as a side effect of the merge.
232
+ - **ITU-R crawler (`DataCrawlerR`, issue #75)** — ITU-R
233
+ metadata is still fully server-rendered, so enumeration *is* possible without
234
+ RunSearch; `data_crawler_r.rb` proves it. Three levels, per family (`FAMILIES`):
235
+
236
+ | family | index | grouping level | page URL | date |
237
+ |---|---|---|---|---|
238
+ | `R-REC` Recommendations | `/pub/R-REC/en` | 16 series letters | `/rec/<id>/en` | `Approved in …` — **approval** |
239
+ | `R-REP` Reports | `/pub/R-REP/en` | 14 series letters | `/pub/<id>/en` | files' "Posted" — **publication** |
240
+ | `R-QUE` Questions | `/pub/R-QUE/en` | 6 study groups (`SG01`…) | `/pub/<id>/en` | files' "Posted" |
241
+ | `R-RES` Resolutions | `/pub/R-RES/en` | **none — flat** | `/pub/<id>/en` | files' "Posted" |
242
+ | `R-HDB` Handbooks | `/pub/R-HDB/en` | **none — flat** | `/pub/<id>/en` | **the id** — no page carries one |
243
+
244
+ e.g. `/rec/R-REC-BO/en` (54 documents) → `/rec/R-REC-BO.1130/en` (Main +
245
+ Previous versions, one row per edition) → `/rec/R-REC-BO.1130-5-202602-I/en`.
246
+ Every id names its own family (`#family_of`), so the level-2/3 methods take an
247
+ id alone. That date column is the whole reason `DataMergeR` exists (below):
248
+ a harvested **report** reproduces the published `date:` exactly (verified for
249
+ `ITU-R BO.1227-2` → `1998-01`), a harvested **recommendation** cannot.
250
+ Load-bearing details:
251
+ - URLs are **rebuilt** from each link's `parent=` id (`/rec/<id>/en` for a
252
+ recommendation, `/pub/<id>/en` for a report); the pages' own
253
+ `./recommendation.asp?…` / `publications.aspx?…` hrefs resolve against the
254
+ series directory and do **not** reach the canonical page.
255
+ - The PDF link is matched **anchored on the edition id**
256
+ (`//a[contains(@href,'<id>') and contains(@href,'PDF-E')]`): every `/pub`
257
+ page carries a QUICK LINKS sidebar whose Publication Catalogue entry is
258
+ itself a `…-PDF-E.pdf`, so an unanchored match would give a report edition
259
+ with no English PDF the catalogue's URL — which `DataMergeR` would then
260
+ backfill into the dataset permanently.
261
+ - **Each family spells its identifier differently**, and the published dataset
262
+ is the authority (`DataParserR#family_docid`). `R-REC` uses the displayed
263
+ code; `R-REP` prefixes it (`Report ITU-R BT.2020-1`, see the collisions note);
264
+ `R-QUE` appends a colon (`202-2/1` → `ITU-R 202-2/1:`); and `R-RES` and
265
+ `R-HDB` cannot use their code at all — a Resolution page renders
266
+ `Res.1-9 (2023)` while the record is `ITU-R R.1-9`, and a Handbook shows its
267
+ *title* on the index and a bare year on the leaf, so both read the **page id**
268
+ (`R-RES-R.1-9-2023`, `R-HDB-43-2013`). The first four reproduce the published
269
+ filenames exactly, verified live; `R-HDB` deliberately does not (below).
270
+ - **`R-HDB` Handbooks are keyed per edition — `ITU-R 43.HDB (2013)`.** ITU
271
+ publishes several editions per handbook (43 has 2002, 2013 and 2026) while the
272
+ published dataset carries **one record per handbook number** (`ITU-R 01.HDB`,
273
+ no edition in the id), so harvesting every edition under the old convention
274
+ would collapse them onto one filename. `Pubid::Itu` models the year, so the
275
+ year is what separates them; the four published records that carry a bare
276
+ `ITU-R 43` are worse than colliding — that parses as a **recommendation**,
277
+ claiming a number that belongs to another document. A full rebuild replaces
278
+ all 60 published handbook files with 63 edition-keyed ones.
279
+ The family publishes less per page than any other, so two things move
280
+ (`FAMILIES["R-HDB"]` carries `date: :id` and `title: :document`):
281
+ - **The date comes from the id.** No handbook page carries a date at all, and
282
+ the displayed codes disagree with each other and with themselves — one
283
+ handbook's three editions render "2026", "2014" (for id `R-HDB-43-2013`) and
284
+ "Edition of 2002". `#row` therefore does not warn about the missing page
285
+ date for this family; for every other family that warning means a degraded
286
+ fetch.
287
+ - **The title comes from the level-1 row**, the only place ITU prints it: the
288
+ index anchor holds the *title* where every other family's holds a number
289
+ (the number is in the id alone), and the edition rows hold only a year. So
290
+ `#harvest` copies the document row's title onto each of its editions —
291
+ nothing downstream sees the index again.
292
+ - **Measured live (2026-08-20):** 45 handbooks → **63 editions**, 46 requests
293
+ in 133 s shallow (`deep: true` adds one request per edition, ~109 / ~4 min).
294
+ All 63 docids are distinct, all pass the index gate as
295
+ `pubid:itu:handbook`, and none is missing a title, date or PDF.
296
+ - The docid comes from the **displayed code** minus ` (MM/YYYY)` — `BO.1212
297
+ (10/95)` → `ITU-R BO.1212`, *not* the page id's `ITU-R BO.1212-0`. That
298
+ reproduces the published filenames and index rows exactly (the spec asserts
299
+ `data/itu-r-bo-1130-5.yaml` / `data/itu-r-bo-1212.yaml`).
300
+ - The date comes from the id (a recommendation id ends in `YYYYMM`, a report id
301
+ in its year; displayed codes use 2-digit years pre-2000, so the id is the only
302
+ unambiguous source), upgraded by the level-3 page. **For recommendations that
303
+ is the approval date, and the preserved records carry RunSearch's publication
304
+ date.** Measured across the whole BO series: the date differs for **82/82**
305
+ records and the *year* differs for **48** of them (`ITU-R BO.600-1`: published
306
+ `2002-01` vs approved `1986-07`). Since references are matched by year, that
307
+ is a regression, not a precision upgrade — hence `DataMergeR`.
308
+ - `deep: false` costs `1 + documents` requests (~1.3k for all 16 REC series)
309
+ with coarser dates and a pattern-derived PDF URL; `deep: true` adds one
310
+ request per edition. **Measured live on BO (2026-08-13):** 54 documents,
311
+ 82 editions, 137 requests in 121 s at a 0.4 s delay, no throttling — so the
312
+ whole ~5.3k-record corpus is roughly 7k requests / ~1 h, inside the 6 h
313
+ Actions cap. Coverage on that run: 82 harvested vs 83 published BO
314
+ recommendations (the one gap is `ITU-R BO.4/BL/4`, a BL-form id absent from
315
+ the series page); the other 23 published `itu-r-bo-*` files are reports.
316
+ - **In deep mode the edition page is the authority.** An edition with no
317
+ `!!PDF-E.pdf` (older ones are Word-only, e.g. `R-REC-BO.1130-0-199408-S`) is
318
+ left **sourceless** rather than given a derived URL that 404s, and a page
319
+ that returns no approval date — ITU served an empty 200 for
320
+ `R-REC-BO.1130-4-200104-S` while the cassette was being recorded — is
321
+ **warned about** before falling back to the id's date, so a degraded deep
322
+ crawl can't pass for a clean one — except for a family whose date lives in
323
+ the id by design (`R-HDB`), where finding none on the page is the norm and
324
+ warning would cry wolf on every record. Every level also warns when it finds
325
+ nothing (a layout change would otherwise read as an empty corpus).
326
+ - **Throttling has two shapes, and one is silent.** Measured over a full
327
+ corpus run: `/rec` answers **HTTP 503**, `/pub` answers **302 →
328
+ `/en/publications/pages/notfound.aspx`** — indistinguishable from "no such
329
+ document" unless you look, and it once emptied all 14 report series while
330
+ every level dutifully reported success. `#get` therefore retries both
331
+ (`RETRIES` = 3, linear `RETRY_BACKOFF`) and raises when they persist, so a
332
+ throttled series fails loudly; `#warn_if_empty` is the backstop that caught
333
+ it in the first place. A ~1 s delay ran 13 of 14 report series clean where
334
+ 0.4 s did not.
335
+ - **Two row/link shapes that look like data and are not.** ITU lists some
336
+ editions **twice**: a `…-I` row coded `M.2083-0` and a `…-P` row whose
337
+ displayed Number is the associated *Question* (`M.5/BL/22`) — so `#editions`
338
+ keeps only rows whose code starts with the document's own number, or the
339
+ crawl mints Recommendations out of question numbers. And `/pub` pages carry
340
+ an add-to-cart control whose href is `javascript:addcart(…,'R-REP-BT.2526-1-2024-PDF-E',…)`,
341
+ which contains both the edition id and `PDF-E`; the PDF match therefore
342
+ requires a `.pdf`-terminated href (feeding the javascript one to `URI#merge`
343
+ raises `URI::InvalidURIError` and killed a whole series).
344
+ - **Recent reports have no anonymous PDF at all** — `R-REP-BT.2526-1-2024`
345
+ offers only the cart flow — so `source` is legitimately empty for them.
346
+ - **Missing before promotion:** no per-row rescue in `#harvest` (unlike
347
+ `DataFetcher#spawn_rec_worker`), results accumulated in memory rather than
348
+ streamed.
349
+ - `status` is scraped (`In force (Main)` / `Superseded`) but **not** modelled —
350
+ no published ITU-R record has one; it is the first candidate enrichment.
351
+ - Same F5-WAF hardening as `#rec_agent` (browser UA, `max_history = 1`,
352
+ timeouts) plus a `delay:` politeness pause. Driven by
353
+ `DataFetcher#fetch_publications`; `itu.rb` still does not require it, so a
354
+ consumer-only load never pulls the crawler in.
355
+ - **Incremental write path (`DataMergeR`, `data_merge_r.rb`)** — the crawl is a
356
+ *partial, lossier* view of the corpus, so a harvest must never be written as a
357
+ rebuild. `DataMergeR.write_all(items, fetcher)` merges each record into the
358
+ dataset and returns `{added:, backfilled:, unchanged:, skipped:, collisions:}`:
359
+ - **`date` is never rewritten** (the 48/82 year-change measured above), and
360
+ neither is anything else the published record already has. Only `title` and
361
+ `source` are backfilled, and only when absent.
362
+ - An unchanged record is **not rewritten at all** (byte-identical file, so the
363
+ data repo's diff shows only real changes) but is still `index_primary`'d,
364
+ because the index is rebuilt from scratch each run.
365
+ - **Filename-collision guard.** ITU-R report and recommendation docids share a
366
+ namespace (`ITU-R BO.1227-1` is a report; recommendations run through the
367
+ same 1200s), so both map to `data/itu-r-bo-1227-1.yaml`. A harvested record
368
+ whose published counterpart has a **different doctype** — or two harvested
369
+ records claiming one filename — is logged via `Util.error` and **skipped**,
370
+ never silently overwritten (which is what `#write_file`'s last-write-wins
371
+ would do).
372
+ - Verified end-to-end against real records (10 published BO files, both
373
+ families, live harvest): 4 files rewritten, each changing **only** `source`;
374
+ 6 byte-identical; every published `date` preserved; no deletions.
375
+ - **Corpus-scale dry run** (2026-08-13, against a writable copy of
376
+ `relaton-data-itu`'s 5,334 ITU-R records; the checkout itself is read-only):
377
+ recommendations 12/16 series in 49 min, reports 13/14 series in 126 min at a
378
+ 1 s delay. **213 records added** (34 recommendations — all pre-2024 gaps in
379
+ the RunSearch-era crawl — and 179 reports, 8 of them published *after* the
380
+ dataset froze, e.g. `ITU-R SM.2571-0`, 2026-08); **135 records backfilled, a
381
+ `source` every one**; **0 dates changed and 0 records deleted**, across 3,600
382
+ merged records. That is the invariant holding at corpus scale.
383
+ - **52 collisions — being resolved by giving Reports their own identity.**
384
+ ITU-R Recommendations and Reports number independently but used to share this
385
+ dataset's docid/filename namespace: `ITU-R BT.2020-1` is *both* Rec. BT.2020-1
386
+ (06/2014, UHDTV parameter values) and Report BT.2020-1 (2000, objective
387
+ quality assessment), so the published dataset holds whichever family the old
388
+ crawler wrote last — 34 files hold the report and are missing the
389
+ recommendation (incl. `BT.2020`, `M.2083` IMT Vision, `M.2134`), 18 the
390
+ reverse. Since pubid #327 there is a `pubid:itu:report` type, so
391
+ `DataParserR` now emits **`Report ITU-R BT.2020-1`** for the `R-REP` family
392
+ (`#id_prefix`): the docid says which document it is, `output_file` derives
393
+ `data/report-itu-r-*.yaml` from it, and the two can coexist. `DataMergeR`'s
394
+ guard stays as the backstop for anything that still collides.
395
+
396
+ **Not finished until the dataset migrates.** The published records still
397
+ carry the old bare docids (984 of the 1,001 reports are indexed as
398
+ `pubid:itu:recommendation`), so a lookup for `Report ITU-R BT.2020-1`
399
+ resolves nothing until `relaton-data-itu` re-crawls onto the new names. Note
400
+ the new filenames fall outside `data/itu-r-*.yaml`, the glob that repo's
401
+ crawler passes to `#index_files` — it needs widening at the same time.
402
+ - **ITU-T harvester** (`#fetch_recommendations` + `DataParserT`) — added for issue
403
+ relaton-itu#80. `#search_recs` issues **one** GET to
404
+ `mws/api/recommendations/searchRecs?…&main_edition_flag=0&rows=100000&…`, which
405
+ returns the whole ITU-T corpus as one row per edition (recommendations **and**
406
+ supplements) — `{ "Total", "Data": [ { idrec, rec_name, title, approval_date,
407
+ dms_link, status } ] }`. `DataParserT.parse` maps each row to an `ItemData`
408
+ (`flavor: "itu"`): the primary docid is the **dated** `"ITU-T #{rec_name}"` (e.g.
409
+ `"ITU-T A.1 (10/2000)"`, matching the `ITU-T L.163 (11/2018)` convention), so
410
+ `Pubid::Itu` identifies each **edition** distinctly and index rows/filenames stay
411
+ unique across editions (ITU-T editions differ by approval date, not a `-N` part).
412
+ doctype is derived from `rec_name` markers (`Suppl`/`Amd`/`Cor`/`Annex` →
413
+ `recommendation-{supplement,amendment,corrigendum,annex}`, else `recommendation`).
414
+ A browser `User-Agent` (`USER_AGENT`) is sent because `www.itu.int` sits behind
415
+ the same F5 WAF. Forms `Pubid::Itu` can't parse (e.g. `Annex` variants) are still
416
+ written as data files but left unindexed and surfaced via `#report_errors`.
417
+ - **ITU-T enrichment** (`DataParserT.parse(row, agent, errors)`) — the searchRecs
418
+ row is metadata-thin (docid/title/date/source/doctype), so each record is
419
+ enriched to match the live runtime output: `#fetch_recommendations` builds a
420
+ browser-UA Mechanize `agent` (F5 WAF) and passes it per row; `DataParserT`
421
+ fetches `getRecHdrDetail?idrec=…` (via `RecommendationParser`) and adds the
422
+ **abstract** (`summary`), **ISO/IEC co-identifier** (`iso_number`), **status**,
423
+ **editorial-group + publisher contributors** (the editorial group from the
424
+ `rec.aspx` workgroup page), the **edition** (`getRecEditions` → the row's own
425
+ `Version`) and the **relations** (`hasEdition` per sibling edition,
426
+ `complementOf` per `getRecSupplements` entry). The **source** becomes the
427
+ live path's pair — `src` = `handle_id`, `pdf` = `handle_id_pdf_link`, both from
428
+ the same `getRecHdrDetail` response, so no extra call; a header without
429
+ `handle_id` (or a failed enrichment) keeps the row's `dms_link`. The **date** is upgraded to
430
+ day-precision from the row's `approval_date` (no extra call); **copyright** and
431
+ the Geneva **place** are derived from the row alone (`#fetch_copyright`), so
432
+ even an un-enriched record carries them. Enrichment is **best-effort** — a
433
+ detail-fetch failure is logged and degrades to the thin record rather than
434
+ losing it. It costs up to ~4 calls per record (`getRecHdrDetail`, `getRecEditions`,
435
+ `getRecSupplements`, `rec.aspx`) — the bulk of the crawl — so progress is logged
436
+ every 500 records. This is what makes an indexed runtime lookup as rich as the
437
+ live one; the published dataset predates it (see **Runtime lookup** above).
438
+ - **ITU-T request de-duplication** (`FamilyCache`, `family_cache.rb`) —
439
+ `searchRecs` returns one row per **edition**, but two of those four endpoints
440
+ answer for the whole **recommendation**, so the crawl was asking ITU the same
441
+ question once per edition: H.264 has 21 editions and issued 21 identical
442
+ `getRecEditions` requests. Corpus-wide the fan-out is ~2.1 editions per
443
+ recommendation (16,172 `data/itu-t-*` files over ~7,663 base recommendations).
444
+ Counted against that corpus: **64,688 -> 47,670 requests, ~26% fewer** — and it
445
+ removes them, it does not reorder or accelerate them: the crawl asks ITU for
446
+ *less*. Sharing `getRecSupplements` too (see the table) would take it to
447
+ 39,161, ~40% fewer.
448
+
449
+ | endpoint | shared? | evidence |
450
+ |---|---|---|
451
+ | `getRecEditions` | **yes** | **Proven.** `?idrec=16818` and `?idrec=14659` return byte-identical 21-row payloads in `spec/itu/vcr_cassettes/itu_t_h_264.yml`. Structurally safe besides: the response *names every sibling `idrec`*, so one fetch warms the whole family with no name parsing at all. |
452
+ | `rec.aspx` (workgroup) | **yes** | Both recordings in that cassette (`?rec=H.264` and `?rec=14659`) render `ITU-T Study Group 21` — ITU shows the *current* owning study group even on an old edition's page. Also the most expensive request per record: ~90–240 KB of HTML plus a DOM parse. |
453
+ | `getRecSupplements` | **no** | Unverified, so deliberately left per-edition. It very probably is family-invariant, but the suite holds only one recording, and sharing it wrongly would give a record another recommendation's `relation:` list **silently** — the data repo's `guard_enrichment` counts `contributor:` lines, which such a record still has. To settle it: add a `getRecSupplements?idrec=16818` interaction to that cassette, assert the two payloads are equal, then key it on `#family_id`. |
454
+
455
+ **Keys come from ITU, never from parsing a name.** `#family_id` is the smallest
456
+ `idrec` the `getRecEditions` response itself lists. `DataFetcher.family_key`
457
+ (which *does* parse `rec_name`) is used only to order the queue and to group a
458
+ top-up; a wrong guess there costs a cache miss and nothing else.
459
+ `#siblings_of` refuses to warm a response that does not list the `idrec` it
460
+ asked for — losing the de-duplication is the cheap failure.
461
+
462
+ **The cache never touches the runtime path.** `RecommendationFields#cache`
463
+ defaults to `NullCache`, whose `#fetch` is a bare yield and whose `#caching?`
464
+ is false; `Scraper` keeps calling the three-argument
465
+ `RecommendationParser.new`, so no path from `Bibliography.get` can reach a real
466
+ cache, retains no state between lookups, and issues exactly the requests it did
467
+ before. `RELATON_ITU_CACHE_ENTRIES=0` turns it off for the crawl too; the
468
+ cache is otherwise a bounded LRU (512 entries), because ~7.7k families of
469
+ retained JSON would run to hundreds of MB on a CI runner.
470
+ - **ITU-T top-up** (`#held_t?` / `#top_up_rows`) — `RELATON_ITU_MODE=top_up` now
471
+ governs **both** sectors. `#held_t?` is the ITU-T counterpart of `#held?` and is
472
+ HTTP-free: `DataParserT.fetch_docid` reads only `row["rec_name"]`, so the
473
+ decision happens before any of the four detail requests. The unit of work is
474
+ the **family, not the row**: a new edition changes its siblings' `hasEdition`
475
+ relations, so topping up only the new row would leave the rest of the family on
476
+ disk pointing at an incomplete edition list — a staleness the full run's
477
+ wipe-and-rebuild never had. **This needs the data repo to stop wiping
478
+ `data/itu-t-*` unconditionally and to `index_files` them on a top-up**, exactly
479
+ as it already does for ITU-R; until it does, `top_up` is inert for ITU-T
480
+ because every file has just been deleted.
481
+ - **Shared extractor `RecommendationFields`** (`recommendation_fields.rb`) — a
482
+ mixin keyed on `agent`/`idrec`/`imp` hooks. The **`getRecHdrDetail`-sourced field
483
+ extraction** (`fetch_titles`/`fetch_status`/`fetch_dates`/`fetch_abstract`/
484
+ `fetch_source`/`fetch_relations`/`fetch_workgroup`) is **genuinely shared**:
485
+ `RecommendationParser` is now Hit-agnostic (`new(agent, idrec, imp)`), `include`s
486
+ the module, and is used by **both** the live path (`Scraper` builds it with
487
+ `hit.hit_collection.agent`) and the harvester (`DataParserT`, via a
488
+ `RecommendationParser` instance) — one implementation, so those fields can't
489
+ drift. Path-specific bits stay in the concrete classes: row-based
490
+ docid/title/date/doctype in `DataParserT`; `imp` handling in the live path.
491
+ The module also carries `iso_docid`/`editorial_group`/`publisher`/
492
+ `group_subdivision`, used by the **harvester**. NOTE: the live `Scraper` still
493
+ keeps its own equivalent contributor/ISO logic (`fetch_editorial_contributor`/
494
+ `fetch_publisher_contributors`/`group_subdivision`/`createdocid`) because that
495
+ code also serves the RR/OB path — so those four are currently **parallel copies**
496
+ kept in sync, not yet a single source. Fully folding `Scraper`'s recommendation
497
+ contributors onto the module (guarding the RR/OB branch) is a deferred follow-up;
498
+ it also converges naturally once the issue-75 live-path ISO change (`iso_number`)
499
+ lands here.
500
+ - **Hosting:** both sectors live in the combined **`relaton/relaton-data-itu`** repo
501
+ under one flat `data/` and one `pubid:itu` `index-v2` (published on `main`;
502
+ ~21.5k records, ~20.5k indexed). Runtime consumers **are** repointed at it —
503
+ `HitCollection::GH_ITU` — so `relaton-data-itu-r` is no longer read.
504
+
505
+ ### Processor
506
+
507
+ `Relaton::Itu::Processor` extends `Relaton::Core::Processor` and is the entry point for the Relaton plugin system. Provides `get`, `fetch_data`, `from_xml`, `hash_to_bib`, `grammar_hash`, and `remove_index_file`.
508
+
509
+ ### Pubid-backed index-v2
510
+
511
+ The ITU index is **pubid-structured** (`index-v2.yaml`/`.zip`, `INDEXFILE =
512
+ "index-v2"` in `lib/relaton/itu.rb`): each row's `:id` is a `Pubid::Itu::Identifier`
513
+ serialized to its `_type: pubid:itu:{recommendation,handbook,question,…}` hash in the
514
+ **flat, compact** shape (scalar `sector`/`series`/`number`/`parts` directly under
515
+ `_type`, e.g. `sector: R`, `number: '600'`, `parts: ['1']`) that the published
516
+ `relaton-data-itu` index-v2 carries (both sectors; `pubid:itu:supplement` and
517
+ `pubid:itu:amendment` rows come with the ITU-T half). That flat shape + the handbook/question
518
+ identifier types (and pubid #290's `matches?`/`exclude` fix) are in the released
519
+ pubid; the query forms of #460 are not yet, so the root `Gemfile` **temporarily
520
+ pins** pubid to the #460 merge (see the pin note in the root `CLAUDE.md`). A
521
+ mismatched pubid produces a different `to_hash` shape and `Relaton::Index`
522
+ rejects the whole index.
523
+ The wiring mirrors NIST/ETSI/CIE:
524
+
525
+ - **Producer** (`DataFetcher`): `#index` calls `find_or_create(:itu, file:
526
+ "#{INDEXFILE}.yaml", pubid_class: ::Pubid::Itu::Identifier)`; `#write_file`
527
+ routes the primary id through `#index_primary`, which stores the **pubid object**
528
+ (`index.add_or_update pid, file`) so `Relaton::Index` sorts by id number and
529
+ serializes each id to its `_type:` hash on save.
530
+ - **The `#pubid` guard** parses via `::Pubid::Itu.parse` and additionally requires
531
+ a lossless round-trip (`Identifier.from_hash(to_hash).to_hash == to_hash`, the
532
+ index loader's own `Index::FileIO#id_supported?` acceptance test), returning nil
533
+ otherwise. The pinned pubid models recommendations, **handbooks**
534
+ (`ITU-R 23.HDB`), **questions** (`ITU-R 37-7/5:`), ITU-T `(V##)` versions and
535
+ labelled `Annex`es (#320), and — since **#325** — every remaining ITU-T print
536
+ form the corpus carries: `Technical Cor.`, Appendices, `bis`/`ter`
537
+ (`ITU-T V.25 ter`), the D-series `R` suffix, series supplements, joint
538
+ numbering, `Add. N`, and bare `v10`/`V2`/`v.1` versions; since **#460** it also
539
+ parses `ITU-R RR` (`pubid:itu:radio-regulations`, `root.number` `"RR"`), so a
540
+ Radio Regulations record on disk now **indexes** (the consumer still serves RR
541
+ from `/pub`). What the guard still skips is the malformed ITU-R docids
542
+ described under **Index/data gap** below. The **published** index predates #325, so the gap
543
+ closes on the next crawl. A skipped id is **not indexed** but
544
+ its data file is still written; `#index_primary` records it in `#unparseable_ids`,
545
+ and `#report_errors` (ISO-style) surfaces them at `:error` through the `gh_issue`
546
+ channel, raising the "Error fetching documents" GitHub issue in CI
547
+ (`ENV["GITHUB_REPOSITORY"]`). The published index-v2 carries ~5285 rows.
548
+ - **Consumer** (`HitCollection#index`, shared by `#request_document` and
549
+ `#index_hits`): `find_or_create(:itu, url: "#{GH_ITU}#{INDEXFILE}.zip", file:,
550
+ pubid_class: ::Pubid::Itu::Identifier)` against the **combined** repo — one index
551
+ serves both sectors — then `index.search(pubid) { |i| index_match?(pubid, i[:id]) }`.
552
+ The reference is passed as a pubid (not a String) so `Relaton::Index` narrows by
553
+ document number (`id.root.number`) before the block. `HitCollection` receives
554
+ the reference already parsed (see **Runtime lookup**): ITU-R searches with
555
+ `ref` itself, ITU-T with `ref.exclude(:year, :month, :day)`, because every
556
+ edition is a candidate and `Bibliography` selects the year.
557
+ - `#index_match?` delegates to pubid's structured `pubid.matches?(id, ignore:)`,
558
+ ignoring `:parts` **only when the reference omits the part** — so a bare
559
+ `ITU-R P.838` matches **every edition** (all `P.838-N`) while `ITU-R P.838-2`
560
+ matches only that edition, and a bare `ITU-R M.1` does **not** match `ITU-R M.10`
561
+ (a different document number, nor `-1` match `-10`) because pubid compares
562
+ structured ids, not a `-`-anchored string. This mirrors the ETSI flavor's
563
+ `Bibliography#best_match`; it works now that pubid #290 fixed
564
+ `Pubid::Itu::Identifier#matches?`/`#exclude` (previously an
565
+ `exclude`→`self.class.new` kwargs `ArgumentError`), replacing the earlier local
566
+ anchored-`-` stopgap. It additionally ignores **`:year`/`:month`/`:version`**:
567
+ ITU-T rows are one per dated (and possibly versioned) edition, so those are
568
+ separable trailing components the reference is normalised away from — the same
569
+ contract ETSI has (`omits: %i[version date]`) — and `Bibliography` narrows to
570
+ the requested year/version afterwards. There is **no local series guard**: pubid
571
+ #320 fixed `Supplement#==` to compare sector+series (before it, `ITU-T A Suppl. 2`
572
+ matched every series' `Suppl. 2` — 42 rows — and the flavor carried a
573
+ `same_document_family?` workaround). `#request_document`'s
574
+ `max_by { |i| i[:id].code&.parts&.last.to_i }` then returns the latest edition by
575
+ its numeric `code.parts` edition (pubid identifiers aren't Comparable, so a
576
+ numeric key is needed; the index isn't ordered by edition).
577
+ - **Index/data gap — now ITU-R data quality only.** History: 979 unindexed → 755
578
+ after two re-crawls → **47 after pubid #325**, which parses every ITU-T print
579
+ form the dataset carries (`Technical Cor.`, Appendices, `bis`/`ter`, the
580
+ D-series `R` suffix, series supplements, joint numbering, `Add. N`, bare
581
+ `v10`/`V2`/`v.1` versions). Verified against `relaton-data-itu@9ecdaec5` with
582
+ pubid `ac2fd517`: of the 755 then-unindexed records **708 index on the next
583
+ crawl**, and re-parsing all 20,728 already-indexed ids produces **zero** hash
584
+ changes — so no published index row changes shape.
585
+
586
+ The 47 that remain are **all ITU-R**, and none is an identifier problem: they
587
+ are malformed docids left by the decommissioned RunSearch crawler — series
588
+ letters with no document number (`ITU-R BO`, `ITU-R BT`), Roman-numeral
589
+ fragments (`ITU-R IV.IX`), study-group fragments (`ITU-R M.5-BL-13`) and
590
+ trailing colons (`ITU-R 111-2:`). They have no number to key an index row on,
591
+ so they are data to correct in `relaton-data-itu`, not identifiers to parse.
592
+
593
+ relaton keeps exactly **one** normalisation (`DataParserT#normalize_rec_name`):
594
+ a space where the series dot belongs (`G 231` → `G.231`, 4 records). pubid
595
+ rejects that spelling deliberately — a space there is ambiguous against the
596
+ series-only supplement form (`G Suppl. 1`) — so canonicalising it here is the
597
+ agreed division of labour, not a workaround.
598
+ - **Processor** `#remove_index_file` passes `url: true` and `file:` only. It
599
+ needs no `pubid_class:`: the delete never reads the index (see
600
+ `lib/relaton/index/CLAUDE.md`).
601
+
602
+ There is **one** ITU parser, `::Pubid::Itu`: for the caller's reference, the
603
+ hit codes (`Bibliography.search_filter`, `Scraper#createdocid`), the
604
+ `Docidentifier` and the index. The local Parslet grammar `Relaton::Itu::Pubid`
605
+ is gone (see **Runtime lookup**).
606
+
607
+ ## Testing
608
+
609
+ - **Index fixture:** `spec/fixtures/index-v2.zip` holds rows copied **verbatim**
610
+ from the published `relaton-data-itu` `index-v2.zip` (`main`), so the specs stay
611
+ honest about the production `_type: pubid:itu:*` shape — but trimmed to 5361 of
612
+ its 20,472 rows, because deserializing the whole index costs ~37 s in
613
+ `before(:suite)`. Kept: **every** ITU-R row (~5285) plus the ITU-T families the
614
+ specs exercise, including deliberate decoys (`L.1630` next to `L.163`;
615
+ `D Suppl. 2`/`G Suppl. 2` next to `A Suppl. 2`; `H.264.1` next to `H.264`) and the
616
+ deliberate **absence** of `H.264 (V14)`, which is what exercises the live
617
+ fallback. Regenerate with:
618
+
619
+ ```ruby
620
+ # ruby -rnet/http -ryaml -rzip -rstringio
621
+ url = "https://raw.githubusercontent.com/relaton/relaton-data-itu/refs/heads/main/index-v2.zip"
622
+ keep = %w[itu-t-l-163- itu-t-l-1630- itu-t-z-100- itu-t-y-3500- itu-t-a-13-
623
+ itu-t-a-suppl-2- itu-t-d-suppl-2- itu-t-g-suppl-2- itu-t-g-suppl-47-
624
+ itu-t-g-989-2- itu-t-g-780-y-1351- itu-t-h-264-]
625
+ Zip::File.open_buffer(StringIO.new(Net::HTTP.get(URI(url)))) { |z| @yaml = z.first.get_input_stream.read }
626
+ rows = YAML.safe_load(@yaml, permitted_classes: [Symbol]).select do |r|
627
+ b = File.basename(r[:file]); b.start_with?("itu-r-") || keep.any? { |p| b.start_with?(p) }
628
+ end
629
+ Zip::File.open("spec/itu/fixtures/index-v2.zip", Zip::File::CREATE) do |z|
630
+ z.get_output_stream("index-v2.yaml") { |f| f.write rows.to_yaml }
631
+ end
632
+ ```
633
+
634
+ It is loaded into the `Relaton::Index` pool in `before(:suite)`
635
+ (`spec/support/webmock.rb`): the YAML is written to a temp file and read through
636
+ `Relaton::Index::Type.new(:itu, file:, pubid_class: ::Pubid::Itu::Identifier)`, and
637
+ `type.index` forces the offline `pubid_class` deserialize before the net is
638
+ blocked; `actual?` is overridden to match only the remote (`url:`) lookup. The
639
+ fixture is **re-seated in `before(:each)`** because a producer-side
640
+ `find_or_create(:itu, file:)` in another example evicts the url-serving entry
641
+ from the shared pool (`Pool#type` replaces a non-`actual?` entry) — without the
642
+ re-seat a later consumer lookup would rebuild a network-backed `Type` and hit the
643
+ blocked net. The rows must deserialize under the pinned pubid — see the `Gemfile`
644
+ pin note.
645
+ - **Crawler cassettes: one per example, and re-record this suite alone.** The
646
+ `spec/itu/vcr_cassettes/itu_r_re*` cassettes back `data_crawler_r_spec.rb`, and
647
+ each is owned by **exactly one example**. That is not stylistic: VCR re-records
648
+ per cassette *insertion*, so a cassette shared across several examples is
649
+ truncated to the first example's requests the moment the 7-day
650
+ `re_record_interval` fires (verified — `itu_r_rec_bo` went 10 interactions → 1
651
+ and took 15 examples down with it). If you add a crawler example, give it its
652
+ own cassette rather than reusing one.
653
+
654
+ `www.itu.int` sits behind a rate-limiting F5 WAF, so a refresh can bake a
655
+ **block page** into a cassette instead of data — and ITU blocks the two path
656
+ families differently: `/rec` answers **HTTP 503**, `/pub` answers a **302 to
657
+ `notfound.aspx`**, which replays as a page with no rows rather than an error.
658
+ That is the "recorded transport failure" case from the repo-root conventions:
659
+ nothing to reconcile, just re-record. Re-record this suite **alone**
660
+ (`cd spec/itu && bundle exec rspec -I . relaton/itu/data_crawler_r_spec.rb`),
661
+ never under parallel `rake spec`, and sanity-check the counts afterwards — an
662
+ empty series or a document count that collapsed to 0 is a block, not ITU
663
+ deleting its corpus.
664
+ - **Framework:** RSpec with VCR cassettes for HTTP mocking and WebMock
665
+ - **Fixtures:** `spec/fixtures/` contains sample YAML/XML documents for round-trip tests
666
+ - **VCR cassettes:** `spec/vcr_cassettes/` — 19 cassettes recording real HTTP
667
+ responses. VCR ignores only `index-v2.zip` (the index comes from the fixture
668
+ above), so the per-record `raw.githubusercontent.com/…/data/*.yaml` GETs **are**
669
+ recorded. Re-record by deleting the file and running the suite with network
670
+ access; `re_record_interval` is 7 days, so a stale-cassette run refreshes them.
671
+ - **Coverage:** SimpleCov, target near 100%
672
+
673
+ Round-trip tests (serialize → deserialize → compare) are the primary pattern for model classes.
674
+
675
+ ## Key Dependencies
676
+
677
+ - `relaton-bib` — base bibliographic model classes (`Bib::Item`, `Bib::Ext`, etc.)
678
+ - `relaton-core` — `Core::Processor`, `Core::DataFetcher` base classes
679
+ - `lutaml-model` — serialization framework (XML/YAML mapping via `Lutaml::Model::Serializable`)
680
+ - `mechanize` — web scraping for data fetching
681
+ - `relaton-index` — document indexing
682
+
683
+ ## Ruby Version
684
+
685
+ Requires Ruby >= 3.1.0.