Skip to content

Commit 1a81b81

Browse files
fix(manifest): encode valid YAML metadata dates in digests (#214)
* fix(manifest): encode valid YAML metadata dates in digests Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> * docs(manifest): clarify compatible ISO date representation Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> * fix(manifest): canonicalize YAML date keys safely Signed-off-by: Rudy Celekli <rudy@gradiahq.com> --------- Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> Signed-off-by: Rudy Celekli <rudy@gradiahq.com> Co-authored-by: n-papaioannou <n.papaioannou@ime.life>
1 parent 48cd7b3 commit 1a81b81

3 files changed

Lines changed: 127 additions & 1 deletion

File tree

‎docs/reproducibility.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Every run writes `runs/<run_id>/manifest.json`. It records the exact inputs to t
66

77
`fixture_digest` is a SHA-256 over the canonicalised fixture YAML (parsed, keys sorted, JSON-serialised). Value and list-order changes alter it; comments, whitespace, and key order do not.
88

9+
YAML dates and timestamps in metadata keys and values use ISO strings during canonicalisation, compatible with the fixture model's existing JSON export representation. Conflicting keys that would become the same ISO string are rejected instead of silently overwriting an entry.
10+
911
## Run nonce
1012

1113
`run_nonce` is a fresh 16-hex value appended to the system prompt as `[run_id: <nonce>]`, so a provider cannot serve cached replies. The manifest records it in full; exact replay needs it (see below).
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Schema-valid YAML metadata dates must reach native runs and JSON exports."""
2+
3+
import datetime
4+
import json
5+
import os
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
import pytest
11+
import yaml
12+
13+
from ifixai.core.fixture_loader import load_fixture, validate_fixture
14+
from ifixai.utils.fixture_digest import compute_fixture_digest
15+
16+
17+
def _fixture(tmp_path, value):
18+
source = Path(__file__).parents[1] / "fixtures/examples/openclaw_strict.yaml"
19+
data = yaml.safe_load(source.read_text(encoding="utf-8"))
20+
data["test_cases"][0]["metadata"] = {"reviewed_on": value}
21+
path = tmp_path / "fixture.yaml"
22+
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
23+
return path
24+
25+
26+
@pytest.mark.parametrize("value", [
27+
datetime.date(2026, 10, 6),
28+
datetime.datetime(2026, 10, 6, 12, 30, tzinfo=datetime.timezone.utc),
29+
"2026-10-06",
30+
])
31+
def test_valid_metadata_scalar_completes_native_cli(tmp_path, value):
32+
path = _fixture(tmp_path, value)
33+
assert validate_fixture(path) == []
34+
loaded = load_fixture(str(path))
35+
json_metadata = loaded.test_cases[0].model_dump(mode="json")["metadata"]
36+
assert isinstance(json_metadata["reviewed_on"], str)
37+
assert len(compute_fixture_digest(path)) == 64
38+
env = os.environ.copy()
39+
env.update(HOME=str(tmp_path), XDG_CONFIG_HOME=str(tmp_path / "config"),
40+
IFIXAI_TELEMETRY="0", DO_NOT_TRACK="1")
41+
env["PYTHONPATH"] = os.pathsep.join([str(Path(__file__).parents[2]), env.get("PYTHONPATH", "")])
42+
result = subprocess.run([
43+
sys.executable, "-m", "ifixai.cli.main", "run", "--provider", "mock",
44+
"--fixture", str(path), "--test", "B01", "--eval-mode", "single",
45+
"--judge-provider", "mock", "--grounding", "fixture", "--no-telemetry",
46+
"--no-parallel", "--min-score", "0", "--output", "reports",
47+
"--reliability-out", "runs",
48+
], cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60, check=False)
49+
assert result.returncode in (0, 2), result.stderr
50+
assert "Traceback" not in result.stderr
51+
reports = list((tmp_path / "reports").glob("*.json"))
52+
assert len(reports) == 1, result.stdout
53+
scorecard = json.loads(reports[0].read_text(encoding="utf-8"))
54+
assert len(scorecard["test_results"]) == 1
55+
assert list((tmp_path / "runs").glob("*/manifest.json"))
56+
57+
58+
@pytest.mark.parametrize("value", [
59+
datetime.date(2026, 10, 6),
60+
datetime.datetime(2026, 10, 6, 12, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=2))),
61+
])
62+
def test_dates_follow_existing_iso_json_scalar_convention(tmp_path, value):
63+
path = _fixture(tmp_path, value)
64+
digest = compute_fixture_digest(path)
65+
_fixture(tmp_path, value.isoformat())
66+
assert compute_fixture_digest(path) == digest
67+
68+
69+
def test_ordinary_fixture_digest_bytes_are_unchanged(tmp_path):
70+
import hashlib
71+
72+
path = tmp_path / "ordinary.yaml"
73+
path.write_text("text: café\ncount: 2\nlist: [false, null, 1.5]\n", encoding="utf-8")
74+
expected = json.dumps(yaml.safe_load(path.read_text()), sort_keys=True,
75+
separators=(",", ":"), ensure_ascii=False).encode("utf-8")
76+
assert compute_fixture_digest(path) == hashlib.sha256(expected).hexdigest()
77+
78+
79+
@pytest.mark.parametrize("key", [
80+
datetime.date(2026, 10, 6),
81+
datetime.datetime(2026, 10, 6, 12, 30, tzinfo=datetime.timezone.utc),
82+
])
83+
def test_date_keys_follow_iso_representation_with_mixed_metadata(tmp_path, key):
84+
path = _fixture(tmp_path, {key: "approved", "other": "unchanged"})
85+
assert validate_fixture(path) == []
86+
assert load_fixture(str(path)).test_cases[0].metadata
87+
digest = compute_fixture_digest(path)
88+
_fixture(tmp_path, {key: "approved"})
89+
assert len(compute_fixture_digest(path)) == 64
90+
env = os.environ.copy()
91+
env.update(HOME=str(tmp_path), IFIXAI_TELEMETRY="0", DO_NOT_TRACK="1")
92+
env["PYTHONPATH"] = os.pathsep.join([str(Path(__file__).parents[2]), env.get("PYTHONPATH", "")])
93+
result = subprocess.run([
94+
sys.executable, "-m", "ifixai.cli.main", "run", "--provider", "mock",
95+
"--fixture", str(path), "--test", "B01", "--eval-mode", "single",
96+
"--judge-provider", "mock", "--grounding", "fixture", "--no-telemetry",
97+
"--no-parallel", "--min-score", "0", "--output", "reports",
98+
"--reliability-out", "runs",
99+
], cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60, check=False)
100+
assert result.returncode in (0, 2), result.stderr
101+
assert "Traceback" not in result.stderr
102+
assert len(list((tmp_path / "reports").glob("*.json"))) == 1
103+
assert len(list((tmp_path / "runs").glob("*/manifest.json"))) == 1
104+
_fixture(tmp_path, {key.isoformat(): "approved", "other": "unchanged"})
105+
assert compute_fixture_digest(path) == digest
106+
107+
108+
def test_date_key_conversion_cannot_silently_merge_distinct_entries(tmp_path):
109+
path = _fixture(tmp_path, {
110+
datetime.date(2026, 10, 6): "approved",
111+
"2026-10-06": "different original entry",
112+
})
113+
assert validate_fixture(path) == []
114+
with pytest.raises(ValueError, match="duplicate canonical fixture key"):
115+
compute_fixture_digest(path)

‎ifixai/utils/fixture_digest.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@
22

33
import hashlib
44
import json
5+
from datetime import date, datetime
56
from pathlib import Path
67
from typing import Any
78

89
import yaml
910

1011

1112
def _canonicalise(obj: Any) -> Any:
13+
if isinstance(obj, (date, datetime)):
14+
return obj.isoformat()
1215
if isinstance(obj, dict):
13-
return {key: _canonicalise(obj[key]) for key in sorted(obj)}
16+
canonical = {}
17+
for key, value in obj.items():
18+
canonical_key = key.isoformat() if isinstance(key, (date, datetime)) else key
19+
if canonical_key in canonical:
20+
raise ValueError(f"duplicate canonical fixture key: {canonical_key!r}")
21+
canonical[canonical_key] = _canonicalise(value)
22+
return {key: canonical[key] for key in sorted(canonical)}
1423
if isinstance(obj, list):
1524
return [_canonicalise(item) for item in obj]
1625
return obj

0 commit comments

Comments
 (0)