Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
42 changes: 31 additions & 11 deletions examples/quickstart-pytorch/ro-crate-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
]
}
34 changes: 27 additions & 7 deletions flwrcrate/crate_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"

Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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)

Expand All @@ -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))
Expand All @@ -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)}"
Expand All @@ -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": (
Expand Down
6 changes: 4 additions & 2 deletions flwrcrate/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions flwrcrate/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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

Expand All @@ -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")):
Expand Down
9 changes: 7 additions & 2 deletions tests/test_crate_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions tests/test_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading