diff --git a/tests/test_weights.py b/tests/test_weights.py new file mode 100644 index 0000000..978466f --- /dev/null +++ b/tests/test_weights.py @@ -0,0 +1,180 @@ +"""Tests for ``arborist.substrate.weights`` — WeightSet defaults + +``from_dict`` adapter (#000012 Phase 1a). + +Pure-stdlib dataclass + classmethod-style adapter; small surface. +This pins: + + - documented defaults (lifted from the module docstring) + - greek-letter and Python-safe key aliases ("lambda" ↔ "lambda_") + - missing-keys fall-through to defaults + - extra-keys silently ignored + - string values coerce to float + - frozen-dataclass invariant (mutation raises) +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from arborist.substrate.weights import ( + DEFAULT_WEIGHTS, + WeightSet, + from_dict, +) + + +# --- defaults pinned to the docstring ------------------------------ + + +def test_default_weights_match_documented_values(): + """Each weight in the dataclass docstring is pinned. If a future + PR changes a default, this test fires and forces an explicit + update of both the docstring AND the test.""" + assert DEFAULT_WEIGHTS.alpha == 1.0 + assert DEFAULT_WEIGHTS.beta == 1.0 + assert DEFAULT_WEIGHTS.gamma == 1.0 + assert DEFAULT_WEIGHTS.delta == 0.5 + assert DEFAULT_WEIGHTS.epsilon == 0.3 + assert DEFAULT_WEIGHTS.zeta == 0.0 # validator-diversity off in single-validator + assert DEFAULT_WEIGHTS.eta == 2.0 # regression > improvement weight + assert DEFAULT_WEIGHTS.theta == 0.5 + assert DEFAULT_WEIGHTS.iota == 0.0 # reserved + assert DEFAULT_WEIGHTS.kappa == 0.0 # reserved + assert DEFAULT_WEIGHTS.lambda_ == 0.5 + + +def test_eta_is_heavier_than_alpha(): + """Documented invariant from the docstring: + 'η (RegressionPenalty) heavier than improvement weights so the + scorer is biased toward refusing regressions.'""" + assert DEFAULT_WEIGHTS.eta > DEFAULT_WEIGHTS.alpha + assert DEFAULT_WEIGHTS.eta > DEFAULT_WEIGHTS.beta + assert DEFAULT_WEIGHTS.eta > DEFAULT_WEIGHTS.gamma + + +def test_three_batteries_equal_by_default(): + """Documented invariant: 'α = β = γ (3 batteries weighted equally + by default).'""" + assert DEFAULT_WEIGHTS.alpha == DEFAULT_WEIGHTS.beta == DEFAULT_WEIGHTS.gamma + + +def test_reserved_weights_are_zero_phase_1a(): + """Phase 1a discipline: validator-diversity / security / complexity + are zero by default. v8 multi-validator deployments must opt in.""" + assert DEFAULT_WEIGHTS.zeta == 0.0 + assert DEFAULT_WEIGHTS.iota == 0.0 + assert DEFAULT_WEIGHTS.kappa == 0.0 + + +# --- as_dict -------------------------------------------------------- + + +def test_as_dict_returns_all_eleven_fields(): + d = DEFAULT_WEIGHTS.as_dict() + expected_keys = { + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", + "eta", "theta", "iota", "kappa", "lambda_", + } + assert set(d.keys()) == expected_keys + + +def test_as_dict_uses_lambda_underscore_key(): + """``lambda`` is a Python keyword → dataclass uses ``lambda_``; + as_dict() carries it forward unchanged. Callers serializing to + JSON / YAML rename to bare ``lambda`` if needed.""" + d = DEFAULT_WEIGHTS.as_dict() + assert "lambda_" in d + assert "lambda" not in d + + +# --- from_dict adapter --------------------------------------------- + + +def test_from_dict_uses_greek_letter_keys(): + ws = from_dict({"alpha": 0.7, "beta": 0.8, "gamma": 0.9}) + assert ws.alpha == 0.7 + assert ws.beta == 0.8 + assert ws.gamma == 0.9 + # Unspecified keys fall through to defaults. + assert ws.delta == DEFAULT_WEIGHTS.delta + + +def test_from_dict_translates_lambda_to_lambda_underscore(): + """JSON/YAML configs use bare ``lambda`` (the Greek letter, not + the keyword). from_dict translates to the Python-safe field + name ``lambda_``.""" + ws = from_dict({"lambda": 0.99}) + assert ws.lambda_ == 0.99 + # Bare 'lambda' did not survive into a hidden field. + assert "lambda" not in ws.as_dict() + + +def test_from_dict_lambda_underscore_key_also_accepted(): + """For Python callers writing config dicts directly, ``lambda_`` + is the natural key name; from_dict accepts it too.""" + ws = from_dict({"lambda_": 0.42}) + assert ws.lambda_ == 0.42 + + +def test_from_dict_missing_keys_fall_through_to_defaults(): + """Empty dict → identical to DEFAULT_WEIGHTS.""" + ws = from_dict({}) + assert ws == DEFAULT_WEIGHTS + + +def test_from_dict_partial_override_preserves_other_defaults(): + ws = from_dict({"alpha": 0.5}) + assert ws.alpha == 0.5 + # Every other field is unchanged. + assert ws.beta == DEFAULT_WEIGHTS.beta + assert ws.eta == DEFAULT_WEIGHTS.eta + assert ws.lambda_ == DEFAULT_WEIGHTS.lambda_ + + +def test_from_dict_ignores_unknown_keys(): + """Defensive: extra keys (e.g. "mu" — never defined) are + silently dropped rather than raising. Forward-compat with + config files that include vendor-specific tags.""" + ws = from_dict({"alpha": 0.6, "mu": 1.234, "extra_metadata": "ignore"}) + assert ws.alpha == 0.6 + # mu / extra_metadata silently dropped. + assert not hasattr(ws, "mu") + + +def test_from_dict_coerces_string_to_float(): + """JSON/YAML may pass numeric weights as strings. from_dict + coerces via float().""" + ws = from_dict({"alpha": "0.7", "lambda": "0.3"}) + assert ws.alpha == 0.7 + assert ws.lambda_ == 0.3 + + +def test_from_dict_int_input_becomes_float(): + ws = from_dict({"alpha": 1}) + assert ws.alpha == 1.0 + assert isinstance(ws.alpha, float) + + +# --- frozen invariant ---------------------------------------------- + + +def test_weight_set_is_frozen(): + """WeightSet is dataclass(frozen=True) — mutation raises + FrozenInstanceError. Pinned so a future @dataclass change + forces this test to be updated explicitly.""" + with pytest.raises(dataclasses.FrozenInstanceError): + DEFAULT_WEIGHTS.alpha = 99.0 + + +def test_weight_sets_equal_by_value(): + """Two WeightSet instances with identical fields compare equal — + standard dataclass equality. Used by tests + audit-trail + serialization.""" + a = WeightSet(alpha=0.5, beta=0.6) + b = WeightSet(alpha=0.5, beta=0.6) + assert a == b + # Different values → not equal. + c = WeightSet(alpha=0.5, beta=0.7) + assert a != c