pubid 2.0.20 → 2.0.21

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/data/iho/s4-reserved-specs.yaml +15 -0
  3. data/data/parg/bipm.json +1 -1
  4. data/data/parg/bipm.parg +46 -11
  5. data/data/parg/iho.json +1 -1
  6. data/data/parg/iho.parg +128 -11
  7. data/data/parg/plateau.json +1 -1
  8. data/lib/pubid/bipm/CLAUDE.md +6 -1
  9. data/lib/pubid/bipm/builder.rb +20 -1
  10. data/lib/pubid/bipm/renderer.rb +14 -2
  11. data/lib/pubid/bipm/urn_generator.rb +80 -14
  12. data/lib/pubid/bipm/urn_parser.rb +70 -6
  13. data/lib/pubid/ecma/CLAUDE.md +12 -0
  14. data/lib/pubid/ecma/identifier.rb +1 -2
  15. data/lib/pubid/ecma/parser.rb +49 -0
  16. data/lib/pubid/iec/CLAUDE.md +5 -1
  17. data/lib/pubid/iec/identifier.rb +19 -70
  18. data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
  19. data/lib/pubid/iec/single_identifier.rb +69 -4
  20. data/lib/pubid/iec/supplement_identifier.rb +52 -76
  21. data/lib/pubid/iho/CLAUDE.md +52 -0
  22. data/lib/pubid/iho/builder.rb +105 -2
  23. data/lib/pubid/iho/identifiers/base.rb +52 -1
  24. data/lib/pubid/iho/pubid_urn_schema.rb +11 -4
  25. data/lib/pubid/iho/renderer.rb +39 -2
  26. data/lib/pubid/iho/reserved_specs.rb +54 -0
  27. data/lib/pubid/iho/urn_generator.rb +6 -0
  28. data/lib/pubid/iho/urn_parser.rb +50 -9
  29. data/lib/pubid/iho.rb +1 -0
  30. data/lib/pubid/nist/CLAUDE.md +11 -0
  31. data/lib/pubid/nist/preprocessor.rb +16 -0
  32. data/lib/pubid/plateau/CLAUDE.md +35 -0
  33. data/lib/pubid/plateau/builder.rb +4 -0
  34. data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
  35. data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
  36. data/lib/pubid/plateau/renderer.rb +6 -1
  37. data/lib/pubid/sae/CLAUDE.md +11 -2
  38. data/lib/pubid/version.rb +1 -1
  39. metadata +4 -2
@@ -22,15 +22,80 @@ module Pubid
22
22
  # their own (Identifiers::Base and SupplementIdentifier).
23
23
  #
24
24
  # That is a structural exclusion, not a denylist of no-op converters that
25
- # a later key can be forgotten from — which is how `month`/`day` came to
26
- # leak (see DelegatedFieldSuppression).
25
+ # a later key can be forgotten from. The date keys live here for the
26
+ # same reason: the wrappers delegate `date` too, and when the date maps
27
+ # sat on Iec::Identifier the denylist no-opped `year` but not
28
+ # `month`/`day`/`undated`, so `IEC CA 01:2025-10 CSV` emitted a second,
29
+ # top-level "month" beside the one inside "base".
27
30
  #
28
- # The values are plain strings, so no converter is needed: lutaml writes
29
- # the scalar, and ::Pubid::Identifier#to_hash drops a nil.
31
+ # number/part/subpart are plain strings, so no converter is needed:
32
+ # lutaml writes the scalar, and ::Pubid::Identifier#to_hash drops a nil.
33
+ # The date keys keep converters, because they split one `date`
34
+ # attribute across four keys.
30
35
  key_value do
31
36
  map "number", to: :number
32
37
  map "part", to: :part
33
38
  map "subpart", to: :subpart
39
+ # date serialized flat as year/month/day, nils omitted.
40
+ map "year", with: { to: :year_to_kv, from: :year_from_kv }
41
+ map "month", with: { to: :month_to_kv, from: :month_from_kv }
42
+ map "day", with: { to: :day_to_kv, from: :day_from_kv }
43
+ # IEC undated reference (e.g. IEC 60050:--). The `false` default is
44
+ # omitted; only the meaningful `true` round-trips via to_hash.
45
+ map "undated", with: { to: :undated_to_kv, from: :undated_from_kv }
46
+ end
47
+
48
+ # --- date serialized flat as year/month/day ---
49
+ def year_to_kv(model, doc)
50
+ y = model.date&.year
51
+ return if y.nil? || y.to_s.empty?
52
+
53
+ doc.add_child(Lutaml::KeyValue::DataModel::Element.new("year", y.to_s))
54
+ end
55
+
56
+ def year_from_kv(model, value)
57
+ return if value.nil? || value.to_s.empty?
58
+
59
+ (model.date ||= ::Pubid::Components::Date.new).year = value.to_s
60
+ end
61
+
62
+ def month_to_kv(model, doc)
63
+ m = model.date&.month
64
+ return if m.nil? || m.to_s.empty?
65
+
66
+ doc.add_child(Lutaml::KeyValue::DataModel::Element.new("month", m.to_s))
67
+ end
68
+
69
+ def month_from_kv(model, value)
70
+ return if value.nil? || value.to_s.empty?
71
+
72
+ (model.date ||= ::Pubid::Components::Date.new).month = value.to_s
73
+ end
74
+
75
+ def day_to_kv(model, doc)
76
+ d = model.date&.day
77
+ return if d.nil? || d.to_s.empty?
78
+
79
+ doc.add_child(Lutaml::KeyValue::DataModel::Element.new("day", d.to_s))
80
+ end
81
+
82
+ def day_from_kv(model, value)
83
+ return if value.nil? || value.to_s.empty?
84
+
85
+ (model.date ||= ::Pubid::Components::Date.new).day = value.to_s
86
+ end
87
+
88
+ # --- date undated flag (IEC undated reference, e.g. IEC 60050:--) ---
89
+ # The `false` default is omitted; only the meaningful `true` round-trips
90
+ # via to_hash / from_hash.
91
+ def undated_to_kv(model, doc)
92
+ return unless model.date&.undated?
93
+
94
+ doc.add_child(Lutaml::KeyValue::DataModel::Element.new("undated", true))
95
+ end
96
+
97
+ def undated_from_kv(model, value)
98
+ (model.date ||= ::Pubid::Components::Date.new).undated = value.to_s == "true"
34
99
  end
35
100
 
36
101
  # Generate URN for this identifier
@@ -33,26 +33,11 @@ module Pubid
33
33
  def base_from_kv(model, value)
34
34
  return unless value
35
35
 
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
36
+ # A bare SingleIdentifier here is the synthetic base of a standalone
37
+ # supplement; #synthetic_base? reads that class, so nothing else needs
38
+ # restoring. "number" already holds the supplement number, which is
39
+ # what ensure_base stores on the parse path too.
40
+ model.base = ::Pubid::Iec::Identifier.from_hash(value)
56
41
  end
57
42
 
58
43
  # Override base getter to ensure it's always created for standalone supplements
@@ -60,29 +45,21 @@ module Pubid
60
45
  @base || ensure_base
61
46
  end
62
47
 
63
- # Override number getter to trigger swap for standalone supplements
48
+ # A standalone supplement holds the grammar's layout (base number in
49
+ # `number`, supplement number in `part`) until #ensure_base moves it.
50
+ # Not every construction path goes through one builder branch ("IEC FDAM
51
+ # 60038-1" does not, nor a hand-built Amendment), so these readers make
52
+ # the move before they answer: the result never depends on which reader
53
+ # ran first, and #exclude, which copies through the readers, copies the
54
+ # moved layout. They only TRIGGER the move; they return the stored value.
64
55
  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
56
+ settle_standalone_layout
57
+ super
76
58
  end
77
59
 
78
- # Override part getter for standalone supplements (part becomes nil after swap)
79
60
  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
61
+ settle_standalone_layout
62
+ super
86
63
  end
87
64
 
88
65
  # Delegate publisher and copublishers to base whenever there is
@@ -102,59 +79,52 @@ module Pubid
102
79
  base ? base.copublishers : @copublishers
103
80
  end
104
81
 
105
- # Ensure we have a base for standalone supplements
106
- # Creates a synthetic base identifier from supplement attributes if needed
107
- # Returns the base
82
+ # Synthesise the base of a supplement that names none, and return it.
83
+ #
84
+ # A standalone supplement ("IEC/FDAM 60038-1") arrives from the grammar
85
+ # with the BASE number in `number` and the SUPPLEMENT number in `part`.
86
+ # This moves the base number onto a bare SingleIdentifier base and stores
87
+ # the supplement number in `number`, so the attributes hold what they
88
+ # mean — the layout `from_hash` rebuilds from the serialized hash. A
89
+ # supplement with no part (a bare "+AMD1:2008" in a consolidation,
90
+ # "IEC/AMD 1:2017") gets a self-base whose number equals its own; its
91
+ # `number` is left as it is.
92
+ #
93
+ # The base deliberately gets NO typed_stage. A stage without a code is
94
+ # not serialized, so `from_hash` cannot restore one, and a stage set
95
+ # here alone made `parse != from_hash(to_hash)` — `==` compares `base`
96
+ # (`lib/pubid/iec/CLAUDE.md`).
108
97
  def ensure_base
109
98
  return @base if @base
110
99
 
111
- # Guard flag to prevent recursion in number getter
100
+ # publisher/copublishers read through `base`; this guard makes them
101
+ # read the supplement's own value while the base is being built.
112
102
  @ensuring_base = true
103
+ return nil unless publisher && (@number || @part)
113
104
 
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
105
  base = SingleIdentifier.new
129
106
  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
107
+ base.number = @number if @number
108
+ base.subpart = @subpart if @subpart
133
109
  base.date = date if date
134
110
 
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
- )
111
+ if @part
112
+ @number = @part
113
+ @part = nil
114
+ end
142
115
 
143
116
  @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
117
  ensure
152
118
  @ensuring_base = false
153
119
  end
154
120
 
155
- # Check if this supplement has a synthetic base (created for standalone format)
121
+ # True when the base is the bare SingleIdentifier that #ensure_base (or
122
+ # from_hash) synthesised for a standalone supplement; a real attached
123
+ # base is always a concrete type (InternationalStandard, SheetIdentifier
124
+ # …). Read from the base itself, so it survives #exclude, which copies
125
+ # `base` into a new object.
156
126
  def synthetic_base?
157
- @synthetic_base ||= false
127
+ base.instance_of?(::Pubid::Iec::SingleIdentifier)
158
128
  end
159
129
 
160
130
  def to_s(**opts)
@@ -173,6 +143,12 @@ module Pubid
173
143
 
174
144
  segments.join(".")
175
145
  end
146
+
147
+ private
148
+
149
+ def settle_standalone_layout
150
+ ensure_base if @base.nil? && @part && !@ensuring_base
151
+ end
176
152
  end
177
153
  end
178
154
  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
@@ -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,11 @@ 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 official urn:mrn:iho:… form stays on #to_urn.
20
21
  PubidUrnSchema = Pubid::PubidUrn.define("iho") do
21
22
  atom "edition", "ed"
22
23
  atom "year", "year"
@@ -26,6 +27,12 @@ module Pubid
26
27
  atom "part", "part"
27
28
  atom "part_edition", "parted"
28
29
  atom "part_year", "party"
30
+ # The S-4 loose-leaf spec citation (issue #691): a child of the
31
+ # part (the citation letter rides `part-`); the section is
32
+ # derivable from the spec's hundreds digit — never an atom.
33
+ atom "spec", "spec"
34
+ atom "spec_sub", "specsub"
35
+ atom "spec_variant", "specvar"
29
36
  atom "annex", "annex"
30
37
  atom "annex_edition", "anned"
31
38
  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