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,447 @@
1
+ """Route a request to the Laya checkpoint best suited to it.
2
+
3
+ Three checkpoints, measured on a shared benchmark (17,416 questions, one T4, identical questions
4
+ per model -- see the repository's benchmark notebook):
5
+
6
+ english convaiinnovations/laya 421M ModernBERT-large, 512 tokens
7
+ multilingual convaiinnovations/laya-multilingual 322M mmBERT-base, 1024 tokens, 100+ langs
8
+ typed-decisions convaiinnovations/laya-typed-decisions 421M ModernBERT-large, 1024 tokens,
9
+ fine-tuned on the typed-decisions
10
+ workflows
11
+
12
+ Why routing is worth it -- accuracy by language family:
13
+
14
+ english multilingual
15
+ MASSIVE intent en 0.783 0.657 <- English checkpoint wins
16
+ MASSIVE intent non-en 0.306 0.451
17
+ XNLI en 0.860 0.843
18
+ XNLI non-en 0.521 0.731 <- +21 points for multilingual
19
+ English suites 0.684 0.619
20
+
21
+ The English checkpoint does not gently degrade off English, it collapses: on 20-option MASSIVE
22
+ intent it scores 0.100 on Hindi and 0.103 on Korean, against 0.050 for random guessing -- and it
23
+ reports high confidence while doing so (ECE 0.855 on Hindi). Script detection is therefore the
24
+ primary routing signal.
25
+
26
+ `typed-decisions` is never selected automatically unless you opt in with
27
+ `auto_task_detection=True` or pass `task="typed_decisions"`: it is fine-tuned on four specific
28
+ synthetic workflows and should not be a silent default.
29
+ """
30
+ import gc
31
+ import os
32
+ import threading
33
+ from typing import Any, Dict, List, Optional, Union
34
+
35
+ from .lang import analyse
36
+
37
+ # The hub repo bundles all three checkpoints; only the requested subfolder is downloaded.
38
+ BUNDLE_REPO = "convaiinnovations/laya"
39
+ DEFAULT_MODELS = {
40
+ "english": (BUNDLE_REPO, None),
41
+ "multilingual": (BUNDLE_REPO, "multilingual"),
42
+ "typed-decisions": (BUNDLE_REPO, "typed-decisions"),
43
+ }
44
+
45
+ # The same checkpoints also live in their own repos, for anyone who prefers them.
46
+ STANDALONE_MODELS = {
47
+ "english": "convaiinnovations/laya",
48
+ "multilingual": "convaiinnovations/laya-multilingual",
49
+ "typed-decisions": "convaiinnovations/laya-typed-decisions",
50
+ }
51
+
52
+
53
+ def _repo_str(spec):
54
+ """Human-readable id for a model spec: 'repo' or 'repo/subfolder'."""
55
+ repo, sub = _split(spec)
56
+ return "%s/%s" % (repo, sub) if sub else repo
57
+
58
+
59
+ def _split(spec):
60
+ """Normalise a model spec to (repo_or_path, subfolder)."""
61
+ if isinstance(spec, (tuple, list)):
62
+ repo, sub = (list(spec) + [None])[:2]
63
+ return repo, sub
64
+ return spec, None
65
+
66
+ # Aliases people are likely to type.
67
+ _ALIASES = {
68
+ "en": "english", "laya": "english", "default": "english",
69
+ "multi": "multilingual", "ml": "multilingual", "laya-multilingual": "multilingual",
70
+ "typed": "typed-decisions", "typed_decisions": "typed-decisions",
71
+ "laya-typed-decisions": "typed-decisions", "decisions": "typed-decisions",
72
+ }
73
+
74
+ # Question-id signatures of the four typed-decisions workflows, used only when
75
+ # auto_task_detection is enabled.
76
+ _TYPED_DECISION_WORKFLOWS = {
77
+ "agent_trace_observability": {"action", "needs_review", "outcome", "risk", "urgency"},
78
+ "customer_service": {"action", "category", "churn_risk", "needs_human", "urgency"},
79
+ "invoice_processing": {"discrepancy_severity", "disposition", "duplicate", "matches_order", "urgency"},
80
+ "security_incidents": {"credential_compromise", "disposition", "severity", "true_positive", "urgency"},
81
+ }
82
+
83
+
84
+ class RouteDecision(dict):
85
+ """The routing outcome: which model, why, and what was detected.
86
+
87
+ Behaves as a dict so it serialises straight into an API response.
88
+ """
89
+
90
+ @property
91
+ def model(self) -> str:
92
+ return self["model"]
93
+
94
+ @property
95
+ def reason(self) -> str:
96
+ return self["reason"]
97
+
98
+ def __repr__(self):
99
+ return "RouteDecision(model=%r, reason=%r)" % (self["model"], self["reason"])
100
+
101
+
102
+ def normalise_name(name: str) -> str:
103
+ key = str(name).strip().lower()
104
+ key = _ALIASES.get(key, key)
105
+ if key not in DEFAULT_MODELS:
106
+ raise ValueError("unknown model %r; choose one of %s (or an alias: %s)"
107
+ % (name, sorted(DEFAULT_MODELS), sorted(_ALIASES)))
108
+ return key
109
+
110
+
111
+ def match_typed_decisions_workflow(questions: Dict[str, Any]) -> Optional[str]:
112
+ """Name of the typed-decisions workflow whose question ids these are, else None.
113
+
114
+ Requires an exact id-set match, so an unrelated schema that happens to contain 'urgency'
115
+ is never captured.
116
+ """
117
+ ids = set(questions or {})
118
+ for wf, sig in _TYPED_DECISION_WORKFLOWS.items():
119
+ if ids == sig:
120
+ return wf
121
+ return None
122
+
123
+
124
+ # Subtags that mean "the English checkpoint can read this". Routing needs one bit -- is this
125
+ # English Latin text, or something the English checkpoint cannot read -- not a language id, so
126
+ # every other code resolves to the multilingual checkpoint.
127
+ _ENGLISH_SUBTAGS = ("en", "eng", "english")
128
+
129
+
130
+ def _english_from_code(value: Any) -> Optional[bool]:
131
+ """True/False for a language code, or None when the code identifies nothing.
132
+
133
+ Accepts the forms a caller is likely to have to hand: `"en"`, `"EN"`, `"en-US"`, the
134
+ POSIX `"en_US"` (which `$LANG` holds), and `"en_US.UTF-8"`. `None` here means "no usable
135
+ hint", which is what lets a language-identification model abstain.
136
+ """
137
+ if value is None:
138
+ return None
139
+ code = str(value).strip().lower()
140
+ if not code:
141
+ return None
142
+ code = code.split(".", 1)[0] # en_US.UTF-8 -> en_US
143
+ primary = code.replace("_", "-").split("-", 1)[0] # en_US -> en
144
+ if not primary:
145
+ return None
146
+ return primary in _ENGLISH_SUBTAGS
147
+
148
+
149
+ class Router:
150
+ """Lazily loads Laya checkpoints and sends each request to the right one.
151
+
152
+ from laya import Router
153
+
154
+ r = Router()
155
+ r.predict({"message": "Mein Konto wurde zweimal belastet"}, questions) # -> multilingual
156
+ r.predict({"message": "I was charged twice"}, questions) # -> english
157
+ r.predict(state, questions, model="typed-decisions") # explicit
158
+
159
+ Models are downloaded and built on first use. `max_loaded` caps how many stay resident
160
+ (least-recently-used is evicted), because all three together are ~1.16B parameters.
161
+
162
+ The default is 2, because automatic routing only ever chooses between `english` and
163
+ `multilingual`: a cap of one rebuilds the checkpoint it just evicted on every script switch,
164
+ which is seconds per request on exactly the traffic the Router exists for. Traffic that only
165
+ ever sees one language never builds the second checkpoint, so the default costs it nothing.
166
+ Lower it to 1 for a memory-constrained host, and raise it to 3 (or preload) when
167
+ `auto_task_detection`, an explicit `model=` or an explicit `task=` can reach
168
+ `typed-decisions` as well.
169
+
170
+ For a server or a demo, preload instead: a cold load costs seconds, while detection costs
171
+ microseconds, so even the default still pays a load the first time a language appears.
172
+
173
+ r = Router(preload=True) # all three resident, routing is free
174
+ r = Router(preload=True, device="cuda")
175
+ r.preload(["english", "multilingual"]) # or just the two you serve
176
+ """
177
+
178
+ def __init__(
179
+ self,
180
+ models: Optional[Dict[str, str]] = None,
181
+ device: Optional[str] = None,
182
+ token: Optional[str] = None,
183
+ max_loaded: int = 2,
184
+ default: str = "english",
185
+ auto_task_detection: bool = False,
186
+ standalone_repos: bool = False,
187
+ preload: bool = False,
188
+ lang_guess: Optional[Any] = None,
189
+ ):
190
+ self.models = dict(STANDALONE_MODELS if standalone_repos else DEFAULT_MODELS)
191
+ if models:
192
+ self.models.update({normalise_name(k): v for k, v in models.items()})
193
+ self.device = device
194
+ self.token = token or os.environ.get("HF_TOKEN")
195
+ self.max_loaded = max(1, int(max_loaded))
196
+ self.default = normalise_name(default)
197
+ self.auto_task_detection = bool(auto_task_detection)
198
+ # An opt-in language hint installed for every request: a code, or a callable taking the
199
+ # state and returning one (or None to abstain). Checked before the built-in detection,
200
+ # never before an explicit `model`, `task` or `lang`. The default path is unchanged, so
201
+ # the heuristic stays dependency-free; this is the seam for a real LID model.
202
+ self.lang_guess = lang_guess
203
+ self._agents: Dict[str, Any] = {}
204
+ self._order: List[str] = [] # least-recently-used first
205
+ # Re-entrant lock guarding model lifecycle (load/unload/attach/preload) and the
206
+ # LRU bookkeeping. RLock so the public methods can call the private `_touch`/`_evict`
207
+ # helpers without deadlocking. Inference (`Agent.system_one`) is deliberately left
208
+ # outside the lock so concurrent predictions share a checkpoint without serialising.
209
+ self._lock = threading.RLock()
210
+ if preload:
211
+ self.preload()
212
+
213
+ # ------------------------------------------------------------------ loading
214
+ def load(self, name: str):
215
+ """Return the Agent for `name`, downloading and building it on first use.
216
+
217
+ Concurrent callers share a single Agent instead of building duplicates.
218
+ """
219
+ key = normalise_name(name)
220
+ with self._lock:
221
+ if key in self._agents:
222
+ self._touch(key)
223
+ return self._agents[key]
224
+ from .agent import Agent
225
+ repo, sub = _split(self.models[key])
226
+ agent = Agent(repo, device=self.device, token=self.token, subfolder=sub)
227
+ self._agents[key] = agent
228
+ self._order.append(key)
229
+ self._evict()
230
+ return agent
231
+
232
+ def _touch(self, key: str):
233
+ with self._lock:
234
+ if key in self._order:
235
+ self._order.remove(key)
236
+ self._order.append(key)
237
+
238
+ def _evict(self):
239
+ with self._lock:
240
+ evicted = False
241
+ while len(self._order) > self.max_loaded:
242
+ victim = self._order.pop(0)
243
+ agent = self._agents.pop(victim, None)
244
+ if agent is not None:
245
+ evicted = True
246
+ del agent
247
+ if len(self._order) < len(self._agents): # keep the two views consistent
248
+ for k in list(self._agents):
249
+ if k not in self._order:
250
+ agent = self._agents.pop(k, None)
251
+ if agent is not None:
252
+ evicted = True
253
+ del agent
254
+ if evicted:
255
+ gc.collect()
256
+ try:
257
+ import torch
258
+ if torch.cuda.is_available():
259
+ torch.cuda.empty_cache()
260
+ except Exception:
261
+ pass
262
+
263
+ def attach(self, name: str, agent: Any):
264
+ """Register an already-built Agent under `name` instead of loading a second copy.
265
+
266
+ Useful when the process has a checkpoint loaded for other reasons: a demo that already
267
+ built `convaiinnovations/laya` can hand it to the router rather than pay for -- and hold
268
+ in memory -- a duplicate 421M parameters.
269
+ """
270
+ key = normalise_name(name)
271
+ with self._lock:
272
+ self._agents[key] = agent
273
+ self._touch(key)
274
+ self.max_loaded = max(self.max_loaded, len(self._agents))
275
+ return agent
276
+
277
+ def preload(self, names: Optional[List[str]] = None):
278
+ """Download and build checkpoints up front so no request ever pays a model load.
279
+
280
+ A cold load costs seconds; language detection costs microseconds. With every
281
+ checkpoint resident, routing is effectively free -- which is what you want in a
282
+ server or a demo. `max_loaded` is raised to fit both the requested checkpoints and
283
+ all already-resident agents, so incremental preloading does not evict either.
284
+ """
285
+ names = [normalise_name(n) for n in (names or list(self.models))]
286
+ with self._lock:
287
+ self.max_loaded = max(self.max_loaded, len(set(names) | set(self._agents)))
288
+ for n in names:
289
+ if n not in self._agents: # an attached agent is already built
290
+ self.load(n)
291
+ return self
292
+
293
+ def unload(self, name: Optional[str] = None):
294
+ """Free one model, or all of them."""
295
+ with self._lock:
296
+ if name is None:
297
+ self._agents.clear()
298
+ self._order.clear()
299
+ else:
300
+ key = normalise_name(name)
301
+ agent = self._agents.pop(key, None)
302
+ if key in self._order:
303
+ self._order.remove(key)
304
+ del agent
305
+ gc.collect()
306
+ try:
307
+ import torch
308
+ if torch.cuda.is_available():
309
+ torch.cuda.empty_cache()
310
+ except Exception:
311
+ pass
312
+
313
+ @property
314
+ def loaded(self) -> List[str]:
315
+ with self._lock:
316
+ return list(self._order)
317
+
318
+ def _resolve_hint(self, hint: Any, state: Union[str, dict, list, None]) -> Optional[bool]:
319
+ """True/False for a hint about whether the English checkpoint can read `state`.
320
+
321
+ `hint` is either a language code or a callable taking the state. Anything the hint
322
+ cannot answer returns None, which makes `route` fall through to detection rather than
323
+ picking a checkpoint on no evidence.
324
+ """
325
+ if hint is None:
326
+ return None
327
+ if callable(hint):
328
+ hint = hint(state)
329
+ return _english_from_code(hint)
330
+
331
+ # ------------------------------------------------------------------ routing
332
+ def route(
333
+ self,
334
+ state: Union[str, dict, list, None],
335
+ questions: Optional[Dict[str, Any]] = None,
336
+ model: Optional[str] = None,
337
+ task: Optional[str] = None,
338
+ lang: Optional[str] = None,
339
+ lang_guess: Optional[Any] = None,
340
+ ) -> RouteDecision:
341
+ """Decide which checkpoint to use, without loading or running anything.
342
+
343
+ Precedence: explicit `model` > explicit `task` > detected workflow (opt-in) >
344
+ explicit `lang` > `lang_guess` > detected script/language > default.
345
+
346
+ `lang_guess` is an opt-in hint -- a language code or a callable taking the state --
347
+ checked after an explicit `lang` and before the built-in detection. It only answers
348
+ "can the English checkpoint read this?", so any non-English code routes to the
349
+ multilingual checkpoint. A hint that resolves to nothing falls through to detection,
350
+ which lets a language-identification model abstain. Pass one here, or set
351
+ `Router(lang_guess=...)` to apply it to every request.
352
+ """
353
+ if model is not None:
354
+ key = normalise_name(model)
355
+ return RouteDecision(model=key, repo=_repo_str(self.models[key]), reason="explicit model=%r" % model,
356
+ detection=None, workflow=None)
357
+
358
+ if task is not None:
359
+ key = normalise_name("typed-decisions" if str(task).lower().replace("-", "_") == "typed_decisions" else task)
360
+ return RouteDecision(model=key, repo=_repo_str(self.models[key]), reason="explicit task=%r" % task,
361
+ detection=None, workflow=None)
362
+
363
+ workflow = match_typed_decisions_workflow(questions or {})
364
+ if workflow and self.auto_task_detection:
365
+ return RouteDecision(model="typed-decisions", repo=_repo_str(self.models["typed-decisions"]),
366
+ reason="question ids match the %r typed-decisions workflow" % workflow,
367
+ detection=None, workflow=workflow)
368
+
369
+ if lang is not None:
370
+ key = "english" if _english_from_code(lang) else "multilingual"
371
+ return RouteDecision(model=key, repo=_repo_str(self.models[key]), reason="explicit lang=%r" % lang,
372
+ detection=None, workflow=workflow)
373
+
374
+ # Caller-supplied hint, per-call first then the one installed on the Router. Only a hint
375
+ # that actually answers the question routes here; anything else falls through.
376
+ for source, hint in (("lang_guess", lang_guess), ("Router(lang_guess=...)", self.lang_guess)):
377
+ resolved = self._resolve_hint(hint, state)
378
+ if resolved is not None:
379
+ key = "english" if resolved else "multilingual"
380
+ return RouteDecision(
381
+ model=key, repo=_repo_str(self.models[key]),
382
+ reason="%s: the caller identified this as %s text" % (
383
+ source, "English" if resolved else "non-English"),
384
+ detection=None, workflow=workflow)
385
+
386
+ det = analyse(state)
387
+ if det["script"] == "unknown":
388
+ key = self.default
389
+ reason = "no letters detected in state; using default (%s)" % key
390
+ elif det["script"] != "latin":
391
+ key = "multilingual"
392
+ reason = "non-Latin script (%s, %.0f%% of letters); the English checkpoint cannot read it" % (
393
+ det["script"], 100 * float(det["non_latin_fraction"]))
394
+ elif not det["is_english"]:
395
+ key = "multilingual"
396
+ if det["language"]:
397
+ reason = "Latin script but language looks like %r, not English" % det["language"]
398
+ else:
399
+ # Unidentified Latin-script language: routed on the non-English letters alone,
400
+ # because no stopword list here covers it.
401
+ reason = ("Latin script, language not identified but %.0f%% non-English letters; "
402
+ "not safe for the English checkpoint" % (100 * float(det["diacritic_rate"])))
403
+ elif det["language_undecided"]:
404
+ # Nothing identifies the language: too short, or only content words ("Quero cancelar",
405
+ # "Esqueci minha senha"). That is no evidence of English either, so it takes the same
406
+ # `default` as a state with no letters. A deployment that serves mostly non-English
407
+ # traffic sets `Router(default="multilingual")`; the stock default keeps it English.
408
+ key = self.default
409
+ reason = ("Latin script, language not identified and no non-English letters; "
410
+ "using default (%s)" % key)
411
+ else:
412
+ key = "english"
413
+ reason = "English Latin text"
414
+ return RouteDecision(model=key, repo=_repo_str(self.models[key]), reason=reason,
415
+ detection=det, workflow=workflow)
416
+
417
+ # ------------------------------------------------------------------ running
418
+ def predict(
419
+ self,
420
+ state: Union[str, dict, list],
421
+ questions: Dict[str, Any],
422
+ model: Optional[str] = None,
423
+ task: Optional[str] = None,
424
+ lang: Optional[str] = None,
425
+ lang_guess: Optional[Any] = None,
426
+ ) -> Dict[str, Any]:
427
+ """Route, then answer every question in one forward pass on the chosen checkpoint.
428
+
429
+ The result is the usual `system_one` payload plus a `routing` key recording the decision.
430
+ """
431
+ decision = self.route(state, questions, model=model, task=task, lang=lang, lang_guess=lang_guess)
432
+ agent = self.load(decision["model"])
433
+ result = agent.system_one(state, questions)
434
+ result["routing"] = dict(decision)
435
+ return result
436
+
437
+ def __enter__(self):
438
+ return self
439
+
440
+ def __exit__(self, exc_type, exc_val, exc_tb):
441
+ self.unload()
442
+ return False
443
+
444
+ system_one = predict
445
+
446
+ def __repr__(self):
447
+ return "Router(loaded=%s, max_loaded=%d, default=%r)" % (self.loaded, self.max_loaded, self.default)