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,140 @@
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
+ relaton-core is the foundation gem every flavor plugin builds on. It provides the abstract **`Relaton::Core::Processor`** base class (the registry plug-in interface), the search primitives (`HitCollection`, `Hit`), a parallel-fetch thread pool (`WorkersPool`), the crawl-politeness pair `Pacer` (shared request pacing) and `Governor` (pool-wide rate-limit back-pressure), the `DataFetcher` base for bulk ingest, and small parsing/utility mixins. It has no knowledge of any specific standards body.
8
+
9
+ ## Development
10
+
11
+ ```bash
12
+ bundle exec rake # default task → rspec
13
+ bundle exec rspec spec/core/processor_spec.rb # single file
14
+ bundle exec rubocop # lint (rubocop-performance/-rake/-rspec)
15
+ ```
16
+
17
+ ## Architecture
18
+
19
+ Namespace: `Relaton::Core`. Key classes in `lib/relaton/core/`:
20
+
21
+ - **`Processor`** (`processor.rb`) — abstract base with the template methods every flavor implements: `get`, `fetch_data`, `from_xml`, `from_yaml`, `grammar_hash`, `threads`. Flavor processors subclass it and set `@short`/`@prefix`/`@defaultprefix`/`@datasets` in `initialize`.
22
+ - **`HitCollection`** (`hit_collection.rb`) — holds search results, delegates to an internal array, and uses `WorkersPool` to fetch items in parallel (4 workers by default). Uses `WeakRef` to avoid a circular reference back to its hits.
23
+ - **`Hit`** (`hit.rb`) — wraps one search result; its `item` is lazy-loaded on first access.
24
+ - **`DataFetcher`** (`data_fetcher.rb`) — abstract base for flavor bulk fetchers; provides `gh_issue` reporting (via `Relaton::Logger::Channels::GhIssue`), `output_file`, `unique_output_file`, `write_unique`, and `serialize` helpers.
25
+
26
+ **Filename collisions.** `output_file` sanitizes `.`, `,`, `/`, `:`, `(`, `)`,
27
+ `-` and whitespace all to `-`, so two **distinct** docids can map to one path
28
+ — the live instance is relaton-data-iana's `rpki/signed-objects` and
29
+ `rpki-signed-objects`, which both give `data/rpki-signed-objects.yaml`. A
30
+ writer that merely warns and writes anyway leaves one file holding the wrong
31
+ document for one of two index ids: a wrong answer, not a missing one.
32
+
33
+ **`unique_output_file(docid)` is the write-path entry point.** It reserves and
34
+ returns `output_file(docid)` when that path is free *or already belongs to the
35
+ same docid*, and only a real clash with a **different** docid gets a variant
36
+ suffixed with a digest of that docid. Two consequences worth keeping:
37
+
38
+ - It is a **no-op for genuine duplicates**, so each flavor's own duplicate
39
+ handling (skip / merge / last-wins) still fires exactly as before. That is
40
+ what makes it safe to drop into an existing writer.
41
+ - The suffix is keyed on the docid, not on encounter order, so adding a record
42
+ never renames an existing file. (Which member of a clashing pair keeps the
43
+ plain path does follow write order, which is stable for a given corpus.)
44
+
45
+ **The genuine-duplicate check must come FIRST in an adopting writer.** Once a
46
+ docid has been disambiguated, `unique_output_file` returns that suffixed path
47
+ for it *forever*, so `file != output_file(docid)` stays true on every repeat.
48
+ Gating the flavor's duplicate handling on that comparison makes it unreachable
49
+ for a repeat, which silently drops a merge (3gpp) or a skip (ecma, jis). Use
50
+ `@files.include?(file)` as the duplicate test — a reserved path only ever
51
+ belongs to one docid — and use the `!= output_file` comparison only to decide
52
+ what to log:
53
+
54
+ ```ruby
55
+ file = unique_output_file docid
56
+ if @files.include? file
57
+ <genuine duplicate: merge / skip / warn>
58
+ else
59
+ Util.warn "... writing #{file} instead" if file != output_file(docid)
60
+ @files << file
61
+ <write + index>
62
+ end
63
+ ```
64
+
65
+ **`write_unique(docid, content)` closes the cross-PROCESS gap.**
66
+ `unique_output_file` reserves in one process's `@file_docids`, which a forked
67
+ worker cannot see. `write_unique` reserves, then creates the file with
68
+ `O_EXCL` (`EXCLUSIVE = File::WRONLY | File::CREAT | File::EXCL`), and returns
69
+ the path it actually wrote. Four outcomes:
70
+
71
+ | situation | what happens |
72
+ |---|---|
73
+ | we already wrote this path for this docid | plain overwrite — a genuine duplicate stays one file |
74
+ | `O_EXCL` succeeds | the normal case |
75
+ | `EEXIST`, single process | `unique_output_file` already proved no peer of ours holds it, so it is a leftover from an earlier crawl: overwrite |
76
+ | `EEXIST`, `@cross_process` | a peer's file and a leftover are indistinguishable: take the digest path instead |
77
+
78
+ `@cross_process` defaults to false, so every single-process flavor takes the
79
+ third row and behaves exactly as it did before adopting this. A fetcher that
80
+ forks sets it **before** the first fork, so children inherit it — see
81
+ `Relaton::Ietf::DataFetcher#fetch_ieft_internet_drafts`.
82
+
83
+ A forked worker only learns that a path was taken, never *which* clashing
84
+ docid deserves it, so the winner would follow the race and the two filenames
85
+ would swap between crawls. The parent settles that afterwards, once it can see
86
+ every docid: `Relaton::Ietf::DataFetcher#reconcile_output_files`.
87
+
88
+ **Deliberately not a wall-clock staleness test.** `File.mtime(file) <
89
+ run_started_at` looks like the obvious way to tell a leftover from a peer, and
90
+ it is a trap: crawls run into a populated `data/`, so `EEXIST` is the *common*
91
+ case on a re-run and the test would decide it for every record. Its
92
+ false-"stale" direction is the silent overwrite this method exists to prevent,
93
+ and coarse filesystem mtime granularity makes that reachable. Gating on
94
+ `@cross_process` removes the failure class instead of tuning it.
95
+
96
+ **Do not drop the unconditional `delete_suffix("-")` in `output_file`.** It is
97
+ the root cause of the trailing-punctuation half of these collisions, and
98
+ removing it would give the clashing ids distinct names — but it renames any id
99
+ ending in a character the gsub maps to `-`. Measured over this repo's index
100
+ fixtures (2026-08-26):
101
+
102
+ | corpus | rows | filenames that would change |
103
+ |---|---|---|
104
+ | `spec/itu/fixtures/index-v2.zip` | 5,361 | **808** — ids ending `:`, e.g. `ITU-R 1-3/8:` |
105
+ | `spec/nist/fixtures/index-v2.zip` | 19,515 | 2 — `NBS TN 467pt1 Add.` |
106
+ | `spec/ietf/fixtures/ietf-index-v2.zip` | 177,362 | 3 |
107
+ | `spec/iso` + `spec/iec` v2 | 112,189 | 0 |
108
+ | every v1 string-id fixture | 97,966 | 7 — IEEE `IEEE 802.3-`, OGC `20-001r2 ` |
109
+
110
+ A 15% rename of the published ITU corpus does not pay for collisions that
111
+ `unique_output_file` and `write_unique` already close.
112
+
113
+ **Audit of every `output_file` caller** (keep this exhaustive — it is the only
114
+ place the decision is recorded):
115
+
116
+ | Adopted | Abstains, and why |
117
+ |---|---|
118
+ | `iana`, `oasis`, `calconnect`, `xsf`, `nist`, `ecma`, `jis`, `3gpp`, `iec`, `etsi`; `ietf` via `write_unique` | `ieee` — `reconcile_staged_outputs` recomputes `output_file(docnumber)` in a later pass with no reservation state, so a disambiguated path breaks the staged rename |
119
+ | | `itu`, `cie` — documented positional `@seen`/`pos` dedup under a parallel crawl |
120
+ | | `iso` — `File.exist?` → `rewrite_with_same_or_newer` version compare |
121
+ | | `ccsds` — `merge_links` merges into the existing file by design |
122
+
123
+ Note `etsi` had **no** collision handling at all before this (silent overwrite,
124
+ not even a warning), and `iec` had the warn-then-overwrite-anyway shape.
125
+ - **`WorkersPool`** (`workers_pool.rb`) — `SizedQueue`-backed thread pool for parallel work. **Runtime search only** — `HitCollection` uses it. No fetcher does: it carries no per-worker resources (a crawler needs one Mechanize agent per thread) and collects into an unsynchronised array. A crawler that needs a pool builds its own; see `Relaton::Itu::DataCrawlerR#in_parallel` and `Relaton::Cie::DataFetcher#process_hits`.
126
+ - **`Pacer`** (`pacer.rb`) — process-wide, thread-safe request pacer: the whole pool between them starts at most one request per `gap` seconds. **Shared, not per-worker** — that is the point, and the contrast with `Relaton::Cie::DataFetcher::Pacing`, whose gap is per worker (N workers there issue N requests per gap, which is right for CIE and wrong when the budget belongs to one host). A reservation never starts before "now", so an idle stretch cannot be repaid as a burst, and the mutex is never held across the sleep. `mode: :fixed` reproduces a plain `sleep delay` loop as a rollback knob. `clock:`/`sleeper:` are injectable, so the arithmetic is specced without wall-clock waits.
127
+ - **`Governor`** (`governor.rb`) — process-wide, thread-safe rate-limit back-pressure: one shared cooldown the whole pool observes, escalating per *round* (60 s → 900 s) rather than per worker, decaying on success, and latching a give-up after five barren rounds so a banned crawl fails fast instead of grinding out a CI job's remaining hours. Two round counters, deliberately: **`#throttle_rounds`** is the LIVE count since the last success (ITU logs it per retry as the current round) and `#succeeded!` resets it, while **`#give_up_rounds`** is the count snapshotted at the instant `@abandoned` latched, and is what an abort message must report — reading the live one after the latch names whatever re-accumulated since, which is how a W3C give-up at round 5 got logged as *"after 2 consecutive backoffs"*. Note also that `#wait` returning 0.0 once latched stops the pool *waiting*, not *asking*: a flavor must guard its own request sites (see `Relaton::W3c::SafeRealize#realize`) or a banned crawl keeps hammering the host until it winds down. Promoted here from `Relaton::W3c`. A flavor subclasses it and supplies two things: `THROTTLE_ERRORS` (or `.throttle?`, when the signal is a status code rather than an exception class, as for ITU) and `ENV_PREFIX`, which namespaces `<PREFIX>_THROTTLE_BASE/_MAX/_GIVEUP` so two flavors crawling in one process cannot share a ladder. Bindings: `Relaton::W3c::Governor`, `Relaton::Itu::Governor`.
128
+ - **Mixins** — `DateParser` (`parse_date` for "February 2012"/"2012-02-03"/etc.), `ArrayWrapper` (`array(x)` → always an Array), `HashKeysSymbolizer` (recursive string→symbol keys).
129
+
130
+ ### How flavors consume it (important)
131
+
132
+ Every flavor's `Relaton::<Flavor>::Processor < Relaton::Core::Processor`. Because the umbrella registry now loads flavors lazily (only their `…/processor` file), any processor method that touches a flavor constant must `require_relative "../<flavor>"` first — see the root `CLAUDE.md` "Registry is lazy" note.
133
+
134
+ ## External dependencies
135
+
136
+ `nokogiri ~> 1.16`, `psych ~> 5.2.0`, `relaton-logger ~> 2.2.0.pre.alpha.1`.
137
+
138
+ ## Testing
139
+
140
+ RSpec with SimpleCov. Specs live under `spec/core/` and mirror the lib classes; `spec/support/` defines dummy processor/types for exercising the base classes.
@@ -0,0 +1,64 @@
1
+ = Library for importing and caching bibliographic references to technical standards library
2
+
3
+ == Usage
4
+
5
+ === Match a partial reference against a document identifier
6
+
7
+ `#===` is a subset match: the reference is the receiver, the candidate is the
8
+ argument. A component the reference leaves out matches any value. The match is
9
+ NOT symmetric.
10
+
11
+ [source,ruby]
12
+ ----
13
+ require "relaton-core"
14
+
15
+ pubid_first = Identifier.parse("ISO 1:1999")
16
+ pubid_second = Identifier.parse("ISO 1")
17
+
18
+ pubid_first == pubid_second
19
+ => false
20
+
21
+ pubid_second === pubid_first
22
+ => true
23
+
24
+ pubid_first === pubid_second
25
+ => false
26
+ ----
27
+
28
+ === Exclude attributes when compare
29
+
30
+ Use `#exclude` to build a copy without a component, for example to render a
31
+ reference without its year. For matching, prefer `#===`.
32
+
33
+ [source,ruby]
34
+ ----
35
+ pubid_first.exclude(:year) == pubid_second
36
+ => true
37
+ ----
38
+
39
+ === Using #to_h to convert identifier to hash
40
+
41
+ [source,ruby]
42
+ ----
43
+ require "pubid-core"
44
+
45
+ pubid = Identifier.parse("ISO 1:1999")
46
+ pubid.to_h
47
+ => { publisher: "ISO", number: 1, year: 1999 }
48
+ ----
49
+
50
+ === Using #new_edition_of? to compare identifiers
51
+
52
+ [source,ruby]
53
+ ----
54
+ require "pubid-core"
55
+
56
+ pubid_first = Identifier.parse("ISO 1:1999")
57
+ pubid_second = Identifier.parse("ISO 1:2000")
58
+
59
+ pubid_first.new_edition_of?(pubid_second)
60
+ => false
61
+ pubid_second.new_edition_of?(pubid_first)
62
+ => true
63
+
64
+ ----
@@ -25,19 +25,34 @@ module Relaton
25
25
  # Set true by a fetcher that writes from forked worker processes,
26
26
  # where @file_docids cannot see a peer's claim. See #write_unique.
27
27
  @cross_process = false
28
+ # True when the caller asked this run to keep the existing output tree
29
+ # and prune it afterwards (see ::fetch's prune:). A flavor that wipes
30
+ # its output at the start of #fetch must skip the wipe in this mode.
31
+ @prune = false
28
32
  # @docs = []
29
33
  @errors = Hash.new(true)
30
34
  end
31
35
 
36
+ # @return [Boolean] whether this run prunes the existing output tree
37
+ attr_accessor :prune
38
+
32
39
  # API method for external service
33
40
  #
41
+ # @param prune [Boolean] delete output files this run did not write. The
42
+ # flavor must write its whole corpus on every run; #prune_unwritten's
43
+ # ratio guard stops a partial crawl from wiping the dataset.
34
44
  # @return the value returned by the instance `#fetch`, so callers can act
35
45
  # on the outcome (e.g. relaton-iso returns whether it rebuilt).
36
- def self.fetch(source = nil, output: "data", format: "yaml")
46
+ def self.fetch(source = nil, output: "data", format: "yaml", prune: false)
37
47
  t1 = Time.now
38
48
  puts "Started at: #{t1}"
39
49
  FileUtils.mkdir_p output
40
- result = new(output, format).fetch(source)
50
+ fetcher = new(output, format)
51
+ # Only touched in prune mode: the default path stays exactly what it
52
+ # was, and existing ::fetch callers (and their specs) see no new call.
53
+ fetcher.prune = true if prune
54
+ result = fetcher.fetch(source)
55
+ fetcher.prune_unwritten if prune
41
56
  t2 = Time.now
42
57
  puts "Stopped at: #{t2}"
43
58
  puts "Done in: #{(t2 - t1).round} sec."
@@ -187,6 +202,30 @@ module Relaton
187
202
  force_write reserve(digest_output_file(docid), docid), content
188
203
  end
189
204
 
205
+ # Delete output files this run did not write — the replacement for a
206
+ # crawler's `rm -rf data` when the data tree is kept between runs. A
207
+ # partial crawl must never wipe the dataset, so when the run wrote less
208
+ # than +min_ratio+ of the existing corpus nothing is deleted.
209
+ #
210
+ # @param min_ratio [Float]
211
+ # @return [Array<String>] deleted paths
212
+ def prune_unwritten(min_ratio: 0.9)
213
+ existing = Dir.glob(File.join(@output, "**", "*.#{@ext}"))
214
+ return [] if existing.empty?
215
+
216
+ written = existing.count { |file| @written.include?(file) }
217
+ if written < existing.size * min_ratio
218
+ warn "prune skipped: #{written}/#{existing.size} written " \
219
+ "(threshold #{(existing.size * min_ratio).ceil})"
220
+ return []
221
+ end
222
+
223
+ stale = existing - @written.to_a
224
+ stale.each { |file| File.delete(file) }
225
+ warn "pruned #{stale.size} stale file(s)" unless stale.empty?
226
+ stale
227
+ end
228
+
190
229
  #
191
230
  # Serialize bibliographic item
192
231
  #
@@ -213,8 +252,16 @@ module Relaton
213
252
  private
214
253
 
215
254
  # Write, clobbering whatever is there. The caller has already established
216
- # that the path is ours to take.
255
+ # that the path is ours to take. Identical content is left untouched:
256
+ # an incremental crawl (one that keeps the data tree) must not churn
257
+ # mtimes or rewrite bytes for a record that did not change. The file is
258
+ # still recorded as written, so #prune_unwritten keeps it.
217
259
  def force_write(file, content)
260
+ if File.exist?(file) && File.read(file, encoding: "UTF-8") == content
261
+ @written << file
262
+ return file
263
+ end
264
+
218
265
  File.write file, content, encoding: "UTF-8"
219
266
  @written << file
220
267
  file