mirror of https://github.com/razor-ai/soup.git
947 lines
35 KiB
Python
947 lines
35 KiB
Python
"""v0.71.9 — Knowledge edit + unlearn live wiring.
|
|
|
|
Closes #193 (NPO/SimNPO/RMU loss kernels + UnlearnTrainerWrapper.setup),
|
|
#194 (ROME/MEMIT/AlphaEdit kernels + edit-diff generation), #196 (EditGovernor
|
|
SQLite persistence + cross-process locking), #197 (apply_edit consults the
|
|
governor automatically), #203 (live GRACE codebook lookup/write + Registry
|
|
persistence).
|
|
|
|
Loss / kernel maths are exercised with real torch (available in the [dev]
|
|
extra) on tiny CPU tensors; the full model-load paths (NPO train, ROME edit,
|
|
GRACE edit) are covered by the release-step-6 smoke on SmolLM2-135M.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
torch = pytest.importorskip("torch")
|
|
|
|
|
|
# ===========================================================================
|
|
# #196 — EditGovernor SQLite persistence + cross-process locking
|
|
# ===========================================================================
|
|
|
|
|
|
class TestEditGovernorStore:
|
|
def test_path_validation_null_byte(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernorStore
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError):
|
|
EditGovernorStore("evil\x00.db")
|
|
|
|
def test_path_validation_outside_cwd(self):
|
|
from soup_cli.utils.edit_governor import EditGovernorStore
|
|
|
|
with pytest.raises(ValueError, match="HOME"):
|
|
EditGovernorStore("/etc/evil_governor.db")
|
|
|
|
def test_save_load_roundtrip(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import (
|
|
EditGovernor,
|
|
EditGovernorStore,
|
|
load_governor,
|
|
save_governor,
|
|
)
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
db = str(tmp_path / "gov.db")
|
|
with EditGovernorStore(db) as store:
|
|
gov = EditGovernor(base_model="tiny/model")
|
|
gov.record_edit(method="rome", norm_delta=0.3)
|
|
gov.record_edit(method="rome", norm_delta=0.4)
|
|
save_governor(store, gov)
|
|
# Reopen — a fresh process would see the same DB.
|
|
with EditGovernorStore(db) as store2:
|
|
restored = load_governor(store2, "tiny/model")
|
|
assert restored.edit_count == 2
|
|
assert restored.last_method == "rome"
|
|
assert restored.last_verdict == "OK"
|
|
assert abs(restored.last_norm_delta - 0.4) < 1e-9
|
|
|
|
def test_load_fresh_when_absent(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernorStore, load_governor
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with EditGovernorStore(str(tmp_path / "gov.db")) as store:
|
|
gov = load_governor(store, "unseen/model")
|
|
assert gov.edit_count == 0
|
|
assert gov.base_model == "unseen/model"
|
|
|
|
def test_get_state_none_for_unknown(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernorStore
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with EditGovernorStore(str(tmp_path / "g.db")) as store:
|
|
assert store.get_state("nope") is None
|
|
|
|
def test_save_state_rejects_non_governor(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernorStore
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with EditGovernorStore(str(tmp_path / "g.db")) as store:
|
|
with pytest.raises(TypeError):
|
|
store.save_state("not-a-governor")
|
|
|
|
def test_persisted_blowup_blocks_next(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import (
|
|
EditGovernor,
|
|
EditGovernorStore,
|
|
GovernedEditError,
|
|
load_governor,
|
|
save_governor,
|
|
)
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
db = str(tmp_path / "g.db")
|
|
with EditGovernorStore(db) as store:
|
|
gov = EditGovernor(base_model="m")
|
|
gov.record_edit(method="rome", norm_delta=99.0) # BLOWUP
|
|
save_governor(store, gov)
|
|
with EditGovernorStore(db) as store2:
|
|
restored = load_governor(store2, "m")
|
|
assert restored.last_verdict == "BLOWUP"
|
|
with pytest.raises(GovernedEditError):
|
|
restored.check_can_edit()
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink")
|
|
def test_symlink_db_rejected(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernorStore
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
target = tmp_path / "real.db"
|
|
target.write_text("", encoding="utf-8")
|
|
link = tmp_path / "link.db"
|
|
os.symlink(target, link)
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
EditGovernorStore(str(link))
|
|
|
|
|
|
class TestDefaultGovernorDbPath:
|
|
def test_default_under_home(self, monkeypatch):
|
|
from soup_cli.utils.edit_governor import default_governor_db_path
|
|
|
|
monkeypatch.delenv("SOUP_EDIT_GOVERNOR_DB", raising=False)
|
|
path = default_governor_db_path()
|
|
assert path.endswith("edit_governor.db")
|
|
assert ".soup" in path
|
|
|
|
def test_env_override_in_tmp(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.edit_governor import default_governor_db_path
|
|
|
|
target = str(tmp_path / "custom.db")
|
|
monkeypatch.setenv("SOUP_EDIT_GOVERNOR_DB", target)
|
|
assert default_governor_db_path() == target
|
|
|
|
def test_env_override_out_of_bounds_falls_back(self, monkeypatch):
|
|
from soup_cli.utils.edit_governor import default_governor_db_path
|
|
|
|
monkeypatch.setenv("SOUP_EDIT_GOVERNOR_DB", "/etc/cron.d/x.db")
|
|
path = default_governor_db_path()
|
|
assert path.endswith("edit_governor.db")
|
|
assert ".soup" in path
|
|
|
|
def test_validate_rejects_null_byte(self):
|
|
# POSIX os.putenv forbids null bytes in env values, so a real env var
|
|
# can never carry one. Assert the validator (the actual security
|
|
# control) rejects it directly — the "validated is None -> fall back"
|
|
# branch in default_governor_db_path is covered cross-platform by
|
|
# test_env_override_out_of_bounds_falls_back.
|
|
from soup_cli.utils.edit_governor import _validate_governor_db_override
|
|
|
|
assert _validate_governor_db_override("x\x00.db") is None
|
|
|
|
|
|
# ===========================================================================
|
|
# #197 — apply_edit consults the EditGovernor automatically
|
|
# ===========================================================================
|
|
|
|
|
|
class TestApplyEditGovernor:
|
|
def _patch_kernel(self, monkeypatch, *, norm_delta=0.3):
|
|
import soup_cli.utils.edit_kernels as ek
|
|
import soup_cli.utils.live_eval as live_eval
|
|
|
|
loaded = {"called": 0}
|
|
|
|
def _load(*a, **k):
|
|
loaded["called"] += 1
|
|
return ("M", "T", "cpu")
|
|
|
|
monkeypatch.setattr(live_eval, "load_model_and_tokenizer", _load)
|
|
monkeypatch.setattr(ek, "measure_target_prob", lambda *a, **k: 0.0)
|
|
monkeypatch.setattr(
|
|
ek, "run_edit_kernel",
|
|
lambda *a, **k: ek.EditKernelResult(
|
|
method="rome", layer=5, norm_delta=norm_delta, layers_edited=(5,),
|
|
),
|
|
)
|
|
return loaded
|
|
|
|
def test_governor_records_after_edit(self, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernor
|
|
from soup_cli.utils.knowledge_edit import apply_edit, build_edit_plan
|
|
|
|
self._patch_kernel(monkeypatch, norm_delta=0.42)
|
|
gov = EditGovernor(base_model="b")
|
|
plan = build_edit_plan(base="b", method="rome", subject="s", target="t")
|
|
result = apply_edit(plan, governor=gov)
|
|
assert result.governed is True
|
|
assert gov.edit_count == 1
|
|
assert abs(gov.last_norm_delta - 0.42) < 1e-9
|
|
|
|
def test_governor_refuses_before_load(self, monkeypatch):
|
|
from soup_cli.utils.edit_governor import EditGovernor, GovernedEditError
|
|
from soup_cli.utils.knowledge_edit import apply_edit, build_edit_plan
|
|
|
|
loaded = self._patch_kernel(monkeypatch)
|
|
gov = EditGovernor(base_model="b", max_sequential_edits=1)
|
|
gov.record_edit(method="rome", norm_delta=0.1) # now at cap
|
|
plan = build_edit_plan(base="b", method="rome", subject="s", target="t")
|
|
with pytest.raises(GovernedEditError):
|
|
apply_edit(plan, governor=gov)
|
|
# The refusal must short-circuit BEFORE the model load.
|
|
assert loaded["called"] == 0
|
|
|
|
def test_no_governor_skips(self, monkeypatch):
|
|
from soup_cli.utils.knowledge_edit import apply_edit, build_edit_plan
|
|
|
|
self._patch_kernel(monkeypatch)
|
|
plan = build_edit_plan(base="b", method="rome", subject="s", target="t")
|
|
result = apply_edit(plan)
|
|
assert result.governed is False
|
|
|
|
|
|
# ===========================================================================
|
|
# #193 — unlearn loss kernels
|
|
# ===========================================================================
|
|
|
|
|
|
class TestUnlearnKernels:
|
|
def test_sequence_logprob_masks_prompt(self):
|
|
from soup_cli.utils.unlearn_kernels import sequence_logprob
|
|
|
|
logits = torch.randn(1, 4, 10)
|
|
labels = torch.tensor([[-100, -100, 3, 5]])
|
|
lp = sequence_logprob(logits, labels)
|
|
assert lp.shape == (1,)
|
|
# Only the 2 supervised positions contribute (finite negative).
|
|
assert lp.item() < 0
|
|
|
|
def test_sequence_lengths(self):
|
|
from soup_cli.utils.unlearn_kernels import sequence_lengths
|
|
|
|
labels = torch.tensor([[-100, 1, 2, -100]])
|
|
# shift drops position 0; positions 1,2,3 -> labels 1,2,-100 -> 2 supervised
|
|
assert int(sequence_lengths(labels).item()) == 2
|
|
|
|
def test_npo_loss_finite_and_lower_when_policy_below_ref(self):
|
|
from soup_cli.utils.unlearn_kernels import npo_loss
|
|
|
|
policy = torch.tensor([-10.0])
|
|
ref = torch.tensor([-5.0])
|
|
loss_low = npo_loss(policy, ref, beta=0.1)
|
|
loss_high = npo_loss(torch.tensor([-1.0]), ref, beta=0.1)
|
|
assert torch.isfinite(loss_low)
|
|
# Policy well below ref ⇒ smaller loss (more forgotten).
|
|
assert loss_low.item() < loss_high.item()
|
|
|
|
def test_npo_loss_bool_beta_rejected(self):
|
|
from soup_cli.utils.unlearn_kernels import npo_loss
|
|
|
|
with pytest.raises(TypeError):
|
|
npo_loss(torch.tensor([0.0]), torch.tensor([0.0]), beta=True)
|
|
|
|
def test_npo_loss_nonpositive_beta_rejected(self):
|
|
from soup_cli.utils.unlearn_kernels import npo_loss
|
|
|
|
with pytest.raises(ValueError):
|
|
npo_loss(torch.tensor([0.0]), torch.tensor([0.0]), beta=0.0)
|
|
|
|
def test_simnpo_loss_finite(self):
|
|
from soup_cli.utils.unlearn_kernels import simnpo_loss
|
|
|
|
policy = torch.tensor([-8.0])
|
|
lengths = torch.tensor([4])
|
|
loss = simnpo_loss(policy, lengths, beta=0.1, gamma=0.0)
|
|
assert torch.isfinite(loss)
|
|
|
|
def test_simnpo_gamma_bool_rejected(self):
|
|
from soup_cli.utils.unlearn_kernels import simnpo_loss
|
|
|
|
with pytest.raises(TypeError):
|
|
simnpo_loss(torch.tensor([-1.0]), torch.tensor([2]), gamma=True)
|
|
|
|
def test_rmu_loss_finite(self):
|
|
from soup_cli.utils.unlearn_kernels import rmu_loss
|
|
|
|
fa = torch.zeros(8)
|
|
cv = torch.ones(8)
|
|
ra = torch.zeros(8)
|
|
rf = torch.zeros(8)
|
|
loss = rmu_loss(fa, cv, ra, rf, alpha=1.0)
|
|
# forget term = mean((0-1)^2) = 1.0; retain term = 0.
|
|
assert abs(loss.item() - 1.0) < 1e-5
|
|
|
|
def test_rmu_loss_lower_when_acts_approach_control(self):
|
|
# Review H2 — the RMU forget term must shrink as forget_acts steer
|
|
# toward the control vector (lower loss = better steering).
|
|
from soup_cli.utils.unlearn_kernels import rmu_loss
|
|
|
|
cv = torch.ones(8)
|
|
rf = torch.zeros(8)
|
|
far = rmu_loss(torch.zeros(8), cv, torch.zeros(8), rf, alpha=1.0)
|
|
close = rmu_loss(cv.clone(), cv, torch.zeros(8), rf, alpha=1.0)
|
|
assert close.item() < far.item()
|
|
|
|
def test_rmu_alpha_zero_accepted(self):
|
|
from soup_cli.utils.unlearn_kernels import rmu_loss
|
|
|
|
loss = rmu_loss(torch.zeros(4), torch.ones(4), torch.zeros(4), torch.ones(4), alpha=0.0)
|
|
# alpha=0 drops the retain term entirely → forget term only (mean(1)=1).
|
|
assert abs(loss.item() - 1.0) < 1e-5
|
|
|
|
def test_rmu_alpha_negative_rejected(self):
|
|
from soup_cli.utils.unlearn_kernels import rmu_loss
|
|
|
|
with pytest.raises(ValueError):
|
|
rmu_loss(torch.zeros(2), torch.zeros(2), torch.zeros(2), torch.zeros(2), alpha=-1.0)
|
|
|
|
def test_npo_loss_batch_gt_one(self):
|
|
from soup_cli.utils.unlearn_kernels import npo_loss
|
|
|
|
loss = npo_loss(torch.tensor([-3.0, -8.0]), torch.tensor([-2.0, -2.0]), beta=0.1)
|
|
assert torch.isfinite(loss)
|
|
|
|
def test_simnpo_zero_length_clamped(self):
|
|
from soup_cli.utils.unlearn_kernels import simnpo_loss
|
|
|
|
# lengths=0 must NOT produce inf/nan (kernel clamps to 1).
|
|
loss = simnpo_loss(torch.tensor([-4.0]), torch.tensor([0]), beta=0.1)
|
|
assert torch.isfinite(loss)
|
|
|
|
def test_simnpo_gamma_inf_rejected(self):
|
|
from soup_cli.utils.unlearn_kernels import simnpo_loss
|
|
|
|
with pytest.raises(ValueError):
|
|
simnpo_loss(torch.tensor([-1.0]), torch.tensor([2]), gamma=float("inf"))
|
|
|
|
def test_apply_unlearn_loss_returns_kernel(self):
|
|
from soup_cli.utils import unlearn_kernels
|
|
from soup_cli.utils.unlearning import apply_unlearn_loss
|
|
|
|
assert apply_unlearn_loss("npo") is unlearn_kernels.npo_loss
|
|
assert apply_unlearn_loss("SIMNPO") is unlearn_kernels.simnpo_loss
|
|
assert apply_unlearn_loss("rmu") is unlearn_kernels.rmu_loss
|
|
|
|
def test_apply_unlearn_loss_unknown_rejected(self):
|
|
from soup_cli.utils.unlearning import apply_unlearn_loss
|
|
|
|
with pytest.raises(ValueError):
|
|
apply_unlearn_loss("zzz")
|
|
|
|
|
|
class TestUnlearnRowLoader:
|
|
def test_extract_messages(self):
|
|
from soup_cli.trainer.unlearn import _extract_pair
|
|
|
|
row = {"messages": [
|
|
{"role": "user", "content": "Q"},
|
|
{"role": "assistant", "content": "A"},
|
|
]}
|
|
assert _extract_pair(row) == ("Q", "A")
|
|
|
|
def test_extract_prompt_completion(self):
|
|
from soup_cli.trainer.unlearn import _extract_pair
|
|
|
|
assert _extract_pair({"prompt": "p", "completion": "c"}) == ("p", "c")
|
|
|
|
def test_extract_text(self):
|
|
from soup_cli.trainer.unlearn import _extract_pair
|
|
|
|
assert _extract_pair({"text": "hi"}) == ("", "hi")
|
|
|
|
def test_extract_malformed_returns_none(self):
|
|
from soup_cli.trainer.unlearn import _extract_pair
|
|
|
|
assert _extract_pair("not-a-dict") is None
|
|
assert _extract_pair({"prompt": "p"}) is None # no target
|
|
|
|
def test_load_rows_jsonl(self, tmp_path, monkeypatch):
|
|
from soup_cli.trainer.unlearn import _load_unlearn_rows
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
f = tmp_path / "forget.jsonl"
|
|
f.write_text(
|
|
json.dumps({"prompt": "p1", "completion": "c1"}) + "\n"
|
|
+ "garbage\n"
|
|
+ json.dumps({"text": "t2"}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
rows = _load_unlearn_rows("forget.jsonl")
|
|
assert rows == [("p1", "c1"), ("", "t2")]
|
|
|
|
def test_load_rows_outside_cwd_rejected(self):
|
|
from soup_cli.trainer.unlearn import _load_unlearn_rows
|
|
|
|
with pytest.raises(ValueError, match="cwd"):
|
|
_load_unlearn_rows("/etc/passwd")
|
|
|
|
|
|
class TestUnlearnTrainerWrapper:
|
|
def _cfg(self, method="npo"):
|
|
from soup_cli.config.schema import SoupConfig
|
|
|
|
return SoupConfig(
|
|
base="sshleifer/tiny-gpt2",
|
|
task="unlearn",
|
|
data={"train": "t.jsonl", "forget_set": "f.jsonl"},
|
|
training={"unlearn_method": method},
|
|
)
|
|
|
|
def test_train_before_setup_raises(self):
|
|
from soup_cli.trainer.unlearn import UnlearnTrainerWrapper
|
|
|
|
wrapper = UnlearnTrainerWrapper(self._cfg())
|
|
with pytest.raises(RuntimeError, match="setup"):
|
|
wrapper.train()
|
|
|
|
def test_method_attribute(self):
|
|
from soup_cli.trainer.unlearn import UnlearnTrainerWrapper
|
|
|
|
assert UnlearnTrainerWrapper(self._cfg("simnpo")).method == "simnpo"
|
|
|
|
def test_setup_loads_model(self, monkeypatch):
|
|
from soup_cli.trainer.unlearn import UnlearnTrainerWrapper
|
|
|
|
wrapper = UnlearnTrainerWrapper(self._cfg())
|
|
import soup_cli.utils.live_eval as live_eval
|
|
|
|
def _boom(*a, **k):
|
|
raise RuntimeError("LOAD")
|
|
|
|
monkeypatch.setattr(live_eval, "load_model_and_tokenizer", _boom)
|
|
with pytest.raises(RuntimeError, match="LOAD"):
|
|
wrapper.setup()
|
|
|
|
|
|
# ===========================================================================
|
|
# #194 — ROME / MEMIT / AlphaEdit edit kernels
|
|
# ===========================================================================
|
|
|
|
|
|
def _fake_llama(hidden=8, inter=16, layers=4):
|
|
"""Build a tiny Llama-shaped nn.Module with mlp.down_proj per layer."""
|
|
import torch.nn as nn
|
|
|
|
class _MLP(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.down_proj = nn.Linear(inter, hidden, bias=False)
|
|
|
|
class _Block(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.mlp = _MLP()
|
|
|
|
class _Inner(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.layers = nn.ModuleList([_Block() for _ in range(layers)])
|
|
|
|
class _Model(nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.model = _Inner()
|
|
|
|
return _Model()
|
|
|
|
|
|
class TestEditKernelHelpers:
|
|
def test_locate_layers(self):
|
|
from soup_cli.utils.edit_kernels import _locate_decoder_layers
|
|
|
|
model = _fake_llama()
|
|
layers = _locate_decoder_layers(model)
|
|
assert len(layers) == 4
|
|
|
|
def test_locate_layers_missing_raises(self):
|
|
import torch.nn as nn
|
|
|
|
from soup_cli.utils.edit_kernels import _locate_decoder_layers
|
|
|
|
with pytest.raises(ValueError, match="decoder layers"):
|
|
_locate_decoder_layers(nn.Linear(2, 2))
|
|
|
|
def test_down_proj(self):
|
|
from soup_cli.utils.edit_kernels import _down_proj, _locate_decoder_layers
|
|
|
|
model = _fake_llama()
|
|
down = _down_proj(_locate_decoder_layers(model), 2)
|
|
assert down.weight.shape == (8, 16)
|
|
|
|
def test_down_proj_out_of_range(self):
|
|
from soup_cli.utils.edit_kernels import _down_proj, _locate_decoder_layers
|
|
|
|
layers = _locate_decoder_layers(_fake_llama())
|
|
with pytest.raises(ValueError, match="out of range"):
|
|
_down_proj(layers, 99)
|
|
|
|
def test_rank1_update_makes_key_map_to_value(self):
|
|
from soup_cli.utils.edit_kernels import _down_proj, _locate_decoder_layers, _rank1_update
|
|
|
|
model = _fake_llama()
|
|
down = _down_proj(_locate_decoder_layers(model), 0)
|
|
key = torch.ones(16)
|
|
delta = torch.full((8,), 0.5)
|
|
before = down.weight @ key
|
|
norm = _rank1_update(down, key, delta)
|
|
after = down.weight @ key
|
|
assert norm > 0
|
|
assert torch.allclose(after - before, delta, atol=1e-4)
|
|
|
|
def test_rank1_update_zero_key_rejected(self):
|
|
from soup_cli.utils.edit_kernels import _down_proj, _locate_decoder_layers, _rank1_update
|
|
|
|
down = _down_proj(_locate_decoder_layers(_fake_llama()), 0)
|
|
with pytest.raises(ValueError, match="zero norm"):
|
|
_rank1_update(down, torch.zeros(16), torch.ones(8))
|
|
|
|
def test_run_edit_kernel_unknown_method(self):
|
|
from soup_cli.utils.edit_kernels import run_edit_kernel
|
|
|
|
with pytest.raises(ValueError, match="does not handle"):
|
|
run_edit_kernel(
|
|
_fake_llama(), None, method="zzz", subject="s", target="t",
|
|
layer=0, device="cpu",
|
|
)
|
|
|
|
def test_edit_kernel_result_frozen(self):
|
|
from soup_cli.utils.edit_kernels import EditKernelResult
|
|
|
|
r = EditKernelResult(method="rome", layer=1, norm_delta=0.1, layers_edited=(1,))
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
r.method = "memit" # type: ignore
|
|
|
|
|
|
class TestEditResult:
|
|
def test_frozen(self):
|
|
from soup_cli.utils.knowledge_edit import EditResult
|
|
|
|
r = EditResult(
|
|
method="rome", layer=5, norm_delta=0.1, layers_edited=(5,),
|
|
output_dir=None, target_prob_before=0.0, target_prob_after=0.5,
|
|
governed=False,
|
|
)
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
r.method = "x" # type: ignore
|
|
|
|
def test_apply_edit_saves_when_output_dir(self, tmp_path, monkeypatch):
|
|
import soup_cli.utils.edit_kernels as ek
|
|
import soup_cli.utils.live_eval as live_eval
|
|
from soup_cli.utils.knowledge_edit import apply_edit, build_edit_plan
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
saved = {}
|
|
|
|
class _Model:
|
|
def save_pretrained(self, d):
|
|
saved["model"] = d
|
|
|
|
class _Tok:
|
|
def save_pretrained(self, d):
|
|
saved["tok"] = d
|
|
|
|
monkeypatch.setattr(
|
|
live_eval, "load_model_and_tokenizer",
|
|
lambda *a, **k: (_Model(), _Tok(), "cpu"),
|
|
)
|
|
monkeypatch.setattr(ek, "measure_target_prob", lambda *a, **k: 0.0)
|
|
monkeypatch.setattr(
|
|
ek, "run_edit_kernel",
|
|
lambda *a, **k: ek.EditKernelResult(
|
|
method="rome", layer=5, norm_delta=0.2, layers_edited=(5,),
|
|
),
|
|
)
|
|
plan = build_edit_plan(base="b", method="rome", subject="s", target="t")
|
|
result = apply_edit(plan, output_dir="edited")
|
|
assert result.output_dir == "edited"
|
|
assert saved["model"] == "edited"
|
|
|
|
def test_save_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
import soup_cli.utils.edit_kernels as ek
|
|
import soup_cli.utils.live_eval as live_eval
|
|
from soup_cli.utils.knowledge_edit import apply_edit, build_edit_plan
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(
|
|
live_eval, "load_model_and_tokenizer",
|
|
lambda *a, **k: (object(), object(), "cpu"),
|
|
)
|
|
monkeypatch.setattr(ek, "measure_target_prob", lambda *a, **k: 0.0)
|
|
monkeypatch.setattr(
|
|
ek, "run_edit_kernel",
|
|
lambda *a, **k: ek.EditKernelResult(
|
|
method="rome", layer=5, norm_delta=0.2, layers_edited=(5,),
|
|
),
|
|
)
|
|
plan = build_edit_plan(base="b", method="rome", subject="s", target="t")
|
|
with pytest.raises(ValueError, match="cwd"):
|
|
apply_edit(plan, output_dir="/etc/evil")
|
|
|
|
|
|
class TestEditDiffLive:
|
|
def test_live_generation_detects_change(self, monkeypatch, tmp_path):
|
|
import soup_cli.utils.live_eval as live_eval
|
|
from soup_cli.utils.edit_diff import build_diff_report
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
probes = tmp_path / "p.jsonl"
|
|
probes.write_text(
|
|
json.dumps({"prompt": "The capital of France is"}) + "\n"
|
|
+ json.dumps({"prompt": "2 + 2 ="}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _fake_make_gen(model_id, **kwargs):
|
|
if model_id == "before":
|
|
return lambda p: "Paris" if "France" in p else "4"
|
|
return lambda p: "Lyon" if "France" in p else "4"
|
|
|
|
monkeypatch.setattr(live_eval, "make_generator", _fake_make_gen)
|
|
report = build_diff_report(
|
|
before_run_id="r1",
|
|
after_run_id="r2",
|
|
probe_file="p.jsonl",
|
|
before_model="before",
|
|
after_model="after",
|
|
)
|
|
# Changed row (France) first.
|
|
assert report.changes[0].changed is True
|
|
assert report.changes[0].before == "Paris"
|
|
assert report.changes[0].after == "Lyon"
|
|
n_changed = sum(1 for c in report.changes if c.changed)
|
|
assert n_changed == 1
|
|
|
|
def test_placeholder_without_models(self, monkeypatch, tmp_path):
|
|
from soup_cli.utils.edit_diff import build_diff_report
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
probes = tmp_path / "p.jsonl"
|
|
probes.write_text(json.dumps({"prompt": "x"}) + "\n", encoding="utf-8")
|
|
report = build_diff_report(
|
|
before_run_id="r1", after_run_id="r2", probe_file="p.jsonl",
|
|
)
|
|
assert report.changes[0].changed is False
|
|
assert "supply" in report.changes[0].before.lower()
|
|
|
|
|
|
# ===========================================================================
|
|
# #203 — GRACE codebook live (lookup / write + Registry persistence)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGraceCodebook:
|
|
def _cfg(self, dim=4, size=10):
|
|
from soup_cli.utils.grace_codebook import GraceCodebookConfig
|
|
|
|
return GraceCodebookConfig(size=size, dim=dim)
|
|
|
|
def test_add_and_lookup_hit(self):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook
|
|
|
|
cb = GraceCodebook(self._cfg(), epsilon=0.5)
|
|
cb.add([1.0, 0.0, 0.0, 0.0], [9.0, 9.0, 9.0, 9.0], "fact")
|
|
assert len(cb) == 1
|
|
hit = cb.lookup([1.0, 0.0, 0.0, 0.0])
|
|
assert hit == [9.0, 9.0, 9.0, 9.0]
|
|
|
|
def test_lookup_miss_outside_epsilon(self):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook
|
|
|
|
cb = GraceCodebook(self._cfg(), epsilon=0.1)
|
|
cb.add([1.0, 0.0, 0.0, 0.0], [9.0, 9.0, 9.0, 9.0], "fact")
|
|
assert cb.lookup([5.0, 5.0, 5.0, 5.0]) is None
|
|
|
|
def test_lookup_empty_returns_none(self):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook
|
|
|
|
cb = GraceCodebook(self._cfg())
|
|
assert cb.lookup([0.0, 0.0, 0.0, 0.0]) is None
|
|
|
|
def test_add_dim_mismatch(self):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook
|
|
|
|
cb = GraceCodebook(self._cfg())
|
|
with pytest.raises(ValueError, match="dim"):
|
|
cb.add([1.0, 2.0], [1.0, 2.0, 3.0, 4.0], "x")
|
|
|
|
def test_add_full_rejected(self):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook
|
|
|
|
cb = GraceCodebook(self._cfg(size=1))
|
|
cb.add([1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], "a")
|
|
with pytest.raises(ValueError, match="full"):
|
|
cb.add([0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], "b")
|
|
|
|
def test_epsilon_validation(self):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook
|
|
|
|
with pytest.raises(ValueError):
|
|
GraceCodebook(self._cfg(), epsilon=0.0)
|
|
with pytest.raises(TypeError):
|
|
GraceCodebook(self._cfg(), epsilon=True)
|
|
|
|
def test_save_load_roundtrip(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.grace_codebook import (
|
|
GraceCodebook,
|
|
load_codebook,
|
|
save_codebook,
|
|
)
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
cb = GraceCodebook(self._cfg(), epsilon=0.7, layer=3, base_model="m")
|
|
cb.add([1.0, 0.0, 0.0, 0.0], [2.0, 2.0, 2.0, 2.0], "fact")
|
|
out = tmp_path / "ckpt"
|
|
out.mkdir()
|
|
save_codebook(cb, "ckpt")
|
|
restored = load_codebook("ckpt")
|
|
assert len(restored) == 1
|
|
assert restored.layer == 3
|
|
assert restored.base_model == "m"
|
|
assert restored.lookup([1.0, 0.0, 0.0, 0.0]) == [2.0, 2.0, 2.0, 2.0]
|
|
|
|
def test_save_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.grace_codebook import GraceCodebook, save_codebook
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
cb = GraceCodebook(self._cfg())
|
|
with pytest.raises(ValueError, match="cwd"):
|
|
save_codebook(cb, "/etc/evil")
|
|
|
|
def test_load_missing_raises(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.grace_codebook import load_codebook
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "empty").mkdir()
|
|
with pytest.raises(FileNotFoundError):
|
|
load_codebook("empty")
|
|
|
|
def test_apply_grace_codebook_returns_empty(self):
|
|
from soup_cli.utils.grace_codebook import (
|
|
GraceCodebook,
|
|
apply_grace_codebook,
|
|
build_grace_codebook_config,
|
|
)
|
|
|
|
cb = apply_grace_codebook(build_grace_codebook_config(size=10, dim=4))
|
|
assert isinstance(cb, GraceCodebook)
|
|
assert len(cb) == 0
|
|
|
|
|
|
class TestRegistryKinds:
|
|
def test_edit_kinds_registered(self):
|
|
from soup_cli.registry.store import _VALID_KINDS
|
|
|
|
assert "edited_model" in _VALID_KINDS
|
|
assert "grace_codebook" in _VALID_KINDS
|
|
|
|
|
|
# ===========================================================================
|
|
# CLI plumbing
|
|
# ===========================================================================
|
|
|
|
|
|
class TestCli:
|
|
def test_edit_set_help_has_new_flags(self):
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["edit", "set", "--help"])
|
|
assert result.exit_code == 0, result.output
|
|
out = result.output
|
|
# Rich may wrap; strip ANSI + check tokens.
|
|
import re
|
|
|
|
clean = re.sub(r"\x1b\[[0-9;]*m", "", out)
|
|
assert "--output" in clean
|
|
assert "--governor" in clean
|
|
|
|
def test_edit_diff_help_has_model_flags(self):
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["edit", "diff", "--help"])
|
|
assert result.exit_code == 0, result.output
|
|
import re
|
|
|
|
clean = re.sub(r"\x1b\[[0-9;]*m", "", result.output)
|
|
assert "before-model" in clean
|
|
assert "after-model" in clean
|
|
|
|
|
|
# ===========================================================================
|
|
# Review follow-ups
|
|
# ===========================================================================
|
|
|
|
|
|
class TestReviewFollowups:
|
|
def test_validated_output_dir_outside_cwd(self, tmp_path, monkeypatch):
|
|
from soup_cli.trainer.unlearn import _validated_output_dir
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="cwd"):
|
|
_validated_output_dir("/etc/evil")
|
|
|
|
def test_validated_output_dir_null_byte(self, tmp_path, monkeypatch):
|
|
from soup_cli.trainer.unlearn import _validated_output_dir
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="null"):
|
|
_validated_output_dir("x\x00y")
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink")
|
|
def test_validated_output_dir_symlink(self, tmp_path, monkeypatch):
|
|
from soup_cli.trainer.unlearn import _validated_output_dir
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "real").mkdir()
|
|
os.symlink(tmp_path / "real", tmp_path / "link")
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
_validated_output_dir("link")
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink")
|
|
def test_load_unlearn_rows_symlink_rejected(self, tmp_path, monkeypatch):
|
|
from soup_cli.trainer.unlearn import _load_unlearn_rows
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
target = tmp_path / "real.jsonl"
|
|
target.write_text(json.dumps({"text": "x"}) + "\n", encoding="utf-8")
|
|
os.symlink(target, tmp_path / "link.jsonl")
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
_load_unlearn_rows("link.jsonl")
|
|
|
|
def test_load_unlearn_rows_null_byte(self, tmp_path, monkeypatch):
|
|
from soup_cli.trainer.unlearn import _load_unlearn_rows
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="null"):
|
|
_load_unlearn_rows("a\x00b.jsonl")
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink")
|
|
def test_save_edited_model_symlink_rejected(self, tmp_path, monkeypatch):
|
|
import soup_cli.utils.edit_kernels as ek
|
|
import soup_cli.utils.live_eval as live_eval
|
|
from soup_cli.utils.knowledge_edit import apply_edit, build_edit_plan
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "real").mkdir()
|
|
os.symlink(tmp_path / "real", tmp_path / "out")
|
|
monkeypatch.setattr(
|
|
live_eval, "load_model_and_tokenizer",
|
|
lambda *a, **k: (object(), object(), "cpu"),
|
|
)
|
|
monkeypatch.setattr(ek, "measure_target_prob", lambda *a, **k: 0.0)
|
|
monkeypatch.setattr(
|
|
ek, "run_edit_kernel",
|
|
lambda *a, **k: ek.EditKernelResult(
|
|
method="rome", layer=5, norm_delta=0.1, layers_edited=(5,),
|
|
),
|
|
)
|
|
plan = build_edit_plan(base="b", method="rome", subject="s", target="t")
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
apply_edit(plan, output_dir="out")
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink")
|
|
def test_load_codebook_symlink_rejected(self, tmp_path, monkeypatch):
|
|
from soup_cli.utils.grace_codebook import load_codebook
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "ckpt").mkdir()
|
|
real = tmp_path / "real.json"
|
|
real.write_text("{}", encoding="utf-8")
|
|
os.symlink(real, tmp_path / "ckpt" / "grace_codebook.json")
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
load_codebook("ckpt")
|
|
|
|
def test_alphaedit_projection_deterministic(self):
|
|
import soup_cli.utils.edit_kernels as ek
|
|
|
|
model = _fake_llama()
|
|
down = ek._down_proj(ek._locate_decoder_layers(model), 0)
|
|
upd = torch.full((8, 16), 0.3)
|
|
p1 = ek._alphaedit_project(down, upd)
|
|
p2 = ek._alphaedit_project(down, upd)
|
|
assert torch.allclose(p1, p2)
|
|
|
|
def test_classify_norm_blowup_boundaries(self):
|
|
from soup_cli.utils.edit_governor import NormBlowupPolicy, classify_norm_blowup
|
|
|
|
pol = NormBlowupPolicy(warn_threshold=1.0, blowup_threshold=5.0)
|
|
assert classify_norm_blowup(0.99, pol) == "OK"
|
|
assert classify_norm_blowup(1.0, pol) == "WARN" # >= warn
|
|
assert classify_norm_blowup(4.99, pol) == "WARN"
|
|
assert classify_norm_blowup(5.0, pol) == "BLOWUP" # >= blowup
|
|
|
|
def test_apply_grace_edit_governed_refusal(self, monkeypatch):
|
|
import soup_cli.utils.live_eval as live_eval
|
|
from soup_cli.utils.edit_governor import EditGovernor, GovernedEditError
|
|
from soup_cli.utils.grace_codebook import apply_grace_edit
|
|
from soup_cli.utils.knowledge_edit import build_edit_plan
|
|
|
|
loaded = {"n": 0}
|
|
|
|
def _load(*a, **k):
|
|
loaded["n"] += 1
|
|
return ("M", "T", "cpu")
|
|
|
|
monkeypatch.setattr(live_eval, "load_model_and_tokenizer", _load)
|
|
gov = EditGovernor(base_model="b", max_sequential_edits=1)
|
|
gov.record_edit(method="grace", norm_delta=0.1)
|
|
plan = build_edit_plan(base="b", method="grace", subject="s", target="t")
|
|
with pytest.raises(GovernedEditError):
|
|
apply_grace_edit(plan, governor=gov)
|
|
assert loaded["n"] == 0 # refused before any model load
|
|
|
|
|
|
# ===========================================================================
|
|
# Patch invariants
|
|
# ===========================================================================
|
|
|
|
|
|
class TestPatchInvariants:
|
|
def test_version_bumped(self):
|
|
import soup_cli
|
|
|
|
parts = soup_cli.__version__.split(".")
|
|
assert (int(parts[0]), int(parts[1]), int(parts[2])) >= (0, 71, 9)
|
|
|
|
@pytest.mark.parametrize(
|
|
"module",
|
|
[
|
|
"soup_cli.utils.edit_kernels",
|
|
"soup_cli.utils.unlearn_kernels",
|
|
"soup_cli.utils.edit_governor",
|
|
"soup_cli.utils.grace_codebook",
|
|
],
|
|
)
|
|
def test_no_top_level_torch(self, module):
|
|
import importlib
|
|
|
|
mod = importlib.import_module(module)
|
|
with open(mod.__file__, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
for line in src.splitlines():
|
|
assert not line.startswith("import torch"), module
|
|
assert not line.startswith("from torch"), module
|