pubid 2.0.20 → 2.0.22

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +73 -31
  3. data/data/iec/legacy-notation.md +298 -0
  4. data/data/iho/s4-reserved-specs.yaml +15 -0
  5. data/data/iso/historic-schemes.md +368 -0
  6. data/data/parg/bipm.json +1 -1
  7. data/data/parg/bipm.parg +46 -11
  8. data/data/parg/iec.json +1 -1
  9. data/data/parg/iho.json +1 -1
  10. data/data/parg/iho.parg +128 -11
  11. data/data/parg/iso.json +1 -1
  12. data/data/parg/iso.parg +142 -2
  13. data/data/parg/plateau.json +1 -1
  14. data/data/parg/tables/iec_typed_stages.yaml +3 -0
  15. data/data/parg/tables/typed_stages_ballots.yaml +6 -0
  16. data/data/parg/tables/typed_stages_supplements.yaml +1 -0
  17. data/lib/pubid/bipm/CLAUDE.md +6 -1
  18. data/lib/pubid/bipm/builder.rb +20 -1
  19. data/lib/pubid/bipm/renderer.rb +14 -2
  20. data/lib/pubid/bipm/urn_generator.rb +80 -14
  21. data/lib/pubid/bipm/urn_parser.rb +70 -6
  22. data/lib/pubid/ecma/CLAUDE.md +12 -0
  23. data/lib/pubid/ecma/identifier.rb +1 -2
  24. data/lib/pubid/ecma/parser.rb +49 -0
  25. data/lib/pubid/iec/CLAUDE.md +9 -1
  26. data/lib/pubid/iec/builder.rb +47 -0
  27. data/lib/pubid/iec/identifier.rb +19 -70
  28. data/lib/pubid/iec/identifiers/amendment.rb +34 -0
  29. data/lib/pubid/iec/identifiers/corrigendum.rb +6 -1
  30. data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
  31. data/lib/pubid/iec/single_identifier.rb +69 -4
  32. data/lib/pubid/iec/supplement_identifier.rb +89 -83
  33. data/lib/pubid/iho/CLAUDE.md +54 -1
  34. data/lib/pubid/iho/builder.rb +105 -2
  35. data/lib/pubid/iho/identifiers/base.rb +52 -1
  36. data/lib/pubid/iho/pubid_urn_schema.rb +12 -4
  37. data/lib/pubid/iho/renderer.rb +39 -2
  38. data/lib/pubid/iho/reserved_specs.rb +54 -0
  39. data/lib/pubid/iho/urn_generator.rb +6 -0
  40. data/lib/pubid/iho/urn_parser.rb +50 -9
  41. data/lib/pubid/iho.rb +1 -0
  42. data/lib/pubid/iso/CLAUDE.md +49 -0
  43. data/lib/pubid/iso/builder.rb +77 -0
  44. data/lib/pubid/iso/identifiers/amendment.rb +1 -1
  45. data/lib/pubid/iso/single_identifier.rb +7 -1
  46. data/lib/pubid/nist/CLAUDE.md +49 -0
  47. data/lib/pubid/nist/builder.rb +5 -0
  48. data/lib/pubid/nist/identifiers/base.rb +42 -1
  49. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +44 -5
  50. data/lib/pubid/nist/preprocessor.rb +18 -0
  51. data/lib/pubid/nist/urn_parser.rb +1 -1
  52. data/lib/pubid/plateau/CLAUDE.md +35 -0
  53. data/lib/pubid/plateau/builder.rb +4 -0
  54. data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
  55. data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
  56. data/lib/pubid/plateau/renderer.rb +6 -1
  57. data/lib/pubid/sae/CLAUDE.md +11 -2
  58. data/lib/pubid/version.rb +1 -1
  59. metadata +7 -2
@@ -31,6 +31,7 @@ module Pubid
31
31
  # - urn:iho:m:2:sup-1 IHO M-2 Sup 1
32
32
  # - urn:iho:p:5:vol-2024 IHO P-5 Vol. 2024
33
33
  # - urn:iho:p:1:vol-30:issue-2 IHO P-1 Vol. 30 (2)
34
+ # - urn:iho:s:4:ed-4.10.0:part-B:spec-401 IHO S-4 4.10.0 B-401
34
35
  class UrnGenerator
35
36
  attr_reader :identifier
36
37
 
@@ -49,6 +50,11 @@ module Pubid
49
50
  [:supplement, "sup"], [:supplement_edition, "ed"],
50
51
  [:supplement_year, "year"],
51
52
  [:part, "part"], [:part_edition, "ed"], [:part_year, "year"],
53
+ # the S-4 spec citation: a child of its part (the citation
54
+ # letter rides the part- atom), the dotted sub and the
55
+ # parenthesized variant its own children. The SECTION is NOT
56
+ # emitted — derivable from the spec's hundreds digit.
57
+ [:spec, "spec"], [:spec_sub, "specsub"], [:spec_variant, "specvar"],
52
58
  [:annex, "annex"], [:annex_edition, "ed"], [:annex_year, "year"],
53
59
  [:appendix, "appendix"], [:appendix_subnumber, nil],
54
60
  [:appendix_edition, "ed"], [:appendix_year, "year"],
@@ -7,8 +7,8 @@ module Pubid
7
7
  # the segment table lives in the grammar header and the flavor notes):
8
8
  #
9
9
  # urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
10
- # [:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
11
- # [:year-<yyyy>]
10
+ # [:spec-<n>[:specsub-<n>][:specvar-<X>]][:annex-<A>]
11
+ # [:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>][:year-<yyyy>]
12
12
  #
13
13
  # Segment discipline (mirrors the ISO URN parser's strictness):
14
14
  # - the docnumber occupies every leading bare number segment up to
@@ -22,7 +22,11 @@ module Pubid
22
22
  # directly after its appendix segment (a year is always
23
23
  # marker-tagged, so a bare digit there can only be the subnumber);
24
24
  # - the issue is allowed only beside its volume and validated as
25
- # digits.
25
+ # digits;
26
+ # - the S-4 spec atom is a child of its part (the citation letter
27
+ # rides the part- atom): specsub-/specvar- only after spec-, and
28
+ # no ed-/year- after a spec node (the spec rides its publication's
29
+ # edition; the section is derivable, never emitted).
26
30
  #
27
31
  # Legacy 2.0.x spellings remain decodable but are never emitted:
28
32
  # the bare a.b.c edition segment, the dotted keys part./annex./
@@ -38,6 +42,7 @@ module Pubid
38
42
  # - urn:iho:p:5:vol-2024 → IHO P-5 Vol. 2024
39
43
  # - urn:iho:p:1:vol-30:issue-2 → IHO P-1 Vol. 30 (2)
40
44
  # - urn:iho:s:158:100 → IHO S-158:100
45
+ # - urn:iho:s:4:ed-4.10.0:part-B:spec-401 → IHO S-4 4.10.0 B-401
41
46
  class UrnParser < Pubid::UrnParser::Base
42
47
  TYPE_LETTERS = %w[s p m b c].freeze
43
48
 
@@ -50,6 +55,9 @@ module Pubid
50
55
  "ed" => [:edition, /\A\d+\.\d+\.\d+\z/],
51
56
  "sup" => [:supplement, /\A\d+\z/],
52
57
  "part" => [:part, /\A(?:\d+[a-zA-Z]*|[A-Z]|[SPMBC]-\d+)\z/],
58
+ "spec" => [:spec, /\A[1-6]\d\d\z/],
59
+ "specsub" => [:spec_sub, /\A\d+\z/],
60
+ "specvar" => [:spec_variant, /\A[A-Z]+\z/],
53
61
  "annex" => [:annex, /\A[A-Z]\z/],
54
62
  "appendix" => [:appendix, /\A(?:[A-Z]|\d+)\z/],
55
63
  "vol" => [:volume, /\A\d+\z/],
@@ -63,6 +71,12 @@ module Pubid
63
71
  # follow (the component-edition duality).
64
72
  POSITIONAL_MARKERS = %i[edition year].freeze
65
73
 
74
+ # The S-4 spec node and its children: specsub-/specvar- are only
75
+ # allowed after their parent, and an S-4 spec rides its
76
+ # publication's edition — no ed-/year- inside a spec.
77
+ SPEC_NODES = %i[spec spec_sub spec_variant].freeze
78
+ SPEC_PARENTS = %i[spec spec_sub].freeze
79
+
66
80
  # Legacy dotted keys (deprecated; decodable, never emitted).
67
81
  # "ap" is the pre-positional appendix tag; "suppl" the pre-positional
68
82
  # supplement tag; "y" the pre-positional year tag — under the
@@ -97,11 +111,22 @@ module Pubid
97
111
  # own edition).
98
112
  ctx = { component: nil, edition_owner: nil }
99
113
  consume_segments!(attrs, segments, ctx, urn)
100
- Builder.build({ type: type_token.upcase, **attrs })
114
+ build_identifier({ type: type_token.upcase, **attrs }, urn)
101
115
  end
102
116
 
103
117
  private
104
118
 
119
+ # The semantic validations the builder applies to the decoded shape
120
+ # (the S-4 anchor, the citation letter) are URN validation at this
121
+ # layer: a URN that asserts a citation it cannot carry is a
122
+ # malformed URN, and the raw ArgumentError stays as the cause.
123
+ def build_identifier(hash, urn)
124
+ Builder.build(hash)
125
+ rescue ArgumentError => e
126
+ raise Pubid::Errors::UrnParseError,
127
+ "#{e.message} in #{urn.inspect}"
128
+ end
129
+
105
130
  def read_type!(segments, urn)
106
131
  type_token = segments.shift.to_s.downcase
107
132
  return type_token if TYPE_LETTERS.include?(type_token)
@@ -159,10 +184,9 @@ module Pubid
159
184
  marker, value = MARKER_RE.match(segment).captures
160
185
  key, pattern = MARKERS.fetch(marker)
161
186
  raise_invalid!(marker, value, urn) unless value.match?(pattern)
162
-
163
- if POSITIONAL_MARKERS.include?(key)
164
- return consume_positional!(attrs, key, value, ctx, urn)
165
- end
187
+ assert_spec_parent!(key, ctx, urn)
188
+ return consume_positional!(attrs, key, value, ctx, urn) if
189
+ POSITIONAL_MARKERS.include?(key)
166
190
 
167
191
  store!(attrs, key, value, urn)
168
192
  ctx[:component] = key
@@ -170,6 +194,16 @@ module Pubid
170
194
  consume_subnumber!(attrs, key, segments, ctx, urn)
171
195
  end
172
196
 
197
+ # specsub-/specvar- are children of the spec atom — allowed only
198
+ # in their parent's slot (the SEGMENTS table in the generator).
199
+ def assert_spec_parent!(key, ctx, urn)
200
+ return unless %i[spec_sub spec_variant].include?(key)
201
+ return if SPEC_PARENTS.include?(ctx[:component])
202
+
203
+ raise Pubid::Errors::UrnParseError,
204
+ "#{key}- is only allowed after spec- in #{urn.inspect}"
205
+ end
206
+
173
207
  def raise_invalid!(marker, value, urn)
174
208
  raise Pubid::Errors::UrnParseError,
175
209
  "Invalid #{marker} value #{value.inspect} in #{urn.inspect}"
@@ -177,8 +211,15 @@ module Pubid
177
211
 
178
212
  # ed- and year- are POSITIONAL markers (see the marker docs): the
179
213
  # edition attaches to the component it follows, the year to the
180
- # edition owner.
214
+ # edition owner. An S-4 spec carries neither — it rides its
215
+ # publication's edition.
181
216
  def consume_positional!(attrs, key, value, ctx, urn)
217
+ if SPEC_NODES.include?(ctx[:component])
218
+ raise Pubid::Errors::UrnParseError,
219
+ "an S-4 spec rides its publication's edition: #{key}- " \
220
+ "cannot follow a spec segment in #{urn.inspect}"
221
+ end
222
+
182
223
  if key == :edition
183
224
  return store_component_scoped!(attrs, :edition, value, ctx, urn)
184
225
  end
data/lib/pubid/iho.rb CHANGED
@@ -12,6 +12,7 @@ module Pubid
12
12
  autoload :Identifier, "#{__dir__}/iho/identifiers/base"
13
13
  autoload :Identifiers, "#{__dir__}/iho/identifiers"
14
14
  autoload :Renderer, "#{__dir__}/iho/renderer"
15
+ autoload :ReservedSpecs, "#{__dir__}/iho/reserved_specs"
15
16
  autoload :UrnGenerator, "#{__dir__}/iho/urn_generator"
16
17
  autoload :UrnParser, "#{__dir__}/iho/urn_parser"
17
18
  autoload :PubidUrnSchema, "#{__dir__}/iho/pubid_urn_schema"
@@ -158,3 +158,52 @@
158
158
  another reason — `DirectivesSupplement#supplement_publisher` is an
159
159
  `Iso::Components::Publisher` after `parse` and a `Pubid::Components::Publisher`
160
160
  after `from_hash` (11 supplements, 3 bundles).
161
+
162
+ - **Historic TYPE-first ballot faces decode to the canonical on-base slash
163
+ form (owner ruling 2026-11-12, Option B — IHO-style decode-to-modern;
164
+ SSOT `data/iso/historic-schemes.md`).** The DAM-family token in TYPE
165
+ position (`ISO/IEC FDAM 1 14496-12:2004`, `ISO/PDAM 19973-2`) and the
166
+ space-attached ballot face (`ISO 15877-5:2009 FDAM 1`) used to raise; the
167
+ grammar (v0.5.0) gained two additive branches at the END of the
168
+ identifier alternation, so every face an existing member parses keeps its
169
+ exact parse (replayed: 15,307 fixture inputs and 213,414 stored
170
+ docidentifier faces byte-identical on `to_s`/`to_urn`/`to_hash`/
171
+ `to_mr_string`). What to know before editing either side:
172
+ - **The ballot token captures as `ballot_type_with_stage`** (table
173
+ `typed_stages_ballots`: PDAM, DAM, DAmd, FDAM, FDAmd, FPDAM), NOT as
174
+ `supp_type_with_stage` — a shared name would make the builder's
175
+ supplement-prefix peel wrap a second layer, while the stored twins keep
176
+ ONE level. `Builder#build_ballot_supplement` assembles the single
177
+ amendment layer and nests the rest (prefix, copublishers, the base's
178
+ own `type_with_stage` — the `TR` of `PDAM TR 9573-11` — and
179
+ `number_with_part`/`date`) as `:base`.
180
+ - **`ballot_number` is space-bounded on both sides** (mandatory trailing
181
+ space in the grammar group) so `PDAM 19973-2` cannot read 19973 as the
182
+ ballot number: with no second space the group backtracks and 19973
183
+ stays in `second_part`. This is the base-whole guard — the IEC-flavor
184
+ hazard (#693's `ensure_base`) of a base part migrating into the
185
+ supplement slot cannot happen here; the legacy slash-part of
186
+ `FPDAM 9075/1` stays a base part (`9075-1`).
187
+ - **The number precedence is TYPE token → on-base stamp → implied "1"**;
188
+ the stamp's number/date capture as `ballot_stamp_*` while its token
189
+ letters match UNCAPTURED (Option B keeps no spelling — the ballot's
190
+ stage is the TYPE token's). The implied 1 is the owner's no-number
191
+ rule, applied by `Builder#apply_implied_amendment_number` on EVERY
192
+ builder path (parse, URN, bundle members) for `Identifiers::Amendment`
193
+ with a nil number and `typed_stage.code` ∈ `{pdam, damd, fdamd, fpdam}`
194
+ — by `#code`, NOT `#stage_code` (PDAM's stage_code is `:cd`), and
195
+ `to_sym`-ed (the bound map delivers strings). The published stage
196
+ (abbr `Amd`) is out of scope: a numberless `ISO 1234/Amd` stays nil.
197
+ - **The `Am` spelling** joined `typed_stages_supplements` (after the Amd
198
+ entries) and the published stage's abbr list, so `/Am1:2006` parses as
199
+ an alias of `/Amd 1` through the EXISTING supplement branch and the
200
+ ballot stamp accepts it the same way. `detect_stage_format_long` now
201
+ treats an original spelling that is a prefix of the long abbr as the
202
+ long family, so `Am` renders canonical `Amd`; `AMD` still renders short
203
+ (facets keep spelling on render), `Amd.` still renders `Amd`.
204
+ - **The decode is one-way**: parse-in only, canonical renders emit the
205
+ on-base slash face, and a ballot face == its canonical spelling
206
+ (original_abbr is equality-ignored). Locked by
207
+ `spec/pubid/iso/ballot_face_decode_spec.rb` (all 13 SSOT anchors, the
208
+ no-number rule, base-whole guards, Am alias, stored-twin agreement, and
209
+ the website-face-vs-stored-face drift rows the SSOT §5 records).
@@ -151,6 +151,16 @@ module Pubid
151
151
  # reason). The document itself holds no all-parts mark.
152
152
  all_parts = parsed_hash.delete(:all_parts)
153
153
 
154
+ # Historic TYPE-first ballot face ("ISO/IEC FDAM 1 14496-12:2004",
155
+ # "ISO/PDAM 19973-2", "ISO 15877-5:2009 FDAM 1"): the ballot_* keys
156
+ # are one amendment layer over the whole base, never a second
157
+ # supplement layer (the stored twins keep one level), so the layer
158
+ # is assembled here, before the supplement-prefix peel could read
159
+ # the ballot token as an outer wrapper.
160
+ if parsed_hash.key?(:ballot_type_with_stage)
161
+ return build_ballot_supplement(parsed_hash, all_parts)
162
+ end
163
+
154
164
  # The copublisher tail captures as one scalar span ("/IEC/IEEE");
155
165
  # split it into the {copublisher:} element hashes the merge and
156
166
  # the :copublishers cast consume. A pre-built array (the URN
@@ -261,6 +271,10 @@ module Pubid
261
271
  identifier.type = default_typed_stage.to_type
262
272
  end
263
273
 
274
+ # The owner's no-number rule: a numberless DAM-family ballot-stage
275
+ # amendment is Amendment 1 (see the method comment).
276
+ apply_implied_amendment_number(identifier)
277
+
264
278
  all_parts ? identifier.to_all_parts : identifier
265
279
  end
266
280
 
@@ -306,6 +320,69 @@ module Pubid
306
320
  build(inner)
307
321
  end
308
322
 
323
+ # Historic TYPE-first ballot face (owner ruling 2026-11-12, Option B
324
+ # decode-to-modern): "[P|F]?DAM [N] BASE [stamp]" decodes to ONE
325
+ # amendment over the WHOLE base, rendered as the canonical on-base
326
+ # slash face. The stage comes from the ballot token; the number from
327
+ # the TYPE token, then the on-base stamp, then the owner's no-number
328
+ # rule (a ballot face with no number anywhere names Amendment 1 —
329
+ # identity-bearing, so it is set here at build time and serialized,
330
+ # not at render time); the stamp's date is the ballot supplement's
331
+ # date. The base keeps every component it carried: a legacy
332
+ # slash-part stays a part of the base number ("ISO/IEC FPDAM 9075/1"
333
+ # → base "9075-1"), and the base's own type token stays the base's
334
+ # ("ISO/IEC PDAM TR 9573-11" → base "TR 9573-11") — nothing migrates
335
+ # into the supplement slot. The stamp's token letters (Amd/Am/…) are
336
+ # decode-away: the ballot's stage is the TYPE token's.
337
+ #
338
+ # The rest of the hash — prefix, copublishers, the base's
339
+ # type_with_stage / number_with_part / date — is exactly the builder
340
+ # input of the base document, so it nests as :base and re-enters
341
+ # build.
342
+ def build_ballot_supplement(hash, all_parts)
343
+ fields = { type_with_stage: hash.fetch(:ballot_type_with_stage) }
344
+
345
+ number = hash[:ballot_number] ||
346
+ hash[:ballot_stamp_number] ||
347
+ hash[:ballot_stamp_dot_number] ||
348
+ "1"
349
+ fields[:number_with_part] = number
350
+
351
+ if (stamp_date = hash[:ballot_stamp_date])
352
+ fields[:date] = stamp_date
353
+ end
354
+ if hash[:ballot_stamp_undated_marker]
355
+ fields[:undated_marker] = hash[:ballot_stamp_undated_marker]
356
+ end
357
+
358
+ rest = hash.except(:ballot_type_with_stage, :ballot_number,
359
+ :ballot_stamp_number, :ballot_stamp_dot_number,
360
+ :ballot_stamp_date, :ballot_stamp_undated_marker)
361
+
362
+ inner = { **fields, base: rest }
363
+ inner[:all_parts] = all_parts if all_parts
364
+ build(inner)
365
+ end
366
+
367
+ # The owner's no-number rule, applied on every construction path that
368
+ # routes through this builder (parse, URN, joint/bundle members): a
369
+ # DAM-family ballot-stage amendment token with no number anywhere
370
+ # names Amendment 1 ("ISO 19973-2/PDAM", "ISO/IEC PDAM 12207"). The
371
+ # number is identity-bearing, so the default lands at build time and
372
+ # to_hash serializes it — parse and from_hash(to_hash) agree because
373
+ # both carry the filled number. The ballot stages are matched by
374
+ # TypedStage#code (the per-typed-stage key) — PDAM's #stage_code is
375
+ # :cd, the harmonized range it lives in.
376
+ BALLOT_STAGE_CODES = %i[pdam damd fdamd fpdam].freeze
377
+
378
+ def apply_implied_amendment_number(identifier)
379
+ return unless identifier.is_a?(Identifiers::Amendment)
380
+ return unless identifier.number.nil?
381
+ return unless BALLOT_STAGE_CODES.include?(identifier.typed_stage&.code&.to_sym)
382
+
383
+ identifier.number = "1"
384
+ end
385
+
309
386
  def build_directive_supplement(hash, all_parts)
310
387
  fields = rekey_prefix(hash.slice(*DSUPP_KEYS), "dsupp_")
311
388
  rest = hash.except(*DSUPP_KEYS)
@@ -112,7 +112,7 @@ module Pubid
112
112
  ),
113
113
  ::Pubid::Components::TypedStage.new(
114
114
  code: :published,
115
- abbr: ["Amd", "AMD", "Amd."],
115
+ abbr: ["Amd", "AMD", "Amd.", "Am"],
116
116
  short_abbr: "AMD",
117
117
  long_abbr: "Amd",
118
118
  type_code: :amd,
@@ -36,7 +36,13 @@ module Pubid
36
36
  return false unless typed_stage
37
37
 
38
38
  ts = typed_stage
39
- if ts.long_abbr && ts.original_abbr&.start_with?(ts.long_abbr)
39
+ original = ts.original_abbr
40
+ if ts.long_abbr && original && (original.start_with?(ts.long_abbr) ||
41
+ ts.long_abbr.start_with?(original))
42
+ # The long family: the canonical long spelling, its punctuated
43
+ # forms ("Amd." of "Amd"), and shorter long-family spellings
44
+ # ("Am" — the alias of "Amd" the owner ruled renders canonical).
45
+ # The short family ("AMD") matches neither direction.
40
46
  true
41
47
  elsif ts.long_abbr && ts.original_abbr&.include?("Directives")
42
48
  true
@@ -135,3 +135,52 @@ Read this before you change `lib/pubid/nist/` or `spec/pubid/nist/`. The root
135
135
  `spec/pubid/nist/bare_series_reference_spec.rb`; the corpus round froze
136
136
  the six rows (`NBS CS`, `NBS CSM`, `NBS CIS`, `NBS CS-E`, `NIST PS`,
137
137
  `NIST DCI`) into the testsuite with the gem twin in lockstep.
138
+
139
+ - **"FIPS PUB <number>" is the official RENDER face; the preprocessor
140
+ alias exists so the spellings parse (pubid#697; owner ruling
141
+ 2026-11-12).** FIPS is government-published and "PUB" (Federal
142
+ Information Processing Standards PUBlication) is part of the official
143
+ designation, so
144
+ `FederalInformationProcessingStandards#fips_render_lead` renders the
145
+ spelled-NIST-publisher rows as "FIPS PUB <number>" with the tails
146
+ riding the lead; the lead reads `printed_publisher`, so the
147
+ series-default NIST a bare parse fills (#704) is not a printed
148
+ publisher and the bare face stays bare. The grammar reads "NIST FIPS
149
+ <number>", so `normalize_fips_pub_alias!` (head-anchored, immediately
150
+ after the publisher/series stage) folds every printed spelling —
151
+ "FIPS PUB 205" and the publisher-prefixed "NIST FIPS PUB 205"
152
+ (pubid#702) — onto that canonical model, and all the spellings
153
+ converge on one identity: hash-, URN- and MR-identical, and one
154
+ render. The hyphenated numbers and the revision forms the grammar
155
+ models ("FIPS PUB 199 Rev. 1" with its `original_prefix` facet,
156
+ compact "rev1") ride the prefix alias; bare "FIPS <number>"
157
+ (publisher_was_parsed: false), the historical NBS-prefixed rows, and
158
+ the MR face ("NIST.FIPS.197", an identity surface) are untouched.
159
+
160
+ - **A bare "FIPS <number>" carries the series' NIST publisher (hand-off
161
+ nist-bare-fips-publisher, metanorma-pdfa#114).** relaton keeps a CSRC hit
162
+ only when the parsed citation is `==` to the row, and every row is
163
+ "NIST FIPS …", so a bare "FIPS 197" (publisher nil) answered "Not found".
164
+ The Builder now fills `identifier.class.series_default_publisher`
165
+ ("NIST" on `FederalInformationProcessingStandards`, nil on the base)
166
+ **after** it records `publisher_was_parsed: false`. The flag is
167
+ `equality_ignored`, so the two spellings are `==`; the short and MR faces
168
+ print the publisher only when it was spelled (`printed_publisher`), so the
169
+ bare face still renders `FIPS 197` / `FIPS.197`. The long and abbreviated
170
+ styles now name NIST (before, they began with a stray space). "NBS FIPS"
171
+ keeps its parsed NBS and is not `==` to the bare form. **NBS-era ruling**:
172
+ `#matches?` treats a publisher the query did not spell as a wildcard (and
173
+ no longer compares `publisher_was_parsed`, a spelling), so the query
174
+ "FIPS 46" still finds "NBS FIPS 46" — before this, a bare query matched
175
+ neither the NIST nor the NBS row. `===` needs the same rule
176
+ (`subset_skipped_attributes`), because the filled publisher is no longer
177
+ the nil the subset match read as "anything"; **the two primitives must
178
+ agree, so change them together.** `to_hash` of a bare parse gains
179
+ `publisher: NIST`; the 21,365-id `full/` corpus has no bare FIPS row, so
180
+ no fixture output moved. `from_hash` fills the default by the same rule
181
+ (`apply_series_default_publisher`, shared with the Builder), so a row
182
+ stored before this change (`publisher_was_parsed: false`, no publisher)
183
+ is still `==` to the parse. Not covered: `new(series:, number:)` fills
184
+ nothing (its flag defaults to true, so it means a spelled form anyway).
185
+ Locked by
186
+ `spec/pubid/nist/bare_fips_publisher_spec.rb`.
@@ -422,6 +422,11 @@ module Pubid
422
422
  # to_hash) when one was. Keeps the common publisher-bearing id lean.
423
423
  identifier.publisher_was_parsed = false unless identifier.publisher
424
424
 
425
+ # A series that implies its publisher (bare "FIPS 197" is
426
+ # "NIST FIPS 197") gets it now, after the flag above, so the two
427
+ # spellings are == while the bare face keeps rendering bare.
428
+ identifier.apply_series_default_publisher
429
+
425
430
  # NEW: Convert revision with month+year to update component (V1 compatibility)
426
431
  # Patterns like "NIST IR 4743rJun1992" should be rendered as "NIST IR 4743/Upd1-199206"
427
432
  if parsed_hash[:revision_month] && parsed_hash[:revision_year]
@@ -39,6 +39,10 @@ module Pubid
39
39
  id = super(normalize_deserialized_shapes(expand_compact_hash(data)),
40
40
  options)
41
41
  derive_subseries(id)
42
+ # A bare-FIPS row written before the series default existed holds
43
+ # `publisher_was_parsed: false` and no publisher; parse now fills
44
+ # it, so from_hash must too, or the row is no longer == the parse.
45
+ id.apply_series_default_publisher if id.is_a?(Identifier)
42
46
  id
43
47
  end
44
48
 
@@ -291,6 +295,29 @@ module Pubid
291
295
  super + %i[update update_component]
292
296
  end
293
297
 
298
+ # The publisher a series implies when the input spells none. The
299
+ # Builder fills it after it records `publisher_was_parsed: false`, so
300
+ # the bare face still renders without the prefix. nil: no default.
301
+ def self.series_default_publisher
302
+ nil
303
+ end
304
+
305
+ # Fill the series default publisher on an id whose input spelled
306
+ # none. Shared by the Builder and from_hash, so both construction
307
+ # paths agree.
308
+ def apply_series_default_publisher
309
+ return if publisher || publisher_was_parsed
310
+
311
+ self.publisher = self.class.series_default_publisher
312
+ end
313
+
314
+ # `===` (subset match) follows #matches?: a publisher the reference
315
+ # did not spell is a wildcard, so bare "FIPS 46" finds "NBS FIPS 46".
316
+ def subset_skipped_attributes
317
+ skipped = super
318
+ publisher_was_parsed ? skipped : skipped | [:publisher]
319
+ end
320
+
294
321
  # Wildcard / partial-identifier match. Treats +self+ as a QUERY pattern
295
322
  # and +candidate+ as a concrete document: every ID part SET on the query
296
323
  # must equal the candidate's, while parts left unset (nil/empty) are
@@ -301,11 +328,16 @@ module Pubid
301
328
  # Asymmetric (unlike ==): "NBS CIRC 25".matches?("NBS CIRC 25sup1924")
302
329
  # is true, but not the reverse. The candidate must be the same class or
303
330
  # a subclass so series-level identity still holds.
331
+ #
332
+ # `publisher_was_parsed` records only how the query was spelled, so it
333
+ # is never a criterion; and a publisher the query did not spell (the
334
+ # series default a bare "FIPS 46" fills in) is a wildcard, so the bare
335
+ # query still finds the NBS-era "NBS FIPS 46".
304
336
  def matches?(candidate)
305
337
  return false unless candidate.is_a?(self.class)
306
338
 
307
339
  self.class.attributes.each_key.all? do |name|
308
- next true if EQUALITY_IGNORED_ATTRS.include?(name)
340
+ next true unless match_criterion?(name)
309
341
 
310
342
  query_val = public_send(name)
311
343
  next true if query_val.nil?
@@ -316,6 +348,15 @@ module Pubid
316
348
  end
317
349
  end
318
350
 
351
+ # Whether #matches? compares attribute +name+ at all.
352
+ def match_criterion?(name)
353
+ return false if EQUALITY_IGNORED_ATTRS.include?(name)
354
+ return false if name == :publisher_was_parsed
355
+
356
+ name != :publisher || publisher_was_parsed
357
+ end
358
+ private :match_criterion?
359
+
319
360
  # NIST inherits Pubid::Identifier#exclude directly: it now rebuilds via
320
361
  # `self.class.new(**attrs)`, so NIST's keyword-only initialize
321
362
  # (initialize(**attributes)) works without a local override, and the base
@@ -6,7 +6,9 @@ module Pubid
6
6
  # NIST Federal Information Processing Standards (FIPS)
7
7
  # Examples:
8
8
  # - "FIPS 140-3" (no NIST prefix)
9
- # - "NIST FIPS 140-3" (also accepted, normalizes to FIPS 140-3)
9
+ # - "NIST FIPS 140-3" / "FIPS PUB 140-3" (also accepted; both parse
10
+ # to the same identity, and the NIST-published face renders as
11
+ # "FIPS PUB 140-3")
10
12
  class FederalInformationProcessingStandards < Identifier
11
13
  TYPED_STAGES = [
12
14
  Pubid::Components::TypedStage.new(
@@ -21,6 +23,13 @@ module Pubid
21
23
  TYPED_STAGES
22
24
  end
23
25
 
26
+ # Bare "FIPS 197" names the NIST document (NIST/CSRC list every
27
+ # FIPS, NBS-era ones included, as "NIST FIPS"). A parsed "NBS"
28
+ # stays NBS.
29
+ def series_default_publisher
30
+ "NIST"
31
+ end
32
+
24
33
  def type
25
34
  { key: :fips,
26
35
  web: :federal_information_processing_standards, title: "Federal Information Processing Standards",
@@ -41,8 +50,10 @@ module Pubid
41
50
  def to_mr_style
42
51
  # "NIST.FIPS.197" keeps a parsed publisher (the DOI suffix NIST
43
52
  # publishes); "FIPS.46e1977" has none and invents none.
53
+ # A bare "FIPS 197" carries the series default publisher NIST but
54
+ # did not spell it, so it prints no prefix either.
44
55
  # Note: edition is appended directly to number without dot for FIPS
45
- result = [publisher, series_code].compact.join(".")
56
+ result = [printed_publisher, series_code].compact.join(".")
46
57
  result += ".#{number}" if number
47
58
 
48
59
  # FIPS uses dash notation for parts; render here and skip the generic
@@ -58,10 +69,30 @@ module Pubid
58
69
 
59
70
  public
60
71
 
72
+ # The short-face lead: the official printed "FIPS PUB" for a spelled
73
+ # NIST publisher (owner ruling 2026-11-12), the spelled historical
74
+ # publisher for NBS rows, and the bare series code when the input
75
+ # spelled no publisher — the series-default NIST a bare parse fills
76
+ # (#704) is not a printed publisher, so the bare face stays bare.
77
+ def fips_render_lead
78
+ lead_publisher = printed_publisher
79
+ return "FIPS PUB" if lead_publisher == "NIST"
80
+
81
+ spelled = lead_publisher && !lead_publisher.empty?
82
+ return "#{lead_publisher} #{series_code}" if spelled
83
+
84
+ series_code.to_s
85
+ end
86
+
61
87
  def to_short_style
62
- # FIPS format: "FIPS 14e1971" or "NIST FIPS 140-3" (preserve publisher if set)
63
- result = publisher ? "#{publisher} " : ""
64
- result += series_code
88
+ # The official published face is "FIPS PUB <number>": FIPS is
89
+ # government-published, and "PUB" (Federal Information Processing
90
+ # Standards PUBlication) is part of the official designation
91
+ # (owner ruling 2026-11-12). The tail (volume/part/edition/
92
+ # supplement/update/draft) rides the lead unchanged, and the MR
93
+ # face ("NIST.FIPS.197") is an identity surface that does not
94
+ # move.
95
+ result = fips_render_lead
65
96
 
66
97
  if number
67
98
  # Convert dash-year patterns to edition format for FIPS
@@ -90,6 +121,14 @@ module Pubid
90
121
 
91
122
  result
92
123
  end
124
+
125
+ private
126
+
127
+ # The publisher the short and MR faces print: only one the input
128
+ # spelled, never the series default.
129
+ def printed_publisher
130
+ publisher if publisher_was_parsed
131
+ end
93
132
  end
94
133
  end
95
134
  end
@@ -68,6 +68,7 @@ module Pubid
68
68
  end
69
69
  normalize_spurious_u_suffix!
70
70
  normalize_publisher_and_series!
71
+ normalize_fips_pub_alias!
71
72
  normalize_lcirc_supplement_contexts!
72
73
  normalize_revision_spacing!
73
74
  normalize_letter_suffix_casing!
@@ -124,6 +125,23 @@ module Pubid
124
125
  @cleaned = @cleaned.gsub(/\bLC\b(?!IRC)/, "LCIRC")
125
126
  end
126
127
 
128
+ # The FIPS series' official printed face: FIPS is government-published
129
+ # and "PUB" (Federal Information Processing Standards PUBlication) is
130
+ # part of the official designation, so the RENDERER emits
131
+ # "FIPS PUB <number>" directly (owner ruling 2026-11-12). The grammar
132
+ # itself reads the publisher-bearing spelling only through this alias
133
+ # (pubid#697): it folds the printed spellings — "FIPS PUB 205" and the
134
+ # publisher-prefixed "NIST FIPS PUB 205" (pubid#702/#703) — onto the
135
+ # one model "NIST FIPS <number>" the grammar already builds (same
136
+ # to_hash, same URN, same MR string; revision spellings like
137
+ # "FIPS PUB 199 Rev. 1" included), while the renderer owns the printed
138
+ # face the fold converges to. Anchored at the head and run before
139
+ # every other stage so later digit/marker stages see the canonical
140
+ # spelling; "FIPS" alone (publisher_was_parsed: false) is untouched.
141
+ def normalize_fips_pub_alias!
142
+ @cleaned = @cleaned.sub(/\A(?:NIST )?FIPS PUB\b/i, "NIST FIPS")
143
+ end
144
+
127
145
  # LCIRC series: combine with NBS prefix when a supplement marker
128
146
  # follows, and convert MR-format dots to spaces so the grammar's
129
147
  # circ_supplement_identifier rule can match.
@@ -14,7 +14,7 @@ module Pubid
14
14
  # Examples:
15
15
  # - urn:nist:sp:800-53.supp → NIST SP 800-53
16
16
  # - urn:nist:sp:800-53.r5.supp → NIST SP 800-53r5
17
- # - urn:nist:fips:199.supp → NIST FIPS 199
17
+ # - urn:nist:fips:199.supp → FIPS PUB 199
18
18
  class UrnParser < Pubid::UrnParser::Base
19
19
  TYPE_UPCASE = {
20
20
  "sp" => "SP",
@@ -11,3 +11,38 @@ Integer, so its core is `:int` (`core: [["number", :int]]`): `#00` is the core
11
11
  `0`, and `00` in a URN is not canonical. The Annex type has no `number`; it
12
12
  has no core, its document comes first along `base`, and its `letter` is an
13
13
  atom. No input makes an Annex today, so the spec builds one by hand.
14
+
15
+ ## Handbook volume label (本編 / 本編+付録)
16
+
17
+ MLIT publishes Handbook #00 edition 5.0 twice on one date: `第5.0版 本編`
18
+ (main volume) and `第5.0版 本編+付録` (main volume and appendix, one PDF);
19
+ edition 6.0 only as `本編+付録`. `Identifiers::Handbook#volume` keeps that
20
+ label as Japanese text, as `edition` keeps `第X.Y版`. The grammar accepts it
21
+ only after an edition, separated by an ASCII or a full-width (U+3000) space,
22
+ with `+` or ASCII `+`; the builder normalizes `+` to `+`, and `to_s` writes
23
+ one ASCII space before it. The renderer prints the volume only after an
24
+ edition, so `exclude(:edition)` still renders a string that parses.
25
+ `volume` is in `==` and is `subset_strict`, so `#00 第5.0版` matches neither
26
+ labelled publication. A nil volume leaves no key in `to_hash`, so the
27
+ existing index-v2 rows keep their hash. The pubid-scheme URN carries it as
28
+ the `vol-` atom; `to_urn` is unchanged (it has no edition either). Locked by
29
+ `spec/pubid/plateau/handbook_volume_spec.rb`.
30
+
31
+ Known gaps, not caused by this change: a Technical Report drops an edition
32
+ and a volume without a diagnostic (the `handbook` rule matches any
33
+ `doc_type`), and `to_mr_string` raises for every Handbook with an edition
34
+ (the base `mr_edition` calls `edition.number` on a String).
35
+
36
+ ## The grammar is baked here ahead of pubid-grammar
37
+
38
+ `data/parg/plateau.json` was compiled here from its own embedded `source`
39
+ plus the volume rule, at parsanol **1.3.114** (the version of the earlier
40
+ bake; force it with `gem "parsanol", "1.3.114"`, as
41
+ `pubid-grammar/scripts/bake-one.rb` does, because the binstub resolves the
42
+ latest installed gem). Until pubid-grammar takes hand-off
43
+ `pubid__pubid-grammar__plateau-handbook-volume`, its `grammars/plateau.parg`
44
+ is the OLD grammar. To change the grammar, extract the source with
45
+ `jq -j .source data/parg/plateau.json` (`-j`: the source has no trailing
46
+ newline, and one extra byte changes the checksum), edit, re-bake, then run
47
+ `bundle exec parsanol parg test data/parg/plateau.json`. Read the `.parg`
48
+ file as UTF-8: under a US-ASCII locale the parsanol lexer raises on `第`.
@@ -34,6 +34,10 @@ module Pubid
34
34
  if parsed[:edition] && klass == Identifiers::Handbook
35
35
  params[:edition] = parsed[:edition].to_s
36
36
  end
37
+ # The grammar accepts an ASCII "+"; MLIT prints the full-width "+".
38
+ if parsed[:volume] && klass == Identifiers::Handbook
39
+ params[:volume] = parsed[:volume].to_s.tr("+", "+")
40
+ end
37
41
 
38
42
  klass.new(**params)
39
43
  end