Skip to content

Commit 52636e6

Browse files
author
Cüneyt Öztürk
committed
release: v0.2.0 — MLFLOW_FALSIFY_TAG_SCOPE + tag_experiment helper
Surfaced by @smqd19 on mlflow/mlflow#23369: HPO sweeps at 50k runs/day repeat 5 identical descriptive PRML tags per-run. Wasteful. This release adds MLFLOW_FALSIFY_TAG_SCOPE=run|experiment. The audit-essential commitment (prml.manifest_hash, prml.manifest_path) stays per-run. The 5 descriptive fields (metric, comparator, threshold, dataset_id, version) lift to experiment level via the new mlflow_falsify.tag_experiment() helper. Backward compatible: default scope is "run", emitting all 7 tags per-run exactly as v0.1.x did. Closes #1.
1 parent c74a7ed commit 52636e6

6 files changed

Lines changed: 267 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Changelog
2+
3+
All notable changes to mlflow-falsify will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4+
5+
## [0.2.0] - 2026-05-23
6+
7+
### Added
8+
- `MLFLOW_FALSIFY_TAG_SCOPE` environment variable. When set to `experiment`, only `prml.manifest_hash` and `prml.manifest_path` attach per-run; the 5 descriptive tags (metric, comparator, threshold, dataset_id, version) are lifted off-run.
9+
- `mlflow_falsify.tag_experiment(experiment=None, *, manifest_path=None)` helper. Sets the descriptive PRML tags at experiment level via `MlflowClient.set_experiment_tag`. Idempotent.
10+
- README section: "HPO sweeps and tag scope".
11+
- 4 new unit tests covering default scope, experiment scope, unknown-value fallback, and explicit-path computation.
12+
13+
### Why
14+
Surfaced by [@smqd19](https://github.com/smqd19) on [mlflow/mlflow#23369](https://github.com/mlflow/mlflow/discussions/23369): at 50k HPO runs/day, emitting 5 identical descriptive tags per-run is wasteful. The audit-essential commitment (`prml.manifest_hash`) must stay per-run; the descriptive fields can live at experiment scope. Tracks [#1](https://github.com/studio-11-co/mlflow-falsify/issues/1).
15+
16+
### Backward compat
17+
Default behaviour unchanged. Without setting the env var, all 7 tags attach per-run exactly as in v0.1.x.
18+
19+
## [0.1.3] - 2026-05-20
20+
- README: switch DOI badge to shields.io format for reliable rendering.
21+
- README: add Audit & compliance crosswalks section (EU AI Act Art. 12, NIST AI RMF, ISO/IEC 42001).
22+
23+
## [0.1.2] - 2026-05-16
24+
- CI: defer `FalsifyRunContextProvider` import to break MLflow circular load during entry-point discovery.
25+
- Add OpenSSF Scorecard workflow.
26+
27+
## [0.1.1] - 2026-05-15
28+
- First public release on PyPI.
29+
- 5 unittests including TV-001 conformance assertion against the PRML v0.1 normative vector.
30+
- Discovery via standard `mlflow.run_context_provider` entry point.
31+
- Canonicalize logic vendored byte-for-byte from `falsify` v0.1.4.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ When a `.prml.yaml` or `prml.yaml` is found in the current directory or any ance
4141

4242
Missing or malformed fields are silently skipped. The provider never raises into your run.
4343

44+
## HPO sweeps and tag scope
45+
46+
In an HPO sweep the same PRML claim repeats across thousands of runs, so emitting the 5 descriptive tags per-run becomes pure tag noise. As of v0.2.0 the plugin supports lifting them to experiment level:
47+
48+
```bash
49+
export MLFLOW_FALSIFY_TAG_SCOPE=experiment
50+
```
51+
52+
```python
53+
import mlflow_falsify
54+
55+
mlflow.set_experiment("credit-scorer-hpo")
56+
mlflow_falsify.tag_experiment() # idempotent; sets metric/comparator/threshold/dataset_id/version once
57+
58+
for params in hpo_grid:
59+
with mlflow.start_run():
60+
... # only prml.manifest_hash and prml.manifest_path attach per-run
61+
```
62+
63+
Default behaviour (`MLFLOW_FALSIFY_TAG_SCOPE=run` or unset) is unchanged: all 7 tags attach per-run, backward-compatible with v0.1.x.
64+
4465
## Why this matters
4566

4667
- **EU AI Act Article 12 evidence layer.** Every logged run carries a tamper-evident pointer to the claim it was meant to test.

mlflow_falsify/__init__.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,93 @@
1010

1111
from __future__ import annotations
1212

13-
__version__ = "0.1.3"
14-
__all__ = ["FalsifyRunContextProvider"]
13+
from pathlib import Path
14+
from typing import Dict, Optional, Union
15+
16+
__version__ = "0.2.0"
17+
__all__ = ["FalsifyRunContextProvider", "tag_experiment"]
1518

1619

1720
def __getattr__(name: str):
1821
if name == "FalsifyRunContextProvider":
1922
from mlflow_falsify.run_context import FalsifyRunContextProvider
2023
return FalsifyRunContextProvider
24+
if name == "tag_experiment":
25+
return tag_experiment
2126
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
27+
28+
29+
def tag_experiment(
30+
experiment: Optional[Union[str, int]] = None,
31+
*,
32+
manifest_path: Optional[Union[str, Path]] = None,
33+
) -> Dict[str, str]:
34+
"""Lift the descriptive PRML tags to experiment-level once per sweep.
35+
36+
For HPO sweeps where a single PRML claim is reused across thousands of
37+
runs, repeating ``prml.metric`` / ``prml.comparator`` / ``prml.threshold``
38+
/ ``prml.dataset_id`` / ``prml.version`` per-run is wasteful. Setting
39+
``MLFLOW_FALSIFY_TAG_SCOPE=experiment`` keeps only ``prml.manifest_hash``
40+
and ``prml.manifest_path`` at the run level; this helper sets the rest
41+
once at experiment level via ``MlflowClient.set_experiment_tag``.
42+
43+
Idempotent: calling twice with the same manifest is a no-op.
44+
45+
Parameters
46+
----------
47+
experiment :
48+
Experiment name (``str``) or experiment id (``int`` or numeric
49+
``str``). If omitted, the currently active MLflow experiment is
50+
used.
51+
manifest_path :
52+
Optional explicit path to a ``.prml.yaml`` manifest. If omitted,
53+
the manifest is auto-discovered from CWD upwards (the same rule
54+
the provider uses).
55+
56+
Returns
57+
-------
58+
Dict[str, str]
59+
The tags that were written. Empty dict if no manifest was found
60+
or the manifest contained no descriptive fields.
61+
62+
Raises
63+
------
64+
RuntimeError
65+
If ``mlflow`` is not installed.
66+
ValueError
67+
If the named experiment cannot be resolved.
68+
"""
69+
try:
70+
import mlflow
71+
from mlflow.tracking import MlflowClient
72+
except ImportError as exc: # pragma: no cover
73+
raise RuntimeError(
74+
"mlflow_falsify.tag_experiment requires mlflow to be installed"
75+
) from exc
76+
77+
from mlflow_falsify.run_context import _RUN_LEVEL_ALWAYS, _compute_tags
78+
79+
path = Path(manifest_path) if manifest_path is not None else None
80+
all_tags = _compute_tags(path)
81+
descriptive = {k: v for k, v in all_tags.items() if k not in _RUN_LEVEL_ALWAYS}
82+
if not descriptive:
83+
return {}
84+
85+
client = MlflowClient()
86+
87+
if experiment is None:
88+
exp_id = mlflow.tracking.fluent._get_experiment_id()
89+
elif isinstance(experiment, int) or (
90+
isinstance(experiment, str) and experiment.isdigit()
91+
):
92+
exp_id = str(experiment)
93+
else:
94+
exp = client.get_experiment_by_name(str(experiment))
95+
if exp is None:
96+
raise ValueError(f"experiment {experiment!r} not found")
97+
exp_id = exp.experiment_id
98+
99+
for key, value in descriptive.items():
100+
client.set_experiment_tag(exp_id, key, value)
101+
102+
return descriptive

mlflow_falsify/run_context.py

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ def tags(self) -> Dict[str, str]:
3636

3737
MANIFEST_FILENAMES = (".prml.yaml", "prml.yaml")
3838

39+
# Tag-scope routing. Two tags are audit-essential and always emitted per-run:
40+
# prml.manifest_hash — the SHA-256 commitment this run is bound to
41+
# prml.manifest_path — the file the provider resolved (for diagnostics)
42+
# The remaining descriptive fields can optionally be lifted to experiment
43+
# level via `mlflow_falsify.tag_experiment()` in HPO sweeps where the
44+
# claim is identical across thousands of runs. Controlled by env var
45+
# MLFLOW_FALSIFY_TAG_SCOPE: "run" (default) or "experiment".
46+
_RUN_LEVEL_ALWAYS = ("prml.manifest_hash", "prml.manifest_path")
47+
48+
49+
def _tag_scope() -> str:
50+
raw = os.environ.get("MLFLOW_FALSIFY_TAG_SCOPE", "run").strip().lower()
51+
return raw if raw in ("run", "experiment") else "run"
52+
3953

4054
def _find_manifest(start: Optional[Path] = None) -> Optional[Path]:
4155
"""Walk up from `start` (default CWD) looking for a PRML manifest."""
@@ -85,45 +99,56 @@ def in_context(self) -> bool:
8599
return _find_manifest() is not None
86100

87101
def tags(self) -> Dict[str, str]:
88-
result: Dict[str, str] = {}
102+
result = _compute_tags()
103+
if _tag_scope() == "experiment":
104+
return {k: v for k, v in result.items() if k in _RUN_LEVEL_ALWAYS}
105+
return result
106+
107+
108+
def _compute_tags(manifest_path: Optional[Path] = None) -> Dict[str, str]:
109+
"""Compute the full PRML tag set for the manifest at `manifest_path`
110+
(or auto-discovered from CWD if None). Returns an empty dict when no
111+
manifest is found or parseable. Never raises."""
112+
result: Dict[str, str] = {}
113+
if manifest_path is None:
89114
manifest_path = _find_manifest()
90-
if manifest_path is None:
91-
return result
115+
if manifest_path is None:
116+
return result
92117

93-
result["prml.manifest_path"] = _relative_path(manifest_path)
118+
result["prml.manifest_path"] = _relative_path(manifest_path)
94119

95-
spec = _load_manifest(manifest_path)
96-
if spec is None:
97-
return result
120+
spec = _load_manifest(manifest_path)
121+
if spec is None:
122+
return result
98123

99-
# Hash the canonical bytes. Anything raised here is swallowed —
100-
# tag emission must never break the run.
101-
try:
102-
result["prml.manifest_hash"] = manifest_hash(spec)
103-
except Exception:
104-
pass
124+
# Hash the canonical bytes. Anything raised here is swallowed —
125+
# tag emission must never break the run.
126+
try:
127+
result["prml.manifest_hash"] = manifest_hash(spec)
128+
except Exception:
129+
pass
105130

106-
version = _safe_str(spec.get("version"))
107-
if version is not None:
108-
result["prml.version"] = version
131+
version = _safe_str(spec.get("version"))
132+
if version is not None:
133+
result["prml.version"] = version
109134

110-
metric = _safe_str(spec.get("metric"))
111-
if metric is not None:
112-
result["prml.metric"] = metric
135+
metric = _safe_str(spec.get("metric"))
136+
if metric is not None:
137+
result["prml.metric"] = metric
113138

114-
comparator = _safe_str(spec.get("comparator"))
115-
if comparator is not None:
116-
result["prml.comparator"] = comparator
139+
comparator = _safe_str(spec.get("comparator"))
140+
if comparator is not None:
141+
result["prml.comparator"] = comparator
117142

118-
threshold = spec.get("threshold")
119-
threshold_str = _safe_str(threshold)
120-
if threshold_str is not None:
121-
result["prml.threshold"] = threshold_str
143+
threshold = spec.get("threshold")
144+
threshold_str = _safe_str(threshold)
145+
if threshold_str is not None:
146+
result["prml.threshold"] = threshold_str
122147

123-
dataset = spec.get("dataset")
124-
if isinstance(dataset, dict):
125-
dataset_id = _safe_str(dataset.get("id"))
126-
if dataset_id is not None:
127-
result["prml.dataset_id"] = dataset_id
148+
dataset = spec.get("dataset")
149+
if isinstance(dataset, dict):
150+
dataset_id = _safe_str(dataset.get("id"))
151+
if dataset_id is not None:
152+
result["prml.dataset_id"] = dataset_id
128153

129-
return result
154+
return result

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "mlflow-falsify"
7-
version = "0.1.3"
7+
version = "0.2.0"
88
description = "MLflow plugin: automatic PRML manifest hash tagging for runs. Pre-registered ML evaluation claims."
99
readme = "README.md"
1010
requires-python = ">=3.9"

tests/test_run_context.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,79 @@ def test_malformed_manifest_does_not_raise(self) -> None:
112112
self.assertNotIn("prml.manifest_hash", tags)
113113

114114

115+
class TagScopeTests(unittest.TestCase):
116+
"""MLFLOW_FALSIFY_TAG_SCOPE env var routes descriptive tags off-run."""
117+
118+
def _with_scope(self, value: str | None):
119+
# Context manager helper: set env var, restore on exit.
120+
class _Scope:
121+
def __init__(self, v: str | None) -> None:
122+
self.v = v
123+
self.prev: str | None = None
124+
self.had: bool = False
125+
126+
def __enter__(self_inner) -> None:
127+
self_inner.had = "MLFLOW_FALSIFY_TAG_SCOPE" in os.environ
128+
self_inner.prev = os.environ.get("MLFLOW_FALSIFY_TAG_SCOPE")
129+
if self_inner.v is None:
130+
os.environ.pop("MLFLOW_FALSIFY_TAG_SCOPE", None)
131+
else:
132+
os.environ["MLFLOW_FALSIFY_TAG_SCOPE"] = self_inner.v
133+
134+
def __exit__(self_inner, *exc: object) -> None:
135+
if self_inner.had:
136+
os.environ["MLFLOW_FALSIFY_TAG_SCOPE"] = self_inner.prev or ""
137+
else:
138+
os.environ.pop("MLFLOW_FALSIFY_TAG_SCOPE", None)
139+
140+
return _Scope(value)
141+
142+
def test_default_scope_emits_all_tags(self) -> None:
143+
with tempfile.TemporaryDirectory() as tmp:
144+
root = Path(tmp).resolve()
145+
(root / ".prml.yaml").write_text(TV_001_YAML, encoding="utf-8")
146+
with _Chdir(root), self._with_scope(None):
147+
tags = FalsifyRunContextProvider().tags()
148+
self.assertEqual(tags.get("prml.manifest_hash"), TV_001_HASH)
149+
self.assertEqual(tags.get("prml.metric"), "accuracy")
150+
self.assertEqual(tags.get("prml.threshold"), "0.85")
151+
self.assertEqual(tags.get("prml.dataset_id"), "imagenet-val-2012")
152+
153+
def test_experiment_scope_keeps_only_audit_essential(self) -> None:
154+
with tempfile.TemporaryDirectory() as tmp:
155+
root = Path(tmp).resolve()
156+
(root / ".prml.yaml").write_text(TV_001_YAML, encoding="utf-8")
157+
with _Chdir(root), self._with_scope("experiment"):
158+
tags = FalsifyRunContextProvider().tags()
159+
# Audit-essential tags stay
160+
self.assertEqual(tags.get("prml.manifest_hash"), TV_001_HASH)
161+
self.assertIn("prml.manifest_path", tags)
162+
# Descriptive tags lifted off-run
163+
self.assertNotIn("prml.metric", tags)
164+
self.assertNotIn("prml.comparator", tags)
165+
self.assertNotIn("prml.threshold", tags)
166+
self.assertNotIn("prml.dataset_id", tags)
167+
self.assertNotIn("prml.version", tags)
168+
169+
def test_unknown_scope_falls_back_to_run(self) -> None:
170+
with tempfile.TemporaryDirectory() as tmp:
171+
root = Path(tmp).resolve()
172+
(root / ".prml.yaml").write_text(TV_001_YAML, encoding="utf-8")
173+
with _Chdir(root), self._with_scope("nonsense"):
174+
tags = FalsifyRunContextProvider().tags()
175+
self.assertEqual(tags.get("prml.metric"), "accuracy")
176+
177+
def test_compute_tags_with_explicit_path(self) -> None:
178+
from mlflow_falsify.run_context import _compute_tags
179+
180+
with tempfile.TemporaryDirectory() as tmp:
181+
root = Path(tmp).resolve()
182+
manifest = root / "subdir" / "claim.yaml"
183+
manifest.parent.mkdir()
184+
manifest.write_text(TV_001_YAML, encoding="utf-8")
185+
tags = _compute_tags(manifest)
186+
self.assertEqual(tags.get("prml.manifest_hash"), TV_001_HASH)
187+
188+
115189
if __name__ == "__main__":
116190
unittest.main()

0 commit comments

Comments
 (0)