DEG-5B-E2B-1018 / source /tests /test_null_margin.py
gcoderw's picture
Rename package to deg (DEG_ env vars, deg.json); note upcoming modalities
16d760d verified
Raw History Blame Contribute Delete
11.4 kB
"""Extract null margin (options.null_margin): request parsing, the decode rule, server
default, determinism / batch invariance on a tiny model, and the calibration tooling
(deg.bench.null_margin)."""
from __future__ import annotations
import dataclasses
import json
import math
from types import SimpleNamespace
import pytest
from deg.bench import null_margin as nm
from deg.request import FillOptions, RequestError, parse_fill_request
from deg.schema import compile_schema
torch = pytest.importorskip("torch")
BASE = {"context": "Order AB-1 shipped.", "schema": {
"type": "object", "properties": {"x": {"type": ["string", "null"]}}, "required": ["x"]}}
def test_option_parses_and_defaults_to_none():
assert parse_fill_request(BASE).options.null_margin is None
for v in (0, 1.5, -2, 0.0):
assert parse_fill_request({**BASE, "options": {"null_margin": v}}).options.null_margin == v
for bad in (True, "1", float("inf"), [1]):
with pytest.raises(RequestError):
parse_fill_request({**BASE, "options": {"null_margin": bad}})
def test_decision_api_accepts_null_margin():
from deg.api import REQUEST_OPTION_KEYS
assert "null_margin" in REQUEST_OPTION_KEYS
# --------------------------------------------------------------------- decode rule
CTX = "alpha beta gamma delta"
OFFSETS = [(0, 5), (5, 10), (10, 16), (16, 22)] # " beta" etc.: trim_span strips spaces
def _logits(span_lp: float, null_lp: float) -> tuple[torch.Tensor, torch.Tensor]:
"""start/end logits [T+1] whose best span (token 1..1 = "beta") has log-prob ~span_lp
and the null outcome ~null_lp (the rest share what is left)."""
def row(a: float, b: float) -> torch.Tensor:
p = torch.full((5,), 1e-6, dtype=torch.float64)
p[1], p[4] = math.exp(a), math.exp(b)
p[0] = p[2] = p[3] = max(1e-6, (1 - p[1] - p[4]) / 3)
return p.log()
return row(span_lp / 2, null_lp / 2), row(span_lp / 2, null_lp / 2)
def _extract(schema_type: object, span_lp: float, null_lp: float, margin: float | None,
required: bool = True):
from deg.infer import extract_result
schema = {"type": "object", "properties": {"x": {"type": schema_type}},
"required": ["x"] if required else []}
fld = compile_schema(schema).fields[0]
start, end = _logits(span_lp, null_lp)
return extract_result(fld, CTX, SimpleNamespace(offsets=OFFSETS), start, end,
torch.tensor(1.0, dtype=torch.float64),
FillOptions(null_margin=margin))
def test_margin_shifts_the_null_decision():
# span log-prob -1.0 > null -1.6: margin 0 / None take the span
assert _extract(["string", "null"], -1.0, -1.6, None).value == "beta"
assert _extract(["string", "null"], -1.0, -1.6, 0.0).value == "beta"
assert _extract(["string", "null"], -1.0, -1.6, 0.5).value == "beta"
# null wins once span < null + margin
assert _extract(["string", "null"], -1.0, -1.6, 0.7).value is None
# a negative margin keeps a span null would beat at 0
assert _extract(["string", "null"], -1.6, -1.0, 0.0).value is None
assert _extract(["string", "null"], -1.6, -1.0, -0.7).value == "beta"
def test_margin_omits_optional_and_never_touches_forbid():
omitted = _extract("string", -1.0, -1.6, 2.0, required=False)
assert omitted.value is None and omitted.omit
assert _extract("string", -1.0, -1.6, 50.0, required=True).value == "beta" # forbid
def test_server_default_applies_only_when_unset():
from deg.server import with_null_margin
assert with_null_margin(FillOptions(), 1.5).null_margin == 1.5
assert with_null_margin(FillOptions(null_margin=0.0), 1.5).null_margin == 0.0
assert with_null_margin(FillOptions(null_margin=-1), 1.5).null_margin == -1
def test_bind_model_passes_default(monkeypatch):
from deg import infer, server
seen = []
monkeypatch.setattr(infer, "fill", lambda m, c, s, o: seen.append(o.null_margin) or {})
monkeypatch.setattr(infer, "fill_many",
lambda m, jobs: [seen.append(o.null_margin) for _, _, o in jobs])
fill, many = server.bind_model(object(), null_margin=2.0)
fill("c", {}, FillOptions())
fill("c", {}, FillOptions(null_margin=0.5))
many([("c", {}, FillOptions()), ("c", {}, FillOptions(null_margin=0.0))])
assert seen == [2.0, 0.5, 2.0, 0.0]
# ------------------------------------------------------------ tiny model (end to end)
@pytest.fixture(scope="module")
def model():
pytest.importorskip("transformers")
from test_model import SNAPSHOT, tiny_lm
from deg.model import load_model, load_tokenizer
if SNAPSHOT is None:
pytest.skip("Qwen3.5-0.8B tokenizer not cached")
m = load_model(None, device="cpu", dtype=torch.float32, lm=tiny_lm(),
tokenizer=load_tokenizer(SNAPSHOT))
torch.manual_seed(3)
with torch.no_grad():
for p in m.heads.parameters():
p.normal_(0.0, 0.5)
return m
QSCHEMA = {"type": "object", "properties": {
f"q{i}": {"type": ["string", "null"], "description": d} for i, d in enumerate(
["Who signed?", "Where?", "When was it paid?", "What is the color of the car?",
"Which team won?", "What was the amount?"])},
"required": [f"q{i}" for i in range(6)]}
QCTX = ("The contract was signed by Jane Roe in Paris on 3 May 2024. The fee of 400 euros "
"was paid by Globex. The Red team beat the Blue team in the final.")
def test_fill_margin_monotone_deterministic_and_batch_invariant(model):
from deg.infer import fill, fill_many
outs = {m: fill(model, QCTX, QSCHEMA, FillOptions(null_margin=m))["value"]
for m in (-50.0, -1.0, 0.0, 1.0, 50.0)}
nulls = [sum(v is None for v in outs[m].values()) for m in sorted(outs)]
assert nulls == sorted(nulls) # larger margin -> never fewer nulls
assert nulls[-1] == 6 and nulls[0] < 6
assert fill(model, QCTX, QSCHEMA)["value"] == outs[0.0] # None = 0
assert fill(model, QCTX, QSCHEMA, FillOptions(null_margin=1.0))["value"] == outs[1.0]
batched = fill_many(model, [(QCTX, QSCHEMA, FillOptions(null_margin=m)) for m in sorted(outs)])
assert [b["value"] for b in batched] == [outs[m] for m in sorted(outs)]
def test_leaf_probe_reproduces_margin0(model):
from deg.infer import fill
with nm.LeafProbe() as probe:
out = fill(model, QCTX, QSCHEMA, FillOptions())["value"]
assert [r["ptr"] for r in probe.rows] == [f"/q{i}" for i in range(6)]
leaves = [{**r, "gold": "ans", "alts": []} for r in probe.rows]
assert nm.check_margin0(leaves) == 0
for r in probe.rows:
assert r["value"] == out[r["ptr"][1:]]
# the sweep rule at any margin = the real decode at that margin
for m in (-1.0, 1.0):
real = fill(model, QCTX, QSCHEMA, FillOptions(null_margin=m))["value"]
assert {r["ptr"][1:]: (None if nm.is_null(r, m) else r["span_value"])
for r in probe.rows} == real
# --------------------------------------------------------------- calibration tooling
def _leaf(gold: str, span_lp: float, null_lp: float, value: object = "x",
alts: list | None = None) -> dict:
return {"ptr": "/q", "gold": gold, "alts": alts if alts is not None else (
["x"] if gold == "ans" else []), "span_value": value, "span_lp": span_lp,
"null_lp": null_lp, "value": value if span_lp >= null_lp else None}
def test_point_rates_and_cost():
leaves = [_leaf("ans", -0.1, -3.0), _leaf("ans", -1.0, -1.5, "y"),
_leaf("unans", -0.5, -1.0), _leaf("unans", -2.0, -1.0)]
p = nm.point(leaves, 0.0)
assert p["made_up_rate"] == 0.5 and p["omission_rate"] == 0.0
assert p["answerable_correct"] == 0.5 and p["answerable_wrong_span"] == 0.5
assert p["cost"] == pytest.approx(1.0)
q = nm.point(leaves, 1.0) # drops the -0.5/-1.0 made-up one and the -1.0/-1.5 answer
assert q["made_up_rate"] == 0.0 and q["omission_rate"] == 0.5 and q["cost"] == 0.5
def test_choose_refuses_always_null_collapse():
def pt(m, cost, omit):
return {"margin": m, "cost": cost, "sets": {"a": {"omission_rate": omit}}}
curve = [pt(0.0, 0.8, 0.2), pt(1.0, 0.6, 0.3), pt(9.0, 0.5, 0.9)]
assert nm.choose(curve)["margin"] == 1.0
assert nm.choose([pt(-1.0, 0.6, 0.1), pt(1.0, 0.6, 0.1), pt(0.5, 0.6, 0.1)])["margin"] == 0.5
def test_squad_records_exclude_suite_contexts():
rows = [{"context": f"Paragraph {i} text.", "id": f"{i}-{j}", "question": f"Q{j}?",
"title": "T", "answers": ["text"] if j % 2 else []}
for i in range(5) for j in range(4)]
recs, skipped = nm.squad_records(rows, {"Paragraph 2 text."}, None, 10)
assert len(recs) == 4 and skipped["suite_context"] == 1
assert all(r["context"] != "Paragraph 2 text." for r in recs)
r = recs[0]
assert set(r["schema"]["properties"]) == {"q1", "q2", "q3", "q4"}
assert sum(v is None for v in r["value"].values()) == 2
assert nm.leaf_gold(r, "/q1")[0] == ("ans" if r["alternatives"]["/q1"] else "unans")
def test_leaf_gold_nested_and_regold(tmp_path):
rec = {"uid": "r", "value": {"a": {"b": "x", "c": None}, "d": "y"}}
assert nm.leaf_gold(rec, "/a/b") == ("ans", ["x"])
assert nm.leaf_gold(rec, "/a/c") == ("unans", [])
assert nm.leaf_gold(rec, "/a/zz") == ("unans", [])
assert nm.leaf_gold(rec, "/d") == ("ans", ["y"])
nm.write_jsonl(tmp_path / "h.jsonl", [rec])
rows = [{"uid": "r", "error": None, "leaves": [{"ptr": "/a/b", "gold": "unans"}]},
{"uid": "r", "error": "boom", "leaves": [{"ptr": "/d"}]}]
assert nm.regold(rows, tmp_path / "h.jsonl") == [{"ptr": "/a/b", "gold": "ans",
"alts": ["x"]}]
def test_extract_null_rates():
from deg.bench.score import Prediction
recs = [{"uid": "a", "schema": {"type": "object", "properties": {
"p": {"type": ["string", "null"]}, "q": {"type": ["string", "null"]},
"r": {"type": "string"}, "s": {"type": "string"}}, "required": ["p", "q", "r"]},
"value": {"p": "x", "q": None, "r": "z"}}]
preds = {"a": Prediction("a", value={"p": None, "q": "made up", "r": "z", "s": "w"})}
out = nm.extract_null_rates(recs, preds)
# p answerable->null (wrong null); q, s(optional, absent gold) unanswerable->answered;
# r is forbid (required, non-nullable): not counted
assert out == {"unanswerable_n": 2, "answerable_n": 1, "made_up_rate": 1.0,
"wrong_null_rate": 1.0}
def test_suite_label_gets_margin_suffix(monkeypatch):
from deg.bench.systems import deg as ds
class M:
generate_with, meta = "base", {}
monkeypatch.setattr("deg.model.load_model", lambda *a, **k: M())
monkeypatch.setattr(ds.DegSystem, "_warm", lambda self: None)
import pathlib
monkeypatch.setattr(pathlib.Path, "exists", lambda self: True)
s = ds.DegSystem("/x/run/step-1", null_margin=1.5)
assert s.name.endswith("-basegen-nm1.5") and s.options.null_margin == 1.5
assert s.info["null_margin"] == 1.5
assert ds.DegSystem("/x/run/step-1").options == FillOptions()
def test_dataclass_replace_keeps_other_options():
o = FillOptions(array_decode="hybrid", seed=3)
r = dataclasses.replace(o, null_margin=1.0)
assert (r.array_decode, r.seed, r.null_margin) == ("hybrid", 3, 1.0)
json.dumps(dataclasses.asdict(r))