dsh-router-laya 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ """Laya: Fast, non-autoregressive System 1 decision engine with calibrated probabilities."""
2
+
3
+ from .agent import Agent, RLAgent, load
4
+ from .common import (
5
+ QTYPES,
6
+ QTYPE_NAMES,
7
+ confidence_from_probs,
8
+ ece_score,
9
+ proper_reward,
10
+ render_options,
11
+ td_lambda_targets,
12
+ )
13
+ from .email import clean_email_body, email_state
14
+ from .lang import analyse as detect_language
15
+ from .lang import detect_script, is_english
16
+ from .presets import (
17
+ email_questions,
18
+ guard_questions,
19
+ moderation_questions,
20
+ router_questions,
21
+ triage_questions,
22
+ )
23
+ from .router import DEFAULT_MODELS, RouteDecision, Router
24
+
25
+ __version__ = "0.3.7"
26
+ __all__ = [
27
+ "Agent",
28
+ "RLAgent",
29
+ "load",
30
+ "Router",
31
+ "RouteDecision",
32
+ "DEFAULT_MODELS",
33
+ "detect_language",
34
+ "detect_script",
35
+ "is_english",
36
+ "clean_email_body",
37
+ "email_questions",
38
+ "email_state",
39
+ "guard_questions",
40
+ "moderation_questions",
41
+ "router_questions",
42
+ "triage_questions",
43
+ "proper_reward",
44
+ "td_lambda_targets",
45
+ "ece_score",
46
+ "confidence_from_probs",
47
+ "render_options",
48
+ "QTYPES",
49
+ "QTYPE_NAMES",
50
+ "__version__",
51
+ ]
@@ -0,0 +1,447 @@
1
+ """High-level inference runtime for laya System 1 decision models."""
2
+ import json
3
+ import os
4
+ import warnings
5
+ from typing import Any, Dict, Optional, Union
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from .common import (
11
+ QTYPES,
12
+ TEMP_MAX,
13
+ TEMP_MIN,
14
+ amp_dtype,
15
+ build_model,
16
+ build_sequence,
17
+ clamp_temperature,
18
+ collate_items,
19
+ confidence_from_probs,
20
+ render_options,
21
+ temp_bucket,
22
+ )
23
+
24
+
25
+ def _fix_tokenizer_config(path: str):
26
+ """Ensure tokenizer_config.json can be loaded across all transformers versions."""
27
+ cfg_file = os.path.join(path, "tokenizer", "tokenizer_config.json")
28
+ if not os.path.exists(cfg_file):
29
+ return
30
+ try:
31
+ with open(cfg_file) as f:
32
+ tcfg = json.load(f)
33
+ changed = False
34
+ if tcfg.get("tokenizer_class") in (None, "TokenizersBackend"):
35
+ tcfg["tokenizer_class"] = "PreTrainedTokenizerFast"
36
+ tcfg.pop("backend", None)
37
+ tcfg.pop("is_local", None)
38
+ changed = True
39
+ # Checkpoints built on the mmBERT/Gemma tokenizer store extra_special_tokens as a list;
40
+ # transformers expects a mapping and raises "'list' object has no attribute 'keys'",
41
+ # which makes AutoTokenizer -- and so the whole model -- fail to load.
42
+ extra = tcfg.get("extra_special_tokens")
43
+ if isinstance(extra, list):
44
+ tcfg["extra_special_tokens"] = {"extra_%d" % i: t for i, t in enumerate(extra)}
45
+ changed = True
46
+ if changed:
47
+ with open(cfg_file, "w") as f:
48
+ json.dump(tcfg, f, indent=2)
49
+ except Exception:
50
+ pass
51
+
52
+
53
+ def _verify_compatibility(model: torch.nn.Module, cfg: Dict, weights: Dict[str, torch.Tensor], model_id: str):
54
+ """Verify that the loaded checkpoint weights and config strictly match the expected architecture."""
55
+ # 1. Verify required configuration attributes
56
+ required_cfg = ["encoder", "head_layers"]
57
+ missing_cfg = [k for k in required_cfg if k not in cfg]
58
+ if missing_cfg:
59
+ raise ValueError(
60
+ f"Incompatible model config for {model_id!r}: missing configuration keys {missing_cfg}. "
61
+ f"Ensure this is a valid RL Agent decision model."
62
+ )
63
+
64
+ # 2. Check for required component prefixes
65
+ required_prefixes = ("encoder.", "type_emb.", "scorer.", "act_head.")
66
+ for prefix in required_prefixes:
67
+ if not any(k.startswith(prefix) for k in weights.keys()):
68
+ raise ValueError(
69
+ f"Incompatible model weights for {model_id!r}: checkpoint is missing '{prefix}' parameters. "
70
+ f"Expected an RL Agent decision model with encoder and decision heads."
71
+ )
72
+
73
+ # 3. Check for parameter shape mismatches
74
+ model_sd = model.state_dict()
75
+ shape_mismatches = []
76
+ missing_keys = []
77
+
78
+ for name, param in model.named_parameters():
79
+ if name not in weights:
80
+ missing_keys.append(name)
81
+ elif tuple(weights[name].shape) != tuple(param.shape):
82
+ shape_mismatches.append(f" - {name}: expected {tuple(param.shape)}, found {tuple(weights[name].shape)}")
83
+
84
+ if shape_mismatches:
85
+ err_details = "\n".join(shape_mismatches[:5])
86
+ if len(shape_mismatches) > 5:
87
+ err_details += f"\n ... and {len(shape_mismatches) - 5} more mismatched layers."
88
+ raise ValueError(
89
+ f"Model architecture mismatch for {model_id!r}:\n{err_details}\n"
90
+ f"The checkpoint weights do not match the configured model architecture."
91
+ )
92
+
93
+ if missing_keys:
94
+ raise ValueError(
95
+ f"Model weights incomplete for {model_id!r}: missing {len(missing_keys)} parameter tensors "
96
+ f"(e.g. {missing_keys[:3]})."
97
+ )
98
+
99
+
100
+ class Agent:
101
+ """System 1 decision model runtime: fast, non-autoregressive, calibrated decisions."""
102
+
103
+ def __init__(
104
+ self,
105
+ model_id_or_path: str = "convaiinnovations/laya",
106
+ device: Optional[str] = None,
107
+ token: Optional[str] = None,
108
+ subfolder: Optional[str] = None,
109
+ ):
110
+ """Load a Laya checkpoint.
111
+
112
+ `subfolder` selects one checkpoint from a repo that bundles several, e.g.
113
+ `Agent("convaiinnovations/laya", subfolder="multilingual")`. Only that subfolder is
114
+ downloaded, so bundling does not cost every user the whole family.
115
+ """
116
+ from safetensors.torch import load_file
117
+ from transformers import AutoTokenizer
118
+ try:
119
+ from transformers.initialization import no_init_weights
120
+ except ImportError: # Transformers 4.x
121
+ from transformers.modeling_utils import no_init_weights
122
+
123
+ model_dir = model_id_or_path
124
+ if not os.path.exists(model_dir):
125
+ if model_id_or_path.startswith(("/", "./", "../")) or os.path.isabs(model_id_or_path):
126
+ raise FileNotFoundError(
127
+ f"Local model path not found: {model_id_or_path!r}. "
128
+ f"Check that the directory exists and that training saved the model successfully."
129
+ )
130
+ from huggingface_hub import snapshot_download
131
+
132
+ # Restrict root checkpoints too: the default repo also contains sibling
133
+ # checkpoints, which an unfiltered snapshot would unnecessarily download.
134
+ prefix = f"{subfolder}/" if subfolder else ""
135
+ kw = {
136
+ "token": token or os.environ.get("HF_TOKEN"),
137
+ "allow_patterns": [prefix + name for name in (
138
+ "rl_agent_config.json", "model.safetensors", "tokenizer/*", "encoder/*",
139
+ )],
140
+ }
141
+ model_dir = snapshot_download(model_id_or_path, **kw)
142
+
143
+ if subfolder:
144
+ model_dir = os.path.join(model_dir, subfolder)
145
+ if not os.path.isdir(model_dir):
146
+ raise FileNotFoundError(
147
+ f"Subfolder {subfolder!r} not found in {model_id_or_path!r}."
148
+ )
149
+
150
+ _fix_tokenizer_config(model_dir)
151
+
152
+ cfg_path = os.path.join(model_dir, "rl_agent_config.json")
153
+ if not os.path.exists(cfg_path):
154
+ raise FileNotFoundError(
155
+ f"Incompatible model: {model_id_or_path!r} does not contain 'rl_agent_config.json'. "
156
+ f"Make sure you are loading a compatible RL Agent model (e.g. 'convaiinnovations/rl-agent')."
157
+ )
158
+
159
+ with open(cfg_path) as f:
160
+ self.cfg = json.load(f)
161
+
162
+ weights_path = os.path.join(model_dir, "model.safetensors")
163
+ if not os.path.exists(weights_path):
164
+ raise FileNotFoundError(
165
+ f"Incompatible model: 'model.safetensors' not found in {model_id_or_path!r}."
166
+ )
167
+
168
+ # 1. Device resolution with automatic fallback
169
+ if device is not None:
170
+ target_device = torch.device(device)
171
+ if target_device.type == "cuda" and not torch.cuda.is_available():
172
+ print("Warning: CUDA requested but not available. Falling back to CPU.")
173
+ self.device = torch.device("cpu")
174
+ elif target_device.type == "mps" and not (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()):
175
+ print("Warning: MPS requested but not available. Falling back to CPU.")
176
+ self.device = torch.device("cpu")
177
+ else:
178
+ self.device = target_device
179
+ else:
180
+ if torch.cuda.is_available():
181
+ self.device = torch.device("cuda")
182
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
183
+ self.device = torch.device("mps")
184
+ else:
185
+ self.device = torch.device("cpu")
186
+
187
+ tok_dir = os.path.join(model_dir, "tokenizer")
188
+ self.tok = AutoTokenizer.from_pretrained(tok_dir if os.path.exists(tok_dir) else self.cfg.get("encoder"))
189
+
190
+ enc_dir = os.path.join(model_dir, "encoder")
191
+ # The checkpoint supplies every parameter; skip random/base-model weights.
192
+ with no_init_weights():
193
+ self.model = build_model(self.cfg, encoder_dir=enc_dir if os.path.exists(enc_dir) else None,
194
+ pretrained=False)
195
+
196
+ # Load weights and verify architectural compatibility
197
+ weights = load_file(weights_path)
198
+ _verify_compatibility(self.model, self.cfg, weights, model_id_or_path)
199
+
200
+ self.model.load_state_dict(weights, strict=True)
201
+
202
+ # ModernBERT's reference_compile defaults to "auto" and will torch.compile the encoder.
203
+ # That is a loss for the batch sizes Laya runs (a handful of questions per call) and can
204
+ # hang on some platforms, so keep the eager path.
205
+ try:
206
+ self.model.encoder.config.reference_compile = False
207
+ except Exception:
208
+ pass
209
+
210
+ # Keep what the checkpoint shipped for inspection, but only ever apply clamped values:
211
+ # some buckets are fitted to sharpen rather than soften (see clamp_temperature).
212
+ self.temperature_raw = self.cfg.get("temperature", [1.0, 1.0, 1.0])
213
+ self.temperature_by_options_raw = self.cfg.get("temperature_by_options", {})
214
+ self.temperature = [clamp_temperature(t) for t in self.temperature_raw]
215
+ self.temperature_by_options = {k: clamp_temperature(v)
216
+ for k, v in self.temperature_by_options_raw.items()}
217
+ entries = [(k, v, self.temperature_by_options[k]) for k, v in self.temperature_by_options_raw.items()]
218
+ entries += [("temperature[%d]" % i, t, self.temperature[i]) for i, t in enumerate(self.temperature_raw)]
219
+ rejected = []
220
+ for name, raw, applied in entries:
221
+ try:
222
+ if float(raw) == applied:
223
+ continue
224
+ except (TypeError, ValueError):
225
+ # Invalid entries already have a neutral fallback; diagnostics must not
226
+ # repeat the failed conversion or prevent the checkpoint from loading.
227
+ pass
228
+ rejected.append("%s=%r -> %g" % (name, raw, applied))
229
+ if rejected:
230
+ warnings.warn(
231
+ "laya: this checkpoint ships invalid temperatures or values outside [%g, %g]; "
232
+ "using %s. Treat confidence from the affected entries as uncalibrated."
233
+ % (TEMP_MIN, TEMP_MAX, ", ".join(rejected)),
234
+ RuntimeWarning, stacklevel=2)
235
+ self.dtype = amp_dtype(self.cfg.get("amp_dtype", "fp16"))
236
+
237
+ if self.device.type == "cuda" and torch.cuda.get_device_capability(self.device)[0] < 8:
238
+ self.dtype = torch.float16
239
+ elif self.device.type in ("cpu", "mps"):
240
+ self.dtype = torch.float32
241
+
242
+ # 2. Place on device with graceful fallback to CPU on memory error
243
+ fell_back_from = fell_back_why = None
244
+ try:
245
+ self.model.to(self.device).eval()
246
+ except (RuntimeError, torch.cuda.OutOfMemoryError) as e:
247
+ if self.device.type != "cpu":
248
+ # Record what actually went wrong: the reason matters more than the symptom,
249
+ # and it is the only place the underlying exception is ever surfaced.
250
+ fell_back_from, fell_back_why = self.device, e
251
+ self.device = torch.device("cpu")
252
+ self.dtype = torch.float32
253
+ self.model.to(self.device).eval()
254
+ else:
255
+ raise e
256
+
257
+ if fell_back_from is not None:
258
+ print(
259
+ "\n[laya] Warning: could not place the model on %s, so it is running on CPU.\n"
260
+ " Reason: %s\n"
261
+ " Inference will be roughly 10-15x slower (~200-500 ms rather than ~35 ms).\n"
262
+ " If this is a newer NVIDIA GPU (Blackwell / RTX 50-series), your PyTorch build\n"
263
+ " may not support its CUDA architecture:\n"
264
+ " pip install --pre torch --index-url https://download.pytorch.org/whl/nightly/cu128\n"
265
+ " See https://pytorch.org/get-started/locally/\n"
266
+ % (fell_back_from, fell_back_why), flush=True)
267
+
268
+ @staticmethod
269
+ def _check_question(qid: str, qdef: Any) -> None:
270
+ """Reject a question that cannot be answered, naming it and what to fix.
271
+
272
+ `render_options` reads `criteria` in the shape the question's type expects and the decision
273
+ head needs at least one option, so a malformed definition used to surface from three frames
274
+ down as something that names neither the question nor the problem: `AttributeError:
275
+ 'NoneType' object has no attribute 'items'`, `KeyError: 'bool'`, or a `selected index k out
276
+ of range` raised inside the model for a question that ended up with no options at all.
277
+ """
278
+ if not isinstance(qdef, dict):
279
+ raise ValueError("question %r: definition must be a dict, got %s"
280
+ % (qid, type(qdef).__name__))
281
+ t = qdef.get("type")
282
+ if t not in QTYPES:
283
+ raise ValueError("question %r: unknown type %r; use one of %s" % (qid, t, sorted(QTYPES)))
284
+ if "instructions" not in qdef:
285
+ raise ValueError("question %r: no 'instructions'; add the text the model should answer" % (qid,))
286
+ crit = qdef.get("criteria")
287
+ if t == "choice":
288
+ if not isinstance(crit, (dict, list)):
289
+ raise ValueError("question %r: a choice question takes 'criteria' as a dict of "
290
+ "label -> description, or a list of labels" % (qid,))
291
+ if not crit:
292
+ raise ValueError("question %r: a choice question needs at least one criterion" % (qid,))
293
+ elif t == "score":
294
+ if not isinstance(crit, list):
295
+ raise ValueError("question %r: a score question takes 'criteria' as a list of level "
296
+ "descriptions, index 0 first" % (qid,))
297
+ if not crit:
298
+ raise ValueError("question %r: a score question needs at least one level" % (qid,))
299
+ elif crit is not None and not isinstance(crit, dict):
300
+ raise ValueError("question %r: a noul question takes 'criteria' as a dict with optional "
301
+ "'true'/'false' descriptions, or omits it" % (qid,))
302
+
303
+ @staticmethod
304
+ def _to_internal(qdef: Dict) -> Dict:
305
+ t = qdef["type"]
306
+ crit = qdef.get("criteria")
307
+ if t == "choice" and isinstance(crit, list):
308
+ crit = {c: None for c in crit}
309
+ elif t == "noul" and isinstance(crit, dict):
310
+ # Align upstream 0.3.7: normalise keys so render_options'
311
+ # crit.get("false")/get("true") can see boolean or mixed-case criteria.
312
+ crit = {str(k).lower(): v for k, v in crit.items()}
313
+ ins = qdef["instructions"]
314
+ if not isinstance(ins, str):
315
+ ins = json.dumps(ins)
316
+ return {"t": t, "ins": ins, "crit": crit}
317
+
318
+ @torch.no_grad()
319
+ def system_one(self, state: Union[str, dict, list], questions: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
320
+ """Evaluate typed questions across state in a single, parallel forward pass.
321
+
322
+ Args:
323
+ state: Text string, JSON dict, or conversation turn list.
324
+ questions: Dictionary mapping question_id -> question definition.
325
+ - choice: {"type": "choice", "instructions": "...", "criteria": {"optA": "...", ...}}
326
+ - score: {"type": "score", "instructions": "...", "criteria": ["lvl0", "lvl1", ...]}
327
+ - noul: {"type": "noul", "instructions": "..."}
328
+
329
+ Returns:
330
+ Dictionary with answers, probabilities, calibrated confidence, and token usage.
331
+ Empty questions return empty answers and zero token usage without tokenization
332
+ or a model forward pass.
333
+ """
334
+ ids = list(questions.keys())
335
+ if not ids:
336
+ return {
337
+ "model": "laya-rl-agent",
338
+ "answers": {},
339
+ "usage": {"input_tokens": 0, "output_tokens": 0},
340
+ }
341
+ items = []
342
+ max_len = self.cfg.get("max_len", 512)
343
+ head_max_len = self.cfg.get("head_max_len", 192)
344
+
345
+ for qid in ids:
346
+ self._check_question(qid, questions[qid])
347
+ q = self._to_internal(questions[qid])
348
+ seq, markers = build_sequence(self.tok, state, q, max_len, head_max_len)
349
+ if len(markers) != len(render_options(q)):
350
+ raise ValueError("question %r options exceed head_max_len=%d" % (qid, head_max_len))
351
+ items.append({"ids": seq, "markers": markers, "qtype": QTYPES[q["t"]]})
352
+
353
+ b = collate_items([items], self.tok.pad_token_id)
354
+ use_amp = self.device.type == "cuda"
355
+
356
+ try:
357
+ with torch.autocast(device_type=self.device.type, dtype=self.dtype, enabled=use_amp):
358
+ logits, act = self.model(
359
+ b["input_ids"].to(self.device),
360
+ b["attention_mask"].to(self.device),
361
+ b["marker_pos"].to(self.device),
362
+ b["marker_mask"].to(self.device),
363
+ b["qtype"].to(self.device),
364
+ )
365
+ except (RuntimeError, torch.cuda.OutOfMemoryError) as e:
366
+ if self.device.type != "cpu" and ("memory" in str(e).lower() or "cuda" in str(e).lower()):
367
+ print("Warning: GPU memory exceeded during inference. Falling back to CPU...")
368
+ self.device = torch.device("cpu")
369
+ self.dtype = torch.float32
370
+ self.model.to(self.device)
371
+ logits, act = self.model(
372
+ b["input_ids"].to(self.device),
373
+ b["attention_mask"].to(self.device),
374
+ b["marker_pos"].to(self.device),
375
+ b["marker_mask"].to(self.device),
376
+ b["qtype"].to(self.device),
377
+ )
378
+ else:
379
+ raise e
380
+
381
+ logits = logits.float().cpu().numpy()
382
+ act = torch.softmax(act.float(), -1).cpu().numpy()
383
+
384
+ answers = {}
385
+ n_tokens = int(b["attention_mask"].sum())
386
+
387
+ for r, qid in enumerate(ids):
388
+ q = self._to_internal(questions[qid])
389
+ k = len(items[r]["markers"])
390
+ qt = QTYPES[q["t"]]
391
+ t_scale = self.temperature_by_options.get(temp_bucket(qt, k), self.temperature[qt])
392
+ z = logits[r, :k] / t_scale
393
+ p = np.exp(z - z.max())
394
+ p = p / p.sum()
395
+
396
+ conf_score = round(confidence_from_probs(p, k), 4)
397
+ ext = {"act_probability": round(float(act[r, 0]), 4)}
398
+
399
+ if q["t"] == "choice":
400
+ keys = list(q["crit"].keys())
401
+ answers[qid] = {
402
+ "type": "choice",
403
+ "choice": keys[int(p.argmax())],
404
+ "probabilities": {kk: round(float(v), 4) for kk, v in zip(keys, p)},
405
+ "confidence": conf_score,
406
+ "action": ext,
407
+ }
408
+ elif q["t"] == "score":
409
+ exp_score = float((np.arange(k) * p).sum())
410
+ answers[qid] = {
411
+ "type": "score",
412
+ "score": round(exp_score, 4),
413
+ "legend": {str(i): c for i, c in enumerate(q["crit"])},
414
+ "probabilities": {str(i): round(float(v), 4) for i, v in enumerate(p)},
415
+ "confidence": conf_score,
416
+ "action": ext,
417
+ }
418
+ else:
419
+ answers[qid] = {
420
+ "type": "noul",
421
+ "noul": round(float(p[1]), 4),
422
+ "confidence": round(max(float(p[1]), 1.0 - float(p[1])), 4),
423
+ "action": ext,
424
+ }
425
+
426
+ return {
427
+ "model": "laya-rl-agent",
428
+ "answers": answers,
429
+ "usage": {"input_tokens": n_tokens, "output_tokens": 0},
430
+ }
431
+
432
+ predict = system_one
433
+
434
+
435
+ RLAgent = Agent
436
+
437
+
438
+ def load(model_id_or_path: str = "convaiinnovations/laya", device: Optional[str] = None,
439
+ token: Optional[str] = None, subfolder: Optional[str] = None) -> Agent:
440
+ """Load a Laya agent.
441
+
442
+ `subfolder` picks one checkpoint out of a repo that bundles several:
443
+
444
+ laya.load("convaiinnovations/laya") # English (repo root)
445
+ laya.load("convaiinnovations/laya", subfolder="multilingual")
446
+ """
447
+ return Agent(model_id_or_path, device=device, token=token, subfolder=subfolder)