pubid 2.0.0.pre.alpha.11 → 2.0.0.pre.alpha.13

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +43 -1
  3. data/data/ieee/update_codes.yaml +17 -4
  4. data/data/nist/update_codes.yaml +7 -3
  5. data/lib/pubid/adobe/builder.rb +2 -0
  6. data/lib/pubid/all_parts.rb +201 -0
  7. data/lib/pubid/all_parts_identifier.rb +19 -0
  8. data/lib/pubid/amca/CLAUDE.md +47 -0
  9. data/lib/pubid/amca/builder.rb +3 -5
  10. data/lib/pubid/amca/identifiers/base.rb +10 -0
  11. data/lib/pubid/amca/identifiers/publication.rb +13 -0
  12. data/lib/pubid/amca/parser.rb +2 -1
  13. data/lib/pubid/amca/renderer.rb +22 -33
  14. data/lib/pubid/amca/urn_generator.rb +21 -2
  15. data/lib/pubid/amca/urn_parser.rb +36 -10
  16. data/lib/pubid/ansi/builder.rb +6 -0
  17. data/lib/pubid/api/CLAUDE.md +23 -0
  18. data/lib/pubid/api/builder.rb +2 -0
  19. data/lib/pubid/ashrae/CLAUDE.md +13 -0
  20. data/lib/pubid/ashrae/builder.rb +58 -14
  21. data/lib/pubid/ashrae/identifiers/errata.rb +14 -2
  22. data/lib/pubid/ashrae/identifiers/interpretation.rb +2 -10
  23. data/lib/pubid/ashrae/parser.rb +62 -28
  24. data/lib/pubid/ashrae/renderer.rb +32 -1
  25. data/lib/pubid/ashrae/urn_generator.rb +32 -9
  26. data/lib/pubid/asme/CLAUDE.md +25 -0
  27. data/lib/pubid/asme/builder.rb +16 -9
  28. data/lib/pubid/asme/components/code.rb +2 -0
  29. data/lib/pubid/asme/identifiers/standard.rb +6 -1
  30. data/lib/pubid/asme/parser.rb +41 -14
  31. data/lib/pubid/astm/CLAUDE.md +9 -0
  32. data/lib/pubid/astm/builder.rb +2 -0
  33. data/lib/pubid/astm/components/code.rb +2 -0
  34. data/lib/pubid/astm/parser.rb +4 -1
  35. data/lib/pubid/bipm/CLAUDE.md +11 -0
  36. data/lib/pubid/bipm/builder.rb +2 -0
  37. data/lib/pubid/bsi/CLAUDE.md +93 -0
  38. data/lib/pubid/bsi/builder.rb +13 -11
  39. data/lib/pubid/bsi/components/date.rb +10 -4
  40. data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
  41. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
  42. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
  43. data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
  44. data/lib/pubid/bsi/identifiers/british_industrial_practice.rb +1 -1
  45. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
  46. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
  47. data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
  48. data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
  49. data/lib/pubid/bsi/identifiers/handbook.rb +1 -1
  50. data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
  51. data/lib/pubid/bsi/identifiers/practice_guide.rb +1 -1
  52. data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
  53. data/lib/pubid/bsi/identifiers/set.rb +2 -0
  54. data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
  55. data/lib/pubid/bsi/identifiers.rb +1 -0
  56. data/lib/pubid/bsi/parser.rb +8 -8
  57. data/lib/pubid/bsi/renderer.rb +20 -20
  58. data/lib/pubid/bsi/single_identifier.rb +11 -2
  59. data/lib/pubid/bsi/urn_generator.rb +28 -18
  60. data/lib/pubid/builder/base.rb +27 -0
  61. data/lib/pubid/calconnect/builder.rb +2 -0
  62. data/lib/pubid/calconnect/identifier.rb +4 -0
  63. data/lib/pubid/ccsds/builder.rb +2 -0
  64. data/lib/pubid/ccsds/identifier.rb +12 -0
  65. data/lib/pubid/ccsds/single_identifier.rb +14 -10
  66. data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
  67. data/lib/pubid/cen_cenelec/builder.rb +6 -1
  68. data/lib/pubid/cen_cenelec/identifier.rb +10 -27
  69. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
  70. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
  71. data/lib/pubid/cen_cenelec/parser.rb +11 -3
  72. data/lib/pubid/cie/CLAUDE.md +58 -0
  73. data/lib/pubid/cie/builder.rb +2 -0
  74. data/lib/pubid/cie/components/language.rb +2 -0
  75. data/lib/pubid/components/adoption.rb +2 -0
  76. data/lib/pubid/components/code.rb +2 -0
  77. data/lib/pubid/components/date.rb +8 -6
  78. data/lib/pubid/components/edition.rb +2 -0
  79. data/lib/pubid/components/iteration.rb +2 -0
  80. data/lib/pubid/components/language.rb +2 -0
  81. data/lib/pubid/components/locality.rb +2 -0
  82. data/lib/pubid/components/publisher.rb +2 -0
  83. data/lib/pubid/components/relationship.rb +2 -0
  84. data/lib/pubid/components/stage.rb +2 -0
  85. data/lib/pubid/components/supplement.rb +2 -0
  86. data/lib/pubid/components/type.rb +2 -0
  87. data/lib/pubid/components/typed_stage.rb +8 -0
  88. data/lib/pubid/csa/CLAUDE.md +41 -0
  89. data/lib/pubid/csa/builder.rb +5 -3
  90. data/lib/pubid/csa/identifier.rb +13 -1
  91. data/lib/pubid/csa/identifiers/bundled.rb +2 -2
  92. data/lib/pubid/csa/identifiers/cec.rb +1 -1
  93. data/lib/pubid/csa/renderer.rb +12 -12
  94. data/lib/pubid/csa/single_identifier.rb +18 -1
  95. data/lib/pubid/csa/urn_generator.rb +1 -1
  96. data/lib/pubid/doi/builder.rb +2 -0
  97. data/lib/pubid/easc/builder.rb +2 -0
  98. data/lib/pubid/ecma/CLAUDE.md +28 -0
  99. data/lib/pubid/ecma/builder.rb +2 -0
  100. data/lib/pubid/ecma/identifier.rb +7 -0
  101. data/lib/pubid/etsi/CLAUDE.md +34 -0
  102. data/lib/pubid/etsi/builder.rb +2 -0
  103. data/lib/pubid/etsi/components/code.rb +6 -0
  104. data/lib/pubid/etsi/components/version.rb +2 -0
  105. data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
  106. data/lib/pubid/evs/CLAUDE.md +58 -0
  107. data/lib/pubid/evs/builder.rb +3 -1
  108. data/lib/pubid/evs/identifier.rb +6 -6
  109. data/lib/pubid/evs/identifiers/national_adoption.rb +7 -2
  110. data/lib/pubid/evs/renderer.rb +1 -1
  111. data/lib/pubid/evs/urn_generator.rb +1 -1
  112. data/lib/pubid/evs/urn_parser.rb +1 -1
  113. data/lib/pubid/gb/CLAUDE.md +140 -0
  114. data/lib/pubid/gb/builder.rb +21 -11
  115. data/lib/pubid/gb/identifier.rb +28 -7
  116. data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
  117. data/lib/pubid/gb/identifiers.rb +1 -0
  118. data/lib/pubid/gb/parser.rb +4 -0
  119. data/lib/pubid/gb/renderer.rb +4 -4
  120. data/lib/pubid/gb.rb +1 -0
  121. data/lib/pubid/gost/CLAUDE.md +64 -0
  122. data/lib/pubid/gost/builder.rb +3 -1
  123. data/lib/pubid/gost/identifier.rb +5 -0
  124. data/lib/pubid/iala/CLAUDE.md +82 -0
  125. data/lib/pubid/iala/builder.rb +2 -0
  126. data/lib/pubid/iana/CLAUDE.md +7 -0
  127. data/lib/pubid/iana/builder.rb +2 -0
  128. data/lib/pubid/identifier.rb +172 -20
  129. data/lib/pubid/idf/builder.rb +6 -1
  130. data/lib/pubid/idf/identifier.rb +12 -0
  131. data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
  132. data/lib/pubid/idf/identifiers/amendment.rb +1 -1
  133. data/lib/pubid/idf/identifiers/corrigendum.rb +1 -1
  134. data/lib/pubid/idf/identifiers/international_standard.rb +1 -1
  135. data/lib/pubid/idf/identifiers/reviewed_method.rb +1 -1
  136. data/lib/pubid/idf/identifiers.rb +1 -0
  137. data/lib/pubid/idf/single_identifier.rb +1 -1
  138. data/lib/pubid/iec/CLAUDE.md +31 -0
  139. data/lib/pubid/iec/builder.rb +7 -1
  140. data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
  141. data/lib/pubid/iec/components/sheet.rb +2 -0
  142. data/lib/pubid/iec/components/trf_info.rb +2 -0
  143. data/lib/pubid/iec/components/vap_suffix.rb +2 -0
  144. data/lib/pubid/iec/identifier.rb +7 -2
  145. data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
  146. data/lib/pubid/iec/identifiers.rb +1 -0
  147. data/lib/pubid/iec/renderer.rb +0 -1
  148. data/lib/pubid/iec/urn_generator.rb +9 -1
  149. data/lib/pubid/iec/urn_parser.rb +3 -2
  150. data/lib/pubid/ieee/CLAUDE.md +97 -0
  151. data/lib/pubid/ieee/builder.rb +176 -7
  152. data/lib/pubid/ieee/components/code.rb +2 -0
  153. data/lib/pubid/ieee/components/draft.rb +35 -2
  154. data/lib/pubid/ieee/components/typed_stage.rb +2 -0
  155. data/lib/pubid/ieee/identifiers/base.rb +20 -0
  156. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
  157. data/lib/pubid/ieee/identifiers/joint_development.rb +17 -10
  158. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
  159. data/lib/pubid/ieee/identifiers/si_standard.rb +5 -2
  160. data/lib/pubid/ieee/ire/parser.rb +19 -1
  161. data/lib/pubid/ieee/parser.rb +337 -49
  162. data/lib/pubid/ieee/project_renderer.rb +46 -0
  163. data/lib/pubid/ieee/renderer.rb +46 -14
  164. data/lib/pubid/ieee/urn_generator.rb +31 -0
  165. data/lib/pubid/ieee.rb +2 -0
  166. data/lib/pubid/ietf/CLAUDE.md +7 -0
  167. data/lib/pubid/ietf/builder.rb +2 -0
  168. data/lib/pubid/iho/builder.rb +2 -0
  169. data/lib/pubid/isbn/builder.rb +2 -0
  170. data/lib/pubid/iso/CLAUDE.md +47 -0
  171. data/lib/pubid/iso/builder.rb +29 -16
  172. data/lib/pubid/iso/components/publisher.rb +2 -0
  173. data/lib/pubid/iso/components.rb +0 -1
  174. data/lib/pubid/iso/identifier.rb +17 -18
  175. data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
  176. data/lib/pubid/iso/identifiers/directives.rb +7 -4
  177. data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
  178. data/lib/pubid/iso/identifiers/tc_document.rb +31 -46
  179. data/lib/pubid/iso/identifiers.rb +1 -0
  180. data/lib/pubid/iso/normalizer.rb +1 -1
  181. data/lib/pubid/iso/rendering_style.rb +0 -1
  182. data/lib/pubid/iso/urn_generator.rb +10 -10
  183. data/lib/pubid/iso.rb +5 -4
  184. data/lib/pubid/itu/CLAUDE.md +69 -0
  185. data/lib/pubid/itu/builder.rb +12 -0
  186. data/lib/pubid/itu/components/code.rb +2 -0
  187. data/lib/pubid/itu/components/designation.rb +2 -0
  188. data/lib/pubid/itu/components/sector.rb +2 -0
  189. data/lib/pubid/itu/components/series.rb +2 -0
  190. data/lib/pubid/itu/identifiers/base.rb +10 -0
  191. data/lib/pubid/itu/identifiers/contribution.rb +31 -0
  192. data/lib/pubid/itu/identifiers/supplement.rb +15 -0
  193. data/lib/pubid/itu/identifiers.rb +1 -0
  194. data/lib/pubid/itu/parser.rb +19 -2
  195. data/lib/pubid/jcgm/CLAUDE.md +7 -0
  196. data/lib/pubid/jcgm/builder.rb +2 -0
  197. data/lib/pubid/jcgm/components/publisher.rb +2 -0
  198. data/lib/pubid/jis/builder.rb +5 -1
  199. data/lib/pubid/jis/identifier.rb +5 -17
  200. data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
  201. data/lib/pubid/jis/identifiers.rb +1 -0
  202. data/lib/pubid/jis/renderer.rb +0 -2
  203. data/lib/pubid/jis/urn_generator.rb +0 -1
  204. data/lib/pubid/nist/CLAUDE.md +56 -0
  205. data/lib/pubid/nist/builder.rb +14 -12
  206. data/lib/pubid/nist/components/edition.rb +2 -0
  207. data/lib/pubid/nist/components/issue_number.rb +2 -0
  208. data/lib/pubid/nist/components/part.rb +2 -0
  209. data/lib/pubid/nist/components/stage.rb +2 -0
  210. data/lib/pubid/nist/components/supplement.rb +2 -0
  211. data/lib/pubid/nist/components/translation.rb +2 -0
  212. data/lib/pubid/nist/components/update.rb +2 -0
  213. data/lib/pubid/nist/components/version.rb +2 -0
  214. data/lib/pubid/nist/components/volume.rb +2 -0
  215. data/lib/pubid/nist/identifiers/base.rb +34 -6
  216. data/lib/pubid/nist/identifiers/commercial_standard_emergency.rb +1 -1
  217. data/lib/pubid/nist/identifiers/crpl_report.rb +5 -6
  218. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +2 -2
  219. data/lib/pubid/nist/identifiers/miscellaneous_publication.rb +2 -2
  220. data/lib/pubid/nist/parser.rb +15 -2
  221. data/lib/pubid/nist/preprocessor.rb +53 -2
  222. data/lib/pubid/nist/series/ir.rb +3 -7
  223. data/lib/pubid/oasis/CLAUDE.md +19 -0
  224. data/lib/pubid/oasis/builder.rb +2 -0
  225. data/lib/pubid/oasis/identifier.rb +19 -0
  226. data/lib/pubid/ogc/CLAUDE.md +34 -0
  227. data/lib/pubid/ogc/builder.rb +2 -0
  228. data/lib/pubid/ogc/identifier.rb +11 -0
  229. data/lib/pubid/oiml/CLAUDE.md +189 -0
  230. data/lib/pubid/oiml/builder.rb +22 -2
  231. data/lib/pubid/oiml/components/code.rb +6 -0
  232. data/lib/pubid/oiml/identifier.rb +13 -0
  233. data/lib/pubid/oiml/identifiers/annex.rb +4 -0
  234. data/lib/pubid/oiml/identifiers/bulletin.rb +30 -36
  235. data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
  236. data/lib/pubid/oiml/identifiers/code_number.rb +10 -2
  237. data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
  238. data/lib/pubid/oiml/identifiers.rb +2 -0
  239. data/lib/pubid/oiml/parser.rb +41 -5
  240. data/lib/pubid/oiml/renderer.rb +26 -4
  241. data/lib/pubid/oiml/single_identifier.rb +5 -1
  242. data/lib/pubid/oiml/supplement_identifier.rb +7 -0
  243. data/lib/pubid/oiml/urn_generator.rb +32 -4
  244. data/lib/pubid/oiml.rb +5 -0
  245. data/lib/pubid/omg/CLAUDE.md +15 -0
  246. data/lib/pubid/omg/builder.rb +2 -0
  247. data/lib/pubid/parser/grammar.rb +23 -0
  248. data/lib/pubid/plateau/builder.rb +2 -0
  249. data/lib/pubid/plateau/identifiers/base.rb +4 -0
  250. data/lib/pubid/plateau/supplement_identifier.rb +14 -2
  251. data/lib/pubid/plateau/urn_generator.rb +7 -1
  252. data/lib/pubid/renderers/directives_renderer.rb +3 -1
  253. data/lib/pubid/renderers/human_readable.rb +0 -1
  254. data/lib/pubid/sae/builder.rb +3 -1
  255. data/lib/pubid/sae/components/date.rb +2 -0
  256. data/lib/pubid/sae/components/type.rb +2 -0
  257. data/lib/pubid/sae/identifiers/base.rb +4 -1
  258. data/lib/pubid/sae/urn_generator.rb +1 -1
  259. data/lib/pubid/subset_match.rb +197 -0
  260. data/lib/pubid/tgpp/CLAUDE.md +43 -0
  261. data/lib/pubid/tgpp/builder.rb +2 -0
  262. data/lib/pubid/tgpp/identifier.rb +14 -0
  263. data/lib/pubid/type_resolver.rb +14 -2
  264. data/lib/pubid/un/builder.rb +2 -0
  265. data/lib/pubid/version.rb +1 -1
  266. data/lib/pubid/w3c/CLAUDE.md +7 -0
  267. data/lib/pubid/w3c/builder.rb +2 -0
  268. data/lib/pubid/xsf/CLAUDE.md +11 -0
  269. data/lib/pubid/xsf/builder.rb +2 -0
  270. data/lib/pubid.rb +16 -3
  271. data/lib/tasks/docs.rake +13 -11
  272. metadata +46 -4
  273. data/lib/pubid/ccsds/identifiers/base_BASE_88929.rb +0 -70
  274. data/lib/pubid/iso/components/code.rb +0 -24
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Pubid
4
4
  class Identifier < Lutaml::Model::Serializable
5
+ # `reference === candidate`: a subset match (see Pubid::SubsetMatch).
6
+ include SubsetMatch
7
+
5
8
  # Components that serialize as a bare scalar when they carry only their
6
9
  # single significant field: attribute name => the key the scalar is emitted
7
10
  # under. `date` is RENAMED to `year`, matching the flat shape ISO and IEC
@@ -94,7 +97,13 @@ module Pubid
94
97
  klass = concrete_class_for(data)
95
98
  return klass.from_hash(data, options) if klass && klass != self
96
99
 
97
- super(inflate_scalar_components(data), options)
100
+ # A row written before "all parts" became a class carries the old
101
+ # `all_parts: true` key on the document itself. Read it, and wrap.
102
+ legacy_all_parts = data.is_a?(::Hash) &&
103
+ (data["all_parts"] || data[:all_parts])
104
+
105
+ id = super(inflate_scalar_components(data), options)
106
+ legacy_all_parts ? id.to_all_parts : id
98
107
  end
99
108
 
100
109
  # Accept the flat scalar form that {#to_hash} now emits, and the nested
@@ -498,9 +507,17 @@ module Pubid
498
507
  end
499
508
 
500
509
  attribute :_type, :string, polymorphic_class: true
501
- attribute :number, Components::Code
502
- attribute :part, Components::Code
503
- attribute :subpart, Components::Code
510
+ # Plain strings, for every flavor. A Components::Code here held nothing
511
+ # but its `value` on the identifier path, and the disagreement between
512
+ # this declaration and the leaves that redeclared a scalar was the
513
+ # determinism landmine: a redeclaration on an inherited-from class
514
+ # resolves nondeterministically under multi-flavor load. With the base
515
+ # declaring the scalar, no flavor redeclares and the bug class is gone.
516
+ # A flavor that needs structure keeps its own attributes (ISO's committee
517
+ # codes, NIST's series/subseries, ITU's code), never a boxed `number`.
518
+ attribute :number, :string
519
+ attribute :part, :string
520
+ attribute :subpart, :string
504
521
  attribute :stage_iteration, Components::Iteration
505
522
  attribute :date, Components::Date
506
523
  attribute :edition, Components::Edition
@@ -511,7 +528,17 @@ module Pubid
511
528
  attribute :stage, Components::Stage
512
529
  attribute :locality, Components::Locality
513
530
  attribute :typed_stage, Components::TypedStage
514
- attribute :all_parts, Lutaml::Model::Type::Boolean, default: false
531
+ # "All parts" is a class, not a flag: see {Pubid::AllParts}. These two
532
+ # readers keep a caller that read the old attribute working.
533
+ # rubocop:disable Naming/PredicateMethod
534
+ def all_parts
535
+ false
536
+ end
537
+ # rubocop:enable Naming/PredicateMethod
538
+
539
+ def all_parts?
540
+ false
541
+ end
515
542
  # base is declared by supplement subclasses with proper type
516
543
  def base
517
544
  nil
@@ -818,11 +845,15 @@ module Pubid
818
845
  #
819
846
  # This is deliberately a per-class opt-in rather than a module prepended to
820
847
  # every identifier class. That was tried: prepending to ~500 ancestor
821
- # chains made a GOST identical adoption render its adopted ISO Technical
822
- # Report as `ISO TR …` instead of `ISO/TR …`, but only under full-suite
823
- # load — the multi-flavor nondeterminism this file warns about elsewhere.
824
- # An explicit call in the handful of classes that need it changes no
825
- # ancestor chain and cannot have that effect.
848
+ # chains correlated with a GOST identical adoption rendering its adopted
849
+ # ISO Technical Report as `ISO TR …` instead of `ISO/TR …`, but only under
850
+ # full-suite load — the multi-flavor nondeterminism this file warns about
851
+ # elsewhere. The correlation turned out not to be causal: the actual
852
+ # defect was an unrelated foreign-adoption prefix-routing race in
853
+ # `Gost::Builder` (`lib/pubid/gost/CLAUDE.md`), since fixed. The lesson
854
+ # about not prepending stands anyway: an explicit call in the handful of
855
+ # classes that need it changes no ancestor chain and cannot have that
856
+ # effect.
826
857
  #
827
858
  # @param rendered [String] the plain rendering this method produced
828
859
  # @param opts [Hash] the render options it was called with
@@ -1004,6 +1035,28 @@ module Pubid
1004
1035
  Pubid::UrnGenerator::Base
1005
1036
  end
1006
1037
 
1038
+ # Attribute names on THIS class that hold a supplement's OWN identity
1039
+ # date — e.g. an Amendment's own publication year — as distinct from the
1040
+ # date of the document it supplements (reached one layer down, through
1041
+ # `base`/`identifiers`). Empty by default: nothing is protected, so
1042
+ # `exclude(:date)`/`exclude(:year)` fall straight through to every
1043
+ # declared attribute and every nested identifier, the shape almost every
1044
+ # flavor wants.
1045
+ #
1046
+ # A supplement leaf that owns its own date overrides this (CEN
1047
+ # `Amendment` `%i[year]`, CEN `Corrigendum` `%i[year month]`, BSI
1048
+ # `Amendment`/`Corrigendum` `%i[date]`). #exclude then protects those
1049
+ # attributes from a bare `:date`/`:year` — recursion into a *nested*
1050
+ # base/consolidated member is untouched, since recursion always uses the
1051
+ # caller's original args — and requires the explicit `:supplement_year`
1052
+ # key to clear them instead. This is what lets `exclude(:date)` on a
1053
+ # consolidated identifier drop the base standard's date while leaving an
1054
+ # attached amendment's own date alone, without a per-flavor #exclude
1055
+ # override.
1056
+ def self.supplement_date_attributes
1057
+ []
1058
+ end
1059
+
1007
1060
  # Excluded attributes are nilled; every other value is passed through
1008
1061
  # #exclude_from_nested so the exclusion also propagates into nested
1009
1062
  # identifiers — wrapper types (adopted standards, consolidated amendments,
@@ -1023,6 +1076,16 @@ module Pubid
1023
1076
  # has it excluded too. The loop below nils whichever name the flavor has.
1024
1077
  excluded_args << :date if excluded_args.include?(:year)
1025
1078
 
1079
+ own_date_attrs = self.class.supplement_date_attributes
1080
+ # A no-op when own_date_attrs is empty (Array#|/#- with [] returns the
1081
+ # receiver unchanged), which is every class that doesn't override the
1082
+ # hook — no guard needed.
1083
+ if excluded_args.include?(:supplement_year)
1084
+ excluded_args |= own_date_attrs
1085
+ else
1086
+ excluded_args -= own_date_attrs
1087
+ end
1088
+
1026
1089
  attrs = self.class.attributes.each_with_object({}) do |(name, _), h|
1027
1090
  value = excluded_args.include?(name) ? nil : public_send(name)
1028
1091
  h[name] = exclude_from_nested(value, args)
@@ -1111,16 +1174,96 @@ module Pubid
1111
1174
  matches?(other, ignore: [:date, :year]) && self != other
1112
1175
  end
1113
1176
 
1177
+ # The document a wrapper stands in front of: a supplement's base, or
1178
+ # the leading member of a consolidated collection ("BS 7273-4:2015"
1179
+ # inside "BS 7273-4:2015+A1:2021"). Nil for a plain identifier.
1180
+ def wrapped_document
1181
+ return base if self.class.attributes.key?(:base) && base
1182
+
1183
+ if self.class.collection_attribute?(:identifiers) &&
1184
+ identifiers.is_a?(Array) && identifiers.first
1185
+ return identifiers.first
1186
+ end
1187
+
1188
+ nil
1189
+ end
1190
+
1191
+ # The [publisher, number, part] tuple naming the document this
1192
+ # identifier belongs to, resolved through wrappers.
1193
+ def document_identity
1194
+ wrapped_document&.document_identity || [publisher, number, part]
1195
+ end
1196
+
1114
1197
  # Self and +other+ have the same publisher + number but differ in
1115
1198
  # part, subpart, or edition. This is the "sibling documents" pattern:
1116
- # same base standard, different sub-component.
1199
+ # same base standard, different sub-component. Two wrappers compare
1200
+ # through the document they wrap, so e.g. the amendments of one base
1201
+ # standard are siblings while amendments of different documents are
1202
+ # not.
1117
1203
  def sibling_of?(other)
1118
1204
  return false unless other.is_a?(::Pubid::Identifier)
1205
+ return false if self == other
1119
1206
 
1120
- publisher == other.publisher &&
1121
- number == other.number &&
1122
- self != other
1207
+ if wrapped_document && other.wrapped_document
1208
+ document_identity.take(2) == other.document_identity.take(2)
1209
+ else
1210
+ publisher == other.publisher &&
1211
+ number == other.number
1212
+ end
1213
+ end
1214
+
1215
+ # The attributes that name a part of a document.
1216
+ PART_ATTRIBUTES = %i[part subpart parts].freeze
1217
+
1218
+ # The class that #to_all_parts builds. A flavor with its own all-parts
1219
+ # behaviour returns its subclass of {AllPartsIdentifier} here.
1220
+ def self.all_parts_class
1221
+ ::Pubid::AllPartsIdentifier
1222
+ end
1223
+
1224
+ # The attributes that name an edition of a document. An all-parts
1225
+ # identifier ignores them, so it matches every edition. #exclude skips a
1226
+ # name that a class does not declare, so one list serves every flavor; a
1227
+ # flavor with another edition shape overrides this.
1228
+ def self.all_parts_edition_keys
1229
+ %i[date year edition version]
1230
+ end
1231
+
1232
+ # A new identifier for every part of this document. It holds this
1233
+ # identifier, unchanged, as its only member. `to_s` prints the document
1234
+ # without its part and edition ("ISO 9000 (all parts)"), and `===`
1235
+ # matches every part of it in every edition. The receiver does not change.
1236
+ # A parsed "(all parts)" still sets the +all_parts+ flag instead; the two
1237
+ # forms are not equal.
1238
+ # @return [Pubid::AllPartsIdentifier]
1239
+ def to_all_parts
1240
+ self.class.all_parts_class.new(identifiers: [self])
1241
+ end
1242
+
1243
+ # @return [Boolean] true when this identifier names a part of a document
1244
+ def part?
1245
+ without_parts != normalized_copy
1246
+ end
1247
+
1248
+ # A copy without `part`, `parts`, `subpart` and the +extra+ attributes.
1249
+ # Nested identifiers lose them too (#exclude).
1250
+ def without_parts(*extra)
1251
+ normalized_copy(*PART_ATTRIBUTES, *extra)
1252
+ end
1253
+
1254
+ # A copy without the +attrs+ (#exclude), where a part collection holds
1255
+ # [] and never nil. A parse and #from_hash disagree on nil against [] in
1256
+ # some flavors (JIS), so two copies compare equal only after this.
1257
+ def normalized_copy(*attrs)
1258
+ exclude(*attrs).tap do |id|
1259
+ PART_ATTRIBUTES.each do |name|
1260
+ next unless self.class.collection_attribute?(name)
1261
+
1262
+ id.public_send(:"#{name}=", []) if id.public_send(name).nil?
1263
+ end
1264
+ end
1123
1265
  end
1266
+ protected :without_parts, :normalized_copy
1124
1267
 
1125
1268
  # Self is an all-parts collection that covers +other+.
1126
1269
  def includes?(other)
@@ -1140,15 +1283,24 @@ module Pubid
1140
1283
  matches?(other, ignore: [:date, :year, :stage, :typed_stage])
1141
1284
  end
1142
1285
 
1143
- # Self and +other+ have the same publisher + number + part but differ
1144
- # in edition / revision marker.
1286
+ # Self and +other+ are editions of the same document: the edition-year
1287
+ # pair ("ISO 9001:2008" → "ISO 9001:2015"), or two consolidated states
1288
+ # of one base document — "BS 7273-4:2015+A1:2021" is the prior edition
1289
+ # of "BS 7273-4:2015+A2:2023" (pubid/pubid#449). A composite and its
1290
+ # unwrapped base are not editions of each other: bare → +A1 → +A2 is a
1291
+ # supersession series, not an edition-year relationship — the amendment
1292
+ # supplements the base without superseding it.
1145
1293
  def edition_of?(other)
1146
1294
  return false unless other.is_a?(::Pubid::Identifier)
1295
+ return false if self == other
1147
1296
 
1148
- publisher == other.publisher &&
1149
- number == other.number &&
1150
- part == other.part &&
1151
- self != other
1297
+ if wrapped_document && other.wrapped_document
1298
+ document_identity == other.document_identity
1299
+ else
1300
+ publisher == other.publisher &&
1301
+ number == other.number &&
1302
+ part == other.part
1303
+ end
1152
1304
  end
1153
1305
 
1154
1306
  # Any relational predicate holds.
@@ -13,6 +13,7 @@ module Pubid
13
13
  }.freeze
14
14
 
15
15
  def build(parsed_hash)
16
+ all_parts = parsed_hash.delete(:all_parts)
16
17
  typed_stage = Pubid::Idf.locate_stage(parsed_hash[:type_with_stage])
17
18
  identifier = Pubid::Idf.locate_type(typed_stage.type_code).new
18
19
 
@@ -21,7 +22,9 @@ module Pubid
21
22
  end
22
23
 
23
24
  assign_attributes(identifier, parsed_hash)
24
- identifier
25
+ # "(all parts)" names every part of the document, so it wraps the
26
+ # document, which holds no mark itself.
27
+ all_parts ? identifier.to_all_parts : identifier
25
28
  end
26
29
 
27
30
  private
@@ -80,3 +83,5 @@ module Pubid
80
83
  end
81
84
  end
82
85
  end
86
+
87
+ Pubid::Idf::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -5,6 +5,18 @@ module Pubid
5
5
  module Idf
6
6
  # An IDF identifier has no copublisher
7
7
  class Identifier < ::Pubid::Identifier
8
+ # The all-parts identifier of this flavor.
9
+ def self.all_parts_class
10
+ Identifiers::AllParts
11
+ end
12
+
13
+ # A Type INSTANCE, never the raw :key symbol - lutaml materializes
14
+ # defaults during from_hash; casting a Symbol into the
15
+ # component raises InvalidFormatError (pubid#383).
16
+ def self.default_type
17
+ Components::Type.new(abbr: type[:short])
18
+ end
19
+
8
20
  attribute :typed_stage, Components::TypedStage
9
21
 
10
22
  # `number`/`part`/`subpart` are declared here rather than inherited as a
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Idf
5
+ module Identifiers
6
+ # Every part of one IDF document: "IDF 1 (all parts)".
7
+ class AllParts < ::Pubid::Idf::Identifier
8
+ include ::Pubid::AllParts
9
+
10
+ # The document URN; GB and IDF mark no series in the URN.
11
+ def to_urn
12
+ identity.to_urn
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -4,7 +4,7 @@ module Pubid
4
4
  module Idf
5
5
  module Identifiers
6
6
  class Amendment < SupplementIdentifier
7
- attribute :type, Components::Type, default: -> { self.class.type[:key] }
7
+ attribute :type, Components::Type, default: -> { self.class.default_type }
8
8
 
9
9
  TYPED_STAGES = [
10
10
  Components::TypedStage.new(
@@ -4,7 +4,7 @@ module Pubid
4
4
  module Idf
5
5
  module Identifiers
6
6
  class Corrigendum < SupplementIdentifier
7
- attribute :type, Components::Type, default: -> { self.class.type[:key] }
7
+ attribute :type, Components::Type, default: -> { self.class.default_type }
8
8
 
9
9
  TYPED_STAGES = [
10
10
  Components::TypedStage.new(
@@ -5,7 +5,7 @@ module Pubid
5
5
  module Identifiers
6
6
  # International Standard Identifier
7
7
  class InternationalStandard < SingleIdentifier
8
- attribute :type, Components::Type, default: -> { self.class.type[:key] }
8
+ attribute :type, Components::Type, default: -> { self.class.default_type }
9
9
 
10
10
  TYPED_STAGES = [
11
11
  Components::TypedStage.new(
@@ -4,7 +4,7 @@ module Pubid
4
4
  module Idf
5
5
  module Identifiers
6
6
  class ReviewedMethod < SingleIdentifier
7
- attribute :type, Components::Type, default: -> { self.class.type[:key] }
7
+ attribute :type, Components::Type, default: -> { self.class.default_type }
8
8
  TYPED_STAGES = [
9
9
  Components::TypedStage.new(
10
10
  code: :pwirm,
@@ -3,6 +3,7 @@
3
3
  module Pubid
4
4
  module Idf
5
5
  module Identifiers
6
+ autoload :AllParts, "#{__dir__}/identifiers/all_parts"
6
7
  autoload :Amendment, "#{__dir__}/identifiers/amendment"
7
8
  autoload :Corrigendum, "#{__dir__}/identifiers/corrigendum"
8
9
  autoload :InternationalStandard,
@@ -12,7 +12,7 @@ module Pubid
12
12
  #
13
13
  # Note: The type attribute is inherited from the base Identifier class.
14
14
  # Concrete classes should add their own default value like:
15
- # attribute :type, Components::Type, default: -> { self.class.type[:key] }
15
+ # attribute :type, Components::Type, default: -> { self.class.default_type }
16
16
  class SingleIdentifier < Identifier
17
17
  end
18
18
  end
@@ -0,0 +1,31 @@
1
+ # IEC flavor notes
2
+
3
+ IEC typed stages, `from_hash` equality, and the `number` retype that deleted `Iec::Components::Code`.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/iec/` or `spec/pubid/iec/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **IEC `from_hash(to_hash) == parse` — two `from_hash` defaults that diverged from the parse path**: `==` was false for **every** IEC identifier, while `to_s`, `to_urn` and `to_hash` were all correct — so the relaton index gate (`from_hash(to_hash) == to_hash`) passed and a lookup (parsed reference vs `from_hash`-ed row) silently returned nothing. Two independent causes, one family. **(1) `original_abbr`** — the JCGM defect verbatim, with a **second** injector: `Identifier.published_typed_stage` (the attribute default) **and** `#stage_from_kv` both `dup`ped the registry entry and set `original_abbr = canonical_abbreviation`, where the parse path (`Builder#locate_typed_stage`, the `:type_with_stage` cast) leaves it nil. Both writes are deleted; the `dup` stays (`TYPED_STAGES` is frozen, its elements are not). **The hand-off warned this fix was rendering-sensitive because IEC reads the field at `iec/builder.rb:161-163` (long-vs-short `Amd 1` vs `AMD1`). It is not: that read is UNREACHABLE.** The block is gated on `identifier.class.attributes.key?(:rendering_style)`, and **no IEC class declares a `rendering_style` attribute** — verified by force-loading all 26 IEC identifier classes and inspecting `.attributes`. The whole block (builder.rb 155–200) is dead, so the read is doubly dead: never executed, and reading a field already nil on the parse path. The long spelling is already collapsed on `main` (`…/Amd 1:2017` renders back `…/AMD1:2017`). The block is **kept with an explicit UNREACHABLE comment** rather than deleted, so the next reader does not mistake it for a live consumer again. **The general lesson: before copying the JCGM delete to another flavor, check not just whether the field is read but whether the READ IS REACHABLE — a gated block whose gate is never true is not a consumer.** **(2) `Amendment`/`Corrigendum` redeclared `attribute :type`** with `default: -> { self.class.type[:key] }`. The redeclaration shadowed the derived reader `Iec::Identifier#type` (`typed_stage&.to_type`) on those two classes only; `type` is not serialized, so on `from_hash` the default assigned the **Symbol** `:amd`/`:cor` where the parse path assigns a `Components::Type`, and lutaml does not cast it. Both declarations are deleted so the two classes inherit the derived reader like every other IEC type; the class method `self.type` is untouched and still used. This is the AMCA lesson in another guise — **a default (or converter) that yields a different runtime shape from the parse path is a sign the attribute should not be there at all**. It also repairs a **parse-path** bug, not just the round trip: `Builder#wrap_with_consolidated` builds its supplement directly and never calls `assign_attributes`, so a consolidated amendment member (`IEC 60050-300:2001+AMD1:2005 CSV`) returned the bare Symbol `:amd` from `.type` on `main` even when parsed — pinned by a spec. **Latent, deliberately not changed:** `Builder#locate_typed_stage` (the parse-path lookup) hands out the shared frozen-array element **without** a `dup`, unlike the two from_hash paths; harmless today because nothing mutates a `TypedStage` in place, but it is the same "frozen array, unfrozen elements" hazard. Verified: `to_s`, `to_urn` and `to_hash` **byte-identical** across all 12,303 inputs in `spec/fixtures/iec/identifiers/pass/`, so nothing serialized moves and **no index migration follows**; `==` rises from 0 to **9,914 of 12,302**. **Verified against the published `relaton-data-iec` dataset too, which is the measurement that matters**: over all **32,817 `index-v2.yaml` rows**, the consumer shape `parse(row.to_s) == from_hash(row)` goes from **0 to 32,817** (and `matches?(ignore: [:year])` with it), while `from_hash(h).to_hash == h` holds for all 32,817 **both before and after** — so the published index is already valid and **needs no regeneration**. Over the 32,855 primary docidentifiers in `data/*.yaml`, 32,813 parse (the 42 failures are pre-existing title-bearing forms like `IEC Technology Report Cyber security:2019`, unchanged by this) and for all 32,813 `to_s`, `to_urn` and `root.number` are identical before and after, with **0 empty index keys**. The fixture corpus is the stricter test only because it contains WorkingDocument/PNW/TRF forms that the crawler does not publish. Locked by `spec/pubid/iec/typed_stage_equality_spec.rb` and by deleting the `"Pubid::Iec"` entry from `PENDING_EQUALITY`. **The 2,388 that remain have a THIRD, unrelated cause and are deliberately out of scope**: `WorkingDocument` (`technical_committee`/`wd_number`/`wd_stage`/`wd_language`, 2,318 ids — `to_hash` is `{"_type"=>"pubid:iec:working-document"}` and **`root.number` is nil**, so every WD row keys the empty string), the PNW `wp_stage`/`wp_type` pair (32) and `TestReportForm#cispr_identifier` (38) are **never serialized at all**. Mapping them ADDS keys to `to_hash` and forces a `relaton-data-iec` re-crawl, which is a separate decision — and a low-value one today, since the published index contains **zero** `working-document` and **zero** `test-report-form` rows. (hand-off: iec-typed-stage-original-abbr; follow-up hand-off: iec-unserialized-identity-attributes.)
8
+
9
+ - **PNW is a stage of its own (10.20), the stage separator is a space, and the URN is built from components — issue #360 items 1, 2 and 4.** The three are one change, because on `main` the URN was produced by a regex scraped over `identifier.to_s`, so the stage fix and the separator fix both moved it. Reference behaviour was taken from the installed `pubid-iec 1.15.21`, **except for the URN, where 1.x is the wrong oracle** — see below.
10
+ **(1) PNW.** `identifiers/international_standard.rb` listed `PNW` inside the `code: :pwi` stage (harmonized `00.*`), so `IEC PNW 1000-1:2023 ED2` rendered back as `IEC/PWI 1000-1:2023 ED2`. IEC's portal and `pubid-iec-1.15.21/stages.yaml` both give `PNW: ["10.20"]`. PNW now has its own `code: :pnw` TypedStage with `harmonized_stages: ["10.20"]`, and **`10.20` was removed from `:np`** so a lookup by harmonized code is unambiguous — the URN now performs exactly that lookup. `Identifiers::WorkingDocument#stage` built a bare `Components::Stage` from a hand-written `{"PWI"=>"pwi","PNW"=>"pnw"}` map, giving a stage with no harmonized code and a `"pnw"` stage code that existed nowhere else; it resolves through `Pubid::Iec.locate_stage` instead. **The grammar needed no change for the reported form**: `working_programme_with_publisher` required a literal `/` after the publisher, so `IEC PNW 1000-1:2023` already fell through to `type_with_stage`. That rule was **deleted** (it had zero corpus inputs), and `render_working_document` no longer prefixes the publisher: a work-programme registration renders bare (`PNW 65-915 ED1`), because its number is TC-scoped (`86B-15`, `SyCCOMM-1`) rather than an IEC document number, and prefixing `IEC ` would break idempotence — `IEC PNW 65-915 ED1` re-parses as an International Standard. That alone repaired 32 fixture entries.
11
+ **(2) Separator.** `SingleIdentifier#publisher_portion` printed `/` without copublishers and a space with them, while **twelve** per-type classes overrode it to force a space — so `IEC TS 60038` and `IEC DTR 62048` were already right and `IEC/CD 60038`, `IEC/FDAM 60038-1`, `IEC/DCOR 60038-1`, `IEC/DISH 60050-191` were not. The base now always uses a space (the slash stays the copublisher separator only) and **all twelve overrides are deleted**: nine of them built from `publisher.to_s` with no copublisher handling and no `super`, so deleting them **repairs** `ISO/IEC PAS 62975` → `ISO PAS 62975`, `ISO/IEC SRD 12345:2020` → `ISO SRD 12345:2020` and `IEC/IEEE SRD 62582:2022` → `IEC SRD 62582:2022` (three specs had pinned that loss). `renderer.rb#render_supplement_standalone` is a **second, independent** publisher/stage join that `publisher_portion` never reaches — it joined every part with `/` — and needed the same fix. **Input still accepts both spellings**; only rendering normalises. Measured: `to_s` changed for exactly 32 of 12,302 corpus entries, all of them the work-programme repairs above; `to_hash`, the identifier class and `root.number` are byte-identical for all 12,302.
12
+ **(3) URN. Do not use pubid-iec 1.x as the oracle here.** 1.x emits `urn:iec:std:iec:ts:60932:1988:::` — the type *before* the number — while all 32,831 published `relaton-data-iec` URNs put it *after* the date (`urn:iec:std:iec:60932:1988:ts::`). pubid 2's slot **order** was already right and is kept; what was missing were two slots the scrape could never reach. The layout is now `urn:iec:std:{authority}:{number}[-{part}[-{subpart}]]:{date}:{S7}:{S8}:{language}{adjuncts}`, where **S7** is `stage-{harmonized}` for a non-published stage and the legacy type token (`tr`/`ts`/`pas`/`srd`/`guide`) otherwise, and **S8** is the deliverable (`csv`/`rlv`/`exv`/`cmv`/`ser`) or, failing that, `ed-{n}`. So `IEC PNW 1000-1:2023 ED2` → `urn:iec:std:iec:1000-1:2023:stage-10.20:ed-2:`, the string the issue asked for. **A draft of a typed document keeps both, type first** (`IEC DTS 62271-1` → `ts-stage-50.00`): the harmonized codes are shared across types, so a stage-only slot would collide a draft TS, a draft TR and a draft International Standard, and `parse_urn` would resolve all three to an International Standard. Three details are load-bearing and are pinned by specs: the **authority takes the last whitespace-delimited token of a publisher body**, because `IEC CA 01:2017` is published as `urn:iec:std:ca:01:2017:::`; **`Components::Date#render` under a URN context drops the month** (the ISO convention), so the date slot reads the printed form instead and `IEC CA 01:2025-10` keeps its month; and a **deliverable is not always on the outermost wrapper** (`IEC 60079-10-1:2020 CMV/COR1:2021` is a corrigendum around the CMV), so `vap` is searched down the chain. A CISPR test report form carries no number of its own — the document sits in `cispr_identifier` — and the generator falls back to it, which is what keeps those 38 ids from losing their URN entirely.
13
+ **What the regex was actually costing.** Across the corpus the publisher slot held: `iec` 5,401, **nil 2,333**, **`trf` 2,142**, `iso-iec` 2,094, … `iec-pnw` 12, `iec-pwi` 9, `od` 6, `cs` 3, `ca` 3, `dir` 3. `IECEE TRF 10079-1A:2020` keyed `urn:iec:std:trf:10079-1::::` — publisher, letter part and year all gone. After the change **0 URNs are lost, 15 are gained, none gets fewer slots**, and 2,838 change, every one in the direction of carrying more identity.
14
+ **The published corpus changes, and that was a deliberate call.** 668 of the 32,831 published URNs no longer match, in three groups: **598** where the old `[\d-]+` stopped at a letter and took the date with it (`CISPR 11A:1976` → `urn:iec:std:cispr:11::::`, the same URN as `CISPR 11:1976`), **68** multi-deliverable ids where only the first code survived (`EXV-CMV` → `exv`), and **2** fragment identifiers that gain `ed-N`. All 668 are places where the published value is lossier than the identifier, and the measurement that justifies the change is the collision count: the published scheme puts **532 rows in 158 collision groups**, the new one **11 rows in 4**. `relaton-data-iec` therefore needs regenerating — hand-off `relaton-data-iec-regenerate-urns`.
15
+ **The fixture net had to be built first.** `spec/pubid/iec/fixtures_spec.rb` read `archived-gems/pubid-iec/spec/fixtures/iec-pubid.txt` at an 80% gate, with an empty failure-reporting block, so `spec/fixtures/iec/` was guarded by nothing and 35 `pass/` entries were already stale on `main`. It is rewritten on the `spec/pubid/csa/fixtures_spec.rb` shape (glob tripwire, zero-failure per file over `pass/` and `fail/`, conservation against `full/`, disjointness); note IEC's `full/` is many files, not one `identifiers.txt`, and carries the `!input!rendered` marker itself, so both sides split it. Regenerating exposed the CLAUDE.md landmine in the flesh: **five `ISO/IEC DIR …` fixtures had been hand-added to `pass/` without being added to `full/`**, and the classifier deleted them; they are restored into `full/iso-iec-pubid.txt`. The conservation example is what makes that loud from now on. (hand-off: iec-pnw-separator-urn; consumer follow-up: `relaton-data-iec` re-crawl.)
16
+
17
+ - **`edition` serializes flat, and the date does not need to** (pubid#360 item 3): IEC's edition emitted a one-key hash, `edition: {number: "2"}`, for a value that is only ever a scalar. `lib/pubid/iec/builder.rb` has a **single** edition construction site and it writes `Edition.new(number: value&.to_s)` and nothing else, so the component is degenerate by construction — confirmed over all **12,331** parseable IEC fixtures, where 607 carry an edition and `number` is the only field ever populated (`year` and `original_text`: zero). It now emits `edition: "2"`, and `from_hash` reads both that and the stored nested form, so no published `relaton-data-iec` row stops deserializing. The mapping at `lib/pubid/iec/identifier.rb` stays a plain `map "edition", to: :edition`; the flattening is the shared rule on `::Pubid::Identifier`, not an IEC converter. **The date half needed no work at all** — IEC already serializes it flat as top-level `year`/`month`/`day`/`undated` through its own converters, so no `date` key ever reaches the shared flattener. **That is not a coincidence worth ignoring: IEC is the flavor that proves the flat rule has to be per identifier rather than per flavor.** Of its 9,730 dated identifiers, 9,728 carry a year alone, but **10 carry a month** (`IEC CA 01:2025-10`, `IECEE 02:2025-06(en)`, `IECEE AD-001:2024-06(en)`) and **2 are undated** (`IEC 60050:--`, `IEC 60050:—`). A flavor-wide "IEC dates are year-only" switch would have thrown those twelve away silently. `spec/pubid/flat_scalar_serialization_spec.rb` asserts both directions on IEC for exactly this reason.
18
+
19
+ - **`WorkingDocument` is the one IEC type that still renders plain under `annotated: true`.** The cross-flavor annotator (`Renderers::Annotator`, see the root `CLAUDE.md`) wraps the identifier's own component values where they appear in the rendered string; a WorkingDocument's identity lives in `technical_committee` / `wd_number` / `wd_stage` / `wd_language`, none of which is one of those components. This is the same unserialized-identity gap already recorded above (`to_hash` is `{"_type"=>"pubid:iec:working-document"}` and `root.number` is nil), seen from the rendering side. Nothing raises — the flag is accepted and simply finds no token — so it is a missing span, not a failure. Fixing it means mapping those attributes, which forces a `relaton-data-iec` re-crawl, and remains the separate decision it already was. (hand-off: iec-unserialized-identity-attributes.)
20
+
21
+ - **The attribute path takes a plain scalar for language and copublisher — the residue of issue #360 item 3.** Items 1, 2 and 4 gave `IEC PNW 1000-1:2023 ED2` the right stage, separator and URN, and the constructor contract gave it `edition: 2` and `year: 2023`. The URN the issue asked for also carries a language, and that half still failed: `languages: ["en"]` stored raw Strings, so `to_s` raised `wrong number of arguments` at `lib/pubid/iec/single_identifier.rb:59` (`lang.to_s(lang_single:)`) and `to_urn` raised `undefined method 'code'` at `lib/pubid/iec/urn_generator.rb:190`. `copublishers: ["ISO"]` raised the same way on `body`. Both are now coerced by the shared `DEGENERATE_COMPONENT_FIELDS` rule on `::Pubid::Identifier` (see the root `CLAUDE.md`), not by an IEC converter — and IEC is the flavor that proves the wrap has to key on the component **class**, because its copublisher is the subclass `Pubid::Iec::Components::Publisher`, which an attribute-name or exact-class table would miss. So `Identifiers::InternationalStandard.new(number: "1000", part: "1", year: 2023, edition: 2, languages: ["en"])` now renders `IEC 1000-1:2023 ED2(en)` and yields `urn:iec:std:iec:1000-1:2023::ed-2:en`. The Hash and component forms are unchanged and still agree with the scalar form. **`number` was deliberately NOT coerced** — and the bullet below resolves that tension the other way round, by making the attribute a `:string` so a String is the type rather than a tolerated intruder. Note the copublisher renders `IEC/ISO 1000`, IEC's own ordering — the coercion does not touch order. (hand-off: iec-degenerate-component-coercion.)
22
+
23
+ - **`number`/`part`/`subpart` are plain `:string`, `Iec::Components::Code` is deleted, and three of eleven `key_value` converters go with it.** The component was an empty box: over all **12,331** parseable pass fixtures the three were an `Iec::Components::Code` **9,975 / 5,422 / 2,416** times and **not one** populated `prefix`, `parts`, `part` or `subpart` inside it. The subclass existed solely for a `to_s` that prints `"#{prefix} #{value}"`; `Code.parse` was the only site that ever passed `prefix:` and it had **zero callers**, as did the `full_code` alias, and the class had zero references outside `identifier.rb` and its own file. So the subclass is deleted outright along with its `autoload`.
24
+ **This was a live defect, not tidiness, and it is the reason to prefer retyping over coercing.** The parse path produced a Code while the constructor path left a String — a `:string` assigned to a Code-typed attribute stays a String — so `Identifiers::InternationalStandard.new(number: "60068", part: "2", subpart: "28", year: "2008")` was **not `==`** `Pubid::Iec.parse("IEC 60068-2-28:2008")`, and `#matches?` is `exclude(*ignore) == other.exclude(*ignore)`, so every match between a hand-built reference and a parsed one silently returned false. `to_s`, `to_urn` **and** `to_hash` all agreed on both sides, which is exactly why nothing caught it: a relaton index lookup just returned nothing, with no error. `spec/pubid/attribute_construction_spec.rb` had even pinned the String half (`"leaves a scalar number uncoerced (IEC)"`) and asserted `to_s` and `to_hash` on the two paths — but never `==`, so the spec that was closest to the bug documented it as intended behaviour. **The lesson generalises: a spec comparing two construction paths must assert `==`, not only that they render and serialize alike.**
25
+ **`with:` was doing two jobs, and only one of them was the Code.** `emit_code`/`build_code` unwrapped and rewrapped the component — identity functions once the attribute is a String — but the converters were *also* the mechanism by which `DelegatedFieldSuppression` no-opped the delegated fields of the four wrapper types. **Placement replaces the denylist**: `number`/`part`/`subpart` now map on **`Pubid::Iec::SingleIdentifier`**, the parent of `Identifiers::Base` and `SupplementIdentifier` — the only two classes that own a number — while `FragmentIdentifier`, `ConsolidatedIdentifier`, `VapIdentifier` and `SheetIdentifier` inherit from `Iec::Identifier` **directly** and are therefore excluded structurally, not by remembering to add a no-op. That distinction is not academic: the surviving denylist suppresses `year` but **not `month`/`day`/`undated`**, so `IEC CA 01:2025-10 CSV` still emits a top-level `"month" => "10"` duplicating the one inside `base` — a pre-existing leak, left alone here because removing a key from published rows is a wire change (hand-off `iec-wrapper-month-day-leak`).
26
+ **Two more converters were pure boilerplate.** `all_parts` is a Boolean attribute with `default: false` that `::Pubid::Identifier#to_hash` already drops, so it takes the plain GB spelling `map "all_parts", to: :all_parts, render_default: false`. `stage_iteration` holds a `Components::Iteration` — one `:string` field, `number`, whose `to_s`/`render` return it — so it joins the shared `FLAT_SCALAR_COMPONENTS`/`FLAT_SCALAR_FIELDS` tables on `::Pubid::Identifier` and takes a plain map too. **The blast radius of that shared change is exactly ISO and IEC**, the only two flavors that map the key, and both already emitted the bare scalar through their own converters — ISO keeps its converter, which the inflation guard defers to by design. Net: **12 converter methods and 2 helpers deleted**. The five groups that keep `with:` all earn it — `year`/`month`/`day` and `undated` split one `date` attribute across four keys, `publisher`/`copublishers` map a `Components::Publisher` to its `body` and omit the IEC default, and `stage` maps `typed_stage` to its code with class-scoped resolution on load. **The rule this leaves behind: a converter is for a custom class or for splitting one attribute across several keys; a primitive needs a plain map.**
27
+ **The shared splitter gained a hook rather than a special case.** `Builder::Base#parse_number_with_part(value, code_class:)` is used by ISO and IEC only; `code_class:` is now optional and the three constructions route through `wrap_number_component`, which returns the raw scalar when `code_class` is nil. ISO still passes `Pubid::Iso::Components::Code` and is untouched. This is the extension point the remaining tranches reuse.
28
+ **Verified by replaying a captured baseline, not by the suite alone.** Over all **12,331** IEC pass-fixture ids, `to_s`, `to_urn`, `to_mr_string`, `root.number`, the identifier class, the deep-key-sorted `to_hash`, the `from_hash(to_hash) == to_hash` flag and the `from_hash(to_hash) == id` flag are **byte-identical, 0 diffs in every field**. The verbatim `to_hash` differs for 9,950 rows and for **one reason only** — `number`/`part`/`subpart` moved to the end of the key order, because lutaml *appends* a subclass's `key_value` maps to the parent's; removing those three keys leaves the two key sequences identical for every row, and `Hash#==` ignores order, so **0 rows differ in any other way**. Over all **7,573** ISO pass-fixture ids every field including the verbatim hash is byte-identical, so neither shared-file change moved ISO. Against the published `relaton-data-iec` `index-v2.yaml` (**32,847** rows) `from_hash(row).to_hash == row` holds for 32,845 and `parse(from_hash(row).to_s) == from_hash(row)` for all 32,847, **both before and after** — the 2 misses are the pre-existing rows storing the old nested `edition: {number: …}` form. **No index migration follows**, and that is measured rather than assumed. Locked by `spec/pubid/iec/number_string_spec.rb` (structural tripwire over the whole class tree, the constructed-vs-parsed `==`, the wrapper hashes, and the absence of the deleted constant) and by IEC's entry in `spec/pubid/number_string_retype_spec.rb`.
29
+ **Known residue, deliberately unchanged:** after the map moved, `Pubid::Iec::Identifier.from_hash({"number" => "60038"})` — a hand-written hash with **no `_type`**, on the bare base class — silently drops the key instead of building an `Identifier` that holds it. That object is already degenerate (`to_s` on it raises `NoMethodError` for `publisher_portion`) and nothing in this repo or in `relaton-iec` constructs one. (hand-off: iec-number-string.)
30
+
31
+ **Two parent-slot gaps recorded by the cross-flavor `base` survey** (`lib/pubid/bsi/CLAUDE.md` has the measured version). `Identifiers::TestReportForm#cispr_identifier` is a polymorphic `Identifier` parent slot with no `#root` override, so `root` returns the form itself. And `Identifiers::ConsolidatedIdentifier#base_document` returns `identifiers&.first` **without** the recursive `&.base_document` that its BSI, CSA and CEN counterparts use — a one-level peel where the others peel fully, so a consolidated edition of a supplement does not reduce to the bare standard. Neither is chased here; both are pinned by nothing, which is why they are written down.
@@ -28,6 +28,8 @@ module Pubid
28
28
  end
29
29
 
30
30
  def build(parsed_hash)
31
+ all_parts = parsed_hash.delete(:all_parts)
32
+
31
33
  # Handle sheet_supplement_identifier pattern:
32
34
  # {base: {...}, sheet_number: ..., sheet_year: ..., type_with_stage: "COR", number_with_part: "1", date: "1995"}
33
35
  # This is a Corrigendum/Amendment wrapping a SheetIdentifier
@@ -218,7 +220,9 @@ module Pubid
218
220
  vap_suffix_data)
219
221
  end
220
222
 
221
- identifier
223
+ # "(all parts)" and the "ser" URN slot name every part of the
224
+ # document, so they wrap the document, which holds no mark itself.
225
+ all_parts ? identifier.to_all_parts : identifier
222
226
  end
223
227
 
224
228
  def handle_key(identifier, key, value)
@@ -455,3 +459,5 @@ module Pubid
455
459
  end
456
460
  end
457
461
  end
462
+
463
+ Pubid::Iec::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -7,6 +7,8 @@ module Pubid
7
7
  # Individual Amendment within a consolidated chain
8
8
  # Single Responsibility: Represents a single amendment with number and year
9
9
  class Amendment < Lutaml::Model::Serializable
10
+ include ::Pubid::SubsetMatch
11
+
10
12
  attribute :number, :string
11
13
  attribute :year, :string, default: -> {}
12
14
 
@@ -21,6 +23,8 @@ module Pubid
21
23
  # Single Responsibility: Represents multiple amendments combined with + notation
22
24
  # Example: +AMD1:2020+AMD2:2022
23
25
  class ConsolidatedAmendment < Lutaml::Model::Serializable
26
+ include ::Pubid::SubsetMatch
27
+
24
28
  attribute :amendments, Amendment, collection: true
25
29
 
26
30
  def to_s
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Single Responsibility: Represents sheet number in IEC identifiers
9
9
  # Example: IEC 60695-2-1/1:1994 (where /1 is the sheet)
10
10
  class Sheet < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :number, :string
12
14
 
13
15
  def to_s
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Single Responsibility: Represents TRF-specific publishing information
9
9
  # Example: IECEE TRF or IECEx TRF publications
10
10
  class TrfInfo < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :publisher, :string, default: -> {}
12
14
  attribute :series, :string, default: -> {}
13
15
  attribute :version, :string, default: -> {}
@@ -7,6 +7,8 @@ module Pubid
7
7
  # VAP (Validation Assessment Programme) suffix codes
8
8
  # Single Responsibility: Represents VAP validation status suffixes
9
9
  class VapSuffix < Lutaml::Model::Serializable
10
+ include ::Pubid::SubsetMatch
11
+
10
12
  # VAP suffix codes as defined by IEC
11
13
  CODES = {
12
14
  "CMV" => "Common Modifications and Variations",
@@ -16,7 +16,7 @@ module Pubid
16
16
  # converter can be no-opped this way, so a key that needs `with:` for
17
17
  # another reason — year, publisher, copublishers, stage — still lists here.
18
18
  # (`month`/`day`/`undated` are missing from that list and leak a duplicated
19
- # top-level key on a wrapper; pre-existing, see docs/flavors/iec.md.)
19
+ # top-level key on a wrapper; pre-existing, see lib/pubid/iec/CLAUDE.md.)
20
20
  module DelegatedFieldSuppression
21
21
  def year_to_kv(_model, _doc); end
22
22
  def publisher_to_kv(_model, _doc); end
@@ -25,6 +25,12 @@ module Pubid
25
25
  end
26
26
 
27
27
  class Identifier < ::Pubid::Identifier
28
+ # IEC prints "(all parts)" and has a series URN, so it has its own
29
+ # all-parts class.
30
+ def self.all_parts_class
31
+ Identifiers::AllParts
32
+ end
33
+
28
34
  # Override base types with IEC-specific ones. publisher defaults to the
29
35
  # type's implied publisher (IEC), so an omitted publisher key reconstructs
30
36
  # correctly on from_hash.
@@ -187,7 +193,6 @@ module Pubid
187
193
  map "stage", with: { to: :stage_to_kv, from: :stage_from_kv }
188
194
  # A Boolean attribute whose `false` default ::Pubid::Identifier#to_hash
189
195
  # already drops, so a plain map suffices (the GB spelling).
190
- map "all_parts", to: :all_parts, render_default: false
191
196
  end
192
197
 
193
198
  # --- date serialized flat as year/month/day ---
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iec
5
+ module Identifiers
6
+ # Every part of one IEC document: "IEC 80000 (all parts)".
7
+ #
8
+ # The URN is the series URN: the document URN with "ser" in the
9
+ # deliverable slot.
10
+ class AllParts < ::Pubid::Iec::Identifier
11
+ include ::Pubid::AllParts
12
+
13
+ def to_urn
14
+ ::Pubid::Iec::UrnGenerator.new(identity).generate_series
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -3,6 +3,7 @@
3
3
  module Pubid
4
4
  module Iec
5
5
  module Identifiers
6
+ autoload :AllParts, "#{__dir__}/identifiers/all_parts"
6
7
  autoload :Amendment, "#{__dir__}/identifiers/amendment"
7
8
  autoload :Base, "#{__dir__}/identifiers/base"
8
9
  autoload :ComponentSpecification, "#{__dir__}/identifiers/component_specification"
@@ -67,7 +67,6 @@ module Pubid
67
67
  parts << " DB"
68
68
  end
69
69
 
70
- parts << " (all parts)" if id.all_parts
71
70
 
72
71
  result = parts.compact.join
73
72
 
@@ -43,6 +43,14 @@ module Pubid
43
43
  "InterpretationSheet" => "ish",
44
44
  }.freeze
45
45
 
46
+ # The series URN of the document: "ser" takes the deliverable slot.
47
+ # {Identifiers::AllParts} calls it with the document, because the
48
+ # document itself carries no all-parts mark.
49
+ def generate_series
50
+ @series = true
51
+ generate
52
+ end
53
+
46
54
  def generate
47
55
  doc = document
48
56
  return nil if doc.nil?
@@ -146,7 +154,7 @@ module Pubid
146
154
  # A deliverable code owns the slot; the edition takes it only when there
147
155
  # is none.
148
156
  def deliverable_slot
149
- return "ser" if maybe_of(identifier, :all_parts)
157
+ return "ser" if @series
150
158
 
151
159
  vap = vap_codes
152
160
  return vap unless vap.empty?
@@ -41,7 +41,6 @@ module Pubid
41
41
  end
42
42
 
43
43
  id = Pubid::Iec::Identifier.parse(code)
44
- id.all_parts = true if all_parts && id.class.attributes.key?(:all_parts)
45
44
  # The language slot joins codes with a hyphen ("en-fr"). Building one
46
45
  # Language from the whole field gave a single bogus language that
47
46
  # rendered as "(en-fr)".
@@ -50,7 +49,9 @@ module Pubid
50
49
  ::Pubid::Components::Language.new(code: code)
51
50
  end
52
51
  end
53
- id
52
+ # The "ser" slot names every part of the document, so it wraps the
53
+ # document it just built.
54
+ all_parts ? id.to_all_parts : id
54
55
  end
55
56
 
56
57
  private