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
@@ -9,6 +9,8 @@ module Pubid
9
9
  # Human render: abbreviation with flavor-specific separator.
10
10
  # URN render: stage abbreviation alone (no separator).
11
11
  class Stage < Lutaml::Model::Serializable
12
+ include ::Pubid::SubsetMatch
13
+
12
14
  attribute :name, :string
13
15
  attribute :stage_code, :string
14
16
  attribute :harmonized_stages, :string, collection: true
@@ -26,6 +26,8 @@ module Pubid
26
26
  # order they appear in the printed supplement; the URN form joins number
27
27
  # and year with ":".
28
28
  class Supplement < Lutaml::Model::Serializable
29
+ include ::Pubid::SubsetMatch
30
+
29
31
  DEFAULT_TYPE = "sup"
30
32
 
31
33
  attribute :type, :string, default: -> { DEFAULT_TYPE }
@@ -9,6 +9,8 @@ module Pubid
9
9
  # Human render: abbreviation with flavor-specific separator.
10
10
  # URN render: type code (e.g. "tr", "ts") per RFC 5141-bis.
11
11
  class Type < Lutaml::Model::Serializable
12
+ include ::Pubid::SubsetMatch
13
+
12
14
  attribute :name, :string
13
15
  attribute :abbr, :string
14
16
  attribute :type_code, :string
@@ -7,6 +7,14 @@ module Pubid
7
7
  # Human render: typed-stage abbreviation with flavor-specific separator.
8
8
  # URN render: stage code (RFC 5141-bis: stage-XX.XX format handled by urn_generator).
9
9
  class TypedStage < Lutaml::Model::Serializable
10
+ include ::Pubid::SubsetMatch
11
+
12
+ # `original_abbr` records the input spelling ("Amd" or "AMD"), not the
13
+ # stage, so a subset match skips it.
14
+ def self.subset_ignored_attributes
15
+ %i[original_abbr]
16
+ end
17
+
10
18
  attribute :name, :string
11
19
  attribute :code, :string
12
20
  attribute :type_code, :string
@@ -0,0 +1,41 @@
1
+ # CSA flavor notes
2
+
3
+ CSA convergence onto the cross-flavor identifier contract.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/csa/` or `spec/pubid/csa/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **CSA converged onto the cross-flavor contract (`root.number`, real container types, raise-never-nil)**: CSA was the last flavor outside the identifier contract, and the only one where `id.root.number` — the key `Relaton::Index::Type#candidates_by_number` sorts and bsearches on — **raised `NoMethodError`** rather than returning a value. A raise aborts a crawl; an empty key only degrades a search. Measured on `main` over the 829 parseable fixture ids: **0** had a non-empty `root.number`, **600** raised, only 228 survived `from_hash(to_hash)`, 13 raised on `to_mr_string` and 601 of 816 slugs carried characters outside `[a-z0-9._-]`. Four independent changes, plus three fixes they exposed.
8
+ **(1) `code` → `number`, and why it needed no type override.** `SingleIdentifier` declared `attribute :code, Components::Code` and never set the `number` it inherits; the fix **deletes the duplicate** rather than redeclaring anything, because `Pubid::Csa::Components::Code` **is** a bare alias of `Pubid::Components::Code` — exactly the type `::Pubid::Identifier` already declares for `number`. That distinction is load-bearing: CSA's base **is** split across two files (`csa/identifier.rb` + `csa/single_identifier.rb`), the IEEE counter-shape in which a leaf can snapshot a half-built parent attribute table (the `number` determinism landmine), so a *redeclaration* here would have been unsafe where W3C's one-file base made it safe. There is nothing to resolve nondeterministically when you declare nothing. `code` is dropped with **no alias**; readers moved in `builder.rb`, `renderer.rb`, `urn_generator.rb`, `bundled.rb`, `combined.rb`. **The serialized shape is flat** — a `key_value` block on `SingleIdentifier` collapses the `Components::Code` attributes to bare scalars (`number: C22.2-286`, not `number: {value: C22.2-286}`), the ISO/ETSI/ITU pattern. That is a *serialization mapping*, not a retype: the runtime attribute stays a `Components::Code`, so the determinism landmine is still avoided. Two placement facts matter — a lutaml `key_value` block is **exhaustive** (an unmapped attribute is silently dropped, `_type` included, so every attribute must be listed), and the block sits on **`SingleIdentifier`**, which only the single-document types inherit; the containers descend from `Csa::Identifier` directly and keep the default shape for their nested `base`/`identifiers` (whose members still flatten, via lutaml's nested transform). `Cec` declares a **second** block for `cec_part`, which lutaml merges with the inherited one; its `no_number` converter reads through either a `:string` (SingleIdentifier) or a `Components::Code` (Cec), since Cec redeclares that attribute. `Identifiers::Cec#code` (a *method* synthesising `"#{cec_part.value}-#{no_number.value}"`) became `def number` — it shadows lutaml's generated reader, safe because `Cec` declares no `number` of its own (the ITU `itu/identifiers/base.rb` precedent), and lossless because `cec_part`/`no_number` are themselves serialized so `from_hash` re-synthesises the same value. Keys: `Standard` → `"B149.1"`, `Series` → `"Z240"`, `Cec` → `"C22.2-286"`.
9
+ **(2) The five containers became real identifiers.** `WrapperIdentifier`, `CompositeIdentifier`, `Bundled` and `Combined` descended from bare `Lutaml::Model::Serializable` with a hand-written `def root; self; end`, and held their parent link in an **`attr_accessor`** — invisible to `to_hash`, `from_hash` **and** `#exclude` (which iterates `self.class.attributes`), so `CanadianAdopted#to_hash` was `{"reaffirmation" => "2023"}` and `CsaAdopted#to_hash` was `{}`. All four now `< Pubid::Csa::Identifier` with `attribute :base, ::Pubid::Identifier, polymorphic: true` — the **uniform `base` name**, so the inherited `#root` walks it and `exclude_from_nested` reaches inside; `wrapped_identifier` is gone with **no alias**. The type must be the **cross-flavor** `::Pubid::Identifier`, because `CsaAdopted` wraps ISO/IEC/CISPR ids and `polymorphic: true` widens only to *subclasses* (the `Ieee::Identifiers::AdoptedStandard` precedent); `Bundled#base`/`#bundled_with` were widened for the same reason, since a bundle's base is routinely a `Cec`, which is not a `Standard`. **`Combined` collapsed its `first`/`second`/`third` triple into one `identifiers` collection** (the IEC `ConsolidatedIdentifier` shape) — the triple was an observable hash shape hard-capped at three, and there is no published CSA index to migrate, so it was free to move; `#root` walks `identifiers.first`. All nine `polymorphic_name`s derive correctly and distinctly (`pubid:csa:canadian-adopted`, …), so **no explicit override is needed** — verify against `Pubid::Csa::Identifier.polymorphic_type_map` before adding one. The duplicated `to_mr_string`/`to_slug` on the two container base classes were deleted; the containers inherit `Csa::Identifier`'s, which also ended 13 `NoMethodError`s on `Combined`/`Bundled`. **`Identifiers::Base` stays a real intermediate class** (`< SingleIdentifier`), so CSA remains Category B in `identifier_hierarchy_spec.rb` — the containers attach to `Csa::Identifier` and deliberately bypass it.
10
+ **(3) The MR slug is charset-filtered, not escape-listed.** `to_mr_string` keeps its three *semantic* mappings (` ` → `.`, `:` → `.`, `/` → `-`, which carry CSA's segment structure) and then neutralises everything else by charset (`gsub(/[^a-z0-9._-]+/, "-")`, trimming leading/trailing separators) — the BIPM `mr_slug` precedent. Real CSA references carry `(`, `)`, `,`, `&` and `+`, all of which the old `tr` chain let through into what `to_slug` hands a filesystem. CSA keeps its whole-string `to_mr_string` override rather than moving to the `Renderers::MrString` hooks, because CSA identity lives in a dozen shape-specific attributes those seven hooks cannot see and `to_s` already round-trips losslessly. Still 0 collisions.
11
+ **(4) `parse` raises, never returns `nil`.** CSA and `api` were the only two flavors whose `parse` could return `nil` (measured across all 44 registered flavors; 28 raise a bare `RuntimeError`, 15 `Parslet::ParseFailed`, `bsi` a bare `StandardError` — the gem still has no single designed failure type, which is a **live follow-up**). A nil surfaces in the caller as a `NoMethodError` far from the input that caused it. All eight `return nil` branches now raise `Parslet::ParseFailed`, via a `build!` helper for the two that returned whatever `Builder#build` gave them. The guards after a *recursive* `parse` are simply gone — the inner call raises now. Two internal nil signals are deliberately kept: `parse_external_standard` (it probes several flavors) is translated to a raise at its one call site, and the package base scan — which walks progressively longer prefixes and keeps the longest that parses — gets a private **`try_parse`** that rescues back to nil.
12
+ **Three fixes these exposed.** **(a)** The builder marked a prefix-less reference (`C22.1-15`) with `publisher_prefix = ""`, a sentinel the canonical `to_hash` **drops by design** (it strips empty and default-valued attributes) — so such an id came back from `from_hash` with a nil prefix and re-rendered as `CSA C22.1-15`. Replaced by `attribute :code_only, :boolean, default: -> { false }`, named for the RARE case per the ITU rule so it is dropped from every ordinary row, read through `Renderer#publisher_prefix_for` and `UrnGenerator#code_only?` (the URN keeps its **empty** publisher segment, so URNs stay byte-identical). **(b)** `builder.rb` carried three `rescue Pubid::Errors::ParseError` clauses — **that constant does not exist**, only `Pubid::UrnParser::Errors::ParseError` does, so those "try ISO, else IEC" fallbacks raised `NameError` instead of falling back. Same class as the IETF URN-parser bug already recorded in `lib/pubid/ietf/CLAUDE.md`. **(c)** **Known, deliberately untouched:** `spec/pubid/csa/fixtures_spec.rb`'s glob has one `..` too many (repo-root `fixtures/`), so the whole fixture round-trip spec silently iterates an empty file list and reports **0 examples** — the exact ITU defect, again. It is left alone here because fixing it forces a `rake "validation:classify[csa]"` run, and that generator rewrites *whole files*: correcting the one bucket this change invalidates (`pass/nil_class.txt`, whose 21 entries now raise instead of returning nil) mechanically drags in ~28 unrelated `fail` → `pass` corrections that have been stale since 2026-04-01. Keeping them out keeps this diff to the task. Consequence to know: `pass/nil_class.txt` still lists 21 ids as "nil class" that now raise — inert, because nothing reads it while the glob is broken, and because CSA (unlike calconnect/bipm/ogc/iana) has no spec asserting that `fail/` fixtures fail. (follow-up hand-off: csa-revive-fixture-net.)
13
+ **The containers also needed the wrapper-specific overrides, not just the parent link.** Making them identifiers wires up `to_hash`/`from_hash`/`#exclude`/`root.number` for free, but `#base_document` and `#drop_supplements` — the matching vocabulary relaton normalises with — then inherit the base's `return self`, i.e. they answer with the **wrapper**. That is a *silently* wrong answer where the pre-reparenting `NoMethodError` was at least loud, so all five define both: `WrapperIdentifier`/`CompositeIdentifier`/`Bundled` peel through `base`, `Combined` through `identifiers.first` (the `ConsolidatedIdentifier` shape). **Known gap, pre-existing and deliberately left open:** CSA defines no URN shape for a container — `UrnGenerator` reads `publisher_prefix`/`number` straight off the identifier, which a container does not carry, so `to_urn` raises `NoMethodError`. It raised the identical error from the identical place on `main` (verified: 600 of 829 ids, both before and after), so nothing regressed; closing it needs a per-type URN-shape decision, because a bare delegation to `base` would collide a Canadian adoption with the standard it adopts. Pinned by a `root_number_spec.rb` block so the gap stays visible. **`Cec#number` is deliberately NOT memoised**: `@number` is the very ivar lutaml uses for the inherited attribute, so caching there would discard a value assigned through the generated writer and go stale if `cec_part`/`no_number` were reassigned — and `number` is the index key.
14
+ **Verified**: `to_s` **byte-identical** for all 829 ids that parsed on `main` (replayed from a captured baseline input list, not from the regenerated fixtures) and `to_urn` identical for all 829; `root.number` non-empty and raise-free for every parsing id; `from_hash(to_hash)` round-trips for every one; MR 0 raises / 0 unsafe characters / 0 collisions. Locked by `spec/pubid/csa/root_number_spec.rb` (structural tripwire + per-type key table + whole-corpus sweep over `spec/fixtures/csa/**`, which is what actually exercises the fixtures while `fixtures_spec.rb`'s glob stays broken; only meaningful under the full `bundle exec rake`), plus new CSA container rows in `root_spec.rb`, `identifier_roundtrip_spec.rb` (which carried a **pending** case for exactly this, now un-pended), `uniform_identifier_handle_spec.rb` and `mr_string_spec.rb` — none of which could carry a CSA wrapper row before. **relaton note**: there is no `relaton-csa` gem and no `relaton-data-csa`, so nothing published needs migrating; that is precisely why the serialized shape was changed properly rather than patched. (hand-off: csa-root-number-and-wrapper-hierarchy.)
15
+
16
+ - **All six CSA containers rendered plain under `to_s(annotated: true)`,
17
+ for two different reasons.** The five wrappers — `Bundled`,
18
+ `CanadianAdopted`, `Combined`, `CsaAdopted`, `Package` — compose their
19
+ own string, so they needed `annotate_plain_render`; but adding it alone
20
+ fixed none of them, because a wrapper carries no identity of its own and
21
+ the annotator was reading only the wrapper's attributes. The wrap plus
22
+ `Annotator#emit_tokens` walking `base`/`identifiers` is what closes them.
23
+
24
+ **`Cec` defines no `to_s` at all** and already reached the choke point
25
+ inside `render`. It printed plain because `CSA C22.2 NO. 286:23` stores
26
+ the joined `"C22.2-286"` in `number`, which appears nowhere in the output,
27
+ while the halves it does print sit in `cec_part` and `no_number`. It
28
+ overrides `annotation_tokens` to name those two plus `publisher_prefix`.
29
+
30
+ **`CsaAdopted` is the one to be careful with**: its `to_s` regex-rewrites
31
+ the string it gets from `base.to_s` (`:2015`→`:15`, `/Amd 1`→`/A1`). That
32
+ is safe only because it calls `base.to_s` with no options, so the base is
33
+ plain, and because the annotation wrap goes on the **outside**. Move the
34
+ wrap inward and those patterns would start matching inside `<span
35
+ class="year">2015</span>`.
36
+
37
+ ## `Csa::Identifier#exclude` resets the whole year-format cluster, not just `year`
38
+
39
+ Moved from the root `CLAUDE.md`'s `#exclude` bullet (`metanorma__pubid__exclude-recursion-depth`), where it had grown that bullet past the 100,000-byte session-load budget `spec/pubid/flavor_notes_spec.rb` enforces.
40
+
41
+ **CSA follows the same scalar-year pattern** (`Csa::Identifier#exclude`): its `year` is a plain `:string`, but unlike BIPM the year carries *format metadata* stored in sibling attributes (`year_format`, `year_prefix`, `original_year_4digit`, and `french` — the last set **only** from a `:F` year prefix, the French-edition form `CSA B149.1:F20`). A year-less parse leaves all of these nil/false, so the override must reset **every** year-derived attribute (not just `year`) after `super`, or `==` — and thus `matches?` — would still diverge (e.g. a bare `CSA B149.1` would fail to match the French edition `CSA B149.1:F20`). The `respond_to?(:year=)` guard skips the adoption wrappers (`CanadianAdopted`/`CsaAdopted`), which delegate year to a nested inner id and lack a `year` accessor — those are handled by the base recursion.
@@ -253,7 +253,7 @@ module Pubid
253
253
 
254
254
  # Code
255
255
  if parsed_hash[:code]
256
- series.number = Components::Code.new(value: parsed_hash[:code].to_s)
256
+ series.number = parsed_hash[:code].to_s
257
257
  end
258
258
 
259
259
  # Year format and year
@@ -520,13 +520,13 @@ module Pubid
520
520
  # Pattern: "C22.1-15" should become code="C22.1", year="2015"
521
521
  if !data[:year] && code_value =~ /^(.+)-(\d{2})$/
522
522
  # Split code and year
523
- identifier.number = Components::Code.new(value: $1)
523
+ identifier.number = $1
524
524
  # Convert 2-digit year to 4-digit
525
525
  year_2digit = $2
526
526
  identifier.year = "20#{year_2digit}"
527
527
  identifier.year_format = "dash"
528
528
  else
529
- identifier.number = Components::Code.new(value: code_value)
529
+ identifier.number = code_value
530
530
  end
531
531
  end
532
532
 
@@ -669,3 +669,5 @@ module Pubid
669
669
  end
670
670
  end
671
671
  end
672
+
673
+ Pubid::Csa::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -218,7 +218,8 @@ module Pubid
218
218
  # Detect CSA adoption of international standards
219
219
  # Examples: CSA ISO/IEC TR 12785-3:15, CSA CISPR 16-1-1:18, CSA IEC 60601-1:08
220
220
  # CSA CEI/IEC 61000-4-28-01 (bilingual)
221
- if input.match?(/^CSA (ISO\/IEC|CEI\/IEC|CISPR|IEC|CEI|ISO)\s/)
221
+ # CSA IWA 18:17 (the catalogue also drops the ISO keyword)
222
+ if input.match?(/^CSA (ISO\/IEC|CEI\/IEC|CISPR|IEC|CEI|ISO|IWA)\s/)
222
223
  # This is CSA adoption of international standard
223
224
  # Extract the wrapped standard portion
224
225
  wrapped_input = input.sub(/^CSA\s+/, "")
@@ -495,6 +496,17 @@ module Pubid
495
496
  end
496
497
  end
497
498
 
499
+ # The catalogue also prints the IWA designation without the ISO
500
+ # keyword ("CAN/CSA-IWA 18:17" beside "CAN/CSA-ISO IWA 18:17");
501
+ # the ISO parser wants the ISO/ prefix.
502
+ if input.match?(/^IWA\s/)
503
+ begin
504
+ return Pubid::Iso.parse(input.sub(/^IWA\s/, "ISO/IWA "))
505
+ rescue StandardError
506
+ return nil
507
+ end
508
+ end
509
+
498
510
  # Try CISPR (uses IEC parser)
499
511
  if input.match?(/^CISPR\s/)
500
512
  begin
@@ -47,7 +47,7 @@ module Pubid
47
47
  end
48
48
  end
49
49
  base_str = (needs_space ? "#{prefix} " : prefix) +
50
- base.number.value.to_s + # Normalized code (e.g. "C22.2-1")
50
+ base.number.to_s + # Normalized code (e.g. "C22.2-1")
51
51
  ":#{year_display}"
52
52
  parts = [base_str]
53
53
  else
@@ -60,7 +60,7 @@ module Pubid
60
60
  bundled_with.each do |bundled|
61
61
  # For Cec identifiers, use normalized code format
62
62
  if bundled.is_a?(Cec)
63
- bundled_part = bundled.number.value.to_s # e.g. "C22.2-2"
63
+ bundled_part = bundled.number.to_s # e.g. "C22.2-2"
64
64
  if bundled.year
65
65
  # Use dash if year_format is dash, otherwise colon
66
66
  separator = bundled.year_format == "dash" ? "-" : ":"
@@ -58,7 +58,7 @@ module Pubid
58
58
  def number
59
59
  return nil unless cec_part && no_number
60
60
 
61
- Components::Code.new(value: "#{cec_part.value}-#{no_number.value}")
61
+ "#{cec_part.value}-#{no_number.value}"
62
62
  end
63
63
  end
64
64
  end
@@ -115,7 +115,7 @@ module Pubid
115
115
 
116
116
  # Reaffirmation - preserve original format and determine spacing
117
117
  if id.reaffirmation && !id.reaffirmation.to_s.empty?
118
- result += render_reaffirmation(id)
118
+ result += render_reaffirmation(id, year_prints_4digit: id.original_year_4digit == true)
119
119
  end
120
120
 
121
121
  # Package (already has leading space from parser)
@@ -225,11 +225,13 @@ module Pubid
225
225
 
226
226
  private
227
227
 
228
- # Reaffirmation rendering helper - shared across Base, Cec, and Series
229
- def render_reaffirmation(id)
230
- # Check if year was originally 2-digit (original_year_4digit flag)
231
- year_was_2digit = !id.original_year_4digit
232
-
228
+ # Reaffirmation rendering helper - shared across Base, Cec, and Series.
229
+ # The spacing follows the PRINTED year, not the parsed spelling: a
230
+ # 4-digit printed year glues ("C108.1.2-M1981(R2013)"), a 2-digit
231
+ # printed year takes a space ("C22.2 NO. 125-M84 (R2004)") — even
232
+ # when the input spelled the year 4-digit, since the CEC render
233
+ # always displays 2 digits.
234
+ def render_reaffirmation(id, year_prints_4digit: false)
233
235
  # Check if reaffirmation was originally 4-digit (original_reaffirmation_4digit flag)
234
236
  reaffirmation_was_4digit = id.original_reaffirmation_4digit
235
237
 
@@ -245,13 +247,11 @@ module Pubid
245
247
  id.reaffirmation.to_s
246
248
  end
247
249
 
248
- # Determine spacing based on original formats
249
- if year_was_2digit && reaffirmation_was_4digit
250
- # Year was 2-digit, reaffirmation was 4-digit → add space
251
- " (R#{reaffirmation_str})"
252
- else
253
- # Both 2-digit, both 4-digit, or other cases → no space
250
+ # Determine spacing based on the printed year
251
+ if year_prints_4digit || !reaffirmation_was_4digit
254
252
  "(R#{reaffirmation_str})"
253
+ else
254
+ " (R#{reaffirmation_str})"
255
255
  end
256
256
  end
257
257
 
@@ -33,6 +33,23 @@ module Pubid
33
33
  attribute :package, :string # Package portion (Code, Handbook, etc.)
34
34
  attribute :publisher_prefix, :string # Original prefix: "CAN/CSA-", "CSA", "CAN3-"
35
35
 
36
+ # Format flags that describe a value, keyed by that value. When the
37
+ # reference omits the value, a subset match skips its flags too: a bare
38
+ # `CSA C22.2 NO. 125` has `original_year_4digit` at its default `false`,
39
+ # which would otherwise refuse `CSA C22.2 NO. 125-M1984`.
40
+ SUBSET_FORMAT_FLAGS = {
41
+ year: %i[year_format year_prefix original_year_4digit french],
42
+ reaffirmation: %i[original_reaffirmation_4digit],
43
+ }.freeze
44
+
45
+ def subset_attribute_match?(name, mine, theirs)
46
+ return true if SUBSET_FORMAT_FLAGS.any? do |value, flags|
47
+ public_send(value).nil? && flags.include?(name)
48
+ end
49
+
50
+ super
51
+ end
52
+
36
53
  # True when the reference printed no publisher at all ("C22.1-15"), so
37
54
  # rendering must not supply the default "CSA".
38
55
  #
@@ -86,7 +103,7 @@ module Pubid
86
103
  end
87
104
 
88
105
  def number_from_kv(model, value)
89
- model.number = Components::Code.new(value: value.to_s)
106
+ model.number = value.to_s
90
107
  end
91
108
 
92
109
  # `no_number` is a plain :string here but a Components::Code on Cec, so
@@ -19,7 +19,7 @@ module Pubid
19
19
  def urn_number
20
20
  return nil unless identifier.number
21
21
 
22
- identifier.number.render(context: URN_CONTEXT)
22
+ identifier.number.to_s
23
23
  end
24
24
 
25
25
  def urn_no_number
@@ -16,3 +16,5 @@ module Pubid
16
16
  end
17
17
  end
18
18
  end
19
+
20
+ Pubid::Doi::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -53,3 +53,5 @@ module Pubid
53
53
  end
54
54
  end
55
55
  end
56
+
57
+ Pubid::Easc::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -0,0 +1,28 @@
1
+ # ECMA flavor notes
2
+
3
+ ECMA edition and volume in the printed identifier, and the index key.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ecma/` or `spec/pubid/ecma/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **ECMA edition and volume — the one flavor whose `to_s` deliberately renders MORE than the document number**: `Relaton::Index::Type#add_or_update` keys on a **bare** `id.to_s` and cannot pass render options, so the default rendering **is** the index key. ECMA's renderer used to say outright that "the edition is never rendered", and 740 of the 804 published `relaton-data-ecma` rows carry one — so all 804 rows collapsed onto **421** keys and **383 were dropped on every crawl, silently** (`ECMA-74` 22 rows to 1, `ECMA-262` 18 to 1). `edition` and `volume` are therefore rendered **by default**, with `to_s(with_edition: false, with_volume: false)` as the opt-out — the inverse of pubid's usual opt-in, and per-flavor divergence in exactly the way 3GPP (`with_publisher: false`) and W3C (`true`) already diverge. **`volume` is a new `attribute :volume, :string`** (relaton's `:vol:`), needed by exactly four rows: ECMA-269 ed3 vol1-4 share one docidentifier **and one title**, so the volume is the only thing separating those four index rows — it is an index discriminator, not something the document prints about itself. Both are plain `:string`s, so `#exclude`/`#matches?` already treat them as wildcards with no override (`ECMA-269` matches every edition under `ignore: %i[edition volume]`); ECMA's `partial_ref_spec.rb` entry moved from `omits: []` to `%i[edition volume]`.
8
+ **Grammar (`lib/pubid/ecma/parser.rb`)**: ` ed<N>` (dotted — `ed5.1` is real, ECMA-402) and ` vol<N>` attach as two independent `.maybe`s **after** the `tr | mem | standard` alternation, so a technical report and a memento carry them exactly as a standard does. The `standard` branch also gained a **space separator** (`ECMA 6`), which is a **NORMALIZING parse** — it renders back as `ECMA-6`, so it lives in `spec/pubid/ecma/edition_volume_spec.rb` and must **never** enter the byte-exact `pass` fixtures (the BIPM `CIPM/2005-06(REV)` precedent). **Two PEG facts are load-bearing and easy to lose.** (1) `standard` cannot shadow ` TR/`/` MEM/` because after the space it demands a **digit** — verified by inverting the alternation order, which still parses both correctly. Keeping `tr | mem` first is belt-and-braces; the **disjoint first character** is the real invariant. (2) Appending a sequence moved the alternation **out of tail position**, so Parslet no longer threads `consume_all` into it and **will not backtrack into an alternative that already succeeded** (`rule(:alt) { str("ab") | str("a") }; rule(:root) { alt >> str("bc") }` fails on `"abc"`). A future branch matching a proper prefix of another branch's input would now fail the whole parse where before it was rescued. `maybe` is `repeat(0,1)` and does not backtrack either, so a marker without its number (` ed`, ` ed5.`, ` vol`) fails atomically and then fails `consume_all` — which is what rejects `ECMA-6 ed` and pins the canonical suffix order (`ECMA-6 vol1 ed3` is rejected).
9
+ **Render-option threading — `:with_volume` rides the `:trademark` route, deliberately.** `Pubid::Identifier#render` `except`s a flag from the rendering context and `slice`s it to the renderer; `:with_edition` was already in both, and `:trademark` is the precedent for an **IEEE-only** flag carried the same way. So `:with_volume` was added to those two lines and **`build_rendering_context`'s signature was left alone** — widening it would touch `Pubid::Iso::SingleIdentifier`'s strict override. The payoff is that **ECMA's `to_s` needed no change at all** and the renderer owns the defaults: no second transient ivar beside the pre-existing `@with_publisher`, so rendering stays stateless and reentrant. Residual blast radius, accepted: passing `with_volume:` to a *non*-ECMA identifier now reaches that flavor's renderer instead of dying in the context builder — `Renderers::HumanReadable` absorbs unknown opts by design, while ISO's three strict renderers (`DirectivesRenderer`/`IwaRenderer`/`SupplementRenderer`) raise, which they did before too.
10
+ **All three identity surfaces, and the two defects found by measuring them.** Per the project rule, edition and volume reach `to_s`, `to_urn` **and** `to_mr_string`. **URN**: labelled segments after the existing `part-<n>` (`urn:ecma:269:ed-3:vol-2`), purely additive — `UrnParser` probes for a labelled prefix (a `shift_labelled` helper using `delete_prefix`, not `sub`), so a URN minted before the segments existed still reads back. The **dot of a decimal edition is KEPT** (`urn:ecma:402:ed-5.1`): `.` is `unreserved` in RFC 8141 and the URN has an inverse, so it must be lossless. **MR**: ECMA defined **no** `mr_*` hooks, so (a) the base `mr_edition` (`edition&.number`) **raised `NoMethodError` on all 740 edition-carrying rows** — the BIPM/ASTM/ASHRAE crash again — and (b) `mr_publisher` was nil (the publisher is the `PUBLISHER` constant, not the inherited attribute) and `mr_type` was nil (the Builder picks a class, it never sets a `typed_stage`), so **`ECMA-101` and `ECMA TR/101` both slugged to `"101"`** and one output filename overwrote the other. Four hooks fix it, sanitising **by charset** (`gsub(/[^a-z0-9]+/, "-")`, the BIPM `mr_slug` precedent) rather than by an escape list. **The MR is deliberately lossy where the URN is not** (`ed5-1` vs `ed-5.1`): `Renderers::MrString` joins **segments** with `.`, so a dot inside one breaks the documented structure, and nothing parses an ECMA MR back (`Parsers::MrString::FLAVOR_MAP` has no ECMA entry). Say so in the comment rather than leaving the asymmetry looking accidental.
11
+ **New shared `mr_volume` slot.** `Renderers::MrString#render_flat` had no volume segment, so it gained one plus a **literal-nil** `mr_volume` hook on `Pubid::Identifier` — literally nil, **not** `volume&.to_s`, because `Pubid::Nist::Identifiers::Base` declares its own `volume` attribute with different MR semantics and a generic reader would silently move NIST's slug. Inert by construction (no flavor defined `mr_volume`, there is no `method_missing` in `lib/pubid`, and NIST overrides `to_mr_string` wholesale), and verified rather than assumed: a `main` baseline replay of `to_mr_string` over all **98,217** identifiers in `spec/fixtures/*/identifiers/pass/*.txt` is **byte-identical**. `render_flat` was rewritten around a frozen `SEGMENTS` list of hook names while it was open, which also cleared a pre-existing ABC offence.
12
+ **The corpus is the gate.** `spec/fixtures/ecma/identifiers/pass/index_corpus.txt` is the complete published index (804 rows) rendered as `"<:id:>[ ed<:ed:>][ vol<:vol:>]"`, generated from `relaton-data-ecma/index-v1.yaml`; `spec/pubid/ecma/corpus_spec.rb` asserts over all of it that every row parses and renders byte-exactly, that `to_s`/`to_urn`/`to_mr_string` each give **804 distinct** keys, that every slug is filename-safe, that the URN and the hash both round-trip, that `from_hash(to_hash) == id` (stronger than hash equality — `#matches?` is built on `==`), and that `root.number` is non-empty. **ECMA has no `identifiers/full/` corpus**, so `rake "validation:classify[ecma]"` cannot run and cannot clobber these files — its fixtures are hand-maintained, unlike every generated flavor. Note ECMA's `fixtures_spec.rb` glob is **correct** (two `..`, lowercase dir) and is not one of the ten dead ones. **Two existing specs asserted the inverted contract** and had to flip (`serialization_spec.rb`'s "omits edition from the printed string"), so a green suite before the code change was the failure signal, not the success one. **relaton note**: this unblocks `relaton__relaton__ecma-consume-index-v2`; `relaton-data-ecma`'s `index-v1` is string-keyed and no ECMA pubid index is published yet, so nothing needs migrating — but the crawl must run after this lands, or the rows key on the collapsed form again. (hand-off: ecma-edition-and-volume.)
13
+
14
+ ## Subset match: strict attributes
15
+
16
+ Read `docs/SUBSET_MATCH.md` first. `===` reads a nil part of the reference as
17
+ a wildcard, which is wrong for the attributes below: the flavor models a nil
18
+ value as "this document has none". They are declared with `subset_strict`, so
19
+ `===` compares them exactly and a stated collection is not a prefix. A caller
20
+ that does want every part of a document sets `all_parts` on the reference, or
21
+ keeps `#matches?(other, ignore:)`.
22
+
23
+ - **`part` is strict** (`lib/pubid/ecma/identifier.rb`). `ECMA-418` is the
24
+ whole document, not part 1 of it, so `ECMA-418 === ECMA-418-1 ed1` is
25
+ false. relaton measured 5 spurious part rows for that one reference
26
+ before the declaration existed. `edition` and `volume` stay wildcards —
27
+ an omitted edition really does mean "any edition", which is why
28
+ `partial_ref_spec.rb` lists them under `omits`.
@@ -43,3 +43,5 @@ module Pubid
43
43
  end
44
44
  end
45
45
  end
46
+
47
+ Pubid::Ecma::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -20,6 +20,13 @@ module Pubid
20
20
  # is only present for standards that split into parts (e.g. ECMA-418-1).
21
21
  attribute :number, :string
22
22
  attribute :part, :string
23
+
24
+ # A nil `part` means the document has no part, not "any part":
25
+ # `ECMA-418` is not `ECMA-418-1`. relaton measured 5 spurious part rows
26
+ # for that one reference. A caller that wants every part of a document
27
+ # sets `all_parts` on the reference.
28
+ subset_strict :part
29
+
23
30
  # Edition is relaton's `:ed:` (the index stores {:id, :ed, :vol} and the
24
31
  # YAML has edition.content). It is part of the printed identifier: the
25
32
  # relaton index keys on a bare `to_s`, so without it all 22 editions of
@@ -0,0 +1,34 @@
1
+ # ETSI flavor notes
2
+
3
+ ETSI index key, part exclusion and MR slug.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/etsi/` or `spec/pubid/etsi/`. 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
+ **(3) `exclude(:part)` needed an override.** With `parts` a real collection attribute the base `#exclude` reaches it and nils it — but a part-less reference parses with `parts` defaulting to `[]`, so `nil != []` would break `==` and therefore `#matches?` against exactly the reference the exclusion exists to match. `Pubid::Etsi::Identifier#exclude` resets it to `[]` after `super` (the "reset the WHOLE cluster" rule from CSA's year and IEEE's year/month/day); this also covers supplements, since the base recurses into the nested `base` through `exclude_from_nested`. The old `exclude_from_nested` `Components::Code` special-case was deleted as dead. **(4) The ETSI MR slug was rewritten, and this is the loud part of the change.** ETSI defined no `mr_*` hooks, and the base ones read the inherited `number`/`part`/`typed_stage`/`edition` — all nil for ETSI — so **every** ETSI slug was `etsi.<date>`: **454 distinct slugs for 24,724 documents**, 794 of them sharing `etsi.2018-07`, and `to_slug` is what consumers use as an output **filename**. `EtsiStandard` now supplies `mr_number_with_part` (number + `parts`), `mr_type` (the ETSI type token) and `mr_edition` (the `version`) — all four fields ETSI's `==` compares, per the rule that an identity-bearing marker must reach **every** identity surface, not just `==`. `SupplementIdentifier` gained `mr_supplement_suffix` so the shared renderer **recurses into `base`** instead of slugging the supplement flat off its own ordinal (which made every `/C1` corrigendum of one month share `etsi.1.<date>`) — the ITU `AnnexOfRecommendation` precedent. A shared **`mr_sanitize`** filters **by charset** (`[^a-z0-9-]` → `-`), not by an enumerated escape list: an ETSI number legitimately contains a **space** (`300 175`, `GSM 02.01`), which is outside the `[a-z0-9.-]` charset `Renderers::MrString` documents, and a dot inside a segment would break that renderer's `.`-joined segment structure (the BIPM `bipm.si-brochure.9e-v3-01.e` precedent). Result: **24,724 distinct slugs for 24,724 ids, 0 collisions, 0 unsafe characters.** OIML's MR is **unchanged** — it already had its own `mr_number_with_part` reading through `code`, which the derived reader keeps working.
10
+
11
+ ## Subset match: strict attributes
12
+
13
+ Read `docs/SUBSET_MATCH.md` first. `===` reads a nil part of the reference as
14
+ a wildcard, which is wrong for the attributes below: the flavor models a nil
15
+ value as "this document has none". They are declared with `subset_strict`, so
16
+ `===` compares them exactly and a stated collection is not a prefix. A caller
17
+ that does want every part of a document sets `all_parts` on the reference, or
18
+ keeps `#matches?(other, ignore:)`.
19
+
20
+ - **`parts` is strict**, on the leaf `EtsiStandard` and on
21
+ `Etsi::Components::Code`. A stated part list is not a prefix, so
22
+ `ETSI TS 129 198-4 === ETSI TS 129 198-4-5` is false. This was the
23
+ largest case relaton measured: over 10,595 bare type+code queries the
24
+ matched set changed for 158 and the `best_match` winner for 122, and
25
+ `ETSI TS 129 198-4` resolved to `-4-5`.
26
+ - The declaration sits **on the leaf**, beside the flat columns and for the
27
+ same reason — `EtsiStandard` owns `number`/`minor`/`parts`, while the
28
+ shared `Pubid::Etsi::Identifier` is also `SupplementIdentifier`'s parent.
29
+ `Etsi::Components::Code` carries the same declaration although `===`
30
+ cannot reach it today (`#code` is a derived reader, not an attribute), so
31
+ the two shapes cannot drift.
32
+ - `SUBSET_PAIRS["etsi"]` in `spec/pubid/subset_match_spec.rb` moved from
33
+ `ETSI EN 300 175` to `ETSI EN 300 175-1`: the reference now states its
34
+ part, and the version and the date are what it omits.
@@ -140,3 +140,5 @@ module Pubid
140
140
  end
141
141
  end
142
142
  end
143
+
144
+ Pubid::Etsi::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -14,10 +14,16 @@ module Pubid
14
14
  # Stays independent of Pubid::Components::Code because ETSI uses
15
15
  # +minor+ (a flavor-specific sub-number) plus +parts+.
16
16
  class Code < Lutaml::Model::Serializable
17
+ include ::Pubid::SubsetMatch
18
+
17
19
  attribute :number, :string # Main number
18
20
  attribute :minor, :string # Optional minor part
19
21
  attribute :parts, :string, collection: true, default: [] # Parts array
20
22
 
23
+ # Mirrors the flat `parts` column of EtsiStandard, which owns the
24
+ # identity; this component is derived from it by `#code`.
25
+ subset_strict :parts
26
+
21
27
  # Render code with space for minor and dash-separated parts
22
28
  def to_s
23
29
  result = number.to_s
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Represents an ETSI version string
9
9
  # Format: V1.2.3, V2.0.0, or ed.1
10
10
  class Version < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :version, :string # e.g., "1.1.1", "2.0.0", "1"
12
14
  attribute :is_edition, :boolean, default: -> {
13
15
  false
@@ -44,6 +44,13 @@ module Pubid
44
44
  attribute :minor, :string
45
45
  attribute :parts, :string, collection: true, default: -> { [] }
46
46
 
47
+ # The reference always states its parts. An empty list means the
48
+ # document has no part, and a stated list is not a prefix:
49
+ # `ETSI TS 129 198-4` is not `ETSI TS 129 198-4-5`. Over 10,595 bare
50
+ # ETSI queries relaton measured 122 changed `best_match` winners
51
+ # without this. A caller that wants every part sets `all_parts`.
52
+ subset_strict :parts
53
+
47
54
  # Compact serialization (mirrors ISO/JCGM/OIML): the code fields are
48
55
  # bare scalars (`number`, a `parts` array and, rarely, a `minor`);
49
56
  # Version flattens to a scalar `version` string with an `is_edition`
@@ -0,0 +1,58 @@
1
+ # EVS flavor notes
2
+
3
+ - **The wrapped document is `base`, the uniform parent accessor**: EVS shipped
4
+ with `adopted_identifier`, the name the rest of the codebase already retired.
5
+ The rename covers the Ruby attribute **and** the serialization key, so an EVS
6
+ row now reads `{"_type" => "pubid:evs:national-adoption", "base" => {…}}`.
7
+ **The name is load-bearing, not cosmetic.** `Pubid::Identifier#root` walks
8
+ `base`, so every EVS identifier used to be its own root with a nil `number`,
9
+ and relaton keyed all of them under the empty string `""` — the index-key
10
+ defect this repository records for wrappers in many other flavors.
11
+ `root.number` is now the origin standard's number (`"9001"` for
12
+ `EVS-EN ISO 9001:2015/A1:2024`, through two wrapper layers, because `#root`
13
+ recurses). `Renderers::Annotator#emit_tokens` also walks `base`, so
14
+ `to_s(annotated: true)` now annotates the wrapped document instead of
15
+ returning a bare string.
16
+ **What moved and what did not**: `to_s`, `to_urn` and the identifier classes
17
+ are unchanged; `to_hash` changes one key name, and `to_s(annotated: true)`
18
+ gains spans. EVS has no published `relaton-data-evs` index, so nothing stored
19
+ needs a migration — but the **pubid-testsuite corpus rows carry the old key**
20
+ and report `canonical hash` for all 10 EVS cases until the flavor is
21
+ re-exported. `to_mr_string` is still `""` for EVS (pre-existing: EVS supplies
22
+ no `mr_*` hooks).
23
+ Locked by the "uniform parent accessor" block in `spec/pubid/evs/evs_spec.rb`.
24
+
25
+ - **`from_hash` raised on every EVS hash — a raw default, not load order
26
+ (pubid#383)**: `Pubid::Evs::Identifier.from_hash(id.to_hash)` raised
27
+ `Lutaml::Model::InvalidFormatError` (`undefined method 'key?' for an instance
28
+ of Symbol`). The cause was one line in
29
+ `lib/pubid/evs/identifiers/national_adoption.rb`:
30
+ `attribute :type, Components::Type, default: -> { self.class.type[:key] }`.
31
+ The default was the Symbol `:evs_en`, not a `Components::Type`. The canonical
32
+ `to_hash` drops a default-valued attribute, so no row carries `type`. On
33
+ `from_hash`, lutaml finds no `type` key, resolves the default, and casts it
34
+ into the component through `Components::Type.from_hash(:evs_en)`, which
35
+ raises. The nested cross-flavor wrapped identifier was never the problem:
36
+ it deserializes alone through `Pubid.from_hash`.
37
+ **The fix landed on `main` in `ea6cab30`**, which gives EVS and all six IDF
38
+ classes a `self.class.default_type` class method returning
39
+ `Components::Type.new(abbr: type[:short])` — the cen_cenelec shape. Nothing in
40
+ EVS reads `type` (builder, renderer, URN generator and URN parser), and `type`
41
+ is still absent from the hash, so nothing rendered or serialized moved.
42
+ **The issue thread called it process-shape dependent. It is not.** It raises
43
+ in every load order tried (plain `require`, `Pubid.eager_load_flavors!`, CEN
44
+ loaded first, BSI loaded first), and all 10 corpus cases raised on `main`,
45
+ not only the one row the testsuite ledger names. The exporter looked clean
46
+ because `Conformance::Generator#round_trips?` rescues `StandardError` and
47
+ returns `false`, so a raise there never reached the report.
48
+ **Do not read `self.class` inside the default lambda body.** lutaml calls the
49
+ lambda with `instance_exec` when it has an instance, but calls it plainly
50
+ (`options[:default].call`) when it has none, and `self.class` is then `Class`.
51
+ A `default_type` class method keeps that resolution in one place.
52
+ **Two specs guard the bug class, and they are complementary**:
53
+ `spec/pubid/evs/from_hash_spec.rb` greps `lib/` for the one raw spelling, so a
54
+ copy of that exact line fails fast; `spec/pubid/component_attribute_default_spec.rb`
55
+ **evaluates** every component-typed default of every identifier class of every
56
+ flavor, so a raw default of any spelling or component type fails, whatever
57
+ flavor introduces it. Its `PENDING_RAW_DEFAULTS` table is empty today; an
58
+ entry that starts to pass turns red, which is the signal to delete it.
@@ -17,10 +17,12 @@ module Pubid
17
17
  adopted = Pubid::CenCenelec::Builder.build(data[:adopted])
18
18
 
19
19
  Identifiers::NationalAdoption.new(
20
- adopted_identifier: adopted,
20
+ base: adopted,
21
21
  separator: data[:evs_separator] || "-",
22
22
  )
23
23
  end
24
24
  end
25
25
  end
26
26
  end
27
+
28
+ Pubid::Evs::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -3,16 +3,16 @@
3
3
  module Pubid
4
4
  module Evs
5
5
  # An EVS national adoption wraps a CEN identifier the same way BSI's
6
- # AdoptedEuropeanNorm does — polymorphic `adopted_identifier` so the
6
+ # AdoptedEuropeanNorm does — polymorphic `base` so the
7
7
  # wrapped object stays a real Pubid::CenCenelec identifier.
8
8
  #
9
9
  # Examples:
10
- # "EVS-EN 18216:2026" (adopted_identifier: EN 18216:2026)
11
- # "EVS-EN ISO 14001:2026" (adopted_identifier: EN ISO 14001:2026)
12
- # "EVS-EN ISO/IEC 27017:2026" (adopted_identifier: EN ISO/IEC 27017:2026)
13
- # "EVS-EN ISO 9001:2015/A1:2024" (adopted_identifier: EN ISO 9001:2015/A1:2024)
10
+ # "EVS-EN 18216:2026" (base: EN 18216:2026)
11
+ # "EVS-EN ISO 14001:2026" (base: EN ISO 14001:2026)
12
+ # "EVS-EN ISO/IEC 27017:2026" (base: EN ISO/IEC 27017:2026)
13
+ # "EVS-EN ISO 9001:2015/A1:2024" (base: EN ISO 9001:2015/A1:2024)
14
14
  class Identifier < ::Pubid::Identifier
15
- attribute :adopted_identifier, ::Pubid::Identifier, polymorphic: true
15
+ attribute :base, ::Pubid::Identifier, polymorphic: true
16
16
  # "-" or " " — preserves the printed separator ("EVS-EN" vs "EVS EN")
17
17
  attribute :separator, :string, default: -> { "-" }
18
18
 
@@ -6,10 +6,15 @@ module Pubid
6
6
  # The EVS national adoption of a European Standard.
7
7
  # Example: "EVS-EN 18216:2026"
8
8
  class NationalAdoption < ::Pubid::Evs::Identifier
9
- attribute :type, Components::Type, default: -> { self.class.type[:key] }
9
+ attribute :type, Components::Type, default: -> { self.class.default_type }
10
+
11
+ def self.default_type
12
+ Components::Type.new(abbr: type[:short])
13
+ end
10
14
 
11
15
  def self.type
12
- { key: :evs_en, title: "EVS National Adoption of a European Standard" }
16
+ { key: :evs_en, short: "EN",
17
+ title: "EVS National Adoption of a European Standard" }
13
18
  end
14
19
  end
15
20
  end
@@ -23,7 +23,7 @@ module Pubid
23
23
  end
24
24
 
25
25
  def adopted
26
- @id.adopted_identifier.to_s
26
+ @id.base.to_s
27
27
  end
28
28
  end
29
29
  end
@@ -17,7 +17,7 @@ module Pubid
17
17
  EVS_NAMESPACE = "urn:evs:"
18
18
 
19
19
  def generate
20
- adopted_urn = identifier.adopted_identifier.to_urn
20
+ adopted_urn = identifier.base.to_urn
21
21
  unless adopted_urn.start_with?(CEN_NAMESPACE)
22
22
  raise Errors::ParseError,
23
23
  "expected adopted CEN URN, got #{adopted_urn.inspect}"
@@ -56,7 +56,7 @@ module Pubid
56
56
 
57
57
  adopted = Pubid::CenCenelec.parse(text)
58
58
  ::Pubid::Evs::Identifiers::NationalAdoption.new(
59
- adopted_identifier: adopted,
59
+ base: adopted,
60
60
  )
61
61
  end
62
62