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
@@ -37,6 +37,11 @@ module Pubid
37
37
  def initialize(input)
38
38
  @input = input.to_s.strip
39
39
  @cleaned = Core::UpdateCodes.apply(@input, :nist)
40
+ # The format describes the string the parser will see: capture
41
+ # it right after the update-codes remap, before the stages'
42
+ # cosmetic spacing (a dotted catalogue alias that remaps to the
43
+ # space form renders short; the dotted originals stay :mr).
44
+ @format = @cleaned.include?(".") && !@cleaned.match?(/\s/) ? :mr : :short
40
45
  end
41
46
 
42
47
  # Run every normalization stage and return a Result.
@@ -53,6 +58,14 @@ module Pubid
53
58
  # Extracted so rubocop can scope length/ABC metrics narrowly.
54
59
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
55
60
  def run_stages
61
+ # Short-form "supprev" is the catalogue spelling of the plain
62
+ # supplement ("NBS CIRC 154supprev" ≡ "NBS CIRC 154sup"; the
63
+ # revision-bearing identity is the mr spelling "154suprev").
64
+ # Rewrite before the supplement/revision stages; the mr form is
65
+ # untouched.
66
+ if detected_format == :short
67
+ @cleaned = @cleaned.gsub("supprev", "sup")
68
+ end
56
69
  normalize_spurious_u_suffix!
57
70
  normalize_publisher_and_series!
58
71
  normalize_lcirc_supplement_contexts!
@@ -75,12 +88,14 @@ module Pubid
75
88
  normalize_part_notation!
76
89
  normalize_series_specific_spacing!
77
90
  normalize_verbose_keywords!
91
+ normalize_legacy_corpus_spellings!
78
92
  end
79
93
  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
80
94
 
81
- # Detect input format: :mr (dot-separated machine-readable) or :short.
95
+ # Detect input format: :mr (dot-separated machine-readable) or
96
+ # :short. Frozen in #initialize (post update-codes, pre-stages).
82
97
  def detected_format
83
- @input.include?(".") && !@input.match?(/\s/) ? :mr : :short
98
+ @format
84
99
  end
85
100
 
86
101
  private
@@ -167,12 +182,21 @@ module Pubid
167
182
  # Trailing "-a" → "-A" at end of identifier.
168
183
  def uppercase_dash_letter!
169
184
  @cleaned = @cleaned.gsub(/(\d)-([a-z])$/) { "#{$1}-#{$2.upcase}" }
185
+ # Same letter when a part tail follows ("-add", " Add."), so the
186
+ # canonical case survives; lowercase update/translation codes
187
+ # ("-upd", ".uppl") never match (next char is a letter).
188
+ @cleaned = @cleaned.gsub(/(\d)-([a-z])(?=[-.\s])/) { "#{$1}-#{$2.upcase}" }
170
189
  end
171
190
 
172
191
  # Trailing "a" → "A" when attached directly to a digit (excludes
173
192
  # "r" to preserve revision+year patterns like "73-197r").
174
193
  def uppercase_trailing_letter!
175
194
  @cleaned = @cleaned.gsub(/(\d)([a-z&&[^r]])$/) { "#{$1}#{$2.upcase}" }
195
+ # Same letter when an addendum tail follows ("-add", " Add."),
196
+ # so the canonical part case survives ("800-38a-add" ->
197
+ # "800-38A"); part digits ("800-85a-1") and update/translation
198
+ # codes ("-upd", ".uppl") never match.
199
+ @cleaned = @cleaned.gsub(/(\d)([a-z&&[^r]])(?=\s*[-.\s]\s*[aA]dd)/) { "#{$1}#{$2.upcase}" }
176
200
  end
177
201
 
178
202
  # Letter suffix on revision: "22r1a" → "22r1A".
@@ -336,6 +360,33 @@ module Pubid
336
360
  end
337
361
  end
338
362
 
363
+ # Legacy corpus spellings: catalogue forms the mr grammar and the
364
+ # renderers cannot round-trip on their own. Each rule mirrors an
365
+ # already-parseable spelling of the same document.
366
+ def normalize_legacy_corpus_spellings!
367
+ # The mr renderer prints the translation code with its own
368
+ # leading dot ("955-S..uppl"); the parser wants one dot.
369
+ @cleaned = @cleaned.gsub("..", ".")
370
+ # Trailing-dot addendum spelling; the canonical render comes from
371
+ # the lowercase ".add" parse ("NBS.TN.467pt1.Add." -> ".add").
372
+ @cleaned = @cleaned.gsub(/\.Add\.\z/, ".add")
373
+ # Edition glued to the series in mr form ("NBS.CIRCe2" is
374
+ # "NBS.CIRC.e2"; digits before "e" are untouched: "24e7", and
375
+ # the short form never glues an edition to a letter part:
376
+ # "150-1Ae2009").
377
+ if detected_format == :mr
378
+ @cleaned = @cleaned.gsub(/([A-Z])e(\d)/, '\1.e\2')
379
+ end
380
+ # Update markers without a number render a phantom "1";
381
+ # "…-upd" is the numberless spelling of "…-upd1".
382
+ @cleaned = @cleaned.gsub(/-upd\z/, "-upd1")
383
+ # Handbook legacy renumbering in the all-dash spelling: the
384
+ # e-form stage already eats the prefix generically ("HB
385
+ # 150-1e2017" -> "HB 1-2017"); the all-dash form carries the
386
+ # same 105-/150- prefixes.
387
+ @cleaned = @cleaned.gsub(/\b(NIST HB\s+)(?:105|150)-(\d+)-(\d{4})(?=\s|\z)/, '\1\2-\3')
388
+ end
389
+
339
390
  # Series-specific reverts: HB handbooks, OWMWP dates, and RPT year
340
391
  # ranges use dash-year structurally (not as an edition marker), so
341
392
  # the broad convert_dashyear_to_edition! rule would corrupt them.
@@ -31,9 +31,7 @@ module Pubid
31
31
  letter_suffix:)
32
32
  return false unless letter_suffix == "R"
33
33
 
34
- identifier.number = Components::Code.new(
35
- value: "#{first_num.value}-#{letter_base}",
36
- )
34
+ identifier.number = "#{first_num.value}-#{letter_base}"
37
35
  edition_obj = Components::Edition.new(type: "r", id: "1")
38
36
  identifier.edition = edition_obj
39
37
  true
@@ -42,13 +40,11 @@ module Pubid
42
40
  def self.finalize_identifier(identifier, _parsed_hash)
43
41
  return unless identifier.number
44
42
 
45
- value = identifier.number.value.to_s
43
+ value = identifier.number.to_s
46
44
  match = value.match(/^(\d+)e(\d{4})$/)
47
45
  return unless match
48
46
 
49
- identifier.number = Components::Code.new(
50
- value: "#{match[1]}-#{match[2]}",
51
- )
47
+ identifier.number = "#{match[1]}-#{match[2]}"
52
48
  identifier.edition = nil
53
49
  end
54
50
  end
@@ -0,0 +1,19 @@
1
+ # OASIS flavor notes
2
+
3
+ OASIS verbatim slugs, the index key, the MR slug and partial-reference matching.
4
+
5
+ Read them before you change `lib/pubid/oasis/` or `spec/pubid/oasis/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **The identity is the verbatim slug; the decomposition is a lossy projection of it**: an OASIS identifier is a free-form slug with no fixed internal order (`OSLC-CoreShapes-3.0-PS01-Pt8`, `OSLC-AM-3.0-Part1-PS01`, `amqp-core`), three part spellings and mixed case. The grammar therefore captures the whole slug with `any.repeat(1)` and only strips the `OASIS ` prefix; `Builder#decompose` then classifies each *whole* dash-separated fragment into `number` / `version` / `stage` / `part` / `label`. **`original` holds the printed slug verbatim and alone drives `to_s` and `to_urn`** — `Renderer#render` is a pure echo of it — so the printed form round-trips byte-exactly whatever the classifier makes of it. The decomposition is **not** an identity and cannot be one: it loses fragment order (`x-1.0-os-Pt1` and `x-1.0-Pt1-os` decompose alike) and keeps only the first fragment of each recognized kind, so a repeated one is dropped. That is not hypothetical — **7 of the 605 published `relaton-data-oasis` ids already lose a fragment** (`OASIS xacml-3.0-hierarchical-v1.0-CS02` keeps `3.0` and drops `v1.0`). Every design decision below follows from that asymmetry.
8
+
9
+ - **`spec` → `number`, the index key (PR #359)**: `Relaton::Index::Type#candidates_by_number` sorts every row and binary-searches it on `id.root.number.to_s`. OASIS kept the specification name in a bespoke `attribute :spec, :string` and never set the `number` it inherits from `::Pubid::Identifier`, so **all 605 published rows shared the empty key `""`** and the search degraded to a linear scan, silently. The attribute is **renamed**, not shadowed by a derived reader: `attribute :number, :string` plus `map "number", to: :number`, and `Builder#decompose` returns `number:`. **`spec` is dropped with no alias and no reader** — one name for one value, the W3C `code` → `number` precedent. A derived `#spec` was written first and then removed: nothing in `lib/` read it, and `relaton-oasis` has no pubid dependency at all, so it had no consumer to preserve. That is what separates this case from IANA's `#registry`, IETF's `#series` and BIPM's `#volume`, which do have one. `root_number_spec.rb` asserts the identifier does **not** respond to `spec`, so the name cannot creep back. The key **clusters**: every version, stage and part of one specification shares it — 309 buckets over 605 ids, median 1, max 25 (`STIX`) — the shape of an IETF draft slug or an IANA registry slug, not an exact key.
10
+
11
+ - **Why the `number` declaration sits on the base here**: `attribute :number, :string` redefines the parent's `Components::Code number` on a class `Identifiers::Standard` inherits from, which is the recorded determinism landmine. It is safe for the same reason W3C's is: lutaml deep-dups the parent attribute table into each subclass at class-definition time, so a subclass holds a snapshot, and Ruby resolves the superclass constant to completion before opening a leaf body. **`Pubid::Oasis::Identifier`'s class body lives in one file and is never reopened**, so the snapshot is always complete. `stage` and `part` in that same body have relied on this since the flavor landed, overriding `Components::Stage` and `Components::Code`. **Split this class across two files and the landmine comes back** — IEEE is the counter-shape, its base reachable through two paths. `spec/pubid/oasis/root_number_spec.rb` carries the structural tripwire (the base *and* the leaf must resolve `number` to `Lutaml::Model::Type::String`) and is only meaningful under the full `bundle exec rake`.
12
+
13
+ - **MR slug**: OASIS supplied no `mr_*` hook, and every base hook looks for something the flavor does not use — the publisher is the `PUBLISHER` constant rather than a lutaml attribute, there is no `Components::Date` and no `typed_stage`. With `number` nil as well, **`to_mr_string` was `""` for all 605 ids**, and `to_slug` is what consumers use as an output **filename**. The base now supplies `mr_publisher` (`"oasis"`) and `mr_number_with_part`, which returns the sanitized **`original`** — not the five decomposed fields, because those assemble `x-1.0-os-Pt1` and `x-1.0-Pt1-os` into one slug, while `original` cannot collide. A private `mr_sanitize` filters **by charset**, so a field added later cannot leak an unsafe character. It uses **BIPM's `[^a-z0-9]+` form, not ETSI's `[^a-z0-9-]+`**: a run of non-alphanumerics collapses to one `-`, without which `v3.0]-PS01` prints a bare `--`. Three characters need the filter today — the `.` in every version (`Renderers::MrString` joins *segments* with `.`, so a dot inside one breaks the documented structure), plus the space and `]` of a few malformed records. Result: **605 distinct slugs for the 605 published ids, 0 raises, 0 characters outside `[a-z0-9._-]`**. One caveat, the same one BIPM records: `-` is both the intra-slug join and the substitute, so two slugs differing only in which non-slug character they use collapse. The corpus has exactly one such pair — the malformed `OpenC2-MQTT-v1.0] -CS01` spelled once with a normal space and once with a non-breaking one, two spellings of one document.
14
+
15
+ - **`#exclude` clears `original`, so a partial reference can widen**: `#matches?` is `exclude(*ignore) == other.exclude(*ignore)`, and `original` spells out verbatim the very component the caller is ignoring. So it survived the exclusion and a bare `OASIS WSDM` could **never** match `OASIS WSDM-v1.1`, however much was ignored — relaton would narrow to the right index bucket and then match nothing in it. `Pubid::Oasis::Identifier#exclude` now nils `original` when the excluded set intersects `DECOMPOSITION_KEYS` (`number version stage part label`), the "reset the whole cluster" rule CSA applies to its year-format siblings. **A plain `==` still compares `original`, deliberately**: an excluded copy is a comparison token, never a rendered document, so widening happens only when the caller asks for it. Dropping `original` from `==` outright was considered and rejected — it costs nothing on today's corpus (no two ids collide) but it exposes those 7 lossy ids to a silent collision with a future sibling, and it would let two different printed identifiers be equal. `spec/pubid/oasis/partial_ref_spec.rb` locks both halves.
16
+
17
+ - **relaton note**: nothing published needs migrating. `relaton-oasis` has no pubid dependency and its `index-v1.yaml` is string-keyed (`:id: OASIS amqp-core`), which is why the serialized shape was changed properly rather than patched. There is **no alias** for the old `spec` key, and a pre-`number` row would deserialize with a nil `number` and no error (lutaml ignores unknown keys), so a future pubid index must be crawled *after* this. Rendering is the loud failure if it is not.
18
+
19
+ - **Verification and the fixture net**: the change was verified by replaying a `main` baseline over all 605 published ids plus the fixture lines — `to_s`, `to_urn`, identifier class and parse errors **byte-identical**, `to_hash` differing **only** by the key rename, empty index keys 607 → 0, distinct MR slugs 1 → 606 of 607, and `from_hash(to_hash) == id` for all 605 with **0** pairs of distinct ids equal. `spec/pubid/oasis/fixtures_spec.rb` is live — its glob is correct, so OASIS is not on the `ten-dead-fixture-specs` list — and `root_number_spec.rb` adds a corpus sweep over the same file. **Do not run `rake "validation:classify[oasis]"`**: there is no `spec/fixtures/oasis/identifiers/full/`, so the generator would delete `pass/oasis.txt` and rebuild nothing. (hand-off: oasis-index-number.)
@@ -77,3 +77,5 @@ module Pubid
77
77
  end
78
78
  end
79
79
  end
80
+
81
+ Pubid::Oasis::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -36,6 +36,18 @@ module Pubid
36
36
  # building the table before `Identifiers::Standard` snapshots it — DO NOT
37
37
  # split this class across two files.
38
38
  attribute :number, :string
39
+
40
+ # `original` alone drives #to_s, and the shared exclude-copy loses it
41
+ # (the redefined attribute does not survive the rebuild). An all-parts
42
+ # copy is therefore dup-based: nil the part and edition-ish attributes
43
+ # in place, keeping the printed slug verbatim.
44
+ def without_parts(*extra)
45
+ copy = dup
46
+ (::Pubid::Identifier::PART_ATTRIBUTES + extra).each do |name|
47
+ copy.public_send(:"#{name}=", nil) if copy.respond_to?(:"#{name}=")
48
+ end
49
+ copy
50
+ end
39
51
  attribute :version, :string
40
52
  attribute :stage, :string
41
53
  attribute :part, :string
@@ -91,6 +103,13 @@ module Pubid
91
103
  result
92
104
  end
93
105
 
106
+ # The same reason applies to a subset match: `original` spells out the
107
+ # parts a partial reference omits, so `===` compares the decomposition
108
+ # only. Two slugs that decompose alike therefore match each other.
109
+ def self.subset_ignored_attributes
110
+ %i[original]
111
+ end
112
+
94
113
  # MR string hooks. `to_slug` delegates to `to_mr_string` and consumers
95
114
  # use it as an output FILENAME, so the slug must be unique per document.
96
115
  #
@@ -0,0 +1,34 @@
1
+ # OGC flavor notes
2
+
3
+ OGC `all_parts_edition_keys`: `year` is core identity, not an edition marker.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ogc/` or `spec/pubid/ogc/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **`all_parts_edition_keys` needed `revision` added AND `year` kept OUT — the
8
+ hand-off's own suggested override was wrong about the second half.**
9
+ `Identifier.all_parts_edition_keys` defaults to `%i[date year edition
10
+ version]`. OGC's real edition/version discriminator is `revision`
11
+ ("r19" in "12-128r19"), which that list never covered, so
12
+ `"12-128r19".to_all_parts` did not collapse onto another revision of
13
+ the same document. But `year` is **also** in the default list, and for
14
+ OGC `year` is NOT an edition marker — it is the document's core
15
+ `"<yy>"` identity token, the first half of the `"12-128"` core that
16
+ `Renderer#render` requires (`"#{id.year}-#{id.number}"`). Stripping it
17
+ (as the default list does, and as the hand-off's suggested override
18
+ `%i[date year edition version revision]` would have kept doing) drops
19
+ the prefix: `to_all_parts.to_s` rendered `"-128r19 (all parts)"`
20
+ instead of `"12-128 (all parts)"`. The two symptoms — the missing
21
+ prefix and the un-stripped revision — are the SAME root cause
22
+ (`year` wrongly treated as a stripped key), not two separate issues,
23
+ despite looking that way in the original repro. The correct override
24
+ replaces the list entirely rather than extending it:
25
+ ```ruby
26
+ def self.all_parts_edition_keys
27
+ %i[revision]
28
+ end
29
+ ```
30
+ `date`/`edition`/`version` are harmless to omit — OGC declares none of
31
+ them as attributes, and `#exclude` (which `without_parts` is built on)
32
+ silently skips a name a class doesn't declare. Locked by
33
+ `spec/pubid/ogc/all_parts_spec.rb` and
34
+ `spec/pubid/all_parts_edition_keys_audit_spec.rb`.
@@ -31,3 +31,5 @@ module Pubid
31
31
  end
32
32
  end
33
33
  end
34
+
35
+ Pubid::Ogc::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -16,6 +16,17 @@ module Pubid
16
16
  # its separator letter: "r1", "c1", "a", "r3a", "r12a". nil when absent.
17
17
  attribute :revision, :string
18
18
 
19
+ # `revision` is OGC's edition/version discriminator, not the default
20
+ # `%i[date year edition version]`. `year` IS in that default list, but
21
+ # here it is the document's core "<yy>" identity token — the renderer
22
+ # requires it to print the "12-128" core (lib/pubid/ogc/renderer.rb) —
23
+ # not an edition marker, so it must stay OUT of the stripped set. Do
24
+ # not "fix" this by adding :revision to the base default; that keeps
25
+ # :year in the list and reproduces the dropped-prefix bug.
26
+ def self.all_parts_edition_keys
27
+ %i[revision]
28
+ end
29
+
19
30
  # Polymorphic type map for lutaml::Model key_value (de)serialization: maps
20
31
  # the concrete class's polymorphic_name to its class name so a stored hash
21
32
  # rebuilds the correct identifier type via from_hash.
@@ -0,0 +1,189 @@
1
+ # OIML flavor notes
2
+
3
+ OIML index key, bulletins and supplement URNs.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/oiml/` or `spec/pubid/oiml/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ ## From the root note "ETSI and OIML index key (`root.number`): the `code` component became flat leaf columns"
8
+
9
+ **(5) OIML `Bulletin` is the one leaf with no code** (its locator is the year/issue/sequence tuple), so it deliberately does **not** include `CodeNumber`. Its key was first the year, as a derived reader; it is now the issue, stored as `number`. The section "Bulletin: the issue is the `number`" below records why. **(6) A pre-existing OIML crash closed in passing**: `Pubid::Oiml::SupplementIdentifier` descends from `Oiml::Identifier` **directly** — it is a *sibling* of `SingleIdentifier`, where `code` and `iteration` live — while `UrnGenerator` reads both unconditionally, so `to_urn` **raised `NoMethodError`** for every Amendment/Errata/Annex (confirmed on the `main` baseline: all five supplement fixtures already raised). Two delegations (`code`/`iteration` → `base&.…`) fix it. **Two gaps deliberately left open and pinned** by expectations in `root_number_spec.rb` that assert the *current* behaviour, so a future fix must flip them: the OIML URN encodes no supplement marker, so an annex, an amendment and their base share one URN (the documented ITU supplement-URN shape); and a supplement inherits none of `SingleIdentifier`'s `mr_*` hooks, so **every** OIML supplement still slugs to `""` — a filename collision, unchanged from `main`. (follow-up hand-off: oiml-supplement-identity-surfaces.)
10
+
11
+ ## Bulletin: the issue is the `number`
12
+
13
+ **The problem.** The first design derived `Bulletin#number` from `date&.year` as a reader, and did not serialize it, so that `year` did not repeat in each row. The runtime key was correct: relaton keys on `root.number.to_s`, and 4133 of the 4134 Bulletin rows of the published `relaton-data-oiml` `index-v2.yaml` keyed on their year. But a scan of the raw YAML reported **4134 rows with no `number`**, and a reader of the index could not see the key.
14
+
15
+ **The change.** The year is the volume and the issue is the number, as in the citation form `LXVII(2)`. So the attribute `issue` became `attribute :number, :string`, mapped as `"number"`, and the derived year reader was deleted. Nothing new is stored: the value moved from the `issue` key to the `number` key. No `issue` method remains, and the constructor refuses an `issue:` key: in Ruby and in the hash, the name is `number`. The attribute is declared on the Bulletin **leaf**, which has no subclasses, so the multi-flavor determinism landmine does not apply (the `CodeNumber` pattern).
16
+
17
+ **No legacy read.** pubid does not read the old `issue` key, by decision. lutaml ignores a key that no attribute maps, and it raises no error. So an old row loads WITHOUT its issue and becomes a different identifier: `{year: "1960", issue: "03", sequence: "01"}` renders as `OIML Bulletin 1960-01`, with URN `urn:oiml:bulletin:1960-01` and no index key. The published `relaton-data-oiml` `index-v2.yaml` must therefore be crawled again when this change is released, before relaton uses it for a Bulletin lookup.
18
+
19
+ **The trade-off, measured on the 4134 published Bulletin rows:**
20
+
21
+ - Rows with no `number`: 4134 → 68. These are the 67 volume rows (`OIML Bulletin 1960`) and the bare `OIML Bulletin`, which name no issue. Their key is `""`, and they share one bucket.
22
+ - The largest bucket: 112 → 1215. The key puts the same issue number of all volumes together (all `01` issues), so relaton's filter reads more rows after the binary search. The binary search stays valid.
23
+ - The MR slug does not change (`oiml.bulletin.03-01.1960`): `mr_number_with_part` emits `number`-`sequence` and not the year, which `Renderers::MrString` gives its own segment. The slug keeps each article distinct.
24
+
25
+ **Verified** by replaying a baseline captured on `main` over all 5646 published rows (each Bulletin `issue` key renamed to `number`, as a new crawl writes it) and every OIML fixture: `to_s`, `to_urn`, `to_mr_string` and the class are identical; a Bulletin `to_hash` differs only by `issue` → `number`; `from_hash(to_hash) == id` did not change for any entry. Locked by the "Bulletin keys on its issue" block of `spec/pubid/oiml/root_number_spec.rb`.
26
+
27
+ **Relaton note (not caused by this change).** `Relaton::Oiml::Bibliography#pubid_match?` compares `exclude(:year, :language).to_s`. For a Bulletin that string is `"OIML Bulletin"` for every article, so a query can return a different article of the same year (hand-off `relaton__relaton__oiml-bulletin-pubid-match`).
28
+
29
+ ## Subset match: strict attributes
30
+
31
+ Read `docs/SUBSET_MATCH.md` first. `===` reads a nil part of the reference as
32
+ a wildcard, which is wrong for the attributes below: the flavor models a nil
33
+ value as "this document has none". They are declared with `subset_strict`, so
34
+ `===` compares them exactly and a stated collection is not a prefix. A caller
35
+ that does want every part of a document sets `all_parts` on the reference, or
36
+ keeps `#matches?(other, ignore:)`.
37
+
38
+ - **`part`, `subpart` and `suffix` are strict**, so `OIML R 138` does not
39
+ match `OIML R 138-Amend:2009`, which is its amendment, and
40
+ `OIML R 137-1 (F)` does not match `OIML R 137-1-2:2012 (F)`, which names a
41
+ subpart it doesn't. The declaration rides the `Identifiers::CodeNumber`
42
+ mixin (`lib/pubid/oiml/identifiers/code_number.rb`), so it reaches all
43
+ seven leaves that install the columns; `Bulletin`, which does not include
44
+ the mixin, is unaffected. `Oiml::Components::Code` carries the same
45
+ declaration for the shapes not to drift, although `===` does not reach it
46
+ today.
47
+ - **`language` is strict**, declared separately on `SingleIdentifier` and
48
+ `SupplementIdentifier` (each declares its own `language` attribute), so
49
+ `OIML R 126:2015 Errata` does not match `OIML R 126:2015 Errata (E)`, its
50
+ English edition. It could not be declared once on the shared `Identifier`
51
+ ancestor: `spec/pubid/subset_match_spec.rb`'s "names only attributes the
52
+ class declares" check requires every `subset_strict` name to be a real
53
+ attribute on the declaring class, and `Identifier` itself declares neither
54
+ `language` nor `letter`.
55
+ - **`Annex#letter` is strict**, so `OIML R 102 Annexes` (no letter — the
56
+ plural form) does not match `OIML R 102:1995 Annex B-C`, which names one.
57
+ Found in review alongside the `language`/`subpart` work above: same bug
58
+ shape, on the one OIML attribute besides `language` that names a specific
59
+ sub-document and had no `subset_strict` declaration.
60
+
61
+ ## Subset match: ignored render flags
62
+
63
+ `parsed_format` (`SingleIdentifier`/`SupplementIdentifier`), `year_on_base`
64
+ (`Annex`), `space_suffix` (`CodeNumber` mixin / `Components::Code`),
65
+ `trailing` and `joined` (`SupplementIdentifier`) are Boolean or string
66
+ attributes with a non-nil default that record which of two equivalent input
67
+ spellings a reference used (short vs long format, dash- vs space-separated
68
+ suffix, trailing-word vs prose supplement form, year glued to the base vs to
69
+ the marker) — never something the document itself states. Left alone, their
70
+ non-nil default reads as "stated" to `===` even in a bare reference (the
71
+ CSA/IEEE year-format trap `docs/SUBSET_MATCH.md` describes), which is why
72
+ `OIML R 102 Annex B-C === OIML R 102:1995 Annex B-C` used to disagree with
73
+ relaton's own OIML match on `year_on_base` alone. They are listed in
74
+ `Oiml::Identifier.subset_ignored_attributes` (`lib/pubid/oiml/identifier.rb`)
75
+ as one shared list on the flavor's common ancestor: a name that isn't an
76
+ attribute of a given leaf (e.g. `year_on_base` only exists on `Annex`) is a
77
+ harmless no-op there, since `===` only walks `self.class.attributes`.
78
+
79
+ **Measurement that drove this** (hand-off
80
+ `metanorma__pubid__oiml-subset-match-strict-language.md`): relaton compared
81
+ `===` against its own OIML match over the full 5,646-row `relaton-data-oiml`
82
+ index (4.57M pairs) and found 192 disagreeing pairs outside the Bulletins —
83
+ 156 on `language`, 6 on `subpart`, 30 on `year_on_base` — all wrong on the
84
+ `===` side before this branch.
85
+
86
+ ## Dual-published identifiers: OIML co-publication with another SDO (pubid #437)
87
+
88
+ **The shape.** OIML sometimes co-publishes a document jointly with another
89
+ SDO — ISO confirmed so far. The printed reference carries both identifiers,
90
+ joined by a bare `|`, e.g. `ISO 4064-1:2024|OIML R 49-1:2024`. Both sides
91
+ name the same document, each in its own SDO's scheme.
92
+
93
+ **`Identifiers::DualPublished` is a THIRD sibling** of `SingleIdentifier`/
94
+ `SupplementIdentifier` under `Oiml::Identifier` — same shape as
95
+ `SupplementIdentifier`, and the same lesson applies: it inherits **nothing**
96
+ from `SingleIdentifier`, so every identity-bearing reader `UrnGenerator` or
97
+ the MR-slug renderer calls (`code`, `type`, `stage`, `iteration`,
98
+ `publisher`, and the `mr_publisher`/`mr_type`/`mr_number_with_part`/
99
+ `mr_year` hooks, plus `#root`) is hand-delegated to whichever side is OIML.
100
+ Without the `mr_*` delegations this class would have shipped with the exact
101
+ same empty-slug gap already recorded above for Amendment/Errata/Annex; this
102
+ class closes it from day one instead of reproducing it.
103
+
104
+ **`first`/`second` hold the two sides in original left-to-right print
105
+ order**, each a real `attribute ..., ::Pubid::Identifier, polymorphic: true`
106
+ — never an `attr_accessor` (see IEEE's `CsaDualPublished`, which used one
107
+ and silently dropped its second identifier from `to_hash`/`from_hash`/
108
+ `#exclude`). `#oiml_identifier`/`#external_identifier` pick the OIML-typed
109
+ member out of the pair by `is_a?`, not by position, since nothing guarantees
110
+ which side prints first.
111
+
112
+ **`language`/`parsed_format` are declared locally even though this class
113
+ never sets them.** `Oiml::Identifier`'s shared `key_value` block (inherited
114
+ by every OIML class) maps `"language"` and `"parsed_format"` to real
115
+ attributes; a subclass's own `key_value` block **merges** with its parent's
116
+ rather than replacing it, so `to_hash`/`from_hash` would call
117
+ `model.language`/`model.language=` regardless of what this class declares.
118
+ Leaving them undeclared would crash `to_hash` outright (no such method);
119
+ delegating `language` as a read-only method would crash `from_hash` (no
120
+ setter). `SingleIdentifier` and `SupplementIdentifier` both declare these
121
+ locally for the same reason — this class follows the same pattern rather
122
+ than inventing a new one.
123
+
124
+ **Parse dispatch lives in `Pubid::Oiml.parse`/
125
+ `Identifiers::DualPublished.build`, not the Parslet grammar.** The string is
126
+ split on `|` before the grammar ever runs; the side that matches `PREFIXES`
127
+ (`["OIML"]`) is parsed via `Oiml.parse` (re-entering this same module
128
+ method — safe, since the substring has no `|`), the other via the generic
129
+ top-level `Pubid.parse`, which auto-routes to any flavor by prefix. `.build`
130
+ returns `nil` for anything that isn't exactly two non-empty sides with
131
+ exactly one OIML side, letting the caller fall through to the ordinary
132
+ grammar — which has no `|` rule, so it raises the standard
133
+ `Parslet::ParseFailed`, keeping the uniform parse-error contract intact. The
134
+ side is detected by content, not position, so `OIML R 49-1:2024|ISO
135
+ 4064-1:2024` parses identically in the other order.
136
+
137
+ **The URN represents only the OIML side** (`urn:oiml:...`) — there is no
138
+ joint URN scheme anywhere in this codebase, matching every other
139
+ cross-flavor wrapper (IEEE's `AdoptedStandard`/`MultiNumberedIdentifier`).
140
+ One generator change was required: `UrnGenerator::Base#urn_year` gates on
141
+ `identifier.class.attributes.key?(:date)`, and `DualPublished` declares no
142
+ `date` of its own (it lives on whichever side is OIML) — so
143
+ `Oiml::UrnGenerator#urn_year` special-cases the class and reads the date
144
+ through `#oiml_identifier` directly. Every other URN segment
145
+ (`urn_type`/`urn_number`/`urn_stage`/`urn_iteration`/`urn_language`) works
146
+ for free through the §1 delegations, since they already read plain
147
+ `identifier.type`/`.code`/etc. rather than going through the attributes
148
+ registry.
149
+
150
+ **`(de)serialization goes through `Pubid.from_hash`/`#to_hash`, not the
151
+ OIML-scoped `Identifier.from_hash`**, because either side of `first`/
152
+ `second` may belong to any flavor — `Identifier.from_hash` only resolves
153
+ OIML `_type` values. Verified this round-trips both an OIML-native nested
154
+ hash and a foreign one via the shared global `TypeResolver`.
155
+
156
+ **Known, deliberately out-of-scope gaps**, matching the issue's one
157
+ confirmed example (a plain `Recommendation` on the OIML side, no edition):
158
+
159
+ - An OIML side that is itself a Amendment/Annex/Errata
160
+ (`SupplementIdentifier`) is untested — the delegation chain should mostly
161
+ work transitively, but this hasn't been exercised.
162
+ - Only two-way co-publication is modeled (two fixed attributes), not
163
+ three-or-more.
164
+ - **`edition` is not delegated**, unlike `code`/`type`/`stage`/etc. This is
165
+ deliberate, not an oversight: OIML's own `edition` is a plain `:string`
166
+ attribute (`SingleIdentifier`), but the shared `#mr_edition`/`#urn_edition`
167
+ (`lib/pubid/identifier.rb`, `lib/pubid/urn_generator/base.rb`) assume the
168
+ `Components::Edition` shape and call `edition.number` — a **pre-existing**
169
+ bug in OIML itself: `Pubid::Oiml.parse("OIML E 5 6th Edition 2015
170
+ (E)").to_mr_string` already raises `NoMethodError` on `main`, with no
171
+ `DualPublished` involved. Delegating `edition` here would only reproduce
172
+ that crash through a second path; leaving it undelegated means an
173
+ edition-bearing OIML side silently drops its edition from the MR slug/URN
174
+ instead of raising — worse data loss, but no new crash surface. Fixing the
175
+ root cause (OIML's edition/MR-edition type mismatch) is unrelated to
176
+ dual-published identifiers and is left for its own change.
177
+ - **Annotated rendering (`to_s(annotated: true)`) only spans the OIML
178
+ side's publisher/type**, not its number/year, and not the external side at
179
+ all. This matches — not regresses — the existing shipped behavior of the
180
+ closest precedent, IEEE's `Identifiers::AdoptedStandard`: `Renderers::
181
+ Annotator#nested_identifiers` (`lib/pubid/renderers/annotator.rb`) only
182
+ recurses into `base`/`ids`/`identifiers`/`bundled_with`, none of which
183
+ `DualPublished` (or `AdoptedStandard`, which uses `ieee_identifier`/
184
+ `adopted_identifiers`) uses, so neither wrapper's nested sides get full
185
+ token annotation. The uniform contract this repo enforces is narrower than
186
+ full annotation: every `to_s` must **accept** `annotated:` without
187
+ raising, which this class does (verified by a spec). Widening
188
+ `Annotator#nested_identifiers` to close this for every such wrapper at
189
+ once is a separate, cross-flavor change.
@@ -16,6 +16,9 @@ module Pubid
16
16
  }.freeze
17
17
 
18
18
  def build(parsed_hash)
19
+ # Certification-system documents carry no type letter
20
+ return build_cs(parsed_hash) if parsed_hash[:cs_series]
21
+
19
22
  # Check for short amendment format (has amd_marker)
20
23
  if parsed_hash[:amd_marker]
21
24
  return build_short_amendment(parsed_hash)
@@ -32,6 +35,20 @@ module Pubid
32
35
 
33
36
  private
34
37
 
38
+ # OIML-CS documents: family (PD/OD/CID), zero-padded number, the
39
+ # printed "Edition N", and an optional parenthesized trailing
40
+ # amendment. The family-number separator is a spelling flag.
41
+ def build_cs(parsed_hash)
42
+ identifier = Identifiers::CertificationSystem.new
43
+ identifier.publisher = parsed_hash[:publisher].to_s if parsed_hash[:publisher]
44
+ identifier.family = parsed_hash[:cs_family].to_s
45
+ identifier.number = parsed_hash[:number].to_s
46
+ identifier.edition = parsed_hash[:edition].to_s if parsed_hash[:edition]
47
+ identifier.amendment = parsed_hash[:cs_amendment].to_s if parsed_hash[:cs_amendment]
48
+ identifier.space_separator = parsed_hash[:cs_separator] == " "
49
+ identifier
50
+ end
51
+
35
52
  def build_short_amendment(parsed_hash)
36
53
  # Build base identifier from the code and type
37
54
  base_hash = {
@@ -110,6 +127,7 @@ module Pubid
110
127
 
111
128
  # Set supplement-specific attributes
112
129
  supplement.year = year_value.to_s if year_value
130
+ supplement.number = parsed_hash[:number].to_s if parsed_hash[:number]
113
131
  supplement.language = extract_language(parsed_hash[:language]) if parsed_hash[:language]
114
132
  supplement.letter = parsed_hash[:annex_letter].to_s if parsed_hash[:annex_letter]
115
133
 
@@ -228,13 +246,13 @@ module Pubid
228
246
  article_id = parsed_hash[:article_id].to_s
229
247
  identifier.date ||= Pubid::Components::Date.new
230
248
  identifier.date.year = article_id[0, 4]
231
- identifier.issue = article_id[4, 2]
249
+ identifier.number = article_id[4, 2]
232
250
  identifier.sequence = article_id[6, 2]
233
251
  warn_on_volume_mismatch(identifier, parsed_hash[:volume_roman])
234
252
  return
235
253
  end
236
254
 
237
- identifier.issue = parsed_hash[:issue].to_s if parsed_hash[:issue]
255
+ identifier.number = parsed_hash[:issue].to_s if parsed_hash[:issue]
238
256
  identifier.sequence = parsed_hash[:sequence].to_s if parsed_hash[:sequence]
239
257
  end
240
258
 
@@ -268,3 +286,5 @@ module Pubid
268
286
  end
269
287
  end
270
288
  end
289
+
290
+ Pubid::Oiml::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -10,6 +10,8 @@ module Pubid
10
10
  # shape is otherwise compatible; a future rename of +number+ →
11
11
  # +value+ would let this become a subclass.
12
12
  class Code < Lutaml::Model::Serializable
13
+ include ::Pubid::SubsetMatch
14
+
13
15
  attribute :number, :string
14
16
  attribute :part, :string
15
17
  attribute :subpart, :string
@@ -20,6 +22,10 @@ module Pubid
20
22
  # the default dash ("R 60-sup").
21
23
  attribute :space_suffix, :boolean, default: false
22
24
 
25
+ # Mirrors the flat columns the CodeNumber mixin installs on the
26
+ # seven leaves, which own the identity.
27
+ subset_strict :part, :subpart, :suffix
28
+
23
29
  def to_s
24
30
  result = number.to_s
25
31
  result += "-#{part}" if part
@@ -12,6 +12,7 @@ module Pubid
12
12
  "pubid:oiml:basic-publication" => "Pubid::Oiml::Identifiers::BasicPublication",
13
13
  "pubid:oiml:bulletin" => "Pubid::Oiml::Identifiers::Bulletin",
14
14
  "pubid:oiml:document" => "Pubid::Oiml::Identifiers::Document",
15
+ "pubid:oiml:dual-published" => "Pubid::Oiml::Identifiers::DualPublished",
15
16
  "pubid:oiml:guide" => "Pubid::Oiml::Identifiers::Guide",
16
17
  "pubid:oiml:vocabulary" => "Pubid::Oiml::Identifiers::Vocabulary",
17
18
  "pubid:oiml:expert-report" => "Pubid::Oiml::Identifiers::ExpertReport",
@@ -19,6 +20,7 @@ module Pubid
19
20
  "pubid:oiml:amendment" => "Pubid::Oiml::Identifiers::Amendment",
20
21
  "pubid:oiml:errata" => "Pubid::Oiml::Identifiers::Errata",
21
22
  "pubid:oiml:annex" => "Pubid::Oiml::Identifiers::Annex",
23
+ "pubid:oiml:certification-system" => "Pubid::Oiml::Identifiers::CertificationSystem",
22
24
  }.freeze
23
25
 
24
26
  # The base Pubid::Identifier no longer auto-maps attributes, so OIML must
@@ -34,6 +36,17 @@ module Pubid
34
36
  map "parsed_format", to: :parsed_format
35
37
  end
36
38
 
39
+ # Rendering-only flags: each records which of two equivalent input
40
+ # spellings a document used (short vs long format, dash- vs
41
+ # space-separated suffix, trailing-word vs prose supplement form, year
42
+ # glued to the base vs to the marker), not something the document
43
+ # itself states. Their non-nil default would otherwise read as
44
+ # "stated" in a bare reference and wrongly refuse a candidate that
45
+ # sets them (the CSA/IEEE year-format trap).
46
+ def self.subset_ignored_attributes
47
+ %i[parsed_format year_on_base space_suffix trailing joined]
48
+ end
49
+
37
50
  # lutaml's polymorphic_map only validates `_type` on deserialization; it
38
51
  # does not re-instantiate the concrete subclass. Route by `_type` so
39
52
  # `Pubid::Oiml::Identifier.from_hash(h)` returns the right class (and its
@@ -15,6 +15,10 @@ module Pubid
15
15
  map "year_on_base", to: :year_on_base
16
16
  end
17
17
 
18
+ # A nil `letter` means the plural "Annexes" reference, not "any
19
+ # annex": it must not match a candidate naming a specific one.
20
+ subset_strict :letter
21
+
18
22
  def supplement_type
19
23
  letter ? "Annex #{letter}" : "Annexes"
20
24
  end