dkappe commited on
Commit
eb95f65
·
verified ·
1 Parent(s): f19b470

IDU 0.2.0: add decider-4b as a second backend

Browse files
app.py CHANGED
@@ -13,24 +13,29 @@ WHAT IT DOES
13
  score -> position on an ordered rubric (can land BETWEEN levels), distribution
14
  noul -> one number: the probability the statement is true
15
 
16
- BACKEND: convaiinnovations/laya (Apache 2.0)
17
- A 421M ModernBERT-large encoder with a decision head, trained by RL against strictly
18
- proper scoring rules. Chosen over the alternatives for three concrete reasons:
19
- * It IS the Jev shape -- `state` + typed questions in, typed decisions out. No
20
- adapter layer, no tool-calling workaround, no triggers.
21
- * Every option is scored at its own [MASK] token, then softmaxed over that
22
- question's options. Options are judged independently, which is what Jev
23
- documents ("each level is judged on its own against the state") and what makes
24
- the polarity problem in a tool-calling model disappear: a noul question is just
25
- two markers, not a competition between two armed tools.
26
- * All questions in one call are answered in ONE forward pass, and it is PyTorch,
27
- so it fits the ZeroGPU runtime natively.
28
-
29
- Honest status of the base checkpoints, from the model's own card: they are near
30
- chance zero-shot on the typed-decisions benchmark (0.362 against a 0.318 random and
31
- 0.461 majority-class baseline). The published 0.766 belongs to a checkpoint tuned on
32
- that benchmark's own training split. Laya is a fast base to SPECIALISE. Treat the
33
- numbers this Space reports as a baseline, not a capability ceiling.
 
 
 
 
 
34
 
35
  IMPORT ORDER IS LOAD-BEARING, NOT STYLE
36
  `spaces` MUST be imported before torch. The spaces library patches torch's CUDA
@@ -72,7 +77,8 @@ print(f"import order: spaces before torch = {'torch' not in sys.modules}", flush
72
  import gradio as gr # noqa: E402 (pulls torch)
73
 
74
  MODEL_ID = "convaiinnovations/laya"
75
- SPACE_REVISION = "0.1.0"
 
76
 
77
  _AGENT: dict[str, Any] = {}
78
 
@@ -182,6 +188,154 @@ def warmup() -> dict:
182
  "warm_choice": out["answers"]["department"]["choice"]}
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  def _normalise_questions(questions: dict) -> dict:
186
  """Accept Jev's shape directly; tolerate a couple of harmless variants.
187
 
@@ -232,12 +386,41 @@ def _apply_temperature(a, t_choice=None, t_score=None, t_noul=None) -> list:
232
 
233
 
234
  @GPU
235
- def answer_systemone(state: Any, questions: dict, temperature: list | None = None) -> dict:
236
- """The single decorated entry point.
 
237
 
238
  Everything shares one GPU-decorated function: the start-up warm-up then covers all
239
  callers instead of each one paying its own first-call GPU attach.
 
 
 
 
 
240
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  a = get_agent()
242
  # Move onto the GPU HERE, inside the decorated call, where ZeroGPU has actually
243
  # attached one. Doing this at module scope cannot work: no device exists then.
@@ -245,7 +428,6 @@ def answer_systemone(state: Any, questions: dict, temperature: list | None = Non
245
  if temperature:
246
  _apply_temperature(a, *temperature)
247
  qs = _normalise_questions(questions)
248
- t0 = time.perf_counter()
249
  res = a.predict(state, qs)
250
  res["_idu"] = {
251
  "space": f"dkappe/IDU {SPACE_REVISION}",
@@ -261,10 +443,10 @@ def answer_systemone(state: Any, questions: dict, temperature: list | None = Non
261
  return res
262
 
263
 
264
- def _safe_answer(state, questions, temperature=None):
265
  """Wrap the GPU call so failures show in the UI instead of killing the app."""
266
  try:
267
- return answer_systemone(state, questions, temperature)
268
  except Exception as e: # pragma: no cover
269
  traceback.print_exc()
270
  return {"error": f"{type(e).__name__}: {e}",
@@ -404,11 +586,17 @@ def build_ui():
404
  t2 = gr.Number(value=0, label="temp: score (0 = fitted)")
405
  t3 = gr.Number(value=0, label="temp: noul (0 = fitted)")
406
  p_go = gr.Button("Ask", variant="primary")
 
 
 
 
 
 
407
  p_rows = gr.Dataframe(headers=["question", "answer", "confidence"],
408
  col_count=(3, "fixed"), label="answers", wrap=True)
409
  p_raw = gr.Code(label="raw response", language="json")
410
 
411
- def run_playground(state_text, q_text, tc, ts, tn):
412
  # Both boxes are gr.Code, but Gradio may hand us either a JSON string or an
413
  # already-parsed object depending on version and input source. Handle both;
414
  # the previous version only tried json.loads and blew up on a dict.
@@ -430,10 +618,10 @@ def build_ui():
430
  qs = q_text
431
  if not isinstance(qs, dict) or not qs:
432
  return [], "questions must be a non-empty JSON object"
433
- r = _safe_answer(state, qs, [tc, ts, tn])
434
  return as_rows(r) if "answers" in r else [], json.dumps(r, indent=2)
435
 
436
- p_go.click(run_playground, [p_state, p_q, t1, t2, t3],
437
  [p_rows, p_raw], api_name="systemone")
438
 
439
  with gr.Tab("Compare vs Needle 3"):
@@ -522,7 +710,7 @@ def compare_with_needle():
522
 
523
 
524
  def health():
525
- """Report the truth about where the model actually ran.
526
 
527
  DELIBERATELY DOES NOT ASSERT ok=... ON THE WEB-PROCESS CACHE.
528
  An earlier version returned ok=(_AGENT["a"] is not None). On ZeroGPU that is a lie:
@@ -534,38 +722,80 @@ def health():
534
  bookkeeping, and `model_in_web_process` is reported separately for diagnosis.
535
  """
536
  warm = _AGENT.get("warmup") or {}
 
537
  a = _AGENT.get("a")
 
538
  return {"ok": bool(warm.get("warm_choice")),
539
  "space": f"dkappe/IDU {SPACE_REVISION}",
540
- "backend": MODEL_ID,
541
  "last_warmup": warm,
542
  "warmup_device": warm.get("device"),
 
 
 
543
  "zero_gpu": ZERO,
544
  "device_mode_env": DEVICE_MODE,
545
  # diagnostic only -- False on ZeroGPU is expected, not a fault
546
  "model_in_web_process": a is not None,
 
547
  "temperature_in_worker": _current_temperature(a) if a else None,
548
  "import_order_ok": "torch" in sys.modules,
549
  "note": ("ok reflects the last warm-up, because on ZeroGPU the model lives in "
550
  "the GPU worker and model_in_web_process will read False by design.")}
551
 
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  demo = build_ui() # safe here: every handler it references is defined above
554
 
555
 
556
- if __name__ == "__main__":
557
- # Build and warm the model BEFORE the UI is reachable. Gradio runs this file as
558
- # __main__, so a warm-up placed in an `else:` branch would never execute. Laya's own
559
- # demo does the same thing for the same reason.
 
 
 
560
  try:
561
  _AGENT["warmup"] = warmup()
562
- print(f"[startup] warm: {_AGENT['warmup']}", flush=True)
563
  except Exception as _e: # pragma: no cover
564
  print(f"[startup] warmup failed: {type(_e).__name__}: {_e}", flush=True)
565
- demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=7860)
566
- else:
567
  try:
568
- _AGENT["warmup"] = warmup()
569
- print(f"[startup] warm: {_AGENT['warmup']}", flush=True)
570
  except Exception as _e: # pragma: no cover
571
- print(f"[startup] warmup failed: {type(_e).__name__}: {_e}", flush=True)
 
 
 
 
 
 
 
 
13
  score -> position on an ordered rubric (can land BETWEEN levels), distribution
14
  noul -> one number: the probability the statement is true
15
 
16
+ BACKENDS (two, same request shape)
17
+ laya convaiinnovations/laya (Apache 2.0). 421M ModernBERT-large encoder with a
18
+ decision head, trained by RL against strictly proper scoring rules. Chosen for
19
+ three concrete reasons:
20
+ * It IS the Jev shape -- `state` + typed questions in, typed decisions out.
21
+ No adapter layer, no tool-calling workaround, no triggers.
22
+ * Every option is scored at its own [MASK] token, then softmaxed over that
23
+ question's options, so options are judged independently. That is what Jev
24
+ documents ("each level is judged on its own against the state") and it makes
25
+ the polarity problem in a tool-calling model disappear: a noul question is
26
+ just two markers, not two competing armed tools.
27
+ * All questions in one call are answered in ONE forward pass, and it is
28
+ PyTorch, so it fits the ZeroGPU runtime natively.
29
+ decider Mapika/decider-4b (Apache 2.0). 4B dense on Qwen3.5-4B-Base, one pass, no
30
+ decoding. Added because it takes the SAME TypeSafe request shape -- the authors
31
+ ship `systemone.py` implementing /v1/systemone -- so the whole adapter is the
32
+ backend branch below, and because it has no small option-count cap (255 options,
33
+ 32k state tokens) where other open decision models stop at 16 options. It also
34
+ needs transformers>=5.17.0, which is why this Space requires that floor.
35
+
36
+ Both backends are selectable per call. They are NOT comparable for latency: both run on
37
+ the same ZeroGPU pool, so wall time measures queueing as much as compute. Accuracy is the
38
+ comparison; timing is not.
39
 
40
  IMPORT ORDER IS LOAD-BEARING, NOT STYLE
41
  `spaces` MUST be imported before torch. The spaces library patches torch's CUDA
 
77
  import gradio as gr # noqa: E402 (pulls torch)
78
 
79
  MODEL_ID = "convaiinnovations/laya"
80
+ DECIDER_ID = "Mapika/decider-4b"
81
+ SPACE_REVISION = "0.2.0"
82
 
83
  _AGENT: dict[str, Any] = {}
84
 
 
188
  "warm_choice": out["answers"]["department"]["choice"]}
189
 
190
 
191
+ # ------------------------------------------------------------------ decider backend
192
+ # Mapika/decider-4b. The authors' own inference package is vendored verbatim in
193
+ # `decider/` from the model repository (Apache 2.0), so the scoring path -- prompt layout,
194
+ # per-question row isolation, isolated Score levels, restricting the answer logits to the
195
+ # option-label tokens, the -inf masking of unused slots -- is theirs, not a reimplementation.
196
+ #
197
+ # Two deviations, both copied from the official decider demo Space:
198
+ # * the checkpoint is built at module scope, on CPU, and moved onto the GPU from inside a
199
+ # decorated call. Same reason as Laya: no CUDA device exists at import time, so building
200
+ # on "cuda" at startup binds the model to CPU and then raises from torch._C._cuda_init()
201
+ # inside the call.
202
+ # * temperature is passed per answer type instead of being instance state, so concurrent
203
+ # requests in one worker cannot race each other.
204
+
205
+ DECIDER_CFG: dict[str, Any] = {}
206
+ DECIDER_MODEL = None
207
+ DECIDER_TOK = None
208
+ DECIDER_T: dict[str, float] = {}
209
+
210
+
211
+ def get_decider():
212
+ """Build the decider model once, at module scope, ON CPU."""
213
+ global DECIDER_MODEL, DECIDER_TOK, DECIDER_CFG, DECIDER_T
214
+ if DECIDER_MODEL is not None:
215
+ return DECIDER_MODEL
216
+ import torch
217
+ from huggingface_hub import hf_hub_download
218
+ from decider.model import DecisionModel
219
+
220
+ try:
221
+ DECIDER_CFG = json.load(open(hf_hub_download(DECIDER_ID, "decider_config.json")))
222
+ except Exception as e: # pragma: no cover
223
+ print(f"decider: no decider_config.json ({type(e).__name__}); defaults in force",
224
+ flush=True)
225
+ DECIDER_CFG = {}
226
+ base = float(DECIDER_CFG.get("temperature", 1.0))
227
+ DECIDER_T = {k: float(v) for k, v in (DECIDER_CFG.get("temperature_by_type") or {}).items()}
228
+ for k in ("choice", "noul", "score"):
229
+ DECIDER_T.setdefault(k, base)
230
+ DECIDER_MODEL = DecisionModel(DECIDER_ID, dtype=torch.bfloat16, grad_ckpt=False).cpu().eval()
231
+ DECIDER_TOK = DECIDER_MODEL.tok
232
+ return DECIDER_MODEL
233
+
234
+
235
+ class _KeepOrder:
236
+ """decider's rng stand-in: keep the caller's option order, never shuffle."""
237
+
238
+ def shuffle(self, x):
239
+ pass
240
+
241
+ def sample(self, xs, k):
242
+ return xs[:k]
243
+
244
+
245
+ def _decider_temps(t_choice=None, t_score=None, t_noul=None) -> dict:
246
+ """Per-answer-type temperatures. 0 / None means 'keep the checkpoint's fitted value',
247
+ the same sentinel Laya uses, so the two backends take the same three numbers.
248
+
249
+ The per-type map matters: decider_config.json ships {choice: 1.11, noul: 1.56,
250
+ score: 1.287}, and serving every answer at the single 1.099 flattens the yes/no and
251
+ Score probabilities."""
252
+ t = dict(DECIDER_T)
253
+ for key, v in (("choice", t_choice), ("score", t_score), ("noul", t_noul)):
254
+ if v is None:
255
+ continue
256
+ v = float(v)
257
+ if v > 0:
258
+ t[key] = v
259
+ return t
260
+
261
+
262
+ def _decider_move_to_gpu() -> str:
263
+ """Move the decider model onto CUDA from inside a decorated call. Same contract as the
264
+ Laya path: degrade to CPU rather than raise, so an unusable GPU is slow, not fatal."""
265
+ global DECIDER_MODEL
266
+ if DECIDER_MODEL is None:
267
+ return "unloaded"
268
+ import torch
269
+ if not ZERO:
270
+ return str(next(DECIDER_MODEL.parameters()).device)
271
+ try:
272
+ if not torch.cuda.is_available():
273
+ print("decider/ZeroGPU: no CUDA inside the call; staying on CPU", flush=True)
274
+ return str(next(DECIDER_MODEL.parameters()).device)
275
+ if next(DECIDER_MODEL.parameters()).device.type != "cuda":
276
+ DECIDER_MODEL = DECIDER_MODEL.to("cuda")
277
+ print("decider/ZeroGPU: model on cuda", flush=True)
278
+ except Exception as e: # pragma: no cover
279
+ print(f"decider/ZeroGPU: move failed ({type(e).__name__}: {e}); CPU", flush=True)
280
+ return str(next(DECIDER_MODEL.parameters()).device)
281
+
282
+
283
+ def _decider_systemone(state: Any, questions: dict, temps: dict) -> dict:
284
+ """decider.infer.Decider.system_one's eager path, with an explicit per-type temperature.
285
+
286
+ Every question gets its own row, so adding or removing a question cannot change another
287
+ answer. The packed single-row path is not used because the temperature is applied per
288
+ slot and the rows carry different answer types.
289
+ """
290
+ import torch
291
+ from decider.infer import Example, Q, neutralize_options
292
+ from decider.model import collate
293
+ from decider.prompt import MAX_OPTIONS, build
294
+ from decider.systemone import (assemble, plan_rows, render_question,
295
+ render_state, row_types)
296
+
297
+ ctx = render_state(state)
298
+ rqs = {k: render_question(v) for k, v in questions.items()}
299
+ neutralize = bool(DECIDER_CFG.get("neutralize_none", True))
300
+ opts = ((lambda r: neutralize_options(r["options"])[0]) if neutralize
301
+ else (lambda r: list(r["options"])))
302
+ isolated = bool(DECIDER_CFG.get("isolated_levels", False))
303
+ max_state = int(DECIDER_CFG.get("max_state_tokens", 32768))
304
+ flat, index = plan_rows(rqs, isolated)
305
+ items = [
306
+ build(Example(ctx, [Q(r["question"], opts(r), 0)]), DECIDER_TOK, _KeepOrder(),
307
+ max_options=MAX_OPTIONS, max_ctx_tokens=max_state, layout="state_first")
308
+ for r in flat
309
+ ]
310
+ rtypes = row_types(rqs, index)
311
+ base_t = float(DECIDER_CFG.get("temperature", 1.0))
312
+
313
+ probs = []
314
+ with torch.no_grad():
315
+ per = max(1, 65536 // max(len(it["ids"]) for it in items))
316
+ for i in range(0, len(items), per):
317
+ chunk = items[i:i + per]
318
+ bt = collate(chunk, DECIDER_TOK.pad_token_id)
319
+ lg = DECIDER_MODEL.slot_logits(
320
+ *[bt[k].to("cuda" if ZERO and torch.cuda.is_available() else "cpu")
321
+ for k in ("input_ids", "attention_mask", "slot_idx", "slot_batch",
322
+ "nopts")])
323
+ tvec = torch.tensor([temps.get(rtypes[i + j], base_t)
324
+ for j in range(len(chunk))],
325
+ device=lg.device, dtype=lg.dtype)
326
+ pr = torch.softmax(lg / tvec.unsqueeze(-1), -1).float().cpu()
327
+ c = 0
328
+ for it in chunk:
329
+ probs.append(pr[c:c + len(it["slots"])])
330
+ c += len(it["slots"])
331
+ flatp = [p.tolist() for ps in probs for p in ps]
332
+ answers = assemble(rqs, index, flatp)
333
+ return {"model": f"decider-4b-{DECIDER_CFG.get('version', 'dev')}",
334
+ "answers": answers,
335
+ "usage": {"input_tokens": sum(len(it["ids"]) for it in items),
336
+ "output_tokens": 0, "rows": len(items)}}
337
+
338
+
339
  def _normalise_questions(questions: dict) -> dict:
340
  """Accept Jev's shape directly; tolerate a couple of harmless variants.
341
 
 
386
 
387
 
388
  @GPU
389
+ def answer_systemone(state: Any, questions: dict, temperature: list | None = None,
390
+ backend: str = "laya") -> dict:
391
+ """The single decorated entry point for every backend.
392
 
393
  Everything shares one GPU-decorated function: the start-up warm-up then covers all
394
  callers instead of each one paying its own first-call GPU attach.
395
+
396
+ `temperature` is [choice, score, noul] with 0 meaning "keep the checkpoint's fitted
397
+ value". Both backends take the same three numbers, but they are NOT the same
398
+ temperatures: laya's fitted values live in rl_agent_config.json and decider's in
399
+ decider_config.json, and each is left alone unless the caller overrides it.
400
  """
401
+ t0 = time.perf_counter()
402
+ temps = list(temperature or (0, 0, 0))
403
+ while len(temps) < 3:
404
+ temps.append(0)
405
+
406
+ if backend == "decider":
407
+ get_decider()
408
+ device_in_use = _decider_move_to_gpu()
409
+ dt = _decider_temps(temps[0], temps[1], temps[2])
410
+ res = _decider_systemone(state, questions, dt)
411
+ res["_idu"] = {
412
+ "space": f"dkappe/IDU {SPACE_REVISION}",
413
+ "backend": DECIDER_ID,
414
+ "device": device_in_use,
415
+ "zero_gpu": ZERO,
416
+ "latency_ms": round((time.perf_counter() - t0) * 1000, 1),
417
+ "temperature": [dt["choice"], dt["score"], dt["noul"]],
418
+ "note": ("decider-4b reads the option-label logits at one answer slot per "
419
+ "question and never generates. Per-type temperatures are the "
420
+ "checkpoint's fitted values unless overridden."),
421
+ }
422
+ return res
423
+
424
  a = get_agent()
425
  # Move onto the GPU HERE, inside the decorated call, where ZeroGPU has actually
426
  # attached one. Doing this at module scope cannot work: no device exists then.
 
428
  if temperature:
429
  _apply_temperature(a, *temperature)
430
  qs = _normalise_questions(questions)
 
431
  res = a.predict(state, qs)
432
  res["_idu"] = {
433
  "space": f"dkappe/IDU {SPACE_REVISION}",
 
443
  return res
444
 
445
 
446
+ def _safe_answer(state, questions, temperature=None, backend="laya"):
447
  """Wrap the GPU call so failures show in the UI instead of killing the app."""
448
  try:
449
+ return answer_systemone(state, questions, temperature, backend)
450
  except Exception as e: # pragma: no cover
451
  traceback.print_exc()
452
  return {"error": f"{type(e).__name__}: {e}",
 
586
  t2 = gr.Number(value=0, label="temp: score (0 = fitted)")
587
  t3 = gr.Number(value=0, label="temp: noul (0 = fitted)")
588
  p_go = gr.Button("Ask", variant="primary")
589
+ p_backend = gr.Radio(choices=["laya", "decider"], value="laya",
590
+ label="backend",
591
+ info="laya = 421M ModernBERT + decision head. "
592
+ "decider = 4B Qwen3.5, 255 options / 32k state. "
593
+ "Do not read latency as a comparison: both share "
594
+ "one ZeroGPU pool.")
595
  p_rows = gr.Dataframe(headers=["question", "answer", "confidence"],
596
  col_count=(3, "fixed"), label="answers", wrap=True)
597
  p_raw = gr.Code(label="raw response", language="json")
598
 
599
+ def run_playground(state_text, q_text, tc, ts, tn, backend):
600
  # Both boxes are gr.Code, but Gradio may hand us either a JSON string or an
601
  # already-parsed object depending on version and input source. Handle both;
602
  # the previous version only tried json.loads and blew up on a dict.
 
618
  qs = q_text
619
  if not isinstance(qs, dict) or not qs:
620
  return [], "questions must be a non-empty JSON object"
621
+ r = _safe_answer(state, qs, [tc, ts, tn], backend or "laya")
622
  return as_rows(r) if "answers" in r else [], json.dumps(r, indent=2)
623
 
624
+ p_go.click(run_playground, [p_state, p_q, t1, t2, t3, p_backend],
625
  [p_rows, p_raw], api_name="systemone")
626
 
627
  with gr.Tab("Compare vs Needle 3"):
 
710
 
711
 
712
  def health():
713
+ """Report the truth about where each model actually ran.
714
 
715
  DELIBERATELY DOES NOT ASSERT ok=... ON THE WEB-PROCESS CACHE.
716
  An earlier version returned ok=(_AGENT["a"] is not None). On ZeroGPU that is a lie:
 
722
  bookkeeping, and `model_in_web_process` is reported separately for diagnosis.
723
  """
724
  warm = _AGENT.get("warmup") or {}
725
+ dwarm = _AGENT.get("warmup_decider") or {}
726
  a = _AGENT.get("a")
727
+ import transformers as _tf
728
  return {"ok": bool(warm.get("warm_choice")),
729
  "space": f"dkappe/IDU {SPACE_REVISION}",
730
+ "backends": {"laya": MODEL_ID, "decider": DECIDER_ID},
731
  "last_warmup": warm,
732
  "warmup_device": warm.get("device"),
733
+ "decider_warmup": dwarm,
734
+ "transformers": _tf.__version__,
735
+ "decider_config_version": DECIDER_CFG.get("version"),
736
  "zero_gpu": ZERO,
737
  "device_mode_env": DEVICE_MODE,
738
  # diagnostic only -- False on ZeroGPU is expected, not a fault
739
  "model_in_web_process": a is not None,
740
+ "decider_in_web_process": DECIDER_MODEL is not None,
741
  "temperature_in_worker": _current_temperature(a) if a else None,
742
  "import_order_ok": "torch" in sys.modules,
743
  "note": ("ok reflects the last warm-up, because on ZeroGPU the model lives in "
744
  "the GPU worker and model_in_web_process will read False by design.")}
745
 
746
 
747
+ def warmup_decider() -> dict:
748
+ """Build decider-4b and run one real decision, so both backends are warm at startup.
749
+
750
+ This is the part that costs real memory: laya is 421M and decider is 4.2B, and both sit
751
+ in the same Space. Kept separate from `warmup` so a decider failure cannot take Laya
752
+ down with it -- the two are independent services sharing a UI.
753
+ """
754
+ try:
755
+ get_decider()
756
+ t0 = time.perf_counter()
757
+ dt = _decider_temps()
758
+ out = _decider_systemone(
759
+ "I was charged twice for order A-104. Please refund the duplicate.",
760
+ {"department": {"type": "choice",
761
+ "instructions": "Which team should handle this?",
762
+ "criteria": {"billing": "payments and refunds",
763
+ "technical": "bugs and outages",
764
+ "account": "login and access"}}},
765
+ dt)
766
+ return {"ms": round((time.perf_counter() - t0) * 1000, 1),
767
+ "device": str(next(DECIDER_MODEL.parameters()).device),
768
+ "warm_choice": out["answers"]["department"]["choice"],
769
+ "temperature": [dt["choice"], dt["score"], dt["noul"]]}
770
+ except Exception as e: # pragma: no cover
771
+ traceback.print_exc()
772
+ return {"error": f"{type(e).__name__}: {e}"}
773
+
774
+
775
  demo = build_ui() # safe here: every handler it references is defined above
776
 
777
 
778
+ def _startup():
779
+ """Warm both backends BEFORE the UI is reachable.
780
+
781
+ Gradio runs this file as __main__, so a warm-up placed in an `else:` branch would never
782
+ execute. Laya's own demo does the same thing for the same reason. Each backend is
783
+ warmed independently: a failure in one is reported and does not stop the other.
784
+ """
785
  try:
786
  _AGENT["warmup"] = warmup()
787
+ print(f"[startup] warm laya: {_AGENT['warmup']}", flush=True)
788
  except Exception as _e: # pragma: no cover
789
  print(f"[startup] warmup failed: {type(_e).__name__}: {_e}", flush=True)
 
 
790
  try:
791
+ _AGENT["warmup_decider"] = warmup_decider()
792
+ print(f"[startup] warm decider: {_AGENT['warmup_decider']}", flush=True)
793
  except Exception as _e: # pragma: no cover
794
+ print(f"[startup] warmup_decider failed: {type(_e).__name__}: {_e}", flush=True)
795
+
796
+
797
+ if __name__ == "__main__":
798
+ _startup()
799
+ demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=7860)
800
+ else:
801
+ _startup()
decider/__init__.py ADDED
File without changes
decider/infer.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Usable inference API: typed decisions with probabilities, all from one forward pass.
2
+
3
+ from decider.infer import Decider
4
+ d = Decider("runs/r2_full/model")
5
+ out = d.decide("My card was charged twice for the same purchase.",
6
+ [{"question": "Which department should handle this?", "options": ["billing", "technical", "sales"]},
7
+ {"question": "How urgent is this?", "options": ["low", "medium", "high"]}])
8
+ # -> [{'choice': 'billing', 'confidence': 0.97, 'probs': {...}}, {...}]
9
+ """
10
+ import logging
11
+
12
+ import torch
13
+ from decider.model import DecisionModel, collate
14
+ from decider.prompt import build, MAX_OPTIONS, resolve_layout, chat_template
15
+ from decider import temperature as TT
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass
20
+ class Q:
21
+ text: str; options: list; gold: int = 0
22
+
23
+
24
+ @dataclass
25
+ class Example:
26
+ context: str; qs: list; task: str = "infer"; image: bytes = None
27
+
28
+
29
+ logger = logging.getLogger(__name__)
30
+ NEUTRAL_NONE = "not listed here"
31
+
32
+
33
+ def neutralize_options(options):
34
+ """The training augmentation used the literal 'none of the above', and the model learned that exact string as an
35
+ abstain signal (it abstains even on clear cases when the string is offered). Any option that reads like it is
36
+ rewritten to a neutral phrasing for the model and mapped back in the output."""
37
+ out, back = [], {}
38
+ for o in options:
39
+ key = o.strip().lower()
40
+ if key.startswith("none of the above") or key in ("none of the above", "none", "n/a", "none of these"):
41
+ out.append(NEUTRAL_NONE); back[NEUTRAL_NONE] = o
42
+ else:
43
+ out.append(o)
44
+ return out, back
45
+
46
+
47
+ class _NoShuffle: # keep option order as given
48
+ def shuffle(self, x): pass
49
+ def sample(self, xs, k): return xs[:k]
50
+
51
+
52
+ class CompiledSchema:
53
+ def __init__(self, d, rqs, h, index):
54
+ from decider.systemone import row_types
55
+ self.d, self.rqs, self.h, self.index = d, rqs, h, index
56
+ self.types = row_types(rqs, index) # answer type of every schema row (decider.temperature)
57
+
58
+ def batch(self, states, max_state_tokens=32768):
59
+ from decider.systemone import render_state, assemble
60
+ T = TT.for_types(self.d.T_schema, self.d.T_schema_by_type, self.types)
61
+ probs = self.d._se.score(self.h, [render_state(s) for s in states], temperature=T, max_ctx_tokens=max_state_tokens)
62
+ return [{"model": self.d.name, "answers": assemble(self.rqs, self.index, [p.tolist() for p in pr])} for pr in probs]
63
+
64
+ def __call__(self, state, max_state_tokens=32768):
65
+ return self.batch([state], max_state_tokens)[0]
66
+
67
+
68
+ class Decider:
69
+ """One-pass decisions with automatic CUDA, MPS, or CPU device selection.
70
+
71
+ CUDA uses shape-bucketed graphs by default. MPS defaults to float16 and uses
72
+ the optional MPS patch; CPU defaults to bfloat16. Set ``use_graphs=False``
73
+ for eager execution or debugging.
74
+ """
75
+ def __init__(self, path, device=None, dtype=None, temperature=None, abstain_below=0.0, use_graphs=None, temperature_by_type=None):
76
+ """The prompt layout comes from decider_config.json: "layout": "chat" (chat-trained checkpoints) wraps every prompt in the
77
+ tokenizer's chat template (decider.prompt.build_chat); no "layout" key is the plain layout of every earlier model.
78
+ An unknown layout raises ValueError before the weights are loaded.
79
+
80
+ Temperatures (decider.temperature): decider_config.json "temperature" and the optional "temperature_by_type"
81
+ {"choice": T, "noul": T, "score": T}. temperature= overrides "temperature" and switches the config's by-type map off;
82
+ temperature_by_type= sets the map explicitly. An invalid temperature or map key raises ValueError before the weights
83
+ are loaded."""
84
+ if device is None:
85
+ device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
86
+ if dtype is None:
87
+ dtype = torch.float16 if str(device).startswith("mps") else torch.bfloat16
88
+ logger.info("Decider device=%s dtype=%s", device, dtype)
89
+ import json, os
90
+ cfg = {}
91
+ try: # model folder may carry decider_config.json (temperature, flags)
92
+ from huggingface_hub import hf_hub_download
93
+ cfg_path = os.path.join(path, "decider_config.json") if os.path.isdir(path) else hf_hub_download(path, "decider_config.json")
94
+ cfg = json.load(open(cfg_path))
95
+ except Exception:
96
+ pass
97
+ self.layout = resolve_layout(cfg)
98
+ (temperature, self.T_by_type), (T_schema, self.T_schema_by_type) = TT.from_config(cfg, temperature, temperature_by_type)
99
+ self.neutralize_none = bool(cfg.get("neutralize_none", True)) # v4 and earlier learned the literal string as an abstain signal
100
+ if use_graphs is None:
101
+ use_graphs = str(device).startswith("cuda")
102
+ if use_graphs:
103
+ from decider.engine import Engine
104
+ self.eng = Engine(path, device=device, dtype=dtype); self.m = self.eng.m
105
+ else:
106
+ if str(device).startswith("mps"):
107
+ from decider.mps_ops import patch_mps
108
+ patch_mps()
109
+ self.eng = None; self.m = DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
110
+ self.chat = chat_template(self.m.tok) if self.layout == "chat" else None # None: plain layout, prompts as in 1.1.x
111
+ self.dev = device; self.T = temperature; self.abstain_below = abstain_below
112
+ self.name = "decider-" + str(cfg.get("version", "dev"))
113
+ self.schema_first = bool(cfg.get("schema_first", False)) and self.eng is not None # default layout. Questions-first (the cacheable one) costs accuracy
114
+ self.T_schema = T_schema # (about 1.5 points on fixed label sets, more elsewhere): opt in with schema()
115
+ self.isolated_levels = bool(cfg.get("isolated_levels", False)) # Score levels judged one per row (v8+)
116
+ self._se = None; self._schemas = {}
117
+
118
+ def _decide_items(self, requests, max_ctx_tokens=1536):
119
+ """The prompt rows of decide_batch: -> (requests with neutralized options, one item per request)."""
120
+ exs = []
121
+ if self.neutralize_none:
122
+ requests = [(context, [dict(q, options=neutralize_options(q["options"])[0], _back=neutralize_options(q["options"])[1]) for q in qs]) for context, qs in requests]
123
+ for context, qs in requests:
124
+ for q in qs:
125
+ assert 2 <= len(q["options"]) <= MAX_OPTIONS, f"2..{MAX_OPTIONS} options required"
126
+ exs.append(Example(context, [Q(q["question"], list(q["options"]), 0) for q in qs], "infer"))
127
+ items = [build(e, self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS, max_ctx_tokens=max_ctx_tokens, chat=self.chat) for e in exs]
128
+ for it, (_, qs) in zip(items, requests): # answer type per slot: a plain question is "choice"
129
+ it["types"] = [q.get("_type", "choice") for q in qs]
130
+ return requests, items
131
+
132
+ @torch.no_grad()
133
+ def decide_batch(self, requests, max_ctx_tokens=1536):
134
+ """requests: list of (context:str, questions:list[dict(question, options)]). One forward pass for everything."""
135
+ requests, items = self._decide_items(requests, max_ctx_tokens)
136
+ T = TT.for_items(self.T, self.T_by_type, items) # the scalar self.T when there is no by-type map
137
+ if self.eng is not None:
138
+ probs = torch.cat(self.eng.score_items(items, temperature=T))
139
+ else:
140
+ b = collate(items, self.m.tok.pad_token_id)
141
+ logits = self.m.slot_logits(b["input_ids"].to(self.dev), b["attention_mask"].to(self.dev), b["slot_idx"].to(self.dev),
142
+ b["slot_batch"].to(self.dev), b["nopts"].to(self.dev))
143
+ probs = TT.scaled_softmax(logits, TT.slot_temperatures(T, items)).cpu()
144
+ out, k = [], 0
145
+ for context, qs in requests:
146
+ res = []
147
+ for q in qs:
148
+ p = probs[k, :len(q["options"])].tolist(); k += 1
149
+ j = max(range(len(p)), key=p.__getitem__); back = q.get("_back", {})
150
+ names = [back.get(o, o) for o in q["options"]]
151
+ res.append(dict(choice=names[j] if p[j] >= self.abstain_below else None, confidence=p[j],
152
+ probs={o: pi for o, pi in zip(names, p)}, probs_list=p))
153
+ out.append(res)
154
+ return out
155
+
156
+ def decide(self, context, questions, **kw):
157
+ return self.decide_batch([(context, questions)], **kw)[0]
158
+
159
+ # ---- Jev-shaped interface (decider.systemone): state + {id: Choice | Score | Noul with criteria}
160
+ # ---- schema cache (v7+): the questions are run once, requests only run the state (decider.schema_engine)
161
+ def schema(self, questions, independent=True, isolated=None, compile=False):
162
+ """Compile a fixed set of Jev-shaped questions: schema(state) -> answers; schema.batch([state, ...]) -> [answers]."""
163
+ import json
164
+ from decider.schema_engine import SchemaEngine
165
+ from decider.systemone import render_question
166
+ isolated = self.isolated_levels if isolated is None else isolated
167
+ key = (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent, isolated)
168
+ if key not in self._schemas:
169
+ if self._se is None: self._se = SchemaEngine(self.eng, chat=self.chat)
170
+ if len(self._schemas) >= 64: # drop the oldest schema and its graphs
171
+ old = next(iter(self._schemas)); hid = self._schemas.pop(old)[1].id
172
+ for k in [k for k in self._se.graphs if k[0] == hid]: del self._se.graphs[k]
173
+ from decider.systemone import plan_rows
174
+ rqs = {k: render_question(v) for k, v in questions.items()}
175
+ rows, index = plan_rows(rqs, isolated and independent)
176
+ h = self._se.prepare(rows, independent=independent, compile=compile) # compile=True: ~25 s per (batch, length) shape, 1.6x faster after
177
+ self._schemas[key] = (rqs, h, index)
178
+ return CompiledSchema(self, *self._schemas[key])
179
+
180
+ def system_one(self, state, questions, independent=True, max_state_tokens=32768, max_fwd_tokens=65536, layout=None, isolated=None):
181
+ layout = layout or ("schema_first" if self.schema_first else "state_first")
182
+ isolated = (self.isolated_levels if isolated is None else isolated) and independent
183
+ if layout == "schema_first" and self.eng is not None:
184
+ return self.schema(questions, independent, isolated)(state, max_state_tokens)
185
+ """independent=True scores every question in its own row (state + that question only), so adding, removing or
186
+ reordering questions cannot change any other answer; the state is run once and its cache forked to every
187
+ question (Engine.score_shared). independent=False packs all questions behind one copy of the state in one row
188
+ (later questions can then see earlier question texts)."""
189
+ from decider.systemone import unique_tokens, assemble
190
+ rqs, index, items = self._system_one_items(state, questions, independent, max_state_tokens, layout, isolated)
191
+ flatp = self._system_one_probs(items, layout, max_fwd_tokens, TT.for_items(self.T, self.T_by_type, items))
192
+ return {"model": self.name, "answers": assemble(rqs, index, flatp),
193
+ "usage": {"input_tokens": unique_tokens(items), "output_tokens": 0}}
194
+
195
+ @torch.no_grad()
196
+ def _system_one_probs(self, items, layout, max_fwd_tokens=65536, temperature=None):
197
+ """Score system_one's uncached rows. temperature: as Engine.score_items takes it (a number, or one entry per item).
198
+ -> one probability list per question row, in row order."""
199
+ if self.eng is not None and len(items) > 1 and layout == "state_first":
200
+ probs = self.eng.score_shared(items, temperature=temperature)
201
+ else:
202
+ probs = []; per = max(1, max_fwd_tokens // max(len(it["ids"]) for it in items))
203
+ for i in range(0, len(items), per):
204
+ T = TT.item_slice(temperature, i, i + per)
205
+ if self.eng is not None:
206
+ probs += self.eng.score_items(items[i:i + per], temperature=T)
207
+ else:
208
+ bt = collate(items[i:i + per], self.m.tok.pad_token_id)
209
+ lg = self.m.slot_logits(*[bt[k].to(self.dev) for k in ("input_ids", "attention_mask", "slot_idx", "slot_batch", "nopts")])
210
+ pr = TT.scaled_softmax(lg, TT.slot_temperatures(T, items[i:i + per])).cpu(); c = 0
211
+ for it in items[i:i + per]:
212
+ probs.append(pr[c:c + len(it["slots"])]); c += len(it["slots"])
213
+ return [p.tolist() for ps in probs for p in ps]
214
+
215
+ def _system_one_items(self, state, questions, independent=True, max_state_tokens=32768, layout=None, isolated=None):
216
+ """The prompt rows of system_one's uncached path: -> (rendered questions, answer index, items)."""
217
+ from decider.systemone import render_state, render_question, plan_rows, row_types
218
+ layout = layout or ("schema_first" if self.schema_first else "state_first")
219
+ isolated = (self.isolated_levels if isolated is None else isolated) and independent
220
+ ctx = render_state(state); rqs = {k: render_question(v) for k, v in questions.items()}
221
+ opts = (lambda r: neutralize_options(r["options"])[0]) if self.neutralize_none else (lambda r: list(r["options"]))
222
+ flat, index = plan_rows(rqs, isolated)
223
+ rows = [[r] for r in flat] if independent else [flat]
224
+ items = [build(Example(ctx, [Q(r["question"], opts(r), 0) for r in row]), self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS,
225
+ max_ctx_tokens=max_state_tokens, layout=layout, chat=self.chat) for row in rows]
226
+ types = row_types(rqs, index) # answer type per row (decider.temperature)
227
+ for it, ts in zip(items, [[t] for t in types] if independent else [types]):
228
+ it["types"] = ts
229
+ return rqs, index, items
230
+
231
+ # ---- typed schema interface: {question: {"type": "bool"} | {"type": "choice", "options": [...]}
232
+ # | {"type": "scale", "legend": {"0": "none", "1": "low", ...}}}
233
+ SCHEMA_FORM = ('schema: a map {question: field}, each field one of {"type": "choice", "options": [option strings]}, '
234
+ '{"type": "bool"} or {"type": "scale", "legend": [descriptions] or {level number: description}}')
235
+
236
+ @staticmethod
237
+ def _check_schema(schema):
238
+ """Raise ValueError naming the expected form when `schema` does not have it (the HTTP server turns this into a 422).
239
+ Every schema that 1.1.2 answered stays accepted, except options or a legend given as a bare string (1.1.2 split it into
240
+ characters); an empty schema is answered with {}."""
241
+ import json, math
242
+ form = Decider.SCHEMA_FORM
243
+ if schema is None:
244
+ raise ValueError("schema is required; " + form)
245
+ if not isinstance(schema, dict):
246
+ raise ValueError(f"schema is a {type(schema).__name__}; " + form)
247
+ for qtext, spec in schema.items():
248
+ where = f"schema[{qtext!r}]"
249
+ if not isinstance(spec, dict):
250
+ raise ValueError(f"{where} is a {type(spec).__name__}, not a field object; " + form)
251
+ t = spec.get("type", "choice")
252
+ if t == "choice":
253
+ opts = spec.get("options")
254
+ if not isinstance(opts, (list, tuple, dict)) or not opts or not all(isinstance(o, str) for o in opts):
255
+ raise ValueError(f'{where}: "options" must be a non-empty list of strings; ' + form)
256
+ elif t == "scale":
257
+ leg = spec.get("legend")
258
+ if not isinstance(leg, (list, tuple, dict)) or not leg:
259
+ raise ValueError(f'{where}: "legend" must be a non-empty list or {{level number: description}} map; ' + form)
260
+ try: # the legend is echoed in the answer, which must serialise
261
+ json.dumps(leg, allow_nan=False)
262
+ except (TypeError, ValueError):
263
+ raise ValueError(f'{where}: the "legend" contains a value that is not finite JSON (NaN or Infinity); ' + form)
264
+ if isinstance(leg, dict):
265
+ try:
266
+ ok = all(math.isfinite(float(k)) for k in leg)
267
+ except (TypeError, ValueError, OverflowError):
268
+ ok = False
269
+ if not ok:
270
+ raise ValueError(f'{where}: the keys of a "legend" map must be finite numbers, e.g. {{"0": "none", "1": "low"}}; ' + form)
271
+ elif t != "bool":
272
+ raise ValueError(f"{where}: unknown field type {t!r}; " + form)
273
+
274
+ @staticmethod
275
+ def _schema_to_questions(schema):
276
+ Decider._check_schema(schema)
277
+ qs = []
278
+ for qtext, spec in schema.items():
279
+ t = spec.get("type", "choice")
280
+ if t == "bool":
281
+ qs.append(dict(question=qtext, options=["no", "yes"], _type="noul"))
282
+ elif t == "choice":
283
+ qs.append(dict(question=qtext, options=list(spec["options"]), _type="choice"))
284
+ elif t == "scale":
285
+ leg = spec["legend"]
286
+ keys = sorted(leg, key=lambda k: float(k)) if isinstance(leg, dict) else list(range(len(leg)))
287
+ labels = [f"{k}: {leg[k]}" if isinstance(leg, dict) else f"{i}: {leg[i]}" for i, k in enumerate(keys)]
288
+ qs.append(dict(question=qtext, options=labels, _keys=keys, _legend=leg, _type="score"))
289
+ else:
290
+ raise ValueError(f"unknown field type {t}")
291
+ return qs
292
+
293
+ def decide_json_batch(self, requests, **kw):
294
+ """requests: list of (context, schema). Returns one dict per context keyed by question."""
295
+ qss = [self._schema_to_questions(schema) for _, schema in requests]
296
+ raw = self.decide_batch([(ctx, qs) for (ctx, _), qs in zip(requests, qss)], **kw)
297
+ out = []
298
+ for (ctx, schema), qs, res in zip(requests, qss, raw):
299
+ o = {}
300
+ for (qtext, spec), q, r in zip(schema.items(), qs, res):
301
+ t = spec.get("type", "choice")
302
+ if t == "bool":
303
+ o[qtext] = {"noul": round(r["probs"]["yes"], 4), "type": "noul"}
304
+ elif t == "choice":
305
+ o[qtext] = {"choice": r["choice"], "confidence": round(r["confidence"], 4), "type": "choice",
306
+ "probabilities": {k: round(v, 4) for k, v in r["probs"].items()}}
307
+ else:
308
+ p = [r["probs"][lab] for lab in q["options"]]
309
+ keys = q["_keys"]; n = len(p)
310
+ score = sum(float(k) * pi for k, pi in zip(keys, p)) # expected level on the legend scale
311
+ j = max(range(n), key=p.__getitem__)
312
+ o[qtext] = {"score": round(score, 2), "confidence": round(p[j], 4), "type": "scale", "legend": q["_legend"],
313
+ "probabilities": {str(keys[i]): round(pi, 4) for i, pi in enumerate(p)}}
314
+ out.append(o)
315
+ return out
316
+
317
+ def decide_json(self, context, schema, **kw):
318
+ return self.decide_json_batch([(context, schema)], **kw)[0]
319
+
320
+
321
+ if __name__ == "__main__":
322
+ import sys, json, time
323
+ d = Decider(sys.argv[1] if len(sys.argv) > 1 else "runs/r1_200k/model")
324
+ demo = [
325
+ ("My card was charged twice for the same purchase and I want the extra charge refunded.",
326
+ [{"question": "Which department should handle this?", "options": ["billing", "technical support", "sales"]},
327
+ {"question": "What is the customer's sentiment?", "options": ["angry", "neutral", "happy"]},
328
+ {"question": "Does this need a refund action?", "options": ["no", "yes"]}]),
329
+ ("hey can u turn the lights off in the kitchen",
330
+ [{"question": "What is the intent?", "options": ["smart home control", "set alarm", "play music", "none of the above"]},
331
+ {"question": "Is this request toxic?", "options": ["no", "yes"]}]),
332
+ ("The quarterly report shows revenue fell 12% while costs rose sharply.",
333
+ [{"question": "What is the financial sentiment?", "options": ["bearish", "neutral", "bullish"]}]),
334
+ ]
335
+ t = time.time(); res = d.decide_batch(demo); dt = time.time() - t
336
+ for (ctx, qs), r in zip(demo, res):
337
+ print("\n>>", ctx)
338
+ for q, a in zip(qs, r):
339
+ print(f" {q['question']:45s} -> {a['choice']!s:22s} p={a['confidence']:.2f} " + " ".join(f"{o}:{p:.2f}" for o, p in a['probs'].items()))
340
+ print(f"\n{sum(len(q) for _, q in demo)} decisions in {dt*1000:.0f} ms (one forward pass)")
341
+ schema = {
342
+ "Revenue currently impacted?": {"type": "bool"},
343
+ "What business impact?": {"type": "choice", "options": ["none", "degraded", "outage"]},
344
+ "Integration issue present?": {"type": "bool"},
345
+ "Account health status?": {"type": "choice", "options": ["healthy", "watch", "at risk"]},
346
+ "Which incident scope?": {"type": "choice", "options": ["single_account", "multi_account", "platform_wide"]},
347
+ "Security concern present?": {"type": "bool"},
348
+ "Duplicate charge reported?": {"type": "bool"},
349
+ "Churn likelihood level?": {"type": "scale", "legend": {"0": "none", "1": "low", "2": "medium", "3": "high"}},
350
+ "Human attention needed?": {"type": "bool"},
351
+ "Immediate feature request?": {"type": "bool"},
352
+ }
353
+ ctx = ("Hi, since this morning our Stripe webhook integration stopped firing and our checkout is down for all customers. "
354
+ "We are losing orders every minute and our partner launch is on Thursday. Also I think we got billed twice last week. "
355
+ "If this is not fixed today we will have to look at other providers.")
356
+ t = time.time(); js = d.decide_json(ctx, schema); dt = time.time() - t
357
+ print(f"\n>> {ctx[:80]}...\n" + json.dumps(js, indent=1)[:3000]); print(f"{len(schema)} typed fields in {dt*1000:.0f} ms (one forward pass)")
decider/model.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backbone -> slot hidden states -> restricted logits over option letters."""
2
+ import torch, torch.nn as nn, torch.nn.functional as F
3
+ from transformers import AutoModelForCausalLM, AutoTokenizer
4
+ from decider.prompt import letter_ids, MAX_OPTIONS
5
+
6
+
7
+ class DecisionModel(nn.Module):
8
+ def __init__(self, name, dtype=torch.bfloat16, grad_ckpt=True):
9
+ super().__init__()
10
+ if hasattr(torch.backends.cuda, "enable_cudnn_sdp"): # as decider.engine.set_attention_backend_policy: the cuDNN SDPA
11
+ torch.backends.cuda.enable_cudnn_sdp(False) # backend is wrong for masked attention on Blackwell (torch 2.14)
12
+ self.tok = AutoTokenizer.from_pretrained(name)
13
+ self.lm = AutoModelForCausalLM.from_pretrained(name, dtype=dtype)
14
+ if grad_ckpt:
15
+ self.lm.gradient_checkpointing_enable()
16
+ self.register_buffer("letters", torch.tensor(letter_ids(self.tok)), persistent=False)
17
+
18
+ def slot_logits(self, input_ids, attention_mask, slot_idx, slot_batch, nopts):
19
+ """input_ids [B,T]; slot_idx/slot_batch [N] flat slot positions; nopts [N].
20
+ Returns [N, MAX_OPTIONS] logits with invalid options masked to -inf."""
21
+ h = self.lm.model(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
22
+ hs = h[slot_batch, slot_idx] # [N,H]
23
+ W = self.lm.lm_head.weight[self.letters] # [K,H]
24
+ logits = F.linear(hs, W).float() # [N,K]
25
+ ar = torch.arange(MAX_OPTIONS, device=logits.device)[None, :]
26
+ logits = logits.masked_fill(ar >= nopts[:, None], float("-inf"))
27
+ return logits
28
+
29
+ def forward(self, batch):
30
+ return self.slot_logits(batch["input_ids"], batch["attention_mask"], batch["slot_idx"], batch["slot_batch"], batch["nopts"])
31
+
32
+
33
+ def collate(items, pad_id):
34
+ """items: list of dicts from prompt.build (+ 'task', 'ex_id'). Right-pad."""
35
+ T = max(len(it["ids"]) for it in items)
36
+ T = ((T + 63) // 64) * 64 # few distinct shapes -> fewer kernel (re)compiles
37
+ B = len(items)
38
+ input_ids = torch.full((B, T), pad_id, dtype=torch.long)
39
+ attn = torch.zeros((B, T), dtype=torch.long)
40
+ slot_idx, slot_batch, golds, nopts, tasks, qidx = [], [], [], [], [], []
41
+ for b, it in enumerate(items):
42
+ n = len(it["ids"])
43
+ input_ids[b, :n] = torch.tensor(it["ids"])
44
+ attn[b, :n] = 1
45
+ for k, s in enumerate(it["slots"]):
46
+ slot_idx.append(s); slot_batch.append(b); golds.append(it["golds"][k]); nopts.append(it["nopts"][k])
47
+ tasks.append(it.get("task", "")); qidx.append(k)
48
+ return dict(input_ids=input_ids, attention_mask=attn, slot_idx=torch.tensor(slot_idx), slot_batch=torch.tensor(slot_batch),
49
+ golds=torch.tensor(golds), nopts=torch.tensor(nopts), tasks=tasks, qidx=qidx)
decider/prompt.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt construction. One context, N typed questions, N answer slots.
2
+
3
+ All N decisions are read from a single forward pass: the logits at each
4
+ "Answer k: (" slot are restricted to the option-letter tokens. No answer
5
+ letters are ever inserted, so slot k sees the context and all questions but
6
+ no earlier answers (the decisions are conditionally independent given input).
7
+ """
8
+ import random
9
+
10
+ LETTERS = "ABCDEFGHIJ"
11
+ NARROW = len(LETTERS) # <= NARROW options: the original "(A) .. (J)" rendering, tokenized as a string (unchanged since v1)
12
+ MAX_OPTIONS = 255 # width of the label head. > NARROW options: "wide" rendering, one label token per option:
13
+ # A..Z then the first 229 two-letter upper-case strings that are single tokens (AA, AB, ...)
14
+ ABSTAIN_PREFIXES = ("none of the above", "none of these", "not listed", "no suitable", "does not apply", "cannot tell")
15
+ ABSTAIN_EXACT = ("other", "unsure", "something else", "neither of these", "other / not covered")
16
+
17
+
18
+ def is_abstain_option(o):
19
+ o = o.strip().lower()
20
+ return o.startswith(ABSTAIN_PREFIXES) or o in ABSTAIN_EXACT
21
+
22
+
23
+ _LABELS = {}
24
+ _OPT_CACHE = {}
25
+
26
+
27
+ def _enc_opt(tok, text):
28
+ """Token ids of ") <option text>" (cached: fixed label sets repeat the same strings millions of times)."""
29
+ key = (id(tok), text)
30
+ v = _OPT_CACHE.get(key)
31
+ if v is None:
32
+ v = tok.encode(f") {text}", add_special_tokens=False)
33
+ if len(_OPT_CACHE) < 2_000_000:
34
+ _OPT_CACHE[key] = v
35
+ return v
36
+
37
+
38
+ def label_table(tok):
39
+ """(label strings, label token ids), MAX_OPTIONS entries; the first NARROW are A..J so narrow questions are unchanged."""
40
+ key = id(tok)
41
+ if key not in _LABELS:
42
+ import string
43
+ U = string.ascii_uppercase
44
+ names = list(U) + [a + b for a in U for b in U]
45
+ out = []
46
+ for n in names:
47
+ t = tok.encode(n, add_special_tokens=False)
48
+ if len(t) == 1:
49
+ out.append((n, t[0]))
50
+ if len(out) == MAX_OPTIONS:
51
+ break
52
+ assert len(out) == MAX_OPTIONS and len({i for _, i in out}) == MAX_OPTIONS
53
+ _LABELS[key] = ([n for n, _ in out], [i for _, i in out],
54
+ tok.encode("\n(", add_special_tokens=False))
55
+ return _LABELS[key]
56
+
57
+
58
+ def _select(q, rng, max_options):
59
+ opts = list(range(len(q.options)))
60
+ if len(opts) > max_options:
61
+ # always keep the gold and any abstain-style option (its mere presence must not carry information)
62
+ forced = {q.gold} | {i for i, o in enumerate(q.options) if is_abstain_option(o)}
63
+ others = [i for i in opts if i not in forced]
64
+ opts = rng.sample(others, max_options - len(forced)) + list(forced)
65
+ rng.shuffle(opts)
66
+ return opts
67
+
68
+
69
+ def _options_ids(tok, q, opts):
70
+ if len(opts) <= NARROW:
71
+ return tok.encode("".join(f"\n({LETTERS[j]}) {q.options[oi]}" for j, oi in enumerate(opts)), add_special_tokens=False)
72
+ _, lab_ids, open_ids = label_table(tok); out = []
73
+ for j, oi in enumerate(opts):
74
+ out += open_ids + [lab_ids[j]] + _enc_opt(tok, q.options[oi])
75
+ return out
76
+
77
+
78
+ def build_schema_first(example, tok, rng=None, max_options=NARROW, max_ctx_tokens=1536, chat=None):
79
+ """Schema-first layout: all question/option blocks, then the context, then one answer slot per question.
80
+
81
+ Question 1: ...\nOptions:\n(A) ... <- prefix: depends only on the questions, so its cache (attention KV and
82
+ \n\nQuestion 2: ... delta-net states) is computed once per schema and reused for every state
83
+ \n\nContext:\n<state>\n\nAnswer 1: (\nAnswer 2: (
84
+
85
+ The three parts are tokenized separately, so `ids[:prefix_len]` is identical for every state.
86
+ chat: a ChatTemplate for a chat-layout model (see `build_chat`); None renders the plain layout above."""
87
+ rng = rng or random
88
+ perms = [_select(q, rng, max_options) for q in example.qs]
89
+ pre = schema_prefix_ids(tok, example.qs, perms, chat=chat)
90
+ suf, slots = schema_suffix_ids(tok, example.context, len(example.qs), max_ctx_tokens, chat=chat)
91
+ return dict(ids=pre + suf, slots=[len(pre) + s for s in slots], golds=[p.index(q.gold) if q.gold in p else -1 for p, q in zip(perms, example.qs)],
92
+ nopts=[len(p) for p in perms], perms=perms, prefix_len=len(pre))
93
+
94
+
95
+ def schema_prefix_ids(tok, qs, perms=None, chat=None):
96
+ """Token ids of the question/option blocks (the cacheable part of the schema-first layout).
97
+ chat: a ChatTemplate; the prefix then starts with the template head (the user turn opens before the first question)."""
98
+ multi = len(qs) > 1; pre = [] if chat is None else list(chat.head)
99
+ for k, q in enumerate(qs):
100
+ opts = perms[k] if perms is not None else list(range(len(q.options)))
101
+ pre += tok.encode(f"{chr(10) * 2 if k else ''}Question{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:", add_special_tokens=False) + _options_ids(tok, q, opts)
102
+ return pre
103
+
104
+
105
+ def schema_suffix_ids(tok, context, n_q, max_ctx_tokens=1536, chat=None):
106
+ """Token ids after the schema prefix: the context and one answer slot per question. Returns (ids, slot positions in ids).
107
+ chat: a ChatTemplate; the context then ends the user turn, and the template tail and the answer pieces follow."""
108
+ ids = tok.encode("\n\nContext:\n", add_special_tokens=False) + tok.encode(context, add_special_tokens=False)[:max_ctx_tokens]; slots = []
109
+ if chat is not None:
110
+ ids += chat.tail
111
+ for k in range(n_q):
112
+ ids += chat.answer_ids(tok, k, n_q > 1); slots.append(len(ids) - 1)
113
+ return ids, slots
114
+ for k in range(n_q):
115
+ ids += tok.encode(f"{chr(10) * 2 if k == 0 else chr(10)}Answer{' ' + str(k + 1) if n_q > 1 else ''}: (", add_special_tokens=False); slots.append(len(ids) - 1)
116
+ return ids, slots
117
+
118
+
119
+ def build(example, tok, rng=None, max_options=NARROW, max_ctx_tokens=1536, layout="state_first", chat=None):
120
+ """Returns dict(ids=list[int], slots=list[int], golds=list[int], nopts=list[int], perms=list[list[int]]).
121
+ chat: a ChatTemplate (chat_template(tok)) for a model trained in the chat layout (decider_config.json "layout": "chat");
122
+ None, the default, is the plain layout every earlier model uses, unchanged."""
123
+ if layout == "schema_first":
124
+ return build_schema_first(example, tok, rng, max_options, max_ctx_tokens, chat=chat)
125
+ if chat is not None:
126
+ return build_chat(example, tok, chat, rng, max_options, max_ctx_tokens)
127
+ rng = rng or random
128
+ ctx_ids = tok.encode("Context:\n" + example.context, add_special_tokens=False)[:max_ctx_tokens]
129
+ ids = list(ctx_ids)
130
+ slots, golds, nopts, perms = [], [], [], []
131
+ multi = len(example.qs) > 1
132
+ for k, q in enumerate(example.qs):
133
+ opts = list(range(len(q.options)))
134
+ if len(opts) > max_options:
135
+ # always keep the gold and any abstain-style option (its mere presence must not carry information)
136
+ forced = {q.gold} | {i for i, o in enumerate(q.options) if is_abstain_option(o)}
137
+ others = [i for i in opts if i not in forced]
138
+ keep = rng.sample(others, max_options - len(forced)) + list(forced)
139
+ opts = keep
140
+ rng.shuffle(opts)
141
+ head = f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:"
142
+ tail = f"\nAnswer{' ' + str(k + 1) if multi else ''}: ("
143
+ if len(opts) <= NARROW:
144
+ lines = [head] + [f"\n({LETTERS[j]}) {q.options[oi]}" for j, oi in enumerate(opts)] + [tail]
145
+ piece = tok.encode("".join(lines), add_special_tokens=False)
146
+ else: # wide: "\n(" + <label token> + ") text", built from ids so every label is one token
147
+ _, lab_ids, open_ids = label_table(tok)
148
+ piece = tok.encode(head, add_special_tokens=False)
149
+ for j, oi in enumerate(opts):
150
+ piece += open_ids + [lab_ids[j]] + _enc_opt(tok, q.options[oi])
151
+ piece += tok.encode(tail, add_special_tokens=False)
152
+ ids.extend(piece)
153
+ slots.append(len(ids) - 1) # position of " (" token
154
+ golds.append(opts.index(q.gold) if q.gold in opts else -1)
155
+ nopts.append(len(opts))
156
+ perms.append(opts)
157
+ return dict(ids=ids, slots=slots, golds=golds, nopts=nopts, perms=perms)
158
+
159
+
160
+ # ---- chat layout (1.2.0) ----------------------------------------------------------------------------------------------
161
+ # A model whose decider_config.json has "layout": "chat" (or "chat_template": true) was trained with every prompt wrapped
162
+ # in its tokenizer's chat template: one user turn holding the plain content, thinking switched off, and the answer pieces in
163
+ # the assistant turn. For Qwen3.5, state-first:
164
+ #
165
+ # <|im_start|>user\n Context:\n<state> \n\nQuestion: <q>\nOptions:\n(A) ..\n(B) ..
166
+ # <|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n Answer: (
167
+ #
168
+ # With several questions in one row all question blocks come first and the answer pieces ("Answer 1: (", "\nAnswer 2: (",
169
+ # ...) follow the template tail. Schema-first: the user turn holds the question blocks and then "\n\nContext:\n<state>".
170
+ # Every part is tokenized on its own (head, context, each question header, each option block, tail, each answer piece);
171
+ # the letter is read at the final " (" token as in the plain layout.
172
+ LAYOUTS = ("plain", "chat")
173
+
174
+
175
+ def resolve_layout(cfg):
176
+ """The prompt layout a decider_config.json names: "plain" (no "layout" key and no "chat_template": true, which is every
177
+ released model) or "chat". Raises ValueError for any other value, so a model trained in a layout
178
+ this version does not know is refused instead of being read in the wrong one."""
179
+ cfg = cfg or {}
180
+ layout = cfg.get("layout")
181
+ if layout is None:
182
+ layout = "chat" if cfg.get("chat_template") is True else "plain"
183
+ if layout not in LAYOUTS:
184
+ raise ValueError(f"decider_config.json names the prompt layout {layout!r}; this version of decider-ai knows "
185
+ f"{', '.join(repr(x) for x in LAYOUTS)}. Upgrade decider-ai, or check the model's config.")
186
+ if layout == "plain" and cfg.get("chat_template") is True:
187
+ raise ValueError('decider_config.json says "layout": "plain" and "chat_template": true; these contradict each other')
188
+ return layout
189
+
190
+
191
+ class ChatTemplate:
192
+ """Token ids of the tokenizer's chat template around one user turn, and the answer pieces of the assistant turn.
193
+
194
+ head/tail come from tok.apply_chat_template([user turn], add_generation_prompt=True) with thinking disabled
195
+ (enable_thinking=False where the template knows the switch; a thinking block the template leaves open is closed here),
196
+ with no system prompt. This is the computation of the chat-layout research server the chat models were trained against."""
197
+ SENTINEL = "@@DECIDER_USER_CONTENT@@"
198
+
199
+ def __init__(self, tok, answer="Answer"):
200
+ if not getattr(tok, "chat_template", None):
201
+ raise ValueError('the model is configured for the chat layout ("layout": "chat") but its tokenizer has no chat template')
202
+ msgs = [{"role": "user", "content": self.SENTINEL}]
203
+ kw = {"enable_thinking": False} if "enable_thinking" in tok.chat_template else {}
204
+ s = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, **kw)
205
+ head, tail = s.split(self.SENTINEL)
206
+ for opened, closed in (("<think>\n", "</think>\n\n"), ("<think>", "</think>\n\n"), ("<|channel>thought\n", "<channel|>")):
207
+ if tail.endswith(opened):
208
+ tail += closed
209
+ self.head_text, self.tail_text = head, tail
210
+ self.head = tok.encode(head, add_special_tokens=False)
211
+ self.tail = tok.encode(tail, add_special_tokens=False)
212
+ self.answer = answer
213
+ names, ids, _ = label_table(tok)
214
+ pre = tok.encode(f"{answer}: (", add_special_tokens=False)
215
+ for n, i in list(zip(names, ids))[:NARROW]:
216
+ if tok.encode(f"{answer}: ({n}", add_special_tokens=False) != pre + [i]:
217
+ raise ValueError(f"chat layout: the label {n!r} does not tokenize as one token after {answer!r}: (")
218
+ if tok.encode(tail + f"{answer}: (", add_special_tokens=False) != self.tail + pre:
219
+ raise ValueError("chat layout: the chat template tail merges with the answer prefix")
220
+
221
+ def answer_ids(self, tok, k, multi):
222
+ """Token ids of answer piece k: "Answer: (" (or "Answer k: (" with several questions), later pieces after a newline."""
223
+ return tok.encode(f"{'' if k == 0 else chr(10)}{self.answer}{' ' + str(k + 1) if multi else ''}: (", add_special_tokens=False)
224
+
225
+
226
+ _CHAT = {}
227
+
228
+
229
+ def chat_template(tok):
230
+ """The ChatTemplate of a tokenizer, cached per tokenizer object."""
231
+ key = id(tok)
232
+ if key not in _CHAT:
233
+ _CHAT[key] = (tok, ChatTemplate(tok)) # keep tok alive so its id is not reused
234
+ return _CHAT[key][1]
235
+
236
+
237
+ def chat_for(tok, cfg):
238
+ """The ChatTemplate to pass as `chat=` for a model with this decider_config.json, or None for a plain-layout model."""
239
+ return chat_template(tok) if resolve_layout(cfg) == "chat" else None
240
+
241
+
242
+ def load_decider_config(path):
243
+ """decider_config.json of a model folder or a Hub repository id; {} when the model has none (a raw base model)."""
244
+ import json, os
245
+ if os.path.isdir(path):
246
+ f = os.path.join(path, "decider_config.json")
247
+ return json.load(open(f)) if os.path.exists(f) else {}
248
+ try:
249
+ from huggingface_hub import hf_hub_download
250
+ return json.load(open(hf_hub_download(path, "decider_config.json")))
251
+ except Exception:
252
+ return {}
253
+
254
+
255
+ def chat_for_model(path, tok):
256
+ """chat_for(tok, the model's decider_config.json): what every script that builds prompts for `path` passes as `chat=`."""
257
+ return chat_for(tok, load_decider_config(path))
258
+
259
+
260
+ def build_chat(example, tok, chat, rng=None, max_options=NARROW, max_ctx_tokens=1536):
261
+ """State-first chat layout (see the comment above LAYOUTS). Same return fields as `build`. max_ctx_tokens caps the
262
+ tokens of "Context:\\n" + state, as in the plain layout; the template head and tail are not counted."""
263
+ rng = rng or random
264
+ qs = example.qs; multi = len(qs) > 1
265
+ ids = list(chat.head) + tok.encode("Context:\n" + example.context, add_special_tokens=False)[:max_ctx_tokens]
266
+ golds, nopts, perms = [], [], []
267
+ for k, q in enumerate(qs):
268
+ opts = _select(q, rng, max_options)
269
+ ids += tok.encode(f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:", add_special_tokens=False) + _options_ids(tok, q, opts)
270
+ golds.append(opts.index(q.gold) if q.gold in opts else -1); nopts.append(len(opts)); perms.append(opts)
271
+ ids += chat.tail
272
+ slots = []
273
+ for k in range(len(qs)):
274
+ ids += chat.answer_ids(tok, k, multi); slots.append(len(ids) - 1)
275
+ return dict(ids=ids, slots=slots, golds=golds, nopts=nopts, perms=perms)
276
+
277
+
278
+ def letter_ids(tok):
279
+ ids = label_table(tok)[1]
280
+ for j, L in enumerate(LETTERS):
281
+ assert tok.encode(L, add_special_tokens=False) == [ids[j]], L
282
+ return ids
283
+
284
+
285
+ def render(example, tok, **kw):
286
+ b = build(example, tok, **kw)
287
+ return tok.decode(b["ids"])
decider/systemone.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jev-shaped requests on top of the decider prompt format (same wire format as TypeSafe's POST /v1/systemone).
2
+
3
+ state str | dict | list JSON state is serialised compactly; questions may name a part by path (`ticket.messages[0].text`)
4
+ questions {id: {"type": "choice", "instructions": ..., "criteria": {name: description | {...} | [...] | None}} up to 255 options
5
+ {"type": "score", "instructions": ..., "criteria": [level 0 description, level 1 description, ...]} 2..10 levels
6
+ {"type": "noul", "instructions": ... (optional), "criteria": {"true": ..., "false": ...} (optional)}}
7
+ a noul question needs instructions or at least one true/false description.
8
+ ids are never shown to the model. `instructions` and every description may be a string or any JSON value.
9
+ """
10
+ import json, math
11
+
12
+ MAX_CHOICE, MAX_LEVELS = 255, 10
13
+
14
+
15
+ def _txt(v):
16
+ return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
17
+
18
+
19
+ ANNOTATE_MIN = 8
20
+
21
+
22
+ def annotate_indices(x, min_len=ANNOTATE_MIN):
23
+ """Write each element's position into long arrays ({"_index": i, ...}). A path such as `records[47].text` otherwise makes
24
+ the model count 47 elements; with the index written down it is a lookup (json_k64 probe: 0.49 -> 0.57 accuracy)."""
25
+ if isinstance(x, list):
26
+ if len(x) >= min_len:
27
+ return [({"_index": i, **annotate_indices(v, min_len)} if isinstance(v, dict) else {"_index": i, "value": annotate_indices(v, min_len)}) for i, v in enumerate(x)]
28
+ return [annotate_indices(v, min_len) for v in x]
29
+ if isinstance(x, dict):
30
+ return {k: annotate_indices(v, min_len) for k, v in x.items()}
31
+ return x
32
+
33
+
34
+ def render_state(state, index_arrays=True):
35
+ if isinstance(state, str):
36
+ return state
37
+ return json.dumps(annotate_indices(state) if index_arrays else state, ensure_ascii=False)
38
+
39
+
40
+ def render_question(spec):
41
+ """-> dict(question=str, options=[str], type=..., names=[...]) (names: what the answer reports for each option)"""
42
+ t = spec.get("type", "choice"); crit = spec.get("criteria", spec.get("options"))
43
+ raw = spec.get("instructions", spec.get("question", ""))
44
+ if t in ("noul", "bool") and raw in (None, ""): # the criteria carry the question (NOUL_WITHOUT_INSTRUCTIONS)
45
+ ins = NOUL_WITHOUT_INSTRUCTIONS
46
+ described = isinstance(crit, dict) and any(d not in (None, "") for d in (crit.get("true", crit.get(True)), crit.get("false", crit.get(False))))
47
+ if not described and (crit is None or isinstance(crit, dict)): # a non-map is rejected below with the criteria message
48
+ raise ValueError("noul question without instructions: criteria must describe true or false")
49
+ else:
50
+ ins = _txt(raw)
51
+ if not ins:
52
+ raise ValueError("question without instructions")
53
+ if t == "choice":
54
+ if isinstance(crit, (list, tuple)):
55
+ crit = {str(c): None for c in crit}
56
+ if not isinstance(crit, dict) or not 2 <= len(crit) <= MAX_CHOICE:
57
+ raise ValueError(f"choice criteria: a map of 2..{MAX_CHOICE} options")
58
+ names = list(crit); opts = [n if crit[n] in (None, "") else f"{n}: {_txt(crit[n])}" for n in names]
59
+ elif t == "score":
60
+ if isinstance(crit, dict): # legend form {"0": "...", "1": "..."}
61
+ crit = [crit[k] for k in sorted(crit, key=float)]
62
+ if not isinstance(crit, (list, tuple)) or not 2 <= len(crit) <= MAX_LEVELS:
63
+ raise ValueError(f"score criteria: an ordered list of 2..{MAX_LEVELS} level descriptions")
64
+ names = list(range(len(crit))); opts = [f"{i}: {_txt(c)}" for i, c in enumerate(crit)]
65
+ elif t in ("noul", "bool"):
66
+ if crit is not None and not isinstance(crit, dict):
67
+ raise ValueError("noul criteria: a map of optional true/false descriptions")
68
+ names = [False, True]; c = crit if crit is not None else {}
69
+ f, tr = c.get("false", c.get(False)), c.get("true", c.get(True))
70
+ opts = ["no" if f in (None, "") else f"no: {_txt(f)}", "yes" if tr in (None, "") else f"yes: {_txt(tr)}"]
71
+ else:
72
+ raise ValueError(f"unknown question type {t!r}")
73
+ return dict(question=ins, options=opts, type="noul" if t == "bool" else t, names=names, legend=[_txt(c) for c in crit] if t == "score" else None,
74
+ isolated=bool(spec.get("isolated", True)))
75
+
76
+
77
+ # A noul question may omit `instructions` (TypeSafe's OpenAPI file marks it optional). The question id is never shown to the
78
+ # model, so the question text is this fixed sentence and the true/false descriptions, rendered as the options "no: ..." and
79
+ # "yes: ...", say what is being asked. A request that gives instructions is rendered exactly as before.
80
+ NOUL_WITHOUT_INSTRUCTIONS = "Which answer fits the context?"
81
+
82
+
83
+ # ---- isolated levels: every Score level is judged in its own row, without its number or its neighbours
84
+ ISOLATED = "{q}\nProposed answer: {level}\nDoes the proposed answer fit?"
85
+ _NUM = None
86
+
87
+
88
+ def strip_level_number(text):
89
+ """"2: somewhat" -> "somewhat" (dataset legends carry the number; an isolated level must not)."""
90
+ import re
91
+ return re.sub(r"^\s*-?\d+\s*:\s*", "", text)
92
+
93
+
94
+ def isolated_rows(question, levels):
95
+ """-> one yes/no question per level: [(question text, ["no", "yes"])]."""
96
+ return [(ISOLATED.format(q=question, level=strip_level_number(l)), ["no", "yes"]) for l in levels]
97
+
98
+
99
+ def combine_isolated(p_yes):
100
+ """Per-level P(fits), each computed without reference to any other level -> a distribution over levels.
101
+ Also returns the unnormalised mass: near 1 when exactly one level fits, low when none does, high when several do."""
102
+ tot = sum(p_yes) or 1e-9
103
+ return [x / tot for x in p_yes], tot
104
+
105
+
106
+ def plan_rows(rqs, isolated=True):
107
+ """One scoring row per question; a Score question with isolated levels becomes one yes/no row per level.
108
+ -> (rows [{"question", "options"}], index [(id, "iso" | "list", first row, n rows)])"""
109
+ rows, index = [], []
110
+ for k, r in rqs.items():
111
+ if isolated and r["type"] == "score" and r.get("isolated", True):
112
+ rws = isolated_rows(r["question"], r["legend"]); index.append((k, "iso", len(rows), len(rws))); rows += [dict(question=t, options=o) for t, o in rws]
113
+ else:
114
+ index.append((k, "list", len(rows), 1)); rows.append(dict(question=r["question"], options=r["options"]))
115
+ return rows, index
116
+
117
+
118
+ def row_types(rqs, index):
119
+ """The answer type ("choice", "noul" or "score") of every plan_rows row, in row order. An isolated Score question's
120
+ yes/no level rows carry "score": together they are one Score answer (decider.temperature)."""
121
+ types = [None] * sum(n for _, _, _, n in index)
122
+ for k, _, s, n in index:
123
+ types[s:s + n] = [rqs[k]["type"]] * n
124
+ return types
125
+
126
+
127
+ def assemble(rqs, index, probs):
128
+ """probs: one probability list per row (plan_rows order) -> {id: answer}."""
129
+ out = {}
130
+ for k, kind, s, n in index:
131
+ if kind == "iso":
132
+ fit = [float(probs[s + j][1]) for j in range(n)]; p, mass = combine_isolated(fit); a = format_answer(rqs[k], p)
133
+ a["level_fit"] = {str(j): round(x, 4) for j, x in enumerate(fit)}; a["fit_mass"] = round(mass, 4); out[k] = a
134
+ else:
135
+ out[k] = format_answer(rqs[k], probs[s])
136
+ return out
137
+
138
+
139
+ def certainty(p):
140
+ """1 - normalised entropy: 1 when all mass is on one option, 0 when the distribution is flat."""
141
+ h = -sum(x * math.log(x) for x in p if x > 0)
142
+ return max(0.0, 1.0 - h / math.log(len(p))) if len(p) > 1 else 1.0
143
+
144
+
145
+ def _clip01(x):
146
+ return min(1.0, max(0.0, x))
147
+
148
+
149
+ def _normalised(p):
150
+ """As the adapter's _normalize: a distribution with zero total counts as uniform."""
151
+ tot = sum(p)
152
+ return [1.0 / len(p)] * len(p) if tot == 0 else [x / tot for x in p]
153
+
154
+
155
+ def choice_confidence(p):
156
+ """TypeSafe's Choice confidence: the largest probability rescaled so that a uniform distribution gives 0 and all mass on one
157
+ option gives 1, (n * p_max - 1) / (n - 1); 1 for a single option."""
158
+ n = len(p); p = _normalised(p)
159
+ return 1.0 if n <= 1 else _clip01((n * max(p) - 1) / (n - 1))
160
+
161
+
162
+ def score_confidence(p):
163
+ """TypeSafe's Score confidence (system-one-adapter-python, confidence_metrics.score_confidence): 1 minus the expected distance
164
+ from the most likely level, divided by D = mean over levels i of |i - (n - 1)/2| (the mean distance of the levels from the
165
+ middle of the scale), floored at 0; 1 for a single level.
166
+ With two levels it equals choice_confidence."""
167
+ n = len(p); p = _normalised(p)
168
+ if n <= 1:
169
+ return 1.0
170
+ k = max(range(n), key=p.__getitem__)
171
+ spread = sum(x * abs(i - k) for i, x in enumerate(p))
172
+ uniform = sum(abs(i - (n - 1) / 2) for i in range(n)) / n
173
+ return _clip01(1.0 - spread / uniform)
174
+
175
+
176
+ def format_answer(rq, p, nd=4):
177
+ """rq: render_question output; p: probabilities in option order.
178
+ `confidence` is TypeSafe's (choice_confidence / score_confidence); `x_p_max` is the largest probability, which was
179
+ `confidence` before 1.3.0."""
180
+ p = [float(x) for x in p[:len(rq["options"])]]; s = sum(p) or 1.0; p = [x / s for x in p]
181
+ j = max(range(len(p)), key=p.__getitem__)
182
+ if rq["type"] == "noul":
183
+ return {"type": "noul", "noul": round(p[1], nd)}
184
+ if rq["type"] == "choice":
185
+ return {"type": "choice", "choice": rq["names"][j], "confidence": round(choice_confidence(p), nd), "x_p_max": round(p[j], nd),
186
+ "certainty": round(certainty(p), nd), "probabilities": {n: round(x, nd) for n, x in zip(rq["names"], p)}}
187
+ return {"type": "score", "score": round(sum(i * x for i, x in enumerate(p)), 2), "confidence": round(score_confidence(p), nd), "x_p_max": round(p[j], nd),
188
+ "certainty": round(certainty(p), nd),
189
+ "legend": {str(i): d for i, d in enumerate(rq["legend"])}, "probabilities": {str(i): round(x, nd) for i, x in enumerate(p)}}
190
+
191
+
192
+ def unique_tokens(items):
193
+ """Input tokens of a request whose rows share a prefix (the state): the prefix counts once."""
194
+ ids = [it["ids"] for it in items]
195
+ if len(ids) < 2:
196
+ return sum(len(x) for x in ids)
197
+ lcp = 0; short = min(len(x) for x in ids)
198
+ while lcp < short and all(x[lcp] == ids[0][lcp] for x in ids): lcp += 1
199
+ return lcp + sum(len(x) - lcp for x in ids)
decider/temperature.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Answer temperatures from decider_config.json (1.4.0).
2
+
3
+ Every answer is softmax(logits / T) over its option letters. decider_config.json sets T:
4
+
5
+ "temperature": 1.3 one value for every answer (the only form before 1.4.0)
6
+ "temperature_by_type": {"choice": 1.48, "noul": 2.22, "score": 1.38}
7
+ optional; one value per answer type, a missing type uses "temperature"
8
+ "temperature_schema_first": 1.18 optional; the schema cache (questions-first layout), as before
9
+ "temperature_schema_first_by_type": {...} optional; per answer type on the schema cache
10
+
11
+ The answer types are the /v1/systemone question types. A /decide field maps onto them: "choice" -> "choice", "bool" -> "noul",
12
+ "scale" -> "score". Plain `Decider.decide` questions (a question and its options, no type) are "choice". A Score question
13
+ read with isolated levels (one yes/no row per level) uses the "score" temperature on every one of its level rows: the rows form
14
+ one Score answer, and a temperature fitted on Score answers is fitted through that same readout (decider.calibrate).
15
+
16
+ On the state-first layout the temperature of an answer of type t is temperature_by_type[t], else temperature. On the schema
17
+ cache it is temperature_schema_first_by_type[t], else temperature_schema_first, else (no schema-first value at all) the
18
+ state-first temperature of t. An explicit override (Decider(temperature=...), DECIDER_TEMPERATURE) replaces the state-first
19
+ "temperature" and switches temperature_by_type off, so the override is the one temperature of every state-first answer, as it
20
+ was before 1.4.0.
21
+
22
+ A config without a by-type map gives every path the single number it gave in 1.3.0, and that number reaches the engines as the
23
+ same Python float, so the probabilities are bit-identical to 1.3.0.
24
+ """
25
+ import math
26
+
27
+ TYPES = ("choice", "noul", "score")
28
+ FIELD_TYPES = {"choice": "choice", "bool": "noul", "scale": "score"} # /decide field type -> answer type
29
+ _KEYS_TEXT = ('the keys are "choice", "noul" and "score" (a /decide "bool" field is "noul", a "scale" field is "score"; '
30
+ 'a /v1/systemone "bool" question is "noul")')
31
+
32
+
33
+ def positive(value, where):
34
+ """A temperature: a number (or a numeric string, as float() reads it, which 1.3.0 accepted for "temperature") that is finite
35
+ and > 0. Raises ValueError naming `where`."""
36
+ if isinstance(value, bool):
37
+ raise ValueError(f"{where} must be a finite number > 0, got {value!r}")
38
+ try:
39
+ v = float(value)
40
+ except (TypeError, ValueError):
41
+ raise ValueError(f"{where} must be a finite number > 0, got {value!r}") from None
42
+ if not math.isfinite(v) or v <= 0:
43
+ raise ValueError(f"{where} must be a finite number > 0, got {value!r}")
44
+ return v
45
+
46
+
47
+ def by_type(m, where):
48
+ """Validate a {answer type: temperature} map. None -> {}. Unknown keys, non-numbers and values that are not finite and > 0
49
+ raise ValueError."""
50
+ if m is None:
51
+ return {}
52
+ if not isinstance(m, dict):
53
+ raise ValueError(f"{where} must be a map {{answer type: temperature}}, got {type(m).__name__}; " + _KEYS_TEXT)
54
+ out = {}
55
+ for k, v in m.items():
56
+ if k not in TYPES:
57
+ raise ValueError(f"{where} has the unknown key {k!r}; " + _KEYS_TEXT)
58
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
59
+ raise ValueError(f"{where}[{k!r}] must be a finite number > 0, got {v!r}")
60
+ out[k] = positive(v, f"{where}[{k!r}]")
61
+ return out
62
+
63
+
64
+ def from_config(cfg, temperature=None, temperature_by_type=None):
65
+ """-> ((T, by_type) for the state-first layout, (T, by_type) for the schema cache).
66
+
67
+ temperature / temperature_by_type: explicit overrides (Decider arguments, DECIDER_TEMPERATURE). An explicit temperature
68
+ without an explicit map switches the config's map off (see the module docstring)."""
69
+ cfg = cfg or {}
70
+ where = "decider_config.json"
71
+ if temperature is not None:
72
+ T = positive(temperature, "temperature")
73
+ m = by_type(temperature_by_type, "temperature_by_type") if temperature_by_type is not None else {}
74
+ else:
75
+ T = positive(cfg.get("temperature", 1.0), f'{where} "temperature"')
76
+ m = (by_type(temperature_by_type, "temperature_by_type") if temperature_by_type is not None
77
+ else by_type(cfg.get("temperature_by_type"), f'{where} "temperature_by_type"'))
78
+ ms = by_type(cfg.get("temperature_schema_first_by_type"), f'{where} "temperature_schema_first_by_type"')
79
+ if "temperature_schema_first" in cfg:
80
+ schema = (positive(cfg["temperature_schema_first"], f'{where} "temperature_schema_first"'), ms)
81
+ else:
82
+ schema = (T, {**m, **ms})
83
+ return (T, m), schema
84
+
85
+
86
+ def effective(T, m):
87
+ """{answer type: the temperature it gets} for reporting (/health, the ready line)."""
88
+ return {t: m.get(t, T) for t in TYPES}
89
+
90
+
91
+ def for_types(T, m, types):
92
+ """One temperature per slot, or the scalar T itself when there is no map (the 1.3.0 call)."""
93
+ if not m:
94
+ return T
95
+ return [m.get(t, T) for t in types]
96
+
97
+
98
+ def item_types(it):
99
+ """The answer type of every slot of a prompt item ("types", set where the item is built); None for an item without it."""
100
+ ts = it.get("types")
101
+ return list(ts) if ts is not None else [None] * len(it["slots"])
102
+
103
+
104
+ def for_items(T, m, items):
105
+ """The `temperature` argument of Engine.score_items / score_shared: the scalar T when there is no map (the 1.3.0 call),
106
+ else one list of per-slot temperatures per item."""
107
+ if not m:
108
+ return T
109
+ return [[m.get(t, T) for t in item_types(it)] for it in items]
110
+
111
+
112
+ def slot_temperatures(temperature, items):
113
+ """Engine side. temperature: a scalar (returned as it is), or one entry per item, each a scalar or one value per slot of
114
+ that item. -> the scalar, or a flat list with one temperature per slot in item order."""
115
+ if not isinstance(temperature, (list, tuple)):
116
+ return temperature
117
+ if len(temperature) != len(items):
118
+ raise ValueError(f"temperature: {len(temperature)} entries for {len(items)} items")
119
+ flat = []
120
+ for t, it in zip(temperature, items):
121
+ n = len(it["slots"])
122
+ if isinstance(t, (list, tuple)):
123
+ if len(t) != n:
124
+ raise ValueError(f"temperature: {len(t)} values for an item with {n} slots")
125
+ flat += list(t)
126
+ else:
127
+ flat += [t] * n
128
+ return flat
129
+
130
+
131
+ def item_slice(temperature, lo, hi):
132
+ """The per-item temperature entries of items[lo:hi] (a scalar is shared by every item)."""
133
+ return temperature[lo:hi] if isinstance(temperature, (list, tuple)) else temperature
134
+
135
+
136
+ def scaled_softmax(lg, temperature):
137
+ """softmax(lg / T) over the last axis. A scalar T is the 1.3.0 expression unchanged; a list gives one T per row of lg."""
138
+ import torch
139
+ if isinstance(temperature, (list, tuple)):
140
+ if len(temperature) != lg.shape[0]:
141
+ raise ValueError(f"temperature: {len(temperature)} values for {lg.shape[0]} slots")
142
+ t = torch.tensor(temperature, dtype=lg.dtype).to(lg.device, non_blocking=True)[:, None]
143
+ return torch.softmax(lg / t, -1)
144
+ return torch.softmax(lg / temperature, -1)
requirements.txt CHANGED
@@ -1,7 +1,12 @@
1
  gradio>=5.0
 
 
 
 
2
  laya>=0.3.4
 
 
3
  torch>=2.0.0
4
- transformers>=4.45.0
5
  safetensors>=0.4.0
6
  huggingface_hub>=0.20.0
7
  numpy>=1.20.0
 
1
  gradio>=5.0
2
+ # transformers floor is set by Mapika/decider-4b (its Qwen3.5 linear-attention layers
3
+ # need >=5.17.0). convaiinnovations/laya only requires >=4.48.0 with no upper bound, and
4
+ # the laya authors' own ZeroGPU demo pins >=5.0, so one image can serve both backends.
5
+ transformers>=5.17.0
6
  laya>=0.3.4
7
+ accelerate
8
+ flash-linear-attention
9
  torch>=2.0.0
 
10
  safetensors>=0.4.0
11
  huggingface_hub>=0.20.0
12
  numpy>=1.20.0