Files
helexa/script/generate_laya_fixture.py
T
grenadeandClaude Opus 5.5 816c8dc96f
CI / Classify changes (push) Successful in 25s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Config consistency (push) Successful in 11s
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m36s
CI / Test (push) Successful in 11m25s
CI / CUDA type-check (push) Successful in 22m44s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 6s
CI / Classify changes (pull_request) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 16s
CI / Clippy (pull_request) Successful in 3m9s
CI / Test (pull_request) Successful in 10m25s
CI / CUDA type-check (pull_request) Successful in 21m50s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
feat(neuron): serve /v1/systemone from a Laya decision family (#338, #339)
The neuron half of the decision endpoint, wiring together the prompt
builder (#335), the engine (#336) and the language detector (#339).

CandleHarness::systemone picks the served model -- the gateway's
x-helexa-decision-model header, else the body's model if loaded, else
the node's decision model -- then routes to a checkpoint of the family
exactly as the reference server does: a checkpoint the body's `model`
pins (multilingual, typed-decisions, their published ids and aliases),
else by the detected script and language, else English. The routing
block, reason strings included, matches the reference byte for byte on
every recorded case. A routed-to checkpoint the repo lacks falls back to
the root and says so; a pinned one it lacks is a 404.

Questions are validated and tokenised before admission, scored in one
forward pass per request, and calibrated with the detected language, as
upstream's Router does. Errors carry the #63 envelope plus the FastAPI
`detail` field Jev and laya clients read.

Loading now also fetches the family's multilingual/ and typed-decisions/
checkpoints when the repo has them, and each checkpoint carries its
calibration config and decision tokenizer. The language detector takes
the order-preserving Json state, since an object state's value order
decides what the detector reads first.

Replaying every reference case through systemone with the released
weights (f32, CPU): routing, usage and model identical, chosen answers
identical, probabilities and confidences within 1e-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 15:25:47 +03:00

161 lines
6.0 KiB
Python

"""Generate a tiny Laya parity fixture from the upstream reference (#336).
Builds the `laya` SDK's own `DecisionModel` (laya/common.py) around a
tiny ModernBERT, then records its outputs for a right-padded batch.
Writes, into --out:
rl_agent_config.json the decision-model config, as neuron parses it
encoder/config.json the ModernBERT config, saved by transformers
model.safetensors the reference's own weights, float32
tokenizer.json a placeholder word-level tokenizer (the fixture
is scored on token ids; loading needs a file)
expected.json the batch (ids, markers, question types) and the
reference's per-row scorer logits and act softmax
The shape is chosen so every mechanism is exercised at toy size:
- four encoder layers with a global layer on each side of two sliding
ones, and a sliding window (local_attention = 8, so +-4 positions)
far shorter than the 40-token row, so a window bug changes the result;
- two different rotary bases for global and sliding layers, so using
the wrong one for either kind is visible;
- rows of different lengths, so padding is present and must be masked;
- a one-option row, which takes the reference's single-marker branch
of the act features;
- every question type, so the type embedding is indexed three ways.
Weights are initialised well above transformers' default scale so
attention is sharp rather than near-uniform, and norm weights and biases
are perturbed away from their ones/zeros init: at init a missing norm
and a present one are indistinguishable.
Usage, in a venv holding the `laya` SDK and CPU torch:
python script/generate_laya_fixture.py \
--out crates/neuron/src/harness/testdata/laya/tiny
"""
import argparse
import json
import os
import torch
from safetensors.torch import save_file
from transformers import ModernBertConfig, AutoModel
from laya.common import DecisionModel
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--seed", type=int, default=7)
args = ap.parse_args()
torch.manual_seed(args.seed)
os.makedirs(os.path.join(args.out, "encoder"), exist_ok=True)
ecfg = ModernBertConfig(
vocab_size=97,
hidden_size=32,
num_hidden_layers=4,
num_attention_heads=2,
intermediate_size=48,
max_position_embeddings=128,
local_attention=8,
global_attn_every_n_layers=3,
pad_token_id=0,
bos_token_id=1,
eos_token_id=2,
cls_token_id=1,
sep_token_id=2,
norm_eps=1e-5,
hidden_activation="gelu",
# Well above the 0.02 default: at 0.02 attention is near-uniform and
# a window or rotary bug moves the logits only in the sixth decimal.
initializer_range=0.3,
rope_parameters={
"full_attention": {"rope_theta": 1000.0, "rope_type": "default"},
"sliding_attention": {"rope_theta": 300.0, "rope_type": "default"},
},
)
encoder = AutoModel.from_config(ecfg, attn_implementation="sdpa")
model = DecisionModel(encoder, head_layers=2, n_act=2).eval()
with torch.no_grad():
for name, p in model.named_parameters():
if "norm" in name or name.startswith("scorer.0"):
p.add_(0.3 * torch.randn_like(p))
elif name.startswith("type_emb"):
p.mul_(3.0)
ecfg.save_pretrained(os.path.join(args.out, "encoder"))
rl_cfg = {
"encoder": "tiny-modernbert",
"head_layers": 2,
"max_len": 64,
"head_max_len": 24,
"act_costs": {"escalate": 0.5},
"amp_dtype": "bf16",
"temperature": [1.0, 1.0, 1.0],
"temperature_by_options": {},
}
with open(os.path.join(args.out, "rl_agent_config.json"), "w") as f:
json.dump(rl_cfg, f, indent=1)
# A placeholder: the fixture is scored on token ids, but a checkpoint
# directory is loaded with its tokenizer, so one must exist. It carries
# the special tokens a decision tokenizer is looked up by, with [PAD] at
# the encoder's pad_token_id (0).
with open(os.path.join(args.out, "tokenizer.json"), "w") as f:
json.dump({"version": "1.0", "truncation": None, "padding": None, "added_tokens": [],
"normalizer": None, "pre_tokenizer": {"type": "Whitespace"},
"post_processor": None, "decoder": None,
"model": {"type": "WordLevel",
"vocab": {"[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3, "[MASK]": 4,
"a": 5, "b": 6},
"unk_token": "[UNK]"}}, f)
with open(os.path.join(args.out, "tokenizer_config.json"), "w") as f:
json.dump({"cls_token": "[CLS]", "sep_token": "[SEP]", "mask_token": "[MASK]",
"pad_token": "[PAD]", "unk_token": "[UNK]"}, f, indent=1)
state = {k: v.detach().contiguous().float() for k, v in model.state_dict().items()}
save_file(state, os.path.join(args.out, "model.safetensors"))
# (length, marker positions, qtype). Ids avoid 0 (pad) inside a row.
rows = [
(40, [3, 9, 17], 0),
(23, [4, 11], 1),
(9, [2], 2),
(31, [5, 7, 12, 20, 26], 0),
]
seqs = []
for n, markers, qtype in rows:
ids = torch.randint(1, ecfg.vocab_size, (n,)).tolist()
seqs.append({"input_ids": ids, "markers": markers, "qtype": qtype})
b = len(seqs)
L = max(len(s["input_ids"]) for s in seqs)
K = max(len(s["markers"]) for s in seqs)
ids = torch.zeros((b, L), dtype=torch.long)
att = torch.zeros((b, L), dtype=torch.long)
mpos = torch.zeros((b, K), dtype=torch.long)
mmask = torch.zeros((b, K), dtype=torch.bool)
for i, s in enumerate(seqs):
n = len(s["input_ids"])
ids[i, :n] = torch.tensor(s["input_ids"])
att[i, :n] = 1
k = len(s["markers"])
mpos[i, :k] = torch.tensor(s["markers"])
mmask[i, :k] = True
qtype = torch.tensor([s["qtype"] for s in seqs])
with torch.no_grad():
logits, act = model(ids, att, mpos, mmask, qtype)
act = torch.softmax(act.float(), -1)
for i, s in enumerate(seqs):
k = len(s["markers"])
s["logits"] = logits[i, :k].tolist()
s["act"] = act[i].tolist()
with open(os.path.join(args.out, "expected.json"), "w") as f:
json.dump({"seed": args.seed, "seqs": seqs}, f, indent=1)
print("wrote", args.out, "params:", sum(v.numel() for v in state.values()))