pubid 2.0.4 → 2.0.6

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/lib/pubid/bipm.rb CHANGED
@@ -6,13 +6,19 @@ module Pubid
6
6
  extend Pubid::PrefixesSupport
7
7
 
8
8
  # Leading tokens a printed BIPM identifier can start with: the 13 committee
9
- # acronyms, "Metrologia" (journal), and "BIPM" (the SI Brochure). JCGM is
10
- # deliberately excluded — it is owned by the separate Pubid::Jcgm flavor,
11
- # so BIPM never emits pubid:jcgm:* rows. French full-name forms that lead
12
- # with a type word ("Recommandation 1 du CCL …") are omitted per the
9
+ # acronyms, "Metrologia" (journal), "BIPM" (the publisher prefix the SI
10
+ # Brochure / MEP / guide families print), and the two SI-family series
11
+ # tokens that can lead a reference on their own — "SI Brochure 9e …" and
12
+ # "SI MEP 10" are printed without the leading BIPM by consumers that strip
13
+ # it (relaton's Bibliography#search among them), so both spellings must
14
+ # route. JCGM is deliberately excluded — it is owned by the separate
15
+ # Pubid::Jcgm flavor, so BIPM never emits pubid:jcgm:* rows. French
16
+ # full-name forms that lead with a type word ("Recommandation 1 du CCL …")
17
+ # and the generic French word "Rapport" are omitted per the
13
18
  # "exclude ambiguous tokens" policy.
14
19
  PREFIXES = %w[
15
20
  BIPM Metrologia
21
+ SI\ Brochure SI\ MEP
16
22
  CIPM CGPM JCRB CCTF CCQM CCT CCL CCAUV CCU CCM CCEM CCPR CCRI
17
23
  ].freeze
18
24
 
@@ -91,3 +91,7 @@ The general fix (`metanorma__pubid__exclude-recursion-depth` hand-off) promotes
91
91
  Verified: `spec/pubid/supplement_date_attributes_spec.rb` exercises the mechanism directly; `spec/pubid/bsi/parse_gaps_spec.rb`, `match_primitives_spec.rb` and `adoption_base_spec.rb`, and `spec/pubid/cen_cenelec/relaton_blockers_spec.rb`'s 10 pinned `:supplement_year` cases, all pass unchanged. Because the new logic only fires when `supplement_date_attributes` is non-empty, every other flavor (including ISO/IEC, which deliberately keep a supplement's year in `date` with no protection — see `lib/pubid/cen_cenelec/CLAUDE.md`) is untouched.
92
92
 
93
93
  **Open question, not resolved here: `#dated_version_of?`/`#draft_of?` on a bare BSI supplement.** Both compare `exclude(:date, :year[, …])`, which — like every other bare `:date`/`:year` exclusion — now protects a supplement's own date unless `:supplement_year` is also passed. Two standalone `BS 7273-4:2015+A1:2021`/`...+A1:2022` objects were `dated_version_of?` each other before this branch (BSI's old `year`-attribute dodge never protected against `exclude(:year)`) and are not after. **Whether that's the right answer is genuinely unclear and deliberately left open pending feedback** — one reading is that this just makes BSI consistent with CEN/CENELEC, which already behaved this way before this branch (its pre-existing per-flavor `#exclude` override had the same protection); the other is that these two generic relational predicates should be widened to accept a `:supplement_year`-aware exclusion set so they can still recognize two differently-dated editions of the *same supplement* as related. (The predicates switched from `matches?(other, ignore: …)` to the equivalent `exclude(…)` comparison so NIST's asymmetric `#matches?` override no longer breaks them; BSI behavior is unchanged.) Locked (as current behavior, not as a verdict) by `spec/pubid/supplement_date_attributes_spec.rb`.
94
+
95
+ ## The `BSI `-prefixed spelling of the BS series parses (pubid#644)
96
+
97
+ `BSI BS 5950` used to be a hard parse failure: the strip in `Pubid::Bsi::Identifier.parse` (`strip_bsi_prefix`) only dropped the `BSI ` prefix before PAS/PD/DD/BIP (`BSI_PREFIXED_SERIES`), and `BS` was deliberately absent because **BSI never prints `BSI` in front of its own BS series**. That remains true of the **render** — `to_s(with_publisher: true)` still answers `BS 5950` for a British Standard, and the `BSI` prefix is re-added only for `BSI_PREFIXED_SERIES` — but **external references do cite the series as `BSI BS 5950`**, so the prefixed spelling is now accepted on **parse**: `STRIPPABLE_SERIES = BSI_PREFIXED_SERIES + ["BS"]` drives the strip, and the input normalizes to the canonical `BS 5950` (a normalizing parse — never a byte-exact fixture). Two lists, one line apart, because the parse and render halves of the same question legitimately differ: `BSI_PREFIXED_SERIES` answers "which series does BSI *print* its publisher in front of", `STRIPPABLE_SERIES` answers "which series does an *external* citation print it in front of". Locked by `spec/pubid/router_printed_forms_spec.rb` (routing + canonical render + stable reparse).
@@ -37,7 +37,16 @@ module Pubid
37
37
  # itself and is never preceded by it.
38
38
  BSI_PREFIXED_SERIES = %w[PAS PD DD BIP].freeze
39
39
 
40
- BSI_PREFIX = /\ABSI\s+(?=(?:#{BSI_PREFIXED_SERIES.join('|')})\b)/
40
+ # Series tokens whose `BSI `-prefixed spelling parses (the strip below
41
+ # drops the prefix before the baked grammar runs). That is the
42
+ # BSI_PREFIXED_SERIES plus `BS`: `BS` is never PRINTED with the prefix
43
+ # by BSI itself, but external references do cite the British Standard
44
+ # series as `BSI BS 5950` (pubid#644), so the prefixed spelling is
45
+ # accepted on parse and normalizes to the canonical `BS 5950`. Render
46
+ # never re-adds the prefix for `BS` — only BSI_PREFIXED_SERIES does.
47
+ STRIPPABLE_SERIES = (BSI_PREFIXED_SERIES + %w[BS]).freeze
48
+
49
+ BSI_PREFIX = /\ABSI\s+(?=(?:#{STRIPPABLE_SERIES.join('|')})\b)/
41
50
 
42
51
  # `BSI PAS 2031:2019` is the external spelling of `PAS 2031:2019`. The
43
52
  # prefix is not part of the identity, so it is dropped before the baked
@@ -1812,7 +1812,12 @@ module Pubid
1812
1812
  # Optional attributes (excluding part/subpart since they're in code)
1813
1813
  extract_optional(parsed, attributes, :edition)
1814
1814
  extract_optional(parsed, attributes, :revision)
1815
- extract_optional(parsed, attributes, :rev_sep)
1815
+ # A printed separator before the revision is notation residue: once the
1816
+ # revision is repositioned to the canonical face (and every corpus
1817
+ # expectation for these variants renders it absent), carrying rev_sep
1818
+ # would make the variant hash differ from its canonical equivalent.
1819
+ # Keep it only for a bare "Rev" with no number.
1820
+ extract_optional(parsed, attributes, :rev_sep) unless attributes[:revision] =~ /\A\d/
1816
1821
  attributes[:rev_bare] = true if parsed[:rev_bare]
1817
1822
 
1818
1823
  # Month/day - always extract if present (but not if already extracted from year)
@@ -5,3 +5,5 @@ IETF readiness for the unified relaton index.
5
5
  These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/ietf/` or `spec/pubid/ietf/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
6
 
7
7
  - **IETF readiness for the unified relaton index (`relaton-data-ietf`)**: relaton is collapsing RFCs, the RFC sub-series (BCP/STD/FYI) and Internet-Drafts into **one** index keyed on the structured pubid hash (`pubid_class: ::Pubid::Ietf::Identifier`). That index is **all-or-nothing** — `Relaton::Index::FileIO#deserialize_id` raises on the *first* unparseable row and `#load_index` then rejects the whole index — so one bad id out of 176,862 means no consumer resolves *any* IETF reference. Four changes, all verified against the published corpora (`relaton-data-{rfcs,rfcsubseries,ids}`, branch `v2`). **(1) `draft_rest` accepts `.` and `A-Z`** (`match['a-zA-Z0-9+_.\-']`): exactly 50 of the 166,740 published draft ids failed without them (21 dotted `draft-ietf-pilc-2.5g3g-12`, 29 uppercase `draft-chapin-clnp-ISO8473-00`, 0 both), and a character census confirms the class now covers the corpus **exactly** — the only chars outside `[a-z0-9-]` anywhere in it are `. _ +` and `A-Z`. Widening is safe because the top-level alternation is keyed on the leading token (`RFC`/`BCP|STD|FYI`/`draft-`), so a wider draft tail can never make another branch match; `Builder#split_draft_version` needed no change (it inspects only the last three characters). The set stays **closed** — a space, `/` or `&` in a slug is crawler junk and still fails (pinned by negative examples). **(2) Zero-padded ids parse, grammar-level**: `rfc-index.xml` writes sub-series membership as `<is-also><doc-id>STD0066</doc-id></is-also>` (369 padded doc-ids) and RFCs as `RFC0001`, and relaton#109 forbids relaton normalizing them itself. The space is `.maybe` on both the `rfc` and `subseries` rules and `Builder#unpad` strips the pad (`/\A0+(?=\d)/`, a look-ahead so an all-zero number keeps its last digit), so `STD0066`/`STD 0066`/`STD66` all render the canonical `STD 66`. `unpad` is applied unconditionally, so the **spaced** padded form is normalized too — `RFC 0001` previously parsed and rendered back verbatim, and now renders `RFC 1` with a correspondingly changed hash and URN. That is deliberate (one canonical spelling per document) and has **zero** effect on the published corpora, which contain no padded row. **This is a normalizing parse, so the padded spellings must NEVER go into the byte-exact `spec/fixtures/ietf/identifiers/pass/`** — they live in `spec/pubid/ietf/zero_padded_spec.rb`, the BIPM `CIPM/2005-06(REV)` precedent. **(3) The Internet-Draft slug moved from `name` into `number`, and `series` became derived**: relaton bsearches on `id.root.number.to_s`, so with the slug in `name` all 166,740 drafts keyed to `""` and the narrowing bought nothing (with the slug: 43,564 buckets, median 2, worst 101). `name` is **dropped entirely** (no alias); the slug — leading `draft-` included — is the key, deliberately shared by a draft's unversioned aggregator row and every one of its versions so a document and its variants cluster. `series` is likewise **not stored**: `_type: pubid:ietf:std` already encodes it, so `Bcp`/`Std`/`Fyi` expose a `SERIES` constant + a plain `#series` reader (safe — `::Pubid::Identifier` declares no `series` attribute and nothing generic reads `.series`) and `build_subseries` stops passing it. The whole serialized vocabulary is now **three keys**: `_type` + `number` always, `version` only on a versioned draft — a sub-series row (`{_type, number}`) has the same shape as an RFC's. **The attributes live on the five LEAVES, not the shared `Pubid::Ietf::Identifier`**, via the `Identifiers::Serialization` mixin (`number` + the `key_value` block; `InternetDraft` merges its own `version` mapping on top — lutaml combines a mixin-installed block with a second in-class one, the ITU precedent). This is the IEEE `number` determinism landmine: a `:string` redefinition of the parent's `Components::Code number` on a class the leaves *inherit* from resolves nondeterministically under multi-flavor load. It was invisible while drafts left `number` nil; it is load-bearing now. The base deliberately carries **no** `key_value` block — one there would be inherited-and-merged by every leaf. `spec/pubid/ietf/root_number_spec.rb` is the tripwire (asserts `number` is a `String`, the exact keys, the exact `to_hash` key sets) and **must run under the full `rake` suite** to mean anything. **(4) Round-trip is validated here because relaton structurally cannot**: `FileIO#id_supported?` **skips** its `to_hash`/`from_hash` check whenever the object is a concrete subclass (`return true unless obj.instance_of?(@pubid_class)`), and *every* IETF id is a subclass — so a lossy round-trip would pass validation silently and produce wrong lookups. Three layers: `fixtures_spec.rb` is now **zero-failure** (was a 90% threshold — wrong for an all-or-nothing index) and checks `to_s` byte-exactness, `from_hash(to_hash)` fidelity in both `to_s` and `to_hash`, **and** a non-empty `root.number` for every fixture line; the fixtures grew to ~2,500 lines (the complete 365-row sub-series corpus, a 139-id structural edge set, a deterministic 1-in-84 draft sample — each file's header records the regeneration command); and `spec/pubid/ietf/corpus_round_trip_spec.rb` runs the same four checks over all 176,862 published ids, **opt-in** via `PUBID_IETF_CORPUS=<dir with the three relaton-data checkouts> bundle exec rake test:corpus_ietf` (self-skips otherwise; ~100s; verified 0 failures). **relaton note — the indexes must be regenerated, there is no alias**: `name` and `series` are gone as serialized keys, so a pre-`number` row (`{_type, name, version}`) deserializes with **`number` nil** and no error at all — lutaml ignores unknown keys, and relaton's subclass skip means `id_supported?` won't catch it either, leaving every draft keyed under `""`. Rendering is therefore the loud failure: `Renderer#require_number!` raises `ArgumentError` on a nil/empty `number` rather than returning `nil` or `"-02"`, and the renderer's sub-series arm is an explicit `when Bcp, Std, Fyi` with an `else raise` instead of a catch-all (a bare `Pubid::Ietf::Identifier` has no `series` reader). Pinned by the "legacy `name`-shaped hash" block in `root_number_spec.rb`. **Pre-existing bug fixed in passing**: `UrnParser`'s bare `Errors::ParseError` does not resolve — `Errors` lives under `Pubid::UrnParser` (the module), which is neither in the lexical scope of `Pubid::Ietf::UrnParser` nor an ancestor of `Base` — so an invalid URN raised `NameError` instead, and the spec's `raise_error(StandardError)` accepted it. Now fully qualified as `Pubid::UrnParser::Errors::ParseError` (the adobe/easc/gost/iala convention) with the spec asserting that exact class. **`Pubid::Iec::UrnParser` has the identical bug and is untouched here.** Also: `Identifier.parse` gained the inline `MAX_INPUT_LENGTH` guard — relaton reaches that class-level funnel directly through `pubid_class:`, bypassing `Pubid::Ietf.parse` — and IETF was added to the three cross-flavor tables it was missing from (`identifier_roundtrip_spec`, `uniform_identifier_handle_spec`, `redos_guard_spec`), which is what exercises the leaf `number` under real multi-flavor load. (hand-off: ietf-index-readiness.)
8
+
9
+ - **`IETF` is a prefix (pubid#644)**: `PREFIXES` gained the publisher token itself. `strip_prefix` (`lib/pubid/ietf/identifiers/base.rb`) already parsed the external spellings `IETF RFC 8341` / `IETF I-D draft-…`, and `to_s(with_publisher: true)` prints that same `IETF `-prefixed form — but without "IETF" in the prefix table the flavor had **no claim** on the string, and the router's non-exact fallback (`IETF RFC 2119` normalizes to the canonical `RFC 2119`) is reserved for prefix owners. So `Pubid.parse("IETF RFC 2119")` raised while `Pubid::Ietf.parse` succeeded. With the prefix registered, the router routes it (fallback path) and the `with_publisher: true` render reproduces the input byte for byte. Bare `RFC 2119` keeps routing exactly as before (exact round trip). Locked by `spec/pubid/router_printed_forms_spec.rb`.
data/lib/pubid/ietf.rb CHANGED
@@ -11,8 +11,11 @@ module Pubid
11
11
  extend Pubid::PrefixesSupport
12
12
 
13
13
  # Leading tokens a printed IETF id can begin with. "draft" covers the
14
- # "draft-<slug>" Internet-Draft form. No joint/co-publication prefixes.
15
- PREFIXES = ["RFC", "BCP", "STD", "FYI", "draft"].freeze
14
+ # "draft-<slug>" Internet-Draft form. "IETF" covers the external
15
+ # publisher-prefixed spelling ("IETF RFC 2119", "IETF I-D draft-…") that
16
+ # the flavor's own strip_prefix parses and `to_s(with_publisher: true)`
17
+ # prints back. No joint/co-publication prefixes.
18
+ PREFIXES = ["IETF", "RFC", "BCP", "STD", "FYI", "draft"].freeze
16
19
 
17
20
  autoload :Builder, "#{__dir__}/ietf/builder"
18
21
  autoload :Identifier, "#{__dir__}/ietf/identifiers/base"
@@ -1,98 +1,174 @@
1
1
  # IHO flavor notes
2
2
 
3
3
  IHO (International Hydrographic Organization) identifiers: the
4
- S/P/M/B/C letter series, their parg-driven parse, and the series
5
- components introduced for relaton/relaton#407.
4
+ S/P/M/B/C letter series, their parg-driven parse, and the full
5
+ component model (edition, supplement, part, annex, appendix +
6
+ subnumber, volume, issue, with-year qualifier) from the owner rulings
7
+ of 2026-11-03.
6
8
 
7
9
  Read this before you change `lib/pubid/iho/` or `spec/pubid/iho/`. The
8
10
  root `CLAUDE.md` keeps the cross-flavor contract that every flavor
9
11
  obeys.
10
12
 
11
- ## Parsing runs on the parg artifact
13
+ ## Parsing runs on the parg artifact, through typed bindings
12
14
 
13
15
  There is no `parser.rb` and no Parslet anywhere in this flavor: parse
14
- goes `Pubid::Parg::Backend.parse(:iho, ...)` over the baked artifact
15
- `data/parg/iho.json`. The artifact is baked from the flavor grammar
16
- source `grammars/iho.parg` in the pubid-grammar repository (commit
17
- 840b2b1d "Retire the last live Parslet parsers" ports the original
18
- 13-rule Parslet grammar byte-identically).
19
-
20
- **Drift warning:** the artifact on this branch carries the
21
- relaton/relaton#407 tail rules (year / volume / issue), while the
22
- checked-in `grammars/iho.parg` in pubid-grammar still has only the
23
- version tail. Until the updated grammar source lands in pubid-grammar,
24
- the artifact is ahead of its source and not reproducible from it —
25
- the same artifact-ahead-of-source state the root notes describe under
26
- grammar-artifact drift. Do not re-bake from the stale source, and do
27
- not hand-edit the artifact; land the grammar first.
28
-
29
- ## Series components (relaton/relaton#407)
30
-
31
- The B-7, P-1, P-5, P-6 series are continuously updated publications
32
- whose trailing component is not an a.b.c edition: P-5/P-6 (and B-7)
33
- are identified by **year**, P-1 (the International Hydrographic
34
- Review) by **volume** with an optional **issue**. The grammar tail
35
- picks exactly one discriminator, so only one of `version` / `year` /
36
- `volume` is ever present, with `issue` only beside a volume:
37
-
38
- - `IHO P-5 2024` — year is a plain `:string` scalar, 19xx/20xx only
39
- (`IHO P-5 1850` and the two-segment `IHO B-12 3.0` reject).
40
- - `IHO P-1 Vol. 30 (2)` — volume/issue scalars; `Issue 2` is an input
41
- alias whose canonical print is `(2)`.
42
- - `IHO S-44 5.0.0` — the dotted `version` stays for the edition-bearing
43
- series and legacy `1.0.0` placeholder rows.
44
-
45
- All three are ordinary lutaml attributes mapped through the flavor's
46
- `key_value` block — no converters, no hand-rolled serialization.
47
-
48
- **Identity semantics:** the components are identity-bearing in `==` /
49
- `matches?`; a bare reference (`IHO P-5`) leaves all four nil and
50
- wildcards the series (`spec/pubid/partial_ref_spec.rb` locks the
51
- omitted-key set). The volume/issue pair is a scalar **cluster**:
52
- `exclude(:volume)` nils the issue with it, so a reference that omits
53
- the issue still matches a hit that carries one — the same
54
- additive-alias shape the base applies to `:year -> :date`. A lone
55
- scalar `:year` needs no override; the base nils a declared `:year`
56
- directly.
57
-
58
- **`all_parts_edition_keys` needs no override:** the default
59
- `%i[date year edition version]` already covers `:year` and `version`,
60
- and volume/issue only ride part-less ids, which cannot join
61
- `AllParts` (`validate_member!` demands a `part`). The audit case in
62
- `spec/pubid/all_parts_edition_keys_audit_spec.rb` covers the
63
- version-bearing S-series collapse.
64
-
65
- **URN conventions:** only the dotted `version` is an untagged
66
- segment. A year is tagged `y.N` (`urn:iho:p:5:y.2024`); a volume is a
67
- `vol.N` segment with an optional `issue.N` segment after it
68
- (`urn:iho:p:1:vol.30:issue.2`). The URN parser walks the tagged
69
- segments as a run and hands the rebuilt text back through the flavor
70
- parse, so a URN round-trip exercises the same grammar as a string.
71
- **The number is not escaped and can hold a colon** (`IHO S-158:100`
72
- gives `urn:iho:s:158:100`), so the parser joins every untagged segment
73
- that is not an a.b.c version back into the number. That is why the
74
- year must carry a tag: with the bare year that pubid 2.0.2 emitted,
75
- `IHO S-158:2024` and `IHO S-158 2024` both gave `urn:iho:s:158:2024`,
76
- and no colon number round-tripped at all. A 2.0.2 year URN such as
77
- `urn:iho:p:5:2024` now decodes as the number `5:2024`; it was emitted
78
- only by 2.0.2. A tagged segment with an unknown tag raises
79
- `Pubid::Errors::UrnParseError`.
16
+ goes `Pubid::Parg::Model.bound(:iho, ...)` — the artifact's
17
+ `bindings identifier` block delivers every component as an
18
+ attribute-named scalar, and `Builder` only dispatches the series
19
+ letter to its concrete class and pads one grammar-level alias (the
20
+ S-100 `v1`/`v5.1` shorthand fills `edition` as `1.0.0`/`5.1.0`). The
21
+ artifact is baked from `grammars/iho.parg` in pubid-grammar; the
22
+ vendored twin `data/parg/iho.parg` is the embedded source's provenance
23
+ copy and must move together with `data/parg/iho.json` (the ISO
24
+ pattern).
25
+
26
+ **Bake pin:** the artifact must be baked at parsanol 1.3.126 (the
27
+ grammar repo's `scripts/bake-one.rb` forces the gem version and
28
+ verifies the `compiler_version` stamp). 1.3.127 is broken fleet-wide
29
+ (parsanol-ruby#434 — its loud-loss check matches the binding PATH
30
+ instead of the CAPTURE name), so both repos pin below it: the grammar
31
+ repo's CI moved 1.3.121 → 1.3.126 with the component-model PR.
32
+
33
+ ## The component model (owner rulings, 2026-11-03)
34
+
35
+ Every component is a FIRST-CLASS SEMANTIC component: a typed `:string`
36
+ attribute, independently addressable, URN-encodable and matchable —
37
+ never a string suffix. All of them live flat on `Pubid::Iho::Identifier`
38
+ (mapped through the flavor `key_value` block, no converters):
39
+
40
+ | component | attribute | canonical face | parse-only aliases |
41
+ |-------------|--------------------|-------------------|------------------------------|
42
+ | edition | `edition` | `1.0.0` (bare) | `v1`/`v5.1` (padded) |
43
+ | supplement | `supplement` | `Sup 1` | `Suppl 1` |
44
+ | part | `part` | `Part A`/`Part 1`/`Part S-102` | `S-5-B`, `S-100-1`, `S-158-S-102` (compact) |
45
+ | annex | `annex` | `Annex A` | — |
46
+ | appendix | `appendix` | `Appendix D` | `App`, `Ap.` |
47
+ | subnumber | `appendix_subnumber` | `-2` (joined) | — |
48
+ | volume | `volume` | `Vol. 30`/`Vol. 2024` | `/21` (slash chain), bare `2024` |
49
+ | issue | `issue` | `(2)` | `Issue 2` |
50
+ | year (qualifier) | `year` | `(2024)` | — |
51
+ | component edition | `<kind>_edition` (+ `<kind>_year`) | `<kind> x.y.z (2024)` | — |
52
+
53
+ - **Two main syntaxes:** `IHO S-100 1.0.0` (dated = edition-specific)
54
+ and `IHO S-100` (undated = edition-non-specific). The official IHO
55
+ edition is the dotted x.y.z WITHOUT a year; a year beside an edition
56
+ is the with-year qualifier, never a year-keyed docidentifier. The
57
+ canonical part face is edition-before-part: `IHO S-100 1.0.0 Part 2`.
58
+ - **The part's compact face is hyphenated** (`IHO S-5-B`,
59
+ `IHO S-100-1`) — it parses AND renders via `to_s(compact: true)`.
60
+ Gluing without a separator is disallowed as a form: it cannot extend
61
+ to numbered parts unambiguously (`S-1001`). The four lettered glued
62
+ bases in the records (S-5A, S-5B, S-8A, S-8B) are LEGACY parse-only
63
+ aliases, never rendered; the owner rules later whether to reject
64
+ them. The dash-digit suffix that used to join the number (`P-6-3` as
65
+ number "6-3") is now the part component.
66
+ - **One serial locator: the volume.** P-1 and P-6 are volume-keyed;
67
+ the yearbook year IS a volume (`IHO P-5 Vol. 2024`, short
68
+ `IHO P-5/2024`). The bare year (`IHO P-5 2024`) is a legacy alias
69
+ that binds INTO `volume` — `year` never carries a serial locator.
70
+ - **The with-year qualifier** (`IHO S-100 5.0.0 Part 2 (2024)`) is the
71
+ only base `year` face, always parenthesized beside an edition.
72
+ - **Component-edition duality** (owner: "this can happen with any
73
+ independently publishable artifacts"): supplement, part, annex,
74
+ appendix and volume each carry an optional `<kind>_edition` (+
75
+ `<kind>_year`) of their own, MUTUALLY EXCLUSIVE with the base edition
76
+ context — within-edition (`IHO P-6 1.0.0 Vol. 2`, the component rides
77
+ the base's edition) vs separately-published (`IHO P-6 Vol. 2 1.0.1`,
78
+ `IHO S-98 Part A 1.0.0`, `IHO S-158 Part S-102 0.2.0`; the base is
79
+ cited edition-less). The builder raises when both appear. Every
80
+ trailing edition now follows a component, so the historical
81
+ edition-last spellings reinterpret as component editions.
82
+ - **Series-reference parts**: a check standard applied to another IHO
83
+ series cites it as its part — `IHO S-158 0.2.0 Part S-102`, compact
84
+ `IHO S-158-S-102` (the part value vocabulary is letter | number |
85
+ `[SPMBC]-<digits>`). The wild glued `S-158-102` stays a plain number
86
+ part: the syntax cannot distinguish it from `S-100-1` = Part 1, and
87
+ normalizing it needs an IHO series table (owner call pending).
88
+ - **Supplement canonical face is `Sup`** (the M-2 spelling — why save
89
+ two characters when Annex and Appendix stay full-length); `Suppl`
90
+ (the S-66 wild spelling) is the parse-only alias, never rendered.
91
+ - **Appendix canonical face is `Appendix`**; the subnumber keeps its
92
+ own component (`Appendix D` + `appendix_subnumber` "2" render joined
93
+ as `Appendix D-2`).
94
+
95
+ The compact face (`compact: true`) folds the code-adjacent components
96
+ onto the code: supplement and part hyphenated, volume as the slash
97
+ chain — `IHO M-2-2`, `IHO S-100-2 5.0.0`, `IHO P-5/2024`.
98
+
99
+ ## Identity semantics
100
+
101
+ - The components are identity-bearing in `==`/`matches?`; a bare
102
+ reference (`IHO P-5`) leaves them all nil and wildcards the series
103
+ (`spec/pubid/partial_ref_spec.rb` locks the omitted-key set:
104
+ edition/year/volume/issue).
105
+ - Scalar **clusters**: `exclude(:volume)` nils `issue` with it, and
106
+ `exclude(:appendix)` nils `appendix_subnumber` — a reference that
107
+ omits the finer component still matches a hit that carries one (the
108
+ base's additive `:year -> :date` alias shape).
109
+ - `all_parts_edition_keys` needs no override: the default
110
+ `%i[date year edition version]` already covers `:year` and
111
+ `:edition`.
112
+
113
+ ## URN scheme — positional, containment-ordered
114
+
115
+ The URN mirrors the containment tree, one semantic atom per segment;
116
+ the segment grammar table lives in the grammar header
117
+ (`grammars/iho.parg`) and is implemented by
118
+ `urn_generator.rb`/`urn_parser.rb`:
119
+
120
+ urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
121
+ [:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
122
+ [:year-<yyyy>]
123
+
124
+ - Markers are the lowercased face tokens (`ed-` follows ISO's
125
+ RFC 5141 precedent); letters are case-preserved.
126
+ - `ed-` and `year-` are POSITIONAL: the edition attaches to the node it
127
+ follows — the base when no component precedes it, else that
128
+ component's own edition (`vol-2:ed-1.0.1` = separately-published
129
+ volume). A `year-` belongs to a component only when it follows that
130
+ component's own edition; otherwise it is the base with-year
131
+ qualifier.
132
+ - Containment: part ⊃ annex ⊃ appendix ⊃ subnumber (bare digit atom,
133
+ only directly after its appendix); vol ⊃ issue.
134
+ - The number occupies every leading bare segment up to the first
135
+ marker, so a colon number (`S-158:100` → `urn:iho:s:158:100`)
136
+ round-trips without escaping. A colon number and a volume stay
137
+ distinct because the volume is marker-tagged (`urn:iho:s:158:2024`
138
+ vs `urn:iho:s:158:vol-2024` — the #637 hazard, kept under the
139
+ positional scheme).
140
+ - The yearbook year emits as the volume (`urn:iho:p:5:vol-2024`);
141
+ `year-` exists only for the standards qualifier.
142
+ - The parser validates every marker value against its own pattern and
143
+ rejects unknown/reordered/duplicate/junk segments with
144
+ `Pubid::Errors::UrnParseError` (the ISO urn_parser strictness); it
145
+ builds through `Builder.build` from the decoded attributes, so URN
146
+ decode and string parse share one construction path.
147
+
148
+ **Breaking URN migrations this scheme carries** (legacy forms stay
149
+ decodable, never emitted): the dotted keys (`part.4a`, `suppl.1`,
150
+ `ap.A`, `annex.A`, `vol.30`, `issue.2`, `appendix.D-2`) and the bare
151
+ a.b.c edition segment move to the marker segments; the #635/#637
152
+ `y.2024` tag decodes into the **volume** (`urn:iho:p:5:y.2024` now
153
+ re-emits as `urn:iho:p:5:vol-2024`). relaton-data-iho needs a re-crawl
154
+ (it is already legacy pre-pubid-2 shape); the frozen testsuite corpus
155
+ rows are re-frozen in the coupled corpus round.
80
156
 
81
157
  ## Fixtures run through the classify pipeline
82
158
 
83
159
  `spec/fixtures/iho/identifiers/full/identifiers.txt` is the input
84
- corpus; `rake "validation:classify[iho]"` regenerates `pass/` and
85
- `fail/` from it (the classifier deletes pass/fail first — never edit
86
- them by hand, edit `full/` and reclassify). Before the relaton#407
87
- branch the pass files were hand-curated with **no** `full/` tree, the
88
- OASIS hazard: a classify run would have wiped 131 entries and rebuilt
89
- nothing. The classifier's per-flavor class detection has an `iho`
90
- case, so fail entries land in a named class file.
160
+ corpus (seeded from the hand-curated pass files, the #407 serial
161
+ forms and the component-model matrix off the IHO publications
162
+ spreadsheet); `rake "validation:classify[iho]"` regenerates `pass/`
163
+ and `fail/` from it (the classifier deletes pass/fail first — never
164
+ edit them by hand, edit `full/` and reclassify). The normalizing
165
+ parses (Suppl→Sup, Ap.→Appendix, glued→expanded, bare year→Vol.,
166
+ v-shorthand→dotted) are recorded as `!input!rendered` entries.
91
167
 
92
168
  ## relaton-side follow-ups (not this repo)
93
169
 
94
- relaton/relaton#407 items 3-4 stay open on the relaton side:
95
- `Relaton::Iho::Bibliography#pubid_match?`/`version_key` need the new
96
- component, relaton-cli's `MachineIndex::REFINABLE_KEYS` needs
97
- `year`/`volume` beside `version`/`edition`, and the pubid pin bumps
98
- in step with the relaton-data-iho re-key.
170
+ `Relaton::Iho::Bibliography#pubid_match?`/`version_key` need the
171
+ edition rename (the serialized key moved `version` → `edition`) and
172
+ the volume unification; relaton-cli's `MachineIndex::REFINABLE_KEYS`
173
+ needs `edition`/`volume` beside the retired `version`/`year` keys; the
174
+ pubid pin moves in step with the relaton-data-iho re-key.
@@ -2,25 +2,35 @@
2
2
 
3
3
  module Pubid
4
4
  module Iho
5
- # Build IHO identifier objects from the parg artifact parse tree.
5
+ # Build IHO identifier objects from the grammar's typed bound map.
6
+ #
7
+ # Parse goes through {Pubid::Parg::Model.bound} — the artifact's
8
+ # `bindings identifier` block delivers every component as an
9
+ # attribute-named scalar (type, number, part, annex, appendix,
10
+ # appendix_subnumber, supplement, edition, edition_shorthand, year,
11
+ # volume, issue) — so the builder only dispatches the series letter
12
+ # to its concrete class and resolves the one grammar-level alias:
13
+ # the S-100 "v1"/"v5.1" edition shorthand pads to the official
14
+ # dotted x.y.z edition ("1" -> "1.0.0", "5.1" -> "5.1.0").
6
15
  class Builder
16
+ ATTRIBUTES = %i[number appendix appendix_subnumber part annex
17
+ supplement year volume issue
18
+ part_edition part_year supplement_edition
19
+ supplement_year annex_edition annex_year
20
+ appendix_edition appendix_year volume_edition
21
+ volume_year].freeze
22
+ # The independently-publishable components' own editions; the
23
+ # base edition context is mutually exclusive with them (owner
24
+ # ruling on the two publication modes).
25
+ COMPONENT_EDITIONS = %i[part_edition supplement_edition annex_edition
26
+ appendix_edition volume_edition].freeze
27
+
7
28
  def build(parsed)
8
29
  hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
9
- type_letter = stringify(hash[:type])
10
-
11
- attrs = {
12
- number: stringify(hash[:number]),
13
- appendix: stringify(hash[:appendix]),
14
- part: stringify(hash[:part]),
15
- annex: stringify(hash[:annex]),
16
- supplement: stringify(hash[:supplement]),
17
- version: stringify(hash[:version]),
18
- year: stringify(hash[:year]),
19
- volume: stringify(hash[:volume]),
20
- issue: stringify(hash[:issue]),
21
- }.compact
22
-
23
- Pubid::Iho.identifier_klass_for_type_letter(type_letter).new(**attrs)
30
+ attrs = stringified_attrs(hash)
31
+ assert_edition_mode!(attrs)
32
+ klass = Pubid::Iho.identifier_klass_for_type_letter(stringify(hash[:type]))
33
+ klass.new(**attrs)
24
34
  end
25
35
 
26
36
  def self.build(parsed)
@@ -29,6 +39,41 @@ module Pubid
29
39
 
30
40
  private
31
41
 
42
+ # The two publication modes are mutually exclusive: an identifier
43
+ # is either within-edition (a base edition its components ride) or
44
+ # separately-published (a component carries its own edition and
45
+ # the base is cited edition-less).
46
+ def assert_edition_mode!(attrs)
47
+ return unless attrs[:edition]
48
+ return unless COMPONENT_EDITIONS.any? { |key| attrs[key] }
49
+
50
+ owned = attrs.slice(*COMPONENT_EDITIONS).compact.keys.join(", ")
51
+ raise ArgumentError,
52
+ "a base edition and a component edition are mutually " \
53
+ "exclusive: #{owned} cannot ride edition #{attrs[:edition]}"
54
+ end
55
+
56
+ def stringified_attrs(hash)
57
+ attrs = ATTRIBUTES.to_h { |key| [key, stringify(hash[key])] }
58
+ attrs[:edition] = edition_from(hash[:edition], hash[:edition_shorthand])
59
+ attrs.compact
60
+ end
61
+
62
+ # The official IHO edition is the dotted x.y.z; the "v1"/"v5.1"
63
+ # shorthand is a parse-only alias that fills the same attribute.
64
+ def edition_from(edition, shorthand)
65
+ return stringify(edition) if edition
66
+
67
+ pad_edition_shorthand(shorthand)
68
+ end
69
+
70
+ def pad_edition_shorthand(shorthand)
71
+ return nil if shorthand.nil?
72
+
73
+ major, minor = stringify(shorthand).split(".")
74
+ "#{major}.#{minor || '0'}.0"
75
+ end
76
+
32
77
  def stringify(value)
33
78
  return nil if value.nil?
34
79
 
@@ -3,5 +3,5 @@
3
3
  # The IHO flavor base class is Pubid::Iho::Identifier — a real Pubid::Identifier
4
4
  # subclass that every concrete IHO identifier descends from, so `is_a?` identity
5
5
  # and the shared polymorphic `from_hash` work natively (no facade needed). Its
6
- # body and `.parse` live in identifiers/base.rb; this file just ensures it is loaded when a consumer
7
- # references Pubid::Iho::Identifier directly.
6
+ # body and `.parse` live in identifiers/base.rb; this file just ensures
7
+ # it is loaded when a consumer references Pubid::Iho::Identifier directly.