feat: modernize PSO and add convergence research

Migrate the package and examples to the tensor-native PyTorch implementation, add benchmark evidence, and add the guarded post-training convergence protocol with TensorBoard progress monitoring and hash-verified recovery.

Constraint: Preserve one-shot official-test sealing and auditable research artifacts

Rejected: Commit local .omc runs and downloaded datasets | multi-gigabyte runtime state is machine-local

Confidence: high

Scope-risk: broad

Not-tested: Production CUDA run on pieroot-server
This commit is contained in:
2026-09-07 22:03:25 +09:00
parent 9745bb7ad4
commit 813433000a
120 changed files with 198148 additions and 4745 deletions
@@ -0,0 +1,242 @@
"""Behavioral tests for the offline CIFAR/ResNet convergence adapter."""
from __future__ import annotations
import builtins
import copy
import sys
from pathlib import Path
import numpy as np
import pytest
import torch
import torch.nn as nn
import torch.nn.functional as F
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from test import post_training_resnet_convergence as resnet # noqa: E402
from test.post_training_model_convergence import ( # noqa: E402
CandidateEndpoint,
ObjectiveResult,
ProtocolError,
SelectedResidualCodec,
StudyConfig,
prepare_run,
select_endpoint,
)
class _TinyBlock(nn.Module):
def __init__(self, channels: int = 2) -> None:
super().__init__()
self.conv = nn.Conv2d(channels, channels, kernel_size=1)
self.bn = nn.BatchNorm2d(channels)
def forward(self, value: torch.Tensor) -> torch.Tensor:
return F.relu(self.bn(self.conv(value)))
class _TinyResNet(nn.Module):
"""Small module with the same prefix/layer4/suffix contract as ResNet."""
def __init__(self) -> None:
super().__init__()
self.conv1 = nn.Conv2d(3, 2, kernel_size=3, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(2)
self.relu = nn.ReLU()
self.maxpool = nn.Identity()
self.layer1 = nn.Sequential(_TinyBlock())
self.layer2 = nn.Sequential(_TinyBlock())
self.layer3 = nn.Sequential(_TinyBlock())
self.layer4 = nn.Sequential(_TinyBlock(), _TinyBlock())
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(2, 3)
def forward(self, value: torch.Tensor) -> torch.Tensor:
value = self.maxpool(self.relu(self.bn1(self.conv1(value))))
value = self.layer1(value)
value = self.layer2(value)
value = self.layer3(value)
value = self.layer4(value)
return self.fc(torch.flatten(self.avgpool(value), 1))
def _synthetic_cifar() -> tuple[np.ndarray, np.ndarray, tuple[int, int]]:
"""Make valid-shaped, deterministic pixels without constructing a dataset."""
count = resnet.TRAIN_SAMPLES
labels = np.repeat(np.arange(10, dtype=np.int64), count // 10)
images = np.zeros((count, 32, 32, 3), dtype=np.uint8)
encoded = np.arange(count, dtype=np.uint32).view(np.uint8).reshape(count, 4)
images[:, 0, 0, :] = encoded[:, :3]
rng = np.random.default_rng(20260908)
initial_assignment = np.full(count, "", dtype=object)
for cls in range(10):
members = np.flatnonzero(labels == cls)
members = members[rng.permutation(len(members))]
initial_assignment[members[:3500]] = "bp_train"
initial_assignment[members[3500:4000]] = "refine_search"
initial_assignment[members[4000:5000]] = "selection_val"
first = 0
second = next(
index
for index in range(1, count // 10)
if initial_assignment[index] != initial_assignment[first]
)
images[second] = images[first]
return images, labels, (first, second)
def test_cifar_manifest_is_deterministic_disjoint_and_group_safe() -> None:
images, labels, duplicate_pair = _synthetic_cifar()
first = resnet.build_cifar_manifests(images, labels, split_seed=20260908)
second = resnet.build_cifar_manifests(images, labels, split_seed=20260908)
assert first == second
roles = first["roles"]
role_sets = {role: set(indices) for role, indices in roles.items()}
assert sum(len(indices) for indices in role_sets.values()) == len(labels)
for role, values in role_sets.items():
for other, other_values in role_sets.items():
if role != other:
assert values.isdisjoint(other_values)
assert set().union(*role_sets.values()) == set(range(len(labels)))
owner_roles = [role for role, values in role_sets.items() if duplicate_pair[0] in values]
assert len(owner_roles) == 1
assert duplicate_pair[1] in role_sets[owner_roles[0]]
assert set(first["objective"]).issubset(role_sets["refine_search"])
assert len(first["objective"]) == resnet.OBJECTIVE_SAMPLES
assert len(set(first["objective"])) == resnet.OBJECTIVE_SAMPLES
objective_labels = labels[np.asarray(first["objective"])]
assert np.bincount(objective_labels, minlength=10).tolist() == [103, 103, 103, 103, 102, 102, 102, 102, 102, 102]
assert first["normalization_scope"] == "bp_train_only"
def test_real_resnet_selected_suffix_topology_without_downloads() -> None:
try:
import torchvision # noqa: F401
except Exception as exc: # torchvision is optional on lightweight CI workers.
pytest.skip(f"torchvision unavailable: {exc}")
for architecture, block in (("resnet18", "layer4.1"), ("resnet50", "layer4.2")):
model = resnet.make_cifar_resnet(architecture, seed=501)
assert model.conv1.in_channels == 3
assert model.conv1.out_channels == 64
assert model.conv1.kernel_size == (3, 3)
assert model.conv1.stride == (1, 1)
assert isinstance(model.maxpool, nn.Identity)
names = resnet.selected_parameter_names(model, architecture)
expected = tuple(
name
for name, parameter in model.named_parameters()
if name.startswith(block + ".") and parameter.is_floating_point()
)
assert names == expected
assert names and all(name.startswith(block + ".") for name in names)
assert resnet.head_parameter_names(model) == ("fc.weight", "fc.bias")
def test_cached_suffix_parity_and_residual_zero_nonzero_restoration() -> None:
torch.manual_seed(7)
model = _TinyResNet()
images = torch.randn(5, 3, 8, 8)
labels = torch.tensor([0, 1, 2, 1, 0])
model.eval()
cache = resnet.ResNetCache.build(model, images, labels, block_index=1, batch_size=2)
names = tuple(name for name, _ in model.named_parameters() if name.startswith("layer4.1."))
codec = SelectedResidualCodec(model, names, projection_seed=resnet.PROJECTION_SEED)
zero = codec.zero_residual()
nonzero = torch.full((codec.dimension,), 0.4)
base_state = {name: value.detach().clone() for name, value in model.state_dict().items()}
base_logits = resnet.CachedSuffixEvaluator(model, cache, "cpu").logits()
assert torch.equal(codec.decode(zero), torch.cat([value.reshape(-1) for value in codec.base_values]))
assert torch.count_nonzero(codec.decode_delta(zero)) == 0
assert torch.count_nonzero(codec.decode_delta(nonzero)) > 0
with codec.applied(model, zero):
assert torch.equal(resnet.CachedSuffixEvaluator(model, cache, "cpu").logits(), base_logits)
assert all(torch.equal(value, base_state[name]) for name, value in model.state_dict().items())
model.train()
with pytest.raises(RuntimeError, match="candidate failure"):
with codec.applied(model, nonzero):
selected = dict(model.named_parameters())
assert any(not torch.equal(selected[name], base_state[name]) for name in names)
assert all(torch.equal(selected[name], base_state[name]) for name in selected if name not in names)
assert all(torch.equal(value, base_state[name]) for name, value in model.named_buffers())
raise RuntimeError("candidate failure")
assert model.training
assert all(torch.equal(value, base_state[name]) for name, value in model.state_dict().items())
parity = resnet.cached_residual_parity(model, images, cache, codec, nonzero)
assert parity["passed"] is True
assert parity["samples"] == len(images)
assert parity["max_abs_difference"] <= 1e-6
assert resnet.cached_full_parity(model, images, cache)["passed"] is True
def test_endpoint_selection_ties_are_stable() -> None:
objective = ObjectiveResult(loss=0.25, samples=4)
endpoints = (
CandidateEndpoint(20, 1, torch.ones(64), objective),
CandidateEndpoint(10, 0, torch.zeros(64), objective),
)
assert select_endpoint(endpoints, {10: 0.5, 20: 0.5}).generation == 10
assert select_endpoint(endpoints, {10: 0.8, 20: 0.8}, maximize=True).generation == 10
with pytest.raises(ProtocolError):
select_endpoint(endpoints, {10: float("nan"), 20: 0.5})
def test_ensemble_fit_uses_objective_pool_and_apply_does_not_refit() -> None:
pytest.importorskip("scipy")
rng = np.random.default_rng(19)
objective_probs = rng.uniform(0.01, 1.0, size=(3, 9, 3))
objective_probs /= objective_probs.sum(axis=-1, keepdims=True)
selection_probs = np.roll(objective_probs, shift=1, axis=1).copy()
objective_labels = np.arange(9, dtype=np.int64) % 3
selection_labels = np.roll(objective_labels, 2)
fitted = resnet.run_ensemble_methods(objective_probs, objective_labels, swarm_seeds=(601,))
fitted_snapshot = copy.deepcopy(fitted)
applied = resnet.evaluate_fitted_ensemble(fitted, selection_probs, selection_labels)
assert fitted == fitted_snapshot
uniform = np.full(3, 1 / 3)
expected_uniform = np.einsum("m,mnk->nk", uniform, selection_probs)
expected_nll = float(-np.log(np.clip(expected_uniform[np.arange(9), selection_labels], 1e-300, 1.0)).mean())
assert applied["uniform"]["metrics"]["nll"] == pytest.approx(expected_nll, abs=1e-12)
assert fitted["uniform"]["metrics"]["nll"] == pytest.approx(
float(-np.log(np.clip(np.einsum("m,mnk->nk", uniform, objective_probs)[np.arange(9), objective_labels], 1e-300, 1.0)).mean()),
abs=1e-12,
)
for candidate in applied["ensemble_pso"]:
weights = np.asarray(candidate["weights"], dtype=np.float64)
mixed = np.einsum("m,mnk->nk", weights, selection_probs)
expected = float(-np.log(np.clip(mixed[np.arange(9), selection_labels], 1e-300, 1.0)).mean())
assert candidate["selection_metrics"]["nll"] == pytest.approx(expected, abs=1e-12)
def test_official_test_loader_refuses_pre_freeze_without_importing_dataset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_root = tmp_path / "run"
prepare_run(run_root, StudyConfig())
imported = False
original_import = builtins.__import__
def reject_torchvision(name: str, *args: object, **kwargs: object):
nonlocal imported
if name.startswith("torchvision"):
imported = True
raise AssertionError("official dataset import must be behind the frozen seal")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", reject_torchvision)
with pytest.raises(resnet.TestSealError, match="forbidden before frozen"):
resnet.load_official_test_data(tmp_path / "data", run_root, allow_download=False)
assert imported is False