pubid 2.0.19 → 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 (45) 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/identifier.rb +4 -3
  17. data/lib/pubid/iec/CLAUDE.md +20 -11
  18. data/lib/pubid/iec/builder.rb +19 -1
  19. data/lib/pubid/iec/identifier.rb +19 -70
  20. data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
  21. data/lib/pubid/iec/identifiers/test_report_form.rb +29 -0
  22. data/lib/pubid/iec/identifiers/working_document.rb +23 -6
  23. data/lib/pubid/iec/pubid_urn_schema.rb +15 -7
  24. data/lib/pubid/iec/renderer.rb +8 -4
  25. data/lib/pubid/iec/single_identifier.rb +69 -4
  26. data/lib/pubid/iec/supplement_identifier.rb +52 -76
  27. data/lib/pubid/iho/CLAUDE.md +52 -0
  28. data/lib/pubid/iho/builder.rb +105 -2
  29. data/lib/pubid/iho/identifiers/base.rb +52 -1
  30. data/lib/pubid/iho/pubid_urn_schema.rb +11 -4
  31. data/lib/pubid/iho/renderer.rb +39 -2
  32. data/lib/pubid/iho/reserved_specs.rb +54 -0
  33. data/lib/pubid/iho/urn_generator.rb +6 -0
  34. data/lib/pubid/iho/urn_parser.rb +50 -9
  35. data/lib/pubid/iho.rb +1 -0
  36. data/lib/pubid/nist/CLAUDE.md +11 -0
  37. data/lib/pubid/nist/preprocessor.rb +16 -0
  38. data/lib/pubid/plateau/CLAUDE.md +35 -0
  39. data/lib/pubid/plateau/builder.rb +4 -0
  40. data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
  41. data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
  42. data/lib/pubid/plateau/renderer.rb +6 -1
  43. data/lib/pubid/sae/CLAUDE.md +11 -2
  44. data/lib/pubid/version.rb +1 -1
  45. metadata +18 -2
@@ -10,15 +10,15 @@ module Pubid
10
10
  # Iso::BundledIdentifier.) Fragment keeps its own stage/edition, so it
11
11
  # suppresses a subset inline instead of including this.
12
12
  #
13
- # number/part/subpart are NOT here, and deliberately so: their maps live on
14
- # SingleIdentifier, which the four wrappers do not inherit from, so the
15
- # exclusion is structural rather than a denylist that can drift. Only a
16
- # converter can be no-opped this way, so a key that needs `with:` for
17
- # another reason — year, publisher, copublishers, stage — still lists here.
18
- # (`month`/`day`/`undated` are missing from that list and leak a duplicated
19
- # top-level key on a wrapper; pre-existing, see lib/pubid/iec/CLAUDE.md.)
13
+ # number/part/subpart and the date keys (year/month/day/undated) are NOT
14
+ # here, and deliberately so: their maps live on SingleIdentifier, which
15
+ # the wrappers do not inherit from, so the exclusion is structural rather
16
+ # than a denylist that can drift. (The denylist did drift: it once listed
17
+ # `year` but not `month`/`day`/`undated`, and those three leaked a
18
+ # duplicated top-level key on every wrapper.) Only publisher,
19
+ # copublishers and stage stay here, because those maps are still wanted
20
+ # on the parent block.
20
21
  module DelegatedFieldSuppression
21
- def year_to_kv(_model, _doc); end
22
22
  def publisher_to_kv(_model, _doc); end
23
23
  def copublishers_to_kv(_model, _doc); end
24
24
  def stage_to_kv(_model, _doc); end
@@ -165,7 +165,7 @@ module Pubid
165
165
  key_value do
166
166
  map "_type", to: :_type, polymorphic_map: IEC_TYPE_MAP
167
167
  # number/part/subpart are NOT mapped here — they live on
168
- # SingleIdentifier, the seam that excludes the four delegating wrappers.
168
+ # SingleIdentifier, the seam that excludes the delegating wrappers.
169
169
  # See the comment on that block.
170
170
  #
171
171
  # stage_iteration is a degenerate Components::Iteration (one :string
@@ -173,13 +173,9 @@ module Pubid
173
173
  # ::Pubid::Identifier flattens it to a bare scalar and inflates it back.
174
174
  # A plain map is enough; no converter.
175
175
  map "stage_iteration", to: :stage_iteration, render_default: false
176
- # date serialized flat as year/month/day, nils omitted.
177
- map "year", with: { to: :year_to_kv, from: :year_from_kv }
178
- map "month", with: { to: :month_to_kv, from: :month_from_kv }
179
- map "day", with: { to: :day_to_kv, from: :day_from_kv }
180
- # IEC undated reference (e.g. IEC 60050:--). The `false` default is
181
- # omitted; only the meaningful `true` round-trips via to_hash.
182
- map "undated", with: { to: :undated_to_kv, from: :undated_from_kv }
176
+ # year/month/day/undated are NOT mapped here either — they live on
177
+ # SingleIdentifier for the same reason as number/part/subpart: a
178
+ # wrapper delegates `date` to what it wraps.
183
179
  map "edition", to: :edition, render_default: false
184
180
  map "languages", to: :languages, render_default: false
185
181
  # publisher emitted only when the primary isn't the IEC default;
@@ -195,59 +191,6 @@ module Pubid
195
191
  # already drops, so a plain map suffices (the GB spelling).
196
192
  end
197
193
 
198
- # --- date serialized flat as year/month/day ---
199
- def year_to_kv(model, doc)
200
- y = model.date&.year
201
- return if y.nil? || y.to_s.empty?
202
-
203
- doc.add_child(Lutaml::KeyValue::DataModel::Element.new("year", y.to_s))
204
- end
205
-
206
- def year_from_kv(model, value)
207
- return if value.nil? || value.to_s.empty?
208
-
209
- (model.date ||= ::Pubid::Components::Date.new).year = value.to_s
210
- end
211
-
212
- def month_to_kv(model, doc)
213
- m = model.date&.month
214
- return if m.nil? || m.to_s.empty?
215
-
216
- doc.add_child(Lutaml::KeyValue::DataModel::Element.new("month", m.to_s))
217
- end
218
-
219
- def month_from_kv(model, value)
220
- return if value.nil? || value.to_s.empty?
221
-
222
- (model.date ||= ::Pubid::Components::Date.new).month = value.to_s
223
- end
224
-
225
- def day_to_kv(model, doc)
226
- d = model.date&.day
227
- return if d.nil? || d.to_s.empty?
228
-
229
- doc.add_child(Lutaml::KeyValue::DataModel::Element.new("day", d.to_s))
230
- end
231
-
232
- def day_from_kv(model, value)
233
- return if value.nil? || value.to_s.empty?
234
-
235
- (model.date ||= ::Pubid::Components::Date.new).day = value.to_s
236
- end
237
-
238
- # --- date undated flag (IEC undated reference, e.g. IEC 60050:--) ---
239
- # The `false` default is omitted; only the meaningful `true` round-trips
240
- # via to_hash / from_hash.
241
- def undated_to_kv(model, doc)
242
- return unless model.date&.undated?
243
-
244
- doc.add_child(Lutaml::KeyValue::DataModel::Element.new("undated", true))
245
- end
246
-
247
- def undated_from_kv(model, value)
248
- (model.date ||= ::Pubid::Components::Date.new).undated = value.to_s == "true"
249
- end
250
-
251
194
  # --- publisher: primary only when non-default; copublishers as a list ---
252
195
  def publisher_to_kv(model, doc)
253
196
  pub = model.publisher&.body
@@ -326,7 +269,13 @@ module Pubid
326
269
  # Apply legacy update_codes normalization first, before any other preprocessing
327
270
  normalized = Core::UpdateCodes.apply(string, :iec)
328
271
  parsed = Pubid::Parg::Model.bound(:iec, Pubid::Iec::Parser.normalize_input(normalized))
329
- Pubid::Iec::Builder.new.build(parsed)
272
+ id = Pubid::Iec::Builder.new.build(parsed)
273
+ # A standalone supplement stores its numbers in the layout from_hash
274
+ # rebuilds only after #ensure_base runs; run it here, so a fresh
275
+ # parse holds that layout before any reader runs
276
+ # (SupplementIdentifier#number).
277
+ id.ensure_base if id.is_a?(SupplementIdentifier)
278
+ id
330
279
  end
331
280
 
332
281
  # from_hash is the shared polymorphic dispatch on Pubid::Identifier.
@@ -25,9 +25,9 @@ module Pubid
25
25
  map "fragment_number", to: :fragment_number, render_default: false
26
26
  end
27
27
 
28
- # number/part/subpart need no no-op here: their maps live on
29
- # SingleIdentifier, which this class does not inherit from.
30
- def year_to_kv(_model, _doc); end
28
+ # number/part/subpart and year/month/day/undated need no no-op here:
29
+ # their maps live on SingleIdentifier, which this class does not
30
+ # inherit from.
31
31
  def publisher_to_kv(_model, _doc); end
32
32
  def copublishers_to_kv(_model, _doc); end
33
33
 
@@ -11,6 +11,35 @@ module Pubid
11
11
  attribute :cispr_identifier, Identifier, polymorphic: true, default: -> {
12
12
  }
13
13
 
14
+ # The embedded CISPR document is part of the form's identity, so it
15
+ # serializes under its own key (issue #687 — before it, a
16
+ # CISPR-bearing TRF hashed to just its publisher and year and never
17
+ # matched after a from_hash round trip). The converter shape is
18
+ # VapIdentifier#base_to_kv / #base_from_kv: the nested identifier
19
+ # carries its own polymorphic `_type`, so to_hash embeds the nested
20
+ # hash and from_hash dispatches back through the type map.
21
+ key_value do
22
+ map "cispr_identifier",
23
+ with: { to: :cispr_identifier_to_kv,
24
+ from: :cispr_identifier_from_kv }
25
+ end
26
+
27
+ def cispr_identifier_to_kv(model, doc)
28
+ cispr = model.cispr_identifier
29
+ return unless cispr
30
+
31
+ doc.add_child(
32
+ Lutaml::KeyValue::DataModel::Element.new("cispr_identifier",
33
+ cispr.to_hash),
34
+ )
35
+ end
36
+
37
+ def cispr_identifier_from_kv(model, value)
38
+ if value
39
+ model.cispr_identifier = ::Pubid::Iec::Identifier.from_hash(value)
40
+ end
41
+ end
42
+
14
43
  # TRF has type abbreviation for parsing
15
44
  TYPED_STAGES = [
16
45
  Pubid::Components::TypedStage.new(
@@ -11,12 +11,28 @@ module Pubid
11
11
  # Working Document: "100/3705(F)/FDIS"
12
12
  class WorkingDocument < Base
13
13
  attribute :technical_committee, :string, default: -> {}
14
- attribute :wd_number, :string, default: -> {}
15
14
  attribute :wd_language, :string, default: -> {}
16
15
  attribute :wd_stage, :string, default: -> {}
17
16
  attribute :wp_stage, :string, default: -> {}
18
17
  attribute :wp_type, :string, default: -> {}
19
18
 
19
+ # The working document's identity attributes are plain strings, so no
20
+ # converter is needed: lutaml writes the scalar, and
21
+ # ::Pubid::Identifier#to_hash drops a nil (the `-> {}` defaults are
22
+ # nil). This is the serialization that issue #687 restored — before
23
+ # it, `to_hash` was `{"_type" => "pubid:iec:working-document"}` alone,
24
+ # all 2,318 working documents shared one hash and one URN
25
+ # (`urn:pubid:iec:wd`), and no row of this class ever matched after a
26
+ # from_hash round trip.
27
+ key_value do
28
+ map "technical_committee", to: :technical_committee,
29
+ render_default: false
30
+ map "wd_language", to: :wd_language, render_default: false
31
+ map "wd_stage", to: :wd_stage, render_default: false
32
+ map "wp_stage", to: :wp_stage, render_default: false
33
+ map "wp_type", to: :wp_type, render_default: false
34
+ end
35
+
20
36
  # Working documents have no TYPED_STAGES - they use PROJECT_STAGES only
21
37
  TYPED_STAGES = [].freeze
22
38
 
@@ -29,14 +45,15 @@ module Pubid
29
45
  end
30
46
 
31
47
  # A working document prints its committee, number, language and stage —
32
- # `1/2457(F)/FDIS` — and none of those is the `number` the shared token
33
- # table looks for, so it had no annotatable token. `wd_stage` is the
34
- # printed stage token ("FDIS"); the derived `#stage` reader covers only
35
- # the PWI/PNW work-programme form and is nil here.
48
+ # `1/2457(F)/FDIS` — and the document number is the inherited
49
+ # `number` (issue #687: it used to sit in a parallel `wd_number`
50
+ # attribute that nothing serialized). `wd_stage` is the printed stage
51
+ # token ("FDIS"); the derived `#stage` reader covers only the PWI/PNW
52
+ # work-programme form and is nil here.
36
53
  def annotation_tokens
37
54
  super + [
38
55
  [:technical_committee, "docnumber"],
39
- [:wd_number, "docnumber"],
56
+ [:number, "docnumber"],
40
57
  [:wd_language, "language"],
41
58
  [:wd_stage, "stage"],
42
59
  [:wp_stage, "stage"],
@@ -16,13 +16,15 @@ module Pubid
16
16
  # published URNs are unchanged. `database` was a string atom before any
17
17
  # URN carried it — the corpus holds only `true`, so it is a bool.
18
18
  #
19
- # Known identity gaps the URN inherits, because it carries exactly what
20
- # to_hash carries (pinned in lib/pubid/iec/CLAUDE.md): a WorkingDocument
21
- # serializes only `_type`, and a TestReportForm's CISPR document sits in
22
- # the unserialized `cispr_identifier` — such rows emit type-only URNs
23
- # (`urn:pubid:iec:wd`) and are one hash, so they are not a collision.
24
- # Mapping them is a wire-format change and stays the separate decision
25
- # it already was. The official RFC 5141-style urn:iec:std: form stays
19
+ # The identity gaps this schema used to inherit are closed (issue #687):
20
+ # the working document's committee/number/language/stage and work-programme
21
+ # stage/type serialize (`tc-`, `wdlang-`, `wdstage-`, `wpstage-`,
22
+ # `wptype-`, with the document number in the positional core), and a
23
+ # TestReportForm's CISPR document rides `cispr-` as a nested id — so a
24
+ # working document URNs per document instead of the shared type-only
25
+ # `urn:pubid:iec:wd` it used to be. The new atoms sit after `vap-` and
26
+ # before `lang-`, per the invariant below, so every landed URN encodes
27
+ # identically. The official RFC 5141-style urn:iec:std: form stays
26
28
  # on #to_urn.
27
29
  PubidUrnSchema = Pubid::PubidUrn.define("iec") do
28
30
  atom "publisher", "pub"
@@ -41,6 +43,12 @@ module Pubid
41
43
  atom "sheet_number", "sheet"
42
44
  atom "sheet_year", "sheetyear"
43
45
  atom "vap", "vap", kind: :list
46
+ atom "technical_committee", "tc"
47
+ atom "wd_language", "wdlang"
48
+ atom "wd_stage", "wdstage"
49
+ atom "wp_stage", "wpstage"
50
+ atom "wp_type", "wptype"
51
+ atom "cispr_identifier", "cispr", kind: :id
44
52
  atom "languages.*.code", "lang"
45
53
  atom "identifiers", "ids", kind: :ids
46
54
 
@@ -208,8 +208,10 @@ module Pubid
208
208
  # International Standard, so rendering would not be a fixed point.
209
209
  parts = []
210
210
  parts << id.wp_stage
211
- if id.wp_type && !id.wp_type.strip.empty?
212
- parts << " #{id.wp_type.strip}"
211
+ if id.wp_type && !id.wp_type.empty?
212
+ # wp_type is stripped at the construction site (Builder#cast),
213
+ # so the stored value is what renders.
214
+ parts << " #{id.wp_type}"
213
215
  end
214
216
 
215
217
  if id.number
@@ -226,8 +228,10 @@ module Pubid
226
228
  parts = []
227
229
  parts << id.technical_committee if id.technical_committee
228
230
 
229
- if id.wd_number
230
- num_part = id.wd_number.to_s
231
+ # The working document's own number is the inherited `number`
232
+ # (issue #687).
233
+ if id.number
234
+ num_part = id.number.to_s
231
235
  num_part += "(#{id.wd_language})" if id.wd_language
232
236
  parts << num_part
233
237
  end
@@ -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