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
@@ -0,0 +1,97 @@
1
+ # IEEE flavor notes
2
+
3
+ IEEE code columns, drafts, trademarks and historical series.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ieee/` or `spec/pubid/ieee/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **IEEE splits its document code into index columns (no `code` string)**: IEEE keeps document identity in a `Pubid::Ieee::Components::Code` (`prefix`/`number`/`parts`/`original_separator`), but relaton searches the index **by number** and expects a part-less reference (`IEEE 802`) to match the whole bucket of parts (`802.11`, `802.16`, `802.16.1`). So every IEEE **leaf** serializes the code as four separate, ISO-style scalar columns and does **not** serialize a `code` string at all: `number` (bare digits, the narrowing key — `"802"`, not `"802.16.1"`), `parts` (`["16","1"]`, a `:string` collection with `default: -> { [] }` so it's dropped when empty), `prefix` (letter series, `"C"` for `C37.09`), and `separator` (`"."`/`"-"`, to render parts back). This is the `Identifiers::CodeNumber` mixin (`lib/pubid/ieee/identifiers/code_number.rb`), included on the concrete leaves (`Standard`, `SiStandard`, `ProjectDraftIdentifier`, `JointDevelopment`). **`IecIeeeCopublished` is a variant**: its IEC/IEEE numbers mix `.` and `-` within one code (`60076.57-1202`) and carry a trailing publication year, so it declares `number` + `parts` + a **per-part `separators` array** (not the single `separator`) + `year` (+ `year_sep`, default `-`, dropped), and the builder fills them via `copublished_structured` (peel the trailing `19xx`/`20xx` year with its separator, then tokenise on `.`/`-` keeping each part's leading separator). It does **not** store `copublished_number` — that verbatim string is a computed method reconstructing `number`+`parts`+`separators`+`year` for the renderer/urn (proven lossless across all 112 copublished rows). So `IEC/IEEE 60076-2016 → {number:"60076", year:"2016"}` and `IEC/IEEE 60076.57-1202 → {number:"60076", parts:["57","1202"], separators:[".","-"]}`. The mixin hoists the columns from `code_obj` on the parse path and **rebuilds `code_obj` losslessly from them after `from_hash`** — the renderer/urn_generator read `code_obj` (base `#code` returns it), so rendering is byte-identical from parse or deserialization. **Why leaves, not the base**: redefining `attribute :number, :string` on the directly-instantiated `Ieee::Identifier` base resolves against the parent `::Pubid::Identifier`'s `Components::Code number` **nondeterministically under multi-flavor load** (passes IEEE-only, flips `root.number`→`""` under the full suite) — the same leaf pattern CIE/IHO/IETF use. **Never** add `attribute :code` back to the base or a `#number` *method* (it collides with lutaml's generated accessor). Dropping `code` had to be **structural** (remove the base attribute) not a `to_hash` deletion, because lutaml serializes a nested `base` via its own class transform, so a `to_hash`-delete only strips top-level keys, not a nested Corrigendum base. Consequences captured by `spec/pubid/ieee/root_number_spec.rb` (must run under the **full** suite to guard determinism): `root.number` is the **bare** base (`C37.09` → `number "37"`, `prefix "C"`); combine `prefix + number` for a series-distinct key. `JointDevelopment` also got the split **and** a bug fix — its `publisher`/`copublisher` overrides were accidentally `private`, so lutaml's `public_send(:publisher)` made `to_hash` raise (it never serialized); made public + drop the derived `publisher`/`copublisher` from its hash (they're rebuilt from `publishers`), so ≈22 IEC/IEEE joint rows now round-trip and index.
8
+
9
+ - **IEEE `#exclude`/`#matches?` (date-less matching)**: partial-ref matching (`bare.matches?(dated, ignore: [:year])`) works for every IEEE type after two fixes. **(1) Positional-hash initialize**: the base `Pubid::Identifier#exclude` rebuilds via `self.class.new(attrs)` (a *positional* hash, what lutaml flavors expect), but IEEE's `initialize(**args)` was keyword-only, so `exclude`/`matches?` raised `ArgumentError` for every IEEE id. `Ieee::Identifier#initialize(args = {}, **kwargs)` (and the `CodeNumber`/`ProjectDraftIdentifier`/`JointDevelopment` initializers in the MRO chain) now accept **either** a positional attribute hash **or** keywords. **(2) Scalar-date cluster**: IEEE stores its date as plain `year`/`month`/`day` `:string` attributes (not a `Components::Date`), so the base `#exclude`'s `:year`->`:date` remap can't nil them — `Ieee::Identifier#exclude` overrides to nil the whole `year`/`month`/`day` cluster when `:year`/`:date` is excluded (the CSA/ASHRAE scalar-year pattern). `IecIeeeCopublished#exclude` additionally resets `year_sep`. `spec/pubid/ieee/root_number_spec.rb` locks the date-less-matches-every-date behaviour. (**Note**: a *bare* project-draft like `IEEE P802.16/D-3` still parses its draft differently from the dated `…/D-3-2017-07` form — an unrelated pre-existing `/D-N` normalization gap, not a matching bug.)
10
+
11
+ - **IEEE serialization compaction (`Pubid::Ieee::Compaction`, `lib/pubid/ieee/compaction.rb`)**: a **recursive hash transform** hooked into `Ieee::Identifier#to_hash` (`collapse`, after the base serialize+canonicalize) and `Ieee::Identifier.from_hash` (`expand`, on a deep-dup before lutaml deserializes) that shrinks two things, both provably lossless and **nested-safe** (they descend into a Corrigendum `base` too). **(1) `typed_stage` → scalar `stage`**: the multi-field `Components::TypedStage` (abbr/stage_code/type_code/iso+ieee equivalents/approval_status/project_status) becomes a single **`stage`** key holding its canonical abbreviation (`"Std"`, `"D2"`, `"D4"`, `"P"`, `"FDIS"`, …), rebuilt from the `TypedStages::TYPED_STAGES` registry. `abbr.first` keys the 14-entry registry **uniquely**, and the collapse fires **only when the registry's reconstruction is byte-identical to the serialized sub-hash** (else the full object is kept — *fails closed*); verified lossless across the whole `relaton-data-ieee` corpus. **(2) `draft` slash strip**: `Components::Draft#to_s` renders `"/D3"`, whose leading `/` is boilerplate, so the serialized form is `"D3"` — no expand step needed because `Draft.parse` already accepts the slashless form and reproduces `"/D3"` (verified on all 6420 corpus draft strings). It is deliberately a hash transform, **not lutaml attributes**: `stage` is already an inherited `Components::Stage` attribute name on `::Pubid::Identifier`, so a redefined `attribute :stage, :string` would reintroduce the same multi-flavor nondeterminism as `number`; and only a whole-hash walk reaches a nested Corrigendum `base`, which a per-object `to_hash` override cannot (the nested-serialization wall). The runtime `typed_stage`/`draft_obj` objects (rendering, matching, URN) are untouched. `entry_sub_hash` must list every `TypedStage` attribute — a new field there without a matching entry here just stops that stage compacting (still lossless). `spec/pubid/ieee/compaction_spec.rb` locks the scalar `stage`, the slashless `draft`, the nested-base case, and the lossless round-trips.
12
+
13
+ - **IEEE relaton historical serialization**: `relaton-data-ieee`'s rows were produced by relaton's own formatter (`Relaton::Ieee::PubId::Id#to_s`), whose suffix spellings differ from pubid's grammar — `/D-N` drafts with a trailing `-YYYY[-MM]` numeric date (the dominant ~87% of the corpus), `/E-N` editions, `/R-N` revisions, `/CorN` corrigenda, and a bare ` Redline` (no ` - `). `Pubid::Ieee::Parser.normalize_relaton_suffixes` (called early in `Parser.parse` preprocessing) rewrites these into canonical pubid forms: the draft/edition trailing date is **repositioned onto the document number** as a base year/month (`…/D-3-2017-07` → `…-2017-07/D3`), which pubid already parses **and** keeps the draft component date-free so it round-trips; a draft+corrigendum combo is swapped to corrigendum-first (`…/D-5/Cor1-2005` → `…/Cor 1-2005/D5`); `/R-N` is dropped (no revision model); bare ` Redline` is stripped (pubid already discards ` - Redline` on the parse path, so redline-ness is not preserved). The mandatory hyphen in the `/D-` rules is load-bearing: relaton always emits `/D-<draft>`, while pubid's own canonical joint-development form is `/D<draft>-<year>` (no hyphen) and must **not** be repositioned. This lifts corpus parse+round-trip from 48.5%/39.1% to ~97%/96%. Two foundational fixes went with it: (1) `Components::Draft.parse` is now an **exact inverse of `Draft#to_s`** (strips the `/D` prefix, splits a trailing rendered date via `DATE_SUFFIX` so `version` stays clean for the SI/PSI renderer) — previously `Draft.parse("/D3")` mismatched its own `to_s`, yielding `/D/D3` and breaking `to_hash` round-trip for **every** draft; (2) `Identifiers::SupplementIdentifier#to_hash` deletes the delegated top-level `publisher` **and `code`** keys (both delegate to `base`, so on the wrapper they only duplicate `base.publisher`/`base.number` — and `publisher` desynced across parse vs `from_hash` for a non-IEEE-publisher corrigendum, e.g. ANSI/ISO). Note this `to_hash`-delete trick only removes a key from a **top-level** wrapper; it cannot drop `code` from a *nested* `base` standard (lutaml serializes a nested `Components::*`/identifier via its own transform, not its public `to_hash`), which is why `code` (== `number`) is **kept** on standards rather than dropped — dropping it cleanly would need a canonicalizer change and would also require rebuilding `code_obj` from `number`, which then leaks a spurious `code` onto code-less types like `IecIeeeCopublished`. Remaining non-round-tripping corpus rows are **pre-existing, unrelated** pubid issues (an order-dependent state-pollution crash on some `IEC/IEEE` copublished drafts; an AIEE `code`/`original_format` serialization asymmetry) plus unknown publishers (USEMCSC/AMPP) and no-digit title junk. A further fix surfaced when relaton built and *read back* the index-v2: `Builder#build_flat_corrigendum` handles the combined draft+corrigendum form: the parser emits a **flat** tree (top-level `corrigendum` hash + `draft`, no `:base` subtree), which the `base`-gated `build_corrigendum_supplement` path skips, producing a base-less Corrigendum that rendered `to_s` lossily on parse and empty after `from_hash`; the new path peels the corrigendum off, rebuilds the base standard (with its draft) via `build_single_identifier`, and wraps it. A further fix addresses the **`root.number` index key** (relaton sorts/binary-searches index-v2 on `id.root.number.to_s`). IEEE keeps identity in `code` (a `Components::Code`), so the base's generic `number` was nil and `root.number` keyed empty. The fix: **plain standards are built as the concrete `Identifiers::Standard` leaf** (the builder's default in `determine_identifier_class` is `Standard`, not the base `Identifier`), and leaf types that are their own `#root` and carry a `code` **`include Identifiers::CodeNumber`** — a mixin that redefines `attribute :number, :string` and populates it from `code_obj.to_s` (`Standard`, `ProjectDraftIdentifier`, `SiStandard`). `IecIeeeCopublished` (its own root, no `code`) redefines `:string` `number` directly and the builder sets it from `copublished_number` with a trailing *publication year* (`19xx`/`20xx` only) stripped (`"60076-2016"`→`"60076"`, keeping non-year 4-digit parts like `"60076.57-1202"`). Wrappers/supplements (`Corrigendum` …) carry no `code` → `number` stays nil and `#root` delegates to their base. With this, **every** round-tripping `relaton-data-ieee` row has a non-empty `root.number`. Plain standards now serialize with `_type: pubid:ieee:standard` (was `pubid:ieee:identifier`); acceptable because IEEE index-v2 is generated fresh. **Hard-won landmines — do NOT regress:** the `:string` redefinition MUST be on **leaf** classes only, **never** on the directly-instantiated `Ieee::Identifier` base — a base redefinition resolves against the parent's `Components::Code` **nondeterministically under multi-flavor load** (passes IEEE-only, flips to `Components::Code` and `root.number`→`""` under the full `rake` suite; CIE/IHO/IETF likewise redefine `number` only on leaves). Never define a `#number` **method** (collides with lutaml's generated accessor, corrupts resolution hierarchy-wide) and never set `number` to a `Components::Code` value (type-mismatch: `code_obj` is `Pubid::Ieee::Components::Code`, the attribute wants `Pubid::Components::Code`; serialization crashes in wrappers). **Always verify any `number` change under the full `rake` suite, never IEEE specs alone.**
14
+
15
+ - **IEEE historical S-designation (IPCEA cable) — `S-135`**: the one-off IEEE/IPCEA co-published cable standard `S-135` (`S-135/IPCEA P-46-426-1962`, also the `(IPCEA P46-426)` parenthetical form) gets its **own** parser rule `s_designation` (`lib/pubid/ieee/parser.rb`) + builder path `build_s_designation` (`lib/pubid/ieee/builder.rb`), **never** a change to the shared `number` rule. `S-135` has a dash between the letter series and the digits, which the shared `number` rule deliberately rejects (that tightening — requiring a digit — is exactly what keeps a bare `IEEE S` from parsing, per `root_number_spec.rb`'s numberless-reject set; do not loosen it). The `s_designation` rule requires `str("S") >> dash >> digits`, so `IEEE S` (no dash+digit) still fails. It is registered in the top-level `rule(:identifier)` alternation right after `csa_dual_published` — safe because every earlier alternative fails on `S-135` (their required `number` rejects it). **Modeling choice**: the builder passes the whole `"S-135"` string as the `number:` split column (the `CodeNumber` scalar), **not** as `code:` — routing through `Components::Code.parse("S-135")` would peel `S` as a prefix and leave `number` empty (parts `["135"]`), breaking the relaton index key and `CodeNumber#rebuild_code_obj_from_split` (nil on empty number). It is deliberately *not* the split `prefix:"S", number:"135"` shape either, because `Components::Code#to_s` never emits a separator between prefix and number, so that would render `"S135"` (dash lost). Whole-string `number:"S-135"` keeps `root.number` non-empty and renders the dash back. `root.number` for this one-off is thus `"S-135"` (not bare digits) — acceptable since the invariant is *non-empty* and there is no S-series part-bucket to narrow. The `/IPCEA …` slash co-designation is stored verbatim (leading slash included) in the existing `crossref` string attribute (rendered as-is at `renderer.rb`); the `(IPCEA …)` parenthetical variant flows through the normal `parenthetical_content` path. Canonical render adds `Std` (`IEEE S-135` → `IEEE Std S-135`), like every other IEEE type. Locked by `spec/pubid/ieee/identifiers/s_designation_spec.rb` and a `root_number_spec.rb` table row (`"IEEE Std S-135" => ["S-135", nil, [], nil]`).
16
+
17
+ - **AIEE reparented onto `Pubid::Ieee::Identifier` + `CodeNumber`**: `Pubid::Ieee::Aiee::Identifier` used to descend from bare `Lutaml::Model::Serializable` and serialize a **nested `code` object with no `_type`**, so `Pubid::Ieee::Identifier.from_hash` couldn't route back to it and dropped the code entirely — **every** AIEE row failed the relaton index-v2 round-trip gate (`from_hash(to_hash) != to_hash`) and had a nil `root.number`. It now `< Pubid::Ieee::Identifier` and `include`s the same `Identifiers::CodeNumber` mixin as the other leaves, so it shares the flat split columns (`number`/`prefix`/`parts`/`separator`), a polymorphic `_type`, `#root`, and from_hash routing. Four reconciliations were load-bearing: **(1)** the date long-form separator was renamed `separator` → **`date_separator`** to avoid colliding with `CodeNumber`'s code-part `separator` (the builder sets `date_separator`, `#to_s` reads it); **(2)** the base's `type` default `"Std"` is overridden to nil (`attribute :type, :string, default: -> {}`) so a type-less `AIEE 11-1937` doesn't render as `AIEE Std 11-1937`; **(3)** `polymorphic_name` is overridden to **`"pubid:ieee:aiee"`** because the auto-derived name uses only the last class-name segment (`Identifier`) and would collide with the base's `pubid:ieee:identifier`; **(4)** AIEE lives under the `Aiee` namespace (not `Identifiers`), so it isn't found by the automatic `Identifiers::*` scan — `Pubid::Ieee::Identifier.additional_identifier_classes` (the documented hook in `identifiers/base.rb`) returns `[Aiee::Identifier]` to register it in the `_type`→class map. The custom keyword-only `initialize` and the `#number` **method** (which collided with the mixin's `number` accessor) were removed; the base+`CodeNumber` initialize chain handles `code:`→`code_obj`→split columns. `#to_s` is kept (AIEE has its own rendering, not the shared Renderer). Verified: **0 `to_s` regressions** across all AIEE fixtures, plain AIEE rows flip from 0 → round-tripping with non-empty `root.number`, and the combined/dual (`DualPublished`) and `AdoptedStandard` AIEE paths still render (the `(ASA …)` adopted forms even improved from an `IncorrectModelError` crash to parsing). Locked by `spec/pubid/ieee/aiee_roundtrip_spec.rb`; the mixin's `:string number` is safe because `Aiee::Identifier` is a genuine leaf (nothing subclasses it) — still verify under the full `rake` suite per the number-determinism landmine. **Known pre-existing limitation (NOT AIEE-specific, out of scope):** the shared `Pubid::Components::Relationship` serializes only `relationship_type` and drops the related-identifier text, so a deserialized relationship renders `()` — this affects *every* IEEE type (e.g. `IEEE Std 100-2000 (Revision of IEEE Std 100-1996)`), and the AIEE *hash* still round-trips (the actual index gate).
18
+
19
+ - **IRE + NESC reparented onto `Pubid::Ieee::Identifier` (the AIEE migration, applied to the last two hold-outs)**: `Pubid::Ieee::Ire::Identifier` and the whole `Identifiers::Nesc::*` family still descended from bare `Lutaml::Model::Serializable`, which made them **crash on a successfully parsed id** rather than fail safely — the exact failure mode issue #214 asks about. Three symptoms: (a) no `#root` → **`NoMethodError`** wherever relaton computes its index key `id.root.number.to_s`; (b) not a `::Pubid::Identifier`, so unassignable to `Identifiers::AdoptedStandard#adopted_identifiers` (widened to `::Pubid::Identifier` by #309) → **`Lutaml::Model::IncorrectModelError` on `to_hash`** for the IRE-adopted forms (`IEEE Std 159-1972 (52 IRE 7 S2)`, `55 IRE 2.S1 (IEEE Std 147)`); (c) no polymorphic `_type` → `from_hash` couldn't route back. Both now `< Pubid::Ieee::Identifier` with `include Identifiers::CodeNumber` **on the leaves only**. Reconciliations, all forced by attribute collisions with the base (mirroring AIEE's `separator`→`date_separator` rename): **IRE** — its `number` (`Components::Code`) is gone; the builder now passes the designation as **`code:`** so the base initializer fills `code_obj` and the mixin hoists the split columns (`to_s` reads `code`, not `number`); its `:integer` `year` became the base's `:string` `year` (builder stringifies); the base's `type` default `"Std"` is overridden to nil (else `52 IRE 7.S2` renders `52 IRE Std 7.S2`); `polymorphic_name` → `"pubid:ieee:ire"`. **NESC** — `Nesc::Base`'s own `code`/`year`/`draft`/`month`/`edition` attributes were dropped or folded into the base's: `code` travels as `code:`→`code_obj`, `year` is now the base's plain **`:string`** (so **`nesc.year` returns `"2017"`, not a `Components::Date`** — a public-API change, `year.year` no longer works), `draft` is dropped entirely — the base's `draft` is the draft *designator string*, so a boolean there serializes as garbage (`draft: true` → `"Dtrue"`); draft-ness is now the class plus a **`#draft?`** predicate (a plain method, safe because lutaml generates no `draft?` accessor) — and `month`/`edition` are inherited unchanged. Because `year` can now be nil (`#exclude(:year)`, or a partial row through `from_hash`), every NESC `to_s` composes its segments with `.compact.join(" ")` / an explicit guard instead of interpolating — a bare interpolation left a dangling `C2-` or a double space, i.e. a *corrupt* document number where the old `year.year` at least raised. Each NESC leaf needs an explicit `polymorphic_name` (`pubid:ieee:nesc-standard`/`-handbook`/`-redline`/`-draft`/`-edition`) because the derived name uses only the last class-name segment — `Identifiers::Nesc::Standard` would otherwise collide with `Identifiers::Standard`. **New leaf `Identifiers::Nesc::Edition`**: the builder's fallback used to instantiate the abstract `Nesc::Base` directly for the plain year-first form (`2017 National Electrical Safety Code(R) (NESC(R))`); it now builds `Edition`, so `Base` is never instantiated and only genuine leaves carry the mixin's `:string number` (the multi-flavor determinism landmine). `Nesc::Base#initialize` **raises `NotImplementedError` on a direct `Base.new`** to keep that contract enforceable — an instantiated `Base` would carry the derived `_type` `"pubid:ieee:base"`, which `from_hash` can't resolve (it silently degrades to a plain `Pubid::Ieee::Identifier`), and a nil `number`. **Index key**: the year-first forms print no code, so `Nesc::Builder::DEFAULT_CODE = "C2"` (the NESC's own ANSI/IEEE designation) supplies one — every NESC row keys under `prefix "C"` + `number "2"` instead of the empty key. Caveat recorded at the constant: this files the Handbook/Redline (companions of the code, not the code) in the C2 bucket; harmless because relaton narrows on `root.number` then matches the full hash, where `_type`/`variant` keep them distinct. All seven classes are registered in `Pubid::Ieee::Identifier.additional_identifier_classes` alongside AIEE: the automatic scan only sees classes declared **directly** under `Identifiers`, so it misses both the `Ire::`/`Aiee::` namespaces **and** the nested `Identifiers::Nesc::` one. Verified: **0 `to_s` regressions** across every IRE/NESC form in the corpus, the normtitle feed and the fixtures; on `relaton-data-ieee/index-v1.yaml` both crash classes drop to **zero** (8 `IncorrectModelError` + 3 `NoMethodError` → 0) and round-trip rises 99.05% → 99.14%. Locked by `spec/pubid/ieee/ire_nesc_roundtrip_spec.rb` (+ an IRE case in `adopted_standard_spec.rb`). **Pre-existing, out of scope:** the spelled-out `Draft National Electrical Safety Code, January 2016` never reaches the NESC sub-parser (the generic IEEE grammar claims it first) and builds a plain `Standard` — pinned by a spec so the gap is visible; and `Nesc::Redline` still renders without its `Redline` marker for the `…, C2-2012 - Redline` input.
20
+
21
+ - **Historical ISO-led stage designations (`<pubs> <ISO-stage> [P]<num>[.part][-YYYY[-MM]]`)**: relaton-data-ieee carries ~133 unparseable ISO-led stage rows like `ISO/IEC/IEEE FDIS P26515-2018-05`, `IEEE FDIS P24748.1-2018-05`, `ISO/IEC/IEEE CD2 P15026.4-2018-02`. The `joint_development_iso_format` rule (`lib/pubid/ieee/parser.rb`) was generalized to cover them alongside the original colon form (`ISO/IEC/IEEE FDIS 26511:2018`): a **wider joint-publisher set** (adds `IEEE/ISO/IEC`, `IEEE/IEC/ISO`, `ISO/IEC`, `IEEE/IEC`, and a **bare `IEEE`** — longest token first), an **optional leading `P`** on the number, **multi-digit committee-draft stages** (`CD1`..`CD4`) plus **`CDV`**, and a trailing **`-YYYY[-MM]` dash-date** (the `part` sub-rule guards `year_digits.absent?` so it doesn't swallow the year; the numeric `-MM` becomes `month`). The wider publisher set is safe because the `iso_stage` token (FDIS/DIS/CDV/CD*/WD/PWI/NP) gates the branch — a normal `IEEE 1588`/`IEEE Std …` has no stage token there. `build_joint_development` now also copies `parsed[:month]`; `typed_stages.rb` gained `CD1`/`CD4` (on the existing committee-draft entry, `abbr.first` stays `"CD"` so compaction is unaffected) and a new `CDV` entry. **Round-trip note:** the relaton gate is the **hash** round-trip (`from_hash(to_hash) == to_hash`) + non-empty `root.number`, NOT `to_s == input` — `JointDevelopment#to_iso_format` renders the canonical colon form (`ISO/IEC/IEEE FDIS 26515:2018`, dropping the P and the `-MM`), which is fine because the hash carries `number`/`year`/`month`/`iso_stage`/`stage` and round-trips. Recovers **127/133** of the iso-stage bucket. **Follow-on additions on this branch:** (a) an optional **`/D<draft>` tail** on the ISO rule (bucket 5) — normalize repositions `…/D-3-2017` → `…-2017/D3`, so the draft trails the date; a date-less `/D-4` keeps its hyphen (`dash.maybe`); the draft goes to `ieee_draft` via a builder block hoisted **before** the lead-party split (which had a **load-bearing near-miss**: dropping `lead_party = "IEEE"` from the else branch silently re-rendered every IEEE-led joint draft as ISO colon form — keep it). (b) **bucket-7 variants:** `FCD` + `DIS`/`CD` round-digit (`DIS2`) stages, an optional noise `Std` word after the stage, and a preprocessing gsub restoring the space in a slash-joined publisher+stage (`ISO/IEC/IEEE/ FDIS` → `ISO/IEC/IEEE FDIS`). (c) **bucket-3 status drafts:** the `ieee_approved_draft_identifier` status word widened to `Unapproved`/`Active` (dropped best-effort; the crawler double space is already whitespace-collapsed), recovering the numeric-date subset (`historical_status_draft_spec.rb`). Locked by `spec/pubid/ieee/historical_iso_stage_spec.rb` + `historical_status_draft_spec.rb`. **Still not done (documented, deferred):** two-P-number joint drafts (`IEC/IEEE PC37.60/P62271.111/D-9-…`, ~5), text-month status drafts (bucket 2 — misspelled crawler months like `-Feburary`/`-Sept`, genuine crawl junk), `/R-` revision markers, and PSI/`Pub2`/`PDTR3` one-offs.
22
+
23
+ - **IEEE foreign/partner co-publishers (bucket 4)**: relaton-data-ieee carries co-published rows whose publisher token pubid's `organization` rule (`lib/pubid/ieee/parser.rb`) didn't recognize — **AMPP** and **USAS** (standalone, e.g. `AMPP SP21496-2023`, `USAS C57.12.00-1968`) and the IEEE sub-board / partner co-publishers **USEMCSC**, **EAB**, **MPAI** (after a slash, e.g. `ANSI/USEMCSC C63.14-2023`, `IEEE/EAB 1100`, `IEEE/MPAI P3302/D-1-2024-06`). Adding these five tokens to `organization` (which feeds both the leading `publisher` and the `slash >> organization` copublisher) lets ~16 rows parse with a non-empty `root.number` and round-trip. `organization` is a plain token alternation, so this is additive and IEEE-only (no `number`/determinism impact). Locked by `spec/pubid/ieee/foreign_publishers_spec.rb`. **Deferred (still skipped):** the IRE `x.S1`/`x.S2` measurement forms (`IRE 12.S1-1962` — needs IRE sub-parser work), `NACE SP0XXX-…` (the `XXX` is a literal placeholder, not a real number), and the `P1635/…/ASHARE 21/…` typo one-off.
24
+
25
+ - **IEEE revision-notation dialects (`REVa`/`REVd`/glued) — strip, don't reorder**: the relaton-ieee rawbib feed spells revisions as `<num>-REVa/D8`, `<num>/REVd/D5`, glued `<num>REVa/D5`, `_Rev2`, `.Rev 3`, `REVmb` (case-insensitive `REV`/`Rev` + trailing `[A-Za-z0-9]+`, before the draft). `Pubid::Ieee::Parser.normalize_revision_notation` (runs in `self.parse` *before* `normalize_relaton_suffixes`) **strips** the REV token rather than reordering it to the canonical `/D<n>/R-<x>`. Two reasons: (1) pubid's canonical `…/D<n>/R-<x>` already **drops the revision on render** (`normalize_relaton_suffixes` strips a trailing `/R-x`), so the revision-less result *is* the same identifier — the spec asserts `parse(variant).to_hash == parse("…/R-<x>").to_hash`; (2) **reordering regressed 28 normtitles** — many REV forms (`Draft P…-REVmb/D3.0, March 2010 (…)`, `Unapproved Std P…_Rev2/D5 Jun 2008`) *already parse*, and moving `/R-x` in front of the trailing date/parenthetical broke them; stripping leaves the tail intact. Two subs: a REV token with a `(?=/D[0-9])` look-ahead (the mandatory following draft is what stops it matching the English word **"Revision"**, never followed by a draft), and a digit-anchored trailing glued form (`(\d)REV…\z`) for the no-draft case. Verified on relaton's 8,767 normtitles: **+12 parsed, 0 regressions**. The hand-off's optimistic "~70" conflated REV-*containing* with REV-*blocked*: only ~13 still-failing normtitles carry a REV token and the rest are blocked by *other* issues (2-digit years `Jan 09`, dash-drafts `-DF2`, compound amendment prose) that this can't fix — those are relaton `RawbibIdParser`/re-crawl territory. Locked by `spec/pubid/ieee/revision_notation_spec.rb` (incl. the "does not mangle 'Revision'" guard).
26
+
27
+ - **IEEE trademark rendering (`to_s(trademark: true)`)** — **the mark attaches to the document number, not the end of the string**: IEEE prints `IEEE Std 1619™-2007`, `IEEE Std 802.3®-2018` — the symbol sits immediately after the number (and its parts), **before** the year, revision, draft, `/Cor`/`/Amd`, `/INT`, edition, month, parentheticals, `[nickname]` and ` - Redline`. pubid used to append it to the *finished* string (`IEEE Std 1619-2007™`), which relaton-data-ieee then baked into every `scope: trademark` docidentifier and metanorma-ieee mirrored into `eref`/`citeas` (issue #322). Plain `to_s` is unchanged (default off). **Symbol choice is number-based, not string-based**: `Pubid::Ieee.trademark_symbol_for(number, prefix = nil, publishers: nil)` returns ® iff the bare number (leading `P` stripped) is in the module constant `REGISTERED_SERIES = %w[802 8802 2030]`, the code has **no letter series** (`prefix` nil/empty/`"P"`, so `C802.1` stays ™), **and** the printed publishers include `IEEE` (`Array(publishers).any? { |p| p.split("/").include?("IEEE") }`). This deliberately widens the old `\AIEEE (Std )?(802|2030)` regex, which mis-picked ™ for `IEEE P802.1AE/…`, `ANSI/IEEE 802…` and `ISO/IEC/IEEE 8802…`; it also *must* be number-based now, because the mark no longer lands where a whole-string regex could see the prefix. **The publisher gate is load-bearing**: without it a number-only rule claims an IEEE registered mark for another body that merely numbered a document 802/2030 — `AIEE Std No. 802` is a real corpus row, and AIEE dissolved in 1963, two decades before the IEEE 802 series. Two deliberate escapes: `8802` (`ISO_ADOPTED_SERIES`) is ® on its number alone, because it *is* the ISO/IEC adoption of IEEE 802 whatever publishers print; and `publishers: nil` (the default) skips the gate, so the bare two-arg number lookup still works. `Pubid::Ieee.trademark_symbol(rendered)` is **kept for external callers only** — no render site inside pubid uses it, because the first numeric token of a rendered string is not reliably the document number (a NESC or IRE string starts with the publication year, so a 2030 edition year would silently render ®). **Renderer plumbing**: `Renderer#render` no longer appends anything — it stores `@trademark = opts[:trademark]` (safe as instance state: a renderer is built per `Pubid::Identifier#render` call) and dispatches; each render site then inserts `mark(number, prefix)` (which returns `""` when the flag is off, so **default output is byte-identical**) at its own number boundary — `render_base` (right after the code + `P` prefix), `render_si_standard`, and `render_iec_ieee_copublished` (which passes the mark into `IecIeeeCopublished#copublished_number(mark)`, since that string glues the parts and the year onto the number). **Wrappers propagate instead of appending**, via `child_to_s(child, **opts)`: `render_corrigendum`/`render_amendment`/`render_interpretation`/`render_conformance`/`render_redlined_standard`/`render_parenthetical` mark only their `base`; `render_adopted_standard` marks only `ieee_identifier`; `render_csa_dual_published` only the IEEE half; and `render_dual_published`/`render_dual_identifier`/`render_multi_numbered` mark **each** co-equal designation before its own suffixes (matching relaton). **Landmine — `child_to_s` guards on `child.is_a?(Pubid::Ieee::Identifier)`**: a foreign leaf breaks on the kwarg — `Pubid::Nist::Identifiers::Base#to_s(format = :short)` swallows it *silently* as a positional Hash, `Pubid::Iso::CombinedIdentifier#to_s(lang:, lang_single:)` raises — and an adopted/parenthetical child is not the IEEE document being marked anyway. **Still true**: `Pubid::Identifier#render` slices `:trademark` into the renderer opts but strips it from `build_rendering_context` (`.except(:trademark)`) because ISO overrides that with a strict signature; the shared `Pubid::Renderers::HumanReadable#render` takes `**_opts` so a non-IEEE `to_s(trademark: true)` renders normally with no symbol. **Every leaf that overrides `to_s` must accept-and-honour `trademark:`** — relaton calls it on *every* IEEE id and a raising leaf silently drops the document from the crawl. `Identifiers::JointDevelopment` (keeps `format:`) threads a `mark` string into `to_iso_format`/`to_ieee_format`; `Aiee::Identifier` inserts it after the code, before the date suffix (`AIEE No 13™-1930`). `Ire::Identifier` and the year-first `Identifiers::Nesc::*` leaves (`Base`/`Handbook`/`Redline`/`Draft`) **keep the trailing append** — IRE ends with its code (so end-of-string *is* the code boundary) and those NESC forms render a name (`2012 NESC Handbook`), not a number+suffix chain — but both now pick the *symbol* from their own `code_obj` (via `Nesc::Base#trademark_symbol`), never by scanning the rendered string. `Identifiers::Nesc::Standard` is the exception in that family: it renders its `C2` code first, so it follows the general rule (`C2™-2012 National Electrical Safety Code`). `IecIeeeCopublished#copublished_number(mark = "")` takes the mark as a parameter rather than the renderer rebuilding the string, so marked and plain output can never desynchronise. **IEC/IEEE copublished needs one extra step, because its parse path is string-based**: the grammar slurps everything after `IEC/IEEE ` into one `:content` slice and `Builder#copublished_structured` tokenises it on `.`/`-` **without stopping at whitespace**, so an edition, a colon year, a stage word or a `- Redline` tail ends up *inside* `number`/`parts` (`IEC/IEEE 60076-16 Edition 2.0 2018-09` → `number "60076"`, `parts ["16 Edition 2", "0 2018", "09"]`; `IEC/IEEE 60802 Edition 1.0 2026-06` → the tail lands in **`number`** instead — the split point varies). Appending the mark after the parts therefore printed it at the end of the whole reference. `#marked_code`/`#code_end_index` instead splice it at the end of the **number token** of the concatenated `number + parts_suffix` (scanning the concatenation, not just the suffix, is what covers the `60802` case). The token model is what makes this robust: skip a leading **`STAGE_PREFIX`** stage word (`FDIS 60079-30-2`, `CD2 P62704-5 ED1` — its `(?=P?\d)` lookahead is what stops it eating a number that begins with a letter; without the skip the mark prints as `IEC/IEEE FDIS™ …`), then the code can never run past that token's end, so a trailing edition/date phrase is safe even when nothing matches inside the token. **`CODE_END`** only moves the mark *earlier* within the token: a colon year (`62582-2:2022`), a dash/dot-attached `19xx`/`20xx` year the tail stranded inside the code (`62395.1-2024 Redline` — the `19|20` prefix is what keeps a part that merely looks like a year, `60076.57-1202`, from matching), or a glued parenthetical language marker (`62704-1(E)`). **Plain `to_s` is structurally immune, not merely tested**: `mark` is `""` whenever the flag is off and `#marked_code` early-returns the untouched code (verified: 0 plain-rendering changes across all 8,566 parseable IEEE fixture references, 22 marked ones — all moving the mark onto the number). The remaining known gap is upstream: repairing the *parser* to expose edition/year/Redline as real attributes would remove the need for the splice entirely. Locked by `spec/pubid/ieee/trademark_position_spec.rb` (per-type table, the `trademark_symbol_for` unit table, **and** a corpus property test asserting the mark is purely additive: `to_s(trademark: true).delete("™®") == to_s`), plus `trademark_rendering_spec.rb` / `trademark_leaf_to_s_spec.rb`. **Note for relaton**: regenerating `relaton-data-ieee` will show ™→® flips on the `P802…`/`ANSI/IEEE 802…`/`8802…` rows — that is the intended widening, and diverges from relaton-ieee's own reference renderer, which anchors on the literal `IEEE` prefix. (hand-off: ieee-to-s-trademark-kwarg.)
28
+
29
+ - **IEEE `Redline` suffix — preserve, don't drop (data-loss fix)**: a redline is a **distinct document** from its base standard, but pubid used to silently drop the trailing marker — the ` Redline` (space) form was stripped in preprocessing and the ` - Redline` (dash) form failed to parse — so ~600 relaton-data-ieee redline rows collapsed onto their base standard's id (same index key, the round-trip guard blind to it because the marker never entered `to_hash`). Fix: the grammar's `redline` rule now matches **both** ` Redline` and ` - Redline` (case-insensitive, at the very end, after year/parenthetical), and the **two preprocessing strips were removed** (`normalize_relaton_suffixes`' ` Redline\z` sub and the ` - Redline.*$` gsub) so the suffix reaches the grammar. The builder already sets `redline: true` from the captured marker; `render_base` already restores the ` - Redline` suffix from the flag. **Modeling choice**: redline is a **flat `redline: true` boolean on the plain `Standard` leaf** (the base `Identifier` already declares `attribute :redline`), **not** the `Identifiers::RedlinedStandard` wrapper — the builder's `determine_identifier_class` no longer routes to `RedlinedStandard` (that wrapper expects a nested `base` the flat build never sets, and `render_redlined_standard` would `id.base.to_s`-crash). So `IEEE Std 802.16-2012 Redline` → `Standard{number:"802", parts:["16"], year:"2012", redline:true}`, `_type pubid:ieee:standard`, round-trips, and renders distinctly from its base. Canonical render is ` - Redline` (relaton's ` Redline` also parses back). Verified on relaton's 8,767 normtitles: parse rate **87.7% → 92.6% (+432 redline forms, 0 regressions)** — every change is redline-specific, so non-redline ids are untouched. Locked by `spec/pubid/ieee/redline_suffix_spec.rb`; the `relaton_historical_spec.rb` redline cases now round-trip with the flag.
30
+
31
+ - **IEEE draft digit loss (multi-part designators) — builder concatenation fix**: pubid silently dropped digits from dotted/hyphenated draft designators (`P754/D1.2.6` rendered as `…/D.6`, `P802.8/D-3.0` as `…/D.0`). **Root cause is the builder, not the model**: the grammar's `draft_version.repeat(1,2)` splits `1.2.6` into two captures `["1.2", ".6"]`, and `Builder#handle_draft`'s array **merge** overwrote `draft_version` with the last part. (`Components::Draft` already stores `version` as a verbatim string and `Draft.parse`/`#to_s` round-trip it losslessly — which is why the buggy id still *round-tripped* (`from_hash(to_hash) == to_hash`) even while `to_s` was wrong, so the round-trip guard never caught it.) Fix: `handle_draft` now **collects all `draft_version` parts into an array** (the existing version-extraction `.join`s them back into one verbatim string) instead of merging, and strips a leading hyphen (`/D-3.0` → `3.0`). The `version`/`revision` split is left in place but the whole designator now lands in `version` (revision stays effectively unused). Verbatim means odd inputs are preserved exactly (`P802.8/D.3.2` → `D.3.2`, not the old lossy `D.2`). Verified over relaton's 8,767 normtitles: **38 draft renderings restored (all gained digits), 0 shorter, 0 parse-rate change, 0 regressions**; relaton's canonical `/D-<draft>` forms (`P754/D-1.2.6` …) now round-trip with every digit preserved. Locked by `spec/pubid/ieee/draft_verbatim_spec.rb`.
32
+
33
+ - **IEEE numbered/lettered revisions + edition/ANS residue (`revision` attribute)**: the last residue of relaton-data-ieee ids pubid returned nil for (hand-offs: ieee-numbered-revision, ieee-nil-residue). **(1) Revision model** — a new **`attribute :revision, :string`** on `Pubid::Ieee::Identifier` (safe: `::Pubid::Identifier` has no `revision` attribute, so it's free of the `number`/`stage` multi-flavor collision landmine). IEEE's **native inline `Rev<n>`** (`P802.16Rev2/D3`, `_Rev2`, `.Rev 3`, `-rev1`, and the after-draft ` Rev 18`) and relaton's **synthetic `/R-<x>` suffix** (`/D-09/R-i`, the empty-draft revision-only `/D-/R-17`) both feed this one attribute, rendered back **inline `Rev<id>`** in IEEE's native position (right after the code number, before the draft). Machinery: `normalize_revision_notation` **repositions NUMBERED inline dialects to a trailing `/R-<n>`** (keeping the existing **lettered-inline strip** — repositioning lettered forms regressed 28 normtitles, and no hand-off needs it, so `REVa`/`REVd` still collapse onto their base with `revision` nil); `normalize_relaton_suffixes` gained a **combined `/D-<d>/R-<x>-YYYY[-MM]` + empty-draft `/D-/R-<x>-YYYY` handler** (reposition the base date onto the number, keep `/D<d>` when non-empty, leave `/R-<x>`) and its old plain-`/R-` **strip was changed to preserve** (0 normtitle impact — the feed has zero `/R-` rows; those come from relaton's *index* serialization). A single **`revision_suffix` grammar rule** (`/R-<alnum>`) is slotted **immediately after `draft.maybe`, before any trailing-date clause** in the P/generic/joint rules (placement is load-bearing: normalization always emits `.../D<n>/R-<x><date>`, so a revision_suffix *after* the date clause fails the dated forms `P802.16Rev2/D3 Feb 2008` — this was an 8-normtitle regression, caught by the full-suite gate, fixed by moving it before the date). The `draft_version` rule got a **`(str("R") >> dash).absent?` guard** so the D-less draft path doesn't swallow a bare `/R-<id>` (no-draft `P1722/R-1`) as a draft. Builder already had a (previously no-op) `extract_optional(parsed, attributes, :revision)`; renderer appends `Rev#{id.revision}`. **(2) Edition `/E-<n>`** — `normalize_relaton_suffixes` already rewrote `/E-<n>-YYYY` → `Edition <n>.0 YYYY`, but (a) the `edition` rule only accepted a space/` - ` year separator while preprocessing produces `Edition 3.0-2016` (bare dash) — added `dash` to the separator alternation; (b) the two **joint rules** (`joint_development_ieee_format`/`iso_format`) had no trailing `edition` slot, and (c) `build_joint_development` didn't extract `:edition` — all three fixed, so `ISO/IEC/IEEE FDIS P15289/E-3-2016` carries `edition "3.0"` and round-trips (the joint renderer doesn't emit the edition inline, which is fine — the hash carries it, which is the index gate). **(3) `ANS`** (American Nuclear Society) added to the `organization` token list (after `ANSI` so it can't shadow it) for `ANSI/IEEE/ANS 7.4-3-2-1982`; that row also surfaced a **top-level-array `build` crash** — Parslet returns a top-level *array* when a repeated capture (two subparts in `7.4-3-2`) blocks hash-merging, and `build`'s dispatch indexed it as a Hash; fixed by reading the dispatch keys off a merged view (`d = parsed.is_a?(Array) ? merge_parsed_array(parsed) : parsed`) while still passing the original array to `build_single_identifier` (top-level arrays previously crashed outright, so this is strictly more robust; the `7.4-3-2`→`7.4.2` subpart collapse is a pre-existing multi-subpart merge quirk on this one malformed row — it still round-trips). Locked by `spec/pubid/ieee/nil_residue_spec.rb` + the updated `revision_notation_spec.rb` (numbered/suffix preserved, lettered-inline stripped). Verified: full multi-flavor suite green, normtitle rate 8192→8193 (0 regressions).
34
+
35
+ - **IEEE `to_s` is a bounded identifier — no relationship narrative**: `Pubid::Ieee::Identifier#to_s` (and `Aiee::Identifier#to_s`) deliberately renders **only** the identity string plus **bounded** markers — reaffirmation `(R…)`, the catch-all `parenthetical_content`, `[nickname]`, ` - Redline`. The **unbounded** relationship/amendment narrative (`relationships`, `revision_of`, `amendment_to`, `adoption`, `note`) is **not** embedded, because an "as amended by …" list can run 300+ chars and consumers use `to_s` as a **document number and output filename** (relaton-data-ieee slugs it to `data/<slug>.yaml`; a 300-char name blew past the OS 255-byte limit → `Errno::ENAMETOOLONG`, silently dropping whole crawl batches — ~4.8k docs instead of ~12.5k). **Why `parenthetical_content` is kept but `relationships` dropped**: the grammar routes a real "(Amendment to IEEE Std … as amended by …)" narrative through the **structural** `relationship_clause`/`amendment_to` rules into `relationships`; the `parenthetical_content` catch-all only ever holds **short, non-relationship** parentheticals — printed identity tokens like the `S-135 (IPCEA P46-426)` co-designation (whose canonical `to_s` the s_designation spec asserts *includes* it). So `parenthetical_content` stays bounded and is retained. The narrative is **not lost**: it stays on the runtime `relationships` accessor (and `to_hash` already serialized only `relationship_type`, never the narrative — so serialization/round-trip is unchanged). The three render sites that used to append the narrative — `Renderer#render_base` (the `elsif relationships … / revision_of / amendment_to / adoption / note` chain), `Renderer#render_si_standard`, and `Aiee::Identifier#to_s` — were stripped of it; `Components::Relationship#to_s` itself is untouched (it still renders the full narrative for callers that want it, its unit tests stay green). Wrapper types whose composed child *is* part of the document's identity (`render_adopted_standard`'s adopted-id list, `render_corrigendum`, `render_parenthetical`) are bounded and unchanged. Locked by `spec/pubid/ieee/amendment_narrative_to_s_spec.rb`. (hand-off: ieee-to-s-embeds-amendment-title.)
36
+
37
+ - **IEEE `/Amd N-YYYY` amendment — attach + serialize as a supplement (data-loss fix)**: an `/Amd N-YYYY` suffix (`IEEE Std 802.3-2018/Amd 4-2020`, the no-space `/Amd4-2020`, `ISO/IEC/IEEE 8802.3/Amd4-2021`) used to be **silently dropped** from both `to_s` and `to_hash` — so `X/Amd4` == `X/Amd7` == `X` as identifiers, collapsing distinct amendment documents onto their base's relaton-index row (16 `ISO/IEC/IEEE 8802.x` keys absorbed 48 amendment docs). The parser *did* capture it (`rule(:amendment)` → `{amd_number, amd_year}`, wired inline via `amendment.maybe`), but there was **no Amendment identifier class, no builder attach path, no `determine_identifier_class` route, no `rename_supplement_keys` translation, and no renderer case** — so `amd_number`/`amd_year` reached `Standard.new` as *undeclared* keys and were dropped by the base `initialize`. The fix **mirrors the corrigendum path 1:1**: new `Identifiers::Amendment < SupplementIdentifier` (`amendment.rb` — `attribute :number, :string` (leaf-safe, like Corrigendum; year inherited), `TYPED_STAGES` abbr `["Amd"]`/`type_code "amendment"`, `mr_supplement_suffix` → `"amd.<n>.<y>"`), auto-registered for `from_hash` via the `Identifiers::*` autoload scan (`_type: pubid:ieee:amendment`, no collision). Builder: `build_flat_amendment` (the real path — every amendment tree is **flat**: a top-level `amendment` hash, no `:base`) peels the amendment off, rebuilds the base via `build_single_identifier`, and wraps it; a `build_amendment_supplement` (recursive `:base` + `:amd_number` variant) is kept **for structural parity with corrigendum but is currently unreachable** — no amendment grammar rule emits a `:base` subtree (unlike corrigendum's dedicated `corrigendum_identifier` rule), so it only fires if such a rule is ever added. `determine_identifier_class` routes `amd_number` → `Amendment`; `rename_supplement_keys` folds `amd_number`/`amd_year` → uniform `number`/`year`. Renderer: `render_amendment` emits `id.base.to_s + "/Amd <n>-<y>"` (canonical space form; the no-space input also parses back). The base-string reconstruction inside `build_corrigendum_supplement` was extracted verbatim into a shared `reconstruct_base_string` helper reused by both. **The "8802.x year drop" in the symptom was the amendment's own `amd_year`** riding along with the dropped amendment — the base's own `-YYYY` (`ISO/IEC/IEEE 8802.3-2021`) was never lost; the `Standard`-vs-`IecIeeeCopublished` mis-typing of the `ISO/IEC/IEEE 8802.x` published form is a benign representation choice left unchanged (the amendment attach alone restores full distinctness). `root.number` still walks to the base document number (`"802"`). Locked by `spec/pubid/ieee/identifiers/amendment_spec.rb` (the existing `amendment_slash_spec.rb` only asserted the base parsed, never that the amendment was retained — that was the blind spot). (hand-off: ieee-amendment-dropped-from-identifier.)
38
+
39
+ - **IEEE draft grammar coverage (3 parse gaps → ~700 index-v2 drafts recovered)**: `relaton-data-ieee`'s `index-v2` build rejected ~730 draft docids on `Parslet::ParseFailed`; three additive grammar edits in `lib/pubid/ieee/parser.rb` close the parseable-baseline / failing-variant gaps (the relaton gate is parse + non-empty `root.number` + `to_hash`/`from_hash` round-trip, **not** `to_s == input`). **(1) Status-word prefix on a P-draft (largest bucket, ~426):** `IEEE Active Unapproved Draft P…/D…` and the `Unapproved Draft`/`Approved Draft`/`Active Approved Draft` phrases failed — `ieee_approved_draft_identifier` hard-codes only `Approved` + a required `Draft Std`/`Std`, and the generic `draft_status` bucket demands a `Draft Std`/`type_word` after any status word (no bare `Draft`). Fix: add `"Active Approved"` to the `draft_status` alternation (longest-first; it only feeds a string attribute — `determine_stage_abbr` ignores `draft_status`), and prepend `draft_status.as(:draft_status).maybe` before `str("Draft")` in **`ieee_draft_p_identifier`** (which already consumes the literal `Draft P…`). The status round-trips as the `draft_status` attribute; the literal `Draft` stays a bare marker (dropped on render). `ieee_approved_draft_identifier` still wins for `IEEE Approved Draft Std P…` (earlier in the alternation). **(2) Draft **then** trailing corrigendum (~161):** every bucket's inline `corrigendum.maybe` sits *before* `draft.maybe`, so `…/D1, Jan 2007/Cor. 1` had no path. Fix: a **second** `corrigendum.maybe` **after** the draft+trailing-date clause in `ieee_p_identifier`, `ansi_p_identifier`, and the generic bucket. The draft's own `draft_date` consumes the date, leaving `/Cor. 1`; the flat `corrigendum`+`draft` tree (no `:base`) routes to the existing `build_flat_corrigendum` (rebuilds the base **with** its draft, wraps it) — no builder change. The pre- and post-draft slots are **positionally disjoint** (the pre-draft one fires only when no draft precedes), so both matching in one parse needs a pathological double-corrigendum (`…/Cor1/D3/Cor2`) that already fails to parse anyway. **(3) Draft date — day-without-comma and numeric month (subset of ~115):** `draft_date` accepted `Month YYYY` and `Month DD, YYYY` but not `Month DD YYYY` (`July 15 2012`) nor `MM YYYY` (`05 2007`). Fix: a `month_numeric` variant and a day-without-comma variant, both accepting **`-` as well as space** before the year — **load-bearing coupling**: preprocessing (parser.rb ~line 1278, `gsub(/(\d)\s+(\d{4})(?=\s*\(|\s*$)/, '\1-\2')`) rewrites a trailing ` <digits> <year>` to `<digits>-<year>`, so these dates reach the grammar dash-joined (`July 15-2012`, `05-2007`); the with-comma form escapes that gsub, which is why it already worked. Accepting both space and dash makes the grammar robust whether or not that gsub fires (do **not** delete the dash branch assuming it's dead). Locked by `spec/pubid/ieee/draft_grammar_coverage_spec.rb`; full `rake` green (grammar-ordering + `number`-determinism landmines require the whole suite, not IEEE-only). **Deferred (out of scope):** the ~26 "bad `/D-` delimiter" bucket (`/D-6.1_CDV_…`) overlaps the `relaton-data-ieee` rawbib-draft hand-off; and a pre-existing bare-year-after-draft gap in `ieee_p_identifier` (`IEEE P1234/D5, 2015` fails, though `ansi_p_identifier` handles it) is unrelated. (hand-off: ieee-draft-grammar-coverage.)
40
+
41
+ - **IEEE trailing numeric-month date (`, MM YYYY`) — the *non-draft* analog of (3) above**: the **trailing print/reaffirm** date clause (distinct from `draft_date`) accepted only a month *name* (`, May 2014`), so `IEEE P15026.1, 05 2014` and `ISO/IEC/IEEE 21451.7, 04 2011` (no ISO stage → the generic catch-all branch, not `joint_development_*`) failed — 13 relaton-data-ieee index-v2 docids skipped. Fix (`lib/pubid/ieee/parser.rb`, additive): a reusable **`trailing_month_year`** rule adds a month-first `month_numeric.as(:trailing_month)` sibling beside the `month_name` one, swapped into the 5 identical `:trailing_month`/`:trailing_year` clauses (`ieee_p_identifier` ×2, `ansi_p_identifier` ×2, generic bucket); `ieee_draft_p_identifier`'s space-only clause got the same numeric sibling inline. **Same load-bearing gsub coupling as (3):** the `(\d)\s+(\d{4})` preprocessing rewrites a trailing `05 2014` → `05-2014`, so the numeric branch must accept **`(space | dash)`** before the year (the month-**name** branch is untouched by that gsub — its preceding char is a letter — so it stays space-only). Safe/additive: `month_name` never starts with a digit and `month_numeric` (`0[1-9]|1[0-2]`) never matches a bare `19xx`/`20xx` year, so a bare trailing year still falls through to the callers' own bare-year clause; no `number`/`stage` attribute redefined. Month stored **verbatim** as `:string`, rendered back `", #{month} #{year}"` (renderer.rb ~157) → byte-identical round-trip. The two per-rule clauses (before- and after-draft) were *already* both `.maybe` in the month-name form; swapping to `trailing_month_year` changes nothing about that. Locked by `spec/pubid/ieee/numeric_month_date_spec.rb`; full `rake` green. (hand-off: ieee-numeric-month-date.)
42
+
43
+ - **IEEE `ieee_draft_p_identifier` — optional `P` + trailing corrigendum (residual of draft-grammar-coverage)**: the `IEEE [<status>] Draft P<n>/D<n>` rule (`lib/pubid/ieee/parser.rb`) had two gaps skipping ~11 index-v2 drafts, closed by two **additive** edits to that one rule. **(1) `str("P")` → `str("P").maybe`:** a status-word draft with a **bare (non-`P`) number** (`IEEE Unapproved Draft 802.1ah/D4.2, Mar 2008`, `…C57.15/D8.6…`, `…11073-10471/D02…`) failed because `P` was mandatory — inconsistent with `ieee_approved_draft_identifier`, which already uses `str("P").maybe`. The shared `number` rule already accepts the bare forms, and the branch is gated by the literal `Draft` keyword, so making `P` optional doesn't shadow other alternatives (`ieee_approved_draft_identifier` still wins for `IEEE Approved Draft Std P…` — earlier in the `identifier` alternation). **(2) `corrigendum.maybe` after `draft.maybe`/`revision_suffix.maybe`:** a trailing `/Cor. N` (`…/D2.0/Cor. 1`, or `…/D1.0, Dec 2007/Cor. 1` where the draft's own `draft_date` eats the date) had no path. The resulting **flat** `corrigendum`+`draft` tree (no `:base`) routes to the existing `build_flat_corrigendum` (`builder.rb`), which rebuilds the base standard via `build_single_identifier` — so the `draft` **and** `draft_status` ("Unapproved") ride along on the base — then wraps it in `Identifiers::Corrigendum`; **no builder change**. `C37.04/DB2 …/Cor. 1` hits both gaps. `root.number` still walks to the base document number. Locked by `spec/pubid/ieee/draft_noP_and_corrigendum_spec.rb`; full `rake` green (grammar-ordering gate). (hand-off: ieee-draft-noP-and-corrigendum.)
44
+
45
+ - **IEEE `AdoptedStandard` derived-publisher round-trip (data-loss fix)**: `Identifiers::AdoptedStandard` (`X (Y)` adoptions, e.g. `AIEE No 511-1956 (IEEE Std 275)`) failed the relaton index-v2 gate (`from_hash(to_hash).to_hash != to_hash`) — dropping all 13 corpus `adopted-standard` rows. Its `#publisher` is a **derived** method (`ieee_identifier&.publisher || "IEEE"`), so lutaml omitted `publisher` from `to_hash` on the parse path (attribute unset → `using_default?` true, not serialized) but **re-emitted the derived value after `from_hash`** materialized the attribute default (the override then returns `ieee_identifier.publisher`, e.g. `"AIEE"` ≠ the `"IEEE"` default, so the canonicalizer kept it) → an asymmetric top-level `"publisher"` key. This is the **exact** class of bug already fixed on `JointDevelopment` (whose derived `publisher`/`copublisher` are likewise dropped from its hash). Fix: an `AdoptedStandard#to_hash` override that `hash.delete("publisher")` after `super` — `publisher` is fully derivable from the serialized `ieee_identifier`, so dropping it is lossless. A **top-level `to_hash` delete is sufficient** because `AdoptedStandard` is only ever a top-level wrapper (built in `identifiers/base.rb`; `builder.rb:845` routes to it), **never** a nested `base` (where lutaml's own transform would bypass the override). `copublisher` needs no treatment — `AdoptedStandard` doesn't override it, so it stays an empty (symmetrically-dropped) collection. The IEEE-led ordering (`IEEE Std 275-1956 (AIEE No 511-1956)`) already round-tripped because its derived publisher equals the `"IEEE"` default. Locked by `spec/pubid/ieee/identifiers/adopted_standard_spec.rb` (round-trip block). (hand-off: ieee-adopted-standard-roundtrip.)
46
+
47
+ - **IEEE `AdoptedStandard` cross-flavor adopted-id type (serialization crash fix)**: a *cross-flavor* adoption — `IEEE Std <n> (ANSI/ASME/IEC <m>)`, e.g. `IEEE Std 144-1971 (ANSI C37.24-1971)` — **parses and renders fine but `to_hash` raised** `Lutaml::Model::IncorrectModelError` (`attribute 'adopted_identifiers' value is a 'Pubid::Ansi::Identifiers::Standard' but should be a 'Pubid::Ieee::Identifier'`), so the relaton index-v2 build counted it an *error* (not a parse failure) and skipped the doc — the largest recoverable skip cluster (~36 `ANSI/ASA/USAS/ASME/NSF` rows). Root cause: `Identifiers::AdoptedStandard` declared `adopted_identifiers` (and `ieee_identifier`) with the **IEEE-only** type `Identifier` (== `Pubid::Ieee::Identifier`); lutaml enforces the declared attribute type and `polymorphic: true` widens it only to IEEE **subclasses**, not a `Pubid::Ansi::…`/`Pubid::Iec::…` object the parser correctly builds. Fix (2 lines, `lib/pubid/ieee/identifiers/adopted_standard.rb`): widen **both** attribute types to the cross-flavor base **`::Pubid::Identifier`** (still `polymorphic: true`). The class parent stays `AdoptedStandard < Identifier` (IEEE) — only the two nested attribute *types* change. Cross-flavor `from_hash` already works with no other change: the nested child carries its own `_type` and the root `::Pubid::Identifier` `apply_mappings`/`from_hash` route by `_type` via `TypeResolver` (`lib/pubid/identifier.rb`), so `from_hash(to_hash).to_hash == to_hash` for ANSI, IEC, **and** the pre-existing all-IEEE cases. The existing `#to_hash` `publisher`-drop override is unaffected (still fires; `publisher` stays absent for cross-flavor too). This is **not** the `number`/`stage` multi-flavor determinism landmine (no `attribute :number`/`:stage` redefinition) but the full `rake` suite was still run (green). **Note:** `AdoptedStandard#root.number` is `nil` — but that is *pre-existing* and identical for the already-shipping IEEE-only adopted rows (`AdoptedStandard` has no `base`, so `#root` is `self` and it carries no `number`), so relaton indexes these the same way; out of scope here. The existing spec only exercised `.parse`/`.to_s` for cross-flavor cases (the blind spot) — now locked by a `to_hash`/round-trip block in `spec/pubid/ieee/identifiers/adopted_standard_spec.rb`. (hand-off: ieee-adopted-standard-cross-flavor-type.)
48
+
49
+ - **IEEE `copublisher` default (`from_hash(to_hash) == parse`, issue #214)**: `Identifier#copublisher` (`lib/pubid/ieee/identifiers/base.rb`) is `collection: true`. Most builder paths set it, so a parsed id held `[]`; the hash omits an empty collection, so `from_hash` left `nil`. The two were not `==`, so `#matches?` (parsed reference vs index row) returned `false` with no error. The damage was large: on `relaton-data-ieee/index-v1.yaml` plus the `ieee-rawbib` normtitle feed (17,925 parseable ids), only 7,429 were `==` after `from_hash`. Nested ids (a Corrigendum `base`, an `AdoptedStandard` member) had the same asymmetry. The fix is the Tgpp `parts` one: `initialize_empty: true`. A replay of both corpora against a `main` baseline gave 17,797 `==` (10,368 gained, 0 lost) and **0 differences** in `to_s`, `to_urn`, `to_hash` and `root.number`, so no index changes. Every reader (`renderer.rb`, `urn_generator.rb`, `CsaDualPublished`, `JointDevelopment`) treats `nil` and `[]` the same. The `PENDING_EQUALITY["Pubid::Ieee"]` entry is gone; `spec/pubid/ieee/copublisher_equality_spec.rb` locks the fix. **Residue (not fixed here)**: 113 drafts with a numeric month (`IEEE P1243/D3 02 2023`) are still not `==`, because `Components::Draft.parse` reads the serialized `"D3 02 2023"` as version `"3 02"`; and 4 `IEEE/ASTM PSI 10/D2, October 2010` lose the draft after `from_hash`.
50
+
51
+ ## From the root note "Wrapper index keys (`root.number`) — the number was already there, one level down"
52
+
53
+ **`MultiNumberedIdentifier` was a data-loss bug, not a key gap**: it held both designations in `attr_accessor`s, which lutaml cannot see, so `to_hash` was `{"_type" => …}` and nothing else, `from_hash` could rebuild neither member, `#exclude` skipped them, and **`to_s` was the empty string**. That is the CSA container defect again; the fix is the same, making them attributes typed the cross-flavor `::Pubid::Identifier` (`polymorphic: true` widens only to *subclasses*). Two smaller repairs fell out: the builder stored the crossref slice *including its leading slash*, so `code` was `"/C62.22.1-1996"` and then failed the renderer's own `/^C\d+\./` cross-reference test; and that renderer branch reused the fully-rendered secondary, printing `IEEE Std` twice — a cross-reference prints the **bare** second designation after the slash.
54
+ **`InterpretationIdentifier` had the ASHRAE flat-tree defect**: with a trailing date the parse is FLAT (an `:interpretation` marker beside the number, no `:base` subtree), so the `base`-gated branch skipped it and `determine_identifier_class` built a base-less wrapper that dropped the number, the part and the publisher — `"IEEE Std 1003.2-1992/INT, Dec. 1994 Ed."` rendered as `" 1003.2-1992/INT"`, a leading space where the publisher belongs. New `build_flat_interpretation` mirrors the existing `build_flat_corrigendum`/`build_flat_amendment`. The trailing **month is deliberately dropped** (the nested form carries none either, and setting it fed the URN's `month_day_component`, putting `Dec.` — a word with a dot — into a URN segment).
55
+ **The MR slug and the URN had to move with `#root`, and that is where the real damage was.** Per the project rule that an identity-bearing marker reaches **every** identity surface: `UrnGenerator#code_component` and the base `mr_number_with_part` both read **`code_obj`**, which a wrapper does not have, so all 66 IEEE adoptions collapsed onto **5 URNs and 5 slugs** — 66 documents overwriting each other, since `to_slug` is an output filename. Each wrapper now delegates `code_obj` to the identifier it wraps. `code_obj` is a plain `attr_accessor` on the IEEE base, **not** a lutaml attribute, which is exactly why overriding it as a method is safe where `#number`/`#year` would not be; `mr_year` (a hook) is likewise safe where `#year` is not. Two more leaks: `DualPublished#publisher` returns an **Array**, and both `mr_publisher` (`publisher&.to_s&.downcase`) and the URN's `publisher_component` interpolated it raw — the slug and the URN literally contained `["ieee", "asme"]`, brackets, quotes, comma and space included. Both now join with `-`. And `UrnGenerator` gained a `year_component` that falls back to `root.year`, fixed in the one place that reads the year rather than by a `#year` method on each wrapper. **Result: 101 IEEE wrapper ids went from 5 distinct MR slugs to 88 — one per distinct rendering, 0 clashes.**
56
+ **The review finding worth keeping — making a member serializable ACTIVATES the derived-attribute round-trip bug.** `MultiNumberedIdentifier#publisher` and `#year` were harmless while the members were `attr_accessor`s, because `from_hash` rebuilt nothing for them to derive from; the moment the members became attributes, the derived values started being re-emitted after `from_hash` materialized the attribute defaults while the parse path omits them as unset. That is the asymmetry `Ieee::Identifiers::AdoptedStandard#to_hash` and `JointDevelopment` already delete a key to avoid. Checking the whole corpus for it found the bug **live on a sibling this branch had not touched: all 25 `DualPublished` ids failed `from_hash(to_hash) == to_hash`** — and since `publisher` there is an Array, the re-emitted value was the literal string `["IEEE", "IEEE"]`, the same leak as the slug. That gate is what relaton's index build uses to decide whether to keep a document, so those 25 were being skipped. Both classes now drop the derived keys in `to_hash`; IEEE corpus round-trip failures went **34 → 9** (the 9 are unrelated and pre-existing: 5 `IncorrectModelError` on parenthetical revisions, 4 `SiStandard` value drifts). **The rule: whenever a wrapper's member becomes serializable, audit every method on that wrapper that derives from it — and check the siblings, because the pattern is usually copied.**
57
+
58
+ - **Two multi-designation wrappers were dropping their second designation, silently, and the unknown-key contract is what found them.** Both were the same shape: the builder passed a constructor key the class did not accept, and lutaml (and IEEE's own hand-rolled `initialize` loop, which assigns only declared attributes) discarded it without a word. Neither cost a test, because `to_s` merely came out *shorter* rather than wrong. **(1) `AdoptedStandard` — a spelling mismatch.** `Identifier.build_aiee_asa_adoption` passed `adopted_identifier:`, while the class declares the **collection** `adopted_identifiers`. So the ASA half of **13** historical AIEE identifiers was never stored, and `AIEE No 18-1934 (ASA C55 1934)` rendered back as `AIEE No 18-1934`. Passing `adopted_identifiers: [asa_id]` restores it — and the repair is confirmed by the fixture, which **already recorded the correct output**: `spec/fixtures/ieee/identifiers/pass/adopted_standard.txt` carries `!AIEE No 18-1934 (ASA C55 1934)!AIEE No 18-1934 (ASA C55-1934)`, i.e. the fixture and the code had silently diverged. **(2) `CsaDualPublished` — a deliberate non-attribute that nothing assigned.** `csa_identifier` is an `attr_accessor` on purpose ("stored as-is (not a Lutaml model type)"), so IEEE's `initialize` loop — `public_send(setter, value) if attrs.key?(key)` — skipped it every time. The renderer reads it (`ieee/renderer.rb`), so it read nil: `IEEE Std 844.1-2017/CSA C22.2 No. 293.1-17` rendered back as **`IEEE Std 844.1-2017/CSA`**, with the whole CSA designation gone. The fix is two-sided, because a non-attribute constructor parameter needs both halves: the class declares `self.extra_init_keys` (so the cross-flavor unknown-key check admits it) **and** IEEE's `initialize` now assigns an `extra_init_keys` key through its plain accessor. **The general lesson for this flavor**: IEEE is the one flavor whose identifiers routinely carry runtime-only ctor params (`code`, `draft`, `draft_obj`, `csa_identifier`), and its `initialize` is hand-rolled rather than lutaml's. Any new one of those must be added to `extra_init_keys`, or it is accepted, ignored, and lost.
59
+
60
+ - **Six IEEE types rendered plain under `to_s(annotated: true)`; eight
61
+ classes had to change.** `Aiee::Identifier`, `Ire::Identifier` and
62
+ `JointDevelopment` compose their own string and now wrap it in
63
+ `annotate_plain_render`. The `Nesc::` family is the part worth recording:
64
+ only `Edition`, `Handbook` and `Standard` showed up in the corpus sweep,
65
+ but `Draft` and `Redline` have the same shape, and **none of the four
66
+ subclasses calls `super`** — so annotating `Nesc::Base` alone would have
67
+ covered `Edition` and nothing else. All five carry the call.
68
+
69
+ `JointDevelopment` dispatches on `format` through a `case` whose branches
70
+ each return a different builder; the `case` result is assigned and wrapped
71
+ once, rather than wrapping each branch.
72
+
73
+ ## The parent slot is never called `base` in IEEE
74
+
75
+ Recorded by the cross-flavor survey that moved BSI's adoptions onto `base` (`lib/pubid/bsi/CLAUDE.md`). IEEE is the flavor with the most parent slots under other names, and they fall into two groups.
76
+
77
+ **Carries `#root`, so the index key is correct**: `AdoptedStandard` (`ieee_identifier` is the parent, `adopted_identifiers` the collection — and `adopted_identifiers` is a key in **73 rows of the published `relaton-data-ieee/index-v2.yaml`**, so renaming it is an index regeneration, not a pure refactor), `CsaDualPublished` (`ieee_identifier`), `DualPublished` (`first_identifier`), `MultiNumberedIdentifier` (`primary_identifier`).
78
+
79
+ **Has NO `#root`, so `root` returns the wrapper**: `DualIdentifier` (`first_identifier` / `second_identifier`) and `IecIeeeCopublished` (`iec_identifier` / `ieee_identifier` — this one declares its own `number`, so the key survives anyway).
80
+
81
+ `ParentheticalIdentifier` and `RedlinedStandard` already declare `base` and inherit `#root`; their `parenthetical_identifier` and `revision_of` are relations, not parents, as are the six relationship collections on `Ieee::Identifier` (`amendments`, `corrigenda`, `revision_of`, `incorporates`, `supersedes`, `supplement_to`).
82
+
83
+ The lesson BSI paid for: a wrapper's hand-written `#number` delegation makes the index key look right while `#root` is still broken, and it shadows a real lutaml accessor — which is how the BSI adoption wrappers came to raise on `to_hash` until pubid#379 widened the attribute types. Before trusting one of the IEEE delegations, check what type it returns against what the attribute declares.
84
+
85
+ - **Unapproved-draft render idempotence (pubid#318) — the type word goes whole, and the project P must survive the re-parse.** `"IEEE Active Unapproved Draft Std P99/D2.0, May 2007"` rendered as `"…Unapproved Draft P99/D2.0…"`, whose re-parse dropped the bare `Draft` marker **and** the `P`, drifting for three rounds before settling on a lossy fixed point. Three coordinated edits close it. **(1) The renderer drops the WHOLE type word when `draft_status` matches `/unapproved/i`** — the #209 half-strip (`"Draft Std" → "Draft"`) kept a token the grammar treats as a bare marker, which is exactly what made the render non-idempotent; the status word already carries the draft-ness, and every unapproved canonical in the corpus agrees (`"IEEE Unapproved 802.1ah/D4.2"`, `"IEEE Unapproved P1137/D2"`). **(2) `ieee_draft_p_identifier` no longer consumes the `P` with a bare `str("P").maybe`** — the shared `number` rule carries its own optional leading `P`, so deleting the bare consume lets `:number` capture `"P99"` and `Components::Code` peel it as `code.prefix`, the same path the generic rule uses; the project marker now round-trips. **(3) `determine_stage_abbr` derives stage `"D1"` for a version-less `"Draft Std"`** (gated on `parsed[:draft].nil? && parsed[:digit_draft].nil?`) — the D1–D6 registry entries carry `project_status: true` ("P prefix, unapproved" by design), so the fabricated `/D1` of #205 must drive the same P-prefixed rendering an explicit `/D1` gets, or that family converges only in round 2. **The gate in (3) is load-bearing**: a dotted version (`"D2.0"`) fails the registry lookup and falls through to the type branch — ungated, it would have stolen `"Draft Std"` from `"IEEE Draft Std P802.3/D2.0/Cor. 1"`, which the corpus keeps verbatim. Result: every probed draft form reaches its `to_s` fixed point in **one** round, `from_hash(to_hash) == to_hash` holds on every round, and the status-less/approved guards are untouched. The #209/#205 spec rows moved to the idempotent canonicals (`"IEEE Unapproved Draft Std 802.3"` → `"IEEE Unapproved P802.3/D1"`, `"IEEE Draft Std 802.3"` → `"IEEE P802.3/D1"` — the latter now consistent with the established explicit-`/D2` rendering). **Residual, recorded not chased**: an alias spelling and its canonical still differ in `to_hash` (`type: "Draft Std"` + `stage: "Std"` vs `stage: "D1"`) — the pre-existing alias-vs-canonical divergence in the not-clean IEEE ledger; equalizing it needs the `#matches?`-visible type normalization whose blast radius (the D-registry `project_status` interactions) is its own branch.
86
+
87
+ - **`Rev<n>` before a hyphenated `/D-` draft (pubid#316 family 1, 12 rawbib rows).** `normalize_revision_notation`'s before-draft numbered-revision regex captured the draft as `(/D[0-9A-Za-z.]*)`, which stops at relaton's hyphen — on `"P802.16Rev2/D-3-2008-02"` it matched only `"/D"` and emitted the garbage `"/D/R-2-3-2008-02"`. The draft captures in both numbered positions (and the lettered strip's lookahead) now accept the hyphenated spelling (`/D-?[0-9A-Za-z.]+`), so `"…Rev2/D-3-2008-02"` repositions to `"/D-3/R-2"` and then flows through the existing relaton-suffix rewrite (`"/D-<d>/R-<x>-YYYY[-MM]"` → `"-YYYY-MM/D<d>/R-<x>"`) exactly like a directly-spelled one — parse, `to_s` fixed point, and hash round-trip all verified, with the lettered strip semantics untouched. The date lands as `year`/`month` attributes (a year-only draft date still renders without its year — pre-existing, identical to the no-`Rev` analogs).
88
+
89
+ - **IEEE/ASTM SI/PSI rawbib spellings (pubid#316 family 3, 8 rows).** `ieee_astm_si_psi` only accepted `"IEEE/ASTM <PSI|SI> <n>"`, with a space separator and an undashed `/D<n>` — the rawbib feed hands back bare `"IEEE PSI.10/D-1-2010-05"`, `"IEEE PSI 10/D-2-2010"`, `"IEEE/ASTM SI 10.1997"` (dot-glued year), and the comma-dated legacy `"PSI 10/D2, October 2015"`. Three coordinated grammar edits. **(1) Publishers widen to `(IEEE/ASTM | IEEE)` and the type-number separator to `(space | str("."))`** — long-prefix PEG order. **(2) Draft and date appear in EITHER order** — legacy puts the draft first with a comma-month-year after, while `normalize_relaton_suffixes` repositions the rawbib `/D-<n>-YYYY[-MM]` onto the number as `-YYYY-MM/D<n>`; the alternation is `(psi_draft >> psi_date.maybe) | (psi_date >> psi_draft.maybe)` with each side optional so a date-only or draft-less form still parses. **(3) The comma branch accepts `(month_name | month_numeric)` AND `(space | dash)` before the year** — the renderer emits `, 05 2010` for a numeric-month draft, and the load-bearing preprocessing gsub rewrites a trailing ` <digits> <year>` to `<digits>-<year>` (the same coupling `draft_date` already documented), so the re-parse must read its own rendering back. **The month sub-branch uses `digit.repeat(2,2)`, not `match("[0-9]{2}")`** — the parser's `match` takes a character-class string and silently rejects `{2}` quantifiers (a parslet-test probe kept me on this for too long before the unit check confirmed it). **An incidental pre-existing shadowing bug found en route**: `Pubid::Ieee::Identifiers::SiStandard` re-declared `attr_accessor :draft_obj`, silently defeating the base's lazy-rebuild reader (base.rb) — after every `from_hash(to_hash)`, SiStandard drafts lost their `/D<n>` and the re-emitted hash dropped the `draft` key entirely. Removing the re-declaration inherits the lazy rebuild and the hash round-trip closes; the fix is recorded against the SI/PSI family rather than as its own PR because it's the same single concern. **Result**: all 8 rawbib rows parse, render to the canonical `"IEEE/ASTM"` SI/PSI form (SI 10 is genuinely jointly published — the bare-IEEE rawbib spelling normalizes to the official joint designation), reach `to_s` fixed point in one round, and `from_hash(to_hash) == to_hash` holds. The dashd-drafts spec boundary moves the one PSI row from `"intentionally unmapped"` into the recovered bucket.
90
+
91
+ - **Prefix-first IRE spellings (pubid#316 family 2, 6 rawbib rows).** `"IRE 7.S2-1952"` failed because IRE routing is a year-first LOOKAHEAD (`(2-digit-year | 19xx) >> space >> "IRE"`, or `IEEE-IRE`) in the main grammar, and the IRE sub-parser's root itself demanded a leading year — a prefix-first spelling had no path at all, even though the `number` rule already accepts `7.S2` (`digits >> (space | dot) >> upper >> digits`). Two additive edits: the sub-parser's `ire_identifier` becomes `year_first | prefix_first` (the prefix-first form reuses the existing `date` rule for the trailing `-YYYY`), and the main-parser lookahead gains `(str("IRE") >> space >> (digits | Standard | Std | Trans))` — deliberately loose, because the sub-parser is strict and a failed delegation just falls through the alternation (every prefix-first `IRE …` string already failed to parse, so nothing is re-routed). **The renderer already emits the year-first short-year form unconditionally**, so each spelling collapses onto its year-first twin — `"IRE 7.S2-1952"` renders AND hash-equals `"52 IRE 7.S2"` (the same document, two crawl spellings; asserted alias-style in the spec).
92
+
93
+ - **Joint ISO-stage residual spellings (pubid#216, 126 → 153 of the issue's 171 rows).** The issue's list was already 126-parseable via the earlier `joint_development_iso_format` generalization; the residual 45 were mechanical spellings plus a semantic tail. **(1) Grammar tail** — the joint rule's `/D<n>` draft now accepts a trailing text date ("CD P26515/D1, March 2017") or bare comma-year ("FDIS P15289/D3, 2017") under **distinct keys** (`:draft_month`/`:draft_year` — plain `:month`/`:year` collide with the date clause and parslet drops the subtree); the language marker accepts a preceding space, and a relationship `parenthetical.maybe` follows ("…CD2 P15288-2013-09 (Revision of …)"). **(2) `Parser.normalize_joint_stage_spellings`** (runs after the month unglues) rewrites only **broken separators** — underscore draft ("P15289_D3"), underscore stage after the draft ("D3_FDIS"), slash-space before the stage ("P16085/ FDIS"), space-year after a slash-stage ("P26511.2/FDIS 2018" → dash-year), space-stage ("P63113 CD4"), chained space-stage+space-draft ("CDV D1"), space-draft after a stage-first number, slash-stage carrying a dash-date — plus the ",March" comma-space typo. **Two safety rules, both learned by regression during the branch**: only broken separators fire (a plain "/FDIS" tail is natively accepted by `ieee_p_identifier`'s fdraft and the stage-LAST embedded rule — a blanket slash-stage rewrite stole ~35 of them, rendering garbage like "DDIS"); and the number carries at most ONE part (the joint rule reads a single optional part; multi-part "P62271-37-013" must stay with ieee_p). **Backreference discipline**: `stage` interpolates WITH its own capture group, so every composed regex's group count shifts — three rewrites shipped with off-by-one references and produced "2018 P26511.2-" style garbage before the counts were re-derived; the pubs anchor also had to CAPTURE its separator or the rewrite glued "IEEE" to the stage. **Deliberately NOT rewritten**: a dash/underscore month glued to the stage ("FDIS_Dec 2012") — even routed onto the joint grammar the render of a month+draft is not yet idempotent (converges to a "DFDIS" draft), and on the bare-IEEE fdraft path the stage and date are DROPPED from `to_hash` — rewriting would trade a visible failure for silent loss. **The semantic residue stays deferred**: ED stages ("ED2" — an edition marker, needs a registry decision), "Second edition" words, amendment+stage compounds ("AMD1_CDV"), YYMM date codes ("DIS-1404"), "FDIS1" stage iterations, and the two cross-flavor rows.
94
+
95
+ - **`all_parts_edition_keys` missed `revision`, `reaffirmed` AND `edition_month` — three gaps, found in two passes.** `Identifier.all_parts_edition_keys` defaults to `%i[date year edition version]`. A first pass fixed IEEE's `revision` attribute (the numbered/lettered draft revision id — "2" in "P802.16Rev2"), a separate discriminator from `edition`/`year` that this list never covered, so `"IEEE P802.16Rev2".to_all_parts === "IEEE P802.16Rev3"` was `false` — two revisions of the same draft failed to collapse. Code review of that same change then found it had stopped one attribute too early: `reaffirmed` (the "(R2010)" year, read by `renderer.rb` and `urn_generator.rb`) meant two reaffirmations of the same standard — `"IEEE Std 218-1956 (R1980) …"` vs `"… (R1990) …"` — also failed to collapse; and `edition_month`, the month half of the `"Edition N YYYY-MM"` rendering (`renderer.rb`'s `"Edition #{edition} #{year}-#{edition_month}"`), survived even though its sibling `year` is already in the default list, so `"IEEE Std 802.11 Edition 3.0 2015-03"` didn't match `"… 2015-06"`. All three fixed together: `Pubid::Ieee::Identifier.all_parts_edition_keys` is `super + %i[revision reaffirmed edition_month]`. Not on the original hand-off's audit list (which didn't cover IEEE); found during code review of the OGC/3GPP/NIST fix for the same class of bug. **Lesson**: finding one missed attribute on a flavor does not mean the audit of that flavor is finished — check every date/edition/version/revision/reaffirmation-shaped attribute against the renderer and builder, not just the one the bug report named (see the root `CLAUDE.md` bullet). Locked by `spec/pubid/all_parts_edition_keys_audit_spec.rb`.
96
+
97
+ - **The project designator format, and the bare-P parse that dropped it (pubid#18).** IEEE's own draft documents are cited by their short designator — "P1201/D0.3", "P10000/D1.2" — and metanorma-ieee had to post-process the full rendering to produce it. Two edits. **(1) `format: :project`** registers `Pubid::Ieee::ProjectRenderer` in the IEEE format registry: the code (with parts), a PREPENDED "P" when the identifier is a project but the code does not already carry it, and "/D<draft>"; no publisher, no type word, no date. "Project" is detected in all three places a marker can live depending on the building path — `code_obj.prefix` (the bare spelling), the "P" type code, or a `typed_stage` with `project_status` (D1-D6). **(2) The bare-P data loss**: `ieee_p_identifier` consumed its leading "P" with a bare `str("P")`, so "P1201/D0.3" parsed WITHOUT its P anywhere (the number was "1201", prefix nil) — the issue's complaint verbatim. The rule now lets `number` capture the P (the #318 pattern), so `code.prefix` survives and the designator has something to read. The DEFAULT rendering is unchanged — the corpus keeps the P in every unapproved-draft canonical, and #318's idempotence work locked that; the issue's suggestion that the default drop the P predates the corpus and is not adopted. A published (non-project) id renders its bare number under `:project` — the format is a draft-designator view.
@@ -669,7 +669,14 @@ module Pubid
669
669
  # Extract publishers from joint_publishers
670
670
  if parsed[:joint_publishers]
671
671
  joint_pub_str = extract_value(parsed[:joint_publishers])
672
- attributes[:publishers] = joint_pub_str.split("/")
672
+ pubs = joint_pub_str.split("/")
673
+ attributes[:publishers] = pubs
674
+ # lutaml materializes attribute defaults during from_hash, so the
675
+ # deserialized joint carries publisher/copublisher alongside
676
+ # publishers; set the same shape on the parse path or the
677
+ # amendment-wrapped round-trip diverges on exactly those keys.
678
+ attributes[:publisher] = pubs.first
679
+ attributes[:copublisher] = pubs.drop(1)
673
680
  end
674
681
 
675
682
  # Build code with parts if present
@@ -678,8 +685,14 @@ module Pubid
678
685
 
679
686
  code_str = extract_value(parsed[:number])
680
687
  if code_str && !code_parts.empty?
681
- code_str += ".#{code_parts.join('.')}"
682
- end
688
+ # The ISO/IEC label's part may be dash-joined ("8802-9") - keep
689
+ # the printed separator (the iso-format route's :part_dash
690
+ # prints as a dot in the joint code).
691
+ sep = parsed[:iec_label_dash] ? "-" : "."
692
+ code_str += "#{sep}#{code_parts.join(sep)}"
693
+ end
694
+ # P = project (a draft): identity-bearing, preserved as spelled.
695
+ code_str = "P#{code_str}" if parsed[:project_marker] && code_str
683
696
  attributes[:code] = code_str
684
697
 
685
698
  # Extract year (and optional numeric month, e.g. the -MM of a historical
@@ -693,6 +706,25 @@ module Pubid
693
706
  end
694
707
  attributes[:month] = extract_value(parsed[:month]) if parsed[:month]
695
708
 
709
+ # A text date trailing the DRAFT (pubid#216: "CD P26515/D1, March
710
+ # 2017") reaches the builder under :draft_month/:draft_year — the
711
+ # grammar captures them separately to avoid the :month/:year
712
+ # duplicate-subtree collision with the date clause.
713
+ if parsed[:draft_year]
714
+ attributes[:year] ||= extract_value(parsed[:draft_year])
715
+ attributes[:month] ||= extract_value(parsed[:draft_month])
716
+ end
717
+
718
+ # The "(E)" edition marker of an ISO/IEC label rides on the joint
719
+ # reference as parenthetical_content - either from the trailing
720
+ # parenthetical slot (the double-label form) or the mid-rule slot
721
+ # before an amendment tail.
722
+ if parsed[:parameters].is_a?(Hash) && parsed[:parameters][:parenthetical_content]
723
+ attributes[:parenthetical_content] =
724
+ extract_value(parsed[:parameters][:parenthetical_content])
725
+ end
726
+ attributes[:parenthetical_content] ||= extract_value(parsed[:edition_marker]) if parsed[:edition_marker]
727
+
696
728
  # Extract edition, from relaton's "/E-<n>" suffix (normalized to
697
729
  # "Edition <n>.0"). nil-residue hand-off item 1.
698
730
  attributes[:edition] = extract_value(parsed[:edition]) if parsed[:edition]
@@ -710,6 +742,89 @@ module Pubid
710
742
  attributes[:ieee_draft] = "D#{draft_ver}" if draft_ver
711
743
  end
712
744
 
745
+ # The joint stage-draft clause (docs/IEEE-DRAFT-STAGES.md §1.3):
746
+ # variant 1's compound tail composes onto the IEEE ordinal
747
+ # ("D5=DIS.3"); variant 1b is the ordinal-less stage draft whose
748
+ # date rides inside the designator ("D=CDV:2020").
749
+ if parsed[:draft_iso_stage]
750
+ stage = extract_value(parsed[:draft_iso_stage])
751
+ joint_draft = if parsed[:draft_version]
752
+ "D#{draft_ver}=#{stage}"
753
+ else
754
+ "D=#{stage}"
755
+ end
756
+ if parsed[:draft_iso_iteration]
757
+ joint_draft += ".#{extract_value(parsed[:draft_iso_iteration])}"
758
+ end
759
+ if parsed[:draft_stage_year]
760
+ joint_draft += ":#{extract_value(parsed[:draft_stage_year])}"
761
+ end
762
+ attributes[:ieee_draft] = joint_draft
763
+ end
764
+
765
+ # The catalogue-PRINTED joint form - the parser tags the dash-year
766
+ # and ", Month YYYY" spellings (:printed_dash_year /
767
+ # :printed_month_year), and a date trailing the DRAFT marks the same
768
+ # printed family: "ISO/IEC/IEEE 21451-7, April 2011",
769
+ # "ISO/IEC/IEEE 13210-1994", "ISO/IEC/IEEE 42010/D8, June 2010".
770
+ # These are IEEE standards printed with joint publishers - a Standard
771
+ # carrying publisher/copublisher renders them as printed and
772
+ # serializes like every other standard, where the colon-year
773
+ # spelling stays an ISO-style JointDevelopment reference.
774
+ # A stage-WORD draft (CD, DIS, FDIS, DCD... letters, not a D-number)
775
+ # belongs to the joint/project-draft family even with a trailing date -
776
+ # its canonical renders drop the P and keep the joint spelling, so it
777
+ # must not become a printed Standard (which keeps the code's P).
778
+ numeric_draft = parsed[:draft_version] &&
779
+ extract_value(parsed[:draft_version]).to_s.match?(/\A\d/)
780
+ printed_joint = parsed[:iso_published] &&
781
+ (parsed[:printed_dash_year] || parsed[:printed_month_year] ||
782
+ (numeric_draft && parsed[:draft_month]) ||
783
+ # A date-less stage-less joint reference carrying only a
784
+ # parenthetical ("16326 (First edition 2009-12-15)") is
785
+ # the same printed family; a colon-year row keeps :year,
786
+ # so its parenthetical stays with the ISO joint form.
787
+ (parsed[:year].nil? && parsed[:parameters].is_a?(Hash) &&
788
+ parsed[:parameters][:parenthetical_content]))
789
+ if printed_joint
790
+ sep = parsed[:part_dash] ? "-" : "."
791
+ printed_code = [extract_value(parsed[:number]),
792
+ extract_value(parsed[:part])].compact.join(sep)
793
+ # P = project: the marker is identity, preserved as spelled.
794
+ printed_code = "P#{printed_code}" if parsed[:project_marker]
795
+ printed_attrs = { publisher: attributes[:publisher],
796
+ copublisher: attributes[:copublisher],
797
+ typed_stage: Pubid::Ieee.locate_stage("Std") }
798
+ printed_draft = attributes[:ieee_draft]
799
+ if parsed[:printed_dash_year] && parsed[:month]
800
+ # A dash-year-month date belongs to the printed code, glued
801
+ # before any draft ("16326-2017-12/D5") - the render's month
802
+ # slot spells text months and would lose the printed form.
803
+ printed_code += "-#{attributes[:year]}"
804
+ printed_code += "-#{attributes[:month]}"
805
+ elsif parsed[:printed_dash_year]
806
+ # A bare dash-year is the identity year - an attribute, so the
807
+ # render attaches it exactly as printed ("21451.7-2011").
808
+ printed_attrs[:year] = attributes[:year]
809
+ elsif parsed[:printed_month_year]
810
+ printed_attrs[:year] = attributes[:year]
811
+ printed_attrs[:month] = attributes[:month]
812
+ elsif parsed[:draft_month]
813
+ # The date trails the draft itself ("D8, June 2010"); keep it
814
+ # inside the draft so the code stays date-less.
815
+ printed_draft = "#{printed_draft}, #{extract_value(parsed[:draft_month])} " "#{extract_value(parsed[:draft_year])}"
816
+ end
817
+ printed_attrs[:code] = printed_code
818
+ printed_attrs[:draft] = printed_draft if printed_draft
819
+ printed_attrs[:edition] = attributes[:edition] if attributes[:edition]
820
+ printed_attrs[:edition_month] = attributes[:edition_month] if attributes[:edition_month]
821
+ if parsed[:parameters].is_a?(Hash) && parsed[:parameters][:parenthetical_content]
822
+ printed_attrs[:parenthetical_content] =
823
+ extract_value(parsed[:parameters][:parenthetical_content])
824
+ end
825
+ return Identifiers::Standard.new(**printed_attrs.compact)
826
+ end
827
+
713
828
  # Detect lead party based on pattern
714
829
  if parsed[:iso_stage]
715
830
  # ISO format - lead party is ISO
@@ -722,6 +837,10 @@ module Pubid
722
837
  attributes[:typed_stage] =
723
838
  Pubid::Ieee.locate_stage(stage_abbr)
724
839
  end
840
+ elsif parsed[:iso_published]
841
+ # Stage-less PUBLISHED joint form (pubid#317): ISO-led spelling,
842
+ # no project marker, no typed stage - renders as printed.
843
+ attributes[:lead_party] = "ISO"
725
844
  else
726
845
  # IEEE format - lead party is IEEE
727
846
  attributes[:lead_party] = "IEEE"
@@ -734,7 +853,19 @@ module Pubid
734
853
  Pubid::Ieee.locate_stage("P")
735
854
  end
736
855
 
737
- Identifiers::JointDevelopment.new(**attributes)
856
+ joint = Identifiers::JointDevelopment.new(**attributes)
857
+
858
+ # Amendment tail on the joint ISO-format form (pubid#317:
859
+ # "8802-11:2012/Amd.1:2014(E)"): wrap the joint id, mirroring
860
+ # build_flat_amendment.
861
+ return joint unless parsed[:amd_number]
862
+
863
+ amd_year = extract_value(parsed[:amd_year]) if parsed[:amd_year]
864
+ Identifiers::Amendment.new(
865
+ base: joint,
866
+ number: extract_value(parsed[:amd_number]),
867
+ year: amd_year,
868
+ )
738
869
  end
739
870
 
740
871
  # Build SI/PSI identifier from parsed data
@@ -977,8 +1108,15 @@ module Pubid
977
1108
  # Create code string with parts
978
1109
  code_str = extract_value(parsed[:number])
979
1110
 
980
- # Extract type and draft_status for typed_stage lookup
1111
+ # Extract type and draft_status for typed_stage lookup. The
1112
+ # historical "No"-prefixed spellings ("IEEE No148, April 1959") are
1113
+ # plain standards - the No normalizes to Std so the render prints
1114
+ # "IEEE Std 148" and the stage lookup resolves.
981
1115
  type_value = extract_value(parsed[:type])
1116
+ # AIEE keeps its historical "No" type; IEEE's No-forms are plain
1117
+ # standards ("IEEE No148" prints "IEEE Std 148").
1118
+ type_value = "Std" if type_value&.match?(/\ANo\.?\z/) &&
1119
+ original_input.to_s.match?(/\AIEEE\s/)
982
1120
  draft_status_value = extract_value(parsed[:draft_status])
983
1121
 
984
1122
  # Handle case where parser captured number without "P" prefix
@@ -1150,6 +1288,19 @@ module Pubid
1150
1288
  # @param parsed [Hash] the full parsed data
1151
1289
  # @return [String, nil] the abbreviation to use for stage lookup
1152
1290
  def determine_stage_abbr(type_value, _draft_status_value, parsed)
1291
+ # An explicit joint stage on the draft ("=DFDIS.3") IS the stage —
1292
+ # it outranks the ordinal ladder, which classifies IEEE-internal
1293
+ # drafts only (docs/IEEE-DRAFT-STAGES.md §1.3).
1294
+ draft_hash = parsed[:draft].is_a?(Array) ? parsed[:draft].inject({}, :merge) : parsed[:draft]
1295
+ if draft_hash.is_a?(Hash) && draft_hash[:draft_iso_stage]
1296
+ stage = extract_value(draft_hash[:draft_iso_stage]).to_s
1297
+ # The doubled-D alias ("DFDIS") carries its own draft marker;
1298
+ # the stage is what remains.
1299
+ stage = stage[1..] if stage.start_with?("D") &&
1300
+ %w[PWI NP WD CD CDV DIS FDIS].include?(stage[1..])
1301
+ return stage
1302
+ end
1303
+
1153
1304
  # Check for specific draft notation (D1, D2, etc.)
1154
1305
  if parsed[:draft]
1155
1306
  draft_data = parsed[:draft]
@@ -1189,6 +1340,14 @@ module Pubid
1189
1340
  return "P"
1190
1341
  elsif type_value == "Std"
1191
1342
  return "Std"
1343
+ elsif type_value == "Draft Std" && parsed[:draft].nil? && parsed[:digit_draft].nil?
1344
+ # A version-less "Draft Std" fabricates /D1 (issue #205);
1345
+ # deriving the stage from that fabricated version renders the
1346
+ # same P-prefixed form as an explicit "/D1" spelling, so the
1347
+ # render reaches its fixed point in one round (pubid#318).
1348
+ # Gated on no-draft: a dotted version ("D2.0") fails the registry
1349
+ # lookup above and must fall through, keeping "Draft Std".
1350
+ return "D1"
1192
1351
  elsif type_value.match?(/^No\.?$/)
1193
1352
  return type_value
1194
1353
  end
@@ -1276,6 +1435,11 @@ module Pubid
1276
1435
 
1277
1436
  revision = extract_value(draft_data[:revision]) if draft_data[:revision]
1278
1437
 
1438
+ # The compound both-systems form (docs/IEEE-DRAFT-STAGES.md
1439
+ # §1.3): the stage half of "=DDIS.3".
1440
+ iso_stage = extract_value(draft_data[:draft_iso_stage]) if draft_data[:draft_iso_stage]
1441
+ iso_iteration = extract_value(draft_data[:draft_iso_iteration]) if draft_data[:draft_iso_iteration]
1442
+
1279
1443
  # Extract date information from draft data
1280
1444
  month_slice = draft_data[:month]
1281
1445
  month = extract_value(month_slice) if month_slice
@@ -1298,14 +1462,17 @@ module Pubid
1298
1462
  version = extract_value(draft_data)
1299
1463
  end
1300
1464
 
1301
- # Create Draft component object if we have version info
1302
- if version
1465
+ # Create Draft component object if we have version info (or a
1466
+ # compound stage half without an ordinal, "D=CDV:2020")
1467
+ if version || draft_data.is_a?(Hash) && draft_data[:draft_iso_stage]
1303
1468
  draft_obj = Components::Draft.new(
1304
1469
  version: version,
1305
1470
  revision: revision,
1306
1471
  month: month,
1307
1472
  year: year,
1308
1473
  day: day,
1474
+ iso_stage: iso_stage,
1475
+ iso_iteration: iso_iteration,
1309
1476
  )
1310
1477
  draft_obj.comma_before_month = comma_before_month
1311
1478
  attributes[:draft_obj] = draft_obj
@@ -1552,3 +1719,5 @@ module Pubid
1552
1719
  end
1553
1720
  end
1554
1721
  end
1722
+
1723
+ Pubid::Ieee::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -16,6 +16,8 @@ module Pubid
16
16
  # +original_separator+ to round-trip dot vs dash notation, and uses
17
17
  # +number+ rather than +value+.
18
18
  class Code < Lutaml::Model::Serializable
19
+ include ::Pubid::SubsetMatch
20
+
19
21
  attribute :prefix, :string # C, P for some identifiers
20
22
  attribute :number, :string # Main number (802, 1234, etc.)
21
23
  attribute :parts, :string, collection: true # Array of part strings
@@ -11,6 +11,8 @@ module Pubid
11
11
  # /D3.4 - version with revision
12
12
  # /D7, July 2019 - with date
13
13
  class Draft < Lutaml::Model::Serializable
14
+ include ::Pubid::SubsetMatch
15
+
14
16
  attribute :version, :string # D5, D3, etc.
15
17
  attribute :revision, :string # .4, .2 in D3.4
16
18
  attribute :year, :string
@@ -20,6 +22,11 @@ module Pubid
20
22
  attribute :comma_before_month, :boolean, default: -> {
21
23
  false
22
24
  } # Track if comma was in input
25
+ # The joint stage-draft half of the compound form "D5=DDIS.3":
26
+ # the ISO/IEC stage this draft iterates (DIS) and its iteration
27
+ # numeral (3). See docs/IEEE-DRAFT-STAGES.md §1.3.
28
+ attribute :iso_stage, :string
29
+ attribute :iso_iteration, :string
23
30
 
24
31
  # Month name to number mapping
25
32
  MONTH_NAMES = {
@@ -32,7 +39,7 @@ module Pubid
32
39
  }.freeze
33
40
 
34
41
  def initialize(version: nil, revision: nil, year: nil, month: nil,
35
- day: nil)
42
+ day: nil, iso_stage: nil, iso_iteration: nil)
36
43
  super()
37
44
  self.version = version
38
45
  self.revision = revision
@@ -40,6 +47,15 @@ day: nil)
40
47
  self.original_month = month # Store original format
41
48
  self.month = convert_month(month)
42
49
  self.day = day
50
+ # The grammar's ordered alternation captures the stage WITH its
51
+ # draft-marker D when the spelling doubles it ("=DDIS"): the
52
+ # marker is notation, the stage is the bare ISO/IEC token.
53
+ if iso_stage.to_s.start_with?("D") &&
54
+ %w[PWI NP WD CD CDV DIS FDIS].include?(iso_stage[1..])
55
+ iso_stage = iso_stage[1..]
56
+ end
57
+ self.iso_stage = iso_stage
58
+ self.iso_iteration = iso_iteration
43
59
  end
44
60
 
45
61
  # Month names as an alternation, longest-first so "September" wins over
@@ -61,12 +77,27 @@ day: nil)
61
77
  # this method after `from_hash` (a mismatch breaks `to_hash` round-trip).
62
78
  # @param value [String, Draft] the value to coerce
63
79
  # @return [Draft]
80
+ # The compound stage suffix (docs/IEEE-DRAFT-STAGES.md §1.3):
81
+ # "=DIS.3" is canonical — D (draft) = DIS (the stage), iteration 3.
82
+ # "=DDIS.3", "=DDIS3", "=DDIS-3" (the stage echoed with its own D)
83
+ # are accepted aliases. Captures [stage, iteration].
84
+ COMPOUND_SUFFIX = /=D?(PWI|NP|WD|CDV|FDIS|DIS|CD)(?:[.-]?(\d+))?\z/.freeze
85
+ private_constant :COMPOUND_SUFFIX
86
+
64
87
  def self.parse(value)
65
88
  return value if value.is_a?(Draft)
66
89
 
67
90
  body = strip_prefix(value.to_s)
91
+ iso_stage = nil
92
+ iso_iteration = nil
93
+ if (m = body.match(COMPOUND_SUFFIX))
94
+ iso_stage = m[1]
95
+ iso_iteration = m[2]
96
+ body = body[0...m.begin(0)]
97
+ end
68
98
  version, month, day, year, comma = split_date(body)
69
- draft = new(version: version, month: month, day: day, year: year)
99
+ draft = new(version: version, month: month, day: day, year: year,
100
+ iso_stage: iso_stage, iso_iteration: iso_iteration)
70
101
  draft.comma_before_month = comma
71
102
  draft
72
103
  end
@@ -110,6 +141,8 @@ day: nil)
110
141
 
111
142
  def to_s
112
143
  result = "/D#{version}"
144
+ result += "=#{iso_stage}" if iso_stage
145
+ result += ".#{iso_iteration}" if iso_stage && iso_iteration
113
146
  result += ".#{revision}" if revision
114
147
 
115
148
  if year