arborist/tests/test_weights.py
russell@unturf.com 3ea27aa471
#000047 — close: delta_aggregator knob on ForkScore (Option D)
The #000025 §10.14 calibration showed _delta_5{s,t,f} mean over a
battery's 5 subs, so a single-sub gain weighs 1/5 of face value (the
5× dilution). #000047 ships the knob to pick the aggregation, default
unchanged.

WeightSet.delta_aggregator ∈ {"mean","max","sum"} (default "mean") —
a categorical field, validated in __post_init__ against
DELTA_AGGREGATORS; from_dict takes it as a string. Default unchanged →
ScoredFork output byte-identical → no fork_score.ESTIMATOR_VERSION
bump.

fork_score._aggregate(deltas, how): mean = arithmetic mean, max =
max(0.0, max_i Δ_i), sum = Σ Δ_i; empty → 0.0. _delta_5s/_delta_5t/
_delta_5f take an aggregator arg (default "mean"); the 5F efficiency
bonus is added after the aggregated base (aggregator-independent).
fork_score passes weights.delta_aggregator. The per-sub
HARD_REGRESSION_FLOOR flags are computed before aggregation, so a
single-sub regression still forces REJECT under max/sum. The chosen
aggregator is recorded in ScoredFork.weights["delta_aggregator"] (via
WeightSet.as_dict()); fork_score_branches traceability stays via the
opaque weights_id — no schema migration.

bench/scripts/fivef_threshold_calibration.py gained §5 — runs the
#000046 below-ceiling pack (5f/falsification at 0.333) and shows the
verdict / γ·Δ5f under each aggregator; bench/results/5f-threshold-
calibration-2026-05-11.md §5 is the captured record. Default stays
"mean" — the conservative, noise-robust, regression-symmetric choice
matching docs/bench-maxing.md's per-rate floor framing; v8 picks
max/sum per-deployment.

Tests: 8 new in tests/test_fork_score.py + 1 anchor in
tests/test_fivef_threshold_calibration.py; tests/test_weights.py
as_dict field-set test updated to include delta_aggregator;
test_fork_score.py AUTOCOUNT tags (#000012 §286, warrant-substrate-
cookbook.md ×2) bumped 23 → 31.

#000047 closed; #000012 §8 §3 + TICKETS.md row updated.
Full suite: 2330 passed, 28 skipped.
2026-05-11 08:27:38 -04:00

182 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_fields():
d = DEFAULT_WEIGHTS.as_dict()
expected_keys = {
"alpha", "beta", "gamma", "delta", "epsilon", "zeta",
"eta", "theta", "iota", "kappa", "lambda_",
"delta_aggregator", # #000047 — categorical, not a numeric weight
}
assert set(d.keys()) == expected_keys
assert d["delta_aggregator"] == "mean"
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