Skip to content
Closed
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
4 changes: 4 additions & 0 deletions changelog/756.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fixed the PMP annual-cycle diagnostic recording absolute output-file paths in its CMEC output bundle.
Plot and data paths are now stored relative to the execution output directory,
consistent with the other PMP diagnostics,
so regression baselines stay portable and can be replayed and re-minted faithfully.
6 changes: 6 additions & 0 deletions changelog/756.improvement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Committed regression baselines no longer embed host- or user-specific provenance.
When a test case's committed CMEC bundle is captured,
the provenance block's `userId` and `date` fields are redacted to stable placeholders (`<USER>`, `<DATE>`),
and absolute paths under the shared-software root are rewritten to `<SOFTWARE_ROOT_DIR>`
(alongside the existing `<OUTPUT_DIR>` / `<TEST_DATA_DIR>` placeholders),
so baselines stay portable across machines and reproducible between minting and replay.
52 changes: 38 additions & 14 deletions packages/climate-ref-core/src/climate_ref_core/output_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
PLACEHOLDER_TEST_DATA_DIR = "<TEST_DATA_DIR>"
"""Placeholder substituted for the absolute provider test-data directory."""

PLACEHOLDER_SOFTWARE_ROOT_DIR = "<SOFTWARE_ROOT_DIR>"
"""Placeholder substituted for the absolute shared-software root directory.

Provider command lines stamped into CMEC provenance reference the installed
software environment (e.g. a conda prefix) under this root.
"""

SANITISED_FILE_GLOBS: tuple[str, ...] = (
"*.json",
"*.txt",
Expand Down Expand Up @@ -102,18 +109,34 @@ def rewrite_tree(
file.write_text(rewritten, encoding="utf-8")


def _placeholder_pairs(
output_dir: Path, test_data_dir: Path, software_root_dir: Path | None
) -> list[tuple[str, Path]]:
"""Return the ``(placeholder, absolute directory)`` pairs sanitised in committed artefacts.

Shared by :func:`to_placeholders` and :func:`from_placeholders` so the placeholder set is
declared once and each picks a substitution direction.
"""
pairs = [(PLACEHOLDER_OUTPUT_DIR, output_dir), (PLACEHOLDER_TEST_DATA_DIR, test_data_dir)]
if software_root_dir is not None:
pairs.append((PLACEHOLDER_SOFTWARE_ROOT_DIR, software_root_dir))
return pairs


def to_placeholders(
directory: Path,
*,
output_dir: Path,
test_data_dir: Path,
software_root_dir: Path | None = None,
globs: tuple[str, ...] = SANITISED_FILE_GLOBS,
) -> None:
"""
Rewrite absolute paths in committed artefacts to portable placeholders ("to").

Replaces the absolute ``output_dir`` with ``<OUTPUT_DIR>`` and the absolute
``test_data_dir`` with ``<TEST_DATA_DIR>`` in every text artefact under ``directory``.
Replaces the absolute ``output_dir`` with ``<OUTPUT_DIR>``, the absolute
``test_data_dir`` with ``<TEST_DATA_DIR>``, and (when given) the absolute
``software_root_dir`` with ``<SOFTWARE_ROOT_DIR>`` in every text artefact under ``directory``.
Binary files are never touched.

Parameters
Expand All @@ -124,28 +147,30 @@ def to_placeholders(
The absolute execution output directory.
test_data_dir
The absolute provider test-data directory.
software_root_dir
The absolute shared-software root directory, if any. Substituted in provenance
command lines so the committed bundle stays machine-independent.
globs
File globs whose contents are rewritten.
"""
rewrite_tree(
directory,
{str(output_dir): PLACEHOLDER_OUTPUT_DIR, str(test_data_dir): PLACEHOLDER_TEST_DATA_DIR},
globs,
)
pairs = _placeholder_pairs(output_dir, test_data_dir, software_root_dir)
rewrite_tree(directory, {str(abs_dir): placeholder for placeholder, abs_dir in pairs}, globs)


def from_placeholders(
directory: Path,
*,
output_dir: Path,
test_data_dir: Path,
software_root_dir: Path | None = None,
globs: tuple[str, ...] = SANITISED_FILE_GLOBS,
) -> None:
"""
Rewrite portable placeholders back to absolute paths ("from").

Inverse of :func:`to_placeholders`: replaces ``<OUTPUT_DIR>`` with the absolute ``output_dir``
and ``<TEST_DATA_DIR>`` with the absolute ``test_data_dir`` in every text artefact under ``directory``.
Inverse of :func:`to_placeholders`: replaces ``<OUTPUT_DIR>`` with the absolute ``output_dir``,
``<TEST_DATA_DIR>`` with the absolute ``test_data_dir``, and (when given) ``<SOFTWARE_ROOT_DIR>``
with the absolute ``software_root_dir`` in every text artefact under ``directory``.
Binary files are never touched.

Parameters
Expand All @@ -156,14 +181,13 @@ def from_placeholders(
The absolute execution output directory to substitute in.
test_data_dir
The absolute provider test-data directory to substitute in.
software_root_dir
The absolute shared-software root directory to substitute in, if any.
globs
File globs whose contents are rewritten.
"""
rewrite_tree(
directory,
{PLACEHOLDER_OUTPUT_DIR: str(output_dir), PLACEHOLDER_TEST_DATA_DIR: str(test_data_dir)},
globs,
)
pairs = _placeholder_pairs(output_dir, test_data_dir, software_root_dir)
rewrite_tree(directory, {placeholder: str(abs_dir) for placeholder, abs_dir in pairs}, globs)


def copy_output_file(
Expand Down
109 changes: 107 additions & 2 deletions packages/climate-ref-core/src/climate_ref_core/regression/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,108 @@ def _round_committed_floats(regression_dir: Path) -> None:
logger.warning(f"{filename} contains float values but is not float-rounded.")


# CMEC provenance lives in the metric bundle's uppercase ``PROVENANCE`` block and the
# output bundle's lowercase ``provenance`` block; series.json never carries one.
_PROVENANCE_BEARING_FILES: tuple[str, ...] = ("diagnostic.json", "output.json")
_PROVENANCE_BLOCK_KEYS: frozenset[str] = frozenset({"PROVENANCE", "provenance"})

# Provenance fields redacted to stable placeholders so the committed bundle stays portable
# (machine-independent) and reproducible: ``userId`` is the minting user and ``date`` is a
# non-reproducible wall-clock timestamp. Absolute paths in ``commandLine`` are made portable
# by path placeholdering (``<OUTPUT_DIR>`` / ``<SOFTWARE_ROOT_DIR>``), not by redaction.
_REDACTED_PROVENANCE_FIELDS: dict[str, str] = {
"userId": "<USER>",
"date": "<DATE>",
}

# JSON dump parameters matching each committed file's canonical on-disk form, so a structured
# edit re-serialises byte-for-byte apart from the changed fields. diagnostic.json is single-sourced
# from _COMMITTED_FLOAT_JSON_KWARGS so its serialisation parameters cannot drift between the
# float-rounding and provenance-redaction re-dumps; output.json is the CMEC output bundle
# (json.dumps(indent=2), no key-sorting -- see _quantise).
_COMMITTED_DUMP_KWARGS: dict[str, dict[str, object]] = {
"diagnostic.json": _COMMITTED_FLOAT_JSON_KWARGS["diagnostic.json"],
"output.json": {"indent": 2},
}


def _redact_provenance_fields(obj: object) -> bool:
"""
Redact the declared provenance fields in every CMEC provenance block of ``obj`` in place.

Walks the parsed bundle and, for each ``PROVENANCE`` / ``provenance`` block, overwrites the
fields in :data:`_REDACTED_PROVENANCE_FIELDS` with their placeholders. Scoping to the
provenance block means a same-named key elsewhere in the bundle is never touched.

Returns
-------
:
``True`` if any field was changed.
"""
changed = False
if isinstance(obj, dict):
for key, value in obj.items():
if key in _PROVENANCE_BLOCK_KEYS and isinstance(value, dict):
for field, placeholder in _REDACTED_PROVENANCE_FIELDS.items():
if field in value and value[field] != placeholder:
value[field] = placeholder
changed = True
elif _redact_provenance_fields(value):
changed = True
elif isinstance(obj, list):
for item in obj:
if _redact_provenance_fields(item):
changed = True
return changed


def _redact_committed_provenance(regression_dir: Path) -> None:
"""
Redact host/user-specific provenance fields from the committed CMEC bundle in place.

CMEC providers (e.g. PMP) stamp each bundle with a provenance block recording the minting
``userId`` and a wall-clock ``date``.
Those leak personal metadata into git-tracked fixtures and never reproduce,
so they are replaced with stable placeholders.
The edit is structured -- the declared fields are set on the parsed object -- and the file is
re-serialised with its canonical parameters,
so the only byte difference is the redacted field values.

Runs after :func:`_round_committed_floats` so it operates on the final, NaN-free bytes,
and inside :func:`write_committed_bundle` so it is applied identically at mint and replay --
keeping the committed digests reproducible across machines.

Parameters
----------
regression_dir
The test case ``regression/`` directory holding the committed bundle.
"""
for filename in _PROVENANCE_BEARING_FILES:
path = regression_dir / filename
if not path.exists():
continue
data = json.loads(path.read_text(encoding="utf-8"))
if _redact_provenance_fields(data):
kwargs = _COMMITTED_DUMP_KWARGS[filename]
path.write_text(json.dumps(data, **kwargs), encoding="utf-8") # type: ignore[arg-type]


def write_committed_bundle(
source_dir: Path,
regression_dir: Path,
*,
output_dir: Path,
test_data_dir: Path,
software_root_dir: Path | None = None,
) -> dict[str, str]:
"""
Write the sanitised committed CMEC bundle into ``regression_dir``.

Copies each committed artefact present in ``source_dir`` into ``regression_dir``,
then rewrites absolute paths to portable placeholders in place
(:func:`~climate_ref_core.output_files.to_placeholders`).
(:func:`~climate_ref_core.output_files.to_placeholders`),
rounds floats (:func:`_round_committed_floats`),
and redacts host/user-specific CMEC provenance fields (:func:`_redact_committed_provenance`).
When a committed artefact is absent from ``source_dir``,
any stale copy left in ``regression_dir`` from a previous capture is removed so it is not re-digested.

Expand All @@ -135,6 +224,9 @@ def write_committed_bundle(
The absolute execution output directory, for path substitution.
test_data_dir
The absolute provider test-data directory, for path substitution.
software_root_dir
The absolute shared-software root directory, for path substitution in provenance
command lines. When ``None``, no software-root substitution is applied.

Returns
-------
Expand All @@ -153,11 +245,19 @@ def write_committed_bundle(
# Drop a stale copy from a previous capture so it is not re-digested.
dest.unlink(missing_ok=True)

to_placeholders(regression_dir, output_dir=output_dir, test_data_dir=test_data_dir)
to_placeholders(
regression_dir,
output_dir=output_dir,
test_data_dir=test_data_dir,
software_root_dir=software_root_dir,
)
# Round floats in place before digesting,
# so the committed bytes (and their recorded digests) are the stable, rounded ones.
# Placeholder substitution only rewrites path strings, so order relative to it does not matter for floats.
_round_committed_floats(regression_dir)
# Redact host/user-specific provenance fields (userId, date) last, so it operates on the
# final NaN-free bytes and the only change is the redacted field values.
_redact_committed_provenance(regression_dir)
return compute_committed_digests(regression_dir)


Expand Down Expand Up @@ -194,6 +294,7 @@ def capture_execution( # noqa: PLR0913
regression_dir: Path,
output_dir: Path,
test_data_dir: Path,
software_root_dir: Path | None = None,
# TODO: Unify the log handling
include_log: bool = False,
) -> tuple[dict[str, str], dict[str, NativeEntry]]:
Expand Down Expand Up @@ -221,6 +322,9 @@ def capture_execution( # noqa: PLR0913
The absolute execution output directory, for path substitution.
test_data_dir
The absolute provider test-data directory, for path substitution.
software_root_dir
The absolute shared-software root directory, for path substitution in provenance
command lines. When ``None``, no software-root substitution is applied.
include_log
If True, the execution log is included in the persisted/native set.

Expand All @@ -245,6 +349,7 @@ def capture_execution( # noqa: PLR0913
regression_dir,
output_dir=output_dir,
test_data_dir=test_data_dir,
software_root_dir=software_root_dir,
)
native = build_native_snapshot(base_dir, relpaths)
return committed, native
Expand Down
95 changes: 94 additions & 1 deletion packages/climate-ref-core/tests/unit/regression/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import pytest

from climate_ref_core.logging import EXECUTION_LOG_FILENAME
from climate_ref_core.output_files import PLACEHOLDER_OUTPUT_DIR, PLACEHOLDER_TEST_DATA_DIR
from climate_ref_core.output_files import (
PLACEHOLDER_OUTPUT_DIR,
PLACEHOLDER_SOFTWARE_ROOT_DIR,
PLACEHOLDER_TEST_DATA_DIR,
)
from climate_ref_core.pycmec.output import CMECOutput
from climate_ref_core.regression.capture import (
_COMMITTED_FLOAT_JSON_KWARGS,
Expand Down Expand Up @@ -169,6 +173,95 @@ def test_write_committed_bundle_leaves_output_json_bytes_unchanged(tmp_path):
assert json.loads((regression_dir / "series.json").read_text())[0]["values"] == [1.761334]


def _leaky_provenance(software_root: Path, output_dir: Path) -> dict:
"""A CMEC provenance block: host/user fields to redact + absolute paths to placeholder."""
return {
"commandLine": (
f"{software_root}/conda/pmp/bin/mean_climate_driver.py "
f"-p {software_root}/params/pmp_param.py "
f"--test_data_path {output_dir} --cmec"
),
"date": "2026-06-24 12:30:54",
"userId": "jared",
"platform": {"Name": "gus", "OS": "Linux"},
}


def test_write_committed_bundle_redacts_and_placeholders_provenance(tmp_path):
output_dir = (tmp_path / "scratch" / "frag").resolve()
test_data_dir = (tmp_path / "test-data").resolve()
software_root = (tmp_path / "software").resolve()
source = tmp_path / "scratch" / "frag"
source.mkdir(parents=True)
# Metric bundle: leaky uppercase PROVENANCE + a float so rounding also runs.
(source / "diagnostic.json").write_text(
json.dumps(
{"PROVENANCE": _leaky_provenance(software_root, output_dir), "RESULTS": {"score": 1.2345678901}}
)
)
# Output bundle: leaky lowercase provenance, float-free, native indent=2 layout.
output_obj = CMECOutput.create_template()
output_obj["provenance"].update(_leaky_provenance(software_root, output_dir))
(source / "output.json").write_text(json.dumps(output_obj, indent=2))
(source / "series.json").write_text(json.dumps([]))
regression_dir = tmp_path / "regression"

digests = write_committed_bundle(
source,
regression_dir,
output_dir=output_dir,
test_data_dir=test_data_dir,
software_root_dir=software_root,
)

for filename in ("diagnostic.json", "output.json"):
text = (regression_dir / filename).read_text()
# date and userId are redacted as structured fields...
assert '"userId": "<USER>"' in text
assert '"date": "<DATE>"' in text
# ...the command line is kept but made portable via path placeholders (not nuked)...
assert "<COMMAND_LINE>" not in text
assert "mean_climate_driver.py" in text
assert PLACEHOLDER_SOFTWARE_ROOT_DIR in text
assert PLACEHOLDER_OUTPUT_DIR in text
# ...and no personal / absolute-path / timestamp data survives.
assert "jared" not in text
assert str(software_root) not in text
assert str(output_dir) not in text
assert "2026-06-24 12:30:54" not in text
# Digest is taken over the sanitised bytes exactly as they sit on disk.
assert digests[filename] == sha256_file(regression_dir / filename)

# Structured redaction survives float-rounding in the metric bundle.
diag = json.loads((regression_dir / "diagnostic.json").read_text())
assert diag["PROVENANCE"]["userId"] == "<USER>"
assert diag["PROVENANCE"]["date"] == "<DATE>"
assert diag["RESULTS"]["score"] == 1.234568

# output.json is re-dumped byte-faithfully: provenance key order is preserved.
out = json.loads((regression_dir / "output.json").read_text())
assert list(out["provenance"].keys()) == list(output_obj["provenance"].keys())


def test_redaction_is_noop_without_provenance_fields(tmp_path):
# A bundle with no host/user provenance fields must pass through untouched,
# so the committed digest stays byte-stable for already-portable artefacts.
output_dir = (tmp_path / "scratch" / "frag").resolve()
test_data_dir = (tmp_path / "test-data").resolve()
source = tmp_path / "scratch" / "frag"
source.mkdir(parents=True)
template = CMECOutput.create_template()
(source / "output.json").write_text(json.dumps(template, indent=2))
source_output_bytes = (source / "output.json").read_bytes()
(source / "diagnostic.json").write_text(json.dumps({"RESULTS": {"score": 1.0}}))
(source / "series.json").write_text(json.dumps([]))
regression_dir = tmp_path / "regression"

write_committed_bundle(source, regression_dir, output_dir=output_dir, test_data_dir=test_data_dir)

assert (regression_dir / "output.json").read_bytes() == source_output_bytes


def test_build_native_snapshot_digests_relpaths(tmp_path):
base = tmp_path / "results" / "frag"
base.mkdir(parents=True)
Expand Down
Loading
Loading