pubid 2.0.21 → 2.0.22

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.
data/data/parg/iso.parg CHANGED
@@ -11,6 +11,20 @@
11
11
  # below, whose captures carry the idf_ prefix so they never collide
12
12
  # with the ISO ones and can bind as scalars.
13
13
  #
14
+ # Historic TYPE-first ballot faces (decode-to-modern, owner ruling
15
+ # 2026-11-12): the DAM-family token in TYPE position — "ISO/IEC FDAM 1
16
+ # 14496-12:2004", "ISO/PDAM 19973-2" — and the space-attached ballot
17
+ # face "ISO 15877-5:2009 FDAM 1". Both branches sit LAST in the
18
+ # identifier alternation, so they are purely additive: every face an
19
+ # earlier branch parses keeps its exact parse. The ballot token
20
+ # captures as ballot_type_with_stage; a number on the TYPE token is
21
+ # space-bounded (ballot_number) so "PDAM 19973-2" keeps 19973 in the
22
+ # base; an on-base stamp's number/date capture as ballot_stamp_* (the
23
+ # stamp token itself matches uncaptured — the ballot's stage comes
24
+ # from the TYPE token and the decode keeps no spelling). The builder
25
+ # folds the captures into ONE amendment layer over the whole base, the
26
+ # number taken from the TYPE token, the stamp, or the implied 1.
27
+ #
14
28
  # Edition 2 with typed model bindings: every capture the builder
15
29
  # consumes is bound. Supplement layers capture into position-scoped
16
30
  # names (supp_* on the first supplement, supp2_* on the second,
@@ -23,7 +37,7 @@
23
37
 
24
38
  use idf
25
39
 
26
- grammar PubidIso version "0.4.0" edition 2 {
40
+ grammar PubidIso version "0.5.0" edition 2 {
27
41
 
28
42
  # ---- lexical (pubid/parser/common_parse_rules.rb + ISO override) ----
29
43
  digit = %x30-39
@@ -47,6 +61,7 @@ grammar PubidIso version "0.4.0" edition 2 {
47
61
  # inline the alt so the capture holds one scalar span) ----
48
62
  typed_stage_abbrev = alt from_table "typed_stages" column "abbr"
49
63
  supp_stage_abbrev = alt from_table "typed_stages_supplements" column "abbr"
64
+ ballot_stage_abbrev = alt from_table "typed_stages_ballots" column "abbr"
50
65
  directives_stage_abbrev = alt from_table "directives_typed_stages" column "abbr"
51
66
  directives_supp_stage_abbrev = alt from_table "directives_supplements_typed_stages" column "abbr"
52
67
  idf_stage_abbrev = alt from_table "idf_typed_stages" column "abbr"
@@ -395,6 +410,76 @@ grammar PubidIso version "0.4.0" edition 2 {
395
410
  )
396
411
  }
397
412
 
413
+ # ---- historic TYPE-first ballot faces (decode-to-modern) ----
414
+ ## The DAM-family ballot token in TYPE position. Captured under its
415
+ ## own name: the supplement tables' captures stay reserved for the
416
+ ## on-base layers, and the ballot's stage is the one the decode keeps.
417
+ ballot_type_with_stage = {
418
+ (
419
+ (alt from_table "typed_stages_ballots" column "abbr")
420
+ ) as ballot_type_with_stage
421
+ }
422
+ ## An on-base stamp riding a ballot face ("/Amd 9:2001", "/Amd.1",
423
+ ## "/Am1:2006"): it carries the amendment's number and ballot date
424
+ ## while its own token letters are decode-away (matched uncaptured —
425
+ ## the ballot's stage comes from the TYPE token). Mirrors the three
426
+ ## supplement tails: a (glued or spaced) number with an optional
427
+ ## date, or a dot number.
428
+ ballot_stamp = {
429
+ slash supp_stage_abbrev
430
+ (
431
+ (
432
+ [space]
433
+ ((1*5digit)) as ballot_stamp_number
434
+ [
435
+ [space]
436
+ (
437
+ (colon / dash) [
438
+ (("19" / "20") 2*digit !digit [(dash 2*digit)] [(dash 2*digit)]) as ballot_stamp_date
439
+ / (("--" / "—")) as ballot_stamp_undated_marker
440
+ ]
441
+ )
442
+ ]
443
+ )
444
+ / ( dot (1*digit) as ballot_stamp_dot_number )
445
+ )
446
+ }
447
+ ## "ISO/IEC FDAM 1 14496-12:2004", "ISO/PDAM 19973-2": the ballot
448
+ ## token first, then the base kept whole. The TYPE-token number is
449
+ ## space-bounded on both sides — mandatory trailing space — so
450
+ ## "PDAM 19973-2" cannot read 19973 as the ballot number: with no
451
+ ## second space the group backtracks and 19973 parses in second_part.
452
+ ballot_supplement_identifier = {
453
+ prefix_with_copublishers
454
+ [space]
455
+ [slash]
456
+ ballot_type_with_stage
457
+ [ space ((1*digit)) as ballot_number space ]
458
+ [space]
459
+ [type_with_stage]
460
+ [space]
461
+ second_part
462
+ [ ballot_stamp ]
463
+ }
464
+ ## The space-attached ballot face: "ISO 15877-5:2009 FDAM 1" — the
465
+ ## base designation first, then the ballot token attached by a space.
466
+ ## The base front is inlined (not supplement_base_no_third): that
467
+ ## rule ends in base_third_part_edition, whose own optional space
468
+ ## would consume the separator the ballot token needs. No base
469
+ ## edition is served here — none of the stored or owner-cited ballot
470
+ ## faces carries one.
471
+ ballot_space_supplement_identifier = {
472
+ [ (("Guide" / "GUIDE") as type_with_stage_fr) space ]
473
+ [prefix_with_copublishers]
474
+ [space]
475
+ [slash]
476
+ [type_with_stage]
477
+ [space]
478
+ second_part
479
+ space ballot_type_with_stage
480
+ [ space ((1*digit)) as ballot_number ]
481
+ }
482
+
398
483
  # ---- directives: "ISO/IEC DIR 1", "ISO/IEC JTC 1 DIR", supplements,
399
484
  # bundled "ISO/IEC DIR 1:2022 + IEC SUP:2022" ----
400
485
  directives_publisher_subgroup = ("JTC 1") as subgroup
@@ -609,7 +694,8 @@ grammar PubidIso version "0.4.0" edition 2 {
609
694
  }
610
695
 
611
696
  ## The ISO identifier root: common-text twin form first, then the
612
- ## single spine.
697
+ ## single spine. The historic ballot faces go last — purely additive
698
+ ## branches (an input an earlier member parses keeps that parse).
613
699
  identifier = {
614
700
  bare_tc_document
615
701
  / nsb_iso_identifier
@@ -623,6 +709,8 @@ grammar PubidIso version "0.4.0" edition 2 {
623
709
  / supplement_identifier
624
710
  / joint_identifier
625
711
  / identifier_copublishers
712
+ / ballot_space_supplement_identifier
713
+ / ballot_supplement_identifier
626
714
  }
627
715
 
628
716
  entry identifier: identifier
@@ -712,6 +800,12 @@ bs3_undated_marker -> bs3_undated_marker (string)
712
800
  idf_supp_stage_iteration2 -> idf_supp_stage_iteration2 (string)
713
801
  idf_supp_date2 -> idf_supp_date2 (string)
714
802
  idf_supp_undated_marker -> idf_supp_undated_marker (string)
803
+ ballot_type_with_stage -> ballot_type_with_stage (string)
804
+ ballot_number -> ballot_number (string)
805
+ ballot_stamp_number -> ballot_stamp_number (string)
806
+ ballot_stamp_dot_number -> ballot_stamp_dot_number (string)
807
+ ballot_stamp_date -> ballot_stamp_date (string)
808
+ ballot_stamp_undated_marker -> ballot_stamp_undated_marker (string)
715
809
  }
716
810
 
717
811
  ## In-file grammar tests: run at compile time, embedded in the artifact,
@@ -728,9 +822,55 @@ bs3_undated_marker -> bs3_undated_marker (string)
728
822
  accept "ISO 5537|IDF 26"
729
823
  accept "ISO 4055/Amd 1:2021"
730
824
  accept "ISO/R 9:1962"
825
+ accept "ISO/IEC FDAM 1 14496-12:2004"
826
+ accept "ISO/PDAM 19973-2"
827
+ accept "ISO/IEC FPDAM 9075/1"
828
+ accept "ISO/IEC FPDAM 10373-6/Amd 9:2001"
829
+ accept "ISO/PDAM 19110:2005/Amd.1"
830
+ accept "ISO/IEC PDAM TR 9573-11/Amd.1"
831
+ accept "ISO/IEC PDAM 9541-1/Amd.4"
832
+ accept "ISO/IEC PDAM 2 10646:2012"
833
+ accept "ISO 15877-5:2009 FDAM 1"
834
+ accept "ISO 19776-2:2005/Am1:2006"
731
835
  reject "nonsense"
836
+ reject "ISO/PDAM"
837
+ reject "ISO/FDAM"
732
838
  example "ISO/IEC WD TS 25025" { publisher: "ISO" }
733
839
  example "JCGM 200:2008(F)" { publisher: "JCGM" }
840
+ example "ISO/IEC FDAM 1 14496-12:2004" {
841
+ ballot_type_with_stage: "FDAM"
842
+ ballot_number: "1"
843
+ number_with_part: "14496-12"
844
+ date: "2004"
845
+ }
846
+ example "ISO/PDAM 19973-2" {
847
+ ballot_type_with_stage: "PDAM"
848
+ number_with_part: "19973-2"
849
+ }
850
+ example "ISO/IEC FPDAM 10373-6/Amd 9:2001" {
851
+ ballot_type_with_stage: "FPDAM"
852
+ ballot_stamp_number: "9"
853
+ ballot_stamp_date: "2001"
854
+ }
855
+ example "ISO/PDAM 19110:2005/Amd.1" {
856
+ ballot_type_with_stage: "PDAM"
857
+ ballot_stamp_number: "1"
858
+ }
859
+ example "ISO/IEC FPDAM 9075/1" {
860
+ ballot_type_with_stage: "FPDAM"
861
+ number_with_part: "9075/1"
862
+ }
863
+ example "ISO 15877-5:2009 FDAM 1" {
864
+ ballot_type_with_stage: "FDAM"
865
+ ballot_number: "1"
866
+ number_with_part: "15877-5"
867
+ date: "2009"
868
+ }
869
+ example "ISO/IEC FDAM 19776-2:2005/Am1:2006" {
870
+ ballot_type_with_stage: "FDAM"
871
+ ballot_stamp_number: "1"
872
+ ballot_stamp_date: "2006"
873
+ }
734
874
  }
735
875
  }
736
876
 
@@ -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"
@@ -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"
@@ -158,3 +158,52 @@
158
158
  another reason — `DirectivesSupplement#supplement_publisher` is an
159
159
  `Iso::Components::Publisher` after `parse` and a `Pubid::Components::Publisher`
160
160
  after `from_hash` (11 supplements, 3 bundles).
161
+
162
+ - **Historic TYPE-first ballot faces decode to the canonical on-base slash
163
+ form (owner ruling 2026-11-12, Option B — IHO-style decode-to-modern;
164
+ SSOT `data/iso/historic-schemes.md`).** The DAM-family token in TYPE
165
+ position (`ISO/IEC FDAM 1 14496-12:2004`, `ISO/PDAM 19973-2`) and the
166
+ space-attached ballot face (`ISO 15877-5:2009 FDAM 1`) used to raise; the
167
+ grammar (v0.5.0) gained two additive branches at the END of the
168
+ identifier alternation, so every face an existing member parses keeps its
169
+ exact parse (replayed: 15,307 fixture inputs and 213,414 stored
170
+ docidentifier faces byte-identical on `to_s`/`to_urn`/`to_hash`/
171
+ `to_mr_string`). What to know before editing either side:
172
+ - **The ballot token captures as `ballot_type_with_stage`** (table
173
+ `typed_stages_ballots`: PDAM, DAM, DAmd, FDAM, FDAmd, FPDAM), NOT as
174
+ `supp_type_with_stage` — a shared name would make the builder's
175
+ supplement-prefix peel wrap a second layer, while the stored twins keep
176
+ ONE level. `Builder#build_ballot_supplement` assembles the single
177
+ amendment layer and nests the rest (prefix, copublishers, the base's
178
+ own `type_with_stage` — the `TR` of `PDAM TR 9573-11` — and
179
+ `number_with_part`/`date`) as `:base`.
180
+ - **`ballot_number` is space-bounded on both sides** (mandatory trailing
181
+ space in the grammar group) so `PDAM 19973-2` cannot read 19973 as the
182
+ ballot number: with no second space the group backtracks and 19973
183
+ stays in `second_part`. This is the base-whole guard — the IEC-flavor
184
+ hazard (#693's `ensure_base`) of a base part migrating into the
185
+ supplement slot cannot happen here; the legacy slash-part of
186
+ `FPDAM 9075/1` stays a base part (`9075-1`).
187
+ - **The number precedence is TYPE token → on-base stamp → implied "1"**;
188
+ the stamp's number/date capture as `ballot_stamp_*` while its token
189
+ letters match UNCAPTURED (Option B keeps no spelling — the ballot's
190
+ stage is the TYPE token's). The implied 1 is the owner's no-number
191
+ rule, applied by `Builder#apply_implied_amendment_number` on EVERY
192
+ builder path (parse, URN, bundle members) for `Identifiers::Amendment`
193
+ with a nil number and `typed_stage.code` ∈ `{pdam, damd, fdamd, fpdam}`
194
+ — by `#code`, NOT `#stage_code` (PDAM's stage_code is `:cd`), and
195
+ `to_sym`-ed (the bound map delivers strings). The published stage
196
+ (abbr `Amd`) is out of scope: a numberless `ISO 1234/Amd` stays nil.
197
+ - **The `Am` spelling** joined `typed_stages_supplements` (after the Amd
198
+ entries) and the published stage's abbr list, so `/Am1:2006` parses as
199
+ an alias of `/Amd 1` through the EXISTING supplement branch and the
200
+ ballot stamp accepts it the same way. `detect_stage_format_long` now
201
+ treats an original spelling that is a prefix of the long abbr as the
202
+ long family, so `Am` renders canonical `Amd`; `AMD` still renders short
203
+ (facets keep spelling on render), `Amd.` still renders `Amd`.
204
+ - **The decode is one-way**: parse-in only, canonical renders emit the
205
+ on-base slash face, and a ballot face == its canonical spelling
206
+ (original_abbr is equality-ignored). Locked by
207
+ `spec/pubid/iso/ballot_face_decode_spec.rb` (all 13 SSOT anchors, the
208
+ no-number rule, base-whole guards, Am alias, stored-twin agreement, and
209
+ the website-face-vs-stored-face drift rows the SSOT §5 records).