From 7e8b3784661c21a2ce67658651d7c0a5d0dc9ef6 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:09:21 -0400 Subject: [PATCH 1/7] feat(grade): incomplete becomes the floor with a per-grade roadmap - Make incomplete the conformance floor: a record with identity but no source documents is valid, grades incomplete, and validate, build-index, and from-sheet all exit 0. Remove the below-floor none state. - Emit a per-grade roadmap to full. Each grade reports as satisfied, an outstanding delta, or gated (its own requirements met but blocked by a lower grade, naming the grade to reach). Add the grade-satisfied and grade-gated info findings. - Make the cutsheet a graded core requirement instead of a schema requirement, so an unattached cutsheet grades incomplete with a roadmap entry rather than failing schema validation. - Loosen the schema additively: shrink the index and product_family required sets and allow an empty source_files array. - Always stamp conformance_level; the index may be sparse for an incomplete record; bump the builder version to 0.5.0. - Re-stamp the example records to 0.8.0; grades are unchanged. - Reframe the docs and schema descriptions to three grades above an incomplete floor. --- CHANGELOG.md | 30 +++ README.md | 4 +- docs/authoring-patterns.md | 2 +- docs/how-it-works.md | 6 +- docs/methodology.md | 6 +- examples/README.md | 6 +- examples/erco-quintessence-30416-023.ulc | 4 +- ...pulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc | 4 +- ...lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc | 4 +- examples/selux-aya-pole-sr-ho-3000k.ulc | 4 +- ...ended-807-so-3500k-90cri-hl-black-48in.ulc | 4 +- schema/taxonomy.schema.json | 4 +- schema/ulc.schema.json | 15 +- tools/validator/cmd/ulc/main.go | 14 +- tools/validator/cmd/ulc/main_test.go | 103 ++++++++ tools/validator/internal/findings/findings.go | 16 +- tools/validator/internal/grade/grade.go | 154 ++++++----- .../internal/grade/grade_drift_test.go | 4 +- tools/validator/internal/grade/grade_test.go | 246 ++++++++++++++++-- tools/validator/internal/index/builder.go | 49 ++-- .../validator/internal/index/builder_test.go | 29 ++- 21 files changed, 544 insertions(+), 164 deletions(-) create mode 100644 tools/validator/cmd/ulc/main_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 509569e..0e1c143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,36 @@ Releases are automated. To ship a release: For emergency manual releases (bypassing the PR flow), trigger the `Release on merge` workflow manually via `workflow_dispatch`, providing the version input. +## 0.8.0 (2026-06-23) + +`incomplete` becomes the true floor of the conformance model. The tooling never fails a record on data completeness: a record with identity but zero source documents is valid, grades `incomplete`, and carries a roadmap to core. The roadmap now decomposes per grade all the way to full, showing the grades a record already satisfies and, for each grade it has not yet reached, only that grade's own remaining fields. The model is now framed as three grades (`core`, `standard`, `full`) above an `incomplete` floor. + +### Behavior change (important for consumers) + +- Records that previously failed `ulc validate`, `ulc build-index`, or `ulc from-sheet` because they were anchorless or missing required index keys now SUCCEED (exit 0) and grade `incomplete` with a roadmap. Any external tooling or CI that treated a nonzero exit as "this record is unacceptable" must now treat `incomplete` as a valid, expected, below-core state. The three subcommands exit nonzero only on schema-invalidity, malformed input, source-file integrity failures (hash mismatch, unreadable files), or a stored-vs-recomputed conformance drift. No data-completeness condition produces a nonzero exit. +- `index.conformance_level` is now always present, including `incomplete`. The internal below-floor sentinel (`none`) is removed: the grader never returns it and nothing renders it. + +### Schema (additive, pre-1.0) + +- Three `required` sites are loosened so an identity-only record is representable: the index drops the three photometric projections (`primary_category`, `nominal_total_lumens`, `nominal_input_power_w`) from its required keys, `product_family` drops `cutsheet`, and `source_files` may be empty (`minItems` 1 to 0). All three are loosenings, so no existing record becomes invalid. +- The `ConformanceLevel` description is reframed: `incomplete` is the floor (a record that has not yet met core), never a published grade, always traveling with a roadmap. The enum values are unchanged. + +### Grading + +- The cutsheet moves from a schema requirement to a graded core item, so an unattached cutsheet now grades `incomplete` with a roadmap entry naming `/product_family/cutsheet` instead of failing schema validation. This is the only rubric membership change; which fields gate which grade is otherwise unchanged. Example grades do not change. +- `AchievedLevel` floors at `incomplete` (the zero value) and never returns a below-floor sentinel. +- The roadmap is emitted per grade through full. Each grade reports one of three states: satisfied (a new `conformance/grade-satisfied` info finding), an outstanding delta (the existing `conformance/gap` roadmap), or gated (a new `conformance/grade-gated` info finding when a grade's own requirements are met but a lower grade is not, naming the grade to reach to unlock it). The structured `Finding` fields are unchanged, so existing JSON consumers keep parsing; they see two new info codes. A consumer keying on `conformance/grade-gated` must treat it as NOT achieved; the achieved grade is `index.conformance_level`. + +### Builder and CLI + +- The builder always stamps `conformance_level`. The index may be sparse for an `incomplete` record (photometric projections are omitted when their data is absent). `BuilderVersion` is `0.5.0`, so every stored index re-stamps on the next `ulc build-index`. +- `ulc from-sheet` writes an `incomplete` converted record to its output directory with a console notice that it is below core; it no longer skips such a record. + +### Examples and docs + +- The five reference records re-stamp to `ulc_version` `0.8.0` and `builder_version` `0.5.0`. Grades are unchanged (erco, selux, and both Lumenpulse records at `standard`; Vode at `core`); their roadmaps now render the full per-grade decomposition. +- `methodology.md`, `how-it-works.md`, `authoring-patterns.md`, the README, and the examples README are reframed from "four levels" to "three grades above an incomplete floor." + ## 0.7.0 (2026-06-20) The conformance rubric is redesigned from a thin 16-field check into an exhaustive, document-aware rule table, and the conformance ladder gains an `incomplete` tier below `core`. Stored indices recompute, so several records change level. diff --git a/README.md b/README.md index 3d6b44c..60dbc98 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A ULC record is a single JSON document that conforms to the ULC schema. It carri ULC does not embed source files. It identifies them. A consumer who obtains a source file through any channel can verify it matches the ULC record by comparing hashes. -Every record also carries a computed **conformance level** (`incomplete`, `core`, `standard`, or `full`), graded by the reference builder from the fields the record populates and stamped into the generated index, never declared by the author. The grading rubric applies its requirements conditionally through a layer of applicability predicates, so a fixture is only ever asked for the data its form actually has: a pure color-mixing fixture is not graded on CRI, and an indoor downlight is not graded on a BUG rating. The tiers are not arbitrary: they mirror the way a construction specification escalates what it asks of a manufacturer, from the mandatory product data and safety listing every fixture must provide, through the selection-grade performance specifications used to compare products, to the independently-certified test reports demanded of the most rigorous fixtures. The four tiers, the per-tier requirement tables, the predicate layer, and the rationale behind the tier cut points are documented in [the conformance rubric](docs/methodology.md#the-conformance-rubric). +Every record also carries a computed **conformance grade** (`core`, `standard`, or `full`, above an `incomplete` floor), graded by the reference builder from the fields the record populates and stamped into the generated index, never declared by the author. The grading rubric applies its requirements conditionally through a layer of applicability predicates, so a fixture is only ever asked for the data its form actually has: a pure color-mixing fixture is not graded on CRI, and an indoor downlight is not graded on a BUG rating. The tiers are not arbitrary: they mirror the way a construction specification escalates what it asks of a manufacturer, from the mandatory product data and safety listing every fixture must provide, through the selection-grade performance specifications used to compare products, to the independently-certified test reports demanded of the most rigorous fixtures. The three grades above an incomplete floor, the per-grade requirement tables, the predicate layer, and the rationale behind the grade cut points are documented in [the conformance rubric](docs/methodology.md#the-conformance-rubric). ## Source inputs @@ -126,7 +126,7 @@ To check the current state of manufacturer adoption (which manufacturers have pu ## Project status -The current release is `0.7.0`. Conformance is computed rather than declared: the reference builder grades each record against the four-tier ladder (`incomplete` < `core` < `standard` < `full`) and stamps the result into the generated index. The toolchain ships the split schema and taxonomy, the drift-guard tooling, the reference command-line validator and compiler (`ulc`) with schema validation, builder parity, and source-file hash verification, the deterministic `from-sheet` workbook-to-record converter, the fill-in workbook template under `templates/workbook/`, and the PIM platform mapping guides under `mappings/pim/`. See `CHANGELOG.md` for the full release history. +The current release is `0.8.0`. Conformance is computed rather than declared: the reference builder grades each record against three grades (`core`, `standard`, `full`) above an `incomplete` floor and stamps the result into the generated index. The toolchain ships the split schema and taxonomy, the drift-guard tooling, the reference command-line validator and compiler (`ulc`) with schema validation, builder parity, and source-file hash verification, the deterministic `from-sheet` workbook-to-record converter, the fill-in workbook template under `templates/workbook/`, and the PIM platform mapping guides under `mappings/pim/`. See `CHANGELOG.md` for the full release history. The specification will continue to evolve based on real-world use, industry feedback, and alignment with adjacent standards. See `CHANGELOG.md` for release notes. diff --git a/docs/authoring-patterns.md b/docs/authoring-patterns.md index 5e8b32f..5e16cac 100644 --- a/docs/authoring-patterns.md +++ b/docs/authoring-patterns.md @@ -210,7 +210,7 @@ A single manufacturer may use different patterns for different product families. The reference validator uses the patterns and primitives above as follows: -- **Conformance level is computed, not declared.** The builder grades each record against the rubric and writes the level it achieves into the generated index as `index.conformance_level`, so the level a record carries is the level its data actually reaches (a hand-edited value fails the build-parity check, like any other index field). The ladder has four tiers: `incomplete` is a real photometric record that has not yet met every core requirement (it indexes and gets a roadmap to core); `core` is a complete, legally-sellable luminaire with full identity, headline numbers, one-line colorimetry, and a market safety listing; `standard` adds the fuller specification a typical LM-79 report produces plus the white-light, directional, outdoor-site, and wet-location conditionals that apply; `full` adds exhaustive accredited characterization (zonal lumens, operating point, uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light), mostly from a test report. Compliance beyond the core safety listing is tracked, not gated, and the safety gate checks for a self-asserted claim, not third-party verification. `ulc validate` reports the achieved level and the specific fields that would raise it, each naming its source document and standard. The level is never a pass/fail gate. +- **Conformance level is computed, not declared.** The builder grades each record against the rubric and writes the level it achieves into the generated index as `index.conformance_level`, so the level a record carries is the level its data actually reaches (a hand-edited value fails the build-parity check, like any other index field). There are three grades above an `incomplete` floor: `incomplete` is the floor, a record that has not yet met a core requirement (authoring can begin from identity alone, where the grader returns incomplete plus a roadmap to core, and a record publishes at core and above); `core` is a complete, legally-sellable luminaire with full identity, headline numbers, one-line colorimetry, and a market safety listing; `standard` adds the fuller specification a typical LM-79 report produces plus the white-light, directional, outdoor-site, and wet-location conditionals that apply; `full` adds exhaustive accredited characterization (zonal lumens, operating point, uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light), mostly from a test report. Compliance beyond the core safety listing is tracked, not gated, and the safety gate checks for a self-asserted claim, not third-party verification. `ulc validate` reports the achieved grade and a per-grade roadmap to full: the grades already satisfied and, for each grade not yet reached, only that grade's own remaining fields, each naming its source document and standard. The level is never a pass/fail gate. The following integrity checks are defined by the specification but are **not yet implemented in the reference validator**. The v1 validator runs schema validation, builder parity, source-file hashes, and conformance reporting only; these are planned for a post-pilot release: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index c00b690..c3716b5 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -58,14 +58,14 @@ Both tiers read the same ULC record. The difference is depth of lighting-domain The reference command-line validator (`ulc`, in `tools/validator/`) checks a record end to end in one command: structure against the JSON Schema, builder parity (that the generated index matches what the builder would produce from the deep blocks), and source-file hashes. -The conformance level is computed and stamped into the generated index as `index.conformance_level` by the builder (`ulc build-index`), never hand-declared. As part of its end-to-end pass, `ulc validate` recomputes the level and checks it against the stored value (builder parity), then reports it. Grading is a cumulative gate: a record is the highest tier all of whose hard requirements it meets. The four levels are: +The conformance level is computed and stamped into the generated index as `index.conformance_level` by the builder (`ulc build-index`), never hand-declared. As part of its end-to-end pass, `ulc validate` recomputes the level and checks it against the stored value (builder parity), then reports it. Grading is a cumulative gate: a record is the highest grade all of whose hard requirements it meets. There are three grades above an `incomplete` floor: -- **incomplete**: a real photometric record (it carries the flux, input-power, and primary-category anchors) that has not yet met every core requirement. Those anchors make the record gradeable at this tier and earn it a roadmap naming the core fields it is missing; a fully valid index also requires the identity core-fields (`manufacturer_slug` and `catalog_model`), which the builder reports through `MissingRequiredKeys` when they are absent. +- **incomplete**: the floor, a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing the identity core-fields (`manufacturer_slug` and `catalog_model`) is schema-invalid, reported through `MissingRequiredKeys`, which is distinct from incomplete. - **core**: a complete, identifiable, legally-sellable luminaire: full identity, headline photometric and electrical numbers, one-line colorimetry, and a market safety listing. - **standard**: core plus the fuller specification a typical LM-79 report produces (full photometric geometry, materials, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). - **full**: standard plus exhaustive accredited characterization (zonal lumens, an operating point, measurement uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products), mostly from an accredited test report. -The safety-listing core gate checks for the presence of a self-asserted listing claim, not third-party verification of it; a conformance level is a data-completeness grade, never a safety certification. Compliance beyond the core safety listing is tracked, not gated. `ulc validate` reports the level a record achieves and the specific fields that would raise it to the next level, each naming its source document and governing standard. The level is verified from the data, not asserted by the author, and it is never a pass/fail gate. Because the index (including the conformance level) is a deterministic function of the deep blocks, a hand-edited value fails the builder-parity check like any other index field. +The safety-listing core gate checks for the presence of a self-asserted listing claim, not third-party verification of it; a conformance level is a data-completeness grade, never a safety certification. Compliance beyond the core safety listing is tracked, not gated. `ulc validate` reports the grade a record achieves and a per-grade roadmap to full: the grades it already satisfies and, for each grade not yet reached, only that grade's own remaining fields, each naming its source document and governing standard. The level is verified from the data, not asserted by the author, and it is never a pass/fail gate. Because the index (including the conformance level) is a deterministic function of the deep blocks, a hand-edited value fails the builder-parity check like any other index field. ## How to try it today diff --git a/docs/methodology.md b/docs/methodology.md index 1cd0280..6367d4f 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -28,7 +28,7 @@ Each principle below is a deliberate constraint, with a short rationale for why **Metadata-only.** ULC references external standards and source files by identifier and content hash. It does not embed or redistribute the text of paid or restricted standards, and it does not bundle the source files themselves. The record points to evidence; it does not republish it. -**Explicit completeness.** Missing fields are explicit rather than hidden, and the computed conformance level (incomplete, core, standard, full) makes a record's completeness legible at a glance. When every manufacturer used their own notion of what belongs on a datasheet, gaps were invisible. A common structure where absence is stated, and where the level is computed from the data actually present, surfaces data-quality issues that no single proprietary format ever exposed. +**Explicit completeness.** Missing fields are explicit rather than hidden, and the computed conformance grade (core, standard, full, above an incomplete floor) makes a record's completeness legible at a glance. When every manufacturer used their own notion of what belongs on a datasheet, gaps were invisible. A common structure where absence is stated, and where the level is computed from the data actually present, surfaces data-quality issues that no single proprietary format ever exposed. Taken together, these principles serve one goal: records that are comparable across manufacturers, with evidence a consumer can verify and completeness a consumer can read. @@ -96,9 +96,9 @@ Compliance evidence is carried by `compliance_documents` (listing certificates, Every ULC record carries a conformance level, and that level is computed, never declared. The reference builder grades the record from the fields actually populated and stamps the result into `index.conformance_level`; the builder-parity check then guards that value like any other index field. There is no place to assert a level by hand and no way to inflate one: a record simply is the highest tier all of whose hard requirements it meets. Grading walks the tiers from low to high and stops at the first tier with an unmet requirement. -There are four ordered tiers, `incomplete` < `core` < `standard` < `full`, sitting above a `none` floor reserved for a non-photometric record that cannot be indexed at all (one missing the three anchors: total luminous flux, input power, and primary category). +There are three ordered grades, `core` < `standard` < `full`, sitting above an `incomplete` floor. -- **`incomplete`.** A real photometric record (it carries the three anchors: total luminous flux, input power, and primary category) that has not yet met every core requirement. The anchors are what make the record gradeable at this tier and earn it a roadmap naming the core fields it is missing. A fully valid index additionally requires the identity core-fields (`manufacturer_slug` and `catalog_model`); when those are absent the builder reports them through `MissingRequiredKeys` rather than emitting a complete index. +- **`incomplete`.** The floor: a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (`manufacturer_slug` and `catalog_model`) is schema-invalid, reported through `MissingRequiredKeys`, which is distinct from incomplete. - **`core`.** A complete, identifiable, legally-sellable luminaire with headline numbers: the identity, classification, headline photometric and electrical values, one-line colorimetry, and a market safety listing that a buyer needs before specifying the fixture. - **`standard`.** Core plus the fuller specification an LM-79 report produces: the maximum intensity, symmetry and coordinate system, materials, the test conditions and measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the conditional rows that apply to the fixture's form. - **`full`.** Standard plus exhaustive accredited characterization: zonal lumens, an operating point, measurement uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and (for white-light fixtures) TM-30 detail. These are mostly fed by accredited test reports. diff --git a/examples/README.md b/examples/README.md index a22f7d8..27857d8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,15 +16,15 @@ Source files (the original PDF datasheets, IES photometric files, and related do ## Conformance levels and roadmap -Each record is graded by the reference validator, which computes a conformance level from the fields actually present and emits a roadmap of what the record would need to climb to the next level. The level is stamped into `index.conformance_level` and is never hand-declared. The table below is the machine output of `ulc validate --verbose `; every gap names the field, the source document it comes from, and the governing standard. +Each record is graded by the reference validator, which computes a conformance grade from the fields actually present and emits a per-grade roadmap to full: the grades the record already satisfies and, for each grade not yet reached, only that grade's own remaining fields. The grade is stamped into `index.conformance_level` and is never hand-declared. The table below is the machine output of `ulc validate --verbose `; every gap names the field, the source document it comes from, and the governing standard, grouped per grade to full. -| File | Level | To reach the next level | +| File | Grade | Remaining grades to full | |---|---|---| | `erco-quintessence-30416-023.ulc` | standard | **full** needs test-report depth: `zonal_lumens` (IES, LM-79), `corrections_applied` (test report, LM-79), `uncertainty` (test report, LM-79 / GUM), `tm_30.rf` and `tm_30.rf_h_per_bin` (test report, TM-30), and instrumentation depth (test report, LM-79 / LM-75) | | `selux-aya-pole-sr-ho-3000k.ulc` | standard | **full** needs `corrections_applied`, `uncertainty` (test report, LM-79 / GUM), method-backed lumen maintenance (test report, LM-80 / TM-21 / TM-28), and `tm_30.rf` and `tm_30.rf_h_per_bin` (test report, TM-30) | | `lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc` | standard | **full** needs `zonal_lumens` (IES, LM-79), `corrections_applied`, `uncertainty` (test report, LM-79 / GUM), and method-backed lumen maintenance (test report, LM-80 / TM-21 / TM-28) | | `lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc` | standard | **full** needs `zonal_lumens` (IES, LM-79), `corrections_applied`, `uncertainty` (test report, LM-79 / GUM), and method-backed lumen maintenance (test report, LM-80 / TM-21 / TM-28) | -| `vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc` | core | **standard** needs `sdcm_step`, the MacAdam ellipse step (datasheet, ANSI C78.377); the Vode cutsheet publishes no MacAdam step | +| `vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc` | core | **standard** needs `sdcm_step`, the MacAdam ellipse step (datasheet, ANSI C78.377); the Vode cutsheet publishes no MacAdam step. **full** additionally needs `zonal_lumens` (IES, LM-79), `corrections_applied`, `uncertainty` (test report, LM-79 / GUM), method-backed lumen maintenance (test report, LM-80 / TM-21 / TM-28), and `tm_30.rf` and `tm_30.rf_h_per_bin` (test report, TM-30) | The four standard records sit one tier below full for the same structural reason: full certifies the complete accredited-report depth, measurement uncertainty, applied corrections, TM-30 fidelity, zonal lumens, and a method-backed maintenance projection, none of which a published cutsheet carries. A record may already satisfy one or two of those gates (Selux carries zonal lumens; Erco a TM-21 maintenance projection), but none carries the whole set. The Vode record is the lone example held at core, by a single missing field: it publishes no MacAdam SDCM step, which standard requires of a white-light fixture. diff --git a/examples/erco-quintessence-30416-023.ulc b/examples/erco-quintessence-30416-023.ulc index 19d28d7..b5ee324 100644 --- a/examples/erco-quintessence-30416-023.ulc +++ b/examples/erco-quintessence-30416-023.ulc @@ -346,7 +346,7 @@ "ul_8750" ], "beam_family": "wide_flood", - "builder_version": "0.4.0", + "builder_version": "0.5.0", "catalog_model": "Quintessence Downlight 30416", "catalog_number": "30416.023", "color_tunability": "static_white", @@ -837,5 +837,5 @@ "file_generation_type": "undefined", "photometry_basis": "absolute" }, - "ulc_version": "0.7.0" + "ulc_version": "0.8.0" } diff --git a/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc b/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc index ebb5c3e..aec6601 100644 --- a/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc +++ b/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc @@ -263,7 +263,7 @@ "rohs" ], "beam_family": "asymmetric", - "builder_version": "0.4.0", + "builder_version": "0.5.0", "catalog_model": "Lumenfacade Inground LOI Color Changing", "catalog_number": "LOI-120-12-RGB-30x60-TS0-XX-XX-ASL", "color_tunability": "rgb", @@ -587,5 +587,5 @@ "file_generation_type": "test_accredited_lab_lumen_scaled", "photometry_basis": "absolute" }, - "ulc_version": "0.7.0" + "ulc_version": "0.8.0" } diff --git a/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc b/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc index 5cff008..30eeff0 100644 --- a/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc +++ b/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc @@ -281,7 +281,7 @@ "rohs" ], "beam_family": "asymmetric", - "builder_version": "0.4.0", + "builder_version": "0.5.0", "catalog_model": "Lumenfacade Inground LOI Color Changing", "catalog_number": "LOI-120-12-RGBW30K-10X60-TS2.5-INTL-XX-XX", "color_tunability": "rgbw", @@ -603,5 +603,5 @@ "file_generation_type": "computer_simulation", "photometry_basis": "absolute" }, - "ulc_version": "0.7.0" + "ulc_version": "0.8.0" } diff --git a/examples/selux-aya-pole-sr-ho-3000k.ulc b/examples/selux-aya-pole-sr-ho-3000k.ulc index 7ce610b..9664534 100644 --- a/examples/selux-aya-pole-sr-ho-3000k.ulc +++ b/examples/selux-aya-pole-sr-ho-3000k.ulc @@ -429,7 +429,7 @@ "rohs" ], "bug_rating": "B2-U0-G1", - "builder_version": "0.4.0", + "builder_version": "0.5.0", "catalog_model": "Aya Pole or Pendant (AYAP)", "catalog_number": "selux-aya-pole-sr-ho-3000k", "color_tunability": "static_white", @@ -1061,5 +1061,5 @@ "file_generation_type": "test_accredited_lab", "photometry_basis": "absolute" }, - "ulc_version": "0.7.0" + "ulc_version": "0.8.0" } diff --git a/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc b/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc index 8df0a9c..6d8bbfe 100644 --- a/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc +++ b/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc @@ -381,7 +381,7 @@ "ul_1598" ], "beam_family": "medium_flood", - "builder_version": "0.4.0", + "builder_version": "0.5.0", "catalog_model": "Nexa Suspended 807", "catalog_number": "vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in", "color_tunability": "static_white", @@ -979,5 +979,5 @@ "file_generation_type": "test_accredited_lab", "photometry_basis": "absolute" }, - "ulc_version": "0.7.0" + "ulc_version": "0.8.0" } diff --git a/schema/taxonomy.schema.json b/schema/taxonomy.schema.json index 2df4040..d1787d3 100644 --- a/schema/taxonomy.schema.json +++ b/schema/taxonomy.schema.json @@ -1092,8 +1092,8 @@ }, "ConformanceLevel": { - "title": "ULC conformance level", - "description": "The completeness level a ULC record achieves, computed by the reference builder from the record's populated fields and stamped into the generated index. It is never hand-declared. There are four tiers, graded as a cumulative gate: the record IS the highest tier all of whose hard requirements it meets, walking the tiers low to high and stopping at the first unmet requirement. `incomplete` is a real photometric record (it carries the flux, input-power, and primary-category anchors) that has not yet met every core requirement; those anchors make the record gradeable at this tier and earn it a roadmap naming the core fields it is missing, while a fully valid index additionally requires the identity core-fields (`manufacturer_slug` and `catalog_model`), which the builder reports through `MissingRequiredKeys` when they are absent. `core` is the minimum identifying, photometric, electrical, one-line-colorimetry, and market-safety-listing dataset that makes a record a complete, legally-sellable luminaire. `standard` adds the fuller specification a typical LM-79 test report produces (full photometric geometry, materials, measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). `full` adds exhaustive accredited characterization (zonal lumens, operating point, measurement uncertainty, corrections applied, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products). Required fields in the JSON Schema are limited to the core anchors; everything beyond is schema-optional, and the achieved level reflects which graded fields a record carries. Conditional requirements that do not apply to a record (for example CRI for a pure color-mixing fixture) are never counted against it. A conformance level is a data-completeness grade, never a safety certification.", + "title": "ULC conformance grade", + "description": "The conformance grade a ULC record achieves, computed by the reference builder from the record's populated fields and stamped into the generated index. It is never hand-declared. There are three grades (`core`, `standard`, `full`) above an `incomplete` floor, graded as a cumulative gate: the record IS the highest grade all of whose hard requirements it meets, walking the grades low to high and stopping at the first unmet requirement. `incomplete` is the floor, not a grade: the state of a record that has not yet met a core requirement. It is never a published grade and never a trustworthy selection surface; it always travels with a roadmap naming the core fields the record still needs. The tooling never refuses an incomplete record (it grades it and emits the roadmap); a record that additionally lacks identity (`manufacturer_slug` and `catalog_model`) is schema-invalid, which the builder reports through `MissingRequiredKeys` and is distinct from `incomplete`. `core` is the first real grade: the minimum identifying, photometric, electrical, one-line-colorimetry, and market-safety-listing dataset (plus an attached cutsheet) that makes a record a complete, legally-sellable luminaire. `standard` adds the fuller specification a typical LM-79 test report produces (full photometric geometry, materials, measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). `full` adds exhaustive accredited characterization (zonal lumens, operating point, measurement uncertainty, corrections applied, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products). Required fields in the JSON Schema are limited to record identity; everything beyond is schema-optional, and the achieved grade reflects which graded fields a record carries. Conditional requirements that do not apply to a record (for example CRI for a pure color-mixing fixture) are never counted against it. A conformance grade is a data-completeness grade, never a safety certification.", "type": "string", "enum": [ "incomplete", diff --git a/schema/ulc.schema.json b/schema/ulc.schema.json index 70794fa..97ceb07 100644 --- a/schema/ulc.schema.json +++ b/schema/ulc.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ulcspec.org/schema/ulc.schema.json", "title": "ULC Record", - "description": "The canonical ULC (Universal Luminaire Cutsheet) record. One record describes one attested photometric scenario for a luminaire, with an applicability block that declares which orderable SKU configurations the record covers. Enumerated values are defined in taxonomy.schema.json and referenced here by $ref. Dual-unit conventions (SI authoritative with Imperial companion) apply to every unit-sensitive field. Conformance is computed at four levels (incomplete, core, standard, full): the reference builder grades the record from its populated fields and stamps the achieved level into the generated index as `index.conformance_level`, so a record is whatever level its data earns rather than whatever level it claims. JSON Schema `required` is used sparingly and only for core-level fields; the reference validator reports the computed level plus INFO guidance toward the next level. This schema is machine-readable; docs/authoring-patterns.md explains the four manufacturer authoring patterns this schema supports and how to map real cutsheets into it.", + "description": "The canonical ULC (Universal Luminaire Cutsheet) record. One record describes one attested photometric scenario for a luminaire, with an applicability block that declares which orderable SKU configurations the record covers. Enumerated values are defined in taxonomy.schema.json and referenced here by $ref. Dual-unit conventions (SI authoritative with Imperial companion) apply to every unit-sensitive field. Conformance is computed as three grades (core, standard, full) above an incomplete floor: the reference builder grades the record from its populated fields and stamps the achieved grade into the generated index as `index.conformance_level`, so a record is whatever grade its data earns rather than whatever grade it claims. `incomplete` is the floor, the state of a record that has not yet met core; it always travels with a roadmap and is never a published grade. JSON Schema `required` is used sparingly and only for record identity; the reference validator reports the computed grade plus INFO roadmap guidance toward each higher grade. This schema is machine-readable; docs/authoring-patterns.md explains the four manufacturer authoring patterns this schema supports and how to map real cutsheets into it.", "type": "object", "properties": { @@ -143,10 +143,10 @@ }, "source_files": { - "description": "Source files from which this record derives its data. Each file is referenced by filename, optional URL, and SHA-256 content hash. Files are not embedded. At least one source file is required at core level.", + "description": "Source files from which this record derives its data. Each file is referenced by filename, optional URL, and SHA-256 content hash. Files are not embedded. The array may be empty for an incomplete record that is still being authored; source files are graded core items, not a schema requirement.", "type": "array", "items": { "$ref": "#/$defs/SourceFile" }, - "minItems": 1 + "minItems": 0 }, "attestations": { @@ -189,7 +189,7 @@ "pattern": "^\\d+\\.\\d+\\.\\d+$" }, "conformance_level": { - "description": "The completeness level this record achieves. GENERATED, NOT HAND-AUTHORED: the reference builder computes it from the record's populated fields (applying the conditional rubric: directional categories require a beam angle, pure RGB/RGBA fixtures with no white channel skip CRI, outdoor-site products require BUG at standard, and so on) and stamps it here like every other index value. Because it is a builder projection, a hand-tampered value fails the build-parity check just like any other index field, so the level a record claims is the level its data actually achieves.", + "description": "The conformance grade this record achieves: one of the three grades (core, standard, full) or the incomplete floor. GENERATED, NOT HAND-AUTHORED: the reference builder computes it from the record's populated fields (applying the conditional rubric: directional categories require a beam angle, pure RGB/RGBA fixtures with no white channel skip CRI, outdoor-site products require BUG at standard, and so on) and stamps it here like every other index value. `incomplete` is the floor, below core: a record that has not yet met a core requirement. Because it is a builder projection, a hand-tampered value fails the build-parity check just like any other index field, so the grade a record claims is the grade its data actually achieves.", "$ref": "taxonomy.schema.json#/$defs/ConformanceLevel" }, "manufacturer_slug": { "type": "string" }, @@ -249,10 +249,7 @@ "builder_version", "conformance_level", "manufacturer_slug", - "catalog_model", - "primary_category", - "nominal_total_lumens", - "nominal_input_power_w" + "catalog_model" ] }, @@ -554,7 +551,7 @@ "introduced_at": { "type": "string", "format": "date" }, "end_of_life_program_url": { "type": "string", "format": "uri" } }, - "required": ["family_id", "manufacturer", "catalog_model", "cutsheet"] + "required": ["family_id", "manufacturer", "catalog_model"] }, "Configuration": { diff --git a/tools/validator/cmd/ulc/main.go b/tools/validator/cmd/ulc/main.go index b139ccc..9a86c27 100644 --- a/tools/validator/cmd/ulc/main.go +++ b/tools/validator/cmd/ulc/main.go @@ -432,10 +432,9 @@ USAGE grade.Report(res.Record, report) report.Finalize() + // conformance_level is always stamped now (the grader floors at `incomplete`), + // so the level string is never empty. level, _ := built["conformance_level"].(string) - if level == "" { - level = "none" - } if report.HasErrors() { fmt.Printf("%s -> %s (%d findings, not written)\n", res.RecordID, level, len(report.Findings)) if err := report.WriteText(os.Stderr, outPath); err != nil { @@ -449,7 +448,14 @@ USAGE failed = true continue } - fmt.Printf("%s -> %s (%d findings)\n", res.RecordID, level, len(report.Findings)) + // `incomplete` is a valid, expected outcome (the floor, below core): write the + // record and flag that it is not yet a publishable grade. The roadmap in the + // report names what core still needs. + if level == grade.LevelIncomplete.String() { + fmt.Printf("%s -> incomplete (below core; see roadmap) (%d findings)\n", res.RecordID, len(report.Findings)) + } else { + fmt.Printf("%s -> %s (%d findings)\n", res.RecordID, level, len(report.Findings)) + } } if sawSentinel { diff --git a/tools/validator/cmd/ulc/main_test.go b/tools/validator/cmd/ulc/main_test.go new file mode 100644 index 0000000..99ddc28 --- /dev/null +++ b/tools/validator/cmd/ulc/main_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// repoRoot resolves the repository root from this package directory +// (tools/validator/cmd/ulc -> up four levels). +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + return root +} + +// identityOnlyRecord is a schema-valid record with identity and zero source +// documents: the canonical floor case. It carries a stub index that build-index +// regenerates. +const identityOnlyRecord = `{ + "ulc_version": "0.8.0", + "record_id": "example-incomplete-floor", + "record_status": "announced", + "index": { "x-ulc-generated": true }, + "product_family": { + "family_id": "example-floor", + "manufacturer": { "slug": "example", "display_name": "Example Manufacturer" }, + "catalog_model": "Floor Demo" + }, + "configuration": { "photometric_scenario_id": "floor-demo-default" }, + "source_files": [] +}` + +// TestCLIFloorExitsZero pins the headline v0.8.0 contract at the CLI layer: an +// identity-only record (zero source documents) is a valid, expected outcome. +// build-index stamps conformance_level "incomplete" and exits 0; validate then +// exits 0 (the floor is never a data-completeness failure). +func TestCLIFloorExitsZero(t *testing.T) { + dir := t.TempDir() + recordPath := filepath.Join(dir, "floor.ulc") + if err := os.WriteFile(recordPath, []byte(identityOnlyRecord), 0o644); err != nil { + t.Fatalf("write record: %v", err) + } + + // build-index writes the index in place and must succeed (exit 0). + if rc := runBuildIndex([]string{recordPath}); rc != 0 { + t.Fatalf("build-index exit = %d, want 0 (incomplete is not a failure)", rc) + } + + // The stamped grade is the floor. + raw, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("re-read record: %v", err) + } + var rec map[string]any + if err := json.Unmarshal(raw, &rec); err != nil { + t.Fatalf("unmarshal record: %v", err) + } + idx, _ := rec["index"].(map[string]any) + if got := idx["conformance_level"]; got != "incomplete" { + t.Errorf("conformance_level = %v, want \"incomplete\"", got) + } + + // validate must exit 0 for a schema-valid record at the floor. + schemaDir := filepath.Join(repoRoot(t), "schema") + if rc := runValidate([]string{"--schema-dir", schemaDir, recordPath}); rc != 0 { + t.Errorf("validate exit = %d, want 0 (incomplete is a valid, expected outcome)", rc) + } +} + +// TestCLIFromSheetWritesRecord guards the from-sheet write path: a converted record +// is WRITTEN to --out and the run exits 0 (the converter no longer skips records on +// data completeness). Uses the canonical CSV bundle fixture, whose referenced files +// resolve against the bundle directory by default. +func TestCLIFromSheetWritesRecord(t *testing.T) { + bundleDir := filepath.Join(repoRoot(t), "tools", "validator", "internal", "sheet", "testdata", "bundle") + if _, err := os.Stat(bundleDir); err != nil { + t.Skipf("bundle fixture not available: %v", err) + } + outDir := t.TempDir() + + if rc := runFromSheet([]string{"--out", outDir, bundleDir}); rc != 0 { + t.Fatalf("from-sheet exit = %d, want 0", rc) + } + + entries, err := os.ReadDir(outDir) + if err != nil { + t.Fatalf("read out dir: %v", err) + } + wrote := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) == ".json" { + wrote++ + } + } + if wrote == 0 { + t.Error("from-sheet wrote no records to --out; the converter should write, not skip") + } +} diff --git a/tools/validator/internal/findings/findings.go b/tools/validator/internal/findings/findings.go index c43777d..c6c349b 100644 --- a/tools/validator/internal/findings/findings.go +++ b/tools/validator/internal/findings/findings.go @@ -50,11 +50,21 @@ const ( // parity check). Conformance grading therefore produces only INFO findings: a // human-facing report of what was computed, never a defect. // - // CodeConformanceLevel is the INFO summary naming the achieved level. + // CodeConformanceLevel is the INFO summary naming the achieved grade. CodeConformanceLevel Code = "conformance/level" + // CodeConformanceGradeSatisfied is the INFO finding emitted for each grade at or + // below the achieved grade: that grade is genuinely met. Carries the grade name in + // its message. + CodeConformanceGradeSatisfied Code = "conformance/grade-satisfied" + // CodeConformanceGradeGated is the INFO finding emitted for an outstanding grade + // (above the achieved grade) whose own requirements are nonetheless all met: it is + // gated only by an unmet lower grade. This is the cascade signal (close the lower + // grade and this one unlocks). A code-keyed consumer MUST treat this as "NOT + // achieved": the achieved grade is index.conformance_level, never this finding. + CodeConformanceGradeGated Code = "conformance/grade-gated" // CodeConformanceGap is the INFO roadmap guidance: one finding per hard field a - // record must add to reach the next level up (conditional predicates applied), - // each carrying the structured source-document and standard detail. + // record must add to reach a given grade (conditional predicates applied), each + // carrying the structured source-document and standard detail. CodeConformanceGap Code = "conformance/gap" // CodeConformanceObservation is an INFO surfaced at core and above: depth the // rubric does not gate on (thermal, flicker, circadian, sustainability, and diff --git a/tools/validator/internal/grade/grade.go b/tools/validator/internal/grade/grade.go index 73fe136..561efde 100644 --- a/tools/validator/internal/grade/grade.go +++ b/tools/validator/internal/grade/grade.go @@ -6,8 +6,8 @@ // runs first and catches structural defects; grading layers the conformance // rubric on top. // -// A record is not assigned a conformance level by hand: it simply IS whatever -// level its populated fields achieve. AchievedLevel is the pure computation the +// A record is not assigned a conformance grade by hand: it simply IS whatever +// grade its populated fields achieve. AchievedLevel is the pure computation the // reference builder calls to stamp index.conformance_level, and the build-parity // check then guards that stored value like any other index field. Because there // is no declaration to fall short of, conformance grading produces no WARNINGs. @@ -20,27 +20,26 @@ // requirement. Conditional rules whose applicability-predicate is false are // dropped entirely, never reported missing. // -// Four ordered tiers: +// Three grades above an incomplete floor: // -// incomplete a real photometric record (the three anchors flux + power + -// primary_category are present, so it is indexable) that has not yet -// met every core requirement. It still gets an index and a roadmap. -// core a complete, identifiable, legally-sellable luminaire with headline -// photometric/electrical numbers, one-line colorimetry, and a market -// safety listing. +// incomplete the floor: a record that has not yet met a core requirement. It is +// not a grade. It always gets an index and a roadmap to core, and the +// tooling never refuses it. It is the zero value of Level. +// core a complete, identifiable, legally-sellable luminaire with an +// attached cutsheet, headline photometric/electrical numbers, one-line +// colorimetry, and a market safety listing. // standard core plus the fuller specification an LM-79 report produces. // full standard plus exhaustive accredited characterization. // -// LevelNone sits below incomplete for a genuinely non-photometric record (no -// anchors); such a record is normally already rejected by schema validation and -// fails ulc build-index on its required keys regardless. LevelObservation is a -// non-ordered sentinel for the non-gating depth rows in the same table. +// LevelObservation is a non-ordered sentinel for the non-gating depth rows in the +// same table. // // Severity model: // -// INFO everything grading emits: the achieved-level summary, the roadmap to -// the next tier (each missing item naming its source document and -// standard), and, at core and above, the non-gating depth observations. +// INFO everything grading emits: the achieved-grade summary, the per-grade +// roadmap (each missing item naming its source document and standard, +// plus satisfied-grade and gated-grade markers), and, at core and above, +// the non-gating depth observations. package grade import ( @@ -51,17 +50,14 @@ import ( "github.com/ulcspec/ULC/tools/validator/internal/findings" ) -// Level is a conformance tier, ordered none < incomplete < core < standard < full. +// Level is a conformance grade, ordered incomplete < core < standard < full. type Level int const ( - // LevelNone: not a photometric record (missing the three anchors). Normally - // already rejected by schema validation, and fails ulc build-index on the - // nominal_total_lumens / nominal_input_power_w required keys regardless. - LevelNone Level = iota - // LevelIncomplete: a real photometric record (anchors present) that misses a - // core requirement. It gets an index and a roadmap to core. - LevelIncomplete + // LevelIncomplete is the floor: a record that has not yet met core. It is not a + // grade; it always carries a roadmap to core, and the tooling never refuses it. + // It is the zero value, so an unset Level reads as the floor. + LevelIncomplete Level = iota LevelCore LevelStandard LevelFull @@ -73,16 +69,16 @@ const ( // String returns the canonical lowercase conformance_level token. func (l Level) String() string { switch l { - case LevelIncomplete: - return "incomplete" case LevelCore: return "core" case LevelStandard: return "standard" case LevelFull: return "full" + case LevelObservation: + return "observation" default: - return "none" + return "incomplete" // floor fallback; the zero value renders here } } @@ -453,6 +449,20 @@ func hasMarketSafetyListing(r map[string]any) bool { return false } +// hasCutsheet checks that the product-family cutsheet is attached with a content +// hash. The cutsheet is a FileReference object whose sha256 is schema-required, so +// keying on sha256 means a placeholder object (no hash) reads as absent. The +// cutsheet moved from schema-required to a graded core item, so an unattached +// cutsheet now grades incomplete with a roadmap entry instead of failing schema +// validation. +func hasCutsheet(r map[string]any) bool { + m, ok := getMap(r, "product_family", "cutsheet") + if !ok { + return false + } + return getString(m, "sha256") != "" +} + // --- the rubric table --- // One row per section-3 item. Gating rows omit message (the roadmap derives it); @@ -466,6 +476,7 @@ var rubric = []rule{ {LevelCore, "/product_family/manufacturer/slug", "", "datasheet_pdf", "identity", "", str("product_family", "manufacturer", "slug"), nil}, {LevelCore, "/product_family/manufacturer/display_name", "", "datasheet_pdf", "identity", "", str("product_family", "manufacturer", "display_name"), nil}, {LevelCore, "/product_family/catalog_model", "", "datasheet_pdf", "identity", "", str("product_family", "catalog_model"), nil}, + {LevelCore, "/product_family/cutsheet", "", "datasheet_pdf", "identity", "", hasCutsheet, nil}, {LevelCore, "/product_family/primary_category", "PrimaryCategory", "datasheet_pdf", "identity", "", str("product_family", "primary_category"), nil}, {LevelCore, "/product_family/indoor_outdoor", "IndoorOutdoor", "datasheet_pdf", "identity", "", str("product_family", "indoor_outdoor"), nil}, {LevelCore, "/product_family/secondary_function", "SecondaryFunction", "datasheet_pdf", "identity", "", arr("product_family", "secondary_function"), nil}, @@ -546,16 +557,15 @@ var rubric = []rule{ // --- the ladder --- -// AchievedLevel returns the honest tier the record reaches: the highest tier all +// AchievedLevel returns the honest grade the record reaches: the highest grade all // of whose hard requirements (conditional predicates applied) are met, walking -// incomplete -> core -> standard -> full and stopping at the first unmet tier. -// A record without the photometric anchors is LevelNone (not a photometric -// record); a record with the anchors but missing a core requirement is -// LevelIncomplete. index.conformance_level == AchievedLevel().String(). +// core -> standard -> full and stopping at the first unmet grade. The floor of the +// walk is LevelIncomplete (returned when core is not yet met), the zero value: the +// grader never refuses and never returns a below-floor sentinel. A record missing +// identity (manufacturer_slug, catalog_model) is schema-invalid and is caught +// upstream; builder.go also reports it via MissingRequiredKeys. +// index.conformance_level == AchievedLevel().String(). func AchievedLevel(record map[string]any) Level { - if !hasPhotometricAnchors(record) { - return LevelNone - } if !allRulesMet(record, LevelCore) { return LevelIncomplete } @@ -568,17 +578,6 @@ func AchievedLevel(record map[string]any) Level { return LevelFull } -// hasPhotometricAnchors: the minimum that makes a record a gradeable photometric -// record (it reaches at least LevelIncomplete). The same three fields gate -// index.conformance_level emission in builder.go. They make the record gradeable, -// not fully index-valid: a complete index also needs the identity core-fields -// (manufacturer_slug, catalog_model), which builder.go reports via MissingRequiredKeys. -func hasPhotometricAnchors(record map[string]any) bool { - return hasNumberValue(record, "photometry", "total_luminous_flux_lm") && - hasNumberValue(record, "electrical", "input_power_w") && - getString(record, "product_family", "primary_category") != "" -} - // allRulesMet reports whether every applicable hard rule at lvl is present. func allRulesMet(record map[string]any, lvl Level) bool { for _, ru := range rubric { @@ -617,37 +616,50 @@ func missingAt(record map[string]any, lvl Level) []rule { // --- reporting --- // Report appends the conformance INFO findings to report and returns the achieved -// level. It is the human-facing half of grading: the stored index.conformance_level +// grade. It is the human-facing half of grading: the stored index.conformance_level // is already verified by the builder-parity step, so this explains what the record -// achieved and how to climb. It emits the achieved-level summary; a roadmap to the -// next tier (each missing item naming its source document and standard); and, once -// the record is a real core record, the non-gating depth observations, the -// non-measured-headline provenance note, and the attestation-coverage note. +// achieved and how to climb. It emits the achieved-grade summary; then, for each of +// the three grades, one of three per-grade states; and, once the record is a real +// core record, the non-gating depth observations, the non-measured-headline +// provenance note, and the attestation-coverage note. // // Report emits no WARNINGs and no ERRORs: there is no declaration to violate. func Report(record map[string]any, report *findings.Report) Level { achieved := AchievedLevel(record) - // LevelNone is not a ConformanceLevel enum token (its String() is the sentinel - // "none"), so present it as the absence of an indexable photometric record rather - // than as a conformance level. Every ordered tier (incomplete and above) names its - // level token. - if achieved == LevelNone { - report.AddInfo(findings.CodeConformanceLevel, "/index/conformance_level", - "this record lacks the photometric anchors (total_luminous_flux_lm, input_power_w, primary_category), so it is not an indexable photometric record and achieves no conformance level") - } else { - report.AddInfo(findings.CodeConformanceLevel, "/index/conformance_level", - fmt.Sprintf("this record achieves conformance level %q", achieved.String())) - } - - // Roadmap to the next tier (each missing item names its document + standard). - switch achieved { - case LevelIncomplete: - emitRoadmap(LevelCore, missingAt(record, LevelCore), report) - case LevelCore: - emitRoadmap(LevelStandard, missingAt(record, LevelStandard), report) - case LevelStandard: - emitRoadmap(LevelFull, missingAt(record, LevelFull), report) + report.AddInfo(findings.CodeConformanceLevel, "/index/conformance_level", + fmt.Sprintf("this record achieves conformance level %q", achieved.String())) + + // Per-grade emission, walking core -> standard -> full. missingAt is exact-tier, + // so each grade lists only its own delta (no repeats). Three states per grade: + // 1. satisfied the grade is at or below the achieved grade. + // 2. roadmap delta the grade is outstanding and has missing rows. + // 3. gated the grade is outstanding but its OWN rows are all met; it + // is gated only by a lower grade. This is the cascade signal + // (close the lower grade and this one unlocks immediately). + // blocker tracks the lowest outstanding grade that has a real delta, so a gated + // grade can name what to reach to unlock it. Because the walk is ascending, the + // first grade with a delta is the blocker for every gated grade above it. + blocker := LevelObservation // sentinel: no gap seen yet + for _, tier := range []Level{LevelCore, LevelStandard, LevelFull} { + if tier <= achieved { + report.AddInfo(findings.CodeConformanceGradeSatisfied, "/index/conformance_level", + fmt.Sprintf("conformance grade %q satisfied", tier.String())) + continue + } + delta := missingAt(record, tier) + if len(delta) == 0 { + // blocker is always set here: by the ladder, the grade just above `achieved` + // has a non-empty delta and is walked (setting blocker) before any gated + // grade above it, so a gated grade always has a named blocker to reach. + report.AddInfo(findings.CodeConformanceGradeGated, "/index/conformance_level", + fmt.Sprintf("conformance grade %q requirements are met; unlocked once %q is reached", tier.String(), blocker.String())) + continue + } + if blocker == LevelObservation { + blocker = tier + } + emitRoadmap(tier, delta, report) } // Depth observations and attestation coverage, only once the record is a real diff --git a/tools/validator/internal/grade/grade_drift_test.go b/tools/validator/internal/grade/grade_drift_test.go index c01e451..5b6d99e 100644 --- a/tools/validator/internal/grade/grade_drift_test.go +++ b/tools/validator/internal/grade/grade_drift_test.go @@ -177,6 +177,7 @@ func setDataPath(rec map[string]any, comps []string, val any) { // constructor), so a generic schema-correct value would not satisfy it. They are // covered by the behavioral tests instead. var predicateBackedPaths = map[string]bool{ + "/product_family/cutsheet": true, // hasCutsheet: needs an attached cutsheet with a content hash "/operating_point": true, // hasOperatingPoint: needs a recognized qualifier "/uncertainty": true, // hasUncertainty: needs coverage_factor_k + an expanded_* "/corrections_applied": true, // hasCorrectionsApplied: needs a recognized correction leaf @@ -711,7 +712,8 @@ func TestProvenancedNumberGatesRejectBareScalar(t *testing.T) { func TestCapstoneJunkObjectsCapAtIncomplete(t *testing.T) { junk := map[string]any{"zzz": float64(1)} rec := map[string]any{ - // Photometric anchors so the record is not LevelNone. + // Photometric anchors present; the record still floors at incomplete because + // the gated object paths carry only junk. "photometry": map[string]any{"total_luminous_flux_lm": map[string]any{"value": float64(1200)}}, "electrical": map[string]any{"input_power_w": map[string]any{"value": float64(10)}}, "product_family": map[string]any{"primary_category": "panel_troffer"}, diff --git a/tools/validator/internal/grade/grade_test.go b/tools/validator/internal/grade/grade_test.go index 6deefb0..b7dcc91 100644 --- a/tools/validator/internal/grade/grade_test.go +++ b/tools/validator/internal/grade/grade_test.go @@ -74,6 +74,7 @@ func coreBase() map[string]any { "product_family": map[string]any{ "manufacturer": map[string]any{"slug": "acme", "display_name": "Acme Lighting"}, "catalog_model": "Orbit 1200", + "cutsheet": map[string]any{"filename": "orbit-1200.pdf", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "primary_category": "panel_troffer", "indoor_outdoor": "indoor", "secondary_function": []any{"general_ambient"}, @@ -218,11 +219,11 @@ func TestReportEmitsInfoOnly(t *testing.T) { // --- the ladder --- -// TestAchievedLevelIncomplete pins the incomplete rung: a photometric record (the -// three anchors present) missing only a core requirement grades incomplete; adding -// the requirement lifts it to core; a record without the anchors grades none. +// TestAchievedLevelIncomplete pins the incomplete rung: a core-complete record +// missing only one core requirement grades incomplete; adding the requirement lifts +// it to core; an anchorless record also grades incomplete (the floor), never below. func TestAchievedLevelIncomplete(t *testing.T) { - // Drop the safety listing: anchors present, core safety gate unmet -> incomplete. + // Drop the safety listing: core safety gate unmet -> incomplete. rec := coreBase() delete(rec["product_family"].(map[string]any), "shared_attestations") if got := AchievedLevel(rec); got != LevelIncomplete { @@ -235,25 +236,24 @@ func TestAchievedLevelIncomplete(t *testing.T) { if got := AchievedLevel(rec); got != LevelCore { t.Errorf("with safety listing = %s, want core", got) } - // No photometric anchors -> none (not a photometric record). + // No photometric anchors -> incomplete (the floor), not a below-floor sentinel. if got := AchievedLevel(map[string]any{ "product_family": map[string]any{"primary_category": "panel_troffer"}, - }); got != LevelNone { - t.Errorf("no anchors = %s, want none", got) + }); got != LevelIncomplete { + t.Errorf("no anchors = %s, want incomplete (the floor)", got) } } -// TestReportNoneIsNotPresentedAsConformanceLevel pins G2: an anchorless record -// (LevelNone) emits a CodeConformanceLevel INFO that explains the missing photometric -// anchors rather than presenting the non-enum sentinel token "none" as a conformance -// level. -func TestReportNoneIsNotPresentedAsConformanceLevel(t *testing.T) { +// TestReportFloorIsIncomplete pins the floor: an anchorless record grades incomplete +// (never a below-floor sentinel), its CodeConformanceLevel INFO names the "incomplete" +// token, and it carries a to-core roadmap rather than refusing to grade. +func TestReportFloorIsIncomplete(t *testing.T) { rec := map[string]any{ "product_family": map[string]any{"primary_category": "panel_troffer"}, } report := findings.NewReport() - if got := Report(rec, report); got != LevelNone { - t.Fatalf("anchorless record = %s, want none", got) + if got := Report(rec, report); got != LevelIncomplete { + t.Fatalf("anchorless record = %s, want incomplete", got) } report.Finalize() levels := findingsFor(report, findings.CodeConformanceLevel) @@ -261,11 +261,16 @@ func TestReportNoneIsNotPresentedAsConformanceLevel(t *testing.T) { t.Fatalf("expected exactly one conformance-level finding, got %d: %+v", len(levels), levels) } msg := levels[0].Message - if strings.Contains(msg, `conformance level "none"`) { - t.Errorf("LevelNone message presents the sentinel token as a conformance level: %q", msg) + if !strings.Contains(msg, `conformance level "incomplete"`) { + t.Errorf("floor message should name the incomplete token: %q", msg) + } + // The floor always travels with a to-core roadmap. + if gaps := findingsFor(report, findings.CodeConformanceGap); len(gaps) == 0 { + t.Errorf("an incomplete record must carry a to-core roadmap; got none") } - if !strings.Contains(msg, "photometric anchors") { - t.Errorf("LevelNone message should explain the missing photometric anchors: %q", msg) + // No satisfied-grade findings: nothing is achieved at the floor. + if sat := findingsFor(report, findings.CodeConformanceGradeSatisfied); len(sat) != 0 { + t.Errorf("floor record should emit no satisfied-grade findings, got %d", len(sat)) } } @@ -544,10 +549,11 @@ func TestRequiresDimmingDetailGate(t *testing.T) { // --- roadmap + observations --- -// TestRoadmapNamesDocumentAndStandard confirms each gap finding carries the -// structured next-level / source-document / standard detail. +// TestRoadmapNamesDocumentAndStandard confirms the per-grade roadmap: an incomplete +// record (missing only the core safety listing) emits a to-core delta plus to-standard +// and to-full deltas, and every gap carries the structured next-grade / source-document +// / standard detail. func TestRoadmapNamesDocumentAndStandard(t *testing.T) { - // An incomplete record's to-core roadmap. rec := coreBase() delete(rec["product_family"].(map[string]any), "shared_attestations") report := findings.NewReport() @@ -557,16 +563,208 @@ func TestRoadmapNamesDocumentAndStandard(t *testing.T) { report.Finalize() gaps := findingsFor(report, findings.CodeConformanceGap) if len(gaps) == 0 { - t.Fatal("expected to-core gap findings, got none") + t.Fatal("expected roadmap gap findings, got none") } + validNext := map[string]bool{"core": true, "standard": true, "full": true} + coreGaps := 0 for _, g := range gaps { - if g.NextConformanceLevel != "core" { - t.Errorf("gap %q next level = %q, want core", g.Path, g.NextConformanceLevel) + if !validNext[g.NextConformanceLevel] { + t.Errorf("gap %q next level = %q, want one of core/standard/full", g.Path, g.NextConformanceLevel) + } + if g.NextConformanceLevel == "core" { + coreGaps++ } if g.SourceDocument == "" || g.Standard == "" { t.Errorf("gap %q missing document/standard: %+v", g.Path, g) } } + if coreGaps == 0 { + t.Error("expected at least one to-core gap (the missing safety listing)") + } +} + +// TestCutsheetIsCoreRule confirms the cutsheet is a graded core requirement: a record +// otherwise core-complete but missing the cutsheet grades incomplete, and its to-core +// roadmap names /product_family/cutsheet. +func TestCutsheetIsCoreRule(t *testing.T) { + rec := coreBase() + delete(rec["product_family"].(map[string]any), "cutsheet") + if got := AchievedLevel(rec); got != LevelIncomplete { + t.Fatalf("no cutsheet = %s, want incomplete", got) + } + report := findings.NewReport() + Report(rec, report) + report.Finalize() + named := false + for _, g := range findingsFor(report, findings.CodeConformanceGap) { + if g.Path == "/product_family/cutsheet" && g.NextConformanceLevel == "core" { + named = true + } + } + if !named { + t.Error("to-core roadmap must name /product_family/cutsheet") + } +} + +// TestSatisfiedGradesReported confirms the satisfied-grade findings: a standard record +// reports core+standard satisfied; a full record reports all three and emits no +// roadmap; a core record reports only core satisfied. +func TestSatisfiedGradesReported(t *testing.T) { + count := func(rec map[string]any, code findings.Code) int { + report := findings.NewReport() + Report(rec, report) + report.Finalize() + return len(findingsFor(report, code)) + } + if got := count(standardBase(), findings.CodeConformanceGradeSatisfied); got != 2 { + t.Errorf("standard record satisfied-grade findings = %d, want 2 (core, standard)", got) + } + if got := count(fullBase(), findings.CodeConformanceGradeSatisfied); got != 3 { + t.Errorf("full record satisfied-grade findings = %d, want 3", got) + } + if got := count(fullBase(), findings.CodeConformanceGap); got != 0 { + t.Errorf("full record gap findings = %d, want 0", got) + } + if got := count(coreBase(), findings.CodeConformanceGradeSatisfied); got != 1 { + t.Errorf("core record satisfied-grade findings = %d, want 1 (core)", got) + } +} + +// TestGatedGradesReported pins the cascade case: a record carrying full standard- and +// full-grade data but missing only the cutsheet grades incomplete, emits NO satisfied- +// grade finding, a to-core roadmap naming the cutsheet, and a gated-grade finding for +// BOTH standard and full (their own requirements are met, gated by core). +func TestGatedGradesReported(t *testing.T) { + rec := fullBase() + delete(rec["product_family"].(map[string]any), "cutsheet") + if got := AchievedLevel(rec); got != LevelIncomplete { + t.Fatalf("full-data record missing cutsheet = %s, want incomplete", got) + } + report := findings.NewReport() + Report(rec, report) + report.Finalize() + + if sat := findingsFor(report, findings.CodeConformanceGradeSatisfied); len(sat) != 0 { + t.Errorf("cascade record should emit no satisfied-grade findings, got %d", len(sat)) + } + gated := map[string]bool{} + for _, g := range findingsFor(report, findings.CodeConformanceGradeGated) { + if strings.Contains(g.Message, `"standard"`) { + gated["standard"] = true + } + if strings.Contains(g.Message, `"full"`) { + gated["full"] = true + } + // The gated message must name the blocker grade to reach (the cascade reveal): + // core is the only unmet grade, so every gated grade unlocks once core is reached. + if !strings.Contains(g.Message, `unlocked once "core" is reached`) { + t.Errorf("gated message should name the blocker grade to reach: %q", g.Message) + } + } + if !gated["standard"] || !gated["full"] { + t.Errorf("expected gated findings for standard AND full, got %v", gated) + } + coreGapNamesCutsheet := false + for _, g := range findingsFor(report, findings.CodeConformanceGap) { + if g.NextConformanceLevel == "core" && g.Path == "/product_family/cutsheet" { + coreGapNamesCutsheet = true + } + if g.NextConformanceLevel != "core" { + t.Errorf("cascade record should have only to-core gaps, got %q at %q", g.NextConformanceLevel, g.Path) + } + } + if !coreGapNamesCutsheet { + t.Error("to-core roadmap must name /product_family/cutsheet") + } +} + +// TestGatedNamesIntermediateBlocker pins the second blocker path: a record that meets +// core and every full requirement but is missing one STANDARD row achieves core, and +// full (its own rows all met) is gated by standard, so the gated message names standard +// (not core) as the grade to reach. +func TestGatedNamesIntermediateBlocker(t *testing.T) { + rec := fullBase() + delete(rec["photometry"].(map[string]any), "maximum_intensity_cd") // a standard rule + if got := AchievedLevel(rec); got != LevelCore { + t.Fatalf("achieved = %s, want core", got) + } + report := findings.NewReport() + Report(rec, report) + report.Finalize() + found := false + for _, g := range findingsFor(report, findings.CodeConformanceGradeGated) { + if strings.Contains(g.Message, `"full"`) { + found = true + if !strings.Contains(g.Message, `unlocked once "standard" is reached`) { + t.Errorf("full gated message should name standard as the blocker: %q", g.Message) + } + } + } + if !found { + t.Error("expected full to be gated (by standard) when only a standard row is missing") + } +} + +// TestRoadmapPerTierToFull confirms the per-grade decomposition: a core record emits +// both a to-standard and a to-full roadmap whose deltas are disjoint (no path repeats); +// a standard record emits only a to-full roadmap. +func TestRoadmapPerTierToFull(t *testing.T) { + report := findings.NewReport() + Report(coreBase(), report) + report.Finalize() + std, full := map[string]bool{}, map[string]bool{} + for _, g := range findingsFor(report, findings.CodeConformanceGap) { + switch g.NextConformanceLevel { + case "standard": + std[g.Path] = true + case "full": + full[g.Path] = true + default: + t.Errorf("core record gap at unexpected grade %q (%q)", g.NextConformanceLevel, g.Path) + } + } + if len(std) == 0 || len(full) == 0 { + t.Fatalf("core record should emit both to-standard (%d) and to-full (%d) deltas", len(std), len(full)) + } + for p := range std { + if full[p] { + t.Errorf("path %q appears in BOTH to-standard and to-full deltas; must be disjoint", p) + } + } + + report2 := findings.NewReport() + Report(standardBase(), report2) + report2.Finalize() + for _, g := range findingsFor(report2, findings.CodeConformanceGap) { + if g.NextConformanceLevel != "full" { + t.Errorf("standard record should emit only to-full gaps, got %q at %q", g.NextConformanceLevel, g.Path) + } + } +} + +// TestZeroDocumentRecordGradesIncomplete pins the floor's worked case: an identity-only +// record (no photometry, no electrical, no documents) grades incomplete, emits only INFO +// findings, and carries a to-core roadmap. +func TestZeroDocumentRecordGradesIncomplete(t *testing.T) { + rec := map[string]any{ + "product_family": map[string]any{ + "family_id": "acme-orbit", + "manufacturer": map[string]any{"slug": "acme", "display_name": "Acme Lighting"}, + "catalog_model": "Orbit 1200", + }, + "source_files": []any{}, + } + report := findings.NewReport() + if got := Report(rec, report); got != LevelIncomplete { + t.Fatalf("identity-only record = %s, want incomplete", got) + } + report.Finalize() + if report.Summary.Errors != 0 || report.Summary.Warnings != 0 { + t.Errorf("floor record should be INFO-only, got %d errors, %d warnings", report.Summary.Errors, report.Summary.Warnings) + } + if gaps := findingsFor(report, findings.CodeConformanceGap); len(gaps) == 0 { + t.Error("identity-only record must carry a to-core roadmap") + } } // TestObservationsAtCoreAndAbove confirms a core record emits observation findings diff --git a/tools/validator/internal/index/builder.go b/tools/validator/internal/index/builder.go index ffbced7..f3260e8 100644 --- a/tools/validator/internal/index/builder.go +++ b/tools/validator/internal/index/builder.go @@ -34,7 +34,13 @@ import ( // one-line colorimetry + a market safety listing); standard and full gain // white-light, directional, outdoor-site, and wet-location conditionals. Computed // levels recompute, so every stored index re-stamps on the next ulc build-index. -const BuilderVersion = "0.4.0" +// 0.5.0: `incomplete` is now the floor (the zero value); conformance_level is +// ALWAYS stamped (the builder never refuses on data completeness); the index may be +// sparse for an incomplete record (photometric projections omitted when absent); +// RequiredKeys shrinks to identity plus the always-generated keys; the cutsheet +// moved from schema-required to a graded core item; the roadmap is per-grade to +// full. Computed levels recompute, so every stored index re-stamps on build-index. +const BuilderVersion = "0.5.0" // RequiredKeys mirrors schema/ulc.schema.json#/$defs/Index.required. The Go // validator enforces this set directly; the legacy Python builder-parity-guard @@ -45,26 +51,19 @@ var RequiredKeys = []string{ "conformance_level", "manufacturer_slug", "catalog_model", - "primary_category", - "nominal_total_lumens", - "nominal_input_power_w", } // RequiredKeySources gives the source path for each required key, used when // the builder refuses to emit an invalid index because a deep block is sparse. +// Only identity keys remain: a record missing them is schema-invalid (malformed), +// which is distinct from `incomplete`. conformance_level is always stamped (the +// grader floors at `incomplete`), so it is never reported missing for a data- +// completeness gap; the photometric projections are now sparse-by-nature and no +// longer required keys. var RequiredKeySources = map[string]string{ - "manufacturer_slug": "product_family.manufacturer.slug", - "catalog_model": "product_family.catalog_model", - "primary_category": "product_family.primary_category", - "nominal_total_lumens": "photometry.total_luminous_flux_lm.value", - "nominal_input_power_w": "electrical.input_power_w.value", - // conformance_level emission is gated on the three photometric anchors (the - // flux and power values above plus the primary category); when those are - // absent the record is not a photometric record and grade.AchievedLevel - // returns LevelNone (no token emitted). When the anchors are present the - // emitted token may be `incomplete` if a core requirement beyond the anchors - // is still missing; the record is indexed and carries a roadmap to core. - "conformance_level": "photometry.total_luminous_flux_lm.value, electrical.input_power_w.value, product_family.primary_category", + "manufacturer_slug": "product_family.manufacturer.slug", + "catalog_model": "product_family.catalog_model", + "conformance_level": "computed by grade.AchievedLevel (always stamped; floors at incomplete)", } // Record is the in-memory representation of a parsed .ulc file. @@ -88,17 +87,13 @@ func Build(record Record) Index { "builder_version": BuilderVersion, } - // Conformance level: computed from the record's populated fields, the single - // source of truth for the stored level. Emit only a valid enum token - // (incomplete / core / standard / full). A record carrying the photometric - // anchors always reaches at least `incomplete`, so a real photometric record - // is never refused over its grade. A record LACKING the anchors grades - // grade.LevelNone: it is not a photometric record at all, conformance_level is - // then absent, and MissingRequiredKeys reports it alongside the other missing - // core keys, so the builder never emits an out-of-enum value. - if lvl := grade.AchievedLevel(record); lvl != grade.LevelNone { - idx["conformance_level"] = lvl.String() - } + // Conformance grade: computed from the record's populated fields, the single + // source of truth for the stored grade. ALWAYS stamped: grade.AchievedLevel + // floors at `incomplete` (the zero value) and never returns a below-floor + // sentinel, so every record (down to identity-only) gets a valid enum token + // (incomplete / core / standard / full). The builder never refuses on data + // completeness; an incomplete record is indexed and carries a roadmap to core. + idx["conformance_level"] = grade.AchievedLevel(record).String() if v := getString(pf, "manufacturer", "slug"); v != "" { idx["manufacturer_slug"] = v diff --git a/tools/validator/internal/index/builder_test.go b/tools/validator/internal/index/builder_test.go index 32fa58c..23df6e3 100644 --- a/tools/validator/internal/index/builder_test.go +++ b/tools/validator/internal/index/builder_test.go @@ -86,7 +86,7 @@ func TestMissingRequiredKeysSorted(t *testing.T) { "primary_category": "downlight", } missing := MissingRequiredKeys(built) - want := []string{"conformance_level", "manufacturer_slug", "nominal_input_power_w", "nominal_total_lumens"} + want := []string{"conformance_level", "manufacturer_slug"} if len(missing) != len(want) { t.Fatalf("got %v, want %v", missing, want) } @@ -97,6 +97,33 @@ func TestMissingRequiredKeysSorted(t *testing.T) { } } +// TestBuildStampsIncompleteFloorSparse pins the v0.8.0 floor at the builder layer: +// Build() always stamps conformance_level (the grader floors at incomplete, never a +// below-floor sentinel), the index is sparse for an identity-only record (the +// photometric projections are omitted when their data is absent), and no required +// key is missing because identity is present. +func TestBuildStampsIncompleteFloorSparse(t *testing.T) { + record := Record{ + "product_family": map[string]any{ + "manufacturer": map[string]any{"slug": "acme", "display_name": "Acme Lighting"}, + "catalog_model": "Orbit 1200", + }, + "source_files": []any{}, + } + built := Build(record) + if got := built["conformance_level"]; got != "incomplete" { + t.Errorf("conformance_level = %v, want \"incomplete\" (always stamped, floored)", got) + } + for _, key := range []string{"primary_category", "nominal_total_lumens", "nominal_input_power_w"} { + if v, present := built[key]; present { + t.Errorf("sparse index should omit %q for an identity-only record, got %v", key, v) + } + } + if missing := MissingRequiredKeys(built); len(missing) != 0 { + t.Errorf("identity-only record should have no missing required keys, got %v", missing) + } +} + // --- helpers --- func loadRecord(path string) (Record, error) { From 5de1bc4cf8675edfe7d3612456b5aa9090fecc47 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:59:32 -0400 Subject: [PATCH 2/7] feat(grade): accept a missing cutsheet in from-sheet and align the docs - Make ulc from-sheet accept a workbook record with an empty cutsheet_file: it converts the record (omitting product_family.cutsheet and the synthesized datasheet source-file entry) so the record grades incomplete with a roadmap, instead of failing during conversion. - Default the from-sheet ulc_version to 0.8.0. - Add the cutsheet to the methodology core-requirements table. - Reframe the PIM mapping guides: omitting the cutsheet grades incomplete, not schema-invalid, since the cutsheet is a graded core requirement. - Clarify the schema and CLI text (the schema envelope versus identity; the per-grade roadmap to full). - Add tests: cutsheet-optional conversion, the malformed-versus-incomplete identity boundary, and the from-sheet incomplete write path. --- CHANGELOG.md | 2 +- docs/methodology.md | 1 + mappings/pim/README.md | 2 +- mappings/pim/akeneo.md | 2 +- mappings/pim/custom-pim.md | 2 +- mappings/pim/salsify.md | 2 +- mappings/pim/sap.md | 2 +- schema/ulc.schema.json | 2 +- tools/validator/cmd/ulc/main.go | 6 +- tools/validator/cmd/ulc/main_test.go | 109 +++++++++++++++++++ tools/validator/internal/sheet/convert.go | 33 +++--- tools/validator/internal/sheet/sheet_test.go | 38 +++++++ 12 files changed, 177 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1c143..64d1f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ For emergency manual releases (bypassing the PR flow), trigger the `Release on m ### Builder and CLI - The builder always stamps `conformance_level`. The index may be sparse for an `incomplete` record (photometric projections are omitted when their data is absent). `BuilderVersion` is `0.5.0`, so every stored index re-stamps on the next `ulc build-index`. -- `ulc from-sheet` writes an `incomplete` converted record to its output directory with a console notice that it is below core; it no longer skips such a record. +- `ulc from-sheet` accepts a workbook record with an empty `cutsheet_file`: it converts the record (omitting `product_family.cutsheet` and the synthesized datasheet source-file entry) instead of failing, so a cutsheet-less record grades `incomplete` with a roadmap. It writes an `incomplete` converted record to its output directory with a console notice that it is below core; it no longer skips such a record. ### Examples and docs diff --git a/docs/methodology.md b/docs/methodology.md index 6367d4f..d6a20ce 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -121,6 +121,7 @@ Two axes order the whole structure. The first is **evidence depth**: each tier a | --- | --- | --- | | Manufacturer slug and display name | `product_family.manufacturer.slug`, `.display_name` | `datasheet_pdf` | | Catalog model | `product_family.catalog_model` | `datasheet_pdf` | +| Cutsheet | `product_family.cutsheet` | `datasheet_pdf` | | Primary category | `product_family.primary_category` | `datasheet_pdf` | | Indoor / outdoor | `product_family.indoor_outdoor` | `datasheet_pdf` | | Secondary function | `product_family.secondary_function` | `datasheet_pdf` | diff --git a/mappings/pim/README.md b/mappings/pim/README.md index 8e3542e..9742d70 100644 --- a/mappings/pim/README.md +++ b/mappings/pim/README.md @@ -74,7 +74,7 @@ ULC's `source_files[]` array requires a SHA-256 hash for every source file (cuts If the manufacturer serves cutsheet PDFs from a CDN, the `reference.url` field can point to the stable URL; `sha256` still anchors integrity even if the URL rots. -**The cutsheet file lives in two places in a ULC record.** The schema requires `product_family.cutsheet` as its own `FileReference` (the family-level cutsheet pointer) in addition to the `source_files[]` entry with `file_type: "datasheet_pdf"`. An emitter that populates only `source_files[]` will produce schema-invalid records because `ProductFamily.required` includes `cutsheet`. Populate both places with the same filename, sha256, revision label, and revision date from the single computed hash; the two blocks carry different consumer semantics (family identity vs. integrity-tracked source-file list) but refer to the same byte-identical file. +**The cutsheet file lives in two places in a ULC record.** A complete record carries `product_family.cutsheet` as its own `FileReference` (the family-level cutsheet pointer) in addition to the `source_files[]` entry with `file_type: "datasheet_pdf"`. An emitter that populates only `source_files[]` produces a record that grades `incomplete` rather than `core`, because `product_family.cutsheet` is a graded core requirement (it is not schema-required, so the record still validates and carries a roadmap naming the cutsheet). Populate both places with the same filename, sha256, revision label, and revision date from the single computed hash; the two blocks carry different consumer semantics (family identity vs. integrity-tracked source-file list) but refer to the same byte-identical file. ### 5. Category and enum mapping diff --git a/mappings/pim/akeneo.md b/mappings/pim/akeneo.md index 719bd18..a3a44a5 100644 --- a/mappings/pim/akeneo.md +++ b/mappings/pim/akeneo.md @@ -90,7 +90,7 @@ In Community Edition (no Asset Manager), cutsheet files typically live as plain Asset version maps to `reference.revision_label`; `updated_at` to `reference.revision_date`. -The cutsheet asset populates **both** `source_files[]` (as the entry with `file_type: "datasheet_pdf"`) **and** `product_family.cutsheet` (which `ProductFamily.required` makes mandatory). Stream and hash once, write the same `FileReference` fields in both places; an emitter that only populates `source_files[]` produces schema-invalid records. +The cutsheet asset populates **both** `source_files[]` (as the entry with `file_type: "datasheet_pdf"`) **and** `product_family.cutsheet` (a graded core requirement). Stream and hash once, write the same `FileReference` fields in both places; an emitter that only populates `source_files[]` produces a record that grades `incomplete` rather than `core`. ### Locales and channels diff --git a/mappings/pim/custom-pim.md b/mappings/pim/custom-pim.md index b3c172a..6d84735 100644 --- a/mappings/pim/custom-pim.md +++ b/mappings/pim/custom-pim.md @@ -130,7 +130,7 @@ def build_source_file_entry(file_record): If files live on S3 or a remote store, stream via the storage SDK rather than downloading into memory for large files. Cache the (storage-key, version) → hash mapping; the hash is stable as long as the bytes don't change. -**The cutsheet file populates both `source_files[]` and `product_family.cutsheet`.** `ProductFamily.required` in the schema includes `cutsheet`, so an emitter that writes only the `source_files[]` entry will produce schema-invalid records. In the `build_source_file_entry` pipeline above, the cutsheet's computed `{filename, sha256, revision_date}` should additionally be written into `product_family.cutsheet`. The same bytes live in one place; the record references them twice with different consumer semantics (family identity vs. integrity-tracked source-file list). +**The cutsheet file populates both `source_files[]` and `product_family.cutsheet`.** `product_family.cutsheet` is a graded core requirement, so an emitter that writes only the `source_files[]` entry produces a record that grades `incomplete` rather than `core` (it still validates and carries a roadmap naming the cutsheet). In the `build_source_file_entry` pipeline above, the cutsheet's computed `{filename, sha256, revision_date}` should additionally be written into `product_family.cutsheet`. The same bytes live in one place; the record references them twice with different consumer semantics (family identity vs. integrity-tracked source-file list). ### Attestations diff --git a/mappings/pim/salsify.md b/mappings/pim/salsify.md index c411cb9..29a1b2c 100644 --- a/mappings/pim/salsify.md +++ b/mappings/pim/salsify.md @@ -84,7 +84,7 @@ Salsify's asset model maps cleanly to ULC's `source_files[]`: The emitter streams each asset by its Salsify asset ID, computes SHA-256, and populates `source_files[].reference.{filename, sha256, url, revision_label, revision_date}`. `revision_label` comes from Salsify's asset version number; `revision_date` from the asset's `updated_at` timestamp. -**The cutsheet file populates both `source_files[]` and `product_family.cutsheet`.** `ProductFamily.required` in the schema includes `cutsheet`, so an emitter that writes only the `source_files[]` entry will produce schema-invalid records. When the Salsify `cutsheet_pdf` asset is streamed and hashed, copy the computed `{filename, sha256, url, revision_label, revision_date}` into `product_family.cutsheet` as well. The same bytes live in one place on disk; the record references them twice with different consumer semantics. +**The cutsheet file populates both `source_files[]` and `product_family.cutsheet`.** `product_family.cutsheet` is a graded core requirement, so an emitter that writes only the `source_files[]` entry produces a record that grades `incomplete` rather than `core` (it still validates and carries a roadmap naming the cutsheet). When the Salsify `cutsheet_pdf` asset is streamed and hashed, copy the computed `{filename, sha256, url, revision_label, revision_date}` into `product_family.cutsheet` as well. The same bytes live in one place on disk; the record references them twice with different consumer semantics. ### Relationships to accessories diff --git a/mappings/pim/sap.md b/mappings/pim/sap.md index c64bbf5..6a7e1cb 100644 --- a/mappings/pim/sap.md +++ b/mappings/pim/sap.md @@ -90,7 +90,7 @@ Cutsheet PDFs, IES, LDT, and lab-test reports live as DMS records. The emitter q | `INSTALL_PDF` | `installation_instructions_pdf` | | `LAB_REPORT` | (metadata only, referenced via `attestations[].source_document_ref`) | -The emitter streams each DMS original, computes SHA-256, and fills `source_files[].reference`. The `CUTSHEET` DMS record additionally populates `product_family.cutsheet` (which `ProductFamily.required` makes mandatory). Stream and hash the DMS original once, then write the same `{filename, sha256, revision_label, revision_date}` into both `product_family.cutsheet` and the `source_files[]` entry for `datasheet_pdf`. An emitter that only populates `source_files[]` produces schema-invalid records. +The emitter streams each DMS original, computes SHA-256, and fills `source_files[].reference`. The `CUTSHEET` DMS record additionally populates `product_family.cutsheet` (a graded core requirement). Stream and hash the DMS original once, then write the same `{filename, sha256, revision_label, revision_date}` into both `product_family.cutsheet` and the `source_files[]` entry for `datasheet_pdf`. An emitter that only populates `source_files[]` produces a record that grades `incomplete` rather than `core`. ### Attestations diff --git a/schema/ulc.schema.json b/schema/ulc.schema.json index 97ceb07..dd082ee 100644 --- a/schema/ulc.schema.json +++ b/schema/ulc.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ulcspec.org/schema/ulc.schema.json", "title": "ULC Record", - "description": "The canonical ULC (Universal Luminaire Cutsheet) record. One record describes one attested photometric scenario for a luminaire, with an applicability block that declares which orderable SKU configurations the record covers. Enumerated values are defined in taxonomy.schema.json and referenced here by $ref. Dual-unit conventions (SI authoritative with Imperial companion) apply to every unit-sensitive field. Conformance is computed as three grades (core, standard, full) above an incomplete floor: the reference builder grades the record from its populated fields and stamps the achieved grade into the generated index as `index.conformance_level`, so a record is whatever grade its data earns rather than whatever grade it claims. `incomplete` is the floor, the state of a record that has not yet met core; it always travels with a roadmap and is never a published grade. JSON Schema `required` is used sparingly and only for record identity; the reference validator reports the computed grade plus INFO roadmap guidance toward each higher grade. This schema is machine-readable; docs/authoring-patterns.md explains the four manufacturer authoring patterns this schema supports and how to map real cutsheets into it.", + "description": "The canonical ULC (Universal Luminaire Cutsheet) record. One record describes one attested photometric scenario for a luminaire, with an applicability block that declares which orderable SKU configurations the record covers. Enumerated values are defined in taxonomy.schema.json and referenced here by $ref. Dual-unit conventions (SI authoritative with Imperial companion) apply to every unit-sensitive field. Conformance is computed as three grades (core, standard, full) above an incomplete floor: the reference builder grades the record from its populated fields and stamps the achieved grade into the generated index as `index.conformance_level`, so a record is whatever grade its data earns rather than whatever grade it claims. `incomplete` is the floor, the state of a record that has not yet met core; it always travels with a roadmap and is never a published grade. JSON Schema `required` is used sparingly, for the record envelope (version, identifiers, status, the generated index container, configuration, and the source-files array) and product-family identity rather than for data completeness; the reference validator reports the computed grade plus INFO roadmap guidance toward each higher grade. This schema is machine-readable; docs/authoring-patterns.md explains the four manufacturer authoring patterns this schema supports and how to map real cutsheets into it.", "type": "object", "properties": { diff --git a/tools/validator/cmd/ulc/main.go b/tools/validator/cmd/ulc/main.go index 9a86c27..af1908a 100644 --- a/tools/validator/cmd/ulc/main.go +++ b/tools/validator/cmd/ulc/main.go @@ -94,7 +94,7 @@ Runs four checks and emits a findings report: 2. Builder parity (stored index matches the deterministic projection, including the computed index.conformance_level) 3. Source-file SHA-256 hash verification (when files are reachable locally) - 4. Conformance report (INFO: the computed level plus guidance toward the next) + 4. Conformance report (INFO: the computed grade plus a per-grade roadmap to full) Exit codes: 0 no ERROR findings (WARNING and INFO do not fail validation) @@ -196,10 +196,10 @@ USAGE recordDir := filepath.Dir(recordPath) validate.VerifyHashes(recordDir, recordMap, report) - // 4. Conformance report. The achieved level was already computed by the + // 4. Conformance report. The achieved grade was already computed by the // builder and stored in index.conformance_level, and the parity step above // guards that stored value. This step is the human-facing report: it prints - // the computed level plus guidance toward the next level (INFO only, never a + // the computed grade plus a per-grade roadmap to full (INFO only, never a // defect). A record is whatever level its data achieves; there is nothing to // fall short of, so conformance produces no WARNINGs. grade.Report(recordMap, report) diff --git a/tools/validator/cmd/ulc/main_test.go b/tools/validator/cmd/ulc/main_test.go index 99ddc28..2f3aabd 100644 --- a/tools/validator/cmd/ulc/main_test.go +++ b/tools/validator/cmd/ulc/main_test.go @@ -4,9 +4,32 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) +// copyFlatDir copies a flat directory of files (the sheet bundle fixtures are flat) +// from src into dst, which must already exist. +func copyFlatDir(t *testing.T, src, dst string) { + t.Helper() + entries, err := os.ReadDir(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(src, e.Name())) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil { + t.Fatalf("write %s: %v", e.Name(), err) + } + } +} + // repoRoot resolves the repository root from this package directory // (tools/validator/cmd/ulc -> up four levels). func repoRoot(t *testing.T) string { @@ -72,6 +95,37 @@ func TestCLIFloorExitsZero(t *testing.T) { } } +// missingDesignationRecord drops catalog_model: identity (the manufacturer name and +// the catalog designation) is the ONLY non-optional thing, so a record that cannot be +// identified is malformed, distinct from the incomplete floor. +const missingDesignationRecord = `{ + "ulc_version": "0.8.0", + "record_id": "example-no-designation", + "record_status": "announced", + "index": { "x-ulc-generated": true }, + "product_family": { + "family_id": "example-floor", + "manufacturer": { "slug": "example", "display_name": "Example Manufacturer" } + }, + "configuration": { "photometric_scenario_id": "floor-demo-default" }, + "source_files": [] +}` + +// TestCLIMissingIdentityIsRejected pins the floor's one hard requirement: a record +// missing its designation (catalog_model) is rejected, NOT graded incomplete. This is +// the boundary between "incomplete is a valid floor" and "the record cannot be +// identified," so build-index reports the missing required key and exits nonzero. +func TestCLIMissingIdentityIsRejected(t *testing.T) { + dir := t.TempDir() + recordPath := filepath.Join(dir, "no-designation.ulc") + if err := os.WriteFile(recordPath, []byte(missingDesignationRecord), 0o644); err != nil { + t.Fatalf("write record: %v", err) + } + if rc := runBuildIndex([]string{recordPath}); rc == 0 { + t.Error("build-index exit = 0 for a record missing catalog_model; identity (name + designation) is required, so this is malformed, not incomplete") + } +} + // TestCLIFromSheetWritesRecord guards the from-sheet write path: a converted record // is WRITTEN to --out and the run exits 0 (the converter no longer skips records on // data completeness). Uses the canonical CSV bundle fixture, whose referenced files @@ -101,3 +155,58 @@ func TestCLIFromSheetWritesRecord(t *testing.T) { t.Error("from-sheet wrote no records to --out; the converter should write, not skip") } } + +// TestCLIFromSheetWritesIncompleteRecord pins the headline from-sheet promise at the +// CLI seam: a workbook record with no cutsheet is WRITTEN to --out (not skipped), +// exits 0, and lands with conformance_level "incomplete". +func TestCLIFromSheetWritesIncompleteRecord(t *testing.T) { + bundleSrc := filepath.Join(repoRoot(t), "tools", "validator", "internal", "sheet", "testdata", "bundle") + if _, err := os.Stat(bundleSrc); err != nil { + t.Skipf("bundle fixture not available: %v", err) + } + bundleDir := filepath.Join(t.TempDir(), "bundle") + if err := os.MkdirAll(bundleDir, 0o755); err != nil { + t.Fatal(err) + } + copyFlatDir(t, bundleSrc, bundleDir) + // Blank the cutsheet so the converted record grades incomplete. + recordsPath := filepath.Join(bundleDir, "records.csv") + b, err := os.ReadFile(recordsPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(recordsPath, []byte(strings.Replace(string(b), "acme-orbit-1200-specs.pdf", "", 1)), 0o644); err != nil { + t.Fatal(err) + } + + outDir := t.TempDir() + if rc := runFromSheet([]string{"--out", outDir, bundleDir}); rc != 0 { + t.Fatalf("from-sheet exit = %d, want 0 (an incomplete record is written, not skipped)", rc) + } + + entries, err := os.ReadDir(outDir) + if err != nil { + t.Fatalf("read out dir: %v", err) + } + written := "" + for _, e := range entries { + if filepath.Ext(e.Name()) == ".json" { + written = filepath.Join(outDir, e.Name()) + } + } + if written == "" { + t.Fatal("from-sheet did not write the incomplete record to --out") + } + raw, err := os.ReadFile(written) + if err != nil { + t.Fatalf("read written record: %v", err) + } + var rec map[string]any + if err := json.Unmarshal(raw, &rec); err != nil { + t.Fatalf("unmarshal written record: %v", err) + } + idx, _ := rec["index"].(map[string]any) + if got := idx["conformance_level"]; got != "incomplete" { + t.Errorf("written record conformance_level = %v, want \"incomplete\"", got) + } +} diff --git a/tools/validator/internal/sheet/convert.go b/tools/validator/internal/sheet/convert.go index d575f6d..5bf4809 100644 --- a/tools/validator/internal/sheet/convert.go +++ b/tools/validator/internal/sheet/convert.go @@ -192,7 +192,7 @@ func assembleRecord(wb Workbook, id string, master Row, pattern Pattern, hasher // ulc_version default per DESIGN.md (overridable by the records column). // Tracks the current ULC spec version, matching every shipped example and // template; a manufacturer who omits the column gets a current-spec record. - rec["ulc_version"] = "0.7.0" + rec["ulc_version"] = "0.8.0" // record_status default: active (overridable below). rec["record_status"] = "active" @@ -218,17 +218,20 @@ func assembleRecord(wb Workbook, id string, master Row, pattern Pattern, hasher } // Cutsheet dual-write: records.cutsheet_file -> product_family.cutsheet AND a - // synthesized source_files[] datasheet_pdf entry. + // synthesized source_files[] datasheet_pdf entry. The cutsheet is a graded core + // requirement, not a schema requirement, so an omitted cutsheet_file is allowed: + // the record converts and grades incomplete with a roadmap naming the cutsheet. cutsheetFile := master["cutsheet_file"] - if cutsheetFile == "" { - return nil, errors.New("missing required column cutsheet_file") - } - cutsheetRef, err := buildFileReference(cutsheetFile, master, "cutsheet_file", hasher) - if err != nil { - return nil, err - } - if err := setPath(rec, "product_family.cutsheet", cutsheetRef); err != nil { - return nil, err + var cutsheetRef map[string]any + if cutsheetFile != "" { + var err error + cutsheetRef, err = buildFileReference(cutsheetFile, master, "cutsheet_file", hasher) + if err != nil { + return nil, err + } + if err := setPath(rec, "product_family.cutsheet", cutsheetRef); err != nil { + return nil, err + } } // source_files (joined sheet) plus the synthesized cutsheet entry. @@ -628,9 +631,11 @@ func assembleSourceFiles(wb Workbook, id, cutsheetFilename string, cutsheetRef m out = append(out, map[string]any{"file_type": fileType, "reference": ref}) } - // Cutsheet dual-write: synthesize the datasheet_pdf entry when the - // manufacturer did not list it themselves. - if !cutsheetListed { + // Cutsheet dual-write: synthesize the datasheet_pdf entry when a cutsheet was + // provided and the manufacturer did not list it themselves. With no cutsheet the + // record grades incomplete (the cutsheet is a graded core requirement), so there + // is nothing to synthesize. + if cutsheetFilename != "" && !cutsheetListed { out = append(out, map[string]any{"file_type": "datasheet_pdf", "reference": cutsheetRef}) } return out, nil diff --git a/tools/validator/internal/sheet/sheet_test.go b/tools/validator/internal/sheet/sheet_test.go index 37a3a53..291f59c 100644 --- a/tools/validator/internal/sheet/sheet_test.go +++ b/tools/validator/internal/sheet/sheet_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "path/filepath" + "strings" "testing" "github.com/ulcspec/ULC/tools/validator/internal/findings" @@ -692,3 +693,40 @@ func TestAllowMissingFilesSentinel(t *testing.T) { t.Fatalf("expected zero-sentinel sha256 for missing cutsheet, got %q", sha) } } + +// TestConvertCutsheetOptionalGradesIncomplete confirms the cutsheet is a graded +// core requirement, not a converter requirement: a workbook record with an empty +// cutsheet_file converts successfully (no hard fail), omits product_family.cutsheet +// and the synthesized datasheet_pdf source-file entry, and grades incomplete. +func TestConvertCutsheetOptionalGradesIncomplete(t *testing.T) { + dir := t.TempDir() + writeFixtureCopy(t, dir) + // Blank the cutsheet_file value so the record carries no cutsheet. + recordsCSV := strings.Replace(readFixture(t, "records.csv"), "acme-orbit-1200-specs.pdf", "", 1) + writeFile(t, filepath.Join(dir, "records.csv"), recordsCSV) + + results, err := Convert(dir, Options{}) + if err != nil { + t.Fatalf("Convert without a cutsheet should succeed (cutsheet is graded, not required): %v", err) + } + if len(results) == 0 { + t.Fatal("no records converted") + } + rec := results[0].Record + + pf, _ := rec["product_family"].(map[string]any) + if _, present := pf["cutsheet"]; present { + t.Error("product_family.cutsheet should be absent when cutsheet_file is empty") + } + for _, sf := range rec["source_files"].([]any) { + if m, ok := sf.(map[string]any); ok && m["file_type"] == "datasheet_pdf" { + t.Error("no datasheet_pdf source_files entry should be synthesized without a cutsheet") + } + } + + // End to end: build the index and confirm the cutsheet-less record grades the floor. + rec["index"] = index.Build(rec) + if got := grade.AchievedLevel(rec); got != grade.LevelIncomplete { + t.Errorf("cutsheet-less record grades %s, want incomplete", got) + } +} From 84871e5cc63ee0a0543e7128ce40a0a3d0810f88 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:08:33 -0400 Subject: [PATCH 3/7] fix(grade): a gated grade names the highest unmet grade as its blocker - When an incomplete record has gaps at more than one lower grade, a gated higher grade (its own requirements met) now names the highest of those grades as the one to reach. Reaching a lower grade alone does not unlock it while an intermediate grade still has a gap. - Add a test for the core-plus-standard dual-gap case. --- tools/validator/internal/grade/grade.go | 20 ++++++++------ tools/validator/internal/grade/grade_test.go | 29 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/tools/validator/internal/grade/grade.go b/tools/validator/internal/grade/grade.go index 561efde..af5a0a4 100644 --- a/tools/validator/internal/grade/grade.go +++ b/tools/validator/internal/grade/grade.go @@ -637,9 +637,11 @@ func Report(record map[string]any, report *findings.Report) Level { // 3. gated the grade is outstanding but its OWN rows are all met; it // is gated only by a lower grade. This is the cascade signal // (close the lower grade and this one unlocks immediately). - // blocker tracks the lowest outstanding grade that has a real delta, so a gated - // grade can name what to reach to unlock it. Because the walk is ascending, the - // first grade with a delta is the blocker for every gated grade above it. + // blocker tracks the HIGHEST outstanding grade that still has a real delta, so a + // gated grade can name what to reach to unlock it. Reaching that grade implies + // every lower grade is met too, whereas reaching a lower grade alone is not enough + // when an intermediate grade still has a gap. The ascending walk updates blocker on + // every roadmap grade, so it holds the last delta grade below any gated grade. blocker := LevelObservation // sentinel: no gap seen yet for _, tier := range []Level{LevelCore, LevelStandard, LevelFull} { if tier <= achieved { @@ -649,16 +651,16 @@ func Report(record map[string]any, report *findings.Report) Level { } delta := missingAt(record, tier) if len(delta) == 0 { - // blocker is always set here: by the ladder, the grade just above `achieved` - // has a non-empty delta and is walked (setting blocker) before any gated - // grade above it, so a gated grade always has a named blocker to reach. + // blocker is the highest outstanding grade below this one that still has a + // delta; reaching it (and thus every lower grade) unlocks this grade, whose + // own rows are already met. It is always set: the grade just above `achieved` + // has a non-empty delta and is walked before any gated grade above it. report.AddInfo(findings.CodeConformanceGradeGated, "/index/conformance_level", fmt.Sprintf("conformance grade %q requirements are met; unlocked once %q is reached", tier.String(), blocker.String())) continue } - if blocker == LevelObservation { - blocker = tier - } + // Update on every roadmap grade so blocker holds the highest delta grade. + blocker = tier emitRoadmap(tier, delta, report) } diff --git a/tools/validator/internal/grade/grade_test.go b/tools/validator/internal/grade/grade_test.go index b7dcc91..bb7c56c 100644 --- a/tools/validator/internal/grade/grade_test.go +++ b/tools/validator/internal/grade/grade_test.go @@ -705,6 +705,35 @@ func TestGatedNamesIntermediateBlocker(t *testing.T) { } } +// TestGatedBlockerIsHighestDelta pins the multi-delta case: when an incomplete record +// has BOTH a core gap and a standard gap while every full requirement is met, full is +// gated and its blocker must be the HIGHEST outstanding delta grade (standard), not the +// lowest (core). Reaching core alone leaves standard's gap open, so full stays locked; +// naming core would mislead. +func TestGatedBlockerIsHighestDelta(t *testing.T) { + rec := fullBase() + delete(rec["product_family"].(map[string]any), "cutsheet") // a core rule + delete(rec["photometry"].(map[string]any), "maximum_intensity_cd") // a standard rule + if got := AchievedLevel(rec); got != LevelIncomplete { + t.Fatalf("achieved = %s, want incomplete", got) + } + report := findings.NewReport() + Report(rec, report) + report.Finalize() + found := false + for _, g := range findingsFor(report, findings.CodeConformanceGradeGated) { + if strings.Contains(g.Message, `"full"`) { + found = true + if !strings.Contains(g.Message, `unlocked once "standard" is reached`) { + t.Errorf("full's blocker should be standard (the highest delta grade), got: %q", g.Message) + } + } + } + if !found { + t.Error("expected full to be gated when core+standard carry deltas but full's own rows are met") + } +} + // TestRoadmapPerTierToFull confirms the per-grade decomposition: a core record emits // both a to-standard and a to-full roadmap whose deltas are disjoint (no path repeats); // a standard record emits only a to-full roadmap. From a3abda89255eb6eb65cfaa35175644dc69d956f1 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:50:21 -0400 Subject: [PATCH 4/7] docs: align version stamps and the identity-rejection wording for v0.8.0 Bump the PIM emitter examples and the from-sheet design note to declare ulc_version 0.8.0, matching the converter default and every shipped example so a copied integration emits a current-spec record. Clarify the identity boundary: a record missing identity is malformed rather than incomplete, naming the schema-required authored fields (product_family.manufacturer.slug, product_family.family_id, product_family.catalog_model) and noting it fails JSON Schema validation and the builder cannot derive its required index keys (MissingRequiredKeys), a rejection distinct from the incomplete grade. Correct the changelog exit-code note: build-index runs no JSON Schema validation and gates on the builder's required index keys, while validate and from-sheet additionally fail on schema invalidity. --- CHANGELOG.md | 2 +- docs/how-it-works.md | 2 +- docs/methodology.md | 2 +- mappings/pim/akeneo.md | 2 +- mappings/pim/custom-pim.md | 2 +- mappings/pim/salsify.md | 2 +- mappings/pim/sap.md | 2 +- schema/taxonomy.schema.json | 2 +- tools/validator/internal/sheet/DESIGN.md | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d1f57..511bebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ For emergency manual releases (bypassing the PR flow), trigger the `Release on m ### Behavior change (important for consumers) -- Records that previously failed `ulc validate`, `ulc build-index`, or `ulc from-sheet` because they were anchorless or missing required index keys now SUCCEED (exit 0) and grade `incomplete` with a roadmap. Any external tooling or CI that treated a nonzero exit as "this record is unacceptable" must now treat `incomplete` as a valid, expected, below-core state. The three subcommands exit nonzero only on schema-invalidity, malformed input, source-file integrity failures (hash mismatch, unreadable files), or a stored-vs-recomputed conformance drift. No data-completeness condition produces a nonzero exit. +- Records that previously failed `ulc validate`, `ulc build-index`, or `ulc from-sheet` because they were anchorless or missing required index keys now SUCCEED (exit 0) and grade `incomplete` with a roadmap. Any external tooling or CI that treated a nonzero exit as "this record is unacceptable" must now treat `incomplete` as a valid, expected, below-core state. The subcommands still exit nonzero on malformed input, missing record identity, source-file integrity failures (hash mismatch, unreadable files), or a stored-vs-recomputed conformance drift; `ulc validate` and `ulc from-sheet` additionally exit nonzero on JSON Schema invalidity, while `ulc build-index` runs no schema validation and instead gates on the builder's required index keys. No data-completeness condition produces a nonzero exit. - `index.conformance_level` is now always present, including `incomplete`. The internal below-floor sentinel (`none`) is removed: the grader never returns it and nothing renders it. ### Schema (additive, pre-1.0) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index c3716b5..be686af 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -60,7 +60,7 @@ The reference command-line validator (`ulc`, in `tools/validator/`) checks a rec The conformance level is computed and stamped into the generated index as `index.conformance_level` by the builder (`ulc build-index`), never hand-declared. As part of its end-to-end pass, `ulc validate` recomputes the level and checks it against the stored value (builder parity), then reports it. Grading is a cumulative gate: a record is the highest grade all of whose hard requirements it meets. There are three grades above an `incomplete` floor: -- **incomplete**: the floor, a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing the identity core-fields (`manufacturer_slug` and `catalog_model`) is schema-invalid, reported through `MissingRequiredKeys`, which is distinct from incomplete. +- **incomplete**: the floor, a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (the schema-required `product_family.manufacturer.slug`, `product_family.family_id`, and `product_family.catalog_model`) is malformed rather than incomplete: it fails JSON Schema validation, and the builder cannot derive its required index keys (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. - **core**: a complete, identifiable, legally-sellable luminaire: full identity, headline photometric and electrical numbers, one-line colorimetry, and a market safety listing. - **standard**: core plus the fuller specification a typical LM-79 report produces (full photometric geometry, materials, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). - **full**: standard plus exhaustive accredited characterization (zonal lumens, an operating point, measurement uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products), mostly from an accredited test report. diff --git a/docs/methodology.md b/docs/methodology.md index d6a20ce..310ca11 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -98,7 +98,7 @@ Every ULC record carries a conformance level, and that level is computed, never There are three ordered grades, `core` < `standard` < `full`, sitting above an `incomplete` floor. -- **`incomplete`.** The floor: a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (`manufacturer_slug` and `catalog_model`) is schema-invalid, reported through `MissingRequiredKeys`, which is distinct from incomplete. +- **`incomplete`.** The floor: a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (the schema-required `product_family.manufacturer.slug`, `product_family.family_id`, and `product_family.catalog_model`) is malformed rather than incomplete: it fails JSON Schema validation, and the builder cannot derive its required index keys (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. - **`core`.** A complete, identifiable, legally-sellable luminaire with headline numbers: the identity, classification, headline photometric and electrical values, one-line colorimetry, and a market safety listing that a buyer needs before specifying the fixture. - **`standard`.** Core plus the fuller specification an LM-79 report produces: the maximum intensity, symmetry and coordinate system, materials, the test conditions and measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the conditional rows that apply to the fixture's form. - **`full`.** Standard plus exhaustive accredited characterization: zonal lumens, an operating point, measurement uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and (for white-light fixtures) TM-30 detail. These are mostly fed by accredited test reports. diff --git a/mappings/pim/akeneo.md b/mappings/pim/akeneo.md index a3a44a5..208181f 100644 --- a/mappings/pim/akeneo.md +++ b/mappings/pim/akeneo.md @@ -138,7 +138,7 @@ function emitUlcFromAkeneo(Product $product, Variant $variant): ?string { return null; } $record = [ - 'ulc_version' => '0.7.0', + 'ulc_version' => '0.8.0', 'record_id' => slug("{$product->getBrand()}-{$product->getIdentifier()}-{$variant->getScenarioSlug()}"), 'record_status' => 'active', 'product_family' => buildFamily($product, $primaryCategory), diff --git a/mappings/pim/custom-pim.md b/mappings/pim/custom-pim.md index 6d84735..c539fa5 100644 --- a/mappings/pim/custom-pim.md +++ b/mappings/pim/custom-pim.md @@ -186,7 +186,7 @@ def emit_ulc(session: Session): family = build_family(product, primary_category, mounting) for scenario in product.photometric_scenarios: record = { - "ulc_version": "0.7.0", + "ulc_version": "0.8.0", "record_id": slug(f"{product.manufacturer}-{product.model}-{scenario.slug}"), "record_status": "active", "product_family": family, diff --git a/mappings/pim/salsify.md b/mappings/pim/salsify.md index 29a1b2c..b8fe3dc 100644 --- a/mappings/pim/salsify.md +++ b/mappings/pim/salsify.md @@ -142,7 +142,7 @@ Accessory-type classification requires another PIM-to-ULC enum mapping (junction # Illustrative pseudocode, not a working implementation. def emit_ulc_from_salsify(product, scenario): record = { - "ulc_version": "0.7.0", + "ulc_version": "0.8.0", "record_id": f"{product.brand_slug}-{product.sku_slug}-{scenario.slug}", "record_status": "active", "product_family": build_family_from_salsify(product), diff --git a/mappings/pim/sap.md b/mappings/pim/sap.md index 6a7e1cb..c6aca4f 100644 --- a/mappings/pim/sap.md +++ b/mappings/pim/sap.md @@ -155,7 +155,7 @@ In practice, ABAP handles data extraction (CDS views or SAP CAP/RAP) and a Pytho def emit_ulc_from_sap(material, variant, characteristics, dms_docs): primary_category = CLASS_TO_ULC_CATEGORY[material['class']] record = { - "ulc_version": "0.7.0", + "ulc_version": "0.8.0", "record_id": slug(f"{material['brand_slug']}-{material['matnr']}-{variant['scenario_slug']}"), "record_status": "active", "product_family": build_family(material, primary_category), diff --git a/schema/taxonomy.schema.json b/schema/taxonomy.schema.json index d1787d3..49c8e88 100644 --- a/schema/taxonomy.schema.json +++ b/schema/taxonomy.schema.json @@ -1093,7 +1093,7 @@ "ConformanceLevel": { "title": "ULC conformance grade", - "description": "The conformance grade a ULC record achieves, computed by the reference builder from the record's populated fields and stamped into the generated index. It is never hand-declared. There are three grades (`core`, `standard`, `full`) above an `incomplete` floor, graded as a cumulative gate: the record IS the highest grade all of whose hard requirements it meets, walking the grades low to high and stopping at the first unmet requirement. `incomplete` is the floor, not a grade: the state of a record that has not yet met a core requirement. It is never a published grade and never a trustworthy selection surface; it always travels with a roadmap naming the core fields the record still needs. The tooling never refuses an incomplete record (it grades it and emits the roadmap); a record that additionally lacks identity (`manufacturer_slug` and `catalog_model`) is schema-invalid, which the builder reports through `MissingRequiredKeys` and is distinct from `incomplete`. `core` is the first real grade: the minimum identifying, photometric, electrical, one-line-colorimetry, and market-safety-listing dataset (plus an attached cutsheet) that makes a record a complete, legally-sellable luminaire. `standard` adds the fuller specification a typical LM-79 test report produces (full photometric geometry, materials, measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). `full` adds exhaustive accredited characterization (zonal lumens, operating point, measurement uncertainty, corrections applied, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products). Required fields in the JSON Schema are limited to record identity; everything beyond is schema-optional, and the achieved grade reflects which graded fields a record carries. Conditional requirements that do not apply to a record (for example CRI for a pure color-mixing fixture) are never counted against it. A conformance grade is a data-completeness grade, never a safety certification.", + "description": "The conformance grade a ULC record achieves, computed by the reference builder from the record's populated fields and stamped into the generated index. It is never hand-declared. There are three grades (`core`, `standard`, `full`) above an `incomplete` floor, graded as a cumulative gate: the record IS the highest grade all of whose hard requirements it meets, walking the grades low to high and stopping at the first unmet requirement. `incomplete` is the floor, not a grade: the state of a record that has not yet met a core requirement. It is never a published grade and never a trustworthy selection surface; it always travels with a roadmap naming the core fields the record still needs. The tooling never refuses an incomplete record (it grades it and emits the roadmap); a record that additionally lacks identity (the schema-required `product_family.manufacturer.slug`, `product_family.family_id`, and `product_family.catalog_model`) is malformed, not incomplete: it fails JSON Schema validation, and the builder cannot derive its required index keys (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. That rejection is distinct from the `incomplete` grade. `core` is the first real grade: the minimum identifying, photometric, electrical, one-line-colorimetry, and market-safety-listing dataset (plus an attached cutsheet) that makes a record a complete, legally-sellable luminaire. `standard` adds the fuller specification a typical LM-79 test report produces (full photometric geometry, materials, measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). `full` adds exhaustive accredited characterization (zonal lumens, operating point, measurement uncertainty, corrections applied, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products). Required fields in the JSON Schema are limited to record identity; everything beyond is schema-optional, and the achieved grade reflects which graded fields a record carries. Conditional requirements that do not apply to a record (for example CRI for a pure color-mixing fixture) are never counted against it. A conformance grade is a data-completeness grade, never a safety certification.", "type": "string", "enum": [ "incomplete", diff --git a/tools/validator/internal/sheet/DESIGN.md b/tools/validator/internal/sheet/DESIGN.md index 7e05b16..3ab1323 100644 --- a/tools/validator/internal/sheet/DESIGN.md +++ b/tools/validator/internal/sheet/DESIGN.md @@ -70,7 +70,7 @@ safety-listing requirements documented in `docs/methodology.md`. Schema structur identity/cutsheet/scenario fields: ``` -record_id, ulc_version(=0.7.0 default), record_status(=active), +record_id, ulc_version(=0.8.0 default), record_status(=active), family_id, manufacturer_slug, manufacturer_display_name, catalog_model, cutsheet_file (-> sha256 + cutsheet/source_files dual-write), primary_category (indexing anchor), From 2a2b75cb3aae435b87ad7e2232477bad614241a3 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:58:02 -0400 Subject: [PATCH 5/7] docs: state the full schema-required surface and identity set precisely The ConformanceLevel description previously claimed JSON Schema required fields are limited to record identity; the schema also requires the record envelope (version, identifiers, status, the generated index container, configuration, and source_files). Reword it to say required covers the record envelope plus product-family identity, matching the record schema description. Complete the identity set in the conformance description and the methodology and how-it-works incomplete sections by adding product_family.manufacturer.display_name, which is schema-required. Scope the builder's MissingRequiredKeys check to the two index keys it projects from identity (manufacturer_slug, catalog_model) and attribute the rest of the identity set to JSON Schema validation, so the two rejection mechanisms are no longer conflated. --- docs/how-it-works.md | 2 +- docs/methodology.md | 2 +- schema/taxonomy.schema.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index be686af..0774456 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -60,7 +60,7 @@ The reference command-line validator (`ulc`, in `tools/validator/`) checks a rec The conformance level is computed and stamped into the generated index as `index.conformance_level` by the builder (`ulc build-index`), never hand-declared. As part of its end-to-end pass, `ulc validate` recomputes the level and checks it against the stored value (builder parity), then reports it. Grading is a cumulative gate: a record is the highest grade all of whose hard requirements it meets. There are three grades above an `incomplete` floor: -- **incomplete**: the floor, a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (the schema-required `product_family.manufacturer.slug`, `product_family.family_id`, and `product_family.catalog_model`) is malformed rather than incomplete: it fails JSON Schema validation, and the builder cannot derive its required index keys (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. +- **incomplete**: the floor, a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (the schema-required `product_family.manufacturer.slug`, `product_family.manufacturer.display_name`, `product_family.family_id`, and `product_family.catalog_model`) is malformed rather than incomplete: `ulc validate` rejects it against the JSON Schema, and the builder additionally cannot derive the required index keys it projects from identity (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. Either rejection is distinct from incomplete. - **core**: a complete, identifiable, legally-sellable luminaire: full identity, headline photometric and electrical numbers, one-line colorimetry, and a market safety listing. - **standard**: core plus the fuller specification a typical LM-79 report produces (full photometric geometry, materials, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). - **full**: standard plus exhaustive accredited characterization (zonal lumens, an operating point, measurement uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products), mostly from an accredited test report. diff --git a/docs/methodology.md b/docs/methodology.md index 310ca11..e1c2f7a 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -98,7 +98,7 @@ Every ULC record carries a conformance level, and that level is computed, never There are three ordered grades, `core` < `standard` < `full`, sitting above an `incomplete` floor. -- **`incomplete`.** The floor: a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (the schema-required `product_family.manufacturer.slug`, `product_family.family_id`, and `product_family.catalog_model`) is malformed rather than incomplete: it fails JSON Schema validation, and the builder cannot derive its required index keys (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. +- **`incomplete`.** The floor: a record that has not yet met a core requirement. It grades incomplete, indexes, and carries a roadmap to core; a record missing identity (the schema-required `product_family.manufacturer.slug`, `product_family.manufacturer.display_name`, `product_family.family_id`, and `product_family.catalog_model`) is malformed rather than incomplete: `ulc validate` rejects it against the JSON Schema, and the builder additionally cannot derive the required index keys it projects from identity (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. Either rejection is distinct from incomplete. - **`core`.** A complete, identifiable, legally-sellable luminaire with headline numbers: the identity, classification, headline photometric and electrical values, one-line colorimetry, and a market safety listing that a buyer needs before specifying the fixture. - **`standard`.** Core plus the fuller specification an LM-79 report produces: the maximum intensity, symmetry and coordinate system, materials, the test conditions and measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the conditional rows that apply to the fixture's form. - **`full`.** Standard plus exhaustive accredited characterization: zonal lumens, an operating point, measurement uncertainty, corrections, instrumentation depth, a method-backed lumen-maintenance projection, and (for white-light fixtures) TM-30 detail. These are mostly fed by accredited test reports. diff --git a/schema/taxonomy.schema.json b/schema/taxonomy.schema.json index 49c8e88..defd015 100644 --- a/schema/taxonomy.schema.json +++ b/schema/taxonomy.schema.json @@ -1093,7 +1093,7 @@ "ConformanceLevel": { "title": "ULC conformance grade", - "description": "The conformance grade a ULC record achieves, computed by the reference builder from the record's populated fields and stamped into the generated index. It is never hand-declared. There are three grades (`core`, `standard`, `full`) above an `incomplete` floor, graded as a cumulative gate: the record IS the highest grade all of whose hard requirements it meets, walking the grades low to high and stopping at the first unmet requirement. `incomplete` is the floor, not a grade: the state of a record that has not yet met a core requirement. It is never a published grade and never a trustworthy selection surface; it always travels with a roadmap naming the core fields the record still needs. The tooling never refuses an incomplete record (it grades it and emits the roadmap); a record that additionally lacks identity (the schema-required `product_family.manufacturer.slug`, `product_family.family_id`, and `product_family.catalog_model`) is malformed, not incomplete: it fails JSON Schema validation, and the builder cannot derive its required index keys (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. That rejection is distinct from the `incomplete` grade. `core` is the first real grade: the minimum identifying, photometric, electrical, one-line-colorimetry, and market-safety-listing dataset (plus an attached cutsheet) that makes a record a complete, legally-sellable luminaire. `standard` adds the fuller specification a typical LM-79 test report produces (full photometric geometry, materials, measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). `full` adds exhaustive accredited characterization (zonal lumens, operating point, measurement uncertainty, corrections applied, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products). Required fields in the JSON Schema are limited to record identity; everything beyond is schema-optional, and the achieved grade reflects which graded fields a record carries. Conditional requirements that do not apply to a record (for example CRI for a pure color-mixing fixture) are never counted against it. A conformance grade is a data-completeness grade, never a safety certification.", + "description": "The conformance grade a ULC record achieves, computed by the reference builder from the record's populated fields and stamped into the generated index. It is never hand-declared. There are three grades (`core`, `standard`, `full`) above an `incomplete` floor, graded as a cumulative gate: the record IS the highest grade all of whose hard requirements it meets, walking the grades low to high and stopping at the first unmet requirement. `incomplete` is the floor, not a grade: the state of a record that has not yet met a core requirement. It is never a published grade and never a trustworthy selection surface; it always travels with a roadmap naming the core fields the record still needs. The tooling never refuses an incomplete record (it grades it and emits the roadmap); a record that additionally lacks identity (the schema-required `product_family.manufacturer.slug`, `product_family.manufacturer.display_name`, `product_family.family_id`, and `product_family.catalog_model`) is malformed, not incomplete: it fails JSON Schema validation, and the builder additionally cannot derive the required index keys it projects from identity (`manufacturer_slug`, `catalog_model`), reported through `MissingRequiredKeys`. That rejection is distinct from the `incomplete` grade. `core` is the first real grade: the minimum identifying, photometric, electrical, one-line-colorimetry, and market-safety-listing dataset (plus an attached cutsheet) that makes a record a complete, legally-sellable luminaire. `standard` adds the fuller specification a typical LM-79 test report produces (full photometric geometry, materials, measurement regime, an LM-79 attestation, a lumen-maintenance framework, and the white-light, directional, outdoor-site, and wet-location conditionals that apply). `full` adds exhaustive accredited characterization (zonal lumens, operating point, measurement uncertainty, corrections applied, instrumentation depth, a method-backed lumen-maintenance projection, and TM-30 detail for white-light products). Required fields in the JSON Schema cover the record envelope (version, identifiers, status, the generated index container, configuration, and the source-files array) and product-family identity rather than data completeness; everything beyond is schema-optional, and the achieved grade reflects which graded fields a record carries. Conditional requirements that do not apply to a record (for example CRI for a pure color-mixing fixture) are never counted against it. A conformance grade is a data-completeness grade, never a safety certification.", "type": "string", "enum": [ "incomplete", From 2db3fcd0bca928dbedbe2f75b541c1642bd96b7d Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:35:33 -0400 Subject: [PATCH 6/7] docs: describe the per-grade roadmap in the validator and examples READMEs [skip Codex] Replace the one-step roadmap wording in the validator README (guidance to reach the next level) with the per-grade roadmap through full, including the conformance/grade-satisfied and conformance/grade-gated INFO codes, so CLI consumers implement against the current output contract. Rename the examples README section from Conformance levels to Conformance grades for terminology consistency with the three-grades above-an-incomplete-floor model. --- examples/README.md | 2 +- tools/validator/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index 27857d8..be2f6a5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,7 +14,7 @@ Source files (the original PDF datasheets, IES photometric files, and related do | `lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc` | C: per-IES with provenance classes | The `extended_photometry` provenance class: every photometric value carries `value_type: rated` and points back to a base LM-79 attestation (Spectralux test ID S1503051-R1, 2015-03-05) via `base_attestation_ref`, preserving the 1:1 IES-to-record mapping specifiers expect while distinguishing scaled derivatives from direct measurements | | `lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc` | C: per-IES with provenance classes | The RGBW counterpart to the RGB facade record: a four-channel fixture whose white channel carries a 3000 K nominal CCT, exercising the white-point split, the CCT is graded while CRI, SDCM, and TM-30 are waived for the color-mixing architecture | -## Conformance levels and roadmap +## Conformance grades and roadmap Each record is graded by the reference validator, which computes a conformance grade from the fields actually present and emits a per-grade roadmap to full: the grades the record already satisfies and, for each grade not yet reached, only that grade's own remaining fields. The grade is stamped into `index.conformance_level` and is never hand-declared. The table below is the machine output of `ulc validate --verbose `; every gap names the field, the source document it comes from, and the governing standard, grouped per grade to full. diff --git a/tools/validator/README.md b/tools/validator/README.md index 72ccb8c..a4473dc 100644 --- a/tools/validator/README.md +++ b/tools/validator/README.md @@ -14,7 +14,7 @@ As of the next release: - [x] Builder parity is included in `ulc validate` (stored `index` vs. computed) - [x] Source-file SHA-256 hash verification when referenced files are reachable on the local filesystem - [x] Structured `ERROR` / `WARNING` / `INFO` findings, each with a JSON Pointer into the record -- [x] Conformance grading: the conformance level (`incomplete` / `core` / `standard` / `full`) is computed by the builder from the record's populated fields and stamped into the generated index as `index.conformance_level`, so it is authoritative rather than self-claimed (a hand-tampered value fails the builder-parity check like any other index field). `ulc validate` reports the computed level as `INFO`, plus `INFO` roadmap guidance naming each field needed to reach the next level along with its source document and governing standard (and, at core and above, observations for comprehensive items the record omits, suppressed from text unless `--verbose`). Because there is no declared level to fall short of, conformance grading emits no `WARNING`. Inapplicable fields are skipped by predicate: a downlight (directional, not an outdoor-site category) has no BUG gate; a pure RGB fixture with no white point waives the CCT and CRI gates; an RGBW fixture keeps the CCT gate but waives the CRI / SDCM gates (those are primarily-white-light metrics); a static-white fixture keeps all of them. +- [x] Conformance grading: the conformance level (`incomplete` / `core` / `standard` / `full`) is computed by the builder from the record's populated fields and stamped into the generated index as `index.conformance_level`, so it is authoritative rather than self-claimed (a hand-tampered value fails the builder-parity check like any other index field). `ulc validate` reports the computed level as `INFO`, plus an `INFO` per-grade roadmap through `full`: the grades the record already satisfies (`conformance/grade-satisfied`) and, for each grade not yet reached, only that grade's own remaining fields, each naming its source document and governing standard (and, at core and above, observations for comprehensive items the record omits, suppressed from text unless `--verbose`). A grade whose own requirements are met while a lower grade is not yet reached is reported as gated (`conformance/grade-gated`). Because there is no declared level to fall short of, conformance grading emits no `WARNING`. Inapplicable fields are skipped by predicate: a downlight (directional, not an outdoor-site category) has no BUG gate; a pure RGB fixture with no white point waives the CCT and CRI gates; an RGBW fixture keeps the CCT gate but waives the CRI / SDCM gates (those are primarily-white-light metrics); a static-white fixture keeps all of them. - [x] `--json` machine-readable output - [x] Single-file binaries via GoReleaser for Linux / macOS / Windows × x64 / arm64, cut on tag push - [x] Embedded schemas via `go:embed` so the binary runs outside the source repository From 31d1c516eaa1713bb5c0064521c07da15e0fad92 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:46:59 -0400 Subject: [PATCH 7/7] docs(schema): state that source_files is not graded, only the cutsheet pointer The source_files description claimed source files are graded core items. The grader gates the core cutsheet on the family-level pointer product_family.cutsheet and does not grade the source_files array at all. Reword the description so it states the array is neither schema-required nor graded, and that product_family.cutsheet is the graded core cutsheet item while source_files carries integrity-tracked provenance. --- schema/ulc.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/ulc.schema.json b/schema/ulc.schema.json index dd082ee..86d9edf 100644 --- a/schema/ulc.schema.json +++ b/schema/ulc.schema.json @@ -143,7 +143,7 @@ }, "source_files": { - "description": "Source files from which this record derives its data. Each file is referenced by filename, optional URL, and SHA-256 content hash. Files are not embedded. The array may be empty for an incomplete record that is still being authored; source files are graded core items, not a schema requirement.", + "description": "Source files from which this record derives its data. Each file is referenced by filename, optional URL, and SHA-256 content hash. Files are not embedded. The array may be empty for an incomplete record that is still being authored; the array is neither schema-required nor graded. The graded core cutsheet item is the family-level pointer `product_family.cutsheet`; `source_files[]` is the integrity-tracked provenance manifest for whatever documents a record cites.", "type": "array", "items": { "$ref": "#/$defs/SourceFile" }, "minItems": 0