relaton 3.0.1 → 3.0.3

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/bib/converter/csl.rb +3 -2
  3. data/lib/relaton/bib/model/docidentifier.rb +61 -0
  4. data/lib/relaton/bib.rb +2 -1
  5. data/lib/relaton/bipm/CLAUDE.md +14 -0
  6. data/lib/relaton/bipm.rb +0 -1
  7. data/lib/relaton/bsi/model/docidentifier.rb +0 -4
  8. data/lib/relaton/calconnect/data_fetcher.rb +2 -2
  9. data/lib/relaton/ccsds/data/fetcher.rb +2 -1
  10. data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
  11. data/lib/relaton/core/data_fetcher.rb +2 -1
  12. data/lib/relaton/core/processor.rb +1 -1
  13. data/lib/relaton/core.rb +7 -0
  14. data/lib/relaton/db/cache.rb +3 -3
  15. data/lib/relaton/db/registry.rb +1 -1
  16. data/lib/relaton/db.rb +22 -3
  17. data/lib/relaton/doi/crossref.rb +2 -1
  18. data/lib/relaton/doi/parser.rb +3 -2
  19. data/lib/relaton/etsi/data_fetcher.rb +3 -3
  20. data/lib/relaton/gb/sec_scraper.rb +3 -3
  21. data/lib/relaton/iec/data_fetcher.rb +3 -2
  22. data/lib/relaton/iec/model/docidentifier.rb +0 -4
  23. data/lib/relaton/ietf/CLAUDE.md +27 -2
  24. data/lib/relaton/ietf/bibxml_parser.rb +38 -0
  25. data/lib/relaton/ietf/data_fetcher.rb +3 -30
  26. data/lib/relaton/ietf/scraper.rb +60 -3
  27. data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
  28. data/lib/relaton/iho/CLAUDE.md +4 -0
  29. data/lib/relaton/iho/bibliography.rb +7 -1
  30. data/lib/relaton/index/dataset_lock.rb +4 -3
  31. data/lib/relaton/index/shard_source.rb +3 -2
  32. data/lib/relaton/index/sqlite_backend.rb +5 -3
  33. data/lib/relaton/isbn/open_library.rb +2 -1
  34. data/lib/relaton/iso/data_fetcher.rb +6 -6
  35. data/lib/relaton/iso/model/docidentifier.rb +0 -4
  36. data/lib/relaton/itu/data_fetcher.rb +2 -2
  37. data/lib/relaton/itu/hit_collection.rb +3 -3
  38. data/lib/relaton/itu/recommendation_fields.rb +2 -2
  39. data/lib/relaton/jis/data_fetcher.rb +2 -1
  40. data/lib/relaton/json.rb +21 -0
  41. data/lib/relaton/logger/channels/gh_issue.rb +3 -3
  42. data/lib/relaton/logger/formatter_json.rb +2 -1
  43. data/lib/relaton/nist/CLAUDE.md +7 -0
  44. data/lib/relaton/nist/bibliography.rb +31 -0
  45. data/lib/relaton/nist/cswp.rb +137 -0
  46. data/lib/relaton/nist/cswp.yaml +155 -0
  47. data/lib/relaton/nist/docidentifier.rb +0 -4
  48. data/lib/relaton/nist/processor.rb +20 -0
  49. data/lib/relaton/nist/pubs_export.rb +2 -1
  50. data/lib/relaton/nist.rb +1 -0
  51. data/lib/relaton/ogc/data_fetcher.rb +2 -2
  52. data/lib/relaton/omg/scraper.rb +3 -3
  53. data/lib/relaton/plateau/data_fetcher.rb +2 -1
  54. data/lib/relaton/un/hit_collection.rb +4 -3
  55. data/lib/relaton/version.rb +1 -1
  56. data/lib/relaton.rb +1 -0
  57. metadata +45 -88
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pubid"
4
4
  require "pubid/ietf"
5
+ require_relative "bibxml_parser"
5
6
 
6
7
  module Relaton
7
8
  module Ietf
@@ -15,6 +16,13 @@ module Relaton
15
16
  # publishing their `index-v1` for released relatons, untouched.
16
17
  IETF = "https://raw.githubusercontent.com/relaton/relaton-data-ietf/main/"
17
18
 
19
+ # The IETF bibxml service, read when the index has no row for a versioned
20
+ # Internet-Draft (relaton-ietf#125). The data repo crawls the rsync
21
+ # `bibxml-ids` snapshot, which keeps only some versions of each draft
22
+ # (`draft-ietf-vcarddav-vcardrev`: only -22); this service resolves any
23
+ # version the datatracker knows, and answers 404 for one it does not.
24
+ BIBXML_IDS = "https://bib.ietf.org/public/rfc/bibxml-ids/"
25
+
18
26
  # @param ref [String, Pubid::Ietf::Identifier] a reference, or its parse
19
27
  # from Relaton::Db (relaton#205), which is used as it is
20
28
  # @return [Relaton::Ietf::ItemData, nil]
@@ -24,8 +32,9 @@ module Relaton
24
32
 
25
33
  fetch_doc id
26
34
  rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
27
- Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
28
- Net::ProtocolError, SocketError
35
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
36
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
37
+ Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
29
38
  raise Relaton::RequestError, "No document found for #{ref} reference"
30
39
  end
31
40
 
@@ -44,7 +53,55 @@ module Relaton
44
53
  # such pairs in the first 40k rows of the index fixture).
45
54
  def fetch_doc(id)
46
55
  row = index.search(id, exact: true).first
47
- get_page "#{IETF}#{row[:file]}" if row
56
+ return get_page "#{IETF}#{row[:file]}" if row
57
+
58
+ fetch_bibxml id if versioned_draft? id
59
+ end
60
+
61
+ # Only a versioned draft falls back. The index holds every RFC, sub-series
62
+ # and unversioned draft aggregator the data repo has, so a miss on one of
63
+ # those is a real "not found", and bib.ietf.org has no aggregator to offer.
64
+ def versioned_draft?(id)
65
+ id.is_a?(::Pubid::Ietf::Identifiers::InternetDraft) && !id.version.to_s.empty?
66
+ end
67
+
68
+ #
69
+ # Read one draft version from bib.ietf.org and parse it the way
70
+ # `DataFetcher#process_singleton` parses a crawled file: the same
71
+ # CP1252 repair, the same parser, and the version from the identifier.
72
+ #
73
+ # A 404 is the service's "no such version". Any other non-200 (a 429, a
74
+ # 5xx) is push-back or an outage, so it raises `Relaton::RequestError`:
75
+ # `Db#net_retry` then retries it, and the Db cache does not store it as a
76
+ # not-found.
77
+ #
78
+ # @param id [Pubid::Ietf::Identifiers::InternetDraft]
79
+ # @return [Relaton::Ietf::ItemData, nil] nil on a 404 or an unreadable body
80
+ #
81
+ def fetch_bibxml(id)
82
+ res = Net::HTTP.get_response URI("#{BIBXML_IDS}reference.I-D.#{id}.xml")
83
+ return if res.code == "404"
84
+ raise Relaton::RequestError, "#{BIBXML_IDS} answered #{res.code} for #{id}" unless res.code == "200"
85
+
86
+ bib = parse_bibxml(res.body, id)
87
+ return unless bib
88
+
89
+ bib.version = [Bib::Version.new(draft: id.version.to_s)]
90
+ bib.fetched = Date.today.to_s
91
+ bib
92
+ end
93
+
94
+ # A 200 that is not bibxml (an HTML error page, a truncated body) costs
95
+ # this one document, not the caller's compile. These are the errors the
96
+ # parse raises on such a body: lutaml's `InvalidFormatError`, and the
97
+ # converter's `NoMethodError` on a document with no `<front>` and
98
+ # `ArgumentError` from its regexes. They are named, not `StandardError`,
99
+ # so a transport error still reaches `scrape_page`'s rescue.
100
+ def parse_bibxml(body, id)
101
+ BibXMLParser.parse BibXMLParser.to_utf8(body)
102
+ rescue Lutaml::Model::InvalidFormatError, NoMethodError, ArgumentError => e
103
+ Util.warn "Unreadable bibxml for `#{id}`: #{e.message.to_s.lines.first.to_s.strip}"
104
+ nil
48
105
  end
49
106
 
50
107
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+
4
4
  require "net/http"
5
5
 
6
6
  module Relaton
@@ -20,7 +20,7 @@ module Relaton
20
20
  response = Net::HTTP.get_response(uri)
21
21
  break unless response.is_a?(Net::HTTPSuccess)
22
22
 
23
- data = JSON.parse(response.body)
23
+ data = Relaton::Json.parse_external(response.body)
24
24
  objects = data["objects"] || []
25
25
  break if objects.empty?
26
26
 
@@ -55,6 +55,10 @@ In `item.rb`, `require_relative "relation"` is placed **after** the `Item` class
55
55
 
56
56
  `Bibliography.search`/`.get` fetches YAML from the relaton-data-iho GitHub repo via `Relaton::Index`, then deserializes into model objects.
57
57
 
58
+ **Editions.** The pubid `version` is the edition (`IHO B-12 3.0.0`; the grammar takes `a.b.c` only). A reference with no version answers with the **latest** edition: `version_key` compares the segments as integers (`2.10.0` after `2.9.0`). It used to be `min_by` on the string, which returned the oldest.
59
+
60
+ **The edition reaches the index only through the committed row.** A record prints `B-12` and keeps its edition in `edition:`, so `relaton index` cannot read a version from the docid. `MachineIndex#refine` (relaton-cli) takes the committed `index-v3.yaml` row when it adds only a `version` to the parse. Before that, the Pages index (generated 2026-10-29) had no `version` in any of its 325 rows, so every versioned reference answered not-found (relaton-data-iho#20). The suite did not see it: `spec/fixtures/index-v3.zip` is the committed index, which has versions.
61
+
58
62
  ### Test Infrastructure
59
63
 
60
64
  - **VCR cassettes** (`spec/vcr_cassettes/`): recorded HTTP responses for offline testing
@@ -21,7 +21,7 @@ module Relaton
21
21
  # Pubid::Iho::Identifiers::* (Relaton::Index deserialized it via the
22
22
  # `pubid_class` passed in `#index`), so `pubid_match?` compares pubids.
23
23
  row = index.search(pubid) { |r| pubid_match?(r[:id], pubid) }
24
- .min_by { |r| r[:id].version.to_s }
24
+ .max_by { |r| version_key(r[:id]) }
25
25
  unless row
26
26
  Util.info "Not found.", key: pubid.to_s
27
27
  return
@@ -96,6 +96,12 @@ module Relaton
96
96
  )
97
97
  end
98
98
 
99
+ # The sort key for the latest edition: segments compare as integers,
100
+ # so 2.10.0 is later than 2.9.0. A row with no version is the oldest.
101
+ def version_key(id)
102
+ id.version.to_s.split(".").map(&:to_i)
103
+ end
104
+
99
105
  # Both `row_id` and `query` are Pubid::Iho::Identifiers::* instances.
100
106
  # Class identity pins the series (S/P/M/B/C). Subdivision keys
101
107
  # (part/appendix/annex/supplement) use strict equality — a nil query
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yeptris"
4
+
3
5
  require "digest"
4
- require "json"
5
6
  require "net/http"
6
7
  require "time"
7
8
  require "uri"
@@ -91,8 +92,8 @@ module Relaton
91
92
  end
92
93
  raise Error, "cannot fetch #{uri}: HTTP #{res.code}" unless res.code == "200"
93
94
 
94
- JSON.parse(res.body)
95
- rescue JSON::ParserError => e
95
+ Yeptris::JSON.load(res.body)
96
+ rescue Yeptris::JSON::ParseError => e
96
97
  raise Error, "malformed manifest at #{uri}: #{e.message}"
97
98
  end
98
99
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "json"
2
3
  require "net/http"
3
4
  require "openssl"
@@ -174,8 +175,8 @@ module Relaton
174
175
  end
175
176
 
176
177
  def parse_json(body, path)
177
- JSON.parse(body)
178
- rescue JSON::ParserError => e
178
+ Yeptris::JSON.load(body)
179
+ rescue Yeptris::JSON::ParseError => e
179
180
  raise Error, "cannot parse `#{url(path)}`: #{e.message}"
180
181
  end
181
182
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yeptris"
4
+
3
5
  require "sqlite3"
4
6
 
5
7
  module Relaton
@@ -34,7 +36,7 @@ module Relaton
34
36
  @db.transaction do
35
37
  @db.execute("DELETE FROM index_rows")
36
38
  @db.prepare("INSERT INTO index_rows (number, sort_key, id_json, file) VALUES (?, ?, ?, ?)") do |stmt|
37
- rows_enum.each { |(number, sort_key, id_hash, file)| stmt.execute(number, sort_key, JSON.generate(id_hash), file) }
39
+ rows_enum.each { |(number, sort_key, id_hash, file)| stmt.execute(number, sort_key, Yeptris::JSON.dump(id_hash), file) }
38
40
  end
39
41
  meta_set("schema_version", SCHEMA_VERSION)
40
42
  end
@@ -44,14 +46,14 @@ module Relaton
44
46
  def bucket(number)
45
47
  rows = []
46
48
  @db.execute("SELECT id_json, file FROM index_rows WHERE number = ? ORDER BY rowid", [number]) do |row|
47
- rows << { id: JSON.parse(row[0]), file: row[1] }
49
+ rows << { id: Yeptris::JSON.load(row[0]), file: row[1] }
48
50
  end
49
51
  rows
50
52
  end
51
53
 
52
54
  def each_row
53
55
  @db.execute("SELECT id_json, file FROM index_rows ORDER BY rowid") do |row|
54
- yield({ id: JSON.parse(row[0]), file: row[1] })
56
+ yield({ id: Yeptris::JSON.load(row[0]), file: row[1] })
55
57
  end
56
58
  end
57
59
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  module Relaton
2
3
  module Isbn
3
4
  #
@@ -37,7 +38,7 @@ module Relaton
37
38
  response = Net::HTTP.get_response uri
38
39
  return unless response.is_a? Net::HTTPSuccess
39
40
 
40
- data = JSON.parse response.body
41
+ data = Relaton::Json.parse_external response.body
41
42
  return unless data["records"]&.any?
42
43
 
43
44
  data["records"].first.last
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
- require "json"
3
3
  require "net/http"
4
4
  require "tmpdir"
5
5
  require_relative "../iso"
@@ -168,7 +168,7 @@ module Relaton
168
168
  amend_map = Hash.new { |h, k| h[k] = [] }
169
169
  date_map = {}
170
170
  File.foreach(path, encoding: "UTF-8") do |line|
171
- rec = JSON.parse(line)
171
+ rec = Relaton::Json.parse_external(line)
172
172
  id = rec["id"]
173
173
  ref = normalize_reference(rec["reference"])
174
174
  next unless ref
@@ -179,7 +179,7 @@ module Relaton
179
179
  if rec["supplementType"] && (base = amend_base(ref))
180
180
  amend_map[base] << ref
181
181
  end
182
- rescue JSON::ParserError
182
+ rescue ::JSON::ParserError
183
183
  next
184
184
  end
185
185
  Util.info "Indexed #{ref_map.size} references; " \
@@ -216,11 +216,11 @@ module Relaton
216
216
  path = download_tc_dataset
217
217
  map = {}
218
218
  File.foreach(path, encoding: "UTF-8") do |line|
219
- rec = JSON.parse(line)
219
+ rec = Relaton::Json.parse_external(line)
220
220
  ref = rec["reference"]
221
221
  title = rec["title"]
222
222
  map[ref] = title if ref && title.is_a?(Hash)
223
- rescue JSON::ParserError
223
+ rescue ::JSON::ParserError
224
224
  next
225
225
  end
226
226
  Util.info "Indexed #{map.size} committees."
@@ -231,7 +231,7 @@ module Relaton
231
231
  Util.info "Parsing records..."
232
232
  count = 0
233
233
  File.foreach(path, encoding: "UTF-8") do |line|
234
- rec = JSON.parse(line)
234
+ rec = Relaton::Json.parse_external(line)
235
235
  next unless ingestable?(rec["reference"])
236
236
 
237
237
  fetch_pub(rec, ref_index, tc_index, amend_index, date_index)
@@ -64,10 +64,6 @@ module Relaton
64
64
  original_content
65
65
  end
66
66
 
67
- def to_s
68
- content.to_s
69
- end
70
-
71
67
  # `remove_part!`/`remove_date!` are unnecessary here: `to_all_parts`'s
72
68
  # own identity computation already excludes part/date (down the whole
73
69
  # base chain, so a supplement's own year is dropped too — a disclosed
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "net/http"
2
- require "json"
3
3
  require "uri"
4
4
  require "mechanize"
5
5
  require_relative "../itu"
@@ -542,7 +542,7 @@ module Relaton
542
542
  request["User-Agent"] = USER_AGENT
543
543
 
544
544
  response = http.request(request)
545
- json = JSON.parse(response.body)
545
+ json = Relaton::Json.parse_external(response.body)
546
546
  json["Data"] || []
547
547
  end
548
548
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  require "cgi"
4
- require "json"
5
5
  require_relative "hit"
6
6
 
7
7
  module Relaton
@@ -239,8 +239,8 @@ module Relaton
239
239
  # @return [Array<Hash>] editions of the recommendation
240
240
  def editions(idrec)
241
241
  # a `null` body parses to nil, so `|| []` is not redundant
242
- JSON.parse(agent.get(format(RECEDITIONS_URL, idrec: idrec)).body) || []
243
- rescue JSON::ParserError
242
+ Relaton::Json.parse_external(agent.get(format(RECEDITIONS_URL, idrec: idrec)).body) || []
243
+ rescue ::JSON::ParserError
244
244
  []
245
245
  end
246
246
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+
4
4
  require_relative "family_cache"
5
5
 
6
6
  module Relaton
@@ -251,7 +251,7 @@ module Relaton
251
251
  end
252
252
 
253
253
  def get_data(url)
254
- JSON.parse request_document(url).body
254
+ Relaton::Json.parse_external request_document(url).body
255
255
  end
256
256
 
257
257
  def request_document(url)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  require_relative "../jis"
4
5
  require_relative "scraper"
5
6
 
@@ -92,7 +93,7 @@ module Relaton
92
93
  body = { record: 0, dantai: "JIS", searchtype2: 1,
93
94
  status_1: 1, status_2: 2 } # rubocop:disable Naming/VariableNumber
94
95
  resp = agent.post "#{URL}W11M0270/index", body
95
- disp = JSON.parse resp.body
96
+ disp = Relaton::Json.parse_external resp.body
96
97
  @initial_time = Time.now
97
98
  disp["status"] || Util.warn("No results found for JIS")
98
99
  end
@@ -0,0 +1,21 @@
1
+ require "yeptris"
2
+
3
+ module Relaton
4
+ # JSON at the system boundary — data we do not control. Everything we
5
+ # generate or own (index manifests, shards, the db cache, request bodies,
6
+ # the schema-version table) parses with Yeptris::JSON's default strict
7
+ # surface.
8
+ module Json
9
+ module_function
10
+
11
+ # Third-party feeds carry duplicate keys in the wild (Crossref book
12
+ # records keyed by ISBN, NIST's scraped pubs export keyed by "&nbsp",
13
+ # the UN search API's blank keys, CCSDS's embedded config); parsing them
14
+ # the historical way is last-wins. yeptris 0.6.51 carries the boundary
15
+ # opt-out (yeptris-ruby#335/#339), so the whole stack stays on yeptris —
16
+ # no stdlib fallback anywhere.
17
+ def parse_external(source)
18
+ ::Yeptris::JSON.load(source, allow_duplicate_key: true)
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "net/http"
2
- require "json"
3
3
 
4
4
  module Relaton
5
5
  module Logger
@@ -70,7 +70,7 @@ module Relaton
70
70
  responce = get(uri)
71
71
  return nil unless responce.code.to_i == 200
72
72
 
73
- items = JSON.parse(responce.body)["items"] || []
73
+ items = Yeptris::JSON.load(responce.body)["items"] || []
74
74
  items.find { |i| i["title"] == @title }&.fetch("number", nil)
75
75
  rescue StandardError => e
76
76
  puts "Failed to search for an open `#{@title}` issue: #{e.message}"
@@ -88,7 +88,7 @@ module Relaton
88
88
  http.use_ssl = true
89
89
 
90
90
  request = Net::HTTP::Post.new(uri.request_uri, headers)
91
- request.body = body.to_json
91
+ request.body = Yeptris::JSON.dump(body)
92
92
 
93
93
  http.request(request)
94
94
  end
@@ -1,8 +1,9 @@
1
+ require "yeptris"
1
2
  module Relaton::Logger
2
3
  class FormatterJSON < ::Logger::Formatter
3
4
  def call(severity, _datetime, progname, msg, **args)
4
5
  hash = { prog: progname, message: msg, severity: severity }.merge(args)
5
- "#{hash.to_json}\n"
6
+ Yeptris::JSON.dump(hash) + "\n"
6
7
  end
7
8
  end
8
9
  end
@@ -64,6 +64,8 @@ All data models inherit from `Lutaml::Model::Serializable` and use declarative a
64
64
  - **Per-record resilience (mirrors the ISO flavor).** A single bad record must never abort the crawl. `#write_file` skips (never `id.content`-crashes on) a record whose MODS entry yields **no docidentifier** (e.g. a DOI stored only in `<identifier type="doi">`, which `ModsParser#parse_doi` doesn't read), and records ids that `#pubid` can't parse (new series like `NIST RB`/`NIST CHIPS`) as "not indexed" — the YAML is still written, only the index-v2 entry is skipped. Both classes accumulate in `#failures`; the `#report_errors` override emits each at `:error` (via `#log_error` → `Util.error`), which the shared `Core::DataFetcher` gh_issue machinery turns into one "Error fetching documents" GitHub issue in CI (`GITHUB_REPOSITORY` + `GITHUB_TOKEN`). `#pubid` no longer warns per-failure; reporting is centralized in `#report_errors`.
65
65
  - **Draft-stage rows come from CSRC (relaton#313).** The MODS collection has no draft records, so `#fetch_csrc` adds the CSRC pubs-export feed (`PubsExport.instance.data`) after the MODS pass. It builds each row **exactly as the runtime builds a CSRC hit** — `HitCollection#hit_data` (which calls `#pubs_export_id`) → `Scraper.parse_page` — so the crawled id and the live id are one string, with the stage, revision and update from the row's uri and DOI (`NIST FIPS 197/Upd1 ipd`, `NIST SP 800-108r1 ipd`, `NIST SP 1800-12 2pd`). Only a row whose id carries a **stage** is written; finals come from MODS, so a CSRC row never shadows a base document. On the 914-row fixture feed this writes 302 staged records. Don't go back to a hand-built `"NIST #{series} #{docnumber} #{stage}"` id: it dropped the numbered drafts (`2pd`…), the `/UpdN`, and spelled revisions `Rev. 1`. A stage that CSRC does not list (e.g. `NIST FIPS 197 fpd`) still answers through the consumer's #303 family fallback. Three more rules: `#csrc_bib` clears the `fetched` date the runtime stamps, or every draft changes each day and `Core#force_write` rewrites ~300 files per incremental crawl; CSRC lists a republished draft twice (`…/ipd` and `…/ipd-(1)`, one DOI, so one id), so each id is written once and the plain uri wins; and only the build step is rescued (to a `#failures` entry) — a write error stops the crawl, as in the MODS pass. `#pubs_export_id` must not mutate its row (`sub`, not `sub!`): the crawler and the runtime share the `PubsExport` data.
66
66
  - **A CSRC relation names the related document (relaton#314).** `Scraper#related_id` finds the related feed row by its uri and derives its id with `HitCollection.pubs_export_id` (the class method the instance method delegates to). The entry's own `docidentifier` drops the update and the part, and the hit's code is the record's own id, so neither is the related id. `HitCollection#search_filter` ends in an `ensure` that returns `arr`: every branch must set `arr`, because an earlier `return` is overridden there.
67
+ - **CSWP comes from CSRC, not the NIST Library (relaton-nist#104).** The NIST Library announced CSWP data, but its MODS release (`allrecords-MODS.xml`, and the MARC `allrecords.xml`) holds **no** CSWP record — 0 on the Sept2026 release. It publishes CSWPs only as RIS/BibTeX files under `bib/` on its `nist-pages` branch. So the index has only the CSRC draft CSWP rows (`#fetch_csrc`), and a final CSWP answers from CSRC at runtime: `HitCollection#search` asks the CSRC feed (`from_json`) and asks the index (`from_ga`) only when CSRC gives no hit. That is the chosen rule — CSRC wins, no merge — because CSRC lists every numbered CSWP with more metadata (affiliations, status, supersedes, comment period). `hit_collection_spec.rb` pins the CSRC answer.
68
+ - **Legacy dated CSWP ids and CSWP translations come from a static table (relaton#406).** CSRC lists neither the ~30 **legacy dated ids** (`NIST CSWP 01142020`, the same paper as `CSWP 9`, from before NIST numbered the series) nor the translations (`NIST.CSWP.6.spa`). `cswp.yaml` holds both, built once from the 84 RIS files (header of the file says how), and `Cswp` reads it. `Bibliography.get` asks `Cswp.lookup` **first** — the parenthetical-date regex after it would strip `(spa)`. A legacy id fetches the numbered id through the normal CSRC path and adds the dated id as a non-primary docidentifier (`06222021-draft` → the CSRC `CSWP 19 ipd`); a translation, dated or numbered, answers an offline record from the table (`NIST CSWP 6(spa)`, the RIS title/authors/year/DOI, `language`/`script` from `Cswp::LANGUAGES`, a `translatedFrom` relation to the original). `normalize` reads `NIST CSWP 6 spa`, `NIST CSWP 6(spa)` and `NIST.CSWP.6.spa` as one key. `Processor#cache_pubid` returns nil (so `Db` hands `get` the String and does not cache) for **every legacy id** and for a translation that pubid does not render back. A legacy answer is the numbered record plus the dated id, and `Db` would store it under the numbered id's row, so a later `NIST CSWP 9` would carry `NIST CSWP 01142020`. pubid reads `04162018ar` as `04162018a`, `6 spa` as `6(spa)`, and fails on `04162018bg`/`02122014pl`. A translation answers only for its own RIS year. Not mapped, on purpose: `02042022-1`…`-5` have no SN and share the title "Cybersecurity White Paper: EO Response", so they still answer not-found. The CSRC `ipd` rows of `27`/`29`/`32` already answer, so the table leaves out their RIS files. A new translation means a new table row.
67
69
  - **`#write_file` serializes before it indexes**, so a record that fails to serialize leaves no index row naming a file that was never written. That is how a `Bib::Date` in `Scraper.fetch_dates` hid: `Nist::ItemData` takes `Nist::Date`, and YAML serialization rejects the parent class (the same strictness as the docidentifier note below).
68
70
  - `ModsParser` — maps MODS XML (via `loc_mods`) to `ItemData`
69
71
  - `Scraper` — fetches items from GitHub YAML or CSRC JSON
@@ -118,6 +120,11 @@ NIST-specific gotchas (why this isn't a straight copy of IEC/CCSDS):
118
120
  `@raw_content` before wrapping `@pubid` in pubid's `AllParts` — matching
119
121
  IEC/CCSDS. `#pubid` itself, read directly, now answers `all_parts? ==
120
122
  true` and renders WITH pubid's generic "(all parts)" marker.
123
+ - **The internal form drops `NIST `/`NBS `** (relaton#49), so
124
+ `docid.to_s(form: :internal)` is `SP 800-113` for `NIST SP 800-113`. The rule
125
+ is the `"NIST"` row of `Bib::Docidentifier::FORM_PUBID`: `@pubid`
126
+ renders with `to_s(with_publisher: false)`. A DOI docid has type `DOI` and is
127
+ unchanged. `to_s` itself is inherited from `Bib::Docidentifier`.
121
128
 
122
129
  ### Serialization Round-Trip Pattern
123
130
 
@@ -39,6 +39,9 @@ module Relaton
39
39
  # read back as its printed form; the pubid itself is not changed.
40
40
  ref = ref.to_s unless ref.is_a?(String)
41
41
  return fetch_ref_err(ref, year, []) if ref.match?(/\sEP$/)
42
+ # Before the parsing below, which reads "(spa)" as a date.
43
+ cswp = Cswp.lookup(ref)
44
+ return cswp_get(ref, cswp, year, opts) if cswp
42
45
 
43
46
  /^(?<code2>[^(]+)(?:\((?<date2>\w+\s(?:\d{2},\s)?\d{4})\))?\s?\(?(?:(?<=\()(?<stage>(?:I|F|\d)PD))?/ =~ ref
44
47
  stage ||= /(?<=\.)PD-\w+(?=\.)/.match(ref)&.to_s
@@ -62,6 +65,34 @@ module Relaton
62
65
 
63
66
  private
64
67
 
68
+ #
69
+ # Get a CSWP that CSRC does not list (relaton#406). A legacy dated id
70
+ # answers the numbered CSRC record, with the dated id as an extra
71
+ # docidentifier; a translation answers its record from the RIS table.
72
+ #
73
+ # @param ref [String] reference
74
+ # @param cswp [Array] the Cswp.lookup result
75
+ # @param year [String, nil] year
76
+ # @param opts [Hash] options
77
+ #
78
+ # @return [Relaton::Nist::ItemData, nil] bibliographic item
79
+ #
80
+ def cswp_get(ref, cswp, year, opts)
81
+ type, id, dated = cswp
82
+ if type == :translation
83
+ return fetch_ref_err(ref, year, [id[:year]]) if year && year.to_s != id[:year]
84
+
85
+ item = Cswp.translation_item(id)
86
+ Util.info "Found: `#{item.docidentifier.first.content}`", key: ref
87
+ return item
88
+ end
89
+
90
+ Util.info "Legacy dated CSWP id, fetching `#{id}`", key: ref
91
+ item = nistbib_get(id, year, opts)
92
+ item&.docidentifier&.<< Docidentifier.new(content: dated, type: "NIST")
93
+ item
94
+ end
95
+
65
96
  #
66
97
  # Get NIST document by reference
67
98
  #
@@ -0,0 +1,137 @@
1
+ require "yaml"
2
+
3
+ module Relaton
4
+ module Nist
5
+ #
6
+ # CSWP ids the CSRC pubs-export feed does not list (relaton#406): the
7
+ # legacy dated ids from before the series was numbered, and the
8
+ # translations. The table (`cswp.yaml`) is built from the NIST Library RIS
9
+ # files; see the header of that file.
10
+ #
11
+ # This file loads no flavor model at require time, so the processor can
12
+ # ask #lookup on the cold path. #translation_item needs the models.
13
+ #
14
+ module Cswp
15
+ # ISO 639-2 code of a translation => [ISO 639-1 code, ISO 15924 script]
16
+ LANGUAGES = {
17
+ "ara" => %w[ar Arab], "bul" => %w[bg Cyrl], "fre" => %w[fr Latn],
18
+ "gre" => %w[el Grek], "ind" => %w[id Latn], "jpn" => %w[ja Jpan],
19
+ "kor" => %w[ko Kore], "may" => %w[ms Latn], "nor" => %w[no Latn],
20
+ "pol" => %w[pl Latn], "por" => %w[pt Latn], "spa" => %w[es Latn],
21
+ "ukr" => %w[uk Cyrl]
22
+ }.freeze
23
+
24
+ class << self
25
+ # @return [Hash] the table as written in cswp.yaml
26
+ def table
27
+ @table ||= YAML.load_file File.expand_path("cswp.yaml", __dir__)
28
+ end
29
+
30
+ #
31
+ # The comparable form of a CSWP reference: no publisher, the
32
+ # separators (".", "(...)") read as spaces, upper case.
33
+ # "NIST CSWP 6(spa)", "NIST.CSWP.6.spa" and "NIST CSWP 6 spa" all
34
+ # give "CSWP 6 SPA".
35
+ #
36
+ # @param ref [String]
37
+ # @return [String]
38
+ def normalize(ref)
39
+ ref.to_s.sub(/\ANIST[.\s]+/i, "").gsub(/[().]/, " ").squeeze(" ").strip.upcase
40
+ end
41
+
42
+ #
43
+ # Find a reference in the table.
44
+ #
45
+ # @param ref [String]
46
+ # @return [Array, nil] `[:legacy, numbered id, dated id]` for a
47
+ # legacy id of an English CSWP, `[:translation, entry]` for a
48
+ # translation (dated or numbered), nil otherwise
49
+ def lookup(ref)
50
+ key = normalize ref
51
+ return unless key.start_with? "CSWP "
52
+
53
+ key = key.delete_prefix "CSWP "
54
+ dated, target = legacy[key]
55
+ entry = translations[(target || key).upcase]
56
+ return [:translation, entry] if entry
57
+
58
+ [:legacy, "NIST CSWP #{target}", "NIST CSWP #{dated}"] if target
59
+ end
60
+
61
+ #
62
+ # Build the record of a translation from its RIS fields.
63
+ #
64
+ # @param entry [Hash] a translation entry from #lookup
65
+ # @return [Relaton::Nist::ItemData]
66
+ def translation_item(entry) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
67
+ number, lang_code = entry[:id].split
68
+ language, script = LANGUAGES.fetch lang_code
69
+ id = "NIST CSWP #{number}(#{lang_code})"
70
+ ItemData.new(
71
+ type: "standard",
72
+ docidentifier: [
73
+ Docidentifier.new(content: id, type: "NIST", primary: true),
74
+ Docidentifier.new(content: entry[:doi].split("/").last, type: "DOI"),
75
+ ],
76
+ title: [Bib::Title.new(content: entry[:title], language: "en", script: "Latn")],
77
+ source: [Bib::Uri.new(type: "doi", content: "https://doi.org/#{entry[:doi]}")],
78
+ date: [Date.new(type: "published", at: entry[:year])],
79
+ contributor: entry[:authors].map { |name| author name } << publisher,
80
+ language: [language],
81
+ script: [script],
82
+ relation: [original_relation("NIST CSWP #{number}")],
83
+ place: [Bib::Place.new(city: "Gaithersburg", region: [Bib::Place::RegionType.new(iso: "MD")])],
84
+ ext: Ext.new(doctype: Doctype.new(content: "standard"), flavor: "nist"),
85
+ )
86
+ end
87
+
88
+ private
89
+
90
+ # dated id (upper case) => [dated id, numbered id]
91
+ def legacy
92
+ @legacy ||= table["legacy"].to_h { |dated, numbered| [dated.upcase, [dated, numbered]] }
93
+ end
94
+
95
+ # translation id (upper case) => entry
96
+ def translations
97
+ @translations ||= table["translations"].to_h do |id, fields|
98
+ [id.upcase, fields.transform_keys(&:to_sym).merge(id: id, authors: fields["authors"] || [])]
99
+ end
100
+ end
101
+
102
+ # @param name [String] RIS author, "Surname, Forename"
103
+ # @return [Bib::Contributor]
104
+ def author(name)
105
+ surname, forename = name.split(/,\s*/, 2)
106
+ fullname = Bib::FullName.new(
107
+ surname: Bib::LocalizedString.new(content: surname, language: "en", script: "Latn"),
108
+ forename: [Bib::FullNameType::Forename.new(content: forename, language: "en", script: "Latn")],
109
+ )
110
+ Bib::Contributor.new(
111
+ person: Bib::Person.new(name: fullname),
112
+ role: [Bib::Contributor::Role.new(type: "author")],
113
+ )
114
+ end
115
+
116
+ # @return [Bib::Contributor]
117
+ def publisher
118
+ org = Bib::Organization.new(
119
+ name: [Bib::TypedLocalizedString.new(content: "National Institute of Standards and Technology")],
120
+ abbreviation: Bib::LocalizedString.new(content: "NIST"),
121
+ )
122
+ Bib::Contributor.new(organization: org, role: [Bib::Contributor::Role.new(type: "publisher")])
123
+ end
124
+
125
+ # @param id [String] the original's id
126
+ # @return [Relaton::Nist::Relation]
127
+ def original_relation(id)
128
+ bib = ItemData.new(
129
+ formattedref: Bib::Formattedref.new(content: id),
130
+ docidentifier: [Docidentifier.new(content: id, type: "NIST", primary: true)],
131
+ )
132
+ Relation.new(type: "translatedFrom", bibitem: bib)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end