diff --git a/src/moralmaps/metrics.py b/src/moralmaps/metrics.py index 606c849..c423c6f 100644 --- a/src/moralmaps/metrics.py +++ b/src/moralmaps/metrics.py @@ -57,6 +57,16 @@ def _delta_per_f(pos_clr, neg_clr, keys) -> dict[str, float]: return {f: sum(pos_clr[k][f] - neg_clr[k][f] for k in keys) / n for f in FOUNDATIONS} +def _paired_keys(pos_clr: dict, neg_clr: dict) -> list[str]: + pos_keys, neg_keys = set(pos_clr), set(neg_clr) + assert pos_keys == neg_keys, ( + f"pos/neg row keys differ: pos_only={sorted(pos_keys - neg_keys)}, " + f"neg_only={sorted(neg_keys - pos_keys)}" + ) + assert pos_keys, "pos/neg clr rows are empty" + return list(pos_clr) + + def _on_off(delta: dict[str, float], intent: dict[str, int], off_set, off_weight) -> tuple[float, float, float]: """on = mean_f∈intent intent[f]*Delta_f ; off = mean_f∉intent |Delta_f| ; sel = on - w*off. @@ -86,8 +96,12 @@ def gated_selectivity( clr is pre-softmax nats: sel_gated is a direction+selectivity anchor, NOT a behavioral effect size. Pair it with si_flips for the behavioral claim. """ - keys = [k for k in pos_clr if k in neg_clr] + keys = _paired_keys(pos_clr, neg_clr) off_set = [f for f in FOUNDATIONS if f not in intent] + pmasses = np.asarray([pmass_pos, pmass_neg, pmass_base], dtype=float) + assert np.isfinite(pmasses).all(), f"pmass inputs must be finite, got {pmasses.tolist()}" + assert (0 <= pmasses).all(), f"pmass inputs must be nonnegative, got {pmasses.tolist()}" + assert pmass_base > 0, f"pmass_base must be positive, got {pmass_base}" coh = min(1.0, min(pmass_pos, pmass_neg) / pmass_base) coh2 = coh ** 2 @@ -127,7 +141,7 @@ def si_flips(pos_clr: dict, neg_clr: dict, intent: dict[str, int]) -> dict: Range [-1, 1]. Youden-J-style (a difference of rates); saturates where clr does not, which is exactly why it is the behavioral cross-check to the unbounded clr selectivity. """ - keys = [k for k in pos_clr if k in neg_clr] + keys = _paired_keys(pos_clr, neg_clr) n = len(keys) per_f: dict[str, float] = {} for f, s in intent.items(): diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 6be3059..d41f73a 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -7,6 +7,7 @@ si_flips is a bounded behavioral pick-rate change. -- Claude from __future__ import annotations import numpy as np +import pytest from moralmaps.metrics import FOUNDATIONS, gated_selectivity, si_flips, clr_per_row @@ -73,6 +74,30 @@ def test_coherence_barrier_squared(): assert r2["coherence"] == 1.0 +@pytest.mark.parametrize("invalid_pmass", [float("nan"), float("inf"), -0.1]) +def test_coherence_inputs_must_be_valid(invalid_pmass): + pos, neg = _sweep(auth_p=-2.0, care_p=+2.0, auth_n=+2.0, care_n=-2.0) + with pytest.raises(AssertionError, match="pmass inputs"): + gated_selectivity( + pos, neg, INTENT, + pmass_pos=invalid_pmass, pmass_neg=0.9, pmass_base=0.9, + ) + + +def test_metrics_require_exact_nonempty_row_pairing(): + pos, neg = _sweep(auth_p=-2.0, care_p=+2.0, auth_n=+2.0, care_n=-2.0) + neg.pop(next(iter(neg))) + with pytest.raises(AssertionError, match="row keys differ"): + gated_selectivity(pos, neg, INTENT, pmass_pos=0.9, pmass_neg=0.9, pmass_base=0.9) + with pytest.raises(AssertionError, match="row keys differ"): + si_flips(pos, neg, INTENT) + + with pytest.raises(AssertionError, match="rows are empty"): + gated_selectivity({}, {}, INTENT, pmass_pos=0.9, pmass_neg=0.9, pmass_base=0.9) + with pytest.raises(AssertionError, match="rows are empty"): + si_flips({}, {}, INTENT) + + def test_si_flips_behavioral_bounded(): # pos makes care the argmax pick, neg makes authority the pick. pos, neg = _sweep(auth_p=-2.0, care_p=+3.0, auth_n=+3.0, care_n=-2.0)