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
@@ -4,6 +4,11 @@ module Pubid
4
4
  module Iec
5
5
  # Identifier that represents a supplement to a base identifier.
6
6
  class SupplementIdentifier < SingleIdentifier
7
+ # The supplement number a legacy standalone face implies when its
8
+ # type token carries none ("IEC/DCOR 60038-1" is Corrigendum 1 of
9
+ # 60038-1); the owner's no-number rule.
10
+ LEGACY_STANDALONE_SUPPLEMENT_NUMBER = "1"
11
+
7
12
  attribute :base, Identifier, polymorphic: true
8
13
 
9
14
  # Serialize the supplemented document under "base". The supplement's own
@@ -22,8 +27,9 @@ module Pubid
22
27
  # (e.g. a bare "+AMD1:2008" nested in a consolidation): a fake doc whose
23
28
  # number equals the supplement's own. It renders nothing and the getter
24
29
  # recreates it identically on load, so don't persist it. A standalone
25
- # supplement ("IEC/FDAM 60038-1") instead moves the real base number into
26
- # the synthetic base (base.number != own number), which must be kept.
30
+ # supplement in the legacy prefix face ("IEC/DCOR 60038-1") instead
31
+ # holds the real base designation (base.number != own number), which
32
+ # must be kept.
27
33
  return if model.synthetic_base? && base.number.to_s == model.number.to_s
28
34
 
29
35
  doc.add_child(Lutaml::KeyValue::DataModel::Element.new("base",
@@ -33,26 +39,11 @@ module Pubid
33
39
  def base_from_kv(model, value)
34
40
  return unless value
35
41
 
36
- base = ::Pubid::Iec::Identifier.from_hash(value)
37
- model.base = base
38
-
39
- # A base of the exact SingleIdentifier class is the synthetic self-base
40
- # of a standalone supplement ("IEC/FDAM 60038-1"); a real attached base
41
- # is always a concrete type (InternationalStandard, etc.). Re-engage the
42
- # synthetic-base state so the supplement renders in standalone form
43
- # (publisher/TYPE base-number-supp-number) instead of attached form.
44
- if base.instance_of?(::Pubid::Iec::SingleIdentifier)
45
- model.mark_synthetic_standalone!
46
- end
47
- end
48
-
49
- # Re-engage the standalone synthetic-base state on a deserialized
50
- # supplement. At this point `number` already holds the supplement number
51
- # (set from the "number" key before "base"), so preserve it as the
52
- # supplement number and flag the synthetic base.
53
- def mark_synthetic_standalone!
54
- @synthetic_base = true
55
- @supplement_number = @number
42
+ # A bare SingleIdentifier here is the synthetic base of a standalone
43
+ # supplement; #synthetic_base? reads that class, so nothing else needs
44
+ # restoring. "number" already holds the supplement number, which is
45
+ # what ensure_base stores on the parse path too.
46
+ model.base = ::Pubid::Iec::Identifier.from_hash(value)
56
47
  end
57
48
 
58
49
  # Override base getter to ensure it's always created for standalone supplements
@@ -60,29 +51,22 @@ module Pubid
60
51
  @base || ensure_base
61
52
  end
62
53
 
63
- # Override number getter to trigger swap for standalone supplements
54
+ # A standalone supplement written in the legacy prefix face holds the
55
+ # grammar's layout (the whole base designation on itself) until
56
+ # #ensure_base moves it. Not every construction path goes through one
57
+ # builder branch ("IEC DCOR 60038-1" reaches the typed branch, a
58
+ # hand-built Amendment none), so these readers make the move before
59
+ # they answer: the result never depends on which reader ran first, and
60
+ # #exclude, which copies through the readers, copies the moved layout.
61
+ # They only TRIGGER the move; they return the stored value.
64
62
  def number
65
- # Guard against recursion in ensure_base
66
- return @number if @ensuring_base
67
-
68
- # For standalone supplements, trigger base creation first
69
- ensure_base if @synthetic_base.nil? && publisher && part
70
-
71
- if @synthetic_base
72
- @supplement_number || @number
73
- else
74
- super
75
- end
63
+ settle_standalone_layout
64
+ super
76
65
  end
77
66
 
78
- # Override part getter for standalone supplements (part becomes nil after swap)
79
67
  def part
80
- # For standalone supplements with synthetic base, part is nil (moved to number)
81
- if @synthetic_base
82
- nil
83
- else
84
- super
85
- end
68
+ settle_standalone_layout
69
+ super
86
70
  end
87
71
 
88
72
  # Delegate publisher and copublishers to base whenever there is
@@ -102,59 +86,75 @@ module Pubid
102
86
  base ? base.copublishers : @copublishers
103
87
  end
104
88
 
105
- # Ensure we have a base for standalone supplements
106
- # Creates a synthetic base identifier from supplement attributes if needed
107
- # Returns the base
89
+ # Synthesise the base of a supplement that names none, and return it.
90
+ #
91
+ # A standalone supplement written in the legacy prefix face ("IEC/DCOR
92
+ # 60038-1", "IEC/DISH 60038-1") arrives from the grammar with the BASE
93
+ # designation stored on itself — the base number in `number`, the base
94
+ # part (and subpart) in `part`/`subpart`. That layout is the grammar's
95
+ # bound shape, not the identity: the face names a supplement OF that
96
+ # designation, so the designation moves WHOLE onto a concrete
97
+ # InternationalStandard base (publisher, copublishers and the date go
98
+ # with it — old notation prints the base's year, and a draft
99
+ # supplement carries no publication date of its own) and the
100
+ # supplement keeps a number of its own, defaulting to 1 — the
101
+ # no-number rule (an unnumbered draft face means
102
+ # Amendment/Corrigendum/Interpretation-Sheet 1; owner ruling
103
+ # 2026-11-12, reversing #693's part-to-number move, which stole the
104
+ # base's part for the supplement). The concrete base is what makes the
105
+ # decode hash-match (and equal) the modern on-base parse of the same
106
+ # document: `parse("IEC/DCOR 60038-1") == parse("IEC 60038-1/DCOR1")`.
107
+ #
108
+ # A supplement with no part (a true standalone like "IEC/AMD 1:2017",
109
+ # or a bare "+AMD1:2008" in a consolidation) keeps its own number and
110
+ # gets a synthetic self-base whose number equals it; its date stays
111
+ # its own.
112
+ #
113
+ # The base deliberately gets NO typed_stage. A stage without a code is
114
+ # not serialized, so `from_hash` cannot restore one, and a stage set
115
+ # here alone made `parse != from_hash(to_hash)` — `==` compares `base`
116
+ # (`lib/pubid/iec/CLAUDE.md`).
108
117
  def ensure_base
109
118
  return @base if @base
110
119
 
111
- # Guard flag to prevent recursion in number getter
120
+ # publisher/copublishers read through `base`; this guard makes them
121
+ # read the supplement's own value while the base is being built.
112
122
  @ensuring_base = true
113
-
114
- # For standalone supplements like "IEC/FDAM 60038-1"
115
- # The supplement number is in the `part` attribute, while `number` is the base number
116
- # We need to create a synthetic base and use part as the supplement number
117
- return nil unless publisher && (number || part)
118
-
119
- # Capture original values BEFORE marking as synthetic
120
- # (otherwise our getters will return swapped values)
121
- base_number = number
122
- supplement_number = part
123
-
124
- # NOW mark that this is a synthetic base (after capturing values)
125
- @synthetic_base = true
126
-
127
- # Use SingleIdentifier which has typed_stage attribute
128
- base = SingleIdentifier.new
129
- base.publisher = publisher
130
- base.number = base_number if base_number
131
- base.part = nil # Base doesn't have part - part is the supplement number
132
- base.subpart = subpart if subpart
133
- base.date = date if date
134
-
135
- # Create typed_stage for the base - use "IS" (International Standard) type
136
- # not the supplement type
137
- base.typed_stage = Pubid::Components::TypedStage.new(
138
- abbr: ["IS"],
139
- type_code: :is,
140
- stage_code: :undated,
141
- )
123
+ return nil unless publisher && (@number || @part)
124
+
125
+ if @part
126
+ base = Identifiers::InternationalStandard.new
127
+ base.publisher = publisher
128
+ base.copublishers = copublishers if copublishers&.any?
129
+ base.number = @number
130
+ base.part = @part
131
+ base.subpart = @subpart if @subpart
132
+ base.date = date if date
133
+ @number = LEGACY_STANDALONE_SUPPLEMENT_NUMBER
134
+ @part = nil
135
+ @date = nil
136
+ else
137
+ base = SingleIdentifier.new
138
+ base.publisher = publisher
139
+ base.number = @number if @number
140
+ base.date = date if date
141
+ end
142
142
 
143
143
  @base = base
144
-
145
- # Store the supplement number for getter override
146
- # The original number is the base number (moved to base)
147
- # The original part is the supplement number (what we want to return)
148
- @supplement_number = supplement_number
149
-
150
- @base
151
144
  ensure
152
145
  @ensuring_base = false
153
146
  end
154
147
 
155
- # Check if this supplement has a synthetic base (created for standalone format)
148
+ # True when the base is the bare SingleIdentifier that #ensure_base (or
149
+ # from_hash) synthesised for a standalone supplement; a real attached
150
+ # base is always a concrete type (InternationalStandard, SheetIdentifier
151
+ # …). Read from the base itself, so it survives #exclude, which copies
152
+ # `base` into a new object. A legacy prefix face's designation moves
153
+ # onto a CONCRETE InternationalStandard instead (see #ensure_base), so
154
+ # it renders in the attached, modern form and hash-matches the modern
155
+ # on-base parse of the same document.
156
156
  def synthetic_base?
157
- @synthetic_base ||= false
157
+ base.instance_of?(::Pubid::Iec::SingleIdentifier)
158
158
  end
159
159
 
160
160
  def to_s(**opts)
@@ -173,6 +173,12 @@ module Pubid
173
173
 
174
174
  segments.join(".")
175
175
  end
176
+
177
+ private
178
+
179
+ def settle_standalone_layout
180
+ ensure_base if @base.nil? && @part && !@ensuring_base
181
+ end
176
182
  end
177
183
  end
178
184
  end
@@ -96,6 +96,58 @@ The compact face (`compact: true`) folds the code-adjacent components
96
96
  onto the code: supplement and part hyphenated, volume as the slash
97
97
  chain — `IHO M-2-2`, `IHO S-100-2 5.0.0`, `IHO P-5/2024`.
98
98
 
99
+ ## S-4 loose-leaf sections and specs (owner rulings, 2026-11-11)
100
+
101
+ The S-4 loose-leaf publication (Parts A/B/C → Sections 100-600, Part C
102
+ 100-500 → Regulations/Specifications cited `<letter>-<hundreds><seq>`)
103
+ is modeled ONE COMPONENT DEEP, not two tiers: the spec number IS the
104
+ leaf. `B-401` binds the letter into `part` ("B") and the number into
105
+ `spec` ("401"); the SECTION (the hundreds digit) is DERIVED —
106
+ `#spec_section` (`B-401` → `"400"`) — never stored, never emitted, and
107
+ the `00` slot (B-400) IS the section-level citation, so `S-4 Part B,
108
+ Section 400` ≡ `IHO S-4 B-400` (one identity, two spellings).
109
+
110
+ | component | attribute | canonical face | parse-only forms |
111
+ |--------------|--------------|---------------------|-------------------------------------|
112
+ | spec | `spec` | `B-401` (with part) | the spelled section (", Section 400") |
113
+ | spec sub | `spec_sub` | `.1` | — |
114
+ | spec variant | `spec_variant` | `(I)` | — |
115
+
116
+ - **Faces**: canonical compact `IHO S-4 B-401` (dated:
117
+ `IHO S-4 4.10.0 B-401` — the edition precedes the citation, as it
118
+ precedes the part). The citation face REPLACES the Part face in the
119
+ renderer (`compact: true` is a no-op on it — it is already compact).
120
+ Subs and variants combine (`B-351.1(I)` is in the 4.9.0 text).
121
+ - **Three forms parse to one identity**: the anchored citation, the
122
+ LONG form `S-4 Part B, Section 400[, B-401]` (normalizing parse; the
123
+ spelled section becomes `spec` alone, and beside a citation it only
124
+ cross-checks the citation's derived section — "Section 500, B-401"
125
+ raises) and the BARE `B-401` (implicit S-4 anchor; B only — bare
126
+ C-x stays the Circular Letter series, and the 3-digit `[1-6]xx`
127
+ shape keeps the B Bibliographic series (B-12) on the main branch).
128
+ `IHO B-401` (publisher + bare) parses to the spec too.
129
+ - **The anchor is load-bearing**: a spec citation requires the S-4
130
+ anchor (series S number 4 — the builder raises otherwise), a
131
+ lettered Part (A/B/C, not a numbered part), and no component
132
+ editions or S-4-foreign components (supplement/annex/appendix/
133
+ volume/issue); the citation letter must agree with a Part-rule
134
+ capture.
135
+ - **Reserved numbers** (`B-170`, `B-230`, `B-305`, `B-323` — "NOT
136
+ CURRENTLY USED" in the Ed 4.9.0 and 4.10.0 contents) parse and
137
+ resolve AS RESERVED, never invalid: `#reserved?` answers from
138
+ `data/iho/s4-reserved-specs.yaml` (edition-keyed, provenance in the
139
+ file; an edition-less reference is reserved when ANY seeded edition
140
+ reserves the number). `lib/pubid/iho/reserved_specs.rb` is the
141
+ loader (`Pubid::Iho::ReservedSpecs`).
142
+ - **URN atoms**: the citation is a child of its part — the letter rides
143
+ the existing `part-` atom (case preserved: `part-B`, never the
144
+ lowercase `part-b` sketch), then `spec-`, `specsub-`, `specvar-`
145
+ (the `appsub-` naming convention; the `:sub-1`/`:variant-i` in the
146
+ ruling's sketch are these atoms). `urn:iho:s:4:ed-4.10.0:part-B:
147
+ spec-401`; the section is not emitted. No `ed-`/`year-` after a
148
+ spec node (UrnParseError — the spec rides its publication's
149
+ edition); `specsub-`/`specvar-` only after `spec-`.
150
+
99
151
  ## Identity semantics
100
152
 
101
153
  - The components are identity-bearing in `==`/`matches?`; a bare
@@ -182,4 +234,5 @@ component with its own edition/year right after it (`sup-`, `part-`,
182
234
  `parted-`, `appendix-`, `appsub-`, `vol-`, `issue-` …). Case is preserved
183
235
  (`part-A`); the yearbook year is a volume (`vol-2024`, no `year-` value —
184
236
  the legacy `y.` marker stays decodable in the official URN parser, never
185
- emitted). The official `urn:mrn:iho:…` form stays on `#to_urn`.
237
+ emitted). The positional `urn:iho:` form of `UrnGenerator` stays on
238
+ `#to_urn` — pubid emits no `urn:mrn:iho:…` URN.
@@ -18,18 +18,34 @@ module Pubid
18
18
  part_edition part_year supplement_edition
19
19
  supplement_year annex_edition annex_year
20
20
  appendix_edition appendix_year volume_edition
21
- volume_year].freeze
21
+ volume_year
22
+ spec spec_sub spec_variant spec_part
23
+ spec_section].freeze
22
24
  # The independently-publishable components' own editions; the
23
25
  # base edition context is mutually exclusive with them (owner
24
26
  # ruling on the two publication modes).
25
27
  COMPONENT_EDITIONS = %i[part_edition supplement_edition annex_edition
26
28
  appendix_edition volume_edition].freeze
29
+ # The S-4 anchor: the loose-leaf publication every spec citation
30
+ # resolves through (bare "B-401" carries it implicitly).
31
+ S4_TYPE = "S"
32
+ S4_NUMBER = "4"
33
+ # A citation letter is one of S-4's lettered Parts; the section
34
+ # vocabulary (100-600, Part C 100-500) is theirs.
35
+ SPEC_LETTERS = %w[A B C].freeze
36
+ # Components an S-4 spec citation cannot carry: the citation is a
37
+ # leaf of the Part, not a wrappable publication.
38
+ SPEC_FOREIGN_COMPONENTS = %i[supplement annex appendix
39
+ appendix_subnumber volume issue].freeze
27
40
 
28
41
  def build(parsed)
29
42
  hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
30
43
  attrs = stringified_attrs(hash)
44
+ resolve_spec_citation!(attrs)
45
+ type = type_letter_from(hash, attrs)
46
+ assert_spec_anchor!(attrs, type)
31
47
  assert_edition_mode!(attrs)
32
- klass = Pubid::Iho.identifier_klass_for_type_letter(stringify(hash[:type]))
48
+ klass = Pubid::Iho.identifier_klass_for_type_letter(type)
33
49
  klass.new(**attrs)
34
50
  end
35
51
 
@@ -39,6 +55,93 @@ module Pubid
39
55
 
40
56
  private
41
57
 
58
+ # The citation captures resolve onto the model (see the grammar
59
+ # header): the citation letter folds into `part` (a Part-rule
60
+ # capture must agree), and the long form's spelled section becomes
61
+ # the spec alone — beside a citation it only cross-checks the
62
+ # citation's derived section ("Part B, Section 400, B-401" stores
63
+ # spec 401; "Part B, Section 500, B-401" is rejected).
64
+ def resolve_spec_citation!(attrs)
65
+ letter = attrs.delete(:spec_part)
66
+ spelled = attrs.delete(:spec_section)
67
+ attrs[:part] = merge_citation_letter!(attrs[:part], letter) if letter
68
+ apply_spelled_section!(attrs, spelled)
69
+ assert_citation_shape!(attrs) if attrs[:spec]
70
+ end
71
+
72
+ def apply_spelled_section!(attrs, spelled)
73
+ return if spelled.nil?
74
+ return attrs[:spec] = spelled unless attrs[:spec]
75
+
76
+ return if spelled == spec_section_of(attrs[:spec])
77
+
78
+ raise ArgumentError,
79
+ "the spelled section #{spelled} disagrees with the " \
80
+ "citation #{attrs[:part]}-#{attrs[:spec]}"
81
+ end
82
+
83
+ def spec_section_of(spec)
84
+ "#{spec[0]}00"
85
+ end
86
+
87
+ def merge_citation_letter!(part, letter)
88
+ return letter if part.nil? || part == letter
89
+
90
+ raise ArgumentError,
91
+ "the citation letter #{letter} conflicts with part #{part}"
92
+ end
93
+
94
+ def assert_citation_shape!(attrs)
95
+ assert_citation_letter!(attrs)
96
+ assert_citation_leaf!(attrs)
97
+ end
98
+
99
+ def assert_citation_letter!(attrs)
100
+ return if SPEC_LETTERS.include?(attrs[:part])
101
+
102
+ raise ArgumentError,
103
+ "an S-4 spec citation needs a lettered Part (A/B/C), " \
104
+ "not #{attrs[:part].inspect}"
105
+ end
106
+
107
+ # The citation is a leaf of its Part: it carries no component
108
+ # edition and none of the wrappable components.
109
+ def assert_citation_leaf!(attrs)
110
+ if attrs[:part_edition] || attrs[:part_year]
111
+ raise ArgumentError,
112
+ "a spec citation rides the S-4 edition, not a part edition"
113
+ end
114
+
115
+ foreign = attrs.slice(*SPEC_FOREIGN_COMPONENTS).compact.keys
116
+ return if foreign.empty?
117
+
118
+ raise ArgumentError,
119
+ "an S-4 spec citation is a leaf of its Part and cannot " \
120
+ "carry #{foreign.join(', ')}"
121
+ end
122
+
123
+ # The series letter: a bare citation branch parse has no series
124
+ # capture, and its anchor is implicitly the S-4 publication (the
125
+ # number binds with it).
126
+ def type_letter_from(hash, attrs)
127
+ type = stringify(hash[:type])
128
+ return type if type
129
+
130
+ raise ArgumentError, "unknown IHO series" unless attrs[:spec]
131
+
132
+ attrs[:number] = S4_NUMBER
133
+ S4_TYPE
134
+ end
135
+
136
+ def assert_spec_anchor!(attrs, type)
137
+ return unless attrs[:spec]
138
+ return if type == S4_TYPE && attrs[:number] == S4_NUMBER
139
+
140
+ raise ArgumentError,
141
+ "an S-4 spec citation requires the S-4 anchor: " \
142
+ "#{type}-#{attrs[:number]} cannot carry spec #{attrs[:spec]}"
143
+ end
144
+
42
145
  # The two publication modes are mutually exclusive: an identifier
43
146
  # is either within-edition (a base edition its components ride) or
44
147
  # separately-published (a component carries its own edition and
@@ -8,6 +8,11 @@ module Pubid
8
8
  # IHO identifiers have the form:
9
9
  # IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
10
10
  # [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
11
+ # The S-4 loose-leaf publication adds the spec citation
12
+ # IHO S-4 [edition] <letter>-<hundreds><seq>[.sub][(variant)]
13
+ # ("IHO S-4 B-401", the long form "S-4 Part B, Section 400" and the
14
+ # bare "B-401" all parse to it; `spec` stores the leaf number, the
15
+ # citation letter rides `part`, the section is a derived reader).
11
16
  #
12
17
  # Every tail component is a first-class semantic component: a typed
13
18
  # attribute, independently addressable, URN-encodable and matchable.
@@ -83,6 +88,15 @@ module Pubid
83
88
  attribute :issue, :string
84
89
  attribute :volume_edition, :string
85
90
  attribute :volume_year, :string
91
+ # The S-4 loose-leaf spec citation (issue #691): `spec` is the leaf
92
+ # number ("401" of B-401), the citation letter rides `part` (the
93
+ # builder folds it in), the dotted sub and the parenthesized
94
+ # variant keep their own components. The SECTION (the hundreds
95
+ # digit) is never stored — a derived reader — and the `00` slot
96
+ # (B-400) IS the section-level citation.
97
+ attribute :spec, :string
98
+ attribute :spec_sub, :string
99
+ attribute :spec_variant, :string
86
100
 
87
101
  # Polymorphic type map for lutaml::Model key_value serialization: maps
88
102
  # each subclass's polymorphic_name to its class name so `from_hash` can
@@ -122,6 +136,9 @@ module Pubid
122
136
  map "volume_edition", to: :volume_edition
123
137
  map "volume_year", to: :volume_year
124
138
  map "issue", to: :issue
139
+ map "spec", to: :spec
140
+ map "spec_sub", to: :spec_sub
141
+ map "spec_variant", to: :spec_variant
125
142
  end
126
143
 
127
144
  # from_hash is the shared polymorphic dispatch on Pubid::Identifier.
@@ -139,9 +156,40 @@ module Pubid
139
156
  args = args.dup
140
157
  args << :issue if args.include?(:volume)
141
158
  args << :appendix_subnumber if args.include?(:appendix)
159
+ # The spec citation is the leaf: a reference that omits the spec
160
+ # nils its sub and variant with it.
161
+ args.push(:spec_sub, :spec_variant) if args.include?(:spec)
142
162
  super
143
163
  end
144
164
 
165
+ # The citation letter of an S-4 spec (nil unless the identifier IS
166
+ # a spec citation): the part carries the letter, the same way the
167
+ # long form spells it ("Part B" of "B-401").
168
+ def spec_part
169
+ spec && part
170
+ end
171
+
172
+ # The section an S-4 spec belongs to: the hundreds digit of the
173
+ # spec number, zero-padded (B-401 -> "400"; B-400 derives its own
174
+ # section). DERIVED, never stored, never emitted — the owner
175
+ # ruling keeps the spec number the leaf.
176
+ def spec_section
177
+ return nil unless spec
178
+
179
+ "#{spec[0]}00"
180
+ end
181
+
182
+ # True when the spec number is reserved ("NOT CURRENTLY USED") in
183
+ # the edition's S-4 contents (data/iho/s4-reserved-specs.yaml). An
184
+ # edition-less reference is reserved when any seeded edition
185
+ # reserves the number. A reserved citation parses and resolves AS
186
+ # RESERVED, never invalid (issue #691).
187
+ def reserved?
188
+ return false unless spec
189
+
190
+ ReservedSpecs.reserved?(edition, part, spec)
191
+ end
192
+
145
193
  # Render the compact face: the code-adjacent components folded onto
146
194
  # the code with separators — the supplement and the part hyphenated
147
195
  # (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
@@ -153,7 +201,10 @@ module Pubid
153
201
  def render(format: :human, **opts)
154
202
  compact = opts.delete(:compact)
155
203
  result = super
156
- compact && format == :human ? compact_face(result) : result
204
+ # A spec citation's canonical face IS the compact form (the
205
+ # citation is already folded onto the code: "IHO S-4 B-401"), so
206
+ # the compact flag is a no-op for it.
207
+ compact && format == :human && !spec ? compact_face(result) : result
157
208
  end
158
209
 
159
210
  # The shared mr_string path reads `edition&.number` (the base
@@ -13,10 +13,12 @@ module Pubid
13
13
  # edition/year right after it — supplement, part, annex, appendix (the
14
14
  # two-level Appendix D-2 keeps the subnumber as its own atom), volume
15
15
  # (the unified locator, yearbook volumes included: `vol-2024`), issue.
16
- # Letters stay case-preserved (`part-A`). The legacy `y.` yearbook
17
- # marker is decodable in the official URN parser, never emitted here:
18
- # a periodical has no `year-` atom value, its volume carries the year.
19
- # The official urn:mrn:iho:… form stays on #to_urn.
16
+ # The S-4 spec citation sits inside its part (`spec-`, `specsub-`,
17
+ # `specvar-`). Letters stay case-preserved (`part-A`). The legacy
18
+ # `y.` yearbook marker is decodable in the official URN parser, never
19
+ # emitted here: a periodical has no `year-` atom value, its volume
20
+ # carries the year. The positional urn:iho:… form of UrnGenerator
21
+ # stays on #to_urn — pubid emits no urn:mrn:iho:… URN.
20
22
  PubidUrnSchema = Pubid::PubidUrn.define("iho") do
21
23
  atom "edition", "ed"
22
24
  atom "year", "year"
@@ -26,6 +28,12 @@ module Pubid
26
28
  atom "part", "part"
27
29
  atom "part_edition", "parted"
28
30
  atom "part_year", "party"
31
+ # The S-4 loose-leaf spec citation (issue #691): a child of the
32
+ # part (the citation letter rides `part-`); the section is
33
+ # derivable from the spec's hundreds digit — never an atom.
34
+ atom "spec", "spec"
35
+ atom "spec_sub", "specsub"
36
+ atom "spec_variant", "specvar"
29
37
  atom "annex", "annex"
30
38
  atom "annex_edition", "anned"
31
39
  atom "annex_year", "annyear"
@@ -19,6 +19,13 @@ module Pubid
19
19
  # beside an edition is the date
20
20
  # qualifier on standards,
21
21
  # parenthesized — the only year face
22
+ # "IHO S-4 B-401" the S-4 spec citation (the long
23
+ # form "S-4 Part B, Section 400"
24
+ # parses to the same identity and
25
+ # renders compact: the citation
26
+ # replaces the Part face; the
27
+ # section is derivable, never
28
+ # printed)
22
29
  #
23
30
  # The renderer is registered as the `:human` format in the IHO format
24
31
  # registry and invoked via `render(format: :human)`; the compact face
@@ -32,12 +39,17 @@ module Pubid
32
39
  # edition (+ parenthesized year) right after it; the appendix joins
33
40
  # its subnumber; the issue rides parenthesized after its volume;
34
41
  # the base year prints only as the parenthesized with-year
35
- # qualifier.
42
+ # qualifier. The S-4 spec citation face (" B-401") sits where the
43
+ # Part face sits — it REPLACES the Part face when a spec is
44
+ # present (the citation letter is the part), printing
45
+ # "<letter>-<number>" with the dotted sub and the parenthesized
46
+ # variant folded on.
36
47
  FACES = [
37
48
  [:edition, " %s"],
38
49
  [:supplement, " Sup %s"], [:supplement_edition, " %s"],
39
50
  [:supplement_year, " (%s)"],
40
51
  [:part, " Part %s"], [:part_edition, " %s"], [:part_year, " (%s)"],
52
+ [:spec, " %s"],
41
53
  [:annex, " Annex %s"], [:annex_edition, " %s"], [:annex_year, " (%s)"],
42
54
  [:appendix, " Appendix %s"], [:appendix_subnumber, "-%s"],
43
55
  [:appendix_edition, " %s"], [:appendix_year, " (%s)"],
@@ -50,11 +62,36 @@ module Pubid
50
62
  id = @id
51
63
  rendered = "#{id.publisher} #{id.class.type[:short]}-#{id.number}"
52
64
  FACES.each do |component, face|
53
- value = id.public_send(component)
65
+ value = face_value(id, component)
54
66
  rendered << (face % value) if value
55
67
  end
56
68
  rendered
57
69
  end
70
+
71
+ private
72
+
73
+ # nil = the face does not print. The citation face replaces the
74
+ # Part face (same containment slot: the citation letter IS the
75
+ # part), and it prints only on a spec citation.
76
+ def face_value(id, component)
77
+ if component == :spec
78
+ return nil unless id.spec
79
+
80
+ return spec_citation(id)
81
+ end
82
+ return nil if component == :part && id.spec
83
+
84
+ id.public_send(component)
85
+ end
86
+
87
+ # "B-401", "B-122.1", "B-521(I)", "B-351.1(I)" — the citation the
88
+ # S-4 contents and cross-references print.
89
+ def spec_citation(id)
90
+ citation = "#{id.part}-#{id.spec}"
91
+ citation << ".#{id.spec_sub}" if id.spec_sub
92
+ citation << "(#{id.spec_variant})" if id.spec_variant
93
+ citation
94
+ end
58
95
  end
59
96
  end
60
97
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Pubid
6
+ module Iho
7
+ # The S-4 reserved specification registry
8
+ # (data/iho/s4-reserved-specs.yaml): the "NOT CURRENTLY USED"
9
+ # numbers, edition-keyed (owner rulings of 2026-11-11, issue #691).
10
+ # {Reserved?} answers from this table — a reserved number parses and
11
+ # resolves AS RESERVED, never invalid.
12
+ module ReservedSpecs
13
+ DATA_PATH =
14
+ File.expand_path("../../../data/iho/s4-reserved-specs.yaml", __dir__)
15
+
16
+ class << self
17
+ # True when the part-lettered spec number is reserved in the
18
+ # given S-4 edition. An edition-less reference is reserved when
19
+ # ANY seeded edition reserves the number: the number is out of
20
+ # circulation in every edition that names it.
21
+ def reserved?(edition, part, spec)
22
+ return false unless part && spec
23
+
24
+ if edition
25
+ specs_for(edition, part).include?(spec)
26
+ else
27
+ registry.values.any? do |parts|
28
+ specs_for_parts(parts, part).include?(spec)
29
+ end
30
+ end
31
+ end
32
+
33
+ # The edition-keyed registry: edition => part letter => spec
34
+ # numbers.
35
+ def registry
36
+ @registry ||=
37
+ (YAML.safe_load_file(DATA_PATH) || {}).transform_values do |parts|
38
+ parts.transform_values { |specs| Array(specs) }
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def specs_for(edition, part)
45
+ specs_for_parts(registry.fetch(edition, {}), part)
46
+ end
47
+
48
+ def specs_for_parts(parts, part)
49
+ parts.fetch(part, [])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end