From e1ddea30dae1bd4de02fcd8bebbd4e6829845c8f Mon Sep 17 00:00:00 2001 From: EiffL Date: Thu, 24 Sep 2026 21:56:27 +0200 Subject: [PATCH] Compact singleton arrays in the crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roc-validator 0.11.4 (2026-09-16) adds RO-Crate 1.1's recommendation that a property with one value be that value, not a one-element array. The crate had four: the root's author, the workflow's input, and the result of each action. CI resolves the validator fresh on every run (the lock is not committed), so every job has failed since the release. The arrays come from three builders, and which of them hold one element depends on the project — one output, one author — so the serialization compacts every one-element array in one place, the same rule rocrate applies to @context. The new requirement was inserted as number 24, renumbering the two affiliation checks the floor pins from 29.x to 30.x. The floor's ids belong to a validator version, so the dev dependency now names 0.11.4. Co-Authored-By: Claude Fable 5.1 --- docs/api/crate.md | 5 ++++- pyproject.toml | 2 +- src/lightcone/engine/crate.py | 15 ++++++++++++--- tests/test_crate.py | 20 ++++++++++++++++++-- tests/test_crate_smoke.py | 4 ++-- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/api/crate.md b/docs/api/crate.md index b5562db3..c403edb5 100644 --- a/docs/api/crate.md +++ b/docs/api/crate.md @@ -25,7 +25,10 @@ Source: `src/lightcone/engine/crate.py` (converged by never-materialized project) and must override rocrate's construction-time default. Entities build in sorted order, serialization is `sort_keys` — render-twice-identical is the one - byte-level claim, and it is what makes convergence sound. + byte-level claim, and it is what makes convergence sound. The + serialization also compacts every one-element array to its value, + as RO-Crate 1.1 recommends: which properties hold one value depends + on the project, so the rule lives in one place, not in each builder. - **Maintenance is derived, never configured.** RO-Crate requires a license; materialize must not refuse to run science over a missing key, and inventing one asserts terms over someone's data. Absent ⇒ diff --git a/pyproject.toml b/pyproject.toml index d53fafc5..41ada83a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "ruff", "mypy", "datalad", - "roc-validator>=0.11.3", + "roc-validator>=0.11.4", ] docs = [ "zensical>=0.0.33", diff --git a/src/lightcone/engine/crate.py b/src/lightcone/engine/crate.py index e24ee21a..cbed1b75 100644 --- a/src/lightcone/engine/crate.py +++ b/src/lightcone/engine/crate.py @@ -173,9 +173,18 @@ def document(self) -> str: self._control(key, self._action(key, manifest)) self._runs(workflow) self._root() - text: str = json.dumps( - self.crate.metadata.generate(), indent=1, sort_keys=True, ensure_ascii=False - ) + document = self.crate.metadata.generate() + # Compacted JSON-LD: a property with one value is that value, not + # a one-element array. Applied here rather than at each builder + # because the arrays come from three places — rocrate's + # `append_to`, and two literal lists — and which of them hold one + # element depends on the project (one output, one author). The + # same rule rocrate itself applies to `@context`. + for entity in document["@graph"]: + for name, value in entity.items(): + if isinstance(value, list) and len(value) == 1: + entity[name] = value[0] + text: str = json.dumps(document, indent=1, sort_keys=True, ensure_ascii=False) return text + "\n" # ----- the workflow and its structure ----- diff --git a/tests/test_crate.py b/tests/test_crate.py index 3f802279..eef3b1cb 100644 --- a/tests/test_crate.py +++ b/tests/test_crate.py @@ -140,6 +140,22 @@ def test_rendering_twice_at_the_same_state_is_byte_identical(project: Path) -> N assert first == second +def test_a_single_value_is_never_a_singleton_array(project: Path) -> None: + """Compacted JSON-LD: one author, one parameter, one result are plain + values — RO-Crate 1.1's recommendation, and what its validator checks. + The arrays come from three builders, so this pins the serialization.""" + _made(project, "baseline", "first", git_sha="aaa111") + rendered = crate.render( + project, _graph(project), license="MIT", dsid=_DSID, writer=_writer, keys={} + ) + document = json.loads(rendered) + for entity in document["@graph"]: + for name, value in entity.items(): + assert not (isinstance(value, list) and len(value) == 1), (entity["@id"], name) + entities = _entities(document) + assert entities["./"]["author"] == {"@id": "mailto:ada@example.org"} + + def test_the_clock_never_enters_the_document(project: Path) -> None: """`datePublished` is the newest recorded instant — rocrate's own default stamps the current time, and this pins the override.""" @@ -230,7 +246,7 @@ def test_an_action_chains_its_inputs_and_its_environment(project: Path) -> None: first_objects = {ref["@id"] for ref in first["object"]} assert {"uv.lock", ".python-version", "pyproject.toml", "data/catalog.csv"} <= first_objects assert "results/baseline/first.txt" in {ref["@id"] for ref in second["object"]} - assert second["result"] == [{"@id": "results/baseline/second.txt"}] + assert second["result"] == {"@id": "results/baseline/second.txt"} assert second["description"] == "make second" assert entities["results/baseline/second.txt"]["version"] == "sha256:baseline-second" @@ -280,7 +296,7 @@ def test_the_person_is_the_saving_commits_author(project: Path) -> None: assert person["name"] == "Ada Lovelace" action = next(e for e in entities.values() if e["@type"] == "CreateAction") assert action["agent"] == {"@id": "mailto:ada@example.org"} - assert {"@id": "mailto:ada@example.org"} in entities["./"]["author"] + assert {"@id": "mailto:ada@example.org"} in _as_list(entities["./"]["author"]) def test_decision_values_point_back_at_their_parameter(project: Path) -> None: diff --git a/tests/test_crate_smoke.py b/tests/test_crate_smoke.py index f1c71d08..a316160c 100644 --- a/tests/test_crate_smoke.py +++ b/tests/test_crate_smoke.py @@ -41,8 +41,8 @@ "process-run-crate-0.5_13.2", # lc knows no publishing organization and no author affiliation "ro-crate-1.1_22.3", - "ro-crate-1.1_29.2", - "ro-crate-1.1_29.3", + "ro-crate-1.1_30.2", + "ro-crate-1.1_30.3", } _SPEC = """