IDU 0.2.0: add decider-4b as a second backend
Browse files- app.py +269 -39
- decider/__init__.py +0 -0
- decider/infer.py +357 -0
- decider/model.py +49 -0
- decider/prompt.py +287 -0
- decider/systemone.py +199 -0
- decider/temperature.py +144 -0
- requirements.txt +6 -1
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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 236 |
-
|
|
|
|
| 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
|
| 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 |
-
"
|
| 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 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
| 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["
|
| 569 |
-
print(f"[startup] warm: {_AGENT['
|
| 570 |
except Exception as _e: # pragma: no cover
|
| 571 |
-
print(f"[startup]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|