Files
laya/tests/test_load_errors.py
PerryLink 71aa638dfb fix(agent): a short temperature list now fails at load, not at the first decode
`Agent.__init__` reads the checkpoint's `temperature` list without checking it, and
`_decode_answers` then indexes that list by question type (`QTYPES`: choice=0, score=1,
noul=2). A checkpoint that ships fewer than 3 entries -- say one -- loads cleanly, answers
`choice` questions, and dies on the first `score` or `noul` question with a bare
`IndexError: list index out of range`; a scalar `temperature` was worse, raising
`TypeError: 'float' object is not iterable` in the list comprehension before any decode.
Measured on a checkpoint built the way `tests/test_load_errors.py` builds them, with
`"temperature": [0.9]`:

loaded OK (a short temperature list is not refused at load)
choice question -> answered: {"type": "choice", "choice": "department", "probabilities": {...}}
noul question -> IndexError: list index out of range

The language-override path already refuses this shape (`agent.py:362`, "must be a list of 3
floats"); the checkpoint's own list had no guard at all. This adds the same shape check --
same wording pattern, naming the field and the expected length -- and pins it in the
load-error suite:

$ python tests/test_load_errors.py      # with the new case, before the fix
17 passed, 3 failed
  FAIL short temperature/raises ValueError None
  FAIL short temperature/names the field None
  FAIL short temperature/says the shape None

$ python tests/test_load_errors.py      # after
20 passed, 0 failed

The same checkpoint now stops at load with the reason:

ValueError: Incompatible model: '/tmp/...' temperature must be a list of 3 floats, got [0.9]

Deliberate choices tested:

- **Refused at load, not tolerated at decode.** The alternatives were to pad/truncate the
  list at decode (invents values) or to quietly treat a short list as `[1, 1, 1]` (a silent
  behaviour change for a malformed checkpoint). Failing where the config is read keeps the
  failure next to its cause and matches the language-override guard.
- **`(list, tuple)`, not just `list`.** Mirrors the language-override check, which #428
  extends to the same shape; JSON only produces lists here, but the two guards should be one
  guard.
- **Nothing shipped is affected:** all three checkpoint configs carry exactly 3 entries --
  english `[1.64, 1.25, 1.98]`, multilingual `[1.0, 1.0, 1.0]`, typed-decisions
  `[1.01, 1.04, 1.06]`.

Known limitation: only the container shape and length are refused; individual entries keep
the existing clamp behaviour (a non-number becomes `1.0` by design), and
`temperature_by_options` entries are per-bucket scalars that remain clamped per element.
2026-09-25 11:17:06 +08:00

211 lines
9.2 KiB
Python

"""The load-time and budget errors a user actually hits.
Every branch here was reachable but never executed by any suite in CI, measured with
`sys.settrace` over all 15 of them: `laya/agent.py` lines 126, 146, 154, 165 and 350 had
zero hits. They are the messages a user sees when a checkpoint is wrong or a question is
too large, so a regression in one is a regression in the only diagnostic they get.
No network: the checkpoint is a tiny local one built here, the same shape
`tests/test_download.py` uses.
Run: python tests/test_load_errors.py
"""
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
os.environ.setdefault("USE_TF", "0")
os.environ.setdefault("USE_TORCH", "1")
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import torch # noqa: E402
from safetensors.torch import save_file # noqa: E402
from tokenizers import Tokenizer # noqa: E402
from tokenizers.models import WordLevel # noqa: E402
from tokenizers.pre_tokenizers import Whitespace # noqa: E402
from transformers import BertConfig, BertModel, PreTrainedTokenizerFast # noqa: E402
from laya import load # noqa: E402
from laya.common import DecisionModel # noqa: E402
PASS, FAIL = [], []
def check(name, got, want):
if got == want:
PASS.append(name)
else:
FAIL.append("%s:\n got %r\n want %r" % (name, got, want))
def check_true(name, cond, detail=""):
if cond:
PASS.append(name)
else:
FAIL.append("%s %s" % (name, detail))
TMP = tempfile.TemporaryDirectory()
REPO = Path(TMP.name) / "repo"
def build_checkpoint(root, max_len=64, head_max_len=32, vocab=("hello",)):
"""A loadable Laya checkpoint, small enough to build in-process."""
words = {"[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3, "[MASK]": 4}
for i, word in enumerate(vocab):
words[word] = 5 + i
root.mkdir(parents=True, exist_ok=True)
config = BertConfig(vocab_size=len(words), hidden_size=64, num_hidden_layers=1,
num_attention_heads=2, intermediate_size=128)
config.save_pretrained(root / "encoder")
# `Whitespace` is required: without a pre-tokenizer, `WordLevel` sees the whole string
# as one word and every label collapses to a single `[UNK]`, so the option block never
# grows and the budget guard below cannot be reached.
word_level = Tokenizer(WordLevel(words, unk_token="[UNK]"))
word_level.pre_tokenizer = Whitespace()
tokenizer = PreTrainedTokenizerFast(
tokenizer_object=word_level,
pad_token="[PAD]", unk_token="[UNK]", cls_token="[CLS]",
sep_token="[SEP]", mask_token="[MASK]",
)
tokenizer.save_pretrained(root / "tokenizer")
model = DecisionModel(BertModel(config), head_layers=0)
save_file(model.state_dict(), root / "model.safetensors")
(root / "rl_agent_config.json").write_text(json.dumps({
"encoder": "unused/offline", "head_layers": 0, "act_costs": {"act": 0},
"max_len": max_len, "head_max_len": head_max_len,
}), encoding="utf-8")
build_checkpoint(REPO)
def load_error(path, **kw):
"""Load and return the exception, or None if it unexpectedly succeeded."""
try:
load(str(path), device="cpu", **kw)
except Exception as exc: # noqa: BLE001 -- the message is the thing under test
return exc
return None
# ------------------------------------------------------- a good checkpoint still loads
good = load(str(REPO), device="cpu")
check_true("baseline/local checkpoint loads", good is not None)
check("baseline/config round-trips", good.cfg["max_len"], 64)
del good
# ----------------------------------------------------------------- 1. missing directory
# `model_id_or_path` starting with ./ or a drive letter is treated as a local path and
# never handed to the Hub, so the user gets this message instead of a network error.
missing_dir = Path(TMP.name) / "not-a-checkpoint"
err = load_error(missing_dir)
check_true("missing path/raises FileNotFoundError", isinstance(err, FileNotFoundError), repr(err))
# The message carries the path through `!r`, so on Windows it is the backslash-escaped
# form; compare against that rather than the plain string.
check_true("missing path/names the path", repr(str(missing_dir)) in str(err), str(err))
check_true("missing path/says what to check",
"does not exist" in str(err) or "training saved" in str(err), str(err))
# -------------------------------------------------------------- 2. missing subfolder
# Reachable when a repo exists but the requested sibling checkpoint is not in it.
err = load_error(REPO, subfolder="multilingual")
check_true("missing subfolder/raises FileNotFoundError", isinstance(err, FileNotFoundError), repr(err))
check_true("missing subfolder/names the subfolder", "multilingual" in str(err), str(err))
sub = Path(TMP.name) / "sub"
build_checkpoint(sub / "multilingual")
check_true("present subfolder/loads", load_error(sub, subfolder="multilingual") is None)
# --------------------------------------------------- 3. missing rl_agent_config.json
# This is the file that makes a directory a Laya checkpoint rather than a bare encoder,
# so the message has to point at the training run that would have written it.
no_cfg = Path(TMP.name) / "no-config"
shutil.copytree(REPO, no_cfg)
os.remove(no_cfg / "rl_agent_config.json")
err = load_error(no_cfg)
check_true("no config/raises FileNotFoundError", isinstance(err, FileNotFoundError), repr(err))
check_true("no config/names the missing file", "rl_agent_config.json" in str(err), str(err))
check_true("no config/says where it comes from",
"ships with the weights" in str(err) or "training run" in str(err), str(err))
# --------------------------------------------------------- 4. missing model.safetensors
# The config is present and valid here, so this is reached only after that check passes.
no_weights = Path(TMP.name) / "no-weights"
shutil.copytree(REPO, no_weights)
os.remove(no_weights / "model.safetensors")
err = load_error(no_weights)
check_true("no weights/raises FileNotFoundError", isinstance(err, FileNotFoundError), repr(err))
check_true("no weights/names the missing file", "model.safetensors" in str(err), str(err))
# ------------------------------------------- 5. a question whose options do not fit
# `build_sequence` drops markers past `max_len`. Without this guard the model gets a
# selected index outside its own option count, which surfaces as an index error deep in
# the head rather than as a statement about the question.
#
# Measured against the shipped budget (`max_len=512`, `head_max_len=192`) with the real
# tokenizer: 100 options give 413 tokens and 100 markers, 140 give 512 tokens and 126
# markers. So the boundary sits between 100 and 140. Nothing documents that threshold,
# which is why this pins the behaviour rather than the number.
wide = Path(TMP.name) / "wide"
WORDS = ("department", "handling", "billing", "enquiries")
build_checkpoint(wide, max_len=512, head_max_len=192,
vocab=WORDS + tuple(str(i) for i in range(1, 201)))
wide_agent = load(str(wide), device="cpu")
many = {("department %d handling billing enquiries" % i): None for i in range(1, 141)}
try:
wide_agent.system_one("hello",
{"q": {"type": "choice", "instructions": "Which department?",
"criteria": many}})
_outcome = None
except Exception as exc: # noqa: BLE001
_outcome = exc
check_true("options over budget/raises ValueError", isinstance(_outcome, ValueError), repr(_outcome))
check_true("options over budget/names the question", "'q'" in str(_outcome), str(_outcome))
check_true("options over budget/reports the budget",
"head_max_len" in str(_outcome), str(_outcome))
# ...and a question that does fit still answers, so the guard is not refusing everything.
fits = {"q": {"type": "choice", "instructions": "Pick one",
"criteria": {"department": None, "billing": None}}}
try:
wide_agent.system_one("hello", fits)
_ok = True
except Exception: # noqa: BLE001
_ok = False
check_true("options within budget/still answers", _ok)
del wide_agent
# ------------------------------------------- 6. a temperature list of the wrong length
# `_decode_answers` indexes `temperature` by question type (`QTYPES`), so a checkpoint that
# ships the wrong number of entries -- say one -- loads cleanly, answers `choice` questions,
# and then raises a bare `IndexError` on the first `score`/`noul` question: a decode-time
# crash whose cause is a single config field. The language-override path already refuses this
# shape ("must be a list of 3 floats"); this pins the same refusal for the checkpoint's own
# list, where there was none.
short_temp = Path(TMP.name) / "short-temperature"
build_checkpoint(short_temp)
_cfg = json.loads((short_temp / "rl_agent_config.json").read_text())
_cfg["temperature"] = [0.9]
(short_temp / "rl_agent_config.json").write_text(json.dumps(_cfg), encoding="utf-8")
err = load_error(short_temp)
check_true("short temperature/raises ValueError", isinstance(err, ValueError), repr(err))
check_true("short temperature/names the field", "temperature" in str(err), str(err))
check_true("short temperature/says the shape", "list of 3" in str(err), str(err))
TMP.cleanup()
print("\n%d passed, %d failed" % (len(PASS), len(FAIL)))
for f in FAIL:
print(" FAIL " + f)
sys.exit(1 if FAIL else 0)