Extend research/eval/metamorphic.py with a deterministic rename_labels transformation: the option at each canonical position keeps its slot and description while its model-facing key becomes an opaque label (A, B, ... Z, then key_26, key_27, ...). Order and semantics are unchanged, so drift in the new label_rename group isolates lexical-label sensitivity (the failure mode of #156) from the position sensitivity already measured by option_order (#269). make_variants now emits baseline, one option permutation and one label rename per case; _report and evaluate_variants accept the new kind; the CLI config records label_renames_per_case. The generated canonical_to_transformed / transformed_to_canonical mapping is saved per variant so the comparison is auditable. No runtime or model behavior changes. Verified: - python -m unittest research.eval.test_metamorphic # 20 passed (17 before; adds order/description preservation, alphabet fallback, a stub scorer that exposes label sensitivity with position invariance as the control, and the position-scorer isolation check) - python -m unittest research.eval.test_laya_eval # 64 passed - python -m unittest research.eval.test_presentation_checks - ruff check laya/ --select=E9,F63,F7,F82,F401,F811 --line-length=120 - python -m compileall -q laya/ tests/
laya-eval — a reproducible per-language evaluation harness
An independent harness for measuring a Laya checkpoint: per-language accuracy and calibration, with machine-readable per-case output.
It exists because the repository's own benchmark scripts are research code. They download every checkpoint, run every part, and print tables. There was no small, reproducible harness a third party could point at a checkpoint to answer "how does this model do on my language, and can I trust its confidence?" — and no per-case record behind the published numbers, so they could not be re-derived without a GPU and the original environment.
This addresses the ask in #35:
A fixed prompt format plus a per-language ECE report is exactly what the repo lacks ... Per-case JSON would be very welcome too.
Install
Nothing beyond a normal Laya install, plus datasets:
pip install laya datasets
The harness is deliberately not part of the laya package: it is evaluation code,
it pulls a dataset, and import laya should stay dependency-light.
Use
# one language
python research/eval/laya_eval.py --model convaiinnovations/laya --langs en
# several, with a JSON report
python research/eval/laya_eval.py --model convaiinnovations/laya \
--langs en,de,ro --out report.json
# every MASSIVE language
python research/eval/laya_eval.py --model convaiinnovations/laya --langs all --out all.json
# the multilingual checkpoint
python research/eval/laya_eval.py --model convaiinnovations/laya \
--subfolder multilingual --langs all --out multilingual.json
# a local checkpoint
python research/eval/laya_eval.py --model ./my-finetune --langs en
Output, per language:
en n=100 acc=0.8200 macro_f1=0.7876 ece=0.1789 conf=0.9989 (36.7s)
macro over 51 languages: acc=... ece=... f1=...
and a JSON document with four parts:
| key | contents |
|---|---|
config |
checkpoint, device, max_len, head_max_len, dataset, per_lang, n_opts, seed, the fixed instructions, the temperatures in force, laya version |
report |
per language: n, accuracy, macro_f1, ece, mean_confidence, acc_at_50_coverage, temperature |
summary |
macro accuracy / ECE / macro-F1 over the languages that ran |
cases |
every individual decision |
Each case carries state, instructions, options, gold_index, gold_label,
pred_index, pred_label, probability, p_gold, confidence, correct and the
temperature used. That is enough to re-derive every number in report from the
file alone, with no model and no network:
import json
d = json.load(open("report.json"))
n = len(d["cases"])
acc = sum(c["correct"] for c in d["cases"]) / n
assert abs(acc - d["report"]["en"]["accuracy"]) < 5e-5
Method
Chosen so results are comparable with the published tables, which is the point of a second implementation:
| dataset | mteb/amazon_massive_intent, split test |
| sampling | first --per-lang rows (default 100); random.Random(13) created fresh per language |
| options | --n-opts (default 20): the gold label plus rng.sample of the others, then shuffled |
| prompt | What is the user asking for in \utterance`?` |
| option text | label with _ → space and . → : |
| metrics | accuracy, macro-F1, ECE over 15 equal-width confidence bins, mean confidence, accuracy at 50% coverage |
| temperature | the bucket Agent would apply, selected by (question type, option count) |
--unclamped scores with the checkpoint's raw bucket temperatures instead of the
clamped ones Agent applies. That is what reproduces the committed sweep, and it is
also how the two can be compared.
Verification
Checked against the committed sweep, not only against itself. Both checkpoints over
all 51 languages (--langs all --per-lang 100 --n-opts 20), per-language accuracy
compared against research/results/cpu_51_language_sweep.json:
| checkpoint | per-language accuracy identical | macro_accuracy committed → mine |
macro_ece committed → mine |
|---|---|---|---|
| english | 51 / 51 | 0.2269 → 0.2269 | 0.7331 → 0.5709 |
| multilingual | 6 / 51 | 0.3661 → 0.4008 | 0.3869 → 0.3911 |
The english checkpoint reproduces every per-language accuracy, not just the macro. Those are deterministic outputs on a fixed sample, so they can only agree if the sampling, prompt text, option construction and inference path are all identical to the committed run.
The macro_ece gap on english is the temperature clamp — choice:11+ is 0.1006
raw and 0.5 as served (#208).
--unclamped exists so both regimes can be produced from one tool. The single-language
view is the same result in miniature (--langs en --unclamped):
| metric | committed | --unclamped |
default |
|---|---|---|---|
accuracy |
0.82 | 0.82 | 0.82 |
macro_f1 |
0.7876 | 0.7876 | 0.7876 |
ece |
0.1789 | 0.1789 | 0.1382 |
mean_confidence |
0.9989 | 0.9989 | 0.9582 |
acc_at_50_coverage |
0.94 | 0.94 | 0.98 |
The multilingual checkpoint no longer matches its committed row
45 of 51 multilingual accuracies differ, so this is not a plumbing accident here —
the same code reproduces english 51/51. Most of the movement is upward
(bn 0.29→0.45, kn 0.15→0.30, fa 0.39→0.51), a few downward (sv 0.57→0.49).
macro_ece barely moves (0.3869→0.3911), consistent with the multilingual checkpoint
having an empty temperature_by_options, so the clamp cannot explain it.
Ruled out: the option sets (identical digest to the english run), the weights
(bundled and standalone multilingual are byte-identical, all 170 tensors
torch.equal), the dataset (revision 940fd47a, last modified 2026-02-24), and
build_sequence (unchanged since v0.2.0). Also ruled out, on re-measurement:
- the shipped
head_max_len, which matters here because this checkpoint ships256and english ships192. The harness reads it from the checkpoint's own config and the run'sconfigblock recordshead_max_len: 256, max_len: 1024, so the multilingual numbers above were not taken at english's budget. Re-running with the value read from config gives the same0.4008, and6/51again. - which of the two multilingual copies was measured. The bundled
multilingual/subfolder and the standaloneconvaiinnovations/laya-multilingualrepo were each run end to end over all 51 languages and both givemacro_accuracy 0.4008,macro_ece 0.3911,6/51. - a checkpoint change since the committed sweep.
multilingual/model.safetensorsis643835514bytes atsha256 b99c8bea…andmultilingual/rl_agent_config.jsonis472bytes atsha256 00e35f88…at every revision from the sweep's timestamp to today; the Hub commits in that window are model-carddocs:/assets:only.
It is in the multilingual inference path between laya 0.2.0 and 0.3.6 and is
not reconciled. Flagged rather than hidden.
Related: head_max_len is load-bearing for accuracy, not just for option
truncation. The english checkpoint at its shipped head_max_len=192 scores 0.82;
forcing 256 or 512 drops it to 0.79.
Tests
research/eval/test_laya_eval.py covers the pure functions and runs offline — no
checkpoint, no network:
python research/eval/test_laya_eval.py # 64 passed, 0 failed
It pins the upstream constants (seed 13, 20 options, the exact instruction string),
the determinism of the sampler, that a fresh RNG per language is used, and the
metric arithmetic, including the confidence == 0.0 bin boundary that this harness
shares with laya.common.ece_score, research/scripts/bench_local.py and
research/scripts/build_benchmark_nb.py. That boundary is asserted against all four,
not just against this harness's own arithmetic.
Limits
- MASSIVE intent only. The same shape applies to
scenarioand to XNLI, but neither is wired up here. per_lang=100is the published setting, not a statistical one. Per-language ECE on 100 cases is noisy; raise--per-langand say so when quoting a number.- The English checkpoint collapses on non-Latin scripts (see
BENCHMARKS.md), so a low score in one language is not by itself evidence of a misroute — checklaya.lang.analysefor the script before concluding which checkpoint was used. - The
confidence == 0.0bin boundary is the one #39 settled: the first bin is closed at the bottom, so0.0is counted. This harness usedconf > lofor every bin until the divergence was found, which made it the only one of the four implementations that binned differently. It now matcheslaya.common.ece_score,research/scripts/bench_local.pyandresearch/scripts/build_benchmark_nb.py, andtest_laya_eval.pyasserts that agreement.
The temperature clamp, measured both ways
research/results/cpu_51_language_sweep_clamped.json carries the same re-run twice, once per
regime, against the committed columns. Macro accuracy reproduces the committed file exactly
and macro ECE is the only macro figure that moves:
| committed | --unclamped |
default | |
|---|---|---|---|
macro_accuracy |
0.2269 | 0.2269 | 0.2269 |
macro_ece |
0.7331 | 0.7331 | 0.5709 |
macro_f1 |
0.2053 | 0.2053 | 0.2053 |
Per language, the unclamped run agrees with the committed file on accuracy and macro_f1
in 51/51, on ece in 48/51 and on mean_confidence in 49/51. The handful that
differ do so by 0.0001, the last stored digit: the committed run used torch 2.8.0 and this
one 2.14.0. The clamped run differs from the committed file on ece and mean_confidence in
51/51, every one of them lower, because it is the only column the clamp can move.
accuracy, macro_f1 and n are identical in all three columns by construction: scaling
logits by any positive temperature does not change the argmax. That is why a re-run can settle
the calibration question without reopening the accuracy numbers.
Presentation checks (presentation_checks.py)
A label-free regression check for the score position prior in #131.
laya-multilingual rarely picks the first-listed score level, and the fix is a
position-balanced retrain. This script says whether a retrained checkpoint removed
the prior. Every input is fixed in the file (10 short English states written for it),
so it needs no dataset and no labels.
python research/eval/presentation_checks.py --model convaiinnovations/laya --subfolder multilingual
python research/eval/presentation_checks.py --model ./retrained-checkpoint --out report.json
Exit status: 0 every check passed, 1 a check failed, 2 the harness disagrees with
Agent.system_one by more than 1e-3 (nothing else is trusted then). CPU is the
default device: fp32 and deterministic, which is what the thresholds were set on.
The two checks
| check | input | metric | gate |
|---|---|---|---|
score_slot0_identical |
one score question whose K levels all carry the same text; texts moderate and a request, K = 3, 4, 5 |
raw slot-0 marker logit minus the mean over the K slots, averaged over 10 states × 6 configurations | >= -0.20 |
score_first_slot_permuted |
Not urgent / Soon / Work is blocked in all 6 orders, per state |
share of the 60 decisions whose argmax is the first slot | >= 0.15 |
score_slot0_identical is the identical-option control from @AlKor13 in #131. With
identical texts the rendered options differ only by position and by the level N:
prefix that render_options always emits, so a checkpoint without a slot prior has
no reason to prefer or avoid any slot.
score_first_slot_permuted presents every order of the three levels, so each level
sits in each slot exactly twice per state. A checkpoint whose answer does not depend
on the order picks the first slot in exactly 1/3 of the decisions, whatever the states
say; the rate moves only through order dependence.
Both read raw marker logits (before temperature) through laya_eval.score_cases,
and the script first compares that path with Agent.system_one on every state.
Measured on the shipped checkpoints
CPU, fp32, convaiinnovations/laya@1c5edc1, laya 0.3.7. Full output, per state and
per configuration: research/results/presentation_checks_shipped.json.
| checkpoint | score_slot0_identical (leave-one-out) |
score_first_slot_permuted (leave-one-out) |
verdict |
|---|---|---|---|
laya (english) |
+0.664 (+0.520 .. +0.741) | 0.217 (0.204 .. 0.241) | PASS |
laya-multilingual |
−0.492 (−0.563 .. −0.425) | 0.017 (0.000 .. 0.019) | FAIL |
Parity with Agent.system_one: max |Δp| 4.98e-5 (multilingual) and 4.92e-5 (english),
which is the 4-decimal rounding of system_one's probabilities.
Thresholds
The gates were set from the leave-one-out ranges above, not tuned to them. Two conditions were fixed before the 10-state run:
- the current multilingual checkpoint fails and the english checkpoint passes in every leave-one-out subset, and
- the worst leave-one-out value of each checkpoint clears the threshold by at least 0.10 logit (slot 0) and 0.05 (first-slot rate, 3 of 60 decisions).
| check | threshold | multilingual worst → margin | english worst → margin |
|---|---|---|---|
score_slot0_identical |
−0.20 | −0.425 → 0.225 | +0.520 → 0.720 |
score_first_slot_permuted |
0.15 | 0.019 → 0.131 | 0.204 → 0.054 |
The tightest margin is the english first-slot rate, at 0.054 against the 0.05 rule. An order-invariant checkpoint sits at exactly 0.333 on that check.
Tests
research/eval/test_presentation_checks.py runs offline, with scripted logits in
place of a checkpoint:
python research/eval/test_presentation_checks.py # 69 passed, 0 failed
It pins the fixed inputs and both gates. It checks that the identical-option
questions render as level i: <same text>, and that every level sits in every slot
exactly twice. It also checks the metric arithmetic by hand, the leave-one-out
bounds, the one-sided gates, and the exit codes. A scripted slot-0 hole fails both
checks, and an order-invariant model scores exactly 1/3.
Limits
- The gate is one-sided because the english checkpoint is not flat either. With
identical options it prefers the early slots, more strongly as K grows: slot 0 sits
+0.10 / +0.41 / +0.85 above the mean at K = 3 / 4 / 5 with
moderate, and +0.21 / +0.74 / +1.68 witha request. At K = 3 withmoderateit is close to flat, which matches the #131 control. A two-sided "no position effect" gate would fail the english checkpoint, so the check asks the narrower question #131 is about: whether slot 0 is suppressed. (multilingual: −0.75 / −0.52 / −0.25 and −0.58 / −0.47 / −0.37.) - Passing is not accuracy. A checkpoint can clear both gates and still rank urgency
badly; this checks one known failure, not
scorequality. - English only,
scoreonly, 10 states. The states are short support messages, so a checkpoint's behaviour on long inputs or other languages is not covered here. - Thresholds were set on CPU fp32. On CUDA,
Agentruns the forward pass under reduced-precision autocast andscore_casesdoes not. The parity check reports that difference instead of hiding it. - New checks are one function each, registered in
CHECKS.
Metamorphic option-order robustness (experimental)
metamorphic.py adds the initial scope of
#244: choice option order robustness, and the label-renaming transformation from #512, without changing model/runtime behavior. Paraphrases, structured-state permutations, score and noul perturbations are intentionally deferred. Run from the repository root after installing Laya and datasets:
python -m research.eval.metamorphic --model convaiinnovations/laya \
--langs en --per-lang 100 --n-opts 20 --batch-size 16 --out robustness.json
python -m research.eval.metamorphic --model convaiinnovations/laya \
--subfolder multilingual --langs en --out multilingual-robustness.json
python -m unittest research.eval.test_metamorphic -v
Each MASSIVE case uses the existing harness's sampler and produces three inputs:
- The unchanged baseline.
- One seeded shuffle of option order; if the shuffle is the identity, a one-slot rotation is used. This is a bounded diagnostic, not exhaustive permutation testing or a uniform draw over all nonidentity permutations.
- A deterministic label rename: the option at each position keeps its slot and
description, and its model-facing key becomes an opaque label (
A,B,C, ...Z, thenkey_26,key_27, ...). Order and semantics are unchanged, so any drift in thelabel_renamegroup isolates lexical-label sensitivity (the failure mode of #156) from the position sensitivity measured byoption_order.
Instructions and state are otherwise unchanged. Option key/value pairs are moved together during permutation. Every result is mapped back to the original semantic option order before predictions and metrics are computed, and each variant records the explicit bidirectional mapping so the comparison is auditable. Exact ties choose the first canonical option. The RNG starts fresh per language; --seed controls both sampling and transformations. --batch-size bounds the number of forward-pass inputs and does not alter the generated variants. Model inference may still have small floating-point differences across devices and batch sizes.
The JSON contains config, per-language report, and full cases. Each case saves its original input, canonical keys and optional gold index; each variant saves its presented keys, explicit canonical_to_transformed and transformed_to_canonical label mappings, slot-to-canonical indices, complete canonical-order probability vector, prediction, confidence, correctness (or null), and comparison to baseline. Probabilities are not rounded. The config records model/subfolder, temperature mode and values, truncation settings, dataset, seed and batch size. For reproducible checkpoint comparisons, use a pinned local snapshot and retain the environment versions alongside the report. --unclamped has the same meaning as in laya_eval. If any language fails, its error is saved and the command exits nonzero while retaining successful languages.
Metrics are grouped under option_order, label_rename and overall:
| Metric | Definition |
|---|---|
semantic_agreement_rate |
Fraction of baseline/variant pairs with the same canonical argmax |
mean_probability_drift |
Mean absolute probability change across options, then pairs |
max_probability_drift |
Largest absolute change of any option across all pairs |
mean_js_divergence |
Mean Jensen-Shannon divergence using natural logs, in [0, ln(2)] |
mean_confidence_drift |
Mean signed change of maximum probability, variant minus baseline |
mean_absolute_confidence_drift |
Mean magnitude of that confidence change |
worst_confidence_increase_on_disagreement |
Largest positive confidence change among changed decisions, or zero if none |
overall is pair-weighted, not a fraction of cases where all variants agree. quality separately reports accuracy and the existing harness's 15-bin ECE for baseline and each transformation on labelled cases only. Empty groups contain n: 0; unlabelled quality groups contain n_labelled: 0 without inventing an accuracy or ECE. Robustness agreement is not a correctness measure: consistently wrong predictions can be perfectly invariant.
For another corpus, the Python API accepts (state, questions) cases in the same shape as the harness, and a callback returning probability vectors in presented option order:
from research.eval.metamorphic import evaluate, model_scorer
agent.model.eval()
result = evaluate(cases, model_scorer(agent), gold_indices=None, seed=13)
The first version intentionally accepts only one choice question per case, with at least two options. Paraphrases and other metamorphic transforms are intentionally deferred as proposed in the issues.
For an explicit single-case experiment, the same implementation exposes:
from research.eval.metamorphic import (
MetamorphicCase, permute_options, rename_labels,
evaluate_variants, compare_predictions,
)
case = MetamorphicCase(state, questions, gold_index=None)
variants = [permute_options(case, seed=42), rename_labels(case)]
agent.model.eval()
results = evaluate_variants(agent, case, variants)
report = compare_predictions(baseline=results.baseline, variants=results.variants)
For offline tests, pass agent=None, score=fake_scorer to evaluate_variants. The scorer takes a batch of harness (state, questions) inputs and returns one probability vector per input. The public transformations return independent copies and explicit mappings in both directions, including identity label mappings for order-only transformations.
Semantic agreement and distribution stability are different properties.
A shift from [0.91, 0.06, 0.03] to [0.88, 0.08, 0.04] preserves the decision while showing nonzero drift. Switching the winner is reported as disagreement, regardless of whether confidence rises or falls. No metric here automatically classifies either observation as a bug; acceptable variation depends on the use case, and the report deliberately defines no universal pass/fail threshold.