reg-schema 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. reg_schema-2.0.0/.gitignore +129 -0
  2. reg_schema-2.0.0/DESIGN.md +317 -0
  3. reg_schema-2.0.0/PKG-INFO +54 -0
  4. reg_schema-2.0.0/README.md +36 -0
  5. reg_schema-2.0.0/pyproject.toml +36 -0
  6. reg_schema-2.0.0/src/reg_schema/__init__.py +54 -0
  7. reg_schema-2.0.0/src/reg_schema/project_data.py +318 -0
  8. reg_schema-2.0.0/src/reg_schema/structural.py +1593 -0
  9. reg_schema-2.0.0/src/reg_schema/validation.py +73 -0
  10. reg_schema-2.0.0/test_corpus/README.md +105 -0
  11. reg_schema-2.0.0/test_corpus/binding_version_suffix_rejected/expected_ValidationResult.json +10 -0
  12. reg_schema-2.0.0/test_corpus/binding_version_suffix_rejected/input.json +19 -0
  13. reg_schema-2.0.0/test_corpus/composite_entity_key/expected_ValidationResult.json +3 -0
  14. reg_schema-2.0.0/test_corpus/composite_entity_key/input.json +56 -0
  15. reg_schema-2.0.0/test_corpus/invalid_period/expected_ValidationResult.json +10 -0
  16. reg_schema-2.0.0/test_corpus/invalid_period/input.json +20 -0
  17. reg_schema-2.0.0/test_corpus/invalid_root_array/expected_ValidationResult.json +10 -0
  18. reg_schema-2.0.0/test_corpus/invalid_root_array/input.json +1 -0
  19. reg_schema-2.0.0/test_corpus/invalid_window/expected_ValidationResult.json +10 -0
  20. reg_schema-2.0.0/test_corpus/invalid_window/input.json +22 -0
  21. reg_schema-2.0.0/test_corpus/load_test_200col/build.py +167 -0
  22. reg_schema-2.0.0/test_corpus/load_test_200col/expected_ValidationResult.json +3 -0
  23. reg_schema-2.0.0/test_corpus/load_test_200col/input.json +1240 -0
  24. reg_schema-2.0.0/test_corpus/minimal/expected_ValidationResult.json +3 -0
  25. reg_schema-2.0.0/test_corpus/minimal/input.json +20 -0
  26. reg_schema-2.0.0/test_corpus/unexpected_field_on_binding/expected_ValidationResult.json +10 -0
  27. reg_schema-2.0.0/test_corpus/unexpected_field_on_binding/input.json +21 -0
  28. reg_schema-2.0.0/test_corpus/unexpected_fields_on_project/expected_ValidationResult.json +16 -0
  29. reg_schema-2.0.0/test_corpus/unexpected_fields_on_project/input.json +9 -0
  30. reg_schema-2.0.0/test_corpus/with_panel/expected_ValidationResult.json +3 -0
  31. reg_schema-2.0.0/test_corpus/with_panel/input.json +54 -0
  32. reg_schema-2.0.0/test_corpus/with_period_range/expected_ValidationResult.json +3 -0
  33. reg_schema-2.0.0/test_corpus/with_period_range/input.json +21 -0
  34. reg_schema-2.0.0/test_corpus/with_study_window/expected_ValidationResult.json +3 -0
  35. reg_schema-2.0.0/test_corpus/with_study_window/input.json +22 -0
  36. reg_schema-2.0.0/tests/test_corpus.py +140 -0
  37. reg_schema-2.0.0/tests/test_project_data.py +360 -0
  38. reg_schema-2.0.0/tests/test_structural.py +1315 -0
  39. reg_schema-2.0.0/tests/test_validation.py +75 -0
@@ -0,0 +1,129 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ # Root-anchored: only the repo-root packaging dir, NOT the real
7
+ # `reg_monabundle/src/reg_monabundle/build/` source package (the bundle
8
+ # amalgamator). `__pycache__/` above still ignores byte-code anywhere.
9
+ /build/
10
+
11
+ # Environments
12
+ .venv/
13
+ .env
14
+ .envrc
15
+
16
+ # Tools
17
+ .ruff_cache/
18
+ .pytest_cache/
19
+ .mypy_cache/
20
+ .hypothesis/
21
+ node_modules/
22
+
23
+ # IDE
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Project
33
+ .tmp/
34
+
35
+ # Input data (SCB CSVs, Socialstyrelsen metadata, source PDFs — not committed).
36
+ # Listed per-subdirectory so the maintainer-curated classifications/ folder
37
+ # can stay tracked without an exclude/re-include dance (git doesn't traverse
38
+ # into a parent dir that's been ignored at the directory level).
39
+ reg_meta_build/input_data/*
40
+ !reg_meta_build/input_data/classifications/
41
+ # Thin curated providers (#422): each agency's hand-authored <provider>.toml IS
42
+ # the committed source delivery (no machine export exists), so its dir stays
43
+ # tracked. One exception line per curated agency — NOT a broad `*/` exception,
44
+ # which would start tracking the untracked SCB/Socialstyrelsen/swecov seed dirs.
45
+ !reg_meta_build/input_data/Folkhalsomyndigheten/
46
+ !reg_meta_build/input_data/Forsakringskassan/
47
+ !reg_meta_build/input_data/Lakemedelsverket/
48
+ !reg_meta_build/input_data/Pliktverket/
49
+ !reg_meta_build/input_data/Riksarkivet/
50
+ !reg_meta_build/input_data/UMU/
51
+ # Canonical-SCB curated content (#444) — committed TOML + value-set CSVs, not an
52
+ # agency dir but the same tracked-committed-source rule.
53
+ !reg_meta_build/input_data/scb_canonical/
54
+ # Under classifications/ only the normalized CSVs (+ manifest.json) are tracked.
55
+ # The raw SOS source workbooks the fetch script downloads are not — they
56
+ # regenerate from scripts/fetch_sos_classifications.py.
57
+ reg_meta_build/input_data/classifications/sos/*.xls
58
+ reg_meta_build/input_data/classifications/sos/*.xlsx
59
+ # landskoder.csv is fetched but deliberately not seeded (see
60
+ # reg_meta_build/CLASSIFICATIONS.md); a bare fetch run regenerates it —
61
+ # keep it out of status noise.
62
+ reg_meta_build/input_data/classifications/sos/landskoder.csv
63
+
64
+ # mock-data-wizard generated output
65
+ mock_data/
66
+ mock_output/
67
+ mdw_runner.py
68
+ mdw_step1_discovery.json
69
+ mdw_step2_config.json
70
+ mdw_step3_stats.json
71
+ extract_stats*.R
72
+
73
+ # mock-data-wizard runtime artifacts (transient — fcntl sidecar, run logs)
74
+ .mock_data_config.lock
75
+ mdw_log_*.txt
76
+
77
+ # Local test workspaces (real user data, not part of the toolkit)
78
+ /covid-education-immigrants-test/
79
+
80
+ # Personal exploration scripts (not part of the toolkit's curated scripts/)
81
+ scripts/sample_*.py
82
+
83
+ # MONA probe artefacts -- can contain workspace metadata (paths,
84
+ # hostnames, DSNs); inspect before sharing. The findings that matter
85
+ # live in mock_data_wizard/DESIGN.md.
86
+ mdw_probe_*.log
87
+ mdw_python_probe_*.log
88
+ mdw_py_probe_*.csv
89
+ mdw_upload_probe_*.txt
90
+
91
+ # reg_meta database (built from SCB exports, not committed)
92
+ *.db
93
+
94
+ # Build-generated auto-slug TOMLs (variable slugs). A `churning` provider (the
95
+ # #470 default) regenerates these from scratch each build — only the curated
96
+ # <provider>.toml, the freeze.toml state map, and .snapshot.json are committed.
97
+ # Covers the global dir and per-steward subdirs (fqid_slugs/swecov/).
98
+ # See reg_meta_build/DESIGN.md → Slug immutability.
99
+ # To pin a provider (curating/frozen) its auto.toml MUST be committed: either
100
+ # `git add -f reg_meta_build/fqid_slugs/<provider>.auto.toml`, or add a
101
+ # per-provider negation here: `!reg_meta_build/fqid_slugs/<provider>.auto.toml`.
102
+ reg_meta_build/fqid_slugs/**/*.auto.toml
103
+ # Pinned providers (#759, curating): the committed auto.toml IS the curating
104
+ # baseline, so un-ignore exactly these. Steward/churning auto.toml stay ignored.
105
+ !reg_meta_build/fqid_slugs/scb.auto.toml
106
+ !reg_meta_build/fqid_slugs/sos.auto.toml
107
+ !reg_meta_build/fqid_slugs/fk.auto.toml
108
+ !reg_meta_build/fqid_slugs/fohm.auto.toml
109
+ !reg_meta_build/fqid_slugs/lakemedelsverket.auto.toml
110
+ !reg_meta_build/fqid_slugs/pliktverket.auto.toml
111
+ !reg_meta_build/fqid_slugs/riksarkivet.auto.toml
112
+ !reg_meta_build/fqid_slugs/umu.auto.toml
113
+
114
+ # SCB source PDFs (binary, copyrighted, not committed)
115
+ *.pdf
116
+
117
+ # Marker raw output (regenerable from PDFs + parser)
118
+ reg_meta_build/docs/_raw/
119
+
120
+ # Archive (concluded investigations, ad-hoc scripts, internal notes)
121
+ archive/
122
+ *.har
123
+
124
+ # Claude Code harness state (transient locks, per-user settings, worktree shims).
125
+ # `.claude/skills/` is intentionally tracked for shared skills, so ignore the
126
+ # transient pieces individually rather than blanket-ignoring `.claude/`.
127
+ .claude/scheduled_tasks.lock
128
+ .claude/worktrees/
129
+ .claude/settings.local.json
@@ -0,0 +1,317 @@
1
+ # Design: reg_schema
2
+
3
+ Design rationale and constraints for the `project_data.json` schema and its structural
4
+ validator. The code (`project_data.py` / `structural.py` / `validation.py`) plus the
5
+ generated `model_json_schema()` are the field-level reference; this file is the WHY.
6
+ Cross-cutting topology (package tree, dependency graph, Pydantic policy) lives in the
7
+ root `ARCHITECTURE.md`; remaining/unbuilt schema work lives in `REFACTOR_SPEC.md`.
8
+
9
+ ## Scope
10
+
11
+ `reg_schema` owns:
12
+
13
+ - The `project_data.json` v2 shape (Model A): Pydantic v2 models for `ProjectData`,
14
+ `Source`, `Binding`, `Panel`, `PanelMember`, `PeriodRange`, `LiteralPeriod`,
15
+ `TimeRange`, `StudyWindow`, and the `Period` / `EntityKey` / `TimeKey` / `TimePoint`
16
+ type aliases. Under Model A a `Source` carries a 3-part `register_variant` coordinate
17
+ plus a required `period`; bindings (renamed from the v0.x `columns`) name a 3-segment
18
+ binding FQID via `variable`. An optional top-level
19
+ `window: {"from": <year>, "to": <year>}` (`StudyWindow`) seeds the global study period
20
+ on the subject page (#613/#611); absent = full history; existing specs validate
21
+ unchanged. `Source.period` is a `PeriodSegment` (int year / period token /
22
+ `PeriodRange`), the `"_default"` sentinel, or — since #307 — a **list of segments**
23
+ (an interrupted series, e.g.
24
+ `[{"from": 2005, "to": 2010}, {"from": 2015, "to": 2020}]`): one source stays one
25
+ register extraction (panel keys / binding sets are not duplicated across
26
+ pseudo-sources). The structural list rules: non-empty, members are segments
27
+ (`_default` and nested lists are not), each member non-inverted, and the members
28
+ **sorted ascending and non-overlapping** (adjacency allowed — the list expresses
29
+ interruption; rejecting contiguity would need calendar adjacency math for no safety
30
+ gain). Sorted-and-disjoint keeps the comma-joined wire form (`2005..2010,2015..2020`)
31
+ canonical and per-segment resolution deterministic. Composite `entity_key` /
32
+ `time_key` arrays are part of the schema from day one (the validator enforces their
33
+ ordering/homogeneity rules). Remaining: composite-key runtime support in the extract
34
+ path — see `REFACTOR_SPEC.md`.
35
+ - The §6.8.1 **structural validator** — rules enforceable with only the spec payload, no
36
+ external state: required fields, type/subtype consistency, FQID well-formedness
37
+ (3-segment binding FQID / 2-segment `class/<slug>` value set / 3-part variant
38
+ coordinate), `Source.period` grammar, panel composite ordering, source-collision,
39
+ panel key-refs landing on the source's `display_name` strings, etc.
40
+ - The §6.8.0 cross-runtime contract: `ValidationIssue` / `ValidationResult`. Same shape
41
+ consumed by `reg_schema` (Python) and the SPA (TypeScript codegen'd from OpenAPI).
42
+ Composition just concatenates `issues`.
43
+
44
+ ## Logical project selection vs. physical delivery
45
+
46
+ `project_data.json` records research intent, not steward storage topology. A `Source`
47
+ groups bindings by logical `register_variant` and requested period; `Source.name` is an
48
+ internal handle used by panels. It is never a physical filename or SQL-table identity.
49
+ One logical source can resolve to many edition-specific physical tables, and one
50
+ multi-period physical table can satisfy several requested periods, so adding `table` or
51
+ physical `edition` fields to `Source` would conflate two different grains.
52
+
53
+ The v1 target therefore keeps a separate, public, version-controlled steward delivery
54
+ inventory compiled into the released steward artifact. Each physical table has one
55
+ explicit finite edition and literal physical columns; each column has zero or more
56
+ mappings to `(register_variant, variable FQID, canonical representation)`. This
57
+ preserves unmapped columns for coverage and permits one table/column to serve several
58
+ logical variants. `reg_schema` remains independent of the inventory and of reg_meta;
59
+ shared `reg_meta` project code will join a structurally valid project, semantic
60
+ resolution, and the optional inventory in the v1 target. `reg_schema` validates the
61
+ requested-period shape only; the materializer must require the union of each matched
62
+ edition's overlap with its exact resolved representation slice to cover every requested
63
+ segment and report exact uncovered gaps.
64
+
65
+ Every v1 researcher project must carry an explicit requested period. `"_default"` exists
66
+ only for the provisional steward pseudo-project, so remove it from `Source.period` when
67
+ that filter migrates to the delivery inventory; do not retain a structurally valid but
68
+ non-orderable project state. The SPA may expose a common study window as an authoring
69
+ default, but each `Source` still persists its concrete period. Adding a source defaults
70
+ that period to the full available intersection, including disjoint segments. If the
71
+ intersection is empty, the add is blocked rather than inventing a period. A later
72
+ common-window edit does not mutate existing source periods; if it leaves an existing
73
+ source disjoint, the source keeps its explicit period and the project becomes blocking.
74
+ Divergence remains visible rather than being hidden as inheritance, and an explicit
75
+ apply-to-all action rewrites only sources with an overlap. An empty project is a valid
76
+ editable draft but cannot be materialized as an order. See `REFACTOR_SPEC.md` §12.
77
+
78
+ ## Closed project root
79
+
80
+ **V1 decision (2026-07-14; implemented by #1134):** `ProjectData` is a closed object.
81
+ Unknown top-level keys receive `unexpected_field`, just like unknown keys on `Source`,
82
+ `Binding`, and `Panel`. V1 has neither arbitrary steward-namespaced blocks nor a
83
+ placeholder `extensions` field. The only current corpus use is the archived
84
+ `reg_monabundle` subsystem, so the cutover deletes that mechanism and its fixtures
85
+ without migration code. If a real future consumer needs extension data, add one explicit
86
+ `extensions` container with a defined owner and validation boundary then.
87
+
88
+ ## Not in scope (intentionally)
89
+
90
+ - **Generic extension validation.** V1 has no extension surface to validate. A future
91
+ extension consumer must introduce its explicit container and owner-specific contract
92
+ rather than reopening the project root.
93
+ - **§6.8.3 semantic rules (reg_meta-backed).** FQID resolution against a live reg_meta
94
+ DB, classification existence, steward-inventory membership, drift detection. The
95
+ current web-only implementation lives in `reg_webapp/semantic.py`; the v1 target moves
96
+ it into shared `reg_meta` project code used by the webapp and CLI. The dependency
97
+ remains one-way, so `reg_schema` still ships reg_meta-free.
98
+ - The `project_data.codes.json` sibling file. Codes live alongside the spec and are
99
+ dereferenced from reg_meta at kit-build time; deferred to the MONA rebuild (see
100
+ REFACTOR_SPEC.md §8/9/10a — archived). It may grow a schema dataclass here later;
101
+ phase 1 keeps it out.
102
+ - **Per-source SQL filtering (`where`).** There is no `where` field in the v1 baseline
103
+ `Source`. Cohort/row filtering is a property of the MONA-side runner, not the order
104
+ spec. A future audit-filter use case requires a separately designed contract; it does
105
+ not reserve a generic v1 escape hatch.
106
+
107
+ ## What this layer does NOT validate
108
+
109
+ `ValidationResult.__post_init__` coerces `issues` to a tuple but does **not** verify
110
+ each element is a `ValidationIssue` instance. JSON deserialization belongs at read/write
111
+ boundaries — the API ingress in `reg_webapp` — not in the contract module itself.
112
+ Python-internal callers are type-checked; cross-runtime callers own their decode step.
113
+ If `result.ok` ever crashes with `AttributeError` on `.level`, that is a boundary bug to
114
+ fix upstream, not a defensive check to add here.
115
+
116
+ The `level` allowlist *is* enforced at construction because the cost of a
117
+ silently-weakened `ok` (returning `True` for a result that should block) is higher than
118
+ the cost of one extra check on a 3-value frozenset.
119
+
120
+ **`schema_version` is not value-checked here.** The structural layer only requires
121
+ `schema_version` to be a present, non-null string — it does **not** reject a v0.x
122
+ (`"1.x.x"`) value. The "Model A files are `"2.0.0"`; v0.x is hard-rejected, no migration
123
+ code" policy is enforced by the consumer that loads the file (the SPA / CLI in
124
+ `reg_webapp`), not by `validate_structural`. Reason: the version-acceptance window is a
125
+ deployment concern (which schema a given app build understands), whereas this layer is
126
+ the version-agnostic shape checker shared by every runtime. Same split for
127
+ `reg_meta_version`: required as a non-null string here; drift against the
128
+ actually-loaded reg_meta DB is a §6.8.3 semantic concern.
129
+
130
+ ## Dependency direction
131
+
132
+ `reg_schema` has **one runtime dependency: Pydantic v2**. The models are the canonical
133
+ project_data shape, double as FastAPI response models in `reg_webapp`, and feed the
134
+ SPA's TypeScript types via `model_json_schema()`; those three jobs make Pydantic's
135
+ declarative field/model validators the right tool here, and keeping `reg_schema` as the
136
+ only Pydantic surface kills the 1:1 wrapper-drift that a separate validation model would
137
+ create between the schema and the API.
138
+
139
+ The **structural validator** (`structural.py`) uses no Pydantic in its rule logic — it
140
+ operates on a parsed dict. (It does import the `Literal` type aliases from
141
+ `project_data.py`, which pulls Pydantic into the import chain.) The Pydantic models live
142
+ on the model surface (`project_data.py`).
143
+
144
+ Why the dep split matters: the spec is validated in two execution contexts with
145
+ different dependency availability — only the webapp backend has reg_meta. Keeping the
146
+ structural layer dep-free means:
147
+
148
+ - Confining Pydantic to `project_data.py` keeps the structural layer importable in
149
+ dep-light contexts without dragging in the model surface.
150
+ - The TypeScript SPA mirrors a small, stable surface.
151
+
152
+ The inbound dependency graph (who imports `reg_schema`) is part of the cross-cutting
153
+ package topology — see `ARCHITECTURE.md`. The constraint `reg_schema` itself imposes is
154
+ the one above: it pulls in **only** Pydantic, and only on the model surface.
155
+
156
+ ## Two layers: models vs. validator
157
+
158
+ `reg_schema` is deliberately split into a **shape** layer and a **rule** layer, and the
159
+ two are kept apart on purpose:
160
+
161
+ - **Models** (`project_data.py`, Pydantic v2): `ProjectData`, `Source`, `Binding`,
162
+ `Panel`, `PanelMember`, `PeriodRange`, `LiteralPeriod`, `TimeRange`, `StudyWindow`,
163
+ and the `Period` / `EntityKey` / `TimeKey` / `TimePoint` type aliases. **Pure shape
164
+ definitions** — structural rules are *not* re-encoded as raising field validators.
165
+ Re-encoding them would replace the issue-accumulating contract with a fail-fast one,
166
+ and every runtime that shares the contract (SPA, webapp) needs the full issue list,
167
+ not the first exception.
168
+ - **Structural validator** (`structural.py`, §6.8.1): the entrypoint
169
+ `validate_structural(data: Mapping[str, object]) -> ValidationResult` operates on a
170
+ **parsed dict, not the Pydantic models**, for two reasons. First, rules like "`type` ∈
171
+ enum" must fire on raw JSON values *before* any `Literal` cast would coerce or reject
172
+ them — a wrong enum value has to surface as an accumulated `invalid_enum_value` issue,
173
+ not a constructor crash. Second, staying off the model surface keeps the validator
174
+ Pydantic-free so it ports cleanly to the TS SPA.
175
+
176
+ Models are constructed at boundaries (API ingress) only *after* `validate_structural`
177
+ has passed; a Pydantic raise at that point signals validator/model drift, not user
178
+ error.
179
+
180
+ ## Shared validator corpus (`test_corpus/`)
181
+
182
+ `reg_schema/test_corpus/` is the single artifact that keeps the §6.8.1 structural rules
183
+ behaving identically in the two runtimes that carry a copy of them — the canonical
184
+ Python `validate_structural` and the SPA's TypeScript port. Each case is a directory
185
+ containing an `input.json` (a `project_data.json` payload) and an
186
+ `expected_ValidationResult.json` (the validator output the structural rules must
187
+ produce). See `test_corpus/README.md` for the directory layout, file formats, and the
188
+ rule for adding cases.
189
+
190
+ **Two** consumers run the structural corpus — the two runtimes that own a copy of the
191
+ §6.8.1 rules:
192
+
193
+ - `reg_schema/tests/test_corpus.py` runs `validate_structural(input)` against every case
194
+ and asserts an unordered-issue equality match with the decoded
195
+ `expected_ValidationResult.json` — the single Python source of truth that the SPA
196
+ mirrors.
197
+ - The SPA's TypeScript test suite imports the JSON as fixtures and runs its TS port of
198
+ the validator against them.
199
+
200
+ The corpus grows alongside the validator: at least one well-formed empty-issues case to
201
+ prove the format/harness/round-trip, plus one (or more) negative case per structural
202
+ rule. Negative cases for §6.8.3 (reg_meta-backed semantic) live in their owning
203
+ packages, not here — `reg_schema` only owns the structural layer's corpus.
204
+
205
+ ## Structural rules and issue codes
206
+
207
+ Issue `code` values are stable across releases — tests pin them, the SPA maps codes to
208
+ UI affordances, new codes are additive (§6.8.0). Current codes:
209
+
210
+ | Code | Rule (§6.8.1) |
211
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
212
+ | `invalid_root` | Root must be an object. |
213
+ | `missing_required_field` | A required field (top-level, source, binding, panel, member) is absent. |
214
+ | `invalid_field_type` | A field's JSON type is wrong (e.g. `steward` is not a string; `members` is not an array; `period` is null). |
215
+ | `invalid_enum_value` | `steward`, `type`, `id_subtype`, `numeric_subtype` is outside its allowed set. |
216
+ | `unexpected_field` | An unrecognized key on a closed object: `ProjectData`, `Source`, `Binding`, `Panel`, or panel member. Unknown top-level values receive this code regardless of whether the value is an object, array, or scalar. |
217
+ | `invalid_fqid` | FQID segment count or per-segment characters are wrong: binding `variable` is not a 3-segment `<provider>/<register>/<slug>`, `value_set` is not a 2-segment `class/<slug>`, or `register_variant` is not a 3-part `<provider>/<register>/<variant>` coordinate. The binding leaf is a bare slug — the retired `@version` pin is now a stray `@` the per-segment grammar rejects (§6.8.3 resolves the value set from `(variable, variant, period)`). |
218
+ | `fqid_register_variant_mismatch` | A binding `variable`'s first **2** segments (provider/register) don't equal the owning source's `register_variant` prefix. The variant is not repeated on the binding — it lives once on the Source. |
219
+ | `invalid_period` | A `Source.period` is not an int year, a period-token string (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `HTYYYY`, `VTYYYY`, `YYYY-Q[1-4]`, `YYYY-H[12]`), the snapshot sentinel `"_default"`, a `{"from","to"}` range object with valid endpoints, or a #307 segment LIST (interrupted series). The list rules raise the same code (member-pathed `/period/<i>`): empty list, a non-segment member (`_default` / nested list / junk), an inverted member range, or members not sorted-ascending / overlapping. A `YYYY-MM-DD` token that passes the syntactic 01-31 day envelope but names a calendar-impossible day (`2019-02-29` in a non-leap year, `2018-02-30`) also raises this code. |
220
+ | `invalid_window` | A top-level `window`'s `to` year is less than its `from` year. |
221
+ | `subtype_on_wrong_type` | A `*_subtype` or `*_format` field is set on a binding whose `type` doesn't own it (e.g. `id_subtype` on a categorical). |
222
+ | `empty_bindings` | A source has zero bindings. |
223
+ | `duplicate_source_name` | Two sources share a `name`. |
224
+ | `display_name_collision` | Two bindings on the same source share an explicit `display_name`. The implicit-resolution half — one explicit + one resolving to the same reg_meta default — needs reg_meta and lives in §6.8.3. |
225
+ | `duplicate_panel_id` | Two panels share a `panel_id`. |
226
+ | `empty_members` | A panel has zero members. |
227
+ | `literal_period_invalid` | The `{"period": ...}` or `{"range": {"from","to"}}` time_key object form is malformed (missing/extra keys, or non-period endpoints). |
228
+ | `composite_time_key_mixed_kinds` | A composite `time_key` array mixes column refs and literals on a single member. |
229
+ | `composite_key_inconsistent` | Composite `entity_key` / `time_key` tuples across members of a panel are not identically ordered. |
230
+ | `time_key_member_kind_mismatch` | A member-level composite `time_key` override has a different kind (literal vs ref) than the panel-level composite. |
231
+ | `literal_time_key_duplicate` | Two members of one panel resolve to the same literal `time_key`. |
232
+ | `entity_key_unknown_column` | A bare-string `entity_key` ref doesn't match any `display_name` on the member's source. Skipped on sources with any unset `display_name` (the ref may resolve to a reg_meta-derived default at runtime). |
233
+ | `time_key_unknown_column` | Same rule, for `time_key` column refs. |
234
+ | `source_referenced_by_multiple_panels` | One source appears in two panels. |
235
+ | `panel_member_unknown_source` | A panel member's `source` does not match any entry in `/sources`. |
236
+
237
+ The "ref exists on source" check is intentionally lenient: when any binding on the
238
+ source lacks an explicit `display_name`, the structural layer skips matching that
239
+ source's refs entirely. The webapp materializes defaults from reg_meta before emitting
240
+ artifacts, and a pre-authoring SPA-state spec shouldn't be flagged for refs that will
241
+ resolve later.
242
+
243
+ ### Effective-key presence is not structural
244
+
245
+ The v0.x `missing_effective_entity_key` / `missing_effective_time_key` codes do **not**
246
+ exist in this layer. Under Model A an omitted `entity_key` / `time_key` inherits from
247
+ the member's variant's `panel_template`, which needs reg_meta state — so the "no
248
+ effective key" case can only be checked once inheritance is materialized at kit-build
249
+ time, a path deferred to the from-scratch MONA rebuild (#707). A member with no panel
250
+ default and no override is simply not flagged at this layer.
251
+
252
+ The composite/literal panel rules that **are** structural live in the issue-code table
253
+ above (`composite_key_inconsistent` ordering, `composite_time_key_mixed_kinds`
254
+ homogeneity, `literal_time_key_duplicate` uniqueness, member-vs-panel
255
+ `time_key_member_kind_mismatch`). The structural layer deliberately keeps the SPA's
256
+ pre-authoring spec valid even while inheritance is still unresolved — it never
257
+ materializes defaults, only checks shapes.
258
+
259
+ The `panel_inheritance_unresolvable` check and key materialization are deferred to the
260
+ MONA rebuild (see `REFACTOR_SPEC.md` §8/9/10a; tracked in #707, archived #699).
261
+
262
+ ### Semantic codes — defined, not emitted by `reg_schema`
263
+
264
+ These `code` values are part of the §6.8.0 contract but are raised by the
265
+ **reg_meta-backed §6.8.3 layer** (`reg_webapp`), never by `validate_structural`. Listed
266
+ here so the stable-code registry is complete and the SPA can map them:
267
+
268
+ | Code | Level | Rule (§6.8.3) |
269
+ | ------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
270
+ | `period_outside_state_validity` | error | No `variable_state` covers the binding's `(variant, period)`. |
271
+ | `binding_state_drifts_within_period` | info | A range `period` crosses a state transition (incl. a delivery-column rename), or a chosen `representation` covers only part of the range; the resolver returns per-state subsets. |
272
+ | `range_period_partially_covered` | info | An explicit range `period` is only PARTIALLY covered by the concept's states — the union of every covering state (across all delivery columns) leaves a gap NO column delivers (e.g. SSYK first delivered 2014 under a `from:2010,to:2020` binding → 2010–2013 has no data). The covered sub-range still extracts; the gap is silently dropped, so this surfaces it. Distinct from `binding_state_drifts_within_period`: that is the CHOSEN representation under-covering vs a SIBLING column that DOES deliver the gap (whole-concept covered); this is the whole concept itself under-covering. Zero coverage is `period_outside_state_validity`, not this. Only fires for an explicit `PeriodRange` (a point/token period is a single instant; `_default` has no author-requested window). |
273
+ | `binding_value_set_version_ambiguous` | error | A binding's `(variant, period)` resolves to several **CO-EXISTING delivery columns** (distinct columns valid at the SAME instant — overlapping windows) — parallel REPRESENTATIONS of the one concept (SSYK 3/4/5-digit, age brackets) — and the binding sets no `representation`. The author must pick one (the SPA offers a chooser); this is where the retired `@version` pin's job now lives, keyed on the delivery column. Distinct columns in NON-overlapping windows (a sequential rename) are drift, not ambiguity. (Also re-used as a backstop for the rarer case of distinct value sets co-delivered on ONE column — a reg_meta build co-delivery the `validate` invariant should make unreachable.) |
274
+ | `binding_representation_unknown` | error (→ warning on the steward path) | A binding's `representation` is not a delivery column of the concept at the source's `(variant, period)`. Downgraded for steward-catalog load (reg_meta dropped/renamed the pinned column = drift), like `period_outside_state_validity`. |
275
+ | `deprecated_traversal` | info | The binding resolves to a variable marked `deprecated` in catalog metadata; the FQID still resolves, but the author should prefer a current successor when one is available. |
276
+ | `variable_replaced` | info | The binding has a `variable_replaced_by` edge effective at or before the source's `period`; hint points at the successor. |
277
+
278
+ (The `panel_inheritance_unresolvable` code is **not** in this live set — its kit-build
279
+ check is deferred to the from-scratch MONA rebuild, see above and `REFACTOR_SPEC.md`
280
+ §8/9/10a; tracked in #707, archived #699.)
281
+
282
+ ## Why no FQID parser dependency
283
+
284
+ §6.8.1 phrases FQID structural checks as syntactic ("3-segment binding FQID
285
+ `<provider>/<register>/<slug>` / 2-segment `class/<slug>` / 3-part `register_variant`
286
+ coordinate"). The binding leaf is a bare slug — there is no `@version` pin to split off
287
+ (that grammar is retired; co-delivery selection moved to the binding `representation`
288
+ field, §6.8.3), so the per-segment `_FQID_TOKEN` rejects a stray `@`. `reg_schema`
289
+ implements these checks locally rather than importing `reg_meta`'s `Fqid` parser. Two
290
+ reasons:
291
+
292
+ - Keeps the dependency direction one-way (`reg_meta` → `reg_meta_build` is the only
293
+ cross-dep today; adding `reg_schema` → `reg_meta` would pull reg_meta into every
294
+ consumer of reg_schema).
295
+ - Structural well-formedness is a small, stable surface (segment count + segment slug
296
+ characters). Duplicating it is cheaper than the coupling.
297
+
298
+ The same rationale covers the **period-token grammar** (`Source.period` and `TimeKey`
299
+ range endpoints): `structural._PERIOD_TOKEN` is a deliberate mirror of the canonical
300
+ grammar in `reg_meta.fqid._PERIOD_PATTERNS` (year 1900-2099, month 01-12, day 01-31,
301
+ plus `HT/VT`, quarter, half-year forms). Both copies also calendar-validate the
302
+ author-supplied day of a `YYYY-MM-DD` token (`_is_period_endpoint` here mirrors
303
+ `is_period` on the reg_meta side): the regex bounds the day 01-31 syntactically, but an
304
+ impossible day (`2019-02-29` in a non-leap year, `2018-02-30`) is rejected by an extra
305
+ `date.fromisoformat` check. The grammar is kept **bound-for-bound identical** so a spec
306
+ that passes this structural gate doesn't later fail reg_meta's period resolution — a
307
+ looser copy would silently split the period contract across the two packages. #307
308
+ widened the mirror by one function: `structural._endpoint_bounds` duplicates
309
+ `reg_meta.fqid.period_token_to_bounds` (token → inclusive ISO interval, including the
310
+ deliberate synthesized Feb-29 upper bound) because the period-list sorted/non-overlap
311
+ rule needs real interval comparisons across mixed grammars (`HT2018` ⊂ `2018`). The
312
+ cross-grammar parity test (`reg_webapp/backend/tests/test_period_grammar_parity.py`) is
313
+ the CI gate that enforces this invariant — token verdicts AND bounds expansions: any
314
+ future change to one side that breaks parity with the other will fail CI.
315
+
316
+ Semantic FQID resolution stays in `reg_meta` and is invoked by the §6.8.3 layer in
317
+ `reg_webapp`.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: reg-schema
3
+ Version: 2.0.0
4
+ Summary: project_data.json schema and structural validator
5
+ Project-URL: Homepage, https://github.com/adamaltmejd/registry-research-toolkit
6
+ Project-URL: Repository, https://github.com/adamaltmejd/registry-research-toolkit
7
+ Project-URL: Issues, https://github.com/adamaltmejd/registry-research-toolkit/issues
8
+ Author-email: Adam Altmejd <adam@altmejd.se>
9
+ License: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: pydantic>=2.13.4
17
+ Description-Content-Type: text/markdown
18
+
19
+ # reg_schema
20
+
21
+ `project_data.json` schema and structural validator. Importable by the webapp and the
22
+ SPA (via TS codegen).
23
+
24
+ Python ≥3.14. One runtime dependency: Pydantic v2 (the deliberate exception to the
25
+ workspace no-Pydantic rule); the structural validator itself operates on raw dicts and
26
+ needs no third-party deps. See [DESIGN.md](DESIGN.md) for scope and dependency
27
+ direction.
28
+
29
+ ## Status
30
+
31
+ v2.0.0 — Model A grammar. The surface: the §6.8.0 cross-runtime contract
32
+ (`ValidationIssue`, `ValidationResult`), the Pydantic v2 models (`ProjectData`,
33
+ `Source`, `Binding`, `Panel`, `PanelMember`, `Period`, `PeriodRange`, `LiteralPeriod`,
34
+ plus the `EntityKey` / `TimeKey` / `TimePoint` aliases), and the unified
35
+ `validate_structural()` entrypoint implementing §6.8.1. See [DESIGN.md](DESIGN.md) for
36
+ the issue-code table.
37
+
38
+ ```python
39
+ import json
40
+
41
+ from reg_schema import validate_structural
42
+
43
+ with open("project_data.json") as f:
44
+ spec = json.load(f)
45
+
46
+ result = validate_structural(spec)
47
+ if not result.ok:
48
+ for issue in result.issues:
49
+ if issue.level == "error":
50
+ print(f"{issue.path}: {issue.code} — {issue.message}")
51
+ ```
52
+
53
+ The validator operates on a parsed dict, not on the Pydantic models, because rules like
54
+ "type is one of the enum values" must fire on raw JSON values before any `Literal` cast.
@@ -0,0 +1,36 @@
1
+ # reg_schema
2
+
3
+ `project_data.json` schema and structural validator. Importable by the webapp and the
4
+ SPA (via TS codegen).
5
+
6
+ Python ≥3.14. One runtime dependency: Pydantic v2 (the deliberate exception to the
7
+ workspace no-Pydantic rule); the structural validator itself operates on raw dicts and
8
+ needs no third-party deps. See [DESIGN.md](DESIGN.md) for scope and dependency
9
+ direction.
10
+
11
+ ## Status
12
+
13
+ v2.0.0 — Model A grammar. The surface: the §6.8.0 cross-runtime contract
14
+ (`ValidationIssue`, `ValidationResult`), the Pydantic v2 models (`ProjectData`,
15
+ `Source`, `Binding`, `Panel`, `PanelMember`, `Period`, `PeriodRange`, `LiteralPeriod`,
16
+ plus the `EntityKey` / `TimeKey` / `TimePoint` aliases), and the unified
17
+ `validate_structural()` entrypoint implementing §6.8.1. See [DESIGN.md](DESIGN.md) for
18
+ the issue-code table.
19
+
20
+ ```python
21
+ import json
22
+
23
+ from reg_schema import validate_structural
24
+
25
+ with open("project_data.json") as f:
26
+ spec = json.load(f)
27
+
28
+ result = validate_structural(spec)
29
+ if not result.ok:
30
+ for issue in result.issues:
31
+ if issue.level == "error":
32
+ print(f"{issue.path}: {issue.code} — {issue.message}")
33
+ ```
34
+
35
+ The validator operates on a parsed dict, not on the Pydantic models, because rules like
36
+ "type is one of the enum values" must fire on raw JSON values before any `Literal` cast.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reg-schema"
7
+ version = "2.0.0"
8
+ description = "project_data.json schema and structural validator"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ authors = [{ name = "Adam Altmejd", email = "adam@altmejd.se" }]
12
+ license = { text = "MIT" }
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Science/Research",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering",
19
+ ]
20
+ # reg_schema is the deliberate exception to the workspace no-Pydantic rule
21
+ # (CLAUDE.md stack §): it is the canonical structural validator + the
22
+ # FastAPI response-model source, and `model_json_schema()` feeds the SPA's
23
+ # TS codegen. See reg_schema/DESIGN.md → Two layers: models vs. validator for the
24
+ # Pydantic boundary. (The MONA bundle that consumed these models was archived to
25
+ # `archive/mona-subsystem` pending a from-scratch rebuild.)
26
+ dependencies = [
27
+ "pydantic>=2.13.4",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/adamaltmejd/registry-research-toolkit"
32
+ Repository = "https://github.com/adamaltmejd/registry-research-toolkit"
33
+ Issues = "https://github.com/adamaltmejd/registry-research-toolkit/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/reg_schema"]