pubid 2.0.19 → 2.0.20

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2bbeed2065311312a5f62f6315be2f0d29cf03245755bf5a95b741567e08a96
4
- data.tar.gz: cd32eefaf7a17f10fae7b4a78055e3f241758e544848d4b1dac239a0d1ff30c1
3
+ metadata.gz: 5bf271d5120b52861eb84e3c8cf375c0bdcebf61ecf3c5666af4a888590ccbc7
4
+ data.tar.gz: 2516fdc850d72aab4de4a589a9187d840d685cc2bc101f1a501e088a43d6287e
5
5
  SHA512:
6
- metadata.gz: 9b543322085f97459a47b0ccd67541b9ae9106695e4779e4e385294460aa9c702f5247623d19762ee33c37810031c3d97e030d5d67e5859fde2e932b18f0e140
7
- data.tar.gz: e147a8e6484951b7a31db7f013942aae7ebad11fe63b321c325fe71d72025e27415dce3cbcfa85f4159f6d2cedf2369da1a77dbce80702708333b7a4c2f62a0a
6
+ metadata.gz: 7127733e72ab23c53b55b62bc17258bd936055fef0a3840d61ef81d66dc98d193a9e30cedab3d1178e50b51a26021a8b13c91141b1a9c49e4c5b67448b346987
7
+ data.tar.gz: b19d01682a51d7b7864643b093e3379fe273fa34370c1f151809e0f84d82f965501dd14a6e95d0956ee9f2206d9e9757eb24c89eec166c8fa7395ed2c7bd6bd6
@@ -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,7 +21,7 @@ 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
 
@@ -28,19 +33,19 @@ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/
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
+ **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
37
 
33
38
  **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
39
 
35
- ## pubid-scheme URN (issue #670)
40
+ ## pubid-scheme URN (issue #670; gaps closed by #687)
36
41
 
37
42
  `lib/pubid/iec/pubid_urn_schema.rb` completes the partial schema the IEEE
38
43
  and GOST waves landed; rulings in `docs/URN-SCHEMES.adoc` (`[[urn-iec]]`).
39
44
  Invariant: every atom added after those waves sits after `database-` and
40
45
  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.
46
+ bool the corpus serializes before any URN carried it). The two identity
47
+ gaps the wave inherited are closed (#687, atoms `tc-`/`wdlang-`/`wdstage-`/
48
+ `wpstage-`/`wptype-`/`cispr-` after `vap-`, still before `lang-`): a
49
+ WorkingDocument URNs per document instead of the shared type-only
50
+ `urn:pubid:iec:wd`, and a TestReportForm's CISPR document nests under
51
+ `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
@@ -11,6 +11,35 @@ module Pubid
11
11
  attribute :cispr_identifier, Identifier, polymorphic: true, default: -> {
12
12
  }
13
13
 
14
+ # The embedded CISPR document is part of the form's identity, so it
15
+ # serializes under its own key (issue #687 — before it, a
16
+ # CISPR-bearing TRF hashed to just its publisher and year and never
17
+ # matched after a from_hash round trip). The converter shape is
18
+ # VapIdentifier#base_to_kv / #base_from_kv: the nested identifier
19
+ # carries its own polymorphic `_type`, so to_hash embeds the nested
20
+ # hash and from_hash dispatches back through the type map.
21
+ key_value do
22
+ map "cispr_identifier",
23
+ with: { to: :cispr_identifier_to_kv,
24
+ from: :cispr_identifier_from_kv }
25
+ end
26
+
27
+ def cispr_identifier_to_kv(model, doc)
28
+ cispr = model.cispr_identifier
29
+ return unless cispr
30
+
31
+ doc.add_child(
32
+ Lutaml::KeyValue::DataModel::Element.new("cispr_identifier",
33
+ cispr.to_hash),
34
+ )
35
+ end
36
+
37
+ def cispr_identifier_from_kv(model, value)
38
+ if value
39
+ model.cispr_identifier = ::Pubid::Iec::Identifier.from_hash(value)
40
+ end
41
+ end
42
+
14
43
  # TRF has type abbreviation for parsing
15
44
  TYPED_STAGES = [
16
45
  Pubid::Components::TypedStage.new(
@@ -11,12 +11,28 @@ module Pubid
11
11
  # Working Document: "100/3705(F)/FDIS"
12
12
  class WorkingDocument < Base
13
13
  attribute :technical_committee, :string, default: -> {}
14
- attribute :wd_number, :string, default: -> {}
15
14
  attribute :wd_language, :string, default: -> {}
16
15
  attribute :wd_stage, :string, default: -> {}
17
16
  attribute :wp_stage, :string, default: -> {}
18
17
  attribute :wp_type, :string, default: -> {}
19
18
 
19
+ # The working document's identity attributes are plain strings, so no
20
+ # converter is needed: lutaml writes the scalar, and
21
+ # ::Pubid::Identifier#to_hash drops a nil (the `-> {}` defaults are
22
+ # nil). This is the serialization that issue #687 restored — before
23
+ # it, `to_hash` was `{"_type" => "pubid:iec:working-document"}` alone,
24
+ # all 2,318 working documents shared one hash and one URN
25
+ # (`urn:pubid:iec:wd`), and no row of this class ever matched after a
26
+ # from_hash round trip.
27
+ key_value do
28
+ map "technical_committee", to: :technical_committee,
29
+ render_default: false
30
+ map "wd_language", to: :wd_language, render_default: false
31
+ map "wd_stage", to: :wd_stage, render_default: false
32
+ map "wp_stage", to: :wp_stage, render_default: false
33
+ map "wp_type", to: :wp_type, render_default: false
34
+ end
35
+
20
36
  # Working documents have no TYPED_STAGES - they use PROJECT_STAGES only
21
37
  TYPED_STAGES = [].freeze
22
38
 
@@ -29,14 +45,15 @@ module Pubid
29
45
  end
30
46
 
31
47
  # A working document prints its committee, number, language and stage —
32
- # `1/2457(F)/FDIS` — and none of those is the `number` the shared token
33
- # table looks for, so it had no annotatable token. `wd_stage` is the
34
- # printed stage token ("FDIS"); the derived `#stage` reader covers only
35
- # the PWI/PNW work-programme form and is nil here.
48
+ # `1/2457(F)/FDIS` — and the document number is the inherited
49
+ # `number` (issue #687: it used to sit in a parallel `wd_number`
50
+ # attribute that nothing serialized). `wd_stage` is the printed stage
51
+ # token ("FDIS"); the derived `#stage` reader covers only the PWI/PNW
52
+ # work-programme form and is nil here.
36
53
  def annotation_tokens
37
54
  super + [
38
55
  [:technical_committee, "docnumber"],
39
- [:wd_number, "docnumber"],
56
+ [:number, "docnumber"],
40
57
  [:wd_language, "language"],
41
58
  [:wd_stage, "stage"],
42
59
  [:wp_stage, "stage"],
@@ -16,13 +16,15 @@ module Pubid
16
16
  # published URNs are unchanged. `database` was a string atom before any
17
17
  # URN carried it — the corpus holds only `true`, so it is a bool.
18
18
  #
19
- # Known identity gaps the URN inherits, because it carries exactly what
20
- # to_hash carries (pinned in lib/pubid/iec/CLAUDE.md): a WorkingDocument
21
- # serializes only `_type`, and a TestReportForm's CISPR document sits in
22
- # the unserialized `cispr_identifier` — such rows emit type-only URNs
23
- # (`urn:pubid:iec:wd`) and are one hash, so they are not a collision.
24
- # Mapping them is a wire-format change and stays the separate decision
25
- # it already was. The official RFC 5141-style urn:iec:std: form stays
19
+ # The identity gaps this schema used to inherit are closed (issue #687):
20
+ # the working document's committee/number/language/stage and work-programme
21
+ # stage/type serialize (`tc-`, `wdlang-`, `wdstage-`, `wpstage-`,
22
+ # `wptype-`, with the document number in the positional core), and a
23
+ # TestReportForm's CISPR document rides `cispr-` as a nested id — so a
24
+ # working document URNs per document instead of the shared type-only
25
+ # `urn:pubid:iec:wd` it used to be. The new atoms sit after `vap-` and
26
+ # before `lang-`, per the invariant below, so every landed URN encodes
27
+ # identically. The official RFC 5141-style urn:iec:std: form stays
26
28
  # on #to_urn.
27
29
  PubidUrnSchema = Pubid::PubidUrn.define("iec") do
28
30
  atom "publisher", "pub"
@@ -41,6 +43,12 @@ module Pubid
41
43
  atom "sheet_number", "sheet"
42
44
  atom "sheet_year", "sheetyear"
43
45
  atom "vap", "vap", kind: :list
46
+ atom "technical_committee", "tc"
47
+ atom "wd_language", "wdlang"
48
+ atom "wd_stage", "wdstage"
49
+ atom "wp_stage", "wpstage"
50
+ atom "wp_type", "wptype"
51
+ atom "cispr_identifier", "cispr", kind: :id
44
52
  atom "languages.*.code", "lang"
45
53
  atom "identifiers", "ids", kind: :ids
46
54
 
@@ -208,8 +208,10 @@ module Pubid
208
208
  # International Standard, so rendering would not be a fixed point.
209
209
  parts = []
210
210
  parts << id.wp_stage
211
- if id.wp_type && !id.wp_type.strip.empty?
212
- parts << " #{id.wp_type.strip}"
211
+ if id.wp_type && !id.wp_type.empty?
212
+ # wp_type is stripped at the construction site (Builder#cast),
213
+ # so the stored value is what renders.
214
+ parts << " #{id.wp_type}"
213
215
  end
214
216
 
215
217
  if id.number
@@ -226,8 +228,10 @@ module Pubid
226
228
  parts = []
227
229
  parts << id.technical_committee if id.technical_committee
228
230
 
229
- if id.wd_number
230
- num_part = id.wd_number.to_s
231
+ # The working document's own number is the inherited `number`
232
+ # (issue #687).
233
+ if id.number
234
+ num_part = id.number.to_s
231
235
  num_part += "(#{id.wd_language})" if id.wd_language
232
236
  parts << num_part
233
237
  end
data/lib/pubid/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pubid
4
- VERSION = "2.0.19"
4
+ VERSION = "2.0.20"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pubid
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.19
4
+ version: 2.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 1.3.122
41
+ - !ruby/object:Gem::Dependency
42
+ name: benchmark
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  description: PubID is a Ruby library for parsing and generating international publication
42
56
  identifiers (PubIDs). It provides a simple and flexible API for working with PubIDs,
43
57
  allowing developers to easily create, manipulate, and validate PubIDs in their applications.