pubid 2.0.19 → 2.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/data/iho/s4-reserved-specs.yaml +15 -0
  3. data/data/parg/bipm.json +1 -1
  4. data/data/parg/bipm.parg +46 -11
  5. data/data/parg/iho.json +1 -1
  6. data/data/parg/iho.parg +128 -11
  7. data/data/parg/plateau.json +1 -1
  8. data/lib/pubid/bipm/CLAUDE.md +6 -1
  9. data/lib/pubid/bipm/builder.rb +20 -1
  10. data/lib/pubid/bipm/renderer.rb +14 -2
  11. data/lib/pubid/bipm/urn_generator.rb +80 -14
  12. data/lib/pubid/bipm/urn_parser.rb +70 -6
  13. data/lib/pubid/ecma/CLAUDE.md +12 -0
  14. data/lib/pubid/ecma/identifier.rb +1 -2
  15. data/lib/pubid/ecma/parser.rb +49 -0
  16. data/lib/pubid/identifier.rb +4 -3
  17. data/lib/pubid/iec/CLAUDE.md +20 -11
  18. data/lib/pubid/iec/builder.rb +19 -1
  19. data/lib/pubid/iec/identifier.rb +19 -70
  20. data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
  21. data/lib/pubid/iec/identifiers/test_report_form.rb +29 -0
  22. data/lib/pubid/iec/identifiers/working_document.rb +23 -6
  23. data/lib/pubid/iec/pubid_urn_schema.rb +15 -7
  24. data/lib/pubid/iec/renderer.rb +8 -4
  25. data/lib/pubid/iec/single_identifier.rb +69 -4
  26. data/lib/pubid/iec/supplement_identifier.rb +52 -76
  27. data/lib/pubid/iho/CLAUDE.md +52 -0
  28. data/lib/pubid/iho/builder.rb +105 -2
  29. data/lib/pubid/iho/identifiers/base.rb +52 -1
  30. data/lib/pubid/iho/pubid_urn_schema.rb +11 -4
  31. data/lib/pubid/iho/renderer.rb +39 -2
  32. data/lib/pubid/iho/reserved_specs.rb +54 -0
  33. data/lib/pubid/iho/urn_generator.rb +6 -0
  34. data/lib/pubid/iho/urn_parser.rb +50 -9
  35. data/lib/pubid/iho.rb +1 -0
  36. data/lib/pubid/nist/CLAUDE.md +11 -0
  37. data/lib/pubid/nist/preprocessor.rb +16 -0
  38. data/lib/pubid/plateau/CLAUDE.md +35 -0
  39. data/lib/pubid/plateau/builder.rb +4 -0
  40. data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
  41. data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
  42. data/lib/pubid/plateau/renderer.rb +6 -1
  43. data/lib/pubid/sae/CLAUDE.md +11 -2
  44. data/lib/pubid/version.rb +1 -1
  45. metadata +18 -2
@@ -39,6 +39,8 @@ module Pubid
39
39
  build_si_brochure_section(data[:si_brochure_section])
40
40
  elsif data.key?(:si_brochure_ordinal_edition)
41
41
  build_si_brochure_ordinal_edition(data[:si_brochure_ordinal_edition])
42
+ elsif data.key?(:si_brochure_bare_edition)
43
+ build_si_brochure_bare_edition(data[:si_brochure_bare_edition])
42
44
  elsif data[:mep]
43
45
  build_mep(data[:mep])
44
46
  elsif data[:guide]
@@ -119,7 +121,7 @@ module Pubid
119
121
  number: node[:edition].to_s,
120
122
  edition: node[:edition].to_s,
121
123
  version: node[:version]&.to_s,
122
- years: node[:years].to_s,
124
+ years: node[:years]&.to_s,
123
125
  language: Identifier.normalize_language(node[:language]),
124
126
  )
125
127
  end
@@ -153,6 +155,23 @@ module Pubid
153
155
  )
154
156
  end
155
157
 
158
+ # The unmerged si-brochure-id proposal spells the edition as a bare
159
+ # number ("SI Brochure 9 (2024) (F)"); the guide's canonical edition
160
+ # carries the trailing "e", so the bare number normalizes onto it the
161
+ # same way the corpus-cited ordinal spelling does. The proposal puts the
162
+ # year and the language in separate parentheses; they map onto the same
163
+ # attributes the canonical form prints, and an absent year stays nil (a
164
+ # partial reference, like "SI Brochure 9th edition" without a year).
165
+ def build_si_brochure_bare_edition(node)
166
+ edition = "#{node[:bare_edition]}e"
167
+ Identifiers::SiBrochure.new(
168
+ number: edition,
169
+ edition: edition,
170
+ years: node[:year]&.to_s,
171
+ language: Identifier.normalize_language(node[:language]),
172
+ )
173
+ end
174
+
156
175
  # The Appendix/Annex/Part tail is the full-content spelling of the SAME
157
176
  # document, so it stays out of the key.
158
177
  def build_mep(node)
@@ -57,6 +57,15 @@ module Pubid
57
57
  def render_committee_long(id)
58
58
  if id.language == "F"
59
59
  name = Identifier::TYPE_NAME_FR[id.type_code]
60
+ # The guide's body-prefixed French pattern ("Décision CIPM/111-10
61
+ # (2022)") is the canonical long face: the group qualifies the
62
+ # resource-specific id with a slash. A number-less document has no
63
+ # id to qualify, so it keeps the connective spelling the guide's
64
+ # general French pattern uses ("Statement de la CGPM (1889)").
65
+ if id.number
66
+ return "#{name} #{id.group}/#{id.number}#{year_segment(id)}"
67
+ end
68
+
60
69
  conn = Identifier.french_connective(id.group)
61
70
  "#{name}#{number_segment(id)} #{conn} #{id.group}#{year_segment(id)}"
62
71
  else
@@ -91,12 +100,15 @@ module Pubid
91
100
 
92
101
  # The version is optional (the guide's "SI Brochure 9e (2019, E)" form)
93
102
  # and the language code too (the language-independent form), so both
94
- # segments are conditional.
103
+ # segments are conditional. The parens carry the year, the language or
104
+ # both, and stay off entirely when the reference names neither ("SI
105
+ # Brochure 9e", the bare-number alias without a year).
95
106
  phrase = id.language == "F" ? "sur le SI " : ""
96
107
  lang = id.language ? ", #{id.language}" : ""
108
+ tail = id.years ? "#{id.years}#{lang}" : id.language.to_s
97
109
  result = +"BIPM SI Brochure #{phrase}#{id.edition}"
98
110
  result << " #{id.version}" if id.version
99
- result << " (#{id.years}#{lang})"
111
+ result << " (#{tail})" if id.years || id.language
100
112
  result
101
113
  end
102
114
 
@@ -3,12 +3,50 @@
3
3
  module Pubid
4
4
  module Bipm
5
5
  # Emits a URN carrying the canonical (language/form-neutral) document
6
- # identity for each family:
7
- # committee: urn:bipm:<group>:<type>:<number>:<year> (empty number seg
8
- # when number-less)
9
- # meeting: urn:bipm:<group>:meeting:<number>:<year>
10
- # metrologia: urn:bipm:metrologia:<vol>[:<issue>[:<article>]]
11
- # brochure: urn:bipm:si-brochure:<lang>:<edition>:<version>:<years>
6
+ # identity for each family, in the guide-of-record's official shapes
7
+ # (bipm-editor-guides, sources/bipm-references-en.adoc, "URN" clauses):
8
+ #
9
+ # committee: urn:bipm:v1:<group>:<type-word>:<identifier>[:<year>]
10
+ # meeting: urn:bipm:v1:<group>:meeting:<number>[:<year>]
11
+ # metrologia: urn:bipm:v1:metrologia:<vol>[:<issue>[:<article>]]
12
+ # brochure: urn:bipm:v1:bipm:si-brochure:<edition>:[<version>]:<year>
13
+ #
14
+ # The guide's general pattern is
15
+ # `urn:bipm:{version}:{group}:{venue-type}:{venue-id}
16
+ # :{type}:{identifier}:{date}` with the venue slots optional. The
17
+ # flavor holds no venue attribute — the venue qualification lives
18
+ # inside the document number itself ("110-17" is the 110th meeting's
19
+ # decision 17) — so the venue slots stay unfilled and one shape per
20
+ # family falls out:
21
+ #
22
+ # * committee — the guide's `decision` example verbatim:
23
+ # `urn:bipm:v1:cipm:decision:110-17:2021`. The type segment is the
24
+ # English type name lowercased (resolution/recommendation/decision/
25
+ # action/statement), not the REC/RES/DECN abbreviation the previous
26
+ # face carried; the empty segment stays for the type-less bare MRA
27
+ # form. The guide's second committee example
28
+ # (`urn:bipm:v1:cipm:resolution:95-2006:1`) does not fit the stated
29
+ # pattern — it puts a venue-id (meeting 95, year 2006) and the
30
+ # outcome ordinal where the pattern's identifier/date slots sit
31
+ # (its own git history edited it there from `95-2006:resolution:1`,
32
+ # which DID fit). This flavor emits the pattern-conforming shape;
33
+ # a venue-qualified number flows through the identifier slot
34
+ # verbatim.
35
+ # * meeting — the venue-type `meeting` plus the venue-id, as separate
36
+ # segments per the guide's meeting-outcomes component list (its only
37
+ # accepted venue-type value). The guide's proceedings example glues
38
+ # them (`meeting-26`) and carries a proceedings type + publication
39
+ # year this flavor does not model.
40
+ # * metrologia — the guide defines no metrologia URN (its section is
41
+ # empty), so only the mandatory `v1` convention applies; the series
42
+ # shape is unchanged.
43
+ # * brochure — the guide's pattern verbatim: the `bipm` group lead,
44
+ # the edition without its `e` ("9e" → "9"), the version without its
45
+ # `v` ("v3.01" → "3.01"), the language segment dropped (the guide's
46
+ # URN is language-independent) and the year the latest the `years`
47
+ # range names ("2019/2024" → "2024", the guide's "latest year of
48
+ # publication at the version"). The version segment stays empty when
49
+ # absent, per the guide's `urn:bipm:v1:bipm:si-brochure:9::2019`.
12
50
  class UrnGenerator < Pubid::UrnGenerator::Base
13
51
  def generate
14
52
  case identifier
@@ -23,18 +61,29 @@ module Pubid
23
61
 
24
62
  private
25
63
 
64
+ # The v1 version segment leads every family; the venue-type/venue-id
65
+ # slots of the guide's general pattern stay unfilled because the
66
+ # flavor carries no venue attribute (see the class comment).
26
67
  def committee
27
68
  id = identifier
28
- # type_code is nil for the bare MRA form → empty type segment, mirroring
29
- # the empty-number-segment convention for number-less documents.
30
- parts = ["urn", "bipm", id.group.downcase,
31
- id.type_code.to_s.downcase, committee_number_segment(id)]
69
+ # type_code is nil for the bare MRA form → empty type segment,
70
+ # mirroring the empty-number-segment convention for number-less
71
+ # documents. The segment is the lowercase English type name the
72
+ # guide spells out ("decision"), never the REC/RES/DECN code.
73
+ parts = ["urn", "bipm", "v1", id.group.downcase,
74
+ committee_type_segment(id), committee_number_segment(id)]
32
75
  # A partial (date-less) reference drops the trailing year segment; a
33
76
  # number-less-but-dated doc keeps its empty number segment.
34
77
  parts << id.year if id.year
35
78
  parts.join(":")
36
79
  end
37
80
 
81
+ def committee_type_segment(id)
82
+ return "" unless id.type_code
83
+
84
+ Identifier::TYPE_NAME_EN[id.type_code].downcase
85
+ end
86
+
38
87
  # The appendix/annex complement folds into the identifier segment as
39
88
  # "-a{appendix}[.{annex}]". A bare number is digits and dashes only, so
40
89
  # the letter-prefixed tail is unambiguous on the way back (UrnParser
@@ -50,21 +99,38 @@ module Pubid
50
99
 
51
100
  def meeting
52
101
  id = identifier
53
- parts = ["urn", "bipm", id.group.downcase, "meeting", id.number.to_s]
102
+ parts = ["urn", "bipm", "v1", id.group.downcase, "meeting",
103
+ id.number.to_s]
54
104
  parts << id.year if id.year
55
105
  parts.join(":")
56
106
  end
57
107
 
58
108
  def metrologia
59
- parts = ["urn", "bipm", "metrologia", identifier.volume]
109
+ parts = ["urn", "bipm", "v1", "metrologia", identifier.volume]
60
110
  parts << identifier.issue if identifier.issue
61
111
  parts << identifier.article if identifier.article
62
112
  parts.join(":")
63
113
  end
64
114
 
65
115
  def si_brochure
66
- ["urn", "bipm", "si-brochure", identifier.language.to_s.downcase,
67
- identifier.edition, identifier.version, identifier.years].join(":")
116
+ id = identifier
117
+ ["urn", "bipm", "v1", "bipm", "si-brochure",
118
+ brochure_edition_segment(id), id.version.to_s.delete_prefix("v"),
119
+ brochure_year_segment(id)].join(":")
120
+ end
121
+
122
+ # The guide's edition-number carries no "e" ("Notice the absence of the
123
+ # letter e after the number"): "9e" emits "9". A variant (Appendix 3 /
124
+ # Concise / FAQ) or a bare section reference names no edition, so the
125
+ # segment is empty — the documented partial-reference bucket.
126
+ def brochure_edition_segment(id)
127
+ id.edition.to_s.delete_suffix("e")
128
+ end
129
+
130
+ # The guide's year is "the latest year of publication at the version":
131
+ # the "2019" of "2019", the "2024" of "2019/2024".
132
+ def brochure_year_segment(id)
133
+ id.years.to_s.split("/").last
68
134
  end
69
135
  end
70
136
  end
@@ -6,21 +6,52 @@ module Pubid
6
6
  # fields (rather than reconstructing a printed string) because the printed
7
7
  # forms carry surface detail — meeting ordinals, French wording — that the
8
8
  # canonical URN intentionally omits.
9
+ #
10
+ # Two URN generations parse. The canonical face leads with the guide's
11
+ # `v1` version segment (see UrnGenerator for the per-family shapes). The
12
+ # pre-adoption face this flavor emitted for years (`urn:bipm:cipm:res:
13
+ # 101-1:2012`, `urn:bipm:si-brochure:e:9e:v3.01:2019/2024`, …) stays
14
+ # parseable as a documented parse-in alias of the same identity: the
15
+ # landed relaton-data-bipm records and any consumer-stored URN keep
16
+ # resolving while the re-crawl lags. The EMITTER is the new shape; an
17
+ # alias never renders back as itself, it rebuilds the identifier whose
18
+ # `to_urn` is the v1 face.
9
19
  class UrnParser < Pubid::UrnParser::Base
10
20
  def parse_urn(urn)
11
21
  body = strip_namespace(urn)
12
22
  parts = body.split(":", -1)
13
23
 
24
+ if parts.first == "v1"
25
+ parse_canonical(parts.drop(1))
26
+ else
27
+ parse_legacy(parts)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # The v1 canonical shapes, with the version segment stripped. The
34
+ # brochure family differs structurally (group lead, no language
35
+ # segment, stripped e/v), so it dispatches before the group-leading
36
+ # committee/meeting shape.
37
+ def parse_canonical(parts)
14
38
  case parts.first
15
39
  when "metrologia" then build_metrologia(parts)
16
- when "si-brochure" then build_si_brochure(parts)
17
- else build_group_leading(parts)
40
+ when "bipm" then build_si_brochure_canonical(parts.drop(2))
41
+ else build_group_leading(parts, canonical_type: true)
18
42
  end
19
43
  end
20
44
 
21
- private
45
+ # The legacy landed shapes (no v1 segment), kept as parse-in aliases.
46
+ def parse_legacy(parts)
47
+ case parts.first
48
+ when "metrologia" then build_metrologia(parts)
49
+ when "si-brochure" then build_si_brochure(parts)
50
+ else build_group_leading(parts, canonical_type: false)
51
+ end
52
+ end
22
53
 
23
- def build_group_leading(parts)
54
+ def build_group_leading(parts, canonical_type:)
24
55
  group = parts[0].upcase
25
56
  if parts[1] == "meeting"
26
57
  Identifiers::Meeting.new(
@@ -30,13 +61,25 @@ module Pubid
30
61
  number, appendix, annex = split_appendix(parts[2])
31
62
  Identifiers::CommitteeDocument.new(
32
63
  group: group,
33
- type_code: (parts[1].empty? ? nil : parts[1].upcase),
64
+ type_code: type_code_from_segment(parts[1],
65
+ canonical_type: canonical_type),
34
66
  number: (number.empty? ? nil : number), year: parts[3]&.to_i,
35
67
  appendix: appendix, annex: annex,
36
68
  )
37
69
  end
38
70
  end
39
71
 
72
+ # The legacy face carried the lowercase type abbreviation ("res"); the
73
+ # v1 face carries the lowercase English type name ("resolution"). Both
74
+ # resolve through TYPE_WORD_TO_CODE, so an empty segment (the bare MRA
75
+ # form) stays a nil type_code on either face.
76
+ def type_code_from_segment(segment, canonical_type:)
77
+ return nil if segment.nil? || segment.empty?
78
+
79
+ word = canonical_type ? segment.capitalize : segment.upcase
80
+ Identifier::TYPE_WORD_TO_CODE[word]
81
+ end
82
+
40
83
  # Inverse of UrnGenerator#committee_number_segment: splits the folded
41
84
  # "-a{appendix}[.{annex}]" tail back out of the identifier segment. A
42
85
  # bare number is digits and dashes only, so the letter-prefixed tail is
@@ -57,10 +100,31 @@ module Pubid
57
100
  )
58
101
  end
59
102
 
103
+ # Legacy brochure face: `si-brochure:<lang>:<edition>:<version>:<years>`.
60
104
  def build_si_brochure(parts)
61
105
  Identifiers::SiBrochure.new(
62
106
  number: presence(parts[2]), language: parts[1].to_s.upcase,
63
- edition: parts[2], version: parts[3], years: parts[4]
107
+ edition: presence(parts[2]), version: presence(parts[3]),
108
+ years: presence(parts[4])
109
+ )
110
+ end
111
+
112
+ # Canonical brochure face:
113
+ # `bipm:si-brochure:<edition-number>:<version-number>:<year>` — the
114
+ # guide strips the "e" off the edition and the "v" off the version and
115
+ # drops the language segment, so they are restored here; the year
116
+ # segment stores back into `years` verbatim, which makes the emission
117
+ # idempotent ("2024" re-emits "2024"). The legacy face's language and
118
+ # multi-year segments are gone on this face: the guide's URN is
119
+ # language-independent and names the latest year only.
120
+ def build_si_brochure_canonical(parts)
121
+ edition = presence(parts[0])
122
+ edition = "#{edition}e" if edition
123
+ version = presence(parts[1])
124
+ version = "v#{version}" if version
125
+ Identifiers::SiBrochure.new(
126
+ number: edition, edition: edition, version: version,
127
+ years: presence(parts[2])
64
128
  )
65
129
  end
66
130
 
@@ -33,3 +33,15 @@ keeps `#matches?(other, ignore:)`.
33
33
  stripper (root `CLAUDE.md`, the `with_publisher:` bullet) leaves the string
34
34
  alone, and the `@with_publisher` ivar and the renderer branch that read it
35
35
  were deleted. XSF made the same change for `XEP 0001`.
36
+
37
+ - **Human edition spellings are pre-parse aliases (pubid#698).** The
38
+ grammar reads exactly one edition face — the glued " ed<N>" — so
39
+ `Parser.normalize_input` (wired into `Identifier.parse`, the
40
+ ashrae/cie ingestion precedent) rewrites the human faces onto it before
41
+ the artifact sees them: the case-insensitive cover form
42
+ ("ECMA-262 Edition 5.1"), the dotted and spaced ed variants, and the
43
+ bare trailing edition ("ECMA 262 5.1"), which fires only behind an
44
+ already-present document number so the space-separator form ("ECMA 6")
45
+ keeps its number and a " vol<N>" is never rewritten. Every rule
46
+ rewrites INTO the canonical face, so a canonical string passes through
47
+ byte-identically and the index key stays "ECMA-262 ed5.1".
@@ -134,8 +134,7 @@ module Pubid
134
134
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
135
135
  end
136
136
 
137
- identifier = ::Pubid.utf8_input(identifier)
138
-
137
+ identifier = Parser.normalize_input(::Pubid.utf8_input(identifier))
139
138
  parsed = Pubid::Parg::Model.bound(:ecma, identifier)
140
139
  Builder.build(parsed)
141
140
  end
@@ -18,7 +18,56 @@ module Pubid
18
18
  # Each branch captures its number under a distinct key so the builder can
19
19
  # pick the identifier class without a separate type token. Numbers are kept
20
20
  # as strings to preserve any leading zeros.
21
+ #
22
+ # The grammar reads exactly one edition spelling — the glued " ed<N>" —
23
+ # while the SDO's own cover and the natural human spellings differ, so
24
+ # {Parser.normalize_input} rewrites those faces onto the glued token
25
+ # before the artifact sees them (pre-parse alias; the baked grammar is
26
+ # untouched). A canonical face passes through byte-identically.
21
27
  class Parser
28
+ # The human edition spellings that alias the glued " ed<N>" token
29
+ # (pubid#698), in rewrite order. Each rule rewrites into the exact
30
+ # canonical face, so the parse (and therefore the render, the URN and
31
+ # the index key) is the one " ed<N>" already produces.
32
+ #
33
+ # ECMA-262 Edition 5.1 the document cover's own form
34
+ # ECMA-262 ed. 5.1 dotted, spaced
35
+ # ECMA-262 ed 5.1 spaced, undotted
36
+ # ECMA-262 ed.5.1 dotted, unspaced
37
+ # ECMA 262 5.1 bare trailing edition
38
+ #
39
+ # " ed5.1" (no separator) is the grammar's own face and is left alone;
40
+ # a " vol<N>" number is never rewritten — the bare-trailing rule fires
41
+ # only behind a document number's own digit, so "ECMA-269 ed3 vol2"
42
+ # and even the spaced "… vol 2" keep their volume.
43
+ EDITION_ALIASES = [
44
+ # "Edition <N>" starts mid-word, so the replacement carries no space
45
+ # of its own — the space before it is kept as-is.
46
+ [/\bedition[.\s]*(\d+(?:\.\d+)?)\b/i, 'ed\1'],
47
+ # "ed. <N>" / "ed <N>" / "ed.<N>": same mid-word shape. The glued
48
+ # "ed5.1" has no separator after "ed" and cannot match [.\s]+, so a
49
+ # canonical face passes through byte-identically.
50
+ [/\bed[.\s]+(\d+(?:\.\d+)?)\b/i, 'ed\1'],
51
+ # Bare trailing edition: fires only when a document number is already
52
+ # present (a digit directly before the space — "ECMA 262 5.1"), so the
53
+ # space-separator form itself ("ECMA 6") keeps its number: there the
54
+ # char before the space is "A", not a digit. The match consumes that
55
+ # one space and the replacement puts it back before the token. A
56
+ # glued "vol2" has no space, and a spaced "vol 2" has no digit before
57
+ # the space, so a volume number is never rewritten — the grammar's
58
+ # ed-then-vol order is untouched.
59
+ [/(\d) (\d+(?:\.\d+)?)\z/, '\1 ed\2'],
60
+ ].freeze
61
+
62
+ # Pre-parse ingestion normalization: map the human edition spellings
63
+ # onto the glued token the grammar reads. Every rule rewrites INTO a
64
+ # canonical face, so a string the grammar already accepts is returned
65
+ # byte-identically and only new accepts are added.
66
+ def self.normalize_input(string)
67
+ EDITION_ALIASES.reduce(string) do |acc, (pattern, replacement)|
68
+ acc.gsub(pattern, replacement)
69
+ end
70
+ end
22
71
  end
23
72
  end
24
73
  end
@@ -957,9 +957,10 @@ module Pubid
957
957
  # The shared table names the components a printed identifier usually
958
958
  # carries — publisher, type, number, part, year. A flavor that keeps its
959
959
  # identity somewhere else answers with its own names appended: IEC's
960
- # working documents are `technical_committee`/`wd_number`/`wd_stage`, and
961
- # CSA's electrical code prints `cec_part`/`no_number` rather than the
962
- # `number` it stores. Without this they carried no annotatable token at all.
960
+ # working documents are `technical_committee`/`wd_stage` (their document
961
+ # number IS `number`, issue #687), and CSA's electrical code prints
962
+ # `cec_part`/`no_number` rather than the `number` it stores. Without this
963
+ # they carried no annotatable token at all.
963
964
  #
964
965
  # Appending rather than replacing keeps whatever the shared table does
965
966
  # find; a token whose value is not in the string is skipped anyway.
@@ -4,7 +4,12 @@ IEC typed stages, `from_hash` equality, and the `number` retype that deleted `Ie
4
4
 
5
5
  These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/iec/` or `spec/pubid/iec/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
6
 
7
- - **IEC `from_hash(to_hash) == parse` — two `from_hash` defaults that diverged from the parse path**: `==` was false for **every** IEC identifier, while `to_s`, `to_urn` and `to_hash` were all correct — so the relaton index gate (`from_hash(to_hash) == to_hash`) passed and a lookup (parsed reference vs `from_hash`-ed row) silently returned nothing. Two independent causes, one family. **(1) `original_abbr`** — the JCGM defect verbatim, with a **second** injector: `Identifier.published_typed_stage` (the attribute default) **and** `#stage_from_kv` both `dup`ped the registry entry and set `original_abbr = canonical_abbreviation`, where the parse path (`Builder#locate_typed_stage`, the `:type_with_stage` cast) leaves it nil. Both writes are deleted; the `dup` stays (`TYPED_STAGES` is frozen, its elements are not). **The hand-off warned this fix was rendering-sensitive because IEC reads the field at `iec/builder.rb:161-163` (long-vs-short `Amd 1` vs `AMD1`). It is not: that read is UNREACHABLE.** The block is gated on `identifier.class.attributes.key?(:rendering_style)`, and **no IEC class declares a `rendering_style` attribute** — verified by force-loading all 26 IEC identifier classes and inspecting `.attributes`. The whole block (builder.rb 155–200) is dead, so the read is doubly dead: never executed, and reading a field already nil on the parse path. The long spelling is already collapsed on `main` (`…/Amd 1:2017` renders back `…/AMD1:2017`). The block is **kept with an explicit UNREACHABLE comment** rather than deleted, so the next reader does not mistake it for a live consumer again. **The general lesson: before copying the JCGM delete to another flavor, check not just whether the field is read but whether the READ IS REACHABLE — a gated block whose gate is never true is not a consumer.** **(2) `Amendment`/`Corrigendum` redeclared `attribute :type`** with `default: -> { self.class.type[:key] }`. The redeclaration shadowed the derived reader `Iec::Identifier#type` (`typed_stage&.to_type`) on those two classes only; `type` is not serialized, so on `from_hash` the default assigned the **Symbol** `:amd`/`:cor` where the parse path assigns a `Components::Type`, and lutaml does not cast it. Both declarations are deleted so the two classes inherit the derived reader like every other IEC type; the class method `self.type` is untouched and still used. This is the AMCA lesson in another guise — **a default (or converter) that yields a different runtime shape from the parse path is a sign the attribute should not be there at all**. It also repairs a **parse-path** bug, not just the round trip: `Builder#wrap_with_consolidated` builds its supplement directly and never calls `assign_attributes`, so a consolidated amendment member (`IEC 60050-300:2001+AMD1:2005 CSV`) returned the bare Symbol `:amd` from `.type` on `main` even when parsed — pinned by a spec. **Latent, deliberately not changed:** `Builder#locate_typed_stage` (the parse-path lookup) hands out the shared frozen-array element **without** a `dup`, unlike the two from_hash paths; harmless today because nothing mutates a `TypedStage` in place, but it is the same "frozen array, unfrozen elements" hazard. Verified: `to_s`, `to_urn` and `to_hash` **byte-identical** across all 12,303 inputs in `spec/fixtures/iec/identifiers/pass/`, so nothing serialized moves and **no index migration follows**; `==` rises from 0 to **9,914 of 12,302**. **Verified against the published `relaton-data-iec` dataset too, which is the measurement that matters**: over all **32,817 `index-v2.yaml` rows**, the consumer shape `parse(row.to_s) == from_hash(row)` goes from **0 to 32,817** (and `matches?(ignore: [:year])` with it), while `from_hash(h).to_hash == h` holds for all 32,817 **both before and after** — so the published index is already valid and **needs no regeneration**. Over the 32,855 primary docidentifiers in `data/*.yaml`, 32,813 parse (the 42 failures are pre-existing title-bearing forms like `IEC Technology Report Cyber security:2019`, unchanged by this) and for all 32,813 `to_s`, `to_urn` and `root.number` are identical before and after, with **0 empty index keys**. The fixture corpus is the stricter test only because it contains WorkingDocument/PNW/TRF forms that the crawler does not publish. Locked by `spec/pubid/iec/typed_stage_equality_spec.rb` and by deleting the `"Pubid::Iec"` entry from `PENDING_EQUALITY`. **The 2,388 that remain have a THIRD, unrelated cause and are deliberately out of scope**: `WorkingDocument` (`technical_committee`/`wd_number`/`wd_stage`/`wd_language`, 2,318 ids — `to_hash` is `{"_type"=>"pubid:iec:working-document"}` and **`root.number` is nil**, so every WD row keys the empty string), the PNW `wp_stage`/`wp_type` pair (32) and `TestReportForm#cispr_identifier` (38) are **never serialized at all**. Mapping them ADDS keys to `to_hash` and forces a `relaton-data-iec` re-crawl, which is a separate decision — and a low-value one today, since the published index contains **zero** `working-document` and **zero** `test-report-form` rows. (hand-off: iec-typed-stage-original-abbr; follow-up hand-off: iec-unserialized-identity-attributes.)
7
+ - **IEC `from_hash(to_hash) == parse` — two `from_hash` defaults that diverged from the parse path**: `==` was false for **every** IEC identifier, while `to_s`, `to_urn` and `to_hash` were all correct — so the relaton index gate (`from_hash(to_hash) == to_hash`) passed and a lookup (parsed reference vs `from_hash`-ed row) silently returned nothing. Two independent causes, one family. **(1) `original_abbr`** — the JCGM defect verbatim, with a **second** injector: `Identifier.published_typed_stage` (the attribute default) **and** `#stage_from_kv` both `dup`ped the registry entry and set `original_abbr = canonical_abbreviation`, where the parse path (`Builder#locate_typed_stage`, the `:type_with_stage` cast) leaves it nil. Both writes are deleted; the `dup` stays (`TYPED_STAGES` is frozen, its elements are not). **The hand-off warned this fix was rendering-sensitive because IEC reads the field at `iec/builder.rb:161-163` (long-vs-short `Amd 1` vs `AMD1`). It is not: that read is UNREACHABLE.** The block is gated on `identifier.class.attributes.key?(:rendering_style)`, and **no IEC class declares a `rendering_style` attribute** — verified by force-loading all 26 IEC identifier classes and inspecting `.attributes`. The whole block (builder.rb 155–200) is dead, so the read is doubly dead: never executed, and reading a field already nil on the parse path. The long spelling is already collapsed on `main` (`…/Amd 1:2017` renders back `…/AMD1:2017`). The block is **kept with an explicit UNREACHABLE comment** rather than deleted, so the next reader does not mistake it for a live consumer again. **The general lesson: before copying the JCGM delete to another flavor, check not just whether the field is read but whether the READ IS REACHABLE — a gated block whose gate is never true is not a consumer.** **(2) `Amendment`/`Corrigendum` redeclared `attribute :type`** with `default: -> { self.class.type[:key] }`. The redeclaration shadowed the derived reader `Iec::Identifier#type` (`typed_stage&.to_type`) on those two classes only; `type` is not serialized, so on `from_hash` the default assigned the **Symbol** `:amd`/`:cor` where the parse path assigns a `Components::Type`, and lutaml does not cast it. Both declarations are deleted so the two classes inherit the derived reader like every other IEC type; the class method `self.type` is untouched and still used. This is the AMCA lesson in another guise — **a default (or converter) that yields a different runtime shape from the parse path is a sign the attribute should not be there at all**. It also repairs a **parse-path** bug, not just the round trip: `Builder#wrap_with_consolidated` builds its supplement directly and never calls `assign_attributes`, so a consolidated amendment member (`IEC 60050-300:2001+AMD1:2005 CSV`) returned the bare Symbol `:amd` from `.type` on `main` even when parsed — pinned by a spec. **Latent, deliberately not changed:** `Builder#locate_typed_stage` (the parse-path lookup) hands out the shared frozen-array element **without** a `dup`, unlike the two from_hash paths; harmless today because nothing mutates a `TypedStage` in place, but it is the same "frozen array, unfrozen elements" hazard. Verified: `to_s`, `to_urn` and `to_hash` **byte-identical** across all 12,303 inputs in `spec/fixtures/iec/identifiers/pass/`, so nothing serialized moves and **no index migration follows**; `==` rises from 0 to **9,914 of 12,302**. **Verified against the published `relaton-data-iec` dataset too, which is the measurement that matters**: over all **32,817 `index-v2.yaml` rows**, the consumer shape `parse(row.to_s) == from_hash(row)` goes from **0 to 32,817** (and `matches?(ignore: [:year])` with it), while `from_hash(h).to_hash == h` holds for all 32,817 **both before and after** — so the published index is already valid and **needs no regeneration**. Over the 32,855 primary docidentifiers in `data/*.yaml`, 32,813 parse (the 42 failures are pre-existing title-bearing forms like `IEC Technology Report Cyber security:2019`, unchanged by this) and for all 32,813 `to_s`, `to_urn` and `root.number` are identical before and after, with **0 empty index keys**. The fixture corpus is the stricter test only because it contains WorkingDocument/PNW/TRF forms that the crawler does not publish. Locked by `spec/pubid/iec/typed_stage_equality_spec.rb` and by deleting the `"Pubid::Iec"` entry from `PENDING_EQUALITY`. **The 2,388 that remained had a THIRD, unrelated cause — fixed by #687, see the unserialized-identity bullet below**: `WorkingDocument` (`technical_committee`/`wd_number`/`wd_stage`/`wd_language`, 2,318 ids — `to_hash` was `{"_type"=>"pubid:iec:working-document"}` and **`root.number` was nil**, so every WD row keyed the empty string), the PNW `wp_stage`/`wp_type` pair (32) and `TestReportForm#cispr_identifier` (38) were **never serialized at all**. (hand-off: iec-typed-stage-original-abbr; follow-up hand-off: iec-unserialized-identity-attributes — landed as #687.)
8
+
9
+ - **The unserialized-identity attributes are serialized (issue #687) — `wd_number` moved into the inherited `number`, five plain `key_value` maps and one nested one added, `wp_type` stripped at the construction site.** Three families kept their identity in attributes no `key_value` map covered, so `to_s` was right while `to_hash` was not: 2,388 of the 12,331 pass fixtures were not `==` after `from_hash(to_hash)`, all 2,318 working documents shared one hash and the type-only URN `urn:pubid:iec:wd`, `root.number` keyed the empty string, and the 38 CISPR-bearing TRFs hashed to publisher+year alone. **(1) `wd_number` is the inherited `number`** — the "prefer the inherited attribute over a parallel one" rule: the attribute is deleted and `Builder#cast` routes the parsed `:wd_number` onto `number`, so `root.number`, the index key and `to_hash` all read it; `technical_committee`/`wd_language`/`wd_stage`/`wp_stage`/`wp_type` take plain maps (primitives need no converters). **The two forms of the class key `number` differently, and that asymmetry is kept, not reconciled.** The #687 open-question-1 ruling ("`number` holds `wd_number`, the committee is a separate column — the ISO/IEEE bucket-by-document-number semantics") says to follow the existing code, and the existing code keys the work-programme form by committee: `PNW 1-1597` has carried number `1` (the TC) and part `1597` (the item) since before #360. So the wd form keys by document number (`1/2457/FDIS` → `2457`) and the wp form by committee (`root.number` = `1`); the committee column (`technical_committee`, serialized now) disambiguates.
10
+ **(2) `wp_type` carried a trailing space** (`"PWI TR 100-36"` captures `"TR "`); the renderer stripped it at print time, so the stored value differed from what rendered. `Builder#cast` strips it now (an all-space value drops to nil) and the renderer's strip is gone with it — `to_s` is byte-identical. **(3) `TestReportForm#cispr_identifier` maps as a nested identifier** with the converter shape of `VapIdentifier#base_to_kv`/`#base_from_kv` — `to_hash` embeds the nested polymorphic hash, `from_hash` dispatches back through the type map. **`#root` of a TRF stays the form** (#687 open-question-2 ruling): a TRF is a form ABOUT the CISPR document, not a wrapper of it, so those 38 ids key the empty string BY DECISION — the serialized `cispr_identifier` is the identity, not a re-rooted number.
11
+ **The pubid-scheme URN gained six atoms** — `tc-`/`wdlang-`/`wdstage-`/`wpstage-`/`wptype-` and `cispr-` (kind `:id`), after `vap-` and before `lang-`, so landed URNs do not move — and a working document URNs per document (`urn:pubid:iec:wd:2457:tc-1:wdstage-FDIS`). **One measured surface moved beyond `to_hash` and the pubid URN: the official `urn:iec:std:` URN.** The 2,318 wd-form ids emitted NO official URN on main (`to_urn` was nil — the generator refuses a document with no number); with `number` populated they emit `urn:iec:std:iec:2457::::`, the same generator rule the 32 wp-form rows already followed on main (`PNW 1-1597 ED1` → `urn:iec:std:iec:1-1597::stage-10.20:ed-1:`). No id that had an official URN changed (0 of the other 9,988), and no published `relaton-data-iec` row is touched (the index holds zero `working-document`/`test-report-form` rows) — the movement is 2,318 gains in the direction of more identity, the #360 precedent.
12
+ **Replay-verified against a main baseline, not just the suite.** Over all 12,331 pass-fixture inputs (12,306 parseable; the 25 parse failures pre-existing): `to_s` byte-identical for all 12,306; `to_urn` differs for exactly the 2,318 formerly URN-less rows above; `to_mr_string` differs for exactly the same 2,318 rows, each from the degenerate publisher-only `iec` (which every working document keyed, the shared-key defect verbatim) to `iec.<number>`; `to_hash` ADDS keys — never removes one, never changes a value — for exactly the 2,388 family rows and nothing outside them; `==` after the round trip rises 9,918 → 12,306; all 12,306 `to_pubid_urn` strings parse back to the same hash. Locked by `spec/pubid/iec/root_number_spec.rb` (the six references, the whole-corpus `==`/root-number sweeps, both rulings) and the per-document URN examples in `spec/pubid/iec/pubid_urn_spec.rb`. (hand-off landed: iec-unserialized-identity-attributes.)
8
13
 
9
14
  - **PNW is a stage of its own (10.20), the stage separator is a space, and the URN is built from components — issue #360 items 1, 2 and 4.** The three are one change, because on `main` the URN was produced by a regex scraped over `identifier.to_s`, so the stage fix and the separator fix both moved it. Reference behaviour was taken from the installed `pubid-iec 1.15.21`, **except for the URN, where 1.x is the wrong oracle** — see below.
10
15
  **(1) PNW.** `identifiers/international_standard.rb` listed `PNW` inside the `code: :pwi` stage (harmonized `00.*`), so `IEC PNW 1000-1:2023 ED2` rendered back as `IEC/PWI 1000-1:2023 ED2`. IEC's portal and `pubid-iec-1.15.21/stages.yaml` both give `PNW: ["10.20"]`. PNW now has its own `code: :pnw` TypedStage with `harmonized_stages: ["10.20"]`, and **`10.20` was removed from `:np`** so a lookup by harmonized code is unambiguous — the URN now performs exactly that lookup. `Identifiers::WorkingDocument#stage` built a bare `Components::Stage` from a hand-written `{"PWI"=>"pwi","PNW"=>"pnw"}` map, giving a stage with no harmonized code and a `"pnw"` stage code that existed nowhere else; it resolves through `Pubid::Iec.locate_stage` instead. **The grammar needed no change for the reported form**: `working_programme_with_publisher` required a literal `/` after the publisher, so `IEC PNW 1000-1:2023` already fell through to `type_with_stage`. That rule was **deleted** (it had zero corpus inputs), and `render_working_document` no longer prefixes the publisher: a work-programme registration renders bare (`PNW 65-915 ED1`), because its number is TC-scoped (`86B-15`, `SyCCOMM-1`) rather than an IEC document number, and prefixing `IEC ` would break idempotence — `IEC PNW 65-915 ED1` re-parses as an International Standard. That alone repaired 32 fixture entries.
@@ -16,31 +21,35 @@ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/
16
21
 
17
22
  - **`edition` serializes flat, and the date does not need to** (pubid#360 item 3): IEC's edition emitted a one-key hash, `edition: {number: "2"}`, for a value that is only ever a scalar. `lib/pubid/iec/builder.rb` has a **single** edition construction site and it writes `Edition.new(number: value&.to_s)` and nothing else, so the component is degenerate by construction — confirmed over all **12,331** parseable IEC fixtures, where 607 carry an edition and `number` is the only field ever populated (`year` and `original_text`: zero). It now emits `edition: "2"`, and `from_hash` reads both that and the stored nested form, so no published `relaton-data-iec` row stops deserializing. The mapping at `lib/pubid/iec/identifier.rb` stays a plain `map "edition", to: :edition`; the flattening is the shared rule on `::Pubid::Identifier`, not an IEC converter. **The date half needed no work at all** — IEC already serializes it flat as top-level `year`/`month`/`day`/`undated` through its own converters, so no `date` key ever reaches the shared flattener. **That is not a coincidence worth ignoring: IEC is the flavor that proves the flat rule has to be per identifier rather than per flavor.** Of its 9,730 dated identifiers, 9,728 carry a year alone, but **10 carry a month** (`IEC CA 01:2025-10`, `IECEE 02:2025-06(en)`, `IECEE AD-001:2024-06(en)`) and **2 are undated** (`IEC 60050:--`, `IEC 60050:—`). A flavor-wide "IEC dates are year-only" switch would have thrown those twelve away silently. `spec/pubid/flat_scalar_serialization_spec.rb` asserts both directions on IEC for exactly this reason.
18
23
 
19
- - **`WorkingDocument` is the one IEC type that still renders plain under `annotated: true`.** The cross-flavor annotator (`Renderers::Annotator`, see the root `CLAUDE.md`) wraps the identifier's own component values where they appear in the rendered string; a WorkingDocument's identity lives in `technical_committee` / `wd_number` / `wd_stage` / `wd_language`, none of which is one of those components. This is the same unserialized-identity gap already recorded above (`to_hash` is `{"_type"=>"pubid:iec:working-document"}` and `root.number` is nil), seen from the rendering side. Nothing raises — the flag is accepted and simply finds no token — so it is a missing span, not a failure. Fixing it means mapping those attributes, which forces a `relaton-data-iec` re-crawl, and remains the separate decision it already was. (hand-off: iec-unserialized-identity-attributes.)
24
+ - **`WorkingDocument` annotates, and since #687 it serializes too.** The cross-flavor annotator (`Renderers::Annotator`, see the root `CLAUDE.md`) wraps the identifier's own component values where they appear in the rendered string; a WorkingDocument's identity lives in `technical_committee` / `number` / `wd_stage` / `wd_language`, so `annotation_tokens` names them explicitly (the wd form's document number IS the inherited `number` since #687 — it used to sit in the unserialized `wd_number`). The rendered annotated form is byte-identical before and after #687 (`<span class="docnumber">1</span>/<span class="docnumber">2457</span>/<span class="stage">FDIS</span>` both sides) — the gap #687 closed was the serialization one recorded above, not the rendering one. (hand-off: iec-unserialized-identity-attributes — landed.)
20
25
 
21
26
  - **The attribute path takes a plain scalar for language and copublisher — the residue of issue #360 item 3.** Items 1, 2 and 4 gave `IEC PNW 1000-1:2023 ED2` the right stage, separator and URN, and the constructor contract gave it `edition: 2` and `year: 2023`. The URN the issue asked for also carries a language, and that half still failed: `languages: ["en"]` stored raw Strings, so `to_s` raised `wrong number of arguments` at `lib/pubid/iec/single_identifier.rb:59` (`lang.to_s(lang_single:)`) and `to_urn` raised `undefined method 'code'` at `lib/pubid/iec/urn_generator.rb:190`. `copublishers: ["ISO"]` raised the same way on `body`. Both are now coerced by the shared `DEGENERATE_COMPONENT_FIELDS` rule on `::Pubid::Identifier` (see the root `CLAUDE.md`), not by an IEC converter — and IEC is the flavor that proves the wrap has to key on the component **class**, because its copublisher is the subclass `Pubid::Iec::Components::Publisher`, which an attribute-name or exact-class table would miss. So `Identifiers::InternationalStandard.new(number: "1000", part: "1", year: 2023, edition: 2, languages: ["en"])` now renders `IEC 1000-1:2023 ED2(en)` and yields `urn:iec:std:iec:1000-1:2023::ed-2:en`. The Hash and component forms are unchanged and still agree with the scalar form. **`number` was deliberately NOT coerced** — and the bullet below resolves that tension the other way round, by making the attribute a `:string` so a String is the type rather than a tolerated intruder. Note the copublisher renders `IEC/ISO 1000`, IEC's own ordering — the coercion does not touch order. (hand-off: iec-degenerate-component-coercion.)
22
27
 
23
28
  - **`number`/`part`/`subpart` are plain `:string`, `Iec::Components::Code` is deleted, and three of eleven `key_value` converters go with it.** The component was an empty box: over all **12,331** parseable pass fixtures the three were an `Iec::Components::Code` **9,975 / 5,422 / 2,416** times and **not one** populated `prefix`, `parts`, `part` or `subpart` inside it. The subclass existed solely for a `to_s` that prints `"#{prefix} #{value}"`; `Code.parse` was the only site that ever passed `prefix:` and it had **zero callers**, as did the `full_code` alias, and the class had zero references outside `identifier.rb` and its own file. So the subclass is deleted outright along with its `autoload`.
24
29
  **This was a live defect, not tidiness, and it is the reason to prefer retyping over coercing.** The parse path produced a Code while the constructor path left a String — a `:string` assigned to a Code-typed attribute stays a String — so `Identifiers::InternationalStandard.new(number: "60068", part: "2", subpart: "28", year: "2008")` was **not `==`** `Pubid::Iec.parse("IEC 60068-2-28:2008")`, and `#matches?` is `exclude(*ignore) == other.exclude(*ignore)`, so every match between a hand-built reference and a parsed one silently returned false. `to_s`, `to_urn` **and** `to_hash` all agreed on both sides, which is exactly why nothing caught it: a relaton index lookup just returned nothing, with no error. `spec/pubid/attribute_construction_spec.rb` had even pinned the String half (`"leaves a scalar number uncoerced (IEC)"`) and asserted `to_s` and `to_hash` on the two paths — but never `==`, so the spec that was closest to the bug documented it as intended behaviour. **The lesson generalises: a spec comparing two construction paths must assert `==`, not only that they render and serialize alike.**
25
- **`with:` was doing two jobs, and only one of them was the Code.** `emit_code`/`build_code` unwrapped and rewrapped the component — identity functions once the attribute is a String — but the converters were *also* the mechanism by which `DelegatedFieldSuppression` no-opped the delegated fields of the four wrapper types. **Placement replaces the denylist**: `number`/`part`/`subpart` now map on **`Pubid::Iec::SingleIdentifier`**, the parent of `Identifiers::Base` and `SupplementIdentifier` — the only two classes that own a number — while `FragmentIdentifier`, `ConsolidatedIdentifier`, `VapIdentifier` and `SheetIdentifier` inherit from `Iec::Identifier` **directly** and are therefore excluded structurally, not by remembering to add a no-op. That distinction is not academic: the surviving denylist suppresses `year` but **not `month`/`day`/`undated`**, so `IEC CA 01:2025-10 CSV` still emits a top-level `"month" => "10"` duplicating the one inside `base` — a pre-existing leak, left alone here because removing a key from published rows is a wire change (hand-off `iec-wrapper-month-day-leak`).
30
+ **`with:` was doing two jobs, and only one of them was the Code.** `emit_code`/`build_code` unwrapped and rewrapped the component — identity functions once the attribute is a String — but the converters were *also* the mechanism by which `DelegatedFieldSuppression` no-opped the delegated fields of the four wrapper types. **Placement replaces the denylist**: `number`/`part`/`subpart` now map on **`Pubid::Iec::SingleIdentifier`**, the parent of `Identifiers::Base` and `SupplementIdentifier` — the only two classes that own a number — while `FragmentIdentifier`, `ConsolidatedIdentifier`, `VapIdentifier` and `SheetIdentifier` inherit from `Iec::Identifier` **directly** and are therefore excluded structurally, not by remembering to add a no-op. The date keys followed later — see the date-keys bullet below.
26
31
  **Two more converters were pure boilerplate.** `all_parts` is a Boolean attribute with `default: false` that `::Pubid::Identifier#to_hash` already drops, so it takes the plain GB spelling `map "all_parts", to: :all_parts, render_default: false`. `stage_iteration` holds a `Components::Iteration` — one `:string` field, `number`, whose `to_s`/`render` return it — so it joins the shared `FLAT_SCALAR_COMPONENTS`/`FLAT_SCALAR_FIELDS` tables on `::Pubid::Identifier` and takes a plain map too. **The blast radius of that shared change is exactly ISO and IEC**, the only two flavors that map the key, and both already emitted the bare scalar through their own converters — ISO keeps its converter, which the inflation guard defers to by design. Net: **12 converter methods and 2 helpers deleted**. The five groups that keep `with:` all earn it — `year`/`month`/`day` and `undated` split one `date` attribute across four keys, `publisher`/`copublishers` map a `Components::Publisher` to its `body` and omit the IEC default, and `stage` maps `typed_stage` to its code with class-scoped resolution on load. **The rule this leaves behind: a converter is for a custom class or for splitting one attribute across several keys; a primitive needs a plain map.**
27
32
  **The shared splitter gained a hook rather than a special case.** `Builder::Base#parse_number_with_part(value, code_class:)` is used by ISO and IEC only; `code_class:` is now optional and the three constructions route through `wrap_number_component`, which returns the raw scalar when `code_class` is nil. ISO still passes `Pubid::Iso::Components::Code` and is untouched. This is the extension point the remaining tranches reuse.
28
33
  **Verified by replaying a captured baseline, not by the suite alone.** Over all **12,331** IEC pass-fixture ids, `to_s`, `to_urn`, `to_mr_string`, `root.number`, the identifier class, the deep-key-sorted `to_hash`, the `from_hash(to_hash) == to_hash` flag and the `from_hash(to_hash) == id` flag are **byte-identical, 0 diffs in every field**. The verbatim `to_hash` differs for 9,950 rows and for **one reason only** — `number`/`part`/`subpart` moved to the end of the key order, because lutaml *appends* a subclass's `key_value` maps to the parent's; removing those three keys leaves the two key sequences identical for every row, and `Hash#==` ignores order, so **0 rows differ in any other way**. Over all **7,573** ISO pass-fixture ids every field including the verbatim hash is byte-identical, so neither shared-file change moved ISO. Against the published `relaton-data-iec` `index-v2.yaml` (**32,847** rows) `from_hash(row).to_hash == row` holds for 32,845 and `parse(from_hash(row).to_s) == from_hash(row)` for all 32,847, **both before and after** — the 2 misses are the pre-existing rows storing the old nested `edition: {number: …}` form. **No index migration follows**, and that is measured rather than assumed. Locked by `spec/pubid/iec/number_string_spec.rb` (structural tripwire over the whole class tree, the constructed-vs-parsed `==`, the wrapper hashes, and the absence of the deleted constant) and by IEC's entry in `spec/pubid/number_string_retype_spec.rb`.
29
34
  **Known residue, deliberately unchanged:** after the map moved, `Pubid::Iec::Identifier.from_hash({"number" => "60038"})` — a hand-written hash with **no `_type`**, on the bare base class — silently drops the key instead of building an `Identifier` that holds it. That object is already degenerate (`to_s` on it raises `NoMethodError` for `publisher_portion`) and nothing in this repo or in `relaton-iec` constructs one. (hand-off: iec-number-string.)
30
35
 
31
- **Two parent-slot gaps recorded by the cross-flavor `base` survey** (`lib/pubid/bsi/CLAUDE.md` has the measured version). `Identifiers::TestReportForm#cispr_identifier` is a polymorphic `Identifier` parent slot with no `#root` override, so `root` returns the form itself. And `Identifiers::ConsolidatedIdentifier#base_document` returns `identifiers&.first` **without** the recursive `&.base_document` that its BSI, CSA and CEN counterparts use — a one-level peel where the others peel fully, so a consolidated edition of a supplement does not reduce to the bare standard. Neither is chased here; both are pinned by nothing, which is why they are written down.
36
+ - **The date keys map on `SingleIdentifier` too — the wrapper `month`/`day`/`undated` leak is closed.** The denylist that `number` escaped had drifted exactly as its failure mode predicts: `DelegatedFieldSuppression` (and `FragmentIdentifier`'s inline copy) no-opped `year_to_kv` but not `month_to_kv`/`day_to_kv`/`undated_to_kv`, so every wrapper whose wrapped document had a month or was undated emitted the key twice — `IEC CA 01:2025-10 CSV` → top-level `"month" => "10"` beside the one in `base`, and the pubid URN carried it as `…:vi:month-10:…`. Vap, Consolidated, Sheet and Fragment all leaked; `AllParts` did not. The fix is the same placement, not three more no-ops: the four date `map` lines and their eight converters moved from `Iec::Identifier` to `SingleIdentifier`, and `year_to_kv` left both denylists. It was safe because **no wrapper wants a top-level date key from `date`** — `SheetIdentifier`'s own `year` attribute serializes as `sheet_year`, a different key. The denylist now holds only `publisher`/`copublishers`/`stage`, which the parent block still needs. **Measured, not assumed:** replaying all 12,331 IEC pass fixtures against a `main` baseline, `to_s`, `to_urn`, `to_mr_string`, `to_pubid_urn`, `root.number`, the class, `to_hash` by `==` and the `from_hash(to_hash) == id` flag are identical for every id — the corpus holds **0** wrapper ids with a month, day or undated date, so no fixture moved. The verbatim key order moves for 7,761 dated rows (the date keys now come after the parent block, as `number` did); `Hash#==` ignores order and the pubid URN codec uses schema order. A local copy of `relaton-data-iec/index-v2.yaml` (32,634 rows) holds 0 `month`/`day`/`undated` keys at any depth, so **no index regeneration follows**; a legacy row that still carries a leaked key deserializes to the same identifier (the stray key is ignored). Locked by `spec/pubid/iec/wrapper_date_keys_spec.rb` (structural tripwire per wrapper class, month/day/undated cases across the four wrappers, both round trips, the legacy row). (hand-off: iec-wrapper-month-day-leak.)
37
+
38
+ **Two parent-slot gaps recorded by the cross-flavor `base` survey** (`lib/pubid/bsi/CLAUDE.md` has the measured version). `Identifiers::TestReportForm#cispr_identifier` is a polymorphic `Identifier` parent slot with no `#root` override, so `root` returns the form itself — ratified as DELIBERATE by #687 (a TRF is a form about the CISPR document, not a wrapper of it; see the unserialized-identity bullet). And `Identifiers::ConsolidatedIdentifier#base_document` returns `identifiers&.first` **without** the recursive `&.base_document` that its BSI, CSA and CEN counterparts use — a one-level peel where the others peel fully, so a consolidated edition of a supplement does not reduce to the bare standard. Neither is chased here; both are pinned by nothing, which is why they are written down.
32
39
 
33
40
  **Stage codes — the SSOT and what is settled.** The authoritative source for IEC stage abbreviations is committed in this repo: **`data/iec/stage-codes.md`** — the 52-row IEC/ISO stage-code correspondence table transcribed from IEC's official *IEC/ISO Harmonized APIs* documents (v0.0.8 2023-06, api-portal.iec.ch; v0.0.7 2021-05, iec-apiportal.apigee.io — the successors that replace the IEC Data Feeds), the legacy Data-Feeds field examples, and the IS-lifecycle ladder from `StageCodes_IS_v2.pdf` (2023-06). `www.iec.ch` itself is fully WAF-gated to non-browsers and the Wayback Machine holds no snapshots of its PDFs, so the extract — not the unreachable original — is the reference. **`Pubid::Iec.locate_stage` reads `TYPED_STAGES` only** (`lib/pubid/iec.rb`); `PROJECT_STAGES` (AFDIS, TCDV, PRVC…) are not queryable through it. Every one of metanorma-iec's 55 `STAGE_CODES` — the list quoted in pubid#237 — resolves, locked by `spec/pubid/iec/typed_stages_spec.rb` in the issue's own order; aliases are always **appended, never prepended**, so `abbr.first` (what renders) is unchanged. Notable rulings from that pass: **ADIS** is the legacy Data-Feeds spelling of AFDIS ("Approved for FDIS circulation", ISO_LEVEL 40.99) and is an alias on `cdv` (the typed stage claiming 40.99); **CDISH/CDPAS** are the 50.20 rows of the DISH/DPAS deliverables, not "committee draft ISH/PAS" (30.xx); **Reconciliation items recorded in the extract, deliberately not applied:** DELPUB sits at 99.60 there while the `is` ladder does not claim 99.60; CAN carries two codes (30.98 IS/TR/TS, 50.98 PAS/ISH/COR); the rows CDISH-table-siblings DECDISH/MERGED/SPLIT/PCC/PRVN/DEL/RPUB/TPUB and the per-deliverable translation/preparation families are not registered as typed-stage abbreviations.
34
41
 
35
- ## pubid-scheme URN (issue #670)
42
+ - **A standalone supplement is `==` its `from_hash(to_hash)` round trip — the cause was the synthetic base's stage, not the ivar swap.** A supplement written without its base (`IEC/FDAM 60038-1`, `IEC/DCOR 60038-1`, `IEC/DISH 60038-1`) gets a bare `Pubid::Iec::SingleIdentifier` base from `SupplementIdentifier#ensure_base`; `to_s`, `to_urn`, `to_hash` and `#hash` agreed on both paths, but `==` was false, so `#matches?` failed silently. **The hand-off blamed the swapped `@number`/`@part` ivars; that was wrong.** `EqualityIgnored#==` compares through `public_send`, so the reader overrides hid the swap from it. The real difference was `base.typed_stage`: `ensure_base` set an IS/undated `TypedStage` with no `code`, which `stage_to_kv` cannot serialize, so `from_hash` rebuilt the base with none. **Diagnose an `==` failure by diffing the attributes through their readers, recursively, before trusting a hypothesis about ivars.** The stage write is deleted (the JCGM "delete the write" shape); the one visible change is `root.to_s` of the synthetic base on the parse path (`IEC IS60038` → `IEC60038`, the value `from_hash` already gave). The ivar swap was cleaned up too, because it was a hazard: `ensure_base` now moves the supplement number from `@part` into `@number`, so the stored layout is what `from_hash` rebuilds, and the `#number`/`#part` overrides, `@supplement_number`, `@synthetic_base` and `mark_synthetic_standalone!` are gone. `synthetic_base?` reads `base.instance_of?(SingleIdentifier)`, so it also survives `#exclude`, which copies `base` into a new object (on `main` the copy lost the flag and rendered in attached form, `IEC IS60038/FDAM1`). **The move must not depend on which reader runs first**, and a first version got that wrong: it called `ensure_base` only in the slash-form branch of `Builder#build`, but the canonical spelling `IEC FDAM 60038-1` (what `to_s` prints) reaches a different grammar branch, so a fresh parse kept the grammar layout until something read `base` — `.number` answered `60038`, `to_mr_string` gave `iec.60038_amd.60038`, and `#exclude` (which copies through the readers) copied the unmoved numbers onto a base synthesised afterwards, rendering `IEC FDAM 60038-60038`. So `#number`/`#part` keep a thin override that only TRIGGERS the move (`settle_standalone_layout`: `ensure_base` when there is a `part` and no base) and then return the stored value, and `Iec::Identifier.parse` calls `ensure_base` on a top-level supplement so a fresh parse already stores the moved layout. Supplements without a part (consolidation members, `IEC/AMD 1:2017`) are untouched by either. Two renderings outside the fixture corpus move, both repairs: `exclude(:date)` of a standalone supplement keeps the standalone form, and `IEC FDAM 60038-1 (all parts)` renders as itself instead of `IEC IS60038/FDAM1 (all parts)`. One hazard is left, unreachable from the grammar: reading `part` on a hand-built supplement BEFORE its base is assigned synthesises a base and moves the number, so a later `base =` renders the moved number. **A second hand-off claim was also wrong: an equal `#hash` for two unequal objects is legal** (the rule is `a == b ⇒ a.hash == b.hash`); `compute_hash` does not read `base`, which is a collision, not a defect. Measured: all 12,331 pass-fixture ids are byte-identical in `to_s`, `to_urn`, `to_mr_string`, `root.number`, `to_hash` and class; all 32,949 `relaton-data-iec` `index-v2.yaml` rows keep `from_hash(row).to_hash == row` and `parse(from_hash(row).to_s) == from_hash(row)`, and none has a `pubid:iec:single-identifier` base, so **no index migration follows**. Locked by `spec/pubid/iec/standalone_supplement_round_trip_spec.rb` and by the six standalone fixtures now in `full/iec-pubid.txt`, which the whole-corpus `==` sweep in `spec/pubid/iec/root_number_spec.rb` covers. **Residue, deliberately unchanged:** the self-base form renders its number twice — `IEC/AMD 1:2017` → `IEC Amd 1-1:2017`, `IEC/FDAM 60038` → `IEC FDAM 60038-60038` — because `render_supplement_standalone` prints `base.number` and then the supplement's own number, which are the same value there. (hand-off: iec-standalone-supplement-ivar-swap.)
43
+
44
+ ## pubid-scheme URN (issue #670; gaps closed by #687)
36
45
 
37
46
  `lib/pubid/iec/pubid_urn_schema.rb` completes the partial schema the IEEE
38
47
  and GOST waves landed; rulings in `docs/URN-SCHEMES.adoc` (`[[urn-iec]]`).
39
48
  Invariant: every atom added after those waves sits after `database-` and
40
49
  before `lang-`, so a landed URN encodes identically (`database` became the
41
- bool the corpus serializes before any URN carried it). The URN inherits
42
- the unserialized-identity gaps pinned above: a WorkingDocument's URN is
43
- the type-only `urn:pubid:iec:wd` (all WD rows share one hash — a shared
44
- hash, not a collision), and a TestReportForm's CISPR document rides
45
- `cispr_identifier`. Mapping them stays the wire-format decision it
46
- already was.
50
+ bool the corpus serializes before any URN carried it). The two identity
51
+ gaps the wave inherited are closed (#687, atoms `tc-`/`wdlang-`/`wdstage-`/
52
+ `wpstage-`/`wptype-`/`cispr-` after `vap-`, still before `lang-`): a
53
+ WorkingDocument URNs per document instead of the shared type-only
54
+ `urn:pubid:iec:wd`, and a TestReportForm's CISPR document nests under
55
+ `cispr-`. All landed URNs are unchanged.
@@ -484,10 +484,28 @@ module Pubid
484
484
  end
485
485
  end
486
486
 
487
- when :technical_committee, :subcommittee, :wd_number, :wd_stage, :wd_language, :wp_stage, :wp_type
487
+ when :technical_committee, :subcommittee, :wd_stage, :wd_language, :wp_stage
488
488
  # Working document/programme fields - just return as string
489
489
  value.to_s
490
490
 
491
+ when :wd_number
492
+ # The working document's own number IS the inherited `number`
493
+ # (issue #687, the "prefer the inherited attribute over a parallel
494
+ # one" rule): `root.number`, the index key and `to_hash` all read
495
+ # `number`, so a parallel `wd_number` attribute left every working
496
+ # document keyed on the empty string. The returned hash routes the
497
+ # value through assign_attributes onto `number`.
498
+ { number: value.to_s }
499
+
500
+ when :wp_type
501
+ # The grammar captures a trailing space ("PWI TR 100-36" → "TR ");
502
+ # the renderer used to strip it at print time. Strip at the
503
+ # construction site instead, so the stored value — and therefore
504
+ # to_hash and the URN — is what renders. An all-space value drops
505
+ # out entirely (nil is skipped by assign_attributes).
506
+ stripped = value.to_s.strip
507
+ stripped.empty? ? nil : stripped
508
+
491
509
  when :cispr_identifier
492
510
  # CISPR identifier object for TRF
493
511
  value