diff --git a/examples/quickstart-pytorch/ro-crate-metadata.json b/examples/quickstart-pytorch/ro-crate-metadata.json index 6040d34..259a707 100644 --- a/examples/quickstart-pytorch/ro-crate-metadata.json +++ b/examples/quickstart-pytorch/ro-crate-metadata.json @@ -4,12 +4,16 @@ { "@id": "./", "@type": "Dataset", - "author": { - "@id": "https://orcid.org/0009-0000-0000-0000" - }, - "conformsTo": { - "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html" - }, + "author": [ + { + "@id": "https://orcid.org/0009-0000-0000-0000" + } + ], + "conformsTo": [ + { + "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html" + } + ], "datePublished": "2026-06-10T10:25:49+00:00", "description": "RO-Crate describing a federated learning run captured with flwrCrate.", "hasPart": [ @@ -20,9 +24,11 @@ "@id": "metrics_log.json" } ], - "license": { - "@id": "https://spdx.org/licenses/MIT.html" - }, + "license": [ + { + "@id": "https://spdx.org/licenses/MIT.html" + } + ], "mentions": [ { "@id": "#fl-run" diff --git a/flwrcrate/crate_builder.py b/flwrcrate/crate_builder.py index a98ee66..40168fa 100644 --- a/flwrcrate/crate_builder.py +++ b/flwrcrate/crate_builder.py @@ -12,6 +12,7 @@ from pathlib import Path from rocrate.rocrate import ROCrate +from rocrate.model.person import Person from rocrate.model.contextentity import ContextEntity from .metrics import metric_to_property_value @@ -37,18 +38,16 @@ def _person(crate, spec, fallback_id): ``name``, ``id``/``orcid`` and ``affiliation``. Returns the added entity. """ if isinstance(spec, dict): - name = spec.get("name") pid = spec.get("id") or spec.get("orcid") or fallback_id - props = {"@type": "Person"} - if name: - props["name"] = name + props = {} + if spec.get("name"): + props["name"] = spec["name"] if spec.get("affiliation"): props["affiliation"] = spec["affiliation"] else: - name = str(spec) pid = fallback_id - props = {"@type": "Person", "name": name} - return crate.add(ContextEntity(crate, pid, properties=props)) + props = {"name": str(spec)} + return crate.add(Person(crate, pid, properties=props)) def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=None, @@ -68,7 +67,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "@type": "CreativeWork", "name": "Federated Learning RO-Crate profile v0.1", })) - crate.root_dataset["conformsTo"] = {"@id": profile.id} + crate.root_dataset.append_to("conformsTo", profile) # --- #5 license / author / agent scaffolding ------------------------------- if license: @@ -76,9 +75,9 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non lic = crate.add(ContextEntity(crate, str(license), properties={ "@type": "CreativeWork", "name": str(license), })) - crate.root_dataset["license"] = {"@id": lic.id} + crate.root_dataset.append_to("license", lic) else: - crate.root_dataset["license"] = str(license) + crate.root_dataset.append_to("license", str(license)) else: logger.warning( "No license set for the RO-Crate. Pass license=... (e.g. an SPDX URL " @@ -89,7 +88,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non author_ref = None if author: author_ref = _person(crate, author, "#author") - crate.root_dataset["author"] = {"@id": author_ref.id} + crate.root_dataset.append_to("author", author_ref) else: logger.warning( "No author set for the RO-Crate. Pass author='Your Name' (or a dict " @@ -109,7 +108,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non if flwr_version: flower_props["softwareVersion"] = flwr_version flower = crate.add(ContextEntity(crate, "#flower", properties=flower_props)) - instruments.append({"@id": flower.id}) + instruments.append(flower) for fw in captured.get("frameworks", []) or []: props = {"@type": "SoftwareApplication", "name": fw["name"]} @@ -120,7 +119,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non if fw.get("declared"): props["softwareRequirements"] = fw["declared"] # spec from pyproject.toml ent = crate.add(ContextEntity(crate, f"#framework-{_slug(fw['package'])}", properties=props)) - instruments.append({"@id": ent.id}) + instruments.append(ent) # --- #2 Aggregation strategy as a SoftwareApplication with hyperparameters --- strat = captured.get("strategy", {}) or {} @@ -133,14 +132,14 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non hp_refs = [] for k, v in (strat.get("attributes") or {}).items(): pid = f"#strategy-param-{_slug(k)}" - crate.add(ContextEntity(crate, pid, properties={ + hp = crate.add(ContextEntity(crate, pid, properties={ "@type": "PropertyValue", "name": k, "value": v, })) - hp_refs.append({"@id": pid}) + hp_refs.append(hp) if hp_refs: strat_props["additionalProperty"] = hp_refs strategy = crate.add(ContextEntity(crate, "#fl-strategy", properties=strat_props)) - instruments.append({"@id": strategy.id}) + instruments.append(strategy) # --- Outputs (results): model file + per-round / federation log file --- results = [] @@ -151,7 +150,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "name": "Final aggregated model", "description": "Final global model produced by the federated learning run.", }) - results.append({"@id": model_entity.id}) + results.append(model_entity) if metrics_log_path and Path(metrics_log_path).exists(): log_entity = crate.add_file(str(metrics_log_path), Path(metrics_log_path).name, properties={ @@ -164,7 +163,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non ), "encodingFormat": "application/json", }) - results.append({"@id": log_entity.id}) + results.append(log_entity) # --- Final metrics as PropertyValues --- final = captured.get("final_metrics", {}) or {} @@ -173,7 +172,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non for name, value in final_metrics.items(): pv = metric_to_property_value(name, value, uri_map) ent = crate.add(ContextEntity(crate, f"#metric-{_slug(name)}", properties=pv)) - metric_refs.append({"@id": ent.id}) + metric_refs.append(ent) # --- Run configuration as PropertyValues (inputs / s:object) --- config = captured.get("environment_config", {}) or {} @@ -182,21 +181,17 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non ent = crate.add(ContextEntity(crate, f"#param-{_slug(name)}", properties={ "@type": "PropertyValue", "name": name, "value": value, })) - config_refs.append({"@id": ent.id}) + config_refs.append(ent) # --- The CreateAction: the FL run itself --- timing = captured.get("run_timing", {}) or {} - action_props = {"@type": "CreateAction", "name": "Federated learning training run", "instrument": instruments} + action_props = {"name": "Federated learning training run"} if timing.get("start_time"): action_props["startTime"] = timing["start_time"] if timing.get("end_time"): action_props["endTime"] = timing["end_time"] - if config_refs: - action_props["object"] = config_refs - if results: - action_props["result"] = results if agent_ref is not None: - action_props["agent"] = {"@id": agent_ref.id} + action_props["agent"] = agent_ref if strat: action_props["description"] = ( f"Run using strategy {strat.get('class_name')} ({strat.get('module')}), " @@ -208,15 +203,21 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non else: action_props["actionStatus"] = {"@id": SCHEMA + "CompletedActionStatus"} - action = crate.add(ContextEntity(crate, "#fl-run", properties=action_props)) + action = crate.add_action( + instruments, + identifier="#fl-run", + object=config_refs, + result=results, + properties=action_props, + ) # --- #1 Link the action from the root so it is discoverable --- - crate.root_dataset["mentions"] = [{"@id": action.id}] + crate.root_dataset.append_to("mentions", action) # Final metrics attach to the output model, else to the action. if metric_refs: host = model_entity if model_entity is not None else action - host["additionalProperty"] = metric_refs + host.append_to("additionalProperty", metric_refs) crate.write(crate_dir) return crate_dir diff --git a/tests/test_crate_builder.py b/tests/test_crate_builder.py index 7e2f3d4..7d529b8 100644 --- a/tests/test_crate_builder.py +++ b/tests/test_crate_builder.py @@ -88,8 +88,8 @@ def test_build_crate_core_entities(tmp_path): assert g["#framework-torch"]["softwareVersion"] == "2.8.0" assert g["#flower"]["softwareVersion"] == "1.30.0" # #5 provenance - assert g["./"]["license"]["@id"] == "https://spdx.org/licenses/MIT.html" - assert g["./"]["author"]["@id"] == "https://orcid.org/0000-0000-0000-0001" + assert g["./"]["license"][0]["@id"] == "https://spdx.org/licenses/MIT.html" + assert g["./"]["author"][0]["@id"] == "https://orcid.org/0000-0000-0000-0001" assert g["#fl-run"]["agent"]["@id"] == "https://orcid.org/0000-0000-0000-0001" diff --git a/tests/test_integration.py b/tests/test_integration.py index 1497299..a23ca9a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -46,7 +46,7 @@ def test_full_lifecycle_produces_complete_crate( assert g["#fl-strategy"]["name"] == "FedAvg" # #2 assert any(i["@id"] == "metrics_log.json" for i in g["#fl-run"]["result"]) # #3 assert "#framework-torch" in g # #4 - assert g["./"]["license"]["@id"].endswith("MIT.html") # #5 + assert g["./"]["license"][0]["@id"].endswith("MIT.html") # #5 assert g["#fl-run"]["agent"]["@id"].endswith("0000-0001") # result-side capture happened (the record_result path)