pubid 2.0.3 → 2.0.5

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.
@@ -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.
@@ -6,10 +6,17 @@ module Pubid
6
6
  # every concrete IHO identifier (Identifiers::*) descends from it.
7
7
  #
8
8
  # IHO identifiers have the form:
9
- # IHO {S|P|M|B|C}-{number}[ Ap. {appendix}][ Part {part}][ Annex {annex}][ Suppl {supplement}][ {version|year|Vol. N [issue]}]
9
+ # IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
10
+ # [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
10
11
  #
11
- # The leading IHO publisher prefix is optional on input but always
12
- # emitted on output.
12
+ # Every tail component is a first-class semantic component: a typed
13
+ # attribute, independently addressable, URN-encodable and matchable.
14
+ # The canonical faces order the edition BEFORE the part
15
+ # (`IHO S-100 1.0.0 Part 2`), print the supplement as "Sup" and the
16
+ # appendix as "Appendix" (with the subnumber after a dash), the
17
+ # volume issue parenthesized (`IHO P-1 Vol. 30 (2)`), and the
18
+ # with-year qualifier parenthesized (`IHO S-100 5.0.0 Part 2 (2024)`
19
+ # — the only year face: a periodical year IS a volume).
13
20
  class Identifier < ::Pubid::Identifier
14
21
  # Parse an IHO identifier string into an identifier object
15
22
  # @param identifier [String] The IHO identifier string to parse
@@ -25,32 +32,55 @@ module Pubid
25
32
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
26
33
  end
27
34
 
28
- parsed = Pubid::Parg::Backend.parse(:iho, identifier.strip)
35
+ parsed = Pubid::Parg::Model.bound(:iho, identifier.strip)
29
36
  Builder.build(parsed)
30
37
  end
31
38
 
32
39
  # `number` overrides the generic Components::Code attribute with a plain
33
- # string (same pattern as `part` below); it holds the IHO document
34
- # number (e.g. "100", "1/21"). Kept as the generic `number` so the
35
- # relaton index sorts/binary-searches on it natively via `id.number`.
40
+ # string (same pattern as the other string components); it holds the IHO
41
+ # document number (e.g. "100", "1/21" until the slash-chain volume
42
+ # parse, "158:100"). Kept as the generic `number` so the relaton index
43
+ # sorts/binary-searches on it natively via `id.number`.
36
44
  attribute :number, :string
37
45
  attribute :publisher, :string, default: "IHO"
38
- attribute :appendix, :string
39
- attribute :part, :string
40
- attribute :annex, :string
41
- attribute :supplement, :string
42
- attribute :version, :string
43
- # Series identity components (relaton#407): the year-identified
44
- # series (B-7, P-5, P-6) and the volume/issue-identified ones
45
- # (P-1 and the IHR) carry no a.b.c edition — their trailing
46
- # component is a year or a "Vol. N [issue]" pair instead. Plain
47
- # scalars, like every other IHO attribute; the grammar's tail
48
- # picks exactly one discriminator, so only one of
49
- # version/year/volume is ever present, with issue only
50
- # alongside volume.
46
+ # The official IHO edition is the dotted x.y.z (a plain string — the
47
+ # ~16 string-edition flavors precede this). `IHO S-100 1.0.0` is
48
+ # edition-specific, `IHO S-100` edition-non-specific; a year beside an
49
+ # edition is the with-year qualifier, never a year-keyed docidentifier.
50
+ attribute :edition, :string
51
+ # The with-year qualifier on standards (`(2024)` beside an edition) —
52
+ # the only year component. A periodical year IS a volume: the bare
53
+ # legacy year input ("IHO P-5 2024") binds into `volume`, so this
54
+ # attribute never carries a serial locator.
51
55
  attribute :year, :string
56
+ # Supplement, part, annex, appendix and volume are independently
57
+ # publishable (owner ruling: "this can happen with any independently
58
+ # publishable artifacts"), so each carries an optional edition (+ year
59
+ # qualifier) of its own, mutually exclusive with the base edition
60
+ # context: `IHO P-6 1.0.0 Vol. 2` (within-edition) vs
61
+ # `IHO P-6 Vol. 2 1.0.1` (separately-published). The URN attaches
62
+ # ed- to the node it follows, so one positional slot records both.
63
+ attribute :supplement, :string
64
+ attribute :supplement_edition, :string
65
+ attribute :supplement_year, :string
66
+ attribute :part, :string
67
+ attribute :part_edition, :string
68
+ attribute :part_year, :string
69
+ attribute :annex, :string
70
+ attribute :annex_edition, :string
71
+ attribute :annex_year, :string
72
+ # The appendix is a letter or a number; a subnumbered appendix
73
+ # ("Appendix D-2") keeps the letter in `appendix` and the "2" in
74
+ # `appendix_subnumber` — two independently addressable components,
75
+ # not one string.
76
+ attribute :appendix, :string
77
+ attribute :appendix_subnumber, :string
78
+ attribute :appendix_edition, :string
79
+ attribute :appendix_year, :string
52
80
  attribute :volume, :string
53
81
  attribute :issue, :string
82
+ attribute :volume_edition, :string
83
+ attribute :volume_year, :string
54
84
 
55
85
  # Polymorphic type map for lutaml::Model key_value serialization: maps
56
86
  # each subclass's polymorphic_name to its class name so `from_hash` can
@@ -71,31 +101,98 @@ module Pubid
71
101
  key_value do
72
102
  map "_type", to: :_type, polymorphic_map: IHO_TYPE_MAP
73
103
  map "number", to: :number
74
- map "appendix", to: :appendix
75
- map "part", to: :part
76
- map "annex", to: :annex
77
- map "supplement", to: :supplement
78
- map "version", to: :version
104
+ map "edition", to: :edition
79
105
  map "year", to: :year
80
- map "volume", to: :volume
81
- map "issue", to: :issue
106
+ map "supplement", to: :supplement
107
+ map "supplement_edition", to: :supplement_edition
108
+ map "supplement_year", to: :supplement_year
109
+ map "part", to: :part
110
+ map "part_edition", to: :part_edition
111
+ map "part_year", to: :part_year
112
+ map "annex", to: :annex
113
+ map "annex_edition", to: :annex_edition
114
+ map "annex_year", to: :annex_year
115
+ map "appendix", to: :appendix
116
+ map "appendix_subnumber", to: :appendix_subnumber
117
+ map "appendix_edition", to: :appendix_edition
118
+ map "appendix_year", to: :appendix_year
119
+ map "volume", to: :volume
120
+ map "volume_edition", to: :volume_edition
121
+ map "volume_year", to: :volume_year
122
+ map "issue", to: :issue
82
123
  end
83
124
 
84
125
  # from_hash is the shared polymorphic dispatch on Pubid::Identifier.
85
- # IHO_TYPE_MAP remains as the key_value polymorphic_map.
86
-
87
- # The volume/issue pair is a scalar cluster: a reference that omits
88
- # the issue must still match a hit that carries one ("P-1 Vol. 30"
89
- # vs "P-1 Vol. 30 (2)"), so excluding the volume nils the issue
90
- # with it — the same additive-alias shape the base applies to
91
- # :year -> :date. A lone scalar :year needs no override here: the
92
- # base nils a declared :year directly (the additive :year -> :date
93
- # alias nils whichever name the flavor declares).
126
+ # IHO_TYPE_MAP remains the key_value polymorphic_map.
127
+
128
+ # The volume/issue and appendix/subnumber pairs are scalar clusters:
129
+ # a reference that omits the finer component must still match a hit
130
+ # that carries one ("P-1 Vol. 30" vs "P-1 Vol. 30 (2)"), so excluding
131
+ # the coarser component nils the finer one with it — the same
132
+ # additive-alias shape the base applies to :year -> :date. A lone
133
+ # scalar :year needs no override here: the base nils a declared :year
134
+ # directly (the additive :year -> :date alias nils whichever name the
135
+ # flavor declares).
94
136
  def exclude(*args)
137
+ args = args.dup
95
138
  args << :issue if args.include?(:volume)
139
+ args << :appendix_subnumber if args.include?(:appendix)
96
140
  super
97
141
  end
98
- end
99
142
 
143
+ # Render the compact face: the code-adjacent components folded onto
144
+ # the code with separators — the supplement and the part hyphenated
145
+ # (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
146
+ # `IHO S-100-1` for `IHO S-100 Part 1`) and the volume as the slash
147
+ # chain (`IHO P-5/2024` for `IHO P-5 Vol. 2024`). The canonical
148
+ # render stays expanded; compact is opt-in. The legacy faces (glued
149
+ # lettered bases, Suppl, bare years) parse but are never rendered in
150
+ # any form.
151
+ def render(format: :human, **opts)
152
+ compact = opts.delete(:compact)
153
+ result = super
154
+ compact && format == :human ? compact_face(result) : result
155
+ end
156
+
157
+ # The shared mr_string path reads `edition&.number` (the base
158
+ # declares edition as Components::Edition); IHO's edition is a plain
159
+ # string, so the segment is built from it directly.
160
+ def mr_edition
161
+ return nil unless edition
162
+
163
+ "ed#{edition}"
164
+ end
165
+
166
+ private
167
+
168
+ # Fold " Sup N" / " Part X" / " Vol. N" into the code as "-N" /
169
+ # "-X" / "/N", keeping everything else in place (the edition
170
+ # follows the compact code, as in the legacy "S-5A 1.0.2" print
171
+ # order). The issue keeps its parenthesized print.
172
+ def compact_face(rendered)
173
+ result = rendered.sub(/\A#{publisher} #{base_code}(?= |$)/,
174
+ "#{publisher} #{compact_code}")
175
+ folded_segments.each { |segment| result = result.sub(segment, "") }
176
+ result
177
+ end
178
+
179
+ def base_code
180
+ "#{self.class.type[:short]}-#{number}"
181
+ end
182
+
183
+ def compact_code
184
+ code = base_code.dup
185
+ code << "-#{supplement}" if supplement
186
+ code << "-#{part}" if part
187
+ code << "/#{volume}" if volume
188
+ code
189
+ end
190
+
191
+ def folded_segments
192
+ [supplement && " Sup #{supplement}",
193
+ part && " Part #{part}",
194
+ volume && " Vol. #{volume}"].compact
195
+ end
196
+ end
100
197
  end
101
198
  end