228 lines
7.8 KiB
Python
228 lines
7.8 KiB
Python
"""Tests for case_studies/utils/registry/specs.py.
|
|
|
|
Hashing determinism is load-bearing: the registry is a content-addressed
|
|
store, so any perturbation to hash computation (key ordering, separator
|
|
choice, seed handling) silently duplicates runs and corrupts lineage.
|
|
|
|
These tests pin the exact byte-for-byte hash output so a reformat of
|
|
canonical_json or compute_hash cannot change the addresses of existing runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from case_studies.utils.registry.specs import (
|
|
DEFAULT_SEED,
|
|
HASH_LENGTH,
|
|
_validate_spec,
|
|
backtest_hash_from_parts,
|
|
canonical_json,
|
|
compute_hash,
|
|
prediction_hash_from_parts,
|
|
training_hash_from_spec,
|
|
)
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# canonical_json — deterministic serialization
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_canonical_json_sorts_keys() -> None:
|
|
a = canonical_json({"b": 2, "a": 1})
|
|
b = canonical_json({"a": 1, "b": 2})
|
|
assert a == b
|
|
assert a == '{"a":1,"b":2}'
|
|
|
|
|
|
def test_canonical_json_uses_compact_separators() -> None:
|
|
assert canonical_json({"x": 1}) == '{"x":1}'
|
|
|
|
|
|
def test_canonical_json_stringifies_unserializable_via_default() -> None:
|
|
"""Path/Enum/datetime-like fields fall through `default=str` so a spec
|
|
never fails serialization."""
|
|
from pathlib import Path
|
|
|
|
out = canonical_json({"path": Path("/tmp/x.parquet")})
|
|
assert "/tmp/x.parquet" in out
|
|
|
|
|
|
def test_canonical_json_is_deterministic_across_nested_structures() -> None:
|
|
spec = {
|
|
"outer": {"b": [3, 2, 1], "a": {"z": 9, "y": 8}},
|
|
"flat": 42,
|
|
}
|
|
first = canonical_json(spec)
|
|
second = canonical_json(spec)
|
|
assert first == second
|
|
# Keys are sorted at every level
|
|
assert first.index('"a":') < first.index('"b":')
|
|
assert first.index('"y":') < first.index('"z":')
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# compute_hash — sha256 truncation invariant
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_compute_hash_default_length_is_12() -> None:
|
|
h = compute_hash("anything")
|
|
assert len(h) == HASH_LENGTH == 12
|
|
|
|
|
|
def test_compute_hash_is_prefix_of_full_sha256() -> None:
|
|
content = "some_training_content"
|
|
expected_prefix = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
assert compute_hash(content) == expected_prefix
|
|
|
|
|
|
def test_compute_hash_length_override_respects_arg() -> None:
|
|
assert len(compute_hash("x", length=6)) == 6
|
|
assert len(compute_hash("x", length=64)) == 64
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# training_hash_from_spec
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _base_spec(**overrides) -> dict:
|
|
spec = {
|
|
"family": "linear",
|
|
"label": "fwd_ret_21d",
|
|
"seed": 42,
|
|
"n_folds": 5,
|
|
}
|
|
spec.update(overrides)
|
|
return spec
|
|
|
|
|
|
def test_training_hash_is_deterministic() -> None:
|
|
spec = _base_spec()
|
|
assert training_hash_from_spec(spec) == training_hash_from_spec(dict(spec))
|
|
|
|
|
|
def test_training_hash_differs_when_seed_changes() -> None:
|
|
assert training_hash_from_spec(_base_spec(seed=1)) != training_hash_from_spec(
|
|
_base_spec(seed=2)
|
|
)
|
|
|
|
|
|
def test_training_hash_differs_when_family_changes() -> None:
|
|
assert training_hash_from_spec(_base_spec(family="gbm")) != training_hash_from_spec(
|
|
_base_spec(family="linear")
|
|
)
|
|
|
|
|
|
def test_training_hash_differs_when_label_changes() -> None:
|
|
assert training_hash_from_spec(_base_spec(label="fwd_ret_5d")) != training_hash_from_spec(
|
|
_base_spec(label="fwd_ret_21d")
|
|
)
|
|
|
|
|
|
def test_training_hash_invariant_under_key_order() -> None:
|
|
"""Client code may build the spec dict in arbitrary order; hash must be stable."""
|
|
spec_a = {"family": "gbm", "label": "fwd_ret_21d", "seed": 42}
|
|
spec_b = {"seed": 42, "label": "fwd_ret_21d", "family": "gbm"}
|
|
assert training_hash_from_spec(spec_a) == training_hash_from_spec(spec_b)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Spec validation
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_spec_missing_seed_injects_default(caplog) -> None:
|
|
"""Missing-seed-only case: warn and inject DEFAULT_SEED."""
|
|
with caplog.at_level("WARNING"):
|
|
enriched = _validate_spec({"family": "gbm", "label": "fwd_ret_5d"})
|
|
|
|
assert enriched["seed"] == DEFAULT_SEED
|
|
assert "missing 'seed'" in caplog.text
|
|
|
|
|
|
def test_validate_spec_missing_multiple_fields_raises() -> None:
|
|
"""Anything beyond a missing seed is a hard error."""
|
|
with pytest.raises(ValueError, match="missing required fields"):
|
|
_validate_spec({"family": "gbm"}) # missing label + seed
|
|
|
|
|
|
def test_validate_spec_does_not_mutate_original() -> None:
|
|
original = {"family": "gbm", "label": "x"}
|
|
enriched = _validate_spec(original)
|
|
assert "seed" not in original # original untouched
|
|
assert enriched["seed"] == DEFAULT_SEED
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# prediction_hash_from_parts
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_prediction_hash_combines_training_hash_checkpoint_split() -> None:
|
|
h = prediction_hash_from_parts("abc123", 100, "val")
|
|
# Reconstruct exact content and compare to the public API
|
|
assert h == compute_hash("abc123|100|val")
|
|
|
|
|
|
def test_prediction_hash_none_checkpoint_becomes_final() -> None:
|
|
h_none = prediction_hash_from_parts("abc", None, "val")
|
|
h_final_str = prediction_hash_from_parts("abc", None, "val") # Same call
|
|
assert h_none == compute_hash("abc|final|val")
|
|
assert h_none == h_final_str
|
|
|
|
|
|
def test_prediction_hash_distinct_on_split() -> None:
|
|
assert prediction_hash_from_parts("abc", 1, "val") != prediction_hash_from_parts(
|
|
"abc", 1, "test"
|
|
)
|
|
|
|
|
|
def test_prediction_hash_distinct_on_checkpoint() -> None:
|
|
assert prediction_hash_from_parts("abc", 10, "val") != prediction_hash_from_parts(
|
|
"abc", 20, "val"
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# backtest_hash_from_parts
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_backtest_hash_combines_prediction_hash_and_strategy_spec() -> None:
|
|
strategy = {"signal": {"method": "equal_weight_top_k", "top_k": 10}}
|
|
h = backtest_hash_from_parts("pred123", strategy)
|
|
assert h == compute_hash(f"pred123|{canonical_json(strategy)}")
|
|
|
|
|
|
def test_backtest_hash_sensitive_to_strategy_change() -> None:
|
|
base = {"top_k": 10}
|
|
variant = {"top_k": 20}
|
|
assert backtest_hash_from_parts("p1", base) != backtest_hash_from_parts("p1", variant)
|
|
|
|
|
|
def test_backtest_hash_invariant_under_strategy_key_order() -> None:
|
|
a = {"signal": {"method": "x", "top_k": 10}, "allocation": {"method": "eq"}}
|
|
b = {"allocation": {"method": "eq"}, "signal": {"top_k": 10, "method": "x"}}
|
|
assert backtest_hash_from_parts("p", a) == backtest_hash_from_parts("p", b)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Regression pin — the exact hash for a canonical spec
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_training_hash_regression_pin_for_canonical_spec() -> None:
|
|
"""Pin the exact hash of a minimal valid spec. Changing this value
|
|
invalidates every existing registry entry — so any change should be an
|
|
explicit migration, not an accidental refactor."""
|
|
spec = {"family": "linear", "label": "fwd_ret_21d", "seed": 42}
|
|
content = json.dumps(spec, sort_keys=True, separators=(",", ":"), default=str)
|
|
expected = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
|
|
assert training_hash_from_spec(spec) == expected
|