diff --git a/README.md b/README.md index aa6784d..c7dd85b 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ The crate's `@graph` contains, linked together: | `./` | `Dataset` | Root: name, author, license, `conformsTo` the FL profile, `mentions` the run | | `#fl-run` | `CreateAction` | The run: `agent`, `startTime`/`endTime`, `actionStatus`, instrument/object/result | | `#flower` | `SoftwareApplication` | Flower with its installed version | -| `#framework-*` | `SoftwareApplication` | Every declared dependency (minus an infrastructure deny-list): `softwareRequirements` = the declared version spec, `softwareVersion` = the actually-installed version | +| `#framework-*` | `SoftwareApplication` | Every declared dependency (minus an infrastructure deny-list): `softwareRequirements` = the declared version spec, `version` = the actually-installed version | | `#fl-strategy` | `SoftwareApplication` | The aggregation strategy with its hyperparameters as `PropertyValue`s | | `#param-*` | `PropertyValue` | Run configuration inputs (the action's `object`) | | `#metric-*` | `PropertyValue` | Final-round metrics, attached to the output model (with `propertyID` when mapped) | diff --git a/examples/quickstart-pytorch/ro-crate-metadata.json b/examples/quickstart-pytorch/ro-crate-metadata.json index 259a707..75b20a6 100644 --- a/examples/quickstart-pytorch/ro-crate-metadata.json +++ b/examples/quickstart-pytorch/ro-crate-metadata.json @@ -12,6 +12,9 @@ "conformsTo": [ { "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html" + }, + { + "@id": "https://w3id.org/ro/wfrun/process/0.5" } ], "datePublished": "2026-06-10T10:25:49+00:00", @@ -48,8 +51,21 @@ }, { "@id": "https://esciencelab.org.uk/federated-learning-ro-crate-profile/federated-learning-profile.html", - "@type": "CreativeWork", - "name": "Federated Learning RO-Crate profile v0.1" + "@type": [ + "CreativeWork", + "Profile" + ], + "name": "Federated Learning RO-Crate profile", + "version": "0.1" + }, + { + "@id": "https://w3id.org/ro/wfrun/process/0.5", + "@type": [ + "CreativeWork", + "Profile" + ], + "name": "Process Run Crate", + "version": "0.5" }, { "@id": "https://spdx.org/licenses/MIT.html", @@ -65,24 +81,24 @@ "@id": "#flower", "@type": "SoftwareApplication", "name": "Flower", - "softwareVersion": "1.30.0", - "url": "https://flower.ai/" + "url": "https://flower.ai/", + "version": "1.30.0" }, { "@id": "#framework-torch", "@type": "SoftwareApplication", "name": "PyTorch", "softwareRequirements": "==2.8.0", - "softwareVersion": "2.8.0", - "url": "https://pytorch.org/" + "url": "https://pytorch.org/", + "version": "2.8.0" }, { "@id": "#framework-torchvision", "@type": "SoftwareApplication", "name": "TorchVision", "softwareRequirements": "==0.23.0", - "softwareVersion": "0.23.0", - "url": "https://pytorch.org/vision/" + "url": "https://pytorch.org/vision/", + "version": "0.23.0" }, { "@id": "#strategy-param-arrayrecord-key", @@ -162,7 +178,9 @@ } ], "description": "Federated aggregation strategy (flwr.serverapp.strategy.fedavg).", - "name": "FedAvg" + "name": "FedAvg", + "url": "https://flower.ai/", + "version": "1.30.0" }, { "@id": "final_model.pt", @@ -185,11 +203,13 @@ } ], "description": "Final global model produced by the federated learning run.", + "encodingFormat": "application/octet-stream", "name": "Final aggregated model" }, { "@id": "metrics_log.json", "@type": "File", + "contentSize": "1511", "description": "Per-round training and evaluation metrics, plus federation details (participant/supernode counts and configuration) for the whole run.", "encodingFormat": "application/json", "name": "Per-round metrics and federation log" @@ -271,7 +291,7 @@ "@id": "https://orcid.org/0009-0000-0000-0000" }, "description": "Run using strategy FedAvg (flwr.serverapp.strategy.fedavg), 3 rounds.", - "endTime": "2026-06-10T10:25:49.281661+00:00", + "endTime": "2026-06-10T10:25:49+00:00", "instrument": [ { "@id": "#flower" @@ -315,7 +335,7 @@ "@id": "metrics_log.json" } ], - "startTime": "2026-06-10T10:25:00.091740+00:00" + "startTime": "2026-06-10T10:25:00+00:00" } ] } \ No newline at end of file diff --git a/flwrcrate/crate_builder.py b/flwrcrate/crate_builder.py index 40168fa..7a3c3b7 100644 --- a/flwrcrate/crate_builder.py +++ b/flwrcrate/crate_builder.py @@ -23,6 +23,7 @@ "https://esciencelab.org.uk/federated-learning-ro-crate-profile/" "federated-learning-profile.html" ) +PROCESS_RUN_CRATE = "https://w3id.org/ro/wfrun/process/0.5" FLOWER_HOMEPAGE = "https://flower.ai/" SCHEMA = "http://schema.org/" @@ -62,13 +63,25 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "RO-Crate describing a federated learning run captured with flwrCrate." ) - # Conformance to the FL profile (RO-Crate spec conformance is set by ro-crate-py). + # Conformance to the profiles the crate follows. RO-Crate spec conformance + # is set on the metadata descriptor by ro-crate-py; profile conformance + # goes on the root data entity, and may list several profiles. profile = crate.add(ContextEntity(crate, FL_PROFILE, properties={ - "@type": "CreativeWork", - "name": "Federated Learning RO-Crate profile v0.1", + "@type": ["CreativeWork", "Profile"], + "name": "Federated Learning RO-Crate profile", + "version": "0.1", })) crate.root_dataset.append_to("conformsTo", profile) + # The FL profile extends Process Run Crate, so declare that too: it lets + # validators and generic provenance tools check the run structure. + prc = crate.add(ContextEntity(crate, PROCESS_RUN_CRATE, properties={ + "@type": ["CreativeWork", "Profile"], + "name": "Process Run Crate", + "version": "0.5", + })) + crate.root_dataset.append_to("conformsTo", prc) + # --- #5 license / author / agent scaffolding ------------------------------- if license: if str(license).startswith("http"): @@ -106,7 +119,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non flwr_version = (captured.get("flower") or {}).get("version") flower_props = {"@type": "SoftwareApplication", "name": "Flower", "url": FLOWER_HOMEPAGE} if flwr_version: - flower_props["softwareVersion"] = flwr_version + flower_props["version"] = flwr_version flower = crate.add(ContextEntity(crate, "#flower", properties=flower_props)) instruments.append(flower) @@ -115,7 +128,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non if fw.get("homepage"): props["url"] = fw["homepage"] if fw.get("installed_version"): - props["softwareVersion"] = fw["installed_version"] + props["version"] = fw["installed_version"] if fw.get("declared"): props["softwareRequirements"] = fw["declared"] # spec from pyproject.toml ent = crate.add(ContextEntity(crate, f"#framework-{_slug(fw['package'])}", properties=props)) @@ -128,7 +141,13 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non "@type": "SoftwareApplication", "name": strat["class_name"], "description": f"Federated aggregation strategy ({strat.get('module')}).", + # The strategy implements Flower's strategy API and is run by Flower, + # so Flower is its reference url and version (RO-Crate requires both + # on a SoftwareApplication). + "url": FLOWER_HOMEPAGE, } + if flwr_version: + strat_props["version"] = flwr_version hp_refs = [] for k, v in (strat.get("attributes") or {}).items(): pid = f"#strategy-param-{_slug(k)}" @@ -145,15 +164,16 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non results = [] model_entity = None if model_path and Path(model_path).exists(): - model_entity = crate.add_file(str(model_path), Path(model_path).name, properties={ + model_entity = crate.add_file(str(model_path), Path(model_path).name, record_size=True, properties={ "@type": "File", "name": "Final aggregated model", "description": "Final global model produced by the federated learning run.", + "encodingFormat": "application/octet-stream", }) 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={ + log_entity = crate.add_file(str(metrics_log_path), Path(metrics_log_path).name, record_size=True, properties={ "@type": "File", "name": "Per-round metrics and federation log", "description": ( diff --git a/flwrcrate/framework.py b/flwrcrate/framework.py index 73b36c7..625ddb2 100644 --- a/flwrcrate/framework.py +++ b/flwrcrate/framework.py @@ -90,11 +90,13 @@ def detect_frameworks(pyproject_path: str = "pyproject.toml") -> list: if known: display, homepage = KNOWN_FRAMEWORKS[name] else: - display, homepage = name, None + # Fall back to the PyPI project page so the entity still has a + # resolvable url, which RO-Crate requires on a SoftwareApplication. + display, homepage = name, f"https://pypi.org/project/{name}/" logger.info( "Recording dependency %r as software used (not in the known-" "frameworks map; add it to KNOWN_FRAMEWORKS for a friendly " - "name + homepage).", name, + "name + curated homepage).", name, ) found.append({ "package": name, diff --git a/flwrcrate/tracker.py b/flwrcrate/tracker.py index e15114b..22fdf07 100755 --- a/flwrcrate/tracker.py +++ b/flwrcrate/tracker.py @@ -74,7 +74,7 @@ def __init__(self, context, strategy, output_dir=None, self._capture = { "app_name": app_name, - "run_timing": {"start_time": datetime.now(timezone.utc).isoformat(), "end_time": None}, + "run_timing": {"start_time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "end_time": None}, "environment_config": run_config, "federation": self._federation, "flower": {"version": flwr_version}, @@ -171,7 +171,7 @@ def wrapped(server_round, arrays): if mr is not None: slot = self._per_round.setdefault(str(server_round), {}) slot["server_side_evaluate"] = metricrecord_to_dict(mr) - slot["captured_at"] = datetime.now(timezone.utc).isoformat() + slot["captured_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") self._save_metrics_log() return mr @@ -183,7 +183,7 @@ def record_result(self, result, model_path=None): if model_path is not None: self.model_path = Path(model_path) - self._capture["run_timing"]["end_time"] = datetime.now(timezone.utc).isoformat() + self._capture["run_timing"]["end_time"] = datetime.now(timezone.utc).isoformat(timespec="seconds") for attr, label in (("train_metrics_clientapp", "train_clientapp"), ("evaluate_metrics_clientapp", "evaluate_clientapp")): diff --git a/tests/test_crate_builder.py b/tests/test_crate_builder.py index 7d529b8..846fb50 100644 --- a/tests/test_crate_builder.py +++ b/tests/test_crate_builder.py @@ -85,8 +85,13 @@ def test_build_crate_core_entities(tmp_path): assert "additionalProperty" in g["#fl-strategy"] # #4 framework with declared + installed versions assert g["#framework-torch"]["softwareRequirements"] == "==2.8.0" - assert g["#framework-torch"]["softwareVersion"] == "2.8.0" - assert g["#flower"]["softwareVersion"] == "1.30.0" + assert g["#framework-torch"]["version"] == "2.8.0" + # #20 conformance: both profiles declared, each as a Profile entity + conforms = {r["@id"] for r in g["./"]["conformsTo"]} + assert "https://w3id.org/ro/wfrun/process/0.5" in conforms + assert any("federated-learning-profile" in i for i in conforms) + assert "Profile" in g["https://w3id.org/ro/wfrun/process/0.5"]["@type"] + assert g["#flower"]["version"] == "1.30.0" # #5 provenance assert g["./"]["license"][0]["@id"] == "https://spdx.org/licenses/MIT.html" assert g["./"]["author"][0]["@id"] == "https://orcid.org/0000-0000-0000-0001" diff --git a/tests/test_framework.py b/tests/test_framework.py index 6d44599..d395ac4 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -67,6 +67,9 @@ def test_detect_returns_declared_and_installed(app_pyproject): # pytest is installed in this env, so the installed-version lookup resolves assert by_pkg["pytest"]["installed_version"] is not None assert by_pkg["pytest"]["known_framework"] is False # not an ML framework + # unknown packages still get a resolvable url (RO-Crate requires one on + # SoftwareApplication); the PyPI project page stands in for a homepage + assert by_pkg["pytest"]["homepage"] == "https://pypi.org/project/pytest/" def test_detect_excludes_infrastructure(app_pyproject):