helmo commited on
Commit
5ff9346
·
verified ·
1 Parent(s): f984f65

Compare DecidaBERT-large-v1.1 and v2: ground truth, benchmark case browser, more demos, question builder

Browse files
README.md CHANGED
@@ -8,22 +8,30 @@ sdk_version: 5.9.1
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
- short_description: DecidaBERT-large vs v2, typed decisions in one pass
12
  ---
13
 
14
- # Decida: DecidaBERT-large vs DecidaBERT-large-v2
15
 
16
- A free ZeroGPU demo that runs [DecidaBERT-large](https://huggingface.co/helmo/DecidaBERT-large) and
17
  [DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2) side by side: send a **state** and typed
18
- **questions** (`choice`, `score`, `noul`), get a probability distribution per question from each model, no
19
- generated text.
20
 
21
- The examples are chosen to show where the models differ: a long log whose key lines sit past v1's context, twelve
22
- questions answered in one pass, and decisions whose right answer is the measured middle option. The **Benchmark**
23
- tab has both models' scores on the Typed Decisions test split.
 
 
 
 
 
24
 
25
- This Space's code is independent of the main project: `model.py` is a standalone port of v1's inference and
26
- `decidabert_v2/` is v2's inference code as published with the model. The local runtime with a REST API, MCP tools
27
- and a testbench is at **https://github.com/heldernoid/decida**.
 
 
 
28
 
29
  Not affiliated with any other model author.
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
+ short_description: DecidaBERT-large-v1.1 vs v2, typed decisions in one pass
12
  ---
13
 
14
+ # Decida: DecidaBERT-large-v1.1 vs DecidaBERT-large-v2
15
 
16
+ A free ZeroGPU demo that runs [DecidaBERT-large-v1.1](https://huggingface.co/helmo/DecidaBERT-large-v1.1) and
17
  [DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2) side by side: send a **state** and typed
18
+ **questions** (`choice`, `score`, `noul`), get a probability distribution per question from each model, one forward
19
+ pass each, no generated text.
20
 
21
+ - **Benchmark cases:** any of the 400 cases of the Typed Decisions **test** split (never used for training), with the
22
+ benchmark's gold answers. Each model is marked ✓ or ✗ per question, and the gold distribution is shown beside them.
23
+ - **Hand-written demos:** a long incident log, a pull request with twelve questions, a phishing email, an invoice
24
+ that does not match its order, and more. Every question carries the demo author's reading of the right answer
25
+ (a judgment, not a benchmark label), so you can see where the models match it, and where they agree or disagree
26
+ with each other.
27
+ - **Your own:** write any state (text or JSON) and questions, or build them with the question form; add `"expected"`
28
+ to a question to have both models marked against it.
29
 
30
+ The **Benchmark** tab has both models' scores on the Typed Decisions test split, overall, per question type and per
31
+ workflow. The two are level overall and differ by workflow.
32
+
33
+ This Space's code is independent of the main project: `decidabert_v2/` is the inference code published with v2, and
34
+ it loads both models (same architecture). The local runtime with a REST API, MCP tools and a testbench is at
35
+ **https://github.com/heldernoid/decida**.
36
 
37
  Not affiliated with any other model author.
__pycache__/app.cpython-312.pyc DELETED
Binary file (21.3 kB)
 
__pycache__/app.cpython-313.pyc DELETED
Binary file (13.8 kB)
 
__pycache__/emoji_finder.cpython-312.pyc DELETED
Binary file (5.04 kB)
 
__pycache__/emoji_finder.cpython-313.pyc DELETED
Binary file (5.11 kB)
 
__pycache__/examples.cpython-312.pyc DELETED
Binary file (6.56 kB)
 
__pycache__/examples.cpython-313.pyc DELETED
Binary file (6.59 kB)
 
__pycache__/games.cpython-312.pyc DELETED
Binary file (8.95 kB)
 
__pycache__/games.cpython-313.pyc DELETED
Binary file (9.08 kB)
 
__pycache__/inbox.cpython-312.pyc DELETED
Binary file (2.5 kB)
 
__pycache__/inbox.cpython-313.pyc DELETED
Binary file (2.54 kB)
 
__pycache__/model.cpython-312.pyc DELETED
Binary file (20.6 kB)
 
__pycache__/model.cpython-313.pyc DELETED
Binary file (21 kB)
 
app.py CHANGED
@@ -1,20 +1,25 @@
1
- """DecidaBERT-large vs DecidaBERT-large-v2, side by side, free on ZeroGPU.
2
 
3
  The same state and typed questions go to both models; each answers every question with a probability distribution
4
- in one forward pass (v1: one sequence per question, up to 2,142 tokens; v2: one sequence for all questions, up to
5
- 8,192 tokens). model.py is the standalone port of v1 used by this Space before; decidabert_v2/ is v2's own
6
- inference code, as published with the model. Neither imports the `decida` package.
 
 
 
7
  """
8
  import html
9
  import json
10
  import os
 
11
  import time
12
 
13
  import gradio as gr
 
14
  import torch
 
15
 
16
- from decidabert_v2 import load as load_v2
17
- from model import Primitive, Question, load as load_v1, predict as predict_v1
18
 
19
  try:
20
  import spaces
@@ -23,25 +28,49 @@ except ImportError:
23
  def GPU(fn):
24
  return fn
25
 
26
- DEVICE = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
27
- V1, V1_LAYOUT = load_v1(DEVICE)
28
- V2 = load_v2(os.environ.get("DECIDABERT_V2", "helmo/DecidaBERT-large-v2"), DEVICE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def _long_incident_log() -> str:
32
- """~3,000 tokens of routine logs; the lines that matter come last, past v1's context and well inside v2's."""
33
  lines = []
34
  for i in range(80):
35
  mm, ss = divmod(i * 7, 60)
36
  svc = ["payments-api", "ledger-worker", "auth-gw", "notify"][i % 4]
37
- if i % 4 == 0:
38
- msg = f"POST /v2/charges 201 latency_ms={38 + (i * 13) % 41} merchant=m_{1000 + i}"
39
- elif i % 4 == 1:
40
- msg = f"batch {5000 + i} settled rows={200 + i % 57} lag_ms={12 + i % 9}"
41
- elif i % 4 == 2:
42
- msg = f"token refresh ok client=web-{i % 7} ttl=3600"
43
- else:
44
- msg = f"queued email receipt id=r_{77000 + i} provider=ok"
45
  lines.append(f"2026-11-14T09:{mm:02d}:{ss:02d}Z INFO {svc:13s} {msg}")
46
  lines += [
47
  "2026-11-14T09:28:01Z WARN ledger-worker db primary latency_ms=2400 (threshold 200)",
@@ -54,102 +83,150 @@ def _long_incident_log() -> str:
54
  return "\n".join(lines)
55
 
56
 
57
- PRESETS = {
 
58
  "Long incident log (~3k tokens)": {
59
- "why": "The failure is in the last lines of a long log. v1 reads about the first 2,100 tokens of a request, "
60
- "v2 reads up to 8,192, so only v2 sees the outage.",
61
  "state": _long_incident_log(),
62
  "questions": {
63
- "active_outage": {"type": "noul", "instructions": "Is there an active outage affecting customers right now?"},
64
  "severity": {"type": "score", "instructions": "How severe is the situation described by these logs?",
65
- "criteria": ["Normal operation", "Minor degradation", "Major degradation", "Critical outage"]},
66
  "next_action": {"type": "choice", "instructions": "What should the on-call engineer do?",
67
- "criteria": {"nothing": "Routine logs, no action needed",
68
- "watch": "Keep an eye on it, nothing is failing yet",
69
- "page_incident": "Declare an incident and page the database owners now"}},
70
  },
71
  },
72
  "Security alert: suspicious, not confirmed": {
73
  "why": "The right call is the measured middle option (investigate), not the extremes (close or contain).",
74
- "state": ("EDR alert on laptop FIN-2231: powershell.exe launched with an encoded command by excel.exe, "
75
- "15 minutes after the user opened 'Q4_invoice_review.xlsm' from an external sender. No network "
76
- "connection to known-bad addresses, no new services, no credential access observed so far. "
77
- "The user is in accounts payable and says the file came from a supplier they work with."),
78
  "questions": {
79
  "disposition": {"type": "choice", "instructions": "How should this alert be handled?",
80
  "criteria": {"close_benign": "Expected activity; close without analyst time",
81
  "monitor": "Not clearly malicious; watch for recurrence",
82
  "investigate": "Open an investigation and have an analyst review it",
83
- "contain": "Isolate the host immediately"}},
84
- "true_positive": {"type": "noul", "instructions": "Is this alert likely to be real malicious activity?"},
85
- "severity": {"type": "score", "instructions": "How severe is it?", "criteria": ["Low", "Medium", "High", "Critical"]},
86
  },
87
  },
88
- "Agent run review: partly done": {
89
- "why": "The agent made progress but stopped short; the measured answers are 'partial' and 'observe'.",
90
- "state": ("Task: migrate 12 cron jobs from the old scheduler to the new workflow engine and verify each runs. "
91
- "Agent trace summary: migrated 9 of 12 jobs; 9 test runs succeeded; jobs 10-12 skipped because their "
92
- "scripts need a secret the agent cannot read. No errors, no destructive actions, no constraint broken. "
93
- "The agent ended with: 'Done, migration complete.'"),
94
  "questions": {
95
  "outcome": {"type": "choice", "instructions": "How did this agent run turn out?",
96
  "criteria": {"success": "The task was completed correctly", "partial": "Progress made, task not fully done",
97
- "failure": "The task was not accomplished", "harmful": "Caused damage or broke a constraint"}},
 
 
 
98
  "action": {"type": "choice", "instructions": "What should the observability system do with this run?",
99
  "criteria": {"continue": "Let the agent proceed without interruption", "observe": "Keep running, flag for later sampling",
100
- "human_review": "Queue for a human to review", "stop": "Halt the agent now"}},
101
- "summary_accurate": {"type": "noul", "instructions": "Is the agent's final message an accurate summary of what it did?"},
102
  },
103
  },
104
  "Invoice vs purchase order": {
105
- "why": "The numbers disagree; the measured answer is to hold, not to approve.",
106
  "state": json.dumps({"invoice": {"vendor": "Northfield Office Supply", "po": "PO-88412", "lines": [
107
  {"item": "Ergonomic chair", "qty": 20, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
108
- "total": 4810.00},
109
  "purchase_order": {"po": "PO-88412", "lines": [
110
  {"item": "Ergonomic chair", "qty": 18, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
111
  "total": 4092.00},
112
  "goods_received": {"Ergonomic chair": 18, "Desk lamp": 20}}, indent=1),
113
  "questions": {
114
- "matches_order": {"type": "noul", "instructions": "Does the invoice match the purchase order and the goods received?"},
115
  "disposition": {"type": "choice", "instructions": "How should this invoice be handled?",
116
  "criteria": {"approve": "Matches; approve for payment", "hold": "Something needs confirming; hold pending clarification",
117
- "reject": "Should not be paid: duplicate, unauthorised or materially wrong"}},
118
  },
119
  },
120
- "12 questions at once": {
121
- "why": "v2 answers all 12 in one pass over the state; v1 builds one sequence per question (12 copies of the state).",
122
- "state": ("Pull request #2291 'Add retry with backoff to the payment webhook sender'. Changes: webhook_sender.py "
123
- "(+84 -12), tests/test_webhook_sender.py (+120), config/defaults.yaml (+3). Adds exponential backoff (base "
124
- "500ms, max 5 tries) and an idempotency key header. CI: 412 tests passed, coverage 81% (+2%). One reviewer "
125
- "approved; one comment unresolved asking whether retries could double-charge if the receiver ignores the "
126
- "idempotency key. Deploy target: production, Friday 17:30."),
127
  "questions": {
128
  "area": {"type": "choice", "instructions": "Which area does this change mainly touch?",
129
- "criteria": {"payments": "Payment flows", "auth": "Login and access", "ui": "User interface", "infra": "Build and infrastructure"}},
 
130
  "kind": {"type": "choice", "instructions": "What kind of change is it?",
131
- "criteria": {"feature": "New capability", "bugfix": "Fixes a defect", "reliability": "Makes an existing flow more robust", "refactor": "No behaviour change"}},
132
- "tests_added": {"type": "noul", "instructions": "Does the change add tests?"},
133
- "ci_green": {"type": "noul", "instructions": "Did CI pass?"},
134
- "open_concern": {"type": "noul", "instructions": "Is there an unresolved review concern?"},
135
- "money_risk": {"type": "noul", "instructions": "Could this change cause customers to be charged twice?"},
136
- "risk": {"type": "score", "instructions": "How risky is merging and deploying this now?", "criteria": ["Low", "Moderate", "High"]},
137
- "size": {"type": "score", "instructions": "How large is the change?", "criteria": ["Tiny", "Small", "Medium", "Large"]},
138
- "deploy_timing_ok": {"type": "noul", "instructions": "Is the planned deploy time a good idea?"},
 
 
139
  "next_step": {"type": "choice", "instructions": "What should happen next?",
140
  "criteria": {"merge": "Merge and deploy as planned", "resolve_first": "Resolve the open concern, then merge",
141
- "reject": "Close the PR"}},
142
- "needs_second_review": {"type": "noul", "instructions": "Should a payments specialist review it before merge?"},
143
- "release_note": {"type": "noul", "instructions": "Does this need a customer-facing release note?"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  },
145
  },
146
  "Tool-call gate": {
147
- "why": "An everyday agent gate with no right answer spelled out: compare how each model weighs it.",
148
  "state": "Agent wants to run: rm -rf ./build && git push --force origin main",
149
  "questions": {
 
150
  "action": {"type": "choice", "instructions": "What should happen to this command?",
151
- "criteria": {"allow": "Safe to run", "confirm": "Ask the user first", "block": "Do not run"}},
152
- "destructive": {"type": "noul", "instructions": "Can this command destroy work that cannot be recovered?"},
153
  },
154
  },
155
  "Billing routing": {
@@ -157,156 +234,331 @@ PRESETS = {
157
  "state": "Customer: my invoice was charged twice and nobody answers the phone!",
158
  "questions": {
159
  "department": {"type": "choice", "instructions": "Which team should handle this?",
160
- "criteria": {"billing": "Charges, invoices, refunds", "technical": "Bugs and outages", "sales": "Pricing, demos, new seats"}},
161
- "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["Calm", "Frustrated", "Very angry"]},
162
- "urgent": {"type": "noul", "instructions": "Is this urgent and needs an answer today?"},
 
 
 
 
 
 
 
 
 
 
163
  },
164
  },
165
  }
166
 
 
167
  CSS = """
168
- :root { --bg:#f4f5f8; --card:#fff; --ink:#141a2e; --ink2:#4a5270; --ink3:#7b839e; --line:#e3e6ee; --v1:#64748b; --v2:#4338ca; --track:#eceefb; --ok:#15803d; --diff:#b45309; }
169
- .gradio-container { max-width: 1180px !important; margin: 0 auto !important; font-family: -apple-system, "Segoe UI", sans-serif !important; background: var(--bg) !important; }
 
170
  #title h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 2px; }
171
  #subtitle { color: var(--ink2); font-size: 15px; margin-bottom: 6px; }
172
  .dc-why { background:#eef2ff; border:1px solid #c7d2fe; color:#312e81; border-radius:10px; padding:8px 12px; font-size:13.5px; }
173
- .dc-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; }
174
  .dc-card h3 { margin: 0 0 8px; font-size: 16px; }
175
- .dc-card.v1 h3 { color: var(--v1); } .dc-card.v2 h3 { color: var(--v2); }
176
  .dc-q { padding: 10px 0; border-top: 1px solid var(--line); }
177
  .dc-q:first-of-type { border-top: none; padding-top: 0; }
178
- .dc-qhead { display:flex; align-items:center; gap:8px; font-weight:700; font-size:14.5px; color: var(--ink); margin-bottom: 2px; }
179
  .dc-type { font: 700 10px ui-monospace, monospace; color: var(--ink3); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; text-transform: uppercase; }
180
  .dc-instr { color: var(--ink2); font-size: 12.5px; margin-bottom: 6px; }
181
  .dc-bar-row { display: grid; grid-template-columns: minmax(80px,150px) 1fr 44px; gap: 8px; align-items: center; margin: 4px 0; font-size: 13px; }
182
  .dc-bar-track { display: block; height: 9px; border-radius: 6px; background: var(--track); overflow: hidden; }
183
  .dc-bar-fill { display: block; height: 100%; border-radius: 6px; }
184
- .dc-bar-label.top { font-weight: 700; color: var(--ink); }
 
185
  .dc-bar-num { font-family: ui-monospace, monospace; text-align: right; color: var(--ink2); }
186
- .dc-meta { color: var(--ink3); font-size: 12.5px; margin-top: 8px; }
187
- .dc-meta b { color: var(--ink); }
188
  .dc-cmp { width:100%; border-collapse: collapse; font-size: 13.5px; }
189
- .dc-cmp th, .dc-cmp td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
190
- .dc-agree { color: var(--ok); font-weight: 700; } .dc-differ { color: var(--diff); font-weight: 700; }
 
 
191
  """
192
 
193
 
194
- def _top(a: dict) -> tuple[str, float]:
195
- if a["type"] == "noul":
196
- return ("yes", a["noul"]) if a["noul"] >= 0.5 else ("no", 1 - a["noul"])
197
- k = max(a["probabilities"], key=a["probabilities"].get)
198
- label = f"{k}: {a['legend'][k]}" if a["type"] == "score" and "legend" in a else k
199
- return label, a["probabilities"][k]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
 
 
 
 
 
201
 
202
- def _render(answers: dict, raw: dict, cls: str, title: str, meta: str) -> str:
203
- rgb = "100,116,139" if cls == "v1" else "67,56,202"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  blocks = []
205
- for qid, a in answers.items():
206
- items = [("yes", a["noul"]), ("no", 1 - a["noul"])] if a["type"] == "noul" else list(a["probabilities"].items())
207
- top_key = max(items, key=lambda kv: kv[1])[0]
208
- rows = []
209
- for k, v in items:
210
- label = f"{k}: {a['legend'][k]}" if a["type"] == "score" and "legend" in a else str(k)
211
- rows.append(f'<div class="dc-bar-row"><span class="dc-bar-label {"top" if k == top_key else ""}">{html.escape(label)}</span>'
212
- f'<span class="dc-bar-track"><span class="dc-bar-fill" style="width:{v * 100:.1f}%; background:rgba({rgb},{0.15 + 0.8 * v:.2f})"></span></span>'
213
- f'<span class="dc-bar-num">{v:.2f}</span></div>')
214
- blocks.append(f'<div class="dc-q"><div class="dc-qhead">{html.escape(qid)} <span class="dc-type">{a["type"]}</span></div>'
215
- f'<div class="dc-instr">{html.escape(str(raw[qid].get("instructions", "")))}</div>{"".join(rows)}</div>')
216
- return f'<div class="dc-card {cls}"><h3>{title}</h3>{"".join(blocks)}<div class="dc-meta">{meta}</div></div>'
217
-
218
-
219
- def _compare(a1: dict, a2: dict) -> str:
220
- rows, same = [], 0
221
- for qid in a2:
222
- t1, p1 = _top(a1[qid]); t2, p2 = _top(a2[qid])
223
- agree = t1 == t2; same += agree
224
- badge = '<span class="dc-agree">same</span>' if agree else '<span class="dc-differ">different</span>'
225
- rows.append(f"<tr><td><b>{html.escape(qid)}</b></td><td>{html.escape(t1)} ({p1:.2f})</td>"
226
- f"<td>{html.escape(t2)} ({p2:.2f}) · confidence {a2[qid]['confidence']:.2f}</td><td>{badge}</td></tr>")
227
- return (f'<div class="dc-card"><h3>Top answers: {same} of {len(a2)} the same</h3><table class="dc-cmp"><tr><th>question</th>'
228
- f'<th>v1</th><th>v2 (calibrated confidence)</th><th></th></tr>{"".join(rows)}</table></div>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
 
231
  @GPU
232
- def answer(state: str, questions_json: str):
233
  try:
234
- raw = json.loads(questions_json)
235
- except json.JSONDecodeError as exc:
236
- msg = f'<div class="dc-card">questions is not valid JSON: {html.escape(str(exc))}</div>'
237
- return msg, "", ""
 
 
 
 
 
 
238
  try:
239
- qs1 = {qid: Question(qid, Primitive(q["type"]), q.get("instructions", ""), q.get("criteria")) for qid, q in raw.items()}
240
- t0 = time.perf_counter(); a1 = predict_v1(V1, V1_LAYOUT, state, qs1); ms1 = (time.perf_counter() - t0) * 1000
241
- t0 = time.perf_counter(); a2 = V2.predict(state, raw); ms2 = (time.perf_counter() - t0) * 1000
242
- except (KeyError, ValueError, TypeError) as exc:
243
- msg = f'<div class="dc-card">bad question: {html.escape(str(exc))}</div>'
244
- return msg, "", ""
245
- n_tok = len(V2.layout.tok(state, add_special_tokens=False)["input_ids"])
246
- v1_read = V1_LAYOUT.build(state, next(iter(qs1.values())))
247
- v1_note = " · <b>state cut to fit</b>" if v1_read.truncated else ""
248
- v2_note = " · <b>state cut to fit</b>" if any(x.get("truncated_state") for x in a2.values()) else ""
249
- meta1 = f"<b>{ms1:.0f} ms</b> · {len(raw)} sequence(s), one per question · context 2,142{v1_note}"
250
- meta2 = f"<b>{ms2:.0f} ms</b> · 1 sequence for all questions · context 8,192{v2_note}"
251
- head = f'<div class="dc-meta">state: {n_tok:,} tokens · {len(raw)} question(s) · on {DEVICE}</div>'
252
- return (_compare(a1, a2) + head,
253
- _render(a1, raw, "v1", "DecidaBERT-large (v1)", meta1),
254
- _render(a2, raw, "v2", "DecidaBERT-large-v2", meta2))
255
-
256
-
257
- def _load_preset(name: str):
258
- p = PRESETS[name]
259
- return f'<div class="dc-why">{html.escape(p["why"])}</div>', p["state"], json.dumps(p["questions"], indent=2)
260
-
261
-
262
- BENCH = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  ### Typed Decisions test split (400 cases, 2,000 questions)
264
 
265
- Scored the same way for both models: a prediction is right when its top answer equals the gold label; 95% Wilson
266
- intervals. Both models were fitted on the benchmark's `train` split; the test split was not used for training.
267
 
268
- | question type | questions | DecidaBERT-large | DecidaBERT-large-v2 | v2 95% interval | random guess |
269
  |---|---|---|---|---|---|
270
- | choice | 600 | 0.722 | **0.747** | 0.710 to 0.780 | 0.233 |
271
- | score | 800 | 0.694 | **0.759** | 0.728 to 0.787 | 0.244 |
272
- | yes/no | 600 | 0.830 | **0.868** | 0.839 to 0.893 | 0.500 |
273
- | **all** | 2,000 | 0.743 (0.723 to 0.762) | **0.788** | **0.770 to 0.805** | |
 
 
 
 
 
274
 
275
- | | DecidaBERT-large | DecidaBERT-large-v2 |
276
  |---|---|---|
277
- | Context | trained at 512, up to 2,142 here | **8,192** |
278
- | Questions per forward pass | one | **all of them** |
279
- | Score within one level of gold | 95.5% | **97.6%** |
280
- | Reported confidence calibration error (test) | 0.130 | **0.023** |
281
- | Parameters | 421M | 395M |
282
-
283
- Models: [DecidaBERT-large](https://huggingface.co/helmo/DecidaBERT-large) ·
284
- [DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2)
 
 
285
  """
286
 
287
- with gr.Blocks(title="DecidaBERT v1 vs v2", css=CSS) as demo:
 
 
 
 
 
 
 
288
  with gr.Column(elem_id="title"):
289
- gr.Markdown("# DecidaBERT-large vs DecidaBERT-large-v2")
290
- gr.Markdown("The same state and typed questions (`choice`, `score`, `noul`) go to both models; each returns a "
291
- "probability distribution per question from a single forward pass, no generated text. "
292
- "Pick an example built to show where they differ, or write your own.", elem_id="subtitle")
293
  with gr.Tab("Compare"):
294
- first = next(iter(PRESETS))
295
- preset = gr.Radio(list(PRESETS), value=first, label="Example")
296
- why = gr.HTML(f'<div class="dc-why">{html.escape(PRESETS[first]["why"])}</div>')
297
  with gr.Row():
298
- state = gr.Textbox(label="State", lines=6, max_lines=14, value=PRESETS[first]["state"])
299
- questions = gr.Code(label="Questions (JSON)", language="json", value=json.dumps(PRESETS[first]["questions"], indent=2))
300
- run = gr.Button("Run both", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  summary = gr.HTML()
302
  with gr.Row():
303
- out1 = gr.HTML()
304
- out2 = gr.HTML()
 
305
  with gr.Tab("Benchmark"):
306
- gr.Markdown(BENCH)
307
- preset.change(_load_preset, preset, [why, state, questions])
308
- run.click(answer, [state, questions], [summary, out1, out2])
309
- demo.load(answer, [state, questions], [summary, out1, out2])
 
 
 
 
 
 
310
 
311
  if __name__ == "__main__":
312
  demo.launch()
 
1
+ """DecidaBERT-large-v1.1 vs DecidaBERT-large-v2, side by side, free on ZeroGPU.
2
 
3
  The same state and typed questions go to both models; each answers every question with a probability distribution
4
+ in one forward pass of up to 8,192 tokens. Both models share one architecture, so one inference package
5
+ (decidabert_v2/, as published with v2) loads both.
6
+
7
+ Ground truth: every question may carry an "expected" answer. Benchmark cases come from the Typed Decisions test split
8
+ (never used for training) with the benchmark's gold; the hand-written demos carry the demo author's reading of the
9
+ right answer (a judgment, not a benchmark label).
10
  """
11
  import html
12
  import json
13
  import os
14
+ import random
15
  import time
16
 
17
  import gradio as gr
18
+ import pandas as pd
19
  import torch
20
+ from huggingface_hub import hf_hub_download
21
 
22
+ from decidabert_v2 import load
 
23
 
24
  try:
25
  import spaces
 
28
  def GPU(fn):
29
  return fn
30
 
31
+ DEVICE = os.environ.get("DECIDA_DEVICE") or ("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
32
+ MODELS = [ # (key, display name, repo or folder, colour as r,g,b)
33
+ ("v11", "DecidaBERT-large-v1.1", os.environ.get("DECIDABERT_V11", "helmo/DecidaBERT-large-v1.1"), "15,118,110"),
34
+ ("v2", "DecidaBERT-large-v2", os.environ.get("DECIDABERT_V2", "helmo/DecidaBERT-large-v2"), "67,56,202"),
35
+ ]
36
+ LOADED = {k: load(path, DEVICE) for k, _, path, _ in MODELS}
37
+ TOK = LOADED["v2"].layout.tok
38
+
39
+ # ---- benchmark cases: LocalLLaMA/typed-decisions test split ------------------------------------------------------
40
+ BENCH = pd.read_parquet(hf_hub_download("LocalLLaMA/typed-decisions", "all/test-00000-of-00001.parquet", repo_type="dataset"))
41
+ WORKFLOWS = sorted(BENCH.workflow.unique())
42
+
43
+
44
+ def _label_to_expected(q: dict, label) -> str:
45
+ if q["type"] == "noul":
46
+ return "yes" if str(label).lower() in ("true", "1", "yes") else "no"
47
+ return str(label)
48
 
49
 
50
+ def bench_case(workflow: str, number: int):
51
+ rows = BENCH[BENCH.workflow == workflow].sort_values("id")
52
+ r = rows.iloc[int(number) % len(rows)]
53
+ state, qs, gold = json.loads(r["state"]), json.loads(r["questions"]), json.loads(r["gold"])
54
+ for qid, q in qs.items():
55
+ q["expected"] = _label_to_expected(q, gold[qid]["label"])
56
+ gold_dist = {qid: {"type": g["type"], "probabilities": g["probabilities"]} for qid, g in gold.items()}
57
+ why = (f"Benchmark case <b>{html.escape(r['id'])}</b> from the Typed Decisions <b>test</b> split, never used to train "
58
+ f"either model. The gold is the benchmark's teacher answer averaged over several samples; its top answer is "
59
+ f"the expected one, and its full distribution is shown on the right.")
60
+ return why, json.dumps(state, indent=1, ensure_ascii=False), json.dumps(qs, indent=2, ensure_ascii=False), gold_dist
61
+
62
+
63
+ # ---- hand-written demos -------------------------------------------------------------------------------------------
64
  def _long_incident_log() -> str:
65
+ """~3,000 tokens of routine logs; the lines that matter come last."""
66
  lines = []
67
  for i in range(80):
68
  mm, ss = divmod(i * 7, 60)
69
  svc = ["payments-api", "ledger-worker", "auth-gw", "notify"][i % 4]
70
+ msg = [f"POST /v2/charges 201 latency_ms={38 + (i * 13) % 41} merchant=m_{1000 + i}",
71
+ f"batch {5000 + i} settled rows={200 + i % 57} lag_ms={12 + i % 9}",
72
+ f"token refresh ok client=web-{i % 7} ttl=3600",
73
+ f"queued email receipt id=r_{77000 + i} provider=ok"][i % 4]
 
 
 
 
74
  lines.append(f"2026-11-14T09:{mm:02d}:{ss:02d}Z INFO {svc:13s} {msg}")
75
  lines += [
76
  "2026-11-14T09:28:01Z WARN ledger-worker db primary latency_ms=2400 (threshold 200)",
 
83
  return "\n".join(lines)
84
 
85
 
86
+ SEVERITY4 = ["Low", "Medium", "High", "Critical"]
87
+ DEMOS = {
88
  "Long incident log (~3k tokens)": {
89
+ "why": "The outage is in the last six lines of a long, routine log: a model has to read to the end.",
 
90
  "state": _long_incident_log(),
91
  "questions": {
92
+ "active_outage": {"type": "noul", "instructions": "Is there an active outage affecting customers right now?", "expected": "yes"},
93
  "severity": {"type": "score", "instructions": "How severe is the situation described by these logs?",
94
+ "criteria": ["Normal operation", "Minor degradation", "Major degradation", "Critical outage"], "expected": "3"},
95
  "next_action": {"type": "choice", "instructions": "What should the on-call engineer do?",
96
+ "criteria": {"nothing": "Routine logs, no action needed", "watch": "Keep an eye on it, nothing is failing yet",
97
+ "page_incident": "Declare an incident and page the database owners now"}, "expected": "page_incident"},
 
98
  },
99
  },
100
  "Security alert: suspicious, not confirmed": {
101
  "why": "The right call is the measured middle option (investigate), not the extremes (close or contain).",
102
+ "state": ("EDR alert on laptop FIN-2231: powershell.exe launched with an encoded command by excel.exe, 15 minutes after "
103
+ "the user opened 'Q4_invoice_review.xlsm' from an external sender. No network connection to known-bad "
104
+ "addresses, no new services, no credential access observed so far. The user is in accounts payable and says "
105
+ "the file came from a supplier they work with."),
106
  "questions": {
107
  "disposition": {"type": "choice", "instructions": "How should this alert be handled?",
108
  "criteria": {"close_benign": "Expected activity; close without analyst time",
109
  "monitor": "Not clearly malicious; watch for recurrence",
110
  "investigate": "Open an investigation and have an analyst review it",
111
+ "contain": "Isolate the host immediately"}, "expected": "investigate"},
112
+ "true_positive": {"type": "noul", "instructions": "Is this alert likely to be real malicious activity?", "expected": "yes"},
113
+ "severity": {"type": "score", "instructions": "How severe is it?", "criteria": SEVERITY4, "expected": "2"},
114
  },
115
  },
116
+ "Agent run review: claims more than it did": {
117
+ "why": "The agent finished 9 of 12 jobs but reported the whole migration as done.",
118
+ "state": ("Task: migrate 12 cron jobs from the old scheduler to the new workflow engine and verify each runs. Agent trace "
119
+ "summary: migrated 9 of 12 jobs; 9 test runs succeeded; jobs 10-12 skipped because their scripts need a secret "
120
+ "the agent cannot read. No errors, no destructive actions, no constraint broken. The agent ended with: "
121
+ "'Done, migration complete.'"),
122
  "questions": {
123
  "outcome": {"type": "choice", "instructions": "How did this agent run turn out?",
124
  "criteria": {"success": "The task was completed correctly", "partial": "Progress made, task not fully done",
125
+ "failure": "The task was not accomplished", "harmful": "Caused damage or broke a constraint"},
126
+ "expected": "partial"},
127
+ "summary_accurate": {"type": "noul", "instructions": "Is the agent's final message an accurate summary of what it did?",
128
+ "expected": "no"},
129
  "action": {"type": "choice", "instructions": "What should the observability system do with this run?",
130
  "criteria": {"continue": "Let the agent proceed without interruption", "observe": "Keep running, flag for later sampling",
131
+ "human_review": "Queue for a human to review", "stop": "Halt the agent now"},
132
+ "expected": "human_review"},
133
  },
134
  },
135
  "Invoice vs purchase order": {
136
+ "why": "The invoice bills 20 chairs; 18 were ordered and received. Hold it, don't pay it.",
137
  "state": json.dumps({"invoice": {"vendor": "Northfield Office Supply", "po": "PO-88412", "lines": [
138
  {"item": "Ergonomic chair", "qty": 20, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
139
+ "total": 4470.00},
140
  "purchase_order": {"po": "PO-88412", "lines": [
141
  {"item": "Ergonomic chair", "qty": 18, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
142
  "total": 4092.00},
143
  "goods_received": {"Ergonomic chair": 18, "Desk lamp": 20}}, indent=1),
144
  "questions": {
145
+ "matches_order": {"type": "noul", "instructions": "Does the invoice match the purchase order and the goods received?", "expected": "no"},
146
  "disposition": {"type": "choice", "instructions": "How should this invoice be handled?",
147
  "criteria": {"approve": "Matches; approve for payment", "hold": "Something needs confirming; hold pending clarification",
148
+ "reject": "Should not be paid: duplicate, unauthorised or materially wrong"}, "expected": "hold"},
149
  },
150
  },
151
+ "Pull request: 12 questions at once": {
152
+ "why": "Twelve questions about one pull request, all answered from a single read of it.",
153
+ "state": ("Pull request #2291 'Add retry with backoff to the payment webhook sender'. Changes: webhook_sender.py (+84 -12), "
154
+ "tests/test_webhook_sender.py (+120), config/defaults.yaml (+3). Adds exponential backoff (base 500ms, max 5 tries) "
155
+ "and an idempotency key header. CI: 412 tests passed, coverage 81% (+2%). One reviewer approved; one comment "
156
+ "unresolved asking whether retries could double-charge if the receiver ignores the idempotency key. Deploy "
157
+ "target: production, Friday 17:30."),
158
  "questions": {
159
  "area": {"type": "choice", "instructions": "Which area does this change mainly touch?",
160
+ "criteria": {"payments": "Payment flows", "auth": "Login and access", "ui": "User interface", "infra": "Build and infrastructure"},
161
+ "expected": "payments"},
162
  "kind": {"type": "choice", "instructions": "What kind of change is it?",
163
+ "criteria": {"feature": "New capability", "bugfix": "Fixes a defect", "reliability": "Makes an existing flow more robust",
164
+ "refactor": "No behaviour change"}, "expected": "reliability"},
165
+ "tests_added": {"type": "noul", "instructions": "Does the change add tests?", "expected": "yes"},
166
+ "ci_green": {"type": "noul", "instructions": "Did CI pass?", "expected": "yes"},
167
+ "open_concern": {"type": "noul", "instructions": "Is there an unresolved review concern?", "expected": "yes"},
168
+ "money_risk": {"type": "noul", "instructions": "Could this change cause customers to be charged twice?", "expected": "yes"},
169
+ "risk": {"type": "score", "instructions": "How risky is merging and deploying this now?", "criteria": ["Low", "Moderate", "High"],
170
+ "expected": "2"},
171
+ "size": {"type": "score", "instructions": "How large is the change?", "criteria": ["Tiny", "Small", "Medium", "Large"], "expected": "2"},
172
+ "deploy_timing_ok": {"type": "noul", "instructions": "Is deploying to production on Friday at 17:30 a good idea?", "expected": "no"},
173
  "next_step": {"type": "choice", "instructions": "What should happen next?",
174
  "criteria": {"merge": "Merge and deploy as planned", "resolve_first": "Resolve the open concern, then merge",
175
+ "reject": "Close the PR"}, "expected": "resolve_first"},
176
+ "needs_second_review": {"type": "noul", "instructions": "Should a payments specialist review it before merge?", "expected": "yes"},
177
+ "release_note": {"type": "noul", "instructions": "Does this need a customer-facing release note?", "expected": "yes"},
178
+ },
179
+ },
180
+ "Phishing email": {
181
+ "why": "A look-alike domain, urgency and a credential link: a textbook phishing attempt.",
182
+ "state": ("From: IT Service Desk <helpdesk@micros0ft-support.com>\nTo: j.alvarez@contoso-logistics.com\n"
183
+ "Subject: URGENT: your mailbox will be suspended in 2 hours\n\n"
184
+ "Dear user, we detected unusual sign-in activity. To keep your mailbox active, confirm your password within "
185
+ "2 hours at https://micros0ft-support.com/verify-account. Failure to do so will permanently delete your emails.\n"
186
+ "Regards, IT Service Desk\n\n[Mail gateway: SPF fail, sender domain registered 3 days ago, link domain not on allow-list]"),
187
+ "questions": {
188
+ "phishing": {"type": "noul", "instructions": "Is this email a phishing attempt?", "expected": "yes"},
189
+ "action": {"type": "choice", "instructions": "What should the mail security team do?",
190
+ "criteria": {"deliver": "Release it to the user's inbox", "warn": "Deliver with a warning banner",
191
+ "quarantine": "Quarantine it and block the sender domain"}, "expected": "quarantine"},
192
+ "severity": {"type": "score", "instructions": "How dangerous is it if a user acts on it?", "criteria": SEVERITY4, "expected": "2"},
193
+ },
194
+ },
195
+ "Refund request inside policy": {
196
+ "why": "Bought 9 days ago, unused, 30-day policy: a routine refund.",
197
+ "state": ("Ticket #48120. Customer: 'Hi, I ordered the Pro Wireless Headset (order A-55812) on Nov 5 but it turns out my "
198
+ "laptop has no Bluetooth. It's still sealed in the box. Can I get my money back?'\n"
199
+ "Order: paid 129.00 EUR on 2026-11-05, delivered 2026-11-07. Returns policy: full refund within 30 days of "
200
+ "delivery for unopened items. Customer since 2021, 14 orders, no previous refunds."),
201
+ "questions": {
202
+ "eligible": {"type": "noul", "instructions": "Is the customer eligible for a full refund under the policy?", "expected": "yes"},
203
+ "action": {"type": "choice", "instructions": "How should the agent respond?",
204
+ "criteria": {"approve_refund": "Approve the return and refund", "offer_credit": "Offer store credit instead",
205
+ "deny": "Decline: outside policy", "escalate": "Escalate to a supervisor"}, "expected": "approve_refund"},
206
+ "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["Calm", "Frustrated", "Very angry"],
207
+ "expected": "0"},
208
+ },
209
+ },
210
+ "Noisy alert that resolved itself": {
211
+ "why": "A short CPU spike during a scheduled batch job, already back to normal: no page needed.",
212
+ "state": ("Alert: HighCPU on batch-worker-07 (non-production). CPU 86% for 4 minutes at 02:03-02:07, threshold 80% for 3 "
213
+ "minutes. Status: RESOLVED automatically at 02:07. Context: the nightly report job runs 02:00-02:10 on this host; "
214
+ "the same alert fired and resolved on 11 of the last 14 nights. No errors, no customer-facing service on the host."),
215
+ "questions": {
216
+ "page_on_call": {"type": "noul", "instructions": "Should the on-call engineer be woken up for this?", "expected": "no"},
217
+ "action": {"type": "choice", "instructions": "What should be done with this alert?",
218
+ "criteria": {"page": "Page on-call now", "ticket": "Open a ticket to tune the alert threshold or schedule",
219
+ "ignore": "Acknowledge and do nothing"}, "expected": "ticket"},
220
+ "severity": {"type": "score", "instructions": "How severe is it?", "criteria": SEVERITY4, "expected": "0"},
221
  },
222
  },
223
  "Tool-call gate": {
224
+ "why": "An everyday agent gate: a force-push to main is destructive whatever the build folder holds.",
225
  "state": "Agent wants to run: rm -rf ./build && git push --force origin main",
226
  "questions": {
227
+ "destructive": {"type": "noul", "instructions": "Can this command destroy work that cannot be recovered?", "expected": "yes"},
228
  "action": {"type": "choice", "instructions": "What should happen to this command?",
229
+ "criteria": {"allow": "Safe to run", "confirm": "Ask the user first", "block": "Do not run"}, "expected": "confirm"},
 
230
  },
231
  },
232
  "Billing routing": {
 
234
  "state": "Customer: my invoice was charged twice and nobody answers the phone!",
235
  "questions": {
236
  "department": {"type": "choice", "instructions": "Which team should handle this?",
237
+ "criteria": {"billing": "Charges, invoices, refunds", "technical": "Bugs and outages", "sales": "Pricing, demos, new seats"},
238
+ "expected": "billing"},
239
+ "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["Calm", "Frustrated", "Very angry"],
240
+ "expected": "1"},
241
+ "urgent": {"type": "noul", "instructions": "Is this urgent and needs an answer today?", "expected": "yes"},
242
+ },
243
+ },
244
+ "Blank: write your own": {
245
+ "why": "Write any state (text or JSON) and your own questions; add \"expected\" to a question to have both models marked.",
246
+ "state": "",
247
+ "questions": {
248
+ "example": {"type": "choice", "instructions": "Replace this with your question.",
249
+ "criteria": {"option_a": "What option A means", "option_b": "What option B means"}, "expected": "option_a"},
250
  },
251
  },
252
  }
253
 
254
+ # ---- answering and rendering ---------------------------------------------------------------------------------------
255
  CSS = """
256
+ :root { --bg:#f4f5f8; --card:#fff; --ink:#141a2e; --ink2:#4a5270; --ink3:#7b839e; --line:#e3e6ee; --track:#eceefb;
257
+ --v11:#0f766e; --v2:#4338ca; --gold:#a16207; --ok:#15803d; --bad:#b91c1c; }
258
+ .gradio-container { max-width: 1240px !important; margin: 0 auto !important; font-family: -apple-system, "Segoe UI", sans-serif !important; }
259
  #title h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 2px; }
260
  #subtitle { color: var(--ink2); font-size: 15px; margin-bottom: 6px; }
261
  .dc-why { background:#eef2ff; border:1px solid #c7d2fe; color:#312e81; border-radius:10px; padding:8px 12px; font-size:13.5px; }
262
+ .dc-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; color: var(--ink); }
263
  .dc-card h3 { margin: 0 0 8px; font-size: 16px; }
264
+ .dc-card.v11 h3 { color: var(--v11); } .dc-card.v2 h3 { color: var(--v2); } .dc-card.gold h3 { color: var(--gold); }
265
  .dc-q { padding: 10px 0; border-top: 1px solid var(--line); }
266
  .dc-q:first-of-type { border-top: none; padding-top: 0; }
267
+ .dc-qhead { display:flex; align-items:center; gap:8px; font-weight:700; font-size:14.5px; margin-bottom: 2px; }
268
  .dc-type { font: 700 10px ui-monospace, monospace; color: var(--ink3); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; text-transform: uppercase; }
269
  .dc-instr { color: var(--ink2); font-size: 12.5px; margin-bottom: 6px; }
270
  .dc-bar-row { display: grid; grid-template-columns: minmax(80px,150px) 1fr 44px; gap: 8px; align-items: center; margin: 4px 0; font-size: 13px; }
271
  .dc-bar-track { display: block; height: 9px; border-radius: 6px; background: var(--track); overflow: hidden; }
272
  .dc-bar-fill { display: block; height: 100%; border-radius: 6px; }
273
+ .dc-bar-label { overflow-wrap: anywhere; } .dc-bar-label.top { font-weight: 700; }
274
+ .dc-bar-label.exp::after { content: " ★"; color: var(--gold); }
275
  .dc-bar-num { font-family: ui-monospace, monospace; text-align: right; color: var(--ink2); }
276
+ .dc-meta { color: var(--ink3); font-size: 12.5px; margin-top: 8px; } .dc-meta b { color: var(--ink); }
 
277
  .dc-cmp { width:100%; border-collapse: collapse; font-size: 13.5px; }
278
+ .dc-cmp th, .dc-cmp td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); vertical-align: top; }
279
+ .dc-ok { color: var(--ok); font-weight: 700; } .dc-bad { color: var(--bad); font-weight: 700; } .dc-na { color: var(--ink3); }
280
+ .dc-score { display:flex; gap:18px; flex-wrap:wrap; font-size:15px; margin-bottom:10px; }
281
+ .dc-score b.v11 { color: var(--v11); } .dc-score b.v2 { color: var(--v2); }
282
  """
283
 
284
 
285
+ def _parse_state(text: str):
286
+ """JSON states are passed as objects (rendered the way the benchmark was scored); anything else as text."""
287
+ s = text.strip()
288
+ if s[:1] in "{[":
289
+ try:
290
+ return json.loads(s)
291
+ except json.JSONDecodeError:
292
+ pass
293
+ return text
294
+
295
+
296
+ def _keys(q: dict) -> list[str]:
297
+ c = q.get("criteria")
298
+ if q["type"] == "noul":
299
+ return ["yes", "no"]
300
+ if q["type"] == "choice":
301
+ return [str(x) for x in (c if isinstance(c, list) else list(c))]
302
+ return [str(i) for i in range(len(c))]
303
+
304
+
305
+ def _levels(q: dict) -> list[str]:
306
+ c = q.get("criteria") or []
307
+ return list(c.values()) if isinstance(c, dict) else list(c)
308
+
309
+
310
+ def _expected(q: dict) -> str | None:
311
+ """Normalise a question's "expected" answer to the key the models' top answer uses (yes/no, label, level index)."""
312
+ e = q.get("expected")
313
+ if e is None or e == "":
314
+ return None
315
+ if q["type"] == "noul":
316
+ return "yes" if str(e).strip().lower() in ("yes", "true", "1", "y") else "no"
317
+ if q["type"] == "score":
318
+ s = str(e).strip()
319
+ if s.isdigit():
320
+ return s
321
+ levels = [str(x).strip().lower() for x in _levels(q)]
322
+ return str(levels.index(s.lower())) if s.lower() in levels else s
323
+ return str(e).strip()
324
+
325
+
326
+ def _dist(a: dict) -> dict[str, float]:
327
+ return {"yes": a["noul"], "no": 1 - a["noul"]} if a["type"] == "noul" else a["probabilities"]
328
+
329
 
330
+ def _label(q: dict, k: str) -> str:
331
+ if q["type"] == "score":
332
+ lv = _levels(q)
333
+ return f"{k}: {lv[int(k)]}" if k.isdigit() and int(k) < len(lv) else k
334
+ return k
335
 
336
+
337
+ def _bars(q: dict, dist: dict[str, float], rgb: str, exp: str | None) -> str:
338
+ top = max(dist, key=dist.get)
339
+ rows = []
340
+ for k, v in dist.items():
341
+ cls = ("top " if k == top else "") + ("exp" if k == exp else "")
342
+ rows.append(f'<div class="dc-bar-row"><span class="dc-bar-label {cls}">{html.escape(_label(q, k))}</span>'
343
+ f'<span class="dc-bar-track"><span class="dc-bar-fill" style="width:{v * 100:.1f}%; '
344
+ f'background:rgba({rgb},{0.15 + 0.8 * v:.2f})"></span></span><span class="dc-bar-num">{v:.2f}</span></div>')
345
+ return "".join(rows)
346
+
347
+
348
+ def _mark(ok: bool | None) -> str:
349
+ return '<span class="dc-na">–</span>' if ok is None else ('<span class="dc-ok">✓</span>' if ok else '<span class="dc-bad">✗</span>')
350
+
351
+
352
+ def _model_card(name: str, cls: str, rgb: str, answers: dict, qs: dict, meta: str) -> str:
353
+ blocks = []
354
+ for qid, q in qs.items():
355
+ d, exp = _dist(answers[qid]), _expected(q)
356
+ ok = None if exp is None else max(d, key=d.get) == exp
357
+ blocks.append(f'<div class="dc-q"><div class="dc-qhead">{_mark(ok) if ok is not None else ""} {html.escape(qid)} '
358
+ f'<span class="dc-type">{q["type"]}</span></div>'
359
+ f'<div class="dc-instr">{html.escape(str(q.get("instructions", "")))}</div>{_bars(q, d, rgb, exp)}'
360
+ f'<div class="dc-meta">confidence {answers[qid]["confidence"]:.2f}</div></div>')
361
+ return f'<div class="dc-card {cls}"><h3>{name}</h3>{"".join(blocks)}<div class="dc-meta">{meta}</div></div>'
362
+
363
+
364
+ def _gold_card(qs: dict, gold: dict | None) -> str:
365
  blocks = []
366
+ for qid, q in qs.items():
367
+ exp = _expected(q)
368
+ g = (gold or {}).get(qid)
369
+ if g and g["type"] == q["type"]:
370
+ d = g["probabilities"]
371
+ d = {"yes": float(d.get("true", 0)), "no": float(d.get("false", 0))} if q["type"] == "noul" else {k: float(v) for k, v in d.items()}
372
+ body = _bars(q, d, "161,98,7", exp) if set(d) == set(_keys(q)) else ""
373
+ else:
374
+ body = ""
375
+ shown = html.escape(_label(q, exp)) if exp is not None else '<span class="dc-na">no expected answer given</span>'
376
+ blocks.append(f'<div class="dc-q"><div class="dc-qhead">{html.escape(qid)} <span class="dc-type">{q["type"]}</span></div>'
377
+ f'<div class="dc-instr">expected: <b>{shown}</b></div>{body}</div>')
378
+ src = ("benchmark gold (teacher average)" if gold else
379
+ "expected answers given with the questions (for the demos: the demo author's reading, not a benchmark label)")
380
+ return f'<div class="dc-card gold"><h3>Ground truth</h3>{"".join(blocks)}<div class="dc-meta">{src}; ★ marks the expected answer</div></div>'
381
+
382
+
383
+ def _summary(qs: dict, results: dict, n_tok: int) -> str:
384
+ rows, scored = [], [qid for qid, q in qs.items() if _expected(q) is not None]
385
+ hits, agree = {k: 0 for k, *_ in MODELS}, 0
386
+ for qid, q in qs.items():
387
+ exp = _expected(q)
388
+ cells, tops = [], []
389
+ for k, *_ in MODELS:
390
+ d = _dist(results[k][0][qid]); top = max(d, key=d.get); tops.append(top)
391
+ ok = None if exp is None else top == exp
392
+ hits[k] += bool(ok)
393
+ cells.append(f"<td>{_mark(ok)} {html.escape(_label(q, top))} ({d[top]:.2f})</td>")
394
+ same = len(set(tops)) == 1; agree += same
395
+ badge = '<span class="dc-ok">agree</span>' if same else '<span class="dc-bad">disagree</span>'
396
+ ex = html.escape(_label(q, exp)) if exp is not None else '<span class="dc-na">–</span>'
397
+ rows.append(f"<tr><td><b>{html.escape(qid)}</b></td><td>{ex}</td>{''.join(cells)}<td>{badge}</td></tr>")
398
+ score = '<div class="dc-score">' + "".join(
399
+ f'<span><b class="{k}">{name}</b>: {hits[k]} of {len(scored)} match the expected answer</span>' for k, name, *_ in MODELS
400
+ ) * bool(scored) + f'<span>the two models agree on <b>{agree} of {len(qs)}</b></span></div>'
401
+ head = "".join(f"<th>{name}</th>" for _, name, *_ in MODELS)
402
+ return (f'<div class="dc-card">{score}<table class="dc-cmp"><tr><th>question</th><th>expected</th>{head}<th>models</th></tr>{"".join(rows)}</table>'
403
+ f'<div class="dc-meta">state: {n_tok:,} tokens · {len(qs)} question(s) · one forward pass per model · on {DEVICE}</div></div>')
404
 
405
 
406
  @GPU
407
+ def answer(state: str, questions_json: str, gold: dict | None):
408
  try:
409
+ qs = json.loads(questions_json)
410
+ assert isinstance(qs, dict) and qs, "questions must be a JSON object {question_id: {...}}"
411
+ for qid, q in qs.items():
412
+ assert q.get("type") in ("noul", "choice", "score"), f"{qid}: type must be noul, choice or score"
413
+ assert q["type"] == "noul" or q.get("criteria"), f"{qid}: choice and score questions need criteria"
414
+ except (json.JSONDecodeError, AssertionError) as exc:
415
+ return f'<div class="dc-card">Questions: {html.escape(str(exc))}</div>', "", "", ""
416
+ clean = {qid: {k: v for k, v in q.items() if k != "expected"} for qid, q in qs.items()}
417
+ st = _parse_state(state)
418
+ results = {}
419
  try:
420
+ for k, *_ in MODELS:
421
+ t0 = time.perf_counter(); a = LOADED[k].predict(st, clean); results[k] = (a, (time.perf_counter() - t0) * 1000)
422
+ except (KeyError, ValueError, TypeError, IndexError) as exc:
423
+ return f'<div class="dc-card">Bad question: {html.escape(str(exc))}</div>', "", "", ""
424
+ n_tok = len(TOK(st if isinstance(st, str) else json.dumps(st, ensure_ascii=False), add_special_tokens=False)["input_ids"])
425
+ cards = []
426
+ for k, name, _, rgb in MODELS:
427
+ a, ms = results[k]
428
+ cut = " · <b>state cut to fit 8,192 tokens</b>" if any(x.get("truncated_state") for x in a.values()) else ""
429
+ cards.append(_model_card(name, k, rgb, a, qs, f"<b>{ms:.0f} ms</b>{cut}"))
430
+ return _summary(qs, results, n_tok), cards[0], cards[1], _gold_card(qs, gold)
431
+
432
+
433
+ def load_demo(name: str):
434
+ p = DEMOS[name]
435
+ return f'<div class="dc-why">{html.escape(p["why"])}</div>', p["state"], json.dumps(p["questions"], indent=2, ensure_ascii=False), None
436
+
437
+
438
+ def load_bench(workflow: str, number: float):
439
+ why, st, qs, gold = bench_case(workflow, int(number))
440
+ return f'<div class="dc-why">{why}</div>', st, qs, gold
441
+
442
+
443
+ def random_bench():
444
+ w = random.choice(WORKFLOWS); n = random.randrange(100)
445
+ return (w, n) + load_bench(w, n)
446
+
447
+
448
+ def add_question(questions_json: str, qid: str, qtype: str, instructions: str, options: str, expected: str):
449
+ try:
450
+ qs = json.loads(questions_json) if questions_json.strip() else {}
451
+ except json.JSONDecodeError:
452
+ return questions_json, "The questions box is not valid JSON; fix or clear it first."
453
+ qid = (qid or "").strip().replace(" ", "_")
454
+ if not qid or not instructions.strip():
455
+ return questions_json, "A question needs an id and instructions."
456
+ q = {"type": qtype, "instructions": instructions.strip()}
457
+ lines = [l.strip() for l in options.splitlines() if l.strip()]
458
+ if qtype == "choice":
459
+ if len(lines) < 2:
460
+ return questions_json, "A choice question needs at least two options, one per line (label: description)."
461
+ q["criteria"] = {l.split(":", 1)[0].strip().replace(" ", "_"): (l.split(":", 1)[1].strip() if ":" in l else "") for l in lines}
462
+ elif qtype == "score":
463
+ if len(lines) < 2:
464
+ return questions_json, "A score question needs at least two levels, lowest first, one per line."
465
+ q["criteria"] = lines
466
+ if expected.strip():
467
+ q["expected"] = expected.strip()
468
+ qs[qid] = q
469
+ return json.dumps(qs, indent=2, ensure_ascii=False), f"Added {qid}."
470
+
471
+
472
+ BENCH_MD = """
473
  ### Typed Decisions test split (400 cases, 2,000 questions)
474
 
475
+ Scored the same way for every model: a prediction is right when its top answer equals the gold label; 95% Wilson
476
+ intervals. All models were fitted on the benchmark's `train` split; the test split was not used for training.
477
 
478
+ | question type | questions | DecidaBERT-large (v1) | DecidaBERT-large-v1.1 | DecidaBERT-large-v2 | random guess |
479
  |---|---|---|---|---|---|
480
+ | choice | 600 | 0.722 | **0.747** | **0.747** | 0.233 |
481
+ | score | 800 | 0.694 | 0.755 | **0.759** | 0.244 |
482
+ | yes/no | 600 | 0.830 | 0.850 | **0.868** | 0.500 |
483
+ | **all** | 2,000 | 0.743 (0.723 to 0.762) | 0.781 (0.762 to 0.799) | **0.788** (0.770 to 0.805) | |
484
+
485
+ **v1.1 against v2, question by question:** level overall (difference −0.007, 95% interval −0.021 to +0.008; 99
486
+ questions only v2 got right, 85 only v1.1). By workflow, v1.1 is ahead on invoice processing (0.832 vs 0.806) and
487
+ behind on security incidents (0.750 vs 0.786); customer service 0.810 vs 0.830, agent traces 0.732 vs 0.730. The two
488
+ give the same top answer on 90% of the questions.
489
 
490
+ | | DecidaBERT-large-v1.1 | DecidaBERT-large-v2 |
491
  |---|---|---|
492
+ | Base | ModernBERT-large-nli | ModernBERT-large |
493
+ | Context | 8,192 | 8,192 |
494
+ | Questions per forward pass | all of them | all of them |
495
+ | Score within one level of gold | **98.3%** | 97.6% |
496
+ | Reported confidence calibration error (test) | 0.024 | **0.023** |
497
+ | Benchmark scorer: accuracy / KL | 0.776 / **0.0852** | **0.7835** / 0.0861 |
498
+
499
+ Models: [DecidaBERT-large-v1.1](https://huggingface.co/helmo/DecidaBERT-large-v1.1) ·
500
+ [DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2) ·
501
+ [DecidaBERT-large](https://huggingface.co/helmo/DecidaBERT-large) (v1)
502
  """
503
 
504
+ QUESTION_HELP = """**Question format.** `{"id": {"type": "noul" | "choice" | "score", "instructions": "...", "criteria": ..., "expected": ...}}`
505
+ · `noul`: yes/no, no criteria needed; expected `yes` or `no`.
506
+ · `choice`: criteria `{"label": "what it means", ...}`; expected a label.
507
+ · `score`: criteria a list of levels, lowest first; expected a level number (0 = first) or its text.
508
+ `expected` is optional; when given, both models are marked ✓ or ✗ against it."""
509
+
510
+ with gr.Blocks(title="DecidaBERT v1.1 vs v2", css=CSS) as demo:
511
+ gold_state = gr.State(None)
512
  with gr.Column(elem_id="title"):
513
+ gr.Markdown("# DecidaBERT-large-v1.1 vs DecidaBERT-large-v2")
514
+ gr.Markdown("Two decision models, one forward pass each: send a state and typed questions (`choice`, `score`, `noul`), "
515
+ "get a probability distribution per question, no generated text. Try a benchmark case with its gold "
516
+ "answers, a hand-written demo, or your own.", elem_id="subtitle")
517
  with gr.Tab("Compare"):
 
 
 
518
  with gr.Row():
519
+ with gr.Column(scale=3):
520
+ demo_pick = gr.Dropdown(list(DEMOS), value=next(iter(DEMOS)), label="Hand-written demo")
521
+ with gr.Column(scale=2):
522
+ bench_wf = gr.Dropdown(WORKFLOWS, value=WORKFLOWS[0], label="Benchmark case: workflow")
523
+ with gr.Column(scale=2):
524
+ bench_n = gr.Slider(0, 99, value=0, step=1, label="case number (test split)")
525
+ with gr.Column(scale=1, min_width=120):
526
+ bench_load = gr.Button("Load case")
527
+ bench_rand = gr.Button("Random case")
528
+ why = gr.HTML(f'<div class="dc-why">{html.escape(DEMOS[next(iter(DEMOS))]["why"])}</div>')
529
+ with gr.Row():
530
+ state = gr.Textbox(label="State (text or JSON)", lines=10, max_lines=24, value=DEMOS[next(iter(DEMOS))]["state"])
531
+ questions = gr.Code(label="Questions (JSON)", language="json", lines=10,
532
+ value=json.dumps(DEMOS[next(iter(DEMOS))]["questions"], indent=2))
533
+ with gr.Accordion("Add a question", open=False):
534
+ gr.Markdown(QUESTION_HELP)
535
+ with gr.Row():
536
+ nq_id = gr.Textbox(label="id", placeholder="e.g. escalate")
537
+ nq_type = gr.Dropdown(["noul", "choice", "score"], value="noul", label="type")
538
+ nq_exp = gr.Textbox(label="expected (optional)", placeholder="yes / a label / a level number")
539
+ nq_ins = gr.Textbox(label="instructions", placeholder="e.g. Should this ticket go to a human agent?")
540
+ nq_opts = gr.Textbox(label="options (choice: one 'label: meaning' per line; score: one level per line, lowest first)", lines=3)
541
+ with gr.Row():
542
+ nq_add = gr.Button("Add to questions")
543
+ nq_clear = gr.Button("Clear all questions")
544
+ nq_msg = gr.Markdown()
545
+ run = gr.Button("Run both models", variant="primary")
546
  summary = gr.HTML()
547
  with gr.Row():
548
+ out_a = gr.HTML()
549
+ out_b = gr.HTML()
550
+ out_g = gr.HTML()
551
  with gr.Tab("Benchmark"):
552
+ gr.Markdown(BENCH_MD)
553
+
554
+ io = [why, state, questions, gold_state]
555
+ demo_pick.change(load_demo, demo_pick, io)
556
+ bench_load.click(load_bench, [bench_wf, bench_n], io)
557
+ bench_rand.click(random_bench, None, [bench_wf, bench_n] + io)
558
+ nq_add.click(add_question, [questions, nq_id, nq_type, nq_ins, nq_opts, nq_exp], [questions, nq_msg])
559
+ nq_clear.click(lambda: ("{}", "Cleared."), None, [questions, nq_msg])
560
+ run.click(answer, [state, questions, gold_state], [summary, out_a, out_b, out_g])
561
+ demo.load(answer, [state, questions, gold_state], [summary, out_a, out_b, out_g])
562
 
563
  if __name__ == "__main__":
564
  demo.launch()
model.py DELETED
@@ -1,241 +0,0 @@
1
- """A standalone port of DecidaBERT-large's inference (the decision heads and the Laya-format prompt layout).
2
-
3
- This intentionally does not import the `decida` package: it is independent code for a Gradio Space, reading only
4
- the public checkpoint at https://huggingface.co/helmo/DecidaBERT-large. The algorithm matches
5
- `src/decida/model/enc.py` in the main project, ported here because ZeroGPU Spaces only support the Gradio SDK, not
6
- a plain server process like `decida serve`. If the real project's encoder logic changes, this file needs updating
7
- by hand; it is not generated from it.
8
- """
9
- from __future__ import annotations
10
-
11
- import json
12
- import os
13
- from dataclasses import dataclass
14
- from enum import Enum
15
- from typing import Any
16
-
17
- import numpy as np
18
- import torch
19
- from safetensors.torch import load_file
20
- from torch import nn
21
- from transformers import AutoConfig, AutoModel, AutoTokenizer
22
-
23
- MODEL_REPO = "helmo/DecidaBERT-large"
24
-
25
-
26
- class Primitive(str, Enum):
27
- CHOICE = "choice"
28
- SCORE = "score"
29
- NOUL = "noul"
30
-
31
-
32
- @dataclass
33
- class Question:
34
- qid: str
35
- primitive: Primitive
36
- instructions: str
37
- criteria: Any
38
-
39
-
40
- @dataclass
41
- class Encoded:
42
- input_ids: torch.Tensor
43
- attention_mask: torch.Tensor
44
- readout_positions: torch.Tensor
45
- n_options: int
46
- truncated: bool
47
-
48
-
49
- @dataclass
50
- class Batch:
51
- input_ids: torch.Tensor
52
- attention_mask: torch.Tensor
53
- readout_positions: torch.Tensor
54
- marker_mask: torch.Tensor
55
- qtypes: torch.Tensor
56
-
57
-
58
- def _render_criterion(value: Any) -> str:
59
- if isinstance(value, str):
60
- return value
61
- return json.dumps(value, ensure_ascii=False, separators=(", ", ": "), default=str)
62
-
63
-
64
- def _render_options(q: Question) -> list[str]:
65
- if q.primitive == Primitive.CHOICE:
66
- return [k if not v else f"{k}: {_render_criterion(v)}" for k, v in q.criteria.items()]
67
- if q.primitive == Primitive.SCORE:
68
- return [f"level {i}: {_render_criterion(c)}" for i, c in enumerate(q.criteria)]
69
- crit = q.criteria or {}
70
- false_str = _render_criterion(crit.get("false")) if crit.get("false") else "no, the statement does not hold"
71
- true_str = _render_criterion(crit.get("true")) if crit.get("true") else "yes, the statement holds"
72
- return [f"false: {false_str}", f"true: {true_str}"]
73
-
74
-
75
- class LayaLayout:
76
- """Builds one [CLS] instructions [SEP] <mask>opt1 <mask>opt2 ... [SEP] state [SEP] sequence per question."""
77
-
78
- def __init__(self, tokenizer, max_len: int = 2142, head_max_len: int = 1400, opt_max_tokens: int = 160):
79
- self.tok, self.max_len, self.head_max_len, self.opt_max_tokens = tokenizer, max_len, head_max_len, opt_max_tokens
80
-
81
- def build(self, state: str, q: Question) -> Encoded:
82
- mask_tok = self.tok.mask_token
83
- opts = _render_options(q)
84
- ins = q.instructions if isinstance(q.instructions, str) else json.dumps(q.instructions, ensure_ascii=False)
85
- if mask_tok:
86
- ins = ins.replace(mask_tok, " ")
87
- head_ids = self.tok(f"{q.primitive.value} question: {ins}", add_special_tokens=False)["input_ids"]
88
-
89
- opt_ids = []
90
- for o in opts:
91
- text_ids = self.tok(" " + o.replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
92
- opt_ids.append([self.tok.mask_token_id] + text_ids[: self.opt_max_tokens])
93
-
94
- opt_budget = self.head_max_len - sum(len(o) for o in opt_ids)
95
- if opt_budget < 16:
96
- per = max(4, (self.head_max_len - 16) // max(1, len(opt_ids)))
97
- opt_ids = [o[:per] for o in opt_ids]
98
- opt_budget = self.head_max_len - sum(len(o) for o in opt_ids)
99
-
100
- head_ids = head_ids[: max(8, opt_budget)]
101
- ids = [self.tok.cls_token_id] + head_ids + [self.tok.sep_token_id]
102
- markers = []
103
- for o in opt_ids:
104
- markers.append(len(ids))
105
- ids.extend(o)
106
- ids.append(self.tok.sep_token_id)
107
-
108
- room = max(0, self.max_len - len(ids) - 1)
109
- state_str = json.dumps(state, ensure_ascii=False) if isinstance(state, (dict, list)) else str(state)
110
- st = self.tok(state_str.replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
111
- truncated = len(st) > room
112
- ids = ids + st[:room] + [self.tok.sep_token_id]
113
-
114
- final_ids = ids[: self.max_len]
115
- valid_markers = [m for m in markers if m < self.max_len]
116
- return Encoded(
117
- input_ids=torch.tensor(final_ids, dtype=torch.long),
118
- attention_mask=torch.ones(len(final_ids), dtype=torch.long),
119
- readout_positions=torch.tensor(valid_markers, dtype=torch.long),
120
- n_options=len(opts),
121
- truncated=truncated,
122
- )
123
-
124
-
125
- class DecidaEncModel(nn.Module):
126
- """The decision heads on top of the ModernBERT backbone: a type embedding, an optional small transformer
127
- head, and a scorer that reads one logit out of each option's <mask> position."""
128
-
129
- def __init__(self, encoder: nn.Module, head_layers: int = 2, n_act: int = 2, cfg: dict[str, Any] | None = None):
130
- super().__init__()
131
- self.encoder, self.cfg = encoder, cfg or {}
132
- d = int(encoder.config.hidden_size)
133
- layer = nn.TransformerEncoderLayer(d_model=d, nhead=max(1, d // 64), dim_feedforward=4 * d, dropout=0.1, batch_first=True, norm_first=True)
134
- self.head = nn.TransformerEncoder(layer, head_layers, enable_nested_tensor=False) if head_layers > 0 else None
135
- self.type_emb = nn.Embedding(3, d)
136
- self.scorer = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, d), nn.GELU(approximate="none"), nn.Linear(d, 1))
137
- self.act_head = nn.Sequential(nn.Linear(d + 4, 256), nn.GELU(approximate="none"), nn.Linear(256, n_act))
138
- self.qtypes_map = {"choice": 0, "score": 1, "noul": 2}
139
-
140
- def forward(self, batch: Batch) -> torch.Tensor:
141
- h = self.encoder(input_ids=batch.input_ids, attention_mask=batch.attention_mask).last_hidden_state
142
- h = h + self.type_emb(batch.qtypes)[:, None, :]
143
- if self.head is not None:
144
- pad = ~batch.attention_mask.bool()
145
- for layer in self.head.layers:
146
- h = layer(h, src_key_padding_mask=pad)
147
- idx = batch.readout_positions.clamp(min=0)[:, :, None].expand(-1, -1, h.size(-1))
148
- logits = self.scorer(torch.gather(h, 1, idx)).squeeze(-1).float()
149
- return logits.masked_fill(~batch.marker_mask, float("-inf"))
150
-
151
- @classmethod
152
- def from_pretrained(cls, path: str) -> DecidaEncModel:
153
- weights = load_file(os.path.join(path, "model.safetensors"))
154
- cfg_path = os.path.join(path, "rl_agent_config.json")
155
- cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {}
156
- ecfg = AutoConfig.from_pretrained(os.path.join(path, "encoder"))
157
- enc = AutoModel.from_config(ecfg, attn_implementation="sdpa")
158
-
159
- act_weight = weights.get("act_head.2.weight", weights.get("core_model.act_head.2.weight"))
160
- if act_weight is None:
161
- raise ValueError(f"{path} has no act_head.2.weight: not a Decida or Laya encoder checkpoint")
162
- n_act, head_layers, new_weights = act_weight.shape[0], 0, {}
163
- for k, v in weights.items():
164
- k = k.removeprefix("core_model.")
165
- new_weights[k] = v
166
- if k.startswith("head.layers."):
167
- head_layers = max(head_layers, int(k.split(".")[2]) + 1)
168
-
169
- model = cls(enc, head_layers=head_layers, n_act=n_act, cfg=cfg)
170
- model.load_state_dict(new_weights, strict=False)
171
- return model
172
-
173
-
174
- def load(device: str = "cuda") -> tuple[DecidaEncModel, LayaLayout]:
175
- """Download `helmo/DecidaBERT-large` from the Hub and build the model plus its prompt layout."""
176
- from huggingface_hub import snapshot_download
177
-
178
- path = snapshot_download(MODEL_REPO)
179
- model = DecidaEncModel.from_pretrained(path).to(device).eval()
180
- tokenizer = AutoTokenizer.from_pretrained(os.path.join(path, "tokenizer") if os.path.isdir(os.path.join(path, "tokenizer")) else path)
181
- # DecidaBERT-large was trained/prompted at a 2142-token context; the checkpoint's own config can override this.
182
- layout = LayaLayout(tokenizer, max_len=model.cfg.get("max_len", 2142),
183
- head_max_len=model.cfg.get("head_max_len", 1400), opt_max_tokens=model.cfg.get("opt_max_tokens", 160))
184
- return model, layout
185
-
186
-
187
- @torch.no_grad()
188
- def predict(model: DecidaEncModel, layout: LayaLayout, state: str, questions: dict[str, Question]) -> dict[str, dict[str, Any]]:
189
- """Answer every question in one batched forward pass. Returns {qid: {type, ...distribution fields}}."""
190
- items = [{"qid": qid, "encoded": layout.build(state, q), "qtype": model.qtypes_map[q.primitive.value]} for qid, q in questions.items()]
191
- if not items:
192
- return {}
193
-
194
- L = max(len(it["encoded"].input_ids) for it in items)
195
- kmax = max(it["encoded"].n_options for it in items)
196
- n = len(items)
197
- pad_id = layout.tok.pad_token_id or 0
198
- ids = torch.full((n, L), pad_id, dtype=torch.long)
199
- att = torch.zeros((n, L), dtype=torch.long)
200
- mpos = torch.zeros((n, kmax), dtype=torch.long)
201
- mmask = torch.zeros((n, kmax), dtype=torch.bool)
202
- qtypes = torch.zeros(n, dtype=torch.long)
203
- for i, it in enumerate(items):
204
- enc = it["encoded"]
205
- ids[i, : len(enc.input_ids)] = enc.input_ids
206
- att[i, : len(enc.attention_mask)] = enc.attention_mask
207
- mpos[i, : enc.n_options] = enc.readout_positions
208
- mmask[i, : enc.n_options] = True
209
- qtypes[i] = it["qtype"]
210
-
211
- device = next(model.parameters()).device
212
- batch = Batch(ids.to(device), att.to(device), mpos.to(device), mmask.to(device), qtypes.to(device))
213
- logits = model.forward(batch).cpu().numpy()
214
-
215
- qtypes_inv = {v: k for k, v in model.qtypes_map.items()}
216
- temp_array = model.cfg.get("temperature", [1.0, 1.0, 1.0])
217
- temp_by_opt = model.cfg.get("temperature_by_options", {})
218
-
219
- answers: dict[str, dict[str, Any]] = {}
220
- for r, it in enumerate(items):
221
- qid, q, k = it["qid"], questions[it["qid"]], it["encoded"].n_options
222
- z = logits[r, :k]
223
- size = "2" if k <= 2 else "3-5" if k <= 5 else "6-10" if k <= 10 else "11+"
224
- bucket = f"{qtypes_inv[it['qtype']]}:{size}"
225
- t_scale = temp_by_opt.get(bucket, temp_array[it["qtype"]])
226
- z = z / max(1e-3, float(t_scale))
227
- p = np.exp(z - z.max())
228
- p = p / p.sum()
229
-
230
- if q.primitive == Primitive.CHOICE:
231
- keys = list(q.criteria.keys())
232
- probs = {kk: float(v) for kk, v in zip(keys, p)}
233
- answers[qid] = {"type": "choice", "choice": keys[int(p.argmax())], "probabilities": probs, "confidence": float(max(probs.values()))}
234
- elif q.primitive == Primitive.SCORE:
235
- probs = {str(i): float(v) for i, v in enumerate(p)}
236
- answers[qid] = {"type": "score", "score": float((np.arange(k) * p).sum()),
237
- "legend": {str(i): str(c) for i, c in enumerate(q.criteria)}, "probabilities": probs, "confidence": float(max(probs.values()))}
238
- else:
239
- noul_prob = float(p[1])
240
- answers[qid] = {"type": "noul", "noul": noul_prob, "confidence": float(noul_prob if noul_prob > 0.5 else 1 - noul_prob)}
241
- return answers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -4,4 +4,6 @@ transformers>=5.0
4
  safetensors>=0.4.0
5
  numpy
6
  huggingface_hub
 
 
7
  requests
 
4
  safetensors>=0.4.0
5
  numpy
6
  huggingface_hub
7
+ pandas
8
+ pyarrow
9
  requests