chore: checkpoint PROTEAN research planning and architecture

This commit is contained in:
willtanoe committed 2026-09-24 17:08:56 +00:00
commit 2cc6a9859e
22 files changed
+2138

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
"""Unit tests for the server-side prototype aggregation math.
Covers the dependency-free subset of the testing strategy in
``docs/research/IMPLEMENTATION_PLAN.md`` (items 1-7). Items requiring the
future data/federated modules (partition determinism, manifest reload,
baseline invariance, validation/test separation) are deferred.
"""
from __future__ import annotations
import math
import pytest
from protean_research.aggregation import (
AggregationMode,
aggregate_prototypes,
aggregation_weights,
beta_weights,
)
BETAS = [0.0, 0.25, 0.5, 0.75, 1.0]
def mean(vectors):
dim = len(vectors[0])
return [sum(v[d] for v in vectors) / len(vectors) for d in range(dim)]
def test_beta_zero_equals_arithmetic_mean() -> None:
prototypes = [[0.0, 2.0, -1.0], [2.0, 0.0, 1.0], [4.0, 4.0, 4.0]]
supports = [1.0, 3.0, 10.0]
out = aggregate_prototypes(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=0.0, supports=supports
)
assert out == pytest.approx(mean(prototypes))
def test_beta_one_gives_sample_count_weighting() -> None:
prototypes = [[0.0, 2.0], [2.0, 0.0], [4.0, 4.0]]
supports = [1.0, 3.0, 10.0]
out = aggregate_prototypes(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=1.0, supports=supports
)
total = sum(supports)
expected = [
sum(s * v[d] for s, v in zip(supports, prototypes)) / total
for d in range(2)
]
assert out == pytest.approx(expected)
def test_single_contributor_invariant_across_betas() -> None:
prototypes = [[1.0, -2.0, 3.0]]
supports = [7.0]
for beta in BETAS:
out = aggregate_prototypes(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=beta, supports=supports
)
assert out == pytest.approx(prototypes[0])
def test_equal_supports_invariant_across_betas() -> None:
prototypes = [[0.0, 2.0], [2.0, 0.0], [4.0, 4.0]]
supports = [5.0, 5.0, 5.0]
baseline = aggregate_prototypes(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=0.0, supports=supports
)
for beta in BETAS:
out = aggregate_prototypes(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=beta, supports=supports
)
assert out == pytest.approx(baseline)
def test_missing_class_not_in_s_j() -> None:
# A client with N_ij == 0 must never be passed into S_j.
with pytest.raises(ValueError):
beta_weights([1.0, 0.0], beta=0.5)
with pytest.raises(ValueError):
beta_weights([1.0, -3.0], beta=0.5)
with pytest.raises(ValueError):
aggregate_prototypes(
[[1.0], [2.0]], AggregationMode.SUPPORT_WEIGHTED, beta=0.5, supports=[1.0, 0.0]
)
def test_normalized_weights_sum_to_one() -> None:
prototypes = [[0.0, 2.0], [2.0, 0.0], [4.0, 4.0]]
for beta in BETAS:
w = aggregation_weights(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=beta, supports=[1.0, 3.0, 10.0]
)
assert sum(w) == pytest.approx(1.0)
w_eq = aggregation_weights(prototypes, AggregationMode.EQUAL_AVAILABLE)
assert sum(w_eq) == pytest.approx(1.0)
assert w_eq == pytest.approx([1 / 3, 1 / 3, 1 / 3])
def test_valid_inputs_never_nan_or_inf() -> None:
prototypes = [[0.0, 2.0], [2.0, 0.0], [4.0, 4.0]]
for beta in BETAS:
out = aggregate_prototypes(
prototypes, AggregationMode.SUPPORT_WEIGHTED, beta=beta, supports=[1.0, 3.0, 10.0]
)
assert all(math.isfinite(v) for v in out)
def test_original_protean_is_not_implemented() -> None:
with pytest.raises(NotImplementedError):
aggregate_prototypes([[1.0, 2.0]], AggregationMode.ORIGINAL_PROTEAN)
def test_validation_errors() -> None:
with pytest.raises(ValueError):
beta_weights([1.0], beta=1.5)
with pytest.raises(ValueError):
beta_weights([1.0], beta=None) # type: ignore[arg-type]
with pytest.raises(ValueError):
aggregate_prototypes([[1.0, 2.0], [1.0]], AggregationMode.SUPPORT_WEIGHTED, beta=0.5, supports=[1.0, 2.0])
with pytest.raises(ValueError):
aggregate_prototypes(
[[1.0, 2.0]], AggregationMode.SUPPORT_WEIGHTED, beta=0.5, supports=[1.0, 2.0]
)
with pytest.raises(ValueError):
aggregate_prototypes([[1.0, float("nan")]], AggregationMode.EQUAL_AVAILABLE)