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
24 changes: 15 additions & 9 deletions examples/quickstart-pytorch/ro-crate-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
Expand Down
59 changes: 30 additions & 29 deletions flwrcrate/crate_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -68,17 +67,17 @@ 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:
if str(license).startswith("http"):
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 "
Expand All @@ -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 "
Expand All @@ -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"]}
Expand All @@ -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 {}
Expand All @@ -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 = []
Expand All @@ -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={
Expand All @@ -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 {}
Expand All @@ -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 {}
Expand All @@ -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')}), "
Expand All @@ -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
4 changes: 2 additions & 2 deletions tests/test_crate_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading