diff --git a/docs/api/crate.md b/docs/api/crate.md index b5562db..c403edb 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 d53fafc..41ada83 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 e24ee21..cbed1b7 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 3f80227..eef3b1c 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 f1c71d0..a316160 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 = """