mirror of https://github.com/razor-ai/soup.git
352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""Part A wave 1 — v0.26.1 follow-ups (#32, #35) for v0.33.0.
|
|
|
|
Covers:
|
|
- #32 Live model scoring: judge / benchmark task dispatch in run_gate,
|
|
score=None + error propagation, _parse_judge_url helper, generator
|
|
factory shape.
|
|
- #35 Registry attach: --attach-to-registry on `soup eval custom`,
|
|
--registry-id auto-attach in `soup export`, registry artifact kind
|
|
extensions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #32 — gate: judge URL parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseJudgeURL:
|
|
def test_ollama_scheme(self):
|
|
from soup_cli.eval.gate import _parse_judge_url
|
|
|
|
provider, model, base = _parse_judge_url("ollama://llama3.1")
|
|
assert provider == "ollama"
|
|
assert model == "llama3.1"
|
|
assert base is None
|
|
|
|
def test_https_openai(self):
|
|
from soup_cli.eval.gate import _parse_judge_url
|
|
|
|
provider, model, base = _parse_judge_url(
|
|
"https://api.openai.com/gpt-4o-mini"
|
|
)
|
|
assert provider == "openai"
|
|
assert model == "gpt-4o-mini"
|
|
assert base == "https://api.openai.com"
|
|
|
|
def test_http_localhost_server(self):
|
|
from soup_cli.eval.gate import _parse_judge_url
|
|
|
|
provider, model, base = _parse_judge_url(
|
|
"http://localhost:8000/Qwen2.5"
|
|
)
|
|
assert provider == "server"
|
|
assert model == "Qwen2.5"
|
|
assert base == "http://localhost:8000"
|
|
|
|
def test_rejects_unsupported_scheme(self):
|
|
from soup_cli.eval.gate import _parse_judge_url
|
|
|
|
with pytest.raises(ValueError, match="unsupported scheme"):
|
|
_parse_judge_url("ftp://example.com/model")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #32 — run_gate: error → score=None propagation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunGateErrorPropagation:
|
|
def test_judge_task_failure_surfaces_score_none(self, tmp_path, monkeypatch):
|
|
"""Exception from judge backend must produce score=None, error=str(exc),
|
|
passed=False — never a silent score=1.0."""
|
|
from soup_cli.eval.gate import EvalSuite, GateTask, run_gate
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
prompts = tmp_path / "prompts.jsonl"
|
|
prompts.write_text(
|
|
json.dumps({"prompt": "Hi"}) + "\n", encoding="utf-8",
|
|
)
|
|
|
|
suite = EvalSuite(suite="t", tasks=[GateTask(
|
|
type="judge", name="quality", threshold=0.5,
|
|
prompts="prompts.jsonl",
|
|
judge_model="ollama://llama3.1",
|
|
)])
|
|
|
|
# Inject a JudgeEvaluator that explodes on construction.
|
|
with patch("soup_cli.eval.judge.JudgeEvaluator") as mock_judge:
|
|
mock_judge.side_effect = OSError("connection refused")
|
|
result = run_gate(
|
|
suite, generate_fn=lambda _p: "stub",
|
|
regression_threshold=0.05,
|
|
)
|
|
|
|
assert len(result.task_results) == 1
|
|
row = result.task_results[0]
|
|
assert row.score is None
|
|
assert row.error and "connection refused" in row.error
|
|
assert row.passed is False
|
|
assert result.passed is False
|
|
|
|
def test_custom_task_unknown_file_error(self, tmp_path, monkeypatch):
|
|
from soup_cli.eval.gate import EvalSuite, GateTask, run_gate
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
suite = EvalSuite(suite="t", tasks=[GateTask(
|
|
type="custom", name="cust", threshold=0.5,
|
|
tasks="missing.jsonl", scorer="exact",
|
|
)])
|
|
|
|
result = run_gate(suite, generate_fn=lambda _p: "out")
|
|
row = result.task_results[0]
|
|
assert row.score is None
|
|
assert row.error
|
|
assert row.passed is False
|
|
|
|
def test_benchmark_task_unavailable(self, tmp_path, monkeypatch):
|
|
from soup_cli.eval import forgetting
|
|
from soup_cli.eval.gate import EvalSuite, GateTask, run_gate
|
|
|
|
# Strip the runner attr to force the RuntimeError branch
|
|
monkeypatch.setattr(
|
|
forgetting, "run_mini_benchmark", None, raising=False,
|
|
)
|
|
# Ensure attribute lookup returns None
|
|
if hasattr(forgetting, "run_mini_benchmark"):
|
|
monkeypatch.delattr(
|
|
forgetting, "run_mini_benchmark", raising=False,
|
|
)
|
|
|
|
suite = EvalSuite(suite="t", tasks=[GateTask(
|
|
type="benchmark", name="bench", threshold=0.3,
|
|
benchmark="mini_mmlu",
|
|
)])
|
|
result = run_gate(suite, generate_fn=lambda _p: "")
|
|
row = result.task_results[0]
|
|
assert row.score is None
|
|
assert row.error and "unavailable" in row.error
|
|
assert row.passed is False
|
|
|
|
|
|
class TestGateTaskResultSchema:
|
|
def test_error_field_default_none(self):
|
|
from soup_cli.eval.gate import GateTaskResult
|
|
|
|
row = GateTaskResult(
|
|
name="x", score=0.7, threshold=0.5,
|
|
baseline=None, delta=None, passed=True,
|
|
)
|
|
assert row.error is None
|
|
|
|
def test_score_optional(self):
|
|
from soup_cli.eval.gate import GateTaskResult
|
|
|
|
row = GateTaskResult(
|
|
name="x", score=None, threshold=0.5,
|
|
baseline=None, delta=None, passed=False,
|
|
error="boom",
|
|
)
|
|
assert row.score is None
|
|
assert row.error == "boom"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #32 — quant_check.make_model_generator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMakeModelGenerator:
|
|
def test_max_new_tokens_bounds(self):
|
|
from soup_cli.eval.quant_check import make_model_generator
|
|
|
|
with pytest.raises(ValueError, match="max_new_tokens"):
|
|
make_model_generator("/tmp/x", max_new_tokens=0)
|
|
with pytest.raises(ValueError, match="max_new_tokens"):
|
|
make_model_generator("/tmp/x", max_new_tokens=99_999)
|
|
|
|
def test_returns_callable_with_mocked_transformers(self):
|
|
from soup_cli.eval import quant_check
|
|
|
|
fake_tokenizer = MagicMock()
|
|
fake_tokenizer.eos_token_id = 0
|
|
fake_inputs = {"input_ids": MagicMock()}
|
|
fake_inputs["input_ids"].shape = (1, 3)
|
|
fake_tokenizer.return_value = fake_inputs
|
|
fake_tokenizer.decode.return_value = "out"
|
|
|
|
fake_model = MagicMock()
|
|
fake_model.generate.return_value = [[1, 2, 3, 4, 5, 6]]
|
|
|
|
with patch.dict("sys.modules", {"transformers": MagicMock(
|
|
AutoTokenizer=MagicMock(from_pretrained=MagicMock(
|
|
return_value=fake_tokenizer,
|
|
)),
|
|
AutoModelForCausalLM=MagicMock(from_pretrained=MagicMock(
|
|
return_value=fake_model,
|
|
)),
|
|
)}):
|
|
gen = quant_check.make_model_generator(
|
|
"/fake/model", max_new_tokens=8,
|
|
)
|
|
out = gen("hello")
|
|
assert out == "out"
|
|
|
|
def test_empty_prompt_returns_empty(self):
|
|
from soup_cli.eval import quant_check
|
|
|
|
fake_tok = MagicMock()
|
|
fake_tok.eos_token_id = 0
|
|
fake_model = MagicMock()
|
|
with patch.dict("sys.modules", {"transformers": MagicMock(
|
|
AutoTokenizer=MagicMock(from_pretrained=MagicMock(return_value=fake_tok)),
|
|
AutoModelForCausalLM=MagicMock(from_pretrained=MagicMock(return_value=fake_model)),
|
|
)}):
|
|
gen = quant_check.make_model_generator("/fake/model")
|
|
assert gen("") == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #35 — registry attach helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryAttachHelpers:
|
|
def test_write_eval_json_containment(self, tmp_path, monkeypatch):
|
|
from soup_cli.registry.attach import write_eval_json
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
out = write_eval_json(
|
|
"results.json", payload={"score": 0.7},
|
|
)
|
|
assert out.exists()
|
|
data = json.loads(out.read_text(encoding="utf-8"))
|
|
assert data["score"] == 0.7
|
|
|
|
def test_write_eval_json_rejects_outside_cwd(self, tmp_path, monkeypatch):
|
|
from soup_cli.registry.attach import write_eval_json
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
outside = str(tmp_path.parent / "evil.json")
|
|
with pytest.raises(ValueError, match="outside cwd"):
|
|
write_eval_json(outside, payload={})
|
|
|
|
def test_attach_artifact_unknown_entry(self, tmp_path, monkeypatch):
|
|
from soup_cli.registry.attach import attach_artifact
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
# Use an isolated registry DB
|
|
db = tmp_path / "reg.db"
|
|
monkeypatch.setenv("SOUP_REGISTRY_DB_PATH", str(db))
|
|
|
|
target = tmp_path / "results.json"
|
|
target.write_text("{}", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="not found"):
|
|
attach_artifact("nonexistent-id", path=str(target), kind="eval_results")
|
|
|
|
def test_attach_artifact_missing_file(self, tmp_path, monkeypatch):
|
|
from soup_cli.registry.attach import attach_artifact
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(FileNotFoundError):
|
|
attach_artifact(
|
|
"any", path=str(tmp_path / "missing.json"), kind="eval_results",
|
|
)
|
|
|
|
def test_attach_artifact_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
|
"""enforce_cwd=True (default) must reject paths outside cwd."""
|
|
from soup_cli.registry.attach import attach_artifact
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
# Isolated DB so the entry-not-found path doesn't shadow the
|
|
# containment error.
|
|
db = tmp_path / "reg.db"
|
|
monkeypatch.setenv("SOUP_REGISTRY_DB_PATH", str(db))
|
|
|
|
# Place an artifact OUTSIDE cwd.
|
|
outside_dir = tmp_path.parent / "outside_for_attach_test"
|
|
outside_dir.mkdir(exist_ok=True)
|
|
outside_file = outside_dir / "results.json"
|
|
outside_file.write_text("{}", encoding="utf-8")
|
|
|
|
# Even with a non-existent entry, the containment check happens
|
|
# inside add_artifact and should surface; either error is acceptable
|
|
# as long as the artifact never gets registered.
|
|
with pytest.raises((ValueError, FileNotFoundError)):
|
|
attach_artifact(
|
|
"any", path=str(outside_file), kind="eval_results",
|
|
)
|
|
|
|
|
|
class TestRegistryArtifactKindsExtended:
|
|
def test_eval_results_kind_accepted(self):
|
|
from soup_cli.registry.store import _VALID_KINDS
|
|
|
|
assert "eval_results" in _VALID_KINDS
|
|
assert "tensorrt" in _VALID_KINDS
|
|
|
|
|
|
class TestLookupEntryByOutputDir:
|
|
def test_lookup_returns_none_when_no_match(self, tmp_path, monkeypatch):
|
|
from soup_cli.registry.attach import lookup_entry_by_output_dir
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
db = tmp_path / "reg.db"
|
|
monkeypatch.setenv("SOUP_REGISTRY_DB_PATH", str(db))
|
|
|
|
result = lookup_entry_by_output_dir(str(tmp_path / "no-such-output"))
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #35 — `soup eval custom --attach-to-registry` CLI integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvalCustomAttachCLI:
|
|
def test_attach_to_unknown_entry_errors(self, tmp_path, monkeypatch):
|
|
"""--attach-to-registry pointing at a missing entry produces a clean
|
|
error and exits non-zero rather than silently passing."""
|
|
from soup_cli.cli import app
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
# Isolated registry DB so we don't pollute the user's ~/.soup
|
|
monkeypatch.setenv(
|
|
"SOUP_REGISTRY_DB_PATH", str(tmp_path / "reg.db"),
|
|
)
|
|
(tmp_path / "tasks.jsonl").write_text(
|
|
json.dumps({"prompt": "p", "expected": "x"}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "model").mkdir()
|
|
|
|
with patch(
|
|
"soup_cli.eval.custom._create_default_generator",
|
|
return_value=lambda _p: "x",
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"eval", "custom",
|
|
"--tasks", "tasks.jsonl",
|
|
"--model", "model",
|
|
"--attach-to-registry", "no-such-id",
|
|
"--output", "results.json",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert (
|
|
"registry entry not found" in result.output
|
|
or "not found" in result.output
|
|
)
|