syntheogenesis / tests /test_aggregate.py
Tengo Gzirishvili
feat: richer DE aggregate — substitution × ESM ΔLL bin (opt-in, gated)
21b1923
Raw
History Blame Contribute Delete
8.95 kB
"""Tests for the cross-user aggregate prior (`dee.core.aggregate`).
Pins the privacy + correctness invariants we actually promise:
* aggregates by substitution TYPE, standardized within each library;
* k-anonymity: a substitution measured by < MIN_USERS distinct users is
dropped and never appears in the output;
* the execution gate refuses before the policy effective date;
* the resulting prior recovers the right sign and folds into the SA pool.
Pure numpy — no DB, no torch.
"""
import datetime as dt
import numpy as np
import pytest
from dee.core import aggregate as agg
from dee.core.aggregate import (
EFFECTIVE_DATE,
AggregationGateError,
GlobalPrior,
apply_global_prior,
build_priors,
parse_sub,
)
from dee.optimizer.search import Mutation
AFTER = EFFECTIVE_DATE # gate passes on/after the date
BEFORE = EFFECTIVE_DATE - dt.timedelta(days=1)
# --------------------------------------------------------------------------- #
def test_parse_sub():
assert parse_sub("W58L") == ("W", "L")
assert parse_sub(" k204r ") == ("K", "R")
assert parse_sub("L10L") is None # synonymous
assert parse_sub("junk") is None
# --------------------------------------------------------------------------- #
# the gate
# --------------------------------------------------------------------------- #
def test_gate_blocks_before_effective_date():
with pytest.raises(AggregationGateError):
build_priors([("u1", [("A2V", 1.0), ("A2V,L3I", 2.0)])], now=BEFORE)
def test_gate_opens_on_effective_date():
# should not raise (may return empty if no signal, but must not be gated)
out = build_priors([("u1", [("A2V", 1.0), ("L3I", 2.0)])], now=AFTER)
assert isinstance(out, GlobalPrior)
def test_enforce_gate_false_for_tests():
out = build_priors([("u1", [("A2V", 1.0), ("L3I", 2.0)])], now=BEFORE, enforce_gate=False)
assert isinstance(out, GlobalPrior)
# --------------------------------------------------------------------------- #
# k-anonymity floor
# --------------------------------------------------------------------------- #
def _lib_for_sub(values):
"""A 2-row library exercising substitutions W>L (good) and D>A (bad)."""
# variant 'W10L' high, 'D20A' low -> standardized gives +/- effects
return [("W10L", values[0]), ("D20A", values[1])]
def test_kanon_drops_substitutions_below_floor():
# W>L and D>A measured by only 2 distinct users -> must be dropped (floor=3)
obls = [
("u1", _lib_for_sub((2.0, 0.0))),
("u2", _lib_for_sub((2.0, 0.0))),
]
out = build_priors(obls, min_users=3, now=AFTER)
assert out.effects == {} # nothing meets the >=3-user floor
# add a third distinct user -> now kept
obls.append(("u3", _lib_for_sub((2.0, 0.0))))
out3 = build_priors(obls, min_users=3, now=AFTER)
assert ("W", "L") in out3.effects and ("D", "A") in out3.effects
assert out3.n_users[("W", "L")] == 3
def test_repeated_user_does_not_inflate_kanon():
# same user across two libraries is still ONE distinct user -> below floor
obls = [
("u1", _lib_for_sub((2.0, 0.0))),
("u1", _lib_for_sub((2.0, 0.0))),
("u2", _lib_for_sub((2.0, 0.0))),
]
out = build_priors(obls, min_users=3, now=AFTER)
assert out.effects == {} # only 2 distinct users
# --------------------------------------------------------------------------- #
# correctness: sign recovery + standardization
# --------------------------------------------------------------------------- #
def test_recovers_substitution_sign_across_users():
# W>L consistently beneficial, D>A consistently deleterious, 4 users,
# each on a DIFFERENT arbitrary assay scale (tests within-library z-scoring)
scales = [(1.0, 0.0), (100.0, 50.0), (5.0, -5.0), (0.2, 0.0)] # (gain, offset)
obls = []
for i, (gain, off) in enumerate(scales):
meas = [
("W10L", off + gain * 1.0), # good
("W30L", off + gain * 0.8), # good (same sub type, other position)
("D20A", off + gain * -1.0), # bad
("W10L,D20A", off + gain * 0.0), # mixed
]
obls.append((f"u{i}", meas))
out = build_priors(obls, min_users=3, now=AFTER)
assert out.effects[("W", "L")] > 0 # beneficial recovered
assert out.effects[("D", "A")] < 0 # deleterious recovered
assert out.n_users[("W", "L")] == 4
assert out.n_obs[("W", "L")] >= 4
def test_zero_variance_or_tiny_library_contributes_nothing():
obls = [
("u1", [("W10L", 5.0)]), # single row -> no effect
("u2", [("W10L", 3.0), ("D20A", 3.0)]), # zero variance -> dropped
]
out = build_priors(obls, min_users=1, now=AFTER)
assert out.effects == {}
# --------------------------------------------------------------------------- #
# serialization round-trip + pool blend
# --------------------------------------------------------------------------- #
def test_to_rows_is_deidentified_and_roundtrips():
obls = [("u%d" % i, _lib_for_sub((2.0, 0.0))) for i in range(3)]
out = build_priors(obls, min_users=3, now=AFTER)
rows = out.to_rows()
assert rows and all(set(r) == {"substitution", "n_users", "n_obs", "mean_effect"} for r in rows)
# no user ids, no positions, no raw values anywhere in the serialized form
blob = str(rows)
assert "u0" not in blob and "u1" not in blob and "10" not in "".join(r["substitution"] for r in rows)
back = GlobalPrior.from_rows(rows)
assert set(back.effects) == set(out.effects)
def test_apply_global_prior_nudges_matching_substitutions():
prior = GlobalPrior(effects={("W", "L"): 2.0}, n_users={("W", "L"): 5}, n_obs={("W", "L"): 9})
pool = [
Mutation(position=57, wt_aa="W", mut_aa="L", delta_ll=1.0), # matches -> nudged
Mutation(position=99, wt_aa="D", mut_aa="A", delta_ll=1.0), # no match -> unchanged
]
out = apply_global_prior(pool, prior, weight=0.3)
assert out[0].delta_ll == pytest.approx(1.0 + 0.3 * 2.0)
assert out[1].delta_ll == 1.0
# identity fields preserved
assert (out[0].position, out[0].wt_aa, out[0].mut_aa) == (57, "W", "L")
def test_apply_global_prior_noop_without_prior():
pool = [Mutation(position=0, wt_aa="A", mut_aa="V", delta_ll=1.0)]
assert apply_global_prior(pool, None)[0].delta_ll == 1.0
empty = GlobalPrior(effects={}, n_users={}, n_obs={})
assert apply_global_prior(pool, empty)[0].delta_ll == 1.0
# --------------------------------------------------------------------------- #
# Richer aggregate: substitution × ESM ΔLL bin
# --------------------------------------------------------------------------- #
def test_bin_ll_thresholds():
assert agg.bin_ll(-5) == "lo" and agg.bin_ll(0) == "mid" and agg.bin_ll(5) == "hi"
assert agg.bin_ll(None) == "mid" and agg.bin_ll("x") == "mid"
def test_binned_build_splits_same_substitution_by_ll():
# W>L at a high-ΔLL position is beneficial; at a low-ΔLL position deleterious
units = [(f"u{i}", [("W10L", 2.0), ("W30L", 0.0)]) for i in range(3)]
ll = [{"W10L": 5.0, "W30L": -5.0}] * 3 # hi vs lo bin
p = agg.build_priors(units, now=AFTER, ll_maps=ll)
assert ("W", "L", "hi") in p.effects and ("W", "L", "lo") in p.effects
assert p.effects[("W", "L", "hi")] > 0 and p.effects[("W", "L", "lo")] < 0
assert p.n_users[("W", "L", "hi")] == 3
# plain (unbinned) call is unchanged -> 2-tuple keys
plain = agg.build_priors(units, now=AFTER)
assert ("W", "L") in plain.effects and all(len(k) == 2 for k in plain.effects)
def test_binned_serialize_roundtrip():
p = agg.GlobalPrior(effects={("W", "L", "hi"): 1.5}, n_users={("W", "L", "hi"): 4},
n_obs={("W", "L", "hi"): 9})
rows = p.to_rows()
assert rows[0]["substitution"] == "W>L@hi"
back = agg.GlobalPrior.from_rows(rows)
assert ("W", "L", "hi") in back.effects and back.effects[("W", "L", "hi")] == 1.5
def test_binned_blend_matches_mutation_ll_bin():
from dee.optimizer.search import Mutation
prior = agg.GlobalPrior(
effects={("W", "L", "hi"): 2.0, ("W", "L", "lo"): -2.0},
n_users={("W", "L", "hi"): 5, ("W", "L", "lo"): 5},
n_obs={("W", "L", "hi"): 9, ("W", "L", "lo"): 9})
pool = [
Mutation(position=9, wt_aa="W", mut_aa="L", delta_ll=5.0), # hi bin -> +
Mutation(position=29, wt_aa="W", mut_aa="L", delta_ll=-5.0), # lo bin -> -
Mutation(position=40, wt_aa="W", mut_aa="L", delta_ll=0.0), # mid -> no matching key -> unchanged
]
out = agg.apply_global_prior(pool, prior, weight=0.3)
assert out[0].delta_ll == pytest.approx(5.0 + 0.3 * 2.0)
assert out[1].delta_ll == pytest.approx(-5.0 + 0.3 * -2.0)
assert out[2].delta_ll == 0.0 # mid bin, no fallback key