pubid 2.0.21 → 2.0.23

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +73 -31
  3. data/data/iec/legacy-notation.md +298 -0
  4. data/data/iso/historic-schemes.md +368 -0
  5. data/data/parg/doi_pfs.json +1 -0
  6. data/data/parg/iec.json +1 -1
  7. data/data/parg/iso.json +1 -1
  8. data/data/parg/iso.parg +142 -2
  9. data/data/parg/nist.json +1 -1
  10. data/data/parg/tables/iec_typed_stages.yaml +3 -0
  11. data/data/parg/tables/typed_stages_ballots.yaml +6 -0
  12. data/data/parg/tables/typed_stages_supplements.yaml +1 -0
  13. data/lib/pubid/doi_pfs/CLAUDE.md +72 -0
  14. data/lib/pubid/doi_pfs/builder.rb +18 -0
  15. data/lib/pubid/doi_pfs/identifier.rb +36 -0
  16. data/lib/pubid/doi_pfs/identifiers/resource.rb +31 -0
  17. data/lib/pubid/doi_pfs/identifiers.rb +7 -0
  18. data/lib/pubid/doi_pfs/parser.rb +10 -0
  19. data/lib/pubid/doi_pfs/pubid_urn_schema.rb +15 -0
  20. data/lib/pubid/doi_pfs/renderer.rb +10 -0
  21. data/lib/pubid/doi_pfs/urn_generator.rb +12 -0
  22. data/lib/pubid/doi_pfs.rb +68 -0
  23. data/lib/pubid/iec/CLAUDE.md +5 -1
  24. data/lib/pubid/iec/builder.rb +47 -0
  25. data/lib/pubid/iec/identifiers/amendment.rb +34 -0
  26. data/lib/pubid/iec/identifiers/corrigendum.rb +6 -1
  27. data/lib/pubid/iec/supplement_identifier.rb +55 -25
  28. data/lib/pubid/iho/CLAUDE.md +2 -1
  29. data/lib/pubid/iho/pubid_urn_schema.rb +2 -1
  30. data/lib/pubid/iso/CLAUDE.md +49 -0
  31. data/lib/pubid/iso/builder.rb +77 -0
  32. data/lib/pubid/iso/identifiers/amendment.rb +1 -1
  33. data/lib/pubid/iso/single_identifier.rb +7 -1
  34. data/lib/pubid/nist/CLAUDE.md +84 -10
  35. data/lib/pubid/nist/builder.rb +15 -0
  36. data/lib/pubid/nist/identifiers/base.rb +52 -1
  37. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +44 -5
  38. data/lib/pubid/nist/identifiers/special_publication.rb +15 -0
  39. data/lib/pubid/nist/preprocessor.rb +14 -12
  40. data/lib/pubid/nist/router.rb +11 -0
  41. data/lib/pubid/nist/urn_parser.rb +1 -1
  42. data/lib/pubid/version.rb +1 -1
  43. data/lib/pubid.rb +4 -0
  44. metadata +16 -2
@@ -4,8 +4,10 @@
4
4
  - abbr: "ANW Amd"
5
5
  - abbr: "WD Amd"
6
6
  - abbr: "CDAM"
7
+ - abbr: "PDAM"
7
8
  - abbr: "DAM"
8
9
  - abbr: "FDAM"
10
+ - abbr: "FPDAM"
9
11
  - abbr: "PRF Amd"
10
12
  - abbr: "Amd"
11
13
  - abbr: "AMD"
@@ -14,6 +16,7 @@
14
16
  - abbr: "ANW Cor"
15
17
  - abbr: "WDCor"
16
18
  - abbr: "CDCor"
19
+ - abbr: "CDCOR"
17
20
  - abbr: "DCOR"
18
21
  - abbr: "FDCOR"
19
22
  - abbr: "PRF Cor"
@@ -0,0 +1,6 @@
1
+ - abbr: "PDAM"
2
+ - abbr: "DAM"
3
+ - abbr: "DAmd"
4
+ - abbr: "FDAM"
5
+ - abbr: "FDAmd"
6
+ - abbr: "FPDAM"
@@ -44,6 +44,7 @@
44
44
  - abbr: "Amd"
45
45
  - abbr: "AMD"
46
46
  - abbr: "Amd."
47
+ - abbr: "Am"
47
48
  - abbr: "PWI Cor"
48
49
  - abbr: "NP Cor"
49
50
  - abbr: "AWI Cor"
@@ -0,0 +1,72 @@
1
+ # DoiPfs — the PFS-compiled doi dev alias
2
+
3
+ This flavor is **generated**, not hand-written: every file here is compiler
4
+ output from `pubid-grammar`'s `definitions/doi.pfs` (pubid/pubid#706),
5
+ reproduce with
6
+
7
+ ```bash
8
+ cd ../pubid-grammar
9
+ ruby scripts/pfs compile definitions/doi.pfs --as doi_pfs \
10
+ --parsanol-version <CI pin> --ruby-out ../pubid/lib
11
+ cp artifacts/doi_pfs.json ../pubid/data/parg/doi_pfs.json
12
+ ```
13
+
14
+ DO NOT EDIT these files by hand — a hand edit is silently overwritten by
15
+ the next compile, and the grammar SSOT is the definition, not this module
16
+ (the `data/parg/doi_pfs.json` artifact's freshness envelope and the
17
+ definition's source sha pin the lineage).
18
+
19
+ ## The coexistence semantics (read before touching anything)
20
+
21
+ `doi_pfs` is the **dev-alias key** of the doi scheme: it registers beside
22
+ the coded `Pubid::Doi` so the compiled pipeline can be proven against the
23
+ coded flavor without cutover (cutover — replacing `doi` with the compiled
24
+ flavor — is a separate, later decision).
25
+
26
+ - **Wire tag**: the definition declares `tag: "pubid:doi:resource"` — the
27
+ SCHEME's canonical tag — so `to_hash` is byte-identical with the coded
28
+ flavor. `Resource.polymorphic_name` overrides the class-name-derived
29
+ tag for exactly this reason. Consequence: a GLOBAL `Pubid.from_hash` of
30
+ a doi_pfs hash resolves the coded class (one scheme, one `_type`, one
31
+ global resolution); the alias's own `DoiPfs::Identifier.from_hash`
32
+ routes the tag back to `DoiPfs::Identifiers::Resource` through its
33
+ `DOI_TYPE_MAP`.
34
+ - **URN namespace**: the definition declares `urn.namespace: doi`, so the
35
+ generated `UrnGenerator` pins `urn_namespace` to `"doi"` and `to_urn`
36
+ is byte-identical (`"urn:doi"`). The pubid-scheme schema registers
37
+ under the FLAVOR key (`PubidUrn.define("doi_pfs")`) — never re-register
38
+ under `"doi"`, that would overwrite the coded flavor's registration.
39
+ - **Routing**: `Pubid.parse` keeps routing `doi:`-faced strings to the
40
+ coded flavor (registry keys sort `"doi"` first; both flavors claim the
41
+ same prefixes). The alias is reached through `Pubid::DoiPfs.parse`.
42
+
43
+ ## The parity gate
44
+
45
+ `spec/pubid/doi_pfs/parity_spec.rb` is the spike's whole point: over the
46
+ full doi inventory (coded fixtures + corpus rows + aliases + constructed
47
+ edge cases), both flavors must agree byte-for-byte on `to_s`, `to_hash`,
48
+ `to_urn`, `to_mr_string`, `to_pubid_urn` and fail on the same inputs with
49
+ `Pubid::Errors::ParseError`. Drift is a defect in the definition or the
50
+ compiler — never relax an expectation here.
51
+
52
+ Non-obvious inventory facts, learned the hard way:
53
+
54
+ - The combined faces (`https://doi.org/doi:10.1000/182`) are rejected by
55
+ the CODED flavor too (the optional faces compose non-backtracking), so
56
+ they are negatives.
57
+ - `Pubid.parse("10.1000/182")` routes to **UN** on main, pre-existing:
58
+ the bare `10.` form is not a doi prefix-owner claim at a token
59
+ boundary (`10.` followed by a digit), and un's token run accepts it.
60
+ Only the scheme-faced form routes to Doi.
61
+ - The coded builder re-adds `"10."` to the registrar digits its grammar
62
+ captures; the doi.pfs pattern captures the whole `10.` face, so the
63
+ doi_pfs builder binds `prefix` directly. Model-level outputs are
64
+ identical; only the bound wire map differs (`prefix: "1000"` vs
65
+ `"10.1000"` in the artifact's corpus).
66
+
67
+ ## v1 boundaries
68
+
69
+ Single-type schemes only, `kind: string` fields only, no free-standing
70
+ `stages:`/`supplements:` sections, no semantic decode rules (the
71
+ DAM-family problem) — see `pubid-grammar`'s `definitions/README.md` for
72
+ the language reference and the fail-closed validation rules.
@@ -0,0 +1,18 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ class Builder
4
+ def self.build(parsed_data)
5
+ new.build(parsed_data)
6
+ end
7
+
8
+ def build(data)
9
+ Identifiers::Resource.new(
10
+ prefix: data[:prefix],
11
+ suffix: data[:suffix],
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ Pubid::DoiPfs::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -0,0 +1,36 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ class Identifier < ::Pubid::Identifier
4
+ attribute :prefix, :string
5
+ attribute :suffix, :string
6
+
7
+ DOI_TYPE_MAP = {
8
+ "pubid:doi:resource" => "Pubid::DoiPfs::Identifiers::Resource",
9
+ }.freeze
10
+
11
+ key_value do
12
+ map "_type", to: :_type, polymorphic_map: DOI_TYPE_MAP
13
+ map "prefix", to: :prefix
14
+ map "suffix", to: :suffix
15
+ end
16
+
17
+ PUBLISHER = "DOI".freeze
18
+
19
+ def self.parse(identifier)
20
+ unless identifier.is_a?(String)
21
+ raise Pubid::Errors::InvalidInputError,
22
+ Pubid::INPUT_NOT_A_STRING_MESSAGE
23
+ end
24
+
25
+ if identifier.length > Pubid::MAX_INPUT_LENGTH
26
+ raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
27
+ end
28
+
29
+ identifier = ::Pubid.utf8_input(identifier)
30
+
31
+ parsed = Pubid::Parg::Model.bound(:doi_pfs, identifier)
32
+ Builder.build(parsed)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ module Identifiers
4
+ # Concrete DOI identifier type (PFS type `resource`).
5
+ class Resource < Identifier
6
+ TYPED_STAGES = [
7
+ Pubid::Components::TypedStage.new(
8
+ code: :pubdoi,
9
+ stage_code: :published,
10
+ type_code: :doi,
11
+ abbr: ["DOI"],
12
+ name: "DOI",
13
+ harmonized_stages: [],
14
+ ),
15
+ ].freeze
16
+
17
+ def self.type
18
+ { key: :doi, web: :doi, title: "DOI", short: "DOI" }
19
+ end
20
+
21
+ # The scheme-canonical wire tag declared in the definition:
22
+ # to_hash stays byte-identical with the scheme's coded
23
+ # flavor, and this flavor's own from_hash routes the tag
24
+ # to this class.
25
+ def self.polymorphic_name
26
+ "pubid:doi:resource"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ module Identifiers
4
+ autoload :Resource, "#{__dir__}/identifiers/resource"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ # PFS-generated parser shell. Parsing runs through the baked
4
+ # PARG artifact (data/parg/doi_pfs.json) via Pubid::Parg::Model.bound;
5
+ # the grammar source lives in pubid-grammar
6
+ # (grammars/doi_pfs.parg, compiled from definitions/doi.pfs).
7
+ class Parser
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ # The pubid-scheme URN schema, registered under the compiled
4
+ # flavor key. The definition's declared namespace
5
+ # ("doi") is the scheme's canonical
6
+ # namespace; identifiers carry the scheme's wire tag, so their
7
+ # pubid URNs encode through the scheme's own schema.
8
+ PubidUrnSchema = Pubid::PubidUrn.define("doi_pfs") do
9
+ atom "prefix", "prefix"
10
+ atom "suffix", "suffix"
11
+ type "pubid:doi_pfs:identifier", "id", core: []
12
+ type "pubid:doi_pfs:resource", "resource", core: []
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ # Human renderer, generated from the canonical template.
4
+ class Renderer < ::Pubid::Renderers::Base
5
+ def render(**_opts)
6
+ "doi:#{@id.prefix}/#{@id.suffix}"
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ module Pubid
2
+ module DoiPfs
3
+ # The official URN generator, pinned to the scheme's declared
4
+ # namespace ("doi") so to_urn stays
5
+ # scheme-canonical under the compiled flavor key.
6
+ class UrnGenerator < ::Pubid::UrnGenerator::Base
7
+ def urn_namespace
8
+ "doi"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # PFS-generated flavor — DO NOT EDIT BY HAND.
5
+ # Source: definitions/doi.pfs (pfs:1) sha256:60abc3c403c61019d727ee44d9a72591c1549672451e6609a72d36189cae6340
6
+ # Compiler: pubid/grammar/pfs 0.1.0 (baked with parsanol 1.3.126)
7
+ # Regenerate: ruby scripts/pfs compile definitions/doi.pfs --as doi_pfs --ruby-out <dir>
8
+
9
+ require "pubid"
10
+
11
+ module Pubid
12
+ # Digital Object Identifier flavor, compiled from its PFS definition
13
+ # (pubid/pubid#706). A compiled flavor is a peer of the coded
14
+ # flavors: same registry, same input guards, same error
15
+ # namespace, same URN contracts.
16
+ module DoiPfs
17
+ extend Pubid::PrefixesSupport
18
+
19
+ PREFIXES = ["doi:", "DOI:", "10."].freeze
20
+
21
+ autoload :Builder, "#{__dir__}/doi_pfs/builder"
22
+ autoload :Identifier, "#{__dir__}/doi_pfs/identifier"
23
+ autoload :Identifiers, "#{__dir__}/doi_pfs/identifiers"
24
+ autoload :Parser, "#{__dir__}/doi_pfs/parser"
25
+ autoload :Renderer, "#{__dir__}/doi_pfs/renderer"
26
+ autoload :PubidUrnSchema, "#{__dir__}/doi_pfs/pubid_urn_schema"
27
+ autoload :UrnGenerator, "#{__dir__}/doi_pfs/urn_generator"
28
+
29
+ def self.parse(identifier)
30
+ Identifier.parse(identifier)
31
+ end
32
+
33
+ Identifier.format_registry = FormatRegistry.new(parent: Identifier.format_registry)
34
+ Identifier.format_registry.register(:human, renderer: DoiPfs::Renderer)
35
+
36
+ def self.identifier_types
37
+ @identifier_types ||= Identifiers.constants
38
+ .filter_map do |c|
39
+
40
+ Identifiers.const_get(c); rescue NameError; nil
41
+
42
+ end
43
+ .select { |c| c.is_a?(Class) && c.singleton_methods(false).include?(:type) }
44
+ .select { |c| c.type.is_a?(Hash) }
45
+ end
46
+
47
+ def self.all_typed_stages
48
+ @all_typed_stages ||= identifier_types.flat_map do |klass|
49
+ klass.const_defined?(:TYPED_STAGES) ? klass.const_get(:TYPED_STAGES) : []
50
+ end
51
+ end
52
+
53
+ def self.locate_type(code)
54
+ identifier_types.find { |t| t.type[:key].to_s == code.to_s }
55
+ end
56
+
57
+ def self.locate_stage(abbr)
58
+ abbr_str = abbr.to_s.upcase
59
+ all_typed_stages.find do |s|
60
+ s.abbr.any? do |a|
61
+ a.to_s.upcase == abbr_str
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ Pubid::Registry.register(:doi_pfs, Pubid::DoiPfs)
@@ -39,7 +39,11 @@ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/
39
39
 
40
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.
41
41
 
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.)
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` — 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.) **CORRECTED 2026-11-12 (owner ruling, reverses this bullet's identity):** the part-to-number move this bullet introduced is the DEFECT the legacy-decode bullet below removes. `IEC/FDAM 60038-1` is not "base 60038 with FDAM 1": the whole `60038-1` is the base designation. The equality machinery this bullet built (the stored layout `from_hash` rebuilds, the reader-triggered settle, `synthetic_base?` reading the class) is kept and holds on the corrected identity.
43
+
44
+ - **The legacy prefix faces decode onto the modern identity (owner ruling 2026-11-12; evidence: `data/iec/legacy-notation.md`; reverses #693's resolution).** The old notation prints the stage token BEFORE the base designation — the JTC1 ballot faces `IEC/FDAM 60038-1`, `ISO/IEC PDAM 12207`, `ISO/IEC PDAM 2 10646:2012`, `ISO/IEC FPDAM 9075/1`, `ISO/IEC FDAM 1 14496-12:2004`, and the IEC draft faces `IEC/DCOR 60038-1`, `IEC/DISH 60038-1`, `IEC CDISH 60050-191`. #693's model had resolved those by STEALING the designation's part for the supplement (`ensure_base` moved `@part` into `@number`), so the old face and the modern on-base face (`IEC 60038-1/FDAM 1`) disagreed on the same document's identity. The decode now: **the designation moves WHOLE onto the base** (number, part, subpart, and the date — old notation prints the base's year, and a draft supplement carries no publication date of its own), **the supplement's number defaults to 1** (the no-number rule: an unnumbered DAM/PDAM/FDAM/FPDAM/DCOR/DISH means Amendment/Corrigendum/Interpretation-Sheet 1), and **the base is a concrete `InternationalStandard`**, not the synthetic `SingleIdentifier` — that concreteness is what makes the legacy face hash-match (and equal) the modern parse: `parse("IEC/DCOR 60038-1") == parse("IEC 60038-1/DCOR 1")`. The render is the modern on-base slash face (`IEC 60038-1/FDAM1`), so the legacy face is not a spelling facet — it is decoded, IHO-style.
45
+ **Two grammar halves.** The DAM-family prefix faces (`DAM`/`PDAM`/`FDAM`/`FPDAM`, spelled literally in the rule so the typed_stages table stays put for them) raised on main; the new `legacy_supplement` rule in `pubid-grammar` captures `legacy_stage`, the optional `legacy_supplement_number` (N before the base), and the base designation — including the old slash-part spelling (`FPDAM 9075/1` = base 9075, part 1; captured as `legacy_base_number` + `legacy_base_part`, and ordered BEFORE the dash-part alternative because `number_with_part` also matches a bare "9075", which would have left the "/1" dangling). The rule sits ahead of `standalone_supplement` in the ordered choice so both the slash and space faces reach it, and its date rides with the designation. PDAM/FPDAM joined the `iec_typed_stages` table and `Amendment::TYPED_STAGES` (stage codes mirror ISO's entries: PDAM at the CD level, FPDAM at the FDIS level; the LEAD harmonized codes are the circulation slots 30.20/50.20 — ISO's 30.00/50.00 are CDAM's and FDAM's (type, leading code) URN keys). The non-DAM draft faces (DCOR, FDCOR, CDISH, DISH, and any typed standalone with a designation-shaped token) already parsed; they are corrected by `ensure_base`, so the two paths converge on one decode. **The discriminator between a legacy face and a true standalone is the PART:** a designation token (`60038-1`, `9075/1`) moves whole onto the base; a bare ordinal (`IEC/AMD 1:2017`) is the supplement's own number and keeps the synthetic self-base (`IEC Amd 1-1:2017`, unchanged). Under the JTC1 reading a bare number after a DAM-family token is a base designation (`IEC FDAM 60038` → `IEC 60038/FDAM1`), not a self-base mangle — that residue from the #693 bullet is gone.
46
+ **Measured by the replay gate, not the suite alone:** over all 26,838 full-fixture inputs, `to_s`/`to_urn`/`to_mr_string`/`to_hash`/class/`root.number` are byte-identical to the `main` baseline EXCEPT exactly the 6 #693-era standalone rows (both faces of FDAM/DCOR/DISH `60038-1`), which re-resolve to the corrected identities; 0 inputs stopped parsing; the prefix faces go raise→parse (12,491 full entries → 12,359 pass, fail steady at 132). #693's equality invariant (`parse == from_hash(to_hash)`) holds on every corrected identity. Locked by `spec/pubid/iec/standalone_supplement_round_trip_spec.rb` (rewritten: legacy faces decode and equal their modern parses, the self-base standalone keeps its form), `stage_separator_spec.rb`, `amendment_spec.rb`, `corrigendum_spec.rb`, `interpretation_sheet_spec.rb`, and the pass fixtures re-pinned by `validation:classify`. **Remaining gaps, deliberately open:** the dotted on-base `/Amd.1` spelling still raises in the IEC flavor (ISO accepts it), and the stamped prefix faces (`ISO/IEC FPDAM 10373-6/Amd 9:2001`, `ISO/IEC PDAM 9541-1/Amd.4`, `ISO/IEC FDAM 19776-2:2005/Am1:2006`, `ISO/IEC PDAM TR 9573-11/Amd.1`) stay raising — their on-base amendment stamps need the dotted/ISO stamp spellings the IEC flavor does not carry (gap 4 of the draft); the ISO-side agent owns that study.
43
47
 
44
48
  ## pubid-scheme URN (issue #670; gaps closed by #687)
45
49
 
@@ -57,6 +57,53 @@ module Pubid
57
57
  def build(parsed_hash)
58
58
  all_parts = parsed_hash.delete(:all_parts)
59
59
 
60
+ # Legacy DAM-family prefix face (IEC/FDAM 60038-1, ISO/IEC PDAM 2
61
+ # 10646:2012, ISO/IEC FPDAM 9075/1): the JTC1 ballot spelling puts
62
+ # the stage token before the base designation. Decode onto the
63
+ # modern identity (owner ruling 2026-11-12, with data/iec/
64
+ # legacy-notation.md): the designation is the BASE's, kept whole —
65
+ # the old slash-part spelling "9075/1" is base 9075 part 1 — and
66
+ # the supplement's number is the captured N, defaulting to 1 (the
67
+ # no-number rule: unnumbered DAM/PDAM/FDAM/FPDAM means Amendment 1).
68
+ # The date rides with the designation (old notation prints the
69
+ # base's year); a draft supplement carries none of its own. The
70
+ # base builds through the ordinary single-document path, so it is
71
+ # the same InternationalStandard the modern on-base parse holds and
72
+ # `parse(legacy) == parse(modern)`.
73
+ if parsed_hash[:legacy_stage]
74
+ supp = build_supplement(
75
+ type_with_stage: parsed_hash[:legacy_stage],
76
+ number_with_part: parsed_hash[:legacy_supplement_number] || "1",
77
+ )
78
+ base_hash = {}
79
+ {
80
+ publisher: :publisher,
81
+ legacy_base_number_with_part: :number_with_part,
82
+ date: :date,
83
+ undated_marker: :undated_marker,
84
+ edition: :edition,
85
+ languages: :languages,
86
+ }.each do |source, target|
87
+ base_hash[target] = parsed_hash[source] if parsed_hash[source]
88
+ end
89
+ # The grammar binds the legacy face's copublisher as a top-level
90
+ # scalar (legacy_copublisher) — the wasm binder scatters array
91
+ # bindings over the sibling list when a bare copublisher node
92
+ # rides a flat shape — so the builder attaches it here, in the
93
+ # same shape the parse path used to carry.
94
+ if parsed_hash[:legacy_copublisher]
95
+ base_hash[:copublishers] = [{ copublisher: parsed_hash[:legacy_copublisher] }]
96
+ end
97
+ # The slash-part designation ("9075/1") captured number and part
98
+ # separately; they go on straight as the plain strings the retyped
99
+ # attributes are, not through cast (which has no :part type).
100
+ base = build(base_hash)
101
+ base.number = parsed_hash[:legacy_base_number].to_s if parsed_hash[:legacy_base_number]
102
+ base.part = parsed_hash[:legacy_base_part].to_s if parsed_hash[:legacy_base_part]
103
+ return apply_fragment_notation(wrap_with_supplement(base, supp),
104
+ parsed_hash)
105
+ end
106
+
60
107
  # Second supplement layer (/AMD1:2018/COR1:2019): build the
61
108
  # first-supplement form, then wrap the second supplement around
62
109
  # it.
@@ -54,6 +54,25 @@ module Pubid
54
54
  name: "Committee Draft Amendment",
55
55
  harmonized_stages: %w[30.00 30.20 30.60 30.92 30.98 30.99],
56
56
  ),
57
+ # The JTC1 ladder's pre-DAM ballot face (zero IEC-only record usage
58
+ # — the DAM family rides on joint work only; see
59
+ # data/iec/legacy-notation.md). Stage codes mirror the ISO flavor's
60
+ # entry for the same letter: PDAM sits at the CD level (30.xx).
61
+ # The LEAD harmonized code is the circulation slot (30.20
62
+ # "circulated as CD"), not ISO's 30.00 — the URN writes
63
+ # stage-{harmonized_stages.first} and parses it back by
64
+ # (type, leading code), which CDAM (30.00) already claims; see
65
+ # spec/pubid/iec/pnw_stage_spec.rb.
66
+ Pubid::Components::TypedStage.new(
67
+ code: :pdam,
68
+ stage_code: :cd,
69
+ type_code: :amd,
70
+ abbr: ["PDAM"],
71
+ short_abbr: nil,
72
+ long_abbr: nil,
73
+ name: "Proposed Draft Amendment",
74
+ harmonized_stages: %w[30.20 30.00 30.60 30.92 30.93 30.98 30.99],
75
+ ),
57
76
  Pubid::Components::TypedStage.new(
58
77
  code: :damd,
59
78
  stage_code: :damd,
@@ -74,6 +93,21 @@ module Pubid
74
93
  name: "Final Draft Amendment",
75
94
  harmonized_stages: %w[50.00 50.20 50.60 50.92 50.98 50.99],
76
95
  ),
96
+ # The JTC1 ladder's pre-FDAM ballot face (same provenance as PDAM
97
+ # above): FPDAM mirrors the ISO flavor's entry, at the FDIS level
98
+ # (50.xx). Its LEAD harmonized code is the circulation slot
99
+ # (50.20), not ISO's 50.00, which FDAM already claims as its
100
+ # (type, leading code) URN key.
101
+ Pubid::Components::TypedStage.new(
102
+ code: :fpdam,
103
+ stage_code: :fdamd,
104
+ type_code: :amd,
105
+ abbr: ["FPDAM"],
106
+ short_abbr: nil,
107
+ long_abbr: nil,
108
+ name: "Final Proposed Draft Amendment",
109
+ harmonized_stages: %w[50.20 50.00 50.60 50.92 50.98 50.99],
110
+ ),
77
111
  Pubid::Components::TypedStage.new(
78
112
  code: :pubamd,
79
113
  stage_code: :published,
@@ -44,7 +44,12 @@ module Pubid
44
44
  code: :cdcor,
45
45
  stage_code: :cd,
46
46
  type_code: :cor,
47
- abbr: ["CDCor"],
47
+ # "CDCOR" is the all-caps spelling the attached-supplement
48
+ # renderer emits (abbr.first.upcase) when a legacy prefix face
49
+ # decodes onto a CDCor draft ("IEC/CDCor 60038-1" renders
50
+ # "IEC 60038-1/CDCOR1"); appended as an alias so the canonical
51
+ # render re-parses. abbr.first stays "CDCor" (spelling-only).
52
+ abbr: ["CDCor", "CDCOR"],
48
53
  short_abbr: "CDCor",
49
54
  long_abbr: "CD Cor",
50
55
  name: "Committee Draft Corrigendum",
@@ -4,6 +4,11 @@ module Pubid
4
4
  module Iec
5
5
  # Identifier that represents a supplement to a base identifier.
6
6
  class SupplementIdentifier < SingleIdentifier
7
+ # The supplement number a legacy standalone face implies when its
8
+ # type token carries none ("IEC/DCOR 60038-1" is Corrigendum 1 of
9
+ # 60038-1); the owner's no-number rule.
10
+ LEGACY_STANDALONE_SUPPLEMENT_NUMBER = "1"
11
+
7
12
  attribute :base, Identifier, polymorphic: true
8
13
 
9
14
  # Serialize the supplemented document under "base". The supplement's own
@@ -22,8 +27,9 @@ module Pubid
22
27
  # (e.g. a bare "+AMD1:2008" nested in a consolidation): a fake doc whose
23
28
  # number equals the supplement's own. It renders nothing and the getter
24
29
  # recreates it identically on load, so don't persist it. A standalone
25
- # supplement ("IEC/FDAM 60038-1") instead moves the real base number into
26
- # the synthetic base (base.number != own number), which must be kept.
30
+ # supplement in the legacy prefix face ("IEC/DCOR 60038-1") instead
31
+ # holds the real base designation (base.number != own number), which
32
+ # must be kept.
27
33
  return if model.synthetic_base? && base.number.to_s == model.number.to_s
28
34
 
29
35
  doc.add_child(Lutaml::KeyValue::DataModel::Element.new("base",
@@ -45,13 +51,14 @@ module Pubid
45
51
  @base || ensure_base
46
52
  end
47
53
 
48
- # A standalone supplement holds the grammar's layout (base number in
49
- # `number`, supplement number in `part`) until #ensure_base moves it.
50
- # Not every construction path goes through one builder branch ("IEC FDAM
51
- # 60038-1" does not, nor a hand-built Amendment), so these readers make
52
- # the move before they answer: the result never depends on which reader
53
- # ran first, and #exclude, which copies through the readers, copies the
54
- # moved layout. They only TRIGGER the move; they return the stored value.
54
+ # A standalone supplement written in the legacy prefix face holds the
55
+ # grammar's layout (the whole base designation on itself) until
56
+ # #ensure_base moves it. Not every construction path goes through one
57
+ # builder branch ("IEC DCOR 60038-1" reaches the typed branch, a
58
+ # hand-built Amendment none), so these readers make the move before
59
+ # they answer: the result never depends on which reader ran first, and
60
+ # #exclude, which copies through the readers, copies the moved layout.
61
+ # They only TRIGGER the move; they return the stored value.
55
62
  def number
56
63
  settle_standalone_layout
57
64
  super
@@ -81,14 +88,27 @@ module Pubid
81
88
 
82
89
  # Synthesise the base of a supplement that names none, and return it.
83
90
  #
84
- # A standalone supplement ("IEC/FDAM 60038-1") arrives from the grammar
85
- # with the BASE number in `number` and the SUPPLEMENT number in `part`.
86
- # This moves the base number onto a bare SingleIdentifier base and stores
87
- # the supplement number in `number`, so the attributes hold what they
88
- # mean — the layout `from_hash` rebuilds from the serialized hash. A
89
- # supplement with no part (a bare "+AMD1:2008" in a consolidation,
90
- # "IEC/AMD 1:2017") gets a self-base whose number equals its own; its
91
- # `number` is left as it is.
91
+ # A standalone supplement written in the legacy prefix face ("IEC/DCOR
92
+ # 60038-1", "IEC/DISH 60038-1") arrives from the grammar with the BASE
93
+ # designation stored on itself — the base number in `number`, the base
94
+ # part (and subpart) in `part`/`subpart`. That layout is the grammar's
95
+ # bound shape, not the identity: the face names a supplement OF that
96
+ # designation, so the designation moves WHOLE onto a concrete
97
+ # InternationalStandard base (publisher, copublishers and the date go
98
+ # with it — old notation prints the base's year, and a draft
99
+ # supplement carries no publication date of its own) and the
100
+ # supplement keeps a number of its own, defaulting to 1 — the
101
+ # no-number rule (an unnumbered draft face means
102
+ # Amendment/Corrigendum/Interpretation-Sheet 1; owner ruling
103
+ # 2026-11-12, reversing #693's part-to-number move, which stole the
104
+ # base's part for the supplement). The concrete base is what makes the
105
+ # decode hash-match (and equal) the modern on-base parse of the same
106
+ # document: `parse("IEC/DCOR 60038-1") == parse("IEC 60038-1/DCOR1")`.
107
+ #
108
+ # A supplement with no part (a true standalone like "IEC/AMD 1:2017",
109
+ # or a bare "+AMD1:2008" in a consolidation) keeps its own number and
110
+ # gets a synthetic self-base whose number equals it; its date stays
111
+ # its own.
92
112
  #
93
113
  # The base deliberately gets NO typed_stage. A stage without a code is
94
114
  # not serialized, so `from_hash` cannot restore one, and a stage set
@@ -102,15 +122,22 @@ module Pubid
102
122
  @ensuring_base = true
103
123
  return nil unless publisher && (@number || @part)
104
124
 
105
- base = SingleIdentifier.new
106
- base.publisher = publisher
107
- base.number = @number if @number
108
- base.subpart = @subpart if @subpart
109
- base.date = date if date
110
-
111
125
  if @part
112
- @number = @part
126
+ base = Identifiers::InternationalStandard.new
127
+ base.publisher = publisher
128
+ base.copublishers = copublishers if copublishers&.any?
129
+ base.number = @number
130
+ base.part = @part
131
+ base.subpart = @subpart if @subpart
132
+ base.date = date if date
133
+ @number = LEGACY_STANDALONE_SUPPLEMENT_NUMBER
113
134
  @part = nil
135
+ @date = nil
136
+ else
137
+ base = SingleIdentifier.new
138
+ base.publisher = publisher
139
+ base.number = @number if @number
140
+ base.date = date if date
114
141
  end
115
142
 
116
143
  @base = base
@@ -122,7 +149,10 @@ module Pubid
122
149
  # from_hash) synthesised for a standalone supplement; a real attached
123
150
  # base is always a concrete type (InternationalStandard, SheetIdentifier
124
151
  # …). Read from the base itself, so it survives #exclude, which copies
125
- # `base` into a new object.
152
+ # `base` into a new object. A legacy prefix face's designation moves
153
+ # onto a CONCRETE InternationalStandard instead (see #ensure_base), so
154
+ # it renders in the attached, modern form and hash-matches the modern
155
+ # on-base parse of the same document.
126
156
  def synthetic_base?
127
157
  base.instance_of?(::Pubid::Iec::SingleIdentifier)
128
158
  end
@@ -234,4 +234,5 @@ component with its own edition/year right after it (`sup-`, `part-`,
234
234
  `parted-`, `appendix-`, `appsub-`, `vol-`, `issue-` …). Case is preserved
235
235
  (`part-A`); the yearbook year is a volume (`vol-2024`, no `year-` value —
236
236
  the legacy `y.` marker stays decodable in the official URN parser, never
237
- emitted). The official `urn:mrn:iho:…` form stays on `#to_urn`.
237
+ emitted). The positional `urn:iho:` form of `UrnGenerator` stays on
238
+ `#to_urn` — pubid emits no `urn:mrn:iho:…` URN.
@@ -17,7 +17,8 @@ module Pubid
17
17
  # `specvar-`). Letters stay case-preserved (`part-A`). The legacy
18
18
  # `y.` yearbook marker is decodable in the official URN parser, never
19
19
  # emitted here: a periodical has no `year-` atom value, its volume
20
- # carries the year. The official urn:mrn:iho:… form stays on #to_urn.
20
+ # carries the year. The positional urn:iho:… form of UrnGenerator
21
+ # stays on #to_urn — pubid emits no urn:mrn:iho:… URN.
21
22
  PubidUrnSchema = Pubid::PubidUrn.define("iho") do
22
23
  atom "edition", "ed"
23
24
  atom "year", "year"