Mapika commited on
Commit
0f8ae8e
·
verified ·
1 Parent(s): 0b3249c

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: Qwen/Qwen3.5-0.8B-Base
4
+ language: [en]
5
+ pipeline_tag: text-classification
6
+ tags: [decision-model, calibrated, structured-output, multi-task, system-one, one-pass]
7
+ ---
8
+
9
+ # decider-0.8b: typed decisions with calibrated probabilities in one forward pass
10
+
11
+ The small sibling of [decider-2b](https://huggingface.co/Mapika/decider-2b): a language model that does not generate text. It reads
12
+ a **state** (a string or any JSON value) and a set of **typed questions** and returns a probability distribution for every question
13
+ from a single forward pass: Choice (2-255 options, optionally described), Score (2-10 described levels), Noul (probability of yes).
14
+ No decoding, no parsing, no output outside the options you defined. Same code, same wire format (`POST /v1/systemone`, TypeSafe
15
+ Jev's format), same training recipe as the 2B: one epoch of `scripts/train.sh full` from `Qwen/Qwen3.5-0.8B-Base` over the full
16
+ mixture (1.47M examples, 455M tokens, 4.5 h on one GH200). Code: https://github.com/Mapika/decider
17
+
18
+ ```python
19
+ # pip install git+https://github.com/Mapika/decider
20
+ from decider.infer import Decider
21
+ d = Decider("Mapika/decider-0.8b")
22
+ d.system_one(
23
+ {"ticket": "I was charged twice for order A-104. Please refund the duplicate."},
24
+ {"team": {"type": "choice", "instructions": "Which team should handle this?",
25
+ "criteria": {"billing": "Charges, invoices, refunds", "technical": "Bugs, outages", "other": None}},
26
+ "refund_requested": {"type": "noul", "instructions": "Does the customer ask for a refund?"},
27
+ "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["calm", "frustrated", "very frustrated"]}})
28
+ ```
29
+
30
+ ## How it compares with the 2B
31
+
32
+ Same 93 public tasks, same protocol, one temperature fitted on in-task data (it came out at 1.03 for both: the recipe calibrates
33
+ at every size). "Held-out" means no example of that dataset was trained on.
34
+
35
+ | | decider-0.8b | decider-2b (same single-run recipe) |
36
+ |---|---|---|
37
+ | in-task accuracy / ECE, 69 tasks | 0.776 / 0.032 | 0.809 / 0.030 |
38
+ | held-out accuracy / ECE, 24 tasks | 0.707 / 0.096 | 0.739 / 0.086 |
39
+ | schema-first layout (the cacheable one), in-task / held-out accuracy | 0.770 / 0.699 | 0.790 / 0.707 |
40
+ | teacher-written custom questions, held-out domains: noul / choice / score | 0.94 / 0.95 / 0.81 | 0.98 / 0.97 / 0.83 |
41
+ | terse-bucket routing, held-out domains: generic / specific / catch-all | 0.87 / 0.93 / 0.84 | 0.91 / 0.94 / 0.92 |
42
+ | JSON state, one of 16 / 64 records named by path (64 with indices written in) | 0.58 / 0.52 (0.62) | 0.59 / 0.53 (0.63) |
43
+ | all 64 / 50 / 70 / 219 labels at once: HWU64, TREC-fine, DBpedia L2, L3 (held-out) | 0.81 / 0.63 / 0.66 / 0.83 | 0.85 / 0.76 / 0.72 / 0.86 |
44
+ | QuALITY, whole article (5-8k tokens) | 0.63 | 0.68 |
45
+ | isolated Score levels vs listwise (teacher-written, held-out domains) | 0.83 vs 0.82, fits sum to 1.02 | 0.84 vs 0.84 |
46
+ | hand-written battery: generic option right / catch-all right | 0.95 / 0.95 | 0.90 / 0.85 |
47
+
48
+ What the smaller model gives up is knowledge, not the decision format: the largest drops are TruthfulQA (0.41 vs 0.55), OpenBookQA
49
+ (0.67 vs 0.81), HellaSwag (0.76 vs 0.88) and ARC (0.74 vs 0.86), and wide label sets that need fine distinctions (TREC-fine). Routing,
50
+ classification, yes/no judgments and JSON lookups on short states are within one to four points of the 2B. It is a weaker player:
51
+ Pong and Breakout stay at the scripted teacher's level, CliffWalking fails (it walks off the cliff), held-out Freeway scores 0.
52
+ The regression set runs about 1.5x faster than on the 2B; bf16 weights are 1.5 GB.
53
+
54
+ ## Limitations
55
+
56
+ Those of decider-2b, more so: a small model without reasoning; English only; rules written into the question ("fill if empty,
57
+ otherwise skip") are not followed reliably, so state the decision as a plain question with described options; knowledge-heavy
58
+ multiple choice is close to the base model; calibration is measured on public datasets and teacher-labelled probes, not on your
59
+ traffic. The teacher-written training data comes from Qwen3.5-27B and carries its biases.
chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is true %}
150
+ {{- '<think>\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n\n</think>\n\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
config.json ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForCausalLM"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "attn_output_gate": true,
8
+ "bos_token_id": null,
9
+ "dtype": "bfloat16",
10
+ "eos_token_id": 248044,
11
+ "full_attention_interval": 4,
12
+ "head_dim": 256,
13
+ "hidden_act": "silu",
14
+ "hidden_size": 1024,
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 3584,
17
+ "layer_types": [
18
+ "linear_attention",
19
+ "linear_attention",
20
+ "linear_attention",
21
+ "full_attention",
22
+ "linear_attention",
23
+ "linear_attention",
24
+ "linear_attention",
25
+ "full_attention",
26
+ "linear_attention",
27
+ "linear_attention",
28
+ "linear_attention",
29
+ "full_attention",
30
+ "linear_attention",
31
+ "linear_attention",
32
+ "linear_attention",
33
+ "full_attention",
34
+ "linear_attention",
35
+ "linear_attention",
36
+ "linear_attention",
37
+ "full_attention",
38
+ "linear_attention",
39
+ "linear_attention",
40
+ "linear_attention",
41
+ "full_attention"
42
+ ],
43
+ "linear_conv_kernel_dim": 4,
44
+ "linear_key_head_dim": 128,
45
+ "linear_num_key_heads": 16,
46
+ "linear_num_value_heads": 16,
47
+ "linear_value_head_dim": 128,
48
+ "mamba_ssm_dtype": "float32",
49
+ "max_position_embeddings": 262144,
50
+ "mlp_only_layers": [],
51
+ "model_type": "qwen3_5_text",
52
+ "mtp_num_hidden_layers": 1,
53
+ "mtp_use_dedicated_embeddings": false,
54
+ "num_attention_heads": 8,
55
+ "num_hidden_layers": 24,
56
+ "num_key_value_heads": 2,
57
+ "pad_token_id": null,
58
+ "partial_rotary_factor": 0.25,
59
+ "rms_norm_eps": 1e-06,
60
+ "rope_parameters": {
61
+ "mrope_interleaved": true,
62
+ "mrope_section": [
63
+ 11,
64
+ 11,
65
+ 10
66
+ ],
67
+ "partial_rotary_factor": 0.25,
68
+ "rope_theta": 10000000,
69
+ "rope_type": "default"
70
+ },
71
+ "tie_word_embeddings": true,
72
+ "transformers_version": "5.17.0",
73
+ "use_cache": true,
74
+ "vocab_size": 248320
75
+ }
decider/__init__.py ADDED
File without changes
decider/engine.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Low-latency inference engine: shape-bucketed CUDA graphs over the one-pass decision model.
2
+
3
+ Right padding + causal layers => pad positions never influence earlier slots, so no attention
4
+ mask is needed and every (B, T) bucket can be captured once and replayed. The graph outputs
5
+ option-letter logits for all positions [B, T, K]; slots are gathered outside.
6
+ """
7
+ import time, torch, torch._dynamo, torch.nn.functional as F
8
+ from decider.model import DecisionModel, collate
9
+ from decider.prompt import build, MAX_OPTIONS
10
+
11
+ T_BUCKETS = [64, 128, 192, 256, 320, 384, 512, 640, 768, 1024, 1280, 1536, 2048]
12
+ B_BUCKETS = [1, 2, 4, 8, 16, 32, 64]
13
+ GRAPH_MAX_T = 2048 # longer inputs (up to the 32k request budget) run eagerly: compute dominates there, and one graph
14
+ LONG_STEP = 1024 # per (B, T) shape would cost a compile + capture for every new length
15
+
16
+
17
+ def _bucket(x, buckets):
18
+ for b in buckets:
19
+ if x <= b:
20
+ return b
21
+ return None
22
+
23
+
24
+ def fused_causal_conv1d_fn(hidden_states, weight, bias=None, activation=None, **kwargs):
25
+ """Depthwise causal conv (kernel k) as k shifted multiply-adds: fuses under torch.compile,
26
+ unlike the cuDNN grouped conv fallback (which was ~11% of batched GPU time)."""
27
+ B, C, T = hidden_states.shape; k = weight.shape[-1]
28
+ x = F.pad(hidden_states.to(weight.dtype), (k - 1, 0))
29
+ out = x[:, :, k - 1:k - 1 + T] * weight[:, k - 1][None, :, None]
30
+ for j in range(k - 1):
31
+ out = out + x[:, :, j:j + T] * weight[:, j][None, :, None]
32
+ if bias is not None:
33
+ out = out + bias[None, :, None]
34
+ if activation == "silu":
35
+ out = F.silu(out)
36
+ elif activation is not None:
37
+ from transformers.activations import ACT2FN
38
+ out = ACT2FN[activation](out)
39
+ return out.to(hidden_states.dtype)
40
+
41
+
42
+ def patch_conv():
43
+ from transformers.models.qwen3_5 import modeling_qwen3_5 as mq
44
+ mq.causal_conv1d_fn = fused_causal_conv1d_fn
45
+
46
+
47
+ def read_slots(out, rows, slots, nopts, temperature, n_per_item):
48
+ """One gather + one softmax + one device-to-host copy for the whole batch (was: three small kernels and a sync per item).
49
+ out [B, T, K] logits; rows/slots/nopts: flat python lists, one entry per question; n_per_item: questions per item."""
50
+ dev = out.device; idx = torch.tensor([rows, slots, nopts], dtype=torch.long).to(dev, non_blocking=True)
51
+ lg = out[idx[0], idx[1]] # [N, K]
52
+ lg = lg.masked_fill(torch.arange(lg.shape[1], device=dev)[None, :] >= idx[2][:, None], float("-inf"))
53
+ p = torch.softmax(lg / temperature, -1).cpu()
54
+ return list(torch.split(p, n_per_item))
55
+
56
+
57
+ def fill_ids(items_ids, B, T, pad):
58
+ import numpy as np
59
+ a = np.full((B, T), pad, dtype=np.int64)
60
+ for b, x in enumerate(items_ids): a[b, :len(x)] = x
61
+ return torch.from_numpy(a)
62
+
63
+
64
+ class Engine:
65
+ """compile: torch.compile the forward (needs use_cache=False; ~1.4x batched, fuses elementwise work).
66
+ fp8: e4m3 weights + per-token activation scaling on the big linears (Hopper tensor cores).
67
+ conv_patch: fusable depthwise causal conv instead of the cuDNN fallback."""
68
+ def __init__(self, path, device="cuda", dtype=torch.bfloat16, use_graphs=True, max_ctx_tokens=1536,
69
+ compile=True, fp8=False, conv_patch=True):
70
+ if conv_patch:
71
+ patch_conv()
72
+ self.m = DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
73
+ self.tok = self.m.tok; self.dev = device; self.use_graphs = use_graphs; self.max_ctx = max_ctx_tokens
74
+ self.core, self.W = self.m.lm.model, self.m.lm.lm_head.weight[self.m.letters].detach().clone()
75
+ self.cfg = dict(compile=compile, fp8=fp8, conv_patch=conv_patch, graphs=use_graphs)
76
+ if fp8:
77
+ from decider.fp8 import convert_to_fp8
78
+ self.cfg["fp8_layers"] = convert_to_fp8(self.core)
79
+ if compile:
80
+ torch._dynamo.config.cache_size_limit = 128
81
+ self._fwd_impl = torch.compile(self._fwd_eager, dynamic=False)
82
+ else:
83
+ self._fwd_impl = self._fwd_eager
84
+ self.graphs = {} # (B, T) -> (static_ids, static_out, graph)
85
+ self.pool = torch.cuda.graph_pool_handle() if use_graphs else None
86
+ self.stats = dict(graph_captures=0, forwards=0)
87
+
88
+ def _fwd_eager(self, ids):
89
+ h = self.core(input_ids=ids, use_cache=False).last_hidden_state
90
+ return F.linear(h, self.W).float() # [B, T, K]
91
+
92
+ @torch.no_grad()
93
+ def _fwd(self, ids):
94
+ return self._fwd_impl(ids)
95
+
96
+ def _capture(self, B, T):
97
+ s_ids = torch.full((B, T), self.tok.pad_token_id, dtype=torch.long, device=self.dev)
98
+ st = torch.cuda.Stream(); st.wait_stream(torch.cuda.current_stream())
99
+ with torch.cuda.stream(st):
100
+ for _ in range(3): self._fwd(s_ids) # warm-up: compile / triton autotune
101
+ torch.cuda.current_stream().wait_stream(st)
102
+ g = torch.cuda.CUDAGraph()
103
+ with torch.cuda.graph(g, pool=self.pool):
104
+ s_out = self._fwd(s_ids)
105
+ self.stats["graph_captures"] += 1
106
+ return s_ids, s_out, g
107
+
108
+ @torch.no_grad()
109
+ def logits_all(self, ids):
110
+ """ids: [B, T] long on device (already right-padded to a bucket). Returns [B, T, K] float."""
111
+ B, T = ids.shape; self.stats["forwards"] += 1
112
+ if T > GRAPH_MAX_T:
113
+ self.stats["long_forwards"] = self.stats.get("long_forwards", 0) + 1
114
+ return self._fwd_eager(ids)
115
+ if not self.use_graphs:
116
+ return self._fwd(ids)
117
+ key = (B, T)
118
+ if key not in self.graphs:
119
+ self.graphs[key] = self._capture(B, T)
120
+ s_ids, s_out, g = self.graphs[key]
121
+ s_ids.copy_(ids); g.replay()
122
+ return s_out
123
+
124
+ @torch.no_grad()
125
+ def score_items(self, items, temperature=1.0):
126
+ """items: list of dicts from prompt.build. Returns list of [n_q, MAX_OPTIONS] prob tensors (cpu)."""
127
+ Tmax = max(len(it["ids"]) for it in items)
128
+ T = _bucket(Tmax, T_BUCKETS) or -(-Tmax // LONG_STEP) * LONG_STEP
129
+ B = (_bucket(len(items), B_BUCKETS) or len(items)) if T <= GRAPH_MAX_T else len(items)
130
+ ids = fill_ids([it["ids"] for it in items], B, T, self.tok.pad_token_id)
131
+ out = self.logits_all(ids.to(self.dev, non_blocking=True))
132
+ return read_slots(out, [b for b, it in enumerate(items) for _ in it["slots"]], [s for it in items for s in it["slots"]],
133
+ [n for it in items for n in it["nopts"]], temperature, [len(it["slots"]) for it in items])
134
+
135
+ @torch.no_grad()
136
+ def score_shared(self, items, temperature=1.0, min_prefix=192):
137
+ """Rows that start with the same tokens (one state, one question per row): run the shared prefix once, fork its
138
+ cache (attention KV + delta-net conv/recurrent states) to every row, and run only the question suffixes.
139
+ Same answers as score_items up to kernel round-off; cost ~ state + sum(questions) instead of n * state."""
140
+ ids = [it["ids"] for it in items]; n = len(ids)
141
+ lcp = 0; short = min(len(x) for x in ids) - 1
142
+ while lcp < short and all(x[lcp] == ids[0][lcp] for x in ids): lcp += 1
143
+ if n < 2 or lcp < min_prefix:
144
+ return self.score_items(items, temperature)
145
+ self.stats["shared_prefix_calls"] = self.stats.get("shared_prefix_calls", 0) + 1
146
+ pre = torch.tensor(ids[0][:lcp], device=self.dev)[None]
147
+ cache = self.core(input_ids=pre, use_cache=True).past_key_values
148
+ cache.reorder_cache(torch.zeros(n, dtype=torch.long, device=self.dev)) # fork: every row gets a copy of row 0
149
+ Ts = max(len(x) for x in ids) - lcp
150
+ suf = fill_ids([x[lcp:] for x in ids], n, Ts, self.tok.pad_token_id)
151
+ h = self.core(input_ids=suf.to(self.dev), past_key_values=cache, use_cache=True).last_hidden_state
152
+ rows = [b for b, it in enumerate(items) for _ in it["slots"]]; sl = [s - lcp for it in items for s in it["slots"]]
153
+ idx = torch.tensor([rows, sl], device=self.dev)
154
+ return read_slots(F.linear(h[idx[0], idx[1]], self.W).float()[:, None, :], list(range(len(rows))), [0] * len(rows),
155
+ [n for it in items for n in it["nopts"]], temperature, [len(it["slots"]) for it in items])
156
+
157
+ def warmup(self, shapes=((1, 128), (1, 256), (1, 384), (1, 512), (8, 256), (8, 512), (32, 256), (32, 512))):
158
+ t = time.time()
159
+ for B, T in shapes:
160
+ self.logits_all(torch.full((B, T), self.tok.pad_token_id, dtype=torch.long, device=self.dev))
161
+ torch.cuda.synchronize(); return time.time() - t
162
+
163
+
164
+ if __name__ == "__main__":
165
+ import sys, random, numpy as np
166
+ from decider import data as D
167
+ from decider.infer import Decider
168
+ path = sys.argv[1] if len(sys.argv) > 1 else "runs/r3_v2/model"
169
+ cfg = dict(compile="nocompile" not in sys.argv[2:], fp8="fp8" in sys.argv[2:], conv_patch="noconv" not in sys.argv[2:])
170
+ _, evals = D.load_cache("data/tasks.pkl")
171
+ eng = Engine(path, **cfg); print("engine cfg", eng.cfg)
172
+ rng = random.Random(0)
173
+ exs = evals["support_tickets"][:64] + evals["clinc_oos"][:64] + evals["race"][:32]
174
+ items = [build(e, eng.tok, rng, max_ctx_tokens=1536) for e in exs]
175
+ # correctness vs eager masked forward (DecisionModel.slot_logits)
176
+ ref = []
177
+ with torch.no_grad():
178
+ for i in range(0, len(items), 16):
179
+ b = collate(items[i:i + 16], eng.tok.pad_token_id)
180
+ lg = eng.m.slot_logits(b["input_ids"].cuda(), b["attention_mask"].cuda(), b["slot_idx"].cuda(), b["slot_batch"].cuda(), b["nopts"].cuda())
181
+ ref.append(torch.softmax(lg, -1).cpu())
182
+ ref = torch.cat(ref)
183
+ got = torch.cat(eng.score_items(items))
184
+ print(f"max |p_graph - p_eager| = {(ref - got).abs().max():.4f} over {len(ref)} questions; argmax agreement {(ref.argmax(1) == got.argmax(1)).float().mean():.4f}")
185
+ print(f"warmup capture of 8 buckets: {eng.warmup():.1f}s; captures so far {eng.stats['graph_captures']}")
186
+ # latency: single real requests
187
+ for name, pool in [("support_tickets", exs[:64]), ("clinc_oos", exs[64:128]), ("race", exs[128:])]:
188
+ its = [build(e, eng.tok, rng) for e in pool]
189
+ ts = []
190
+ for it in its[:40]:
191
+ torch.cuda.synchronize(); t = time.time(); eng.score_items([it]); torch.cuda.synchronize(); ts.append(time.time() - t)
192
+ ts = np.array(ts[5:]) * 1000
193
+ print(f"single request {name:16s}: p50 {np.median(ts):5.1f} ms p90 {np.percentile(ts, 90):5.1f} ms (avg {np.mean([len(i['ids']) for i in its]):.0f} tok, {len(its[0]['slots'])} q)")
194
+ for bs in (8, 32):
195
+ ts = []
196
+ for i in range(0, min(len(its), bs * 6), bs):
197
+ chunk = its[i:i + bs]
198
+ if len(chunk) < bs: break
199
+ torch.cuda.synchronize(); t = time.time(); eng.score_items(chunk); torch.cuda.synchronize(); ts.append(time.time() - t)
200
+ ts = np.array(ts[1:]) * 1000
201
+ print(f" batch {bs:2d}: p50 {np.median(ts):6.1f} ms -> {bs/np.median(ts)*1000:6.0f} ctx/s, {bs*len(its[0]['slots'])/np.median(ts)*1000:6.0f} decisions/s")
202
+ print("stats", eng.stats, "graphs", len(eng.graphs), f"mem {torch.cuda.memory_reserved()/1e9:.1f} GB")
decider/fp8.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FP8 (e4m3) linear layers for Hopper via torch._scaled_mm.
2
+ Weights: per-output-channel scales, quantised once. Activations: per-token dynamic scales.
3
+ Under torch.compile the quantisation ops fuse into the surrounding elementwise work."""
4
+ import torch, torch.nn as nn
5
+
6
+ E4M3_MAX = 448.0
7
+
8
+
9
+ def _quant_rowwise(x):
10
+ s = x.abs().amax(dim=-1, keepdim=True).float().clamp(min=1e-12) / E4M3_MAX
11
+ return (x.float() / s).clamp(-E4M3_MAX, E4M3_MAX).to(torch.float8_e4m3fn), s
12
+
13
+
14
+ class FP8Linear(nn.Module):
15
+ def __init__(self, lin: nn.Linear):
16
+ super().__init__()
17
+ wq, sw = _quant_rowwise(lin.weight.detach()) # [N,K] fp8, [N,1]
18
+ self.register_buffer("wq", wq.contiguous()) # [N,K]; passed as wq.t() -> [K,N] column-major, as _scaled_mm wants
19
+ self.register_buffer("sw_t", sw.t().contiguous()) # [1,N]
20
+ self.bias = None if lin.bias is None else nn.Parameter(lin.bias.detach().clone(), requires_grad=False)
21
+ self.in_features, self.out_features = lin.in_features, lin.out_features
22
+ self.out_dtype = lin.weight.dtype
23
+
24
+ def forward(self, x):
25
+ shp = x.shape[:-1]
26
+ x2 = x.reshape(-1, self.in_features)
27
+ xq, sx = _quant_rowwise(x2)
28
+ y = torch._scaled_mm(xq, self.wq.t(), scale_a=sx, scale_b=self.sw_t, bias=self.bias, out_dtype=self.out_dtype)
29
+ return y.reshape(*shp, self.out_features)
30
+
31
+
32
+ def convert_to_fp8(model, skip=("lm_head",), min_dim=1024):
33
+ """Replace nn.Linear (with in/out >= min_dim) by FP8Linear in place. Returns count."""
34
+ n = 0
35
+ for name, mod in list(model.named_modules()):
36
+ for cname, child in list(mod.named_children()):
37
+ full = f"{name}.{cname}" if name else cname
38
+ if isinstance(child, nn.Linear) and not any(s in full for s in skip) and min(child.in_features, child.out_features) >= min_dim:
39
+ setattr(mod, cname, FP8Linear(child)); n += 1
40
+ return n
41
+
42
+
43
+ if __name__ == "__main__":
44
+ import time
45
+ lin = nn.Linear(2048, 6144, bias=False).cuda().to(torch.bfloat16)
46
+ f8 = FP8Linear(lin)
47
+ x = torch.randn(8192, 2048, device="cuda", dtype=torch.bfloat16)
48
+ ref = lin(x); got = f8(x)
49
+ print("rel err", ((ref.float() - got.float()).abs().mean() / ref.float().abs().mean()).item())
50
+ for f, name in [(lin, "bf16 linear"), (f8, "fp8 linear (eager)"), (torch.compile(f8), "fp8 linear (compiled)")]:
51
+ for _ in range(3): f(x)
52
+ torch.cuda.synchronize(); t = time.time()
53
+ for _ in range(20): f(x)
54
+ torch.cuda.synchronize(); print(f"{name:24s} {(time.time()-t)/20*1000:.3f} ms")
decider/infer.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 torch
11
+ from decider.model import DecisionModel, collate
12
+ from decider.prompt import build, MAX_OPTIONS
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass
17
+ class Q:
18
+ text: str; options: list; gold: int = 0
19
+
20
+
21
+ @dataclass
22
+ class Example:
23
+ context: str; qs: list; task: str = "infer"; image: bytes = None
24
+
25
+
26
+ NEUTRAL_NONE = "not listed here"
27
+
28
+
29
+ def neutralize_options(options):
30
+ """The training augmentation used the literal 'none of the above', and the model learned that exact string as an
31
+ abstain signal (it abstains even on clear cases when the string is offered). Any option that reads like it is
32
+ rewritten to a neutral phrasing for the model and mapped back in the output."""
33
+ out, back = [], {}
34
+ for o in options:
35
+ key = o.strip().lower()
36
+ if key.startswith("none of the above") or key in ("none of the above", "none", "n/a", "none of these"):
37
+ out.append(NEUTRAL_NONE); back[NEUTRAL_NONE] = o
38
+ else:
39
+ out.append(o)
40
+ return out, back
41
+
42
+
43
+ class CompiledSchema:
44
+ def __init__(self, d, rqs, h, index): self.d, self.rqs, self.h, self.index = d, rqs, h, index
45
+
46
+ def batch(self, states, max_state_tokens=32768):
47
+ from decider.systemone import render_state, assemble
48
+ probs = self.d._se.score(self.h, [render_state(s) for s in states], temperature=self.d.T_schema, max_ctx_tokens=max_state_tokens)
49
+ return [{"model": self.d.name, "answers": assemble(self.rqs, self.index, [p.tolist() for p in pr])} for pr in probs]
50
+
51
+ def __call__(self, state, max_state_tokens=32768):
52
+ return self.batch([state], max_state_tokens)[0]
53
+
54
+
55
+ class Decider:
56
+ """use_graphs=True (default on CUDA) routes scoring through decider.engine.Engine: shape-bucketed
57
+ CUDA graphs, ~7x lower single-request latency than eager. Set False for CPU or debugging."""
58
+ def __init__(self, path, device="cuda", dtype=torch.bfloat16, temperature=None, abstain_below=0.0, use_graphs=None):
59
+ import json, os
60
+ cfg = {}
61
+ try: # model folder may carry decider_config.json (temperature, flags)
62
+ from huggingface_hub import hf_hub_download
63
+ cfg_path = os.path.join(path, "decider_config.json") if os.path.isdir(path) else hf_hub_download(path, "decider_config.json")
64
+ cfg = json.load(open(cfg_path))
65
+ except Exception:
66
+ pass
67
+ if temperature is None:
68
+ temperature = float(cfg.get("temperature", 1.0))
69
+ self.neutralize_none = bool(cfg.get("neutralize_none", True)) # v4 and earlier learned the literal string as an abstain signal
70
+ if use_graphs is None:
71
+ use_graphs = str(device).startswith("cuda")
72
+ if use_graphs:
73
+ from decider.engine import Engine
74
+ self.eng = Engine(path, device=device, dtype=dtype); self.m = self.eng.m
75
+ else:
76
+ self.eng = None; self.m = DecisionModel(path, dtype=dtype, grad_ckpt=False).to(device).eval()
77
+ self.dev = device; self.T = temperature; self.abstain_below = abstain_below
78
+ self.name = "decider-" + str(cfg.get("version", "dev"))
79
+ self.schema_first = bool(cfg.get("schema_first", False)) and self.eng is not None # default layout. Questions-first (the cacheable one) costs accuracy
80
+ self.T_schema = float(cfg.get("temperature_schema_first", temperature)) # (about 1.5 points on fixed label sets, more elsewhere): opt in with schema()
81
+ self.isolated_levels = bool(cfg.get("isolated_levels", False)) # Score levels judged one per row (v8+)
82
+ self._se = None; self._schemas = {}
83
+
84
+ @torch.no_grad()
85
+ def decide_batch(self, requests, max_ctx_tokens=1536):
86
+ """requests: list of (context:str, questions:list[dict(question, options)]). One forward pass for everything."""
87
+ exs, meta = [], []
88
+ if self.neutralize_none:
89
+ 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]
90
+ for context, qs in requests:
91
+ for q in qs:
92
+ assert 2 <= len(q["options"]) <= MAX_OPTIONS, f"2..{MAX_OPTIONS} options required"
93
+ exs.append(Example(context, [Q(q["question"], list(q["options"]), 0) for q in qs], "infer"))
94
+ class _NoShuffle: # keep option order as given
95
+ def shuffle(self, x): pass
96
+ def sample(self, xs, k): return xs[:k]
97
+ items = [build(e, self.m.tok, _NoShuffle(), max_options=MAX_OPTIONS, max_ctx_tokens=max_ctx_tokens) for e in exs]
98
+ if self.eng is not None:
99
+ probs = torch.cat(self.eng.score_items(items, temperature=self.T))
100
+ else:
101
+ b = collate(items, self.m.tok.pad_token_id)
102
+ logits = self.m.slot_logits(b["input_ids"].to(self.dev), b["attention_mask"].to(self.dev), b["slot_idx"].to(self.dev),
103
+ b["slot_batch"].to(self.dev), b["nopts"].to(self.dev))
104
+ probs = torch.softmax(logits / self.T, -1).cpu()
105
+ out, k = [], 0
106
+ for context, qs in requests:
107
+ res = []
108
+ for q in qs:
109
+ p = probs[k, :len(q["options"])].tolist(); k += 1
110
+ j = max(range(len(p)), key=p.__getitem__); back = q.get("_back", {})
111
+ names = [back.get(o, o) for o in q["options"]]
112
+ res.append(dict(choice=names[j] if p[j] >= self.abstain_below else None, confidence=p[j],
113
+ probs={o: pi for o, pi in zip(names, p)}, probs_list=p))
114
+ out.append(res)
115
+ return out
116
+
117
+ def decide(self, context, questions, **kw):
118
+ return self.decide_batch([(context, questions)], **kw)[0]
119
+
120
+ # ---- Jev-shaped interface (decider.systemone): state + {id: Choice | Score | Noul with criteria}
121
+ # ---- schema cache (v7+): the questions are run once, requests only run the state (decider.schema_engine)
122
+ def schema(self, questions, independent=True, isolated=None, compile=False):
123
+ """Compile a fixed set of Jev-shaped questions: schema(state) -> answers; schema.batch([state, ...]) -> [answers]."""
124
+ import json
125
+ from decider.schema_engine import SchemaEngine
126
+ from decider.systemone import render_question
127
+ isolated = self.isolated_levels if isolated is None else isolated
128
+ key = (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent, isolated)
129
+ if key not in self._schemas:
130
+ if self._se is None: self._se = SchemaEngine(self.eng)
131
+ if len(self._schemas) >= 64: # drop the oldest schema and its graphs
132
+ old = next(iter(self._schemas)); hid = self._schemas.pop(old)[1].id
133
+ for k in [k for k in self._se.graphs if k[0] == hid]: del self._se.graphs[k]
134
+ from decider.systemone import plan_rows
135
+ rqs = {k: render_question(v) for k, v in questions.items()}
136
+ rows, index = plan_rows(rqs, isolated and independent)
137
+ h = self._se.prepare(rows, independent=independent, compile=compile) # compile=True: ~25 s per (batch, length) shape, 1.6x faster after
138
+ self._schemas[key] = (rqs, h, index)
139
+ return CompiledSchema(self, *self._schemas[key])
140
+
141
+ def system_one(self, state, questions, independent=True, max_state_tokens=32768, max_fwd_tokens=65536, layout=None, isolated=None):
142
+ layout = layout or ("schema_first" if self.schema_first else "state_first")
143
+ isolated = (self.isolated_levels if isolated is None else isolated) and independent
144
+ if layout == "schema_first" and self.eng is not None:
145
+ return self.schema(questions, independent, isolated)(state, max_state_tokens)
146
+ """independent=True scores every question in its own row (state + that question only), so adding, removing or
147
+ reordering questions cannot change any other answer; the state is run once and its cache forked to every
148
+ question (Engine.score_shared). independent=False packs all questions behind one copy of the state in one row
149
+ (later questions can then see earlier question texts)."""
150
+ from decider.systemone import render_state, render_question, unique_tokens, plan_rows, assemble
151
+ ctx = render_state(state); rqs = {k: render_question(v) for k, v in questions.items()}
152
+ opts = (lambda r: neutralize_options(r["options"])[0]) if self.neutralize_none else (lambda r: list(r["options"]))
153
+ flat, index = plan_rows(rqs, isolated)
154
+ rows = [[r] for r in flat] if independent else [flat]
155
+ class _Keep:
156
+ def shuffle(self, x): pass
157
+ def sample(self, xs, k): return xs[:k]
158
+ items = [build(Example(ctx, [Q(r["question"], opts(r), 0) for r in row]), self.m.tok, _Keep(), max_options=MAX_OPTIONS,
159
+ max_ctx_tokens=max_state_tokens, layout=layout) for row in rows]
160
+ with torch.no_grad():
161
+ if self.eng is not None and len(items) > 1 and layout == "state_first":
162
+ probs = self.eng.score_shared(items, temperature=self.T)
163
+ else:
164
+ probs = []; per = max(1, max_fwd_tokens // max(len(it["ids"]) for it in items))
165
+ for i in range(0, len(items), per):
166
+ if self.eng is not None:
167
+ probs += self.eng.score_items(items[i:i + per], temperature=self.T)
168
+ else:
169
+ bt = collate(items[i:i + per], self.m.tok.pad_token_id)
170
+ lg = self.m.slot_logits(*[bt[k].to(self.dev) for k in ("input_ids", "attention_mask", "slot_idx", "slot_batch", "nopts")])
171
+ pr = torch.softmax(lg / self.T, -1).cpu(); c = 0
172
+ for it in items[i:i + per]:
173
+ probs.append(pr[c:c + len(it["slots"])]); c += len(it["slots"])
174
+ flatp = [p.tolist() for ps in probs for p in ps]
175
+ return {"model": self.name, "answers": assemble(rqs, index, flatp),
176
+ "usage": {"input_tokens": unique_tokens(items), "output_tokens": 0}}
177
+
178
+ # ---- typed schema interface: {question: {"type": "bool"} | {"type": "choice", "options": [...]}
179
+ # | {"type": "scale", "legend": {"0": "none", "1": "low", ...}}}
180
+ @staticmethod
181
+ def _schema_to_questions(schema):
182
+ qs = []
183
+ for qtext, spec in schema.items():
184
+ t = spec.get("type", "choice")
185
+ if t == "bool":
186
+ qs.append(dict(question=qtext, options=["no", "yes"]))
187
+ elif t == "choice":
188
+ qs.append(dict(question=qtext, options=list(spec["options"])))
189
+ elif t == "scale":
190
+ leg = spec["legend"]
191
+ keys = sorted(leg, key=lambda k: float(k)) if isinstance(leg, dict) else list(range(len(leg)))
192
+ labels = [f"{k}: {leg[k]}" if isinstance(leg, dict) else f"{i}: {leg[i]}" for i, k in enumerate(keys)]
193
+ qs.append(dict(question=qtext, options=labels, _keys=keys, _legend=leg))
194
+ else:
195
+ raise ValueError(f"unknown field type {t}")
196
+ return qs
197
+
198
+ def decide_json_batch(self, requests, **kw):
199
+ """requests: list of (context, schema). Returns one dict per context keyed by question."""
200
+ qss = [self._schema_to_questions(schema) for _, schema in requests]
201
+ raw = self.decide_batch([(ctx, qs) for (ctx, _), qs in zip(requests, qss)], **kw)
202
+ out = []
203
+ for (ctx, schema), qs, res in zip(requests, qss, raw):
204
+ o = {}
205
+ for (qtext, spec), q, r in zip(schema.items(), qs, res):
206
+ t = spec.get("type", "choice")
207
+ if t == "bool":
208
+ o[qtext] = {"noul": round(r["probs"]["yes"], 4), "type": "noul"}
209
+ elif t == "choice":
210
+ o[qtext] = {"choice": r["choice"], "confidence": round(r["confidence"], 4), "type": "choice",
211
+ "probabilities": {k: round(v, 4) for k, v in r["probs"].items()}}
212
+ else:
213
+ p = [r["probs"][lab] for lab in q["options"]]
214
+ keys = q["_keys"]; n = len(p)
215
+ score = sum(float(k) * pi for k, pi in zip(keys, p)) # expected level on the legend scale
216
+ j = max(range(n), key=p.__getitem__)
217
+ o[qtext] = {"score": round(score, 2), "confidence": round(p[j], 4), "type": "scale", "legend": q["_legend"],
218
+ "probabilities": {str(keys[i]): round(pi, 4) for i, pi in enumerate(p)}}
219
+ out.append(o)
220
+ return out
221
+
222
+ def decide_json(self, context, schema, **kw):
223
+ return self.decide_json_batch([(context, schema)], **kw)[0]
224
+
225
+
226
+ if __name__ == "__main__":
227
+ import sys, json, time
228
+ d = Decider(sys.argv[1] if len(sys.argv) > 1 else "runs/r1_200k/model")
229
+ demo = [
230
+ ("My card was charged twice for the same purchase and I want the extra charge refunded.",
231
+ [{"question": "Which department should handle this?", "options": ["billing", "technical support", "sales"]},
232
+ {"question": "What is the customer's sentiment?", "options": ["angry", "neutral", "happy"]},
233
+ {"question": "Does this need a refund action?", "options": ["no", "yes"]}]),
234
+ ("hey can u turn the lights off in the kitchen",
235
+ [{"question": "What is the intent?", "options": ["smart home control", "set alarm", "play music", "none of the above"]},
236
+ {"question": "Is this request toxic?", "options": ["no", "yes"]}]),
237
+ ("The quarterly report shows revenue fell 12% while costs rose sharply.",
238
+ [{"question": "What is the financial sentiment?", "options": ["bearish", "neutral", "bullish"]}]),
239
+ ]
240
+ t = time.time(); res = d.decide_batch(demo); dt = time.time() - t
241
+ for (ctx, qs), r in zip(demo, res):
242
+ print("\n>>", ctx)
243
+ for q, a in zip(qs, r):
244
+ 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()))
245
+ print(f"\n{sum(len(q) for _, q in demo)} decisions in {dt*1000:.0f} ms (one forward pass)")
246
+ schema = {
247
+ "Revenue currently impacted?": {"type": "bool"},
248
+ "What business impact?": {"type": "choice", "options": ["none", "degraded", "outage"]},
249
+ "Integration issue present?": {"type": "bool"},
250
+ "Account health status?": {"type": "choice", "options": ["healthy", "watch", "at risk"]},
251
+ "Which incident scope?": {"type": "choice", "options": ["single_account", "multi_account", "platform_wide"]},
252
+ "Security concern present?": {"type": "bool"},
253
+ "Duplicate charge reported?": {"type": "bool"},
254
+ "Churn likelihood level?": {"type": "scale", "legend": {"0": "none", "1": "low", "2": "medium", "3": "high"}},
255
+ "Human attention needed?": {"type": "bool"},
256
+ "Immediate feature request?": {"type": "bool"},
257
+ }
258
+ ctx = ("Hi, since this morning our Stripe webhook integration stopped firing and our checkout is down for all customers. "
259
+ "We are losing orders every minute and our partner launch is on Thursday. Also I think we got billed twice last week. "
260
+ "If this is not fixed today we will have to look at other providers.")
261
+ t = time.time(); js = d.decide_json(ctx, schema); dt = time.time() - t
262
+ 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/metrics.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+
4
+ def ece(conf, correct, bins=15):
5
+ conf = np.asarray(conf); correct = np.asarray(correct, dtype=float)
6
+ edges = np.linspace(0, 1, bins + 1); e = 0.0
7
+ for lo, hi in zip(edges[:-1], edges[1:]):
8
+ m = (conf > lo) & (conf <= hi)
9
+ if m.any():
10
+ e += m.mean() * abs(conf[m].mean() - correct[m].mean())
11
+ return float(e)
12
+
13
+
14
+ def aurc(conf, correct):
15
+ """Area under risk-coverage curve (lower is better)."""
16
+ order = np.argsort(-np.asarray(conf)); c = np.asarray(correct, dtype=float)[order]
17
+ risk = np.cumsum(1 - c) / np.arange(1, len(c) + 1)
18
+ return float(risk.mean())
19
+
20
+
21
+ def sel_acc(conf, correct, coverage):
22
+ order = np.argsort(-np.asarray(conf)); c = np.asarray(correct, dtype=float)[order]
23
+ n = max(1, int(round(coverage * len(c))))
24
+ return float(c[:n].mean())
25
+
26
+
27
+ def summarize(probs, golds, nopts):
28
+ """probs [N,K] (masked entries 0), golds [N], nopts [N]."""
29
+ probs = np.asarray(probs); golds = np.asarray(golds); nopts = np.asarray(nopts)
30
+ pred = probs.argmax(1); conf = probs.max(1); correct = (pred == golds)
31
+ p_gold = probs[np.arange(len(golds)), golds]
32
+ nll = -np.log(np.clip(p_gold, 1e-12, 1)).mean()
33
+ onehot = np.zeros_like(probs); onehot[np.arange(len(golds)), golds] = 1
34
+ brier = ((probs - onehot) ** 2).sum(1).mean()
35
+ return dict(n=int(len(golds)), acc=float(correct.mean()), nll=float(nll), brier=float(brier), ece=ece(conf, correct),
36
+ aurc=aurc(conf, correct), acc_at_80=sel_acc(conf, correct, 0.8), acc_at_50=sel_acc(conf, correct, 0.5),
37
+ chance=float((1.0 / nopts).mean()), mean_conf=float(conf.mean()))
decider/model.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ self.tok = AutoTokenizer.from_pretrained(name)
11
+ self.lm = AutoModelForCausalLM.from_pretrained(name, dtype=dtype)
12
+ if grad_ckpt:
13
+ self.lm.gradient_checkpointing_enable()
14
+ self.register_buffer("letters", torch.tensor(letter_ids(self.tok)), persistent=False)
15
+
16
+ def slot_logits(self, input_ids, attention_mask, slot_idx, slot_batch, nopts):
17
+ """input_ids [B,T]; slot_idx/slot_batch [N] flat slot positions; nopts [N].
18
+ Returns [N, MAX_OPTIONS] logits with invalid options masked to -inf."""
19
+ h = self.lm.model(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
20
+ hs = h[slot_batch, slot_idx] # [N,H]
21
+ W = self.lm.lm_head.weight[self.letters] # [K,H]
22
+ logits = F.linear(hs, W).float() # [N,K]
23
+ ar = torch.arange(MAX_OPTIONS, device=logits.device)[None, :]
24
+ logits = logits.masked_fill(ar >= nopts[:, None], float("-inf"))
25
+ return logits
26
+
27
+ def forward(self, batch):
28
+ return self.slot_logits(batch["input_ids"], batch["attention_mask"], batch["slot_idx"], batch["slot_batch"], batch["nopts"])
29
+
30
+
31
+ def collate(items, pad_id):
32
+ """items: list of dicts from prompt.build (+ 'task', 'ex_id'). Right-pad."""
33
+ T = max(len(it["ids"]) for it in items)
34
+ T = ((T + 63) // 64) * 64 # few distinct shapes -> fewer kernel (re)compiles
35
+ B = len(items)
36
+ input_ids = torch.full((B, T), pad_id, dtype=torch.long)
37
+ attn = torch.zeros((B, T), dtype=torch.long)
38
+ slot_idx, slot_batch, golds, nopts, tasks, qidx = [], [], [], [], [], []
39
+ for b, it in enumerate(items):
40
+ n = len(it["ids"])
41
+ input_ids[b, :n] = torch.tensor(it["ids"])
42
+ attn[b, :n] = 1
43
+ for k, s in enumerate(it["slots"]):
44
+ slot_idx.append(s); slot_batch.append(b); golds.append(it["golds"][k]); nopts.append(it["nopts"][k])
45
+ tasks.append(it.get("task", "")); qidx.append(k)
46
+ return dict(input_ids=input_ids, attention_mask=attn, slot_idx=torch.tensor(slot_idx), slot_batch=torch.tensor(slot_batch),
47
+ golds=torch.tensor(golds), nopts=torch.tensor(nopts), tasks=tasks, qidx=qidx)
decider/prompt.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
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
+ rng = rng or random
87
+ perms = [_select(q, rng, max_options) for q in example.qs]
88
+ pre = schema_prefix_ids(tok, example.qs, perms)
89
+ suf, slots = schema_suffix_ids(tok, example.context, len(example.qs), max_ctx_tokens)
90
+ 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)],
91
+ nopts=[len(p) for p in perms], perms=perms, prefix_len=len(pre))
92
+
93
+
94
+ def schema_prefix_ids(tok, qs, perms=None):
95
+ """Token ids of the question/option blocks (the cacheable part of the schema-first layout)."""
96
+ multi = len(qs) > 1; pre = []
97
+ for k, q in enumerate(qs):
98
+ opts = perms[k] if perms is not None else list(range(len(q.options)))
99
+ 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)
100
+ return pre
101
+
102
+
103
+ def schema_suffix_ids(tok, context, n_q, max_ctx_tokens=1536):
104
+ """Token ids after the schema prefix: the context and one answer slot per question. Returns (ids, slot positions in ids)."""
105
+ ids = tok.encode("\n\nContext:\n", add_special_tokens=False) + tok.encode(context, add_special_tokens=False)[:max_ctx_tokens]; slots = []
106
+ for k in range(n_q):
107
+ 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)
108
+ return ids, slots
109
+
110
+
111
+ def build(example, tok, rng=None, max_options=NARROW, max_ctx_tokens=1536, layout="state_first"):
112
+ """Returns dict(ids=list[int], slots=list[int], golds=list[int], nopts=list[int], perms=list[list[int]])."""
113
+ if layout == "schema_first":
114
+ return build_schema_first(example, tok, rng, max_options, max_ctx_tokens)
115
+ rng = rng or random
116
+ ctx_ids = tok.encode("Context:\n" + example.context, add_special_tokens=False)[:max_ctx_tokens]
117
+ ids = list(ctx_ids)
118
+ slots, golds, nopts, perms = [], [], [], []
119
+ multi = len(example.qs) > 1
120
+ for k, q in enumerate(example.qs):
121
+ opts = list(range(len(q.options)))
122
+ if len(opts) > max_options:
123
+ # always keep the gold and any abstain-style option (its mere presence must not carry information)
124
+ forced = {q.gold} | {i for i, o in enumerate(q.options) if is_abstain_option(o)}
125
+ others = [i for i in opts if i not in forced]
126
+ keep = rng.sample(others, max_options - len(forced)) + list(forced)
127
+ opts = keep
128
+ rng.shuffle(opts)
129
+ head = f"\n\nQuestion{' ' + str(k + 1) if multi else ''}: {q.text}\nOptions:"
130
+ tail = f"\nAnswer{' ' + str(k + 1) if multi else ''}: ("
131
+ if len(opts) <= NARROW:
132
+ lines = [head] + [f"\n({LETTERS[j]}) {q.options[oi]}" for j, oi in enumerate(opts)] + [tail]
133
+ piece = tok.encode("".join(lines), add_special_tokens=False)
134
+ else: # wide: "\n(" + <label token> + ") text", built from ids so every label is one token
135
+ _, lab_ids, open_ids = label_table(tok)
136
+ piece = tok.encode(head, add_special_tokens=False)
137
+ for j, oi in enumerate(opts):
138
+ piece += open_ids + [lab_ids[j]] + _enc_opt(tok, q.options[oi])
139
+ piece += tok.encode(tail, add_special_tokens=False)
140
+ ids.extend(piece)
141
+ slots.append(len(ids) - 1) # position of " (" token
142
+ golds.append(opts.index(q.gold) if q.gold in opts else -1)
143
+ nopts.append(len(opts))
144
+ perms.append(opts)
145
+ return dict(ids=ids, slots=slots, golds=golds, nopts=nopts, perms=perms)
146
+
147
+
148
+ def letter_ids(tok):
149
+ ids = label_table(tok)[1]
150
+ for j, L in enumerate(LETTERS):
151
+ assert tok.encode(L, add_special_tokens=False) == [ids[j]], L
152
+ return ids
153
+
154
+
155
+ def render(example, tok, **kw):
156
+ b = build(example, tok, **kw)
157
+ return tok.decode(b["ids"])
decider/schema_engine.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema cache: compute a question schema once, then score states against it.
2
+
3
+ In production the questions are fixed and only the state changes. With the schema-first prompt layout
4
+ (prompt.build_schema_first) the question/option blocks are a prefix that does not depend on the state, so their
5
+ cache - attention K/V for the 6 full-attention layers, conv + recurrent state for the 18 delta-net layers - is computed
6
+ once (`prepare`). A request then runs only "Context: <state>" plus one answer slot per question, as a CUDA graph per
7
+ (batch, length) bucket. The prefix cache is read-only during a request (nothing is written back), so one copy serves
8
+ every batch and every graph.
9
+
10
+ se = SchemaEngine(engine); h = se.prepare([{"question": ..., "options": [...]}, ...])
11
+ probs = se.score(h, ["state 1", "state 2", ...]) # list of [n_questions, MAX_OPTIONS] tensors
12
+ """
13
+ import time, types, torch, torch.nn.functional as F
14
+ from decider.prompt import schema_prefix_ids, schema_suffix_ids, MAX_OPTIONS
15
+ from decider.engine import read_slots, fill_ids
16
+
17
+ TS_BUCKETS = [32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024]
18
+ B_BUCKETS = [1, 2, 4, 8, 16, 32, 64]
19
+
20
+
21
+ class _Q:
22
+ def __init__(self, text, options): self.text, self.options = text, options
23
+
24
+
25
+ class PrefixCache:
26
+ """Duck-typed transformers Cache over fixed, read-only prefixes, for one suffix forward pass.
27
+ A handle holds P prefixes (P = 1: all questions packed in one prefix; P = n_questions: one prefix per question, so every
28
+ question is scored independently). A batch of R states has R * P rows; row r * P + p continues prefix p."""
29
+ def __init__(self, h, R):
30
+ rep = (lambda t: t.expand(R, *t.shape[1:])) if h.P == 1 else (lambda t: t.repeat(R, *([1] * (t.dim() - 1))))
31
+ self.tp = h.tpmax; self.k = {i: rep(k) for i, k in h.k.items()}; self.v = {i: rep(v) for i, v in h.v.items()}
32
+ self.conv = {i: rep(c).contiguous() for i, c in h.conv.items()}
33
+ self.layers = {i: types.SimpleNamespace(record_past=False, recurrent_states={0: rep(r).contiguous()}) for i, r in h.rec.items()}
34
+
35
+ def has_previous_state(self, layer_idx=None, state_idx=None): return True
36
+ def get_seq_length(self, *a, **k): return self.tp
37
+ def update(self, key, value, layer_idx, *a, **k): return torch.cat([self.k[layer_idx], key], 2), torch.cat([self.v[layer_idx], value], 2)
38
+ def update_conv_state(self, x, layer_idx, **k): return torch.cat([self.conv[layer_idx].to(x.dtype), x], -1)
39
+ def update_recurrent_state(self, s, layer_idx, **k): return s
40
+
41
+
42
+ class SchemaEngine:
43
+ def __init__(self, engine, use_graphs=True):
44
+ self.e = engine; self.core = engine.core; self.W = engine.W; self.tok = engine.tok; self.dev = engine.dev
45
+ self.use_graphs = use_graphs and engine.use_graphs; self.graphs = {}; self.stats = dict(prepared=0, captures=0, replays=0, eager=0)
46
+ self.compile = bool(engine.cfg.get("compile")); self._compiled = {}
47
+ if self.compile: # every compiled schema graph specialises the model frames again (its cache tensors are constants)
48
+ import torch._dynamo
49
+ torch._dynamo.config.cache_size_limit = 4096; torch._dynamo.config.accumulated_cache_size_limit = 1 << 16
50
+
51
+ @torch.no_grad()
52
+ def prepare(self, questions, independent=False, compile=False):
53
+ """questions: [{"question": str, "options": [str]}] in the order answers are wanted. Runs the prefix(es) once.
54
+ independent=False: one prefix holding every question (cheapest: a request costs state + n slots).
55
+ independent=True: one prefix per question, one row per question (a request costs n * (state + 1 slot); no question
56
+ can influence another)."""
57
+ qs = [_Q(q["question"], list(q["options"])) for q in questions]
58
+ groups = [[q] for q in qs] if independent else [qs]; pres = [schema_prefix_ids(self.tok, g) for g in groups]
59
+ h = types.SimpleNamespace(P=len(groups), nq=len(qs), slots_per_row=1 if independent else len(qs), nopts=[len(q.options) for q in qs], tps=[len(p) for p in pres],
60
+ tpmax=max(len(p) for p in pres), k={}, v={}, conv={}, rec={}, id=self.stats["prepared"],
61
+ compile=bool(compile and self.compile))
62
+ parts = []
63
+ for pre in pres:
64
+ out = self.core(input_ids=torch.tensor(pre, device=self.dev)[None], use_cache=True).past_key_values; d = dict(k={}, v={}, conv={}, rec={})
65
+ for i, layer in enumerate(out.layers):
66
+ if getattr(layer, "recurrent_states", None) is not None and layer.recurrent_states.get(0) is not None:
67
+ d["conv"][i] = layer.conv_states[0]; d["rec"][i] = layer.recurrent_states[0]
68
+ else: # right-pad every prefix's K/V to the longest; the mask hides the padding
69
+ pad = (0, 0, 0, h.tpmax - len(pre)); d["k"][i] = F.pad(layer.keys, pad); d["v"][i] = F.pad(layer.values, pad)
70
+ parts.append(d)
71
+ for name in ("k", "v", "conv", "rec"):
72
+ getattr(h, name).update({i: torch.cat([d[name][i] for d in parts], 0).clone() for i in parts[0][name]})
73
+ self.stats["prepared"] += 1
74
+ return h
75
+
76
+ def _fwd(self, ids, cache, mask, pos):
77
+ hs = self.core(input_ids=ids, past_key_values=cache, attention_mask={"full_attention": mask, "linear_attention": None}, position_ids=pos, use_cache=True).last_hidden_state
78
+ return F.linear(hs, self.W).float()
79
+
80
+ def _static(self, h, R, Ts):
81
+ """R request slots -> R * P rows. Mask: a row sees its own prefix (not the padding up to tpmax) and the causal suffix."""
82
+ ar = torch.arange(Ts, device=self.dev); tps = torch.tensor(h.tps, device=self.dev).repeat(R) # [R*P]
83
+ pre = (torch.arange(h.tpmax, device=self.dev)[None, :] < tps[:, None])[:, None, None, :].expand(-1, 1, Ts, -1) # [B,1,Ts,tpmax]
84
+ mask = torch.cat([pre, (ar[:, None] >= ar[None, :])[None, None].expand(len(tps), 1, -1, -1)], 3).contiguous()
85
+ return PrefixCache(h, R), mask, (tps[:, None] + ar[None, :]).contiguous()
86
+
87
+ def _capture(self, h, R, Ts):
88
+ B = R * h.P
89
+ ids = torch.full((B, Ts), self.tok.pad_token_id, dtype=torch.long, device=self.dev); cache, mask, pos = self._static(h, R, Ts)
90
+ fwd = self._fwd
91
+ if h.compile: # one compiled function per graph (20-30 s each: only for preloaded schemas): the cache tensors are constants of that graph
92
+ fwd = torch.compile(lambda i: self._fwd(i, cache, mask, pos), dynamic=False)
93
+ call = lambda: fwd(ids)
94
+ else:
95
+ call = lambda: fwd(ids, cache, mask, pos)
96
+ st = torch.cuda.Stream(); st.wait_stream(torch.cuda.current_stream())
97
+ with torch.cuda.stream(st):
98
+ for _ in range(3): call()
99
+ torch.cuda.current_stream().wait_stream(st)
100
+ g = torch.cuda.CUDAGraph()
101
+ with torch.cuda.graph(g, pool=self.e.pool):
102
+ out = call()
103
+ self.stats["captures"] += 1
104
+ return ids, out, g, (cache, mask, pos)
105
+
106
+ def warmup(self, h, batch_sizes=(1, 8, 32), state_tokens=(64, 128, 256)):
107
+ """Capture (and, for a compiled schema, compile) the graphs for these request-batch sizes and suffix lengths ahead of traffic."""
108
+ t = time.time()
109
+ for R in batch_sizes:
110
+ for Ts in state_tokens:
111
+ Ts = next((x for x in TS_BUCKETS if x >= Ts), TS_BUCKETS[-1])
112
+ if (h.id, R, Ts) not in self.graphs: self.graphs[(h.id, R, Ts)] = self._capture(h, R, Ts)
113
+ torch.cuda.synchronize(); return time.time() - t
114
+
115
+ def tokenize(self, h, context, max_ctx_tokens=1536):
116
+ """CPU part of a request (do it outside any GPU lock): -> (suffix ids, slot positions)."""
117
+ return schema_suffix_ids(self.tok, context, h.slots_per_row, max_ctx_tokens)
118
+
119
+ @staticmethod
120
+ def bucket(n_tokens):
121
+ return next((t for t in TS_BUCKETS if t >= n_tokens), -(-n_tokens // 256) * 256)
122
+
123
+ def score(self, h, contexts, temperature=1.0, max_ctx_tokens=1536):
124
+ """-> one [n_questions, MAX_OPTIONS] probability tensor per context."""
125
+ return self.score_rows(h, [self.tokenize(h, c, max_ctx_tokens) for c in contexts], temperature)
126
+
127
+ @torch.no_grad()
128
+ def score_rows(self, h, rows, temperature=1.0):
129
+ """rows: [(suffix ids, slots)] from tokenize()."""
130
+ Tmax = max(len(r[0]) for r in rows); Ts = next((t for t in TS_BUCKETS if t >= Tmax), None); n = len(rows)
131
+ R = next((b for b in B_BUCKETS if b >= n), n) if Ts else n; Ts = Ts or -(-Tmax // 256) * 256
132
+ ids = fill_ids([x for x, _ in rows for _ in range(h.P)], R * h.P, Ts, self.tok.pad_token_id).to(self.dev, non_blocking=True)
133
+ if self.use_graphs and Ts <= TS_BUCKETS[-1]:
134
+ key = (h.id, R, Ts)
135
+ if key not in self.graphs: self.graphs[key] = self._capture(h, R, Ts)
136
+ s_ids, s_out, g, _ = self.graphs[key]; s_ids.copy_(ids); g.replay(); out = s_out; self.stats["replays"] += 1
137
+ else:
138
+ out = self._fwd(ids, *self._static(h, R, Ts)); self.stats["eager"] += 1
139
+ if h.P == 1: # packed: n slots in one row per request
140
+ rws = [r for r in range(n) for _ in range(h.nq)]; sls = [x for _, sl in rows for x in sl]
141
+ else: # independent: one slot in each of the request's P rows
142
+ rws = [r * h.P + p for r in range(n) for p in range(h.P)]; sls = [sl[0] for _, sl in rows for _ in range(h.P)]
143
+ return read_slots(out, rws, sls, h.nopts * n, temperature, [h.nq] * n)
decider/serve.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Micro-batching HTTP server.
2
+ POST /decide {"context": str, "schema": {...}} -> typed JSON decisions (all questions packed in one row)
3
+ POST /v1/systemone {"state": str|object|array, "questions": {id: {...}}} -> the TypeSafe/Jev wire format (decider.systemone):
4
+ Choice (up to 255 described options), Score, Noul; every question is scored in its own row, so answers
5
+ are independent of each other ("independent": false packs them behind one copy of the state instead).
6
+ Requests arriving within `max_wait_ms` are scored in one forward pass (grouped by length bucket).
7
+ uvicorn decider.serve:app --host 0.0.0.0 --port 8000 (env: DECIDER_MODEL, DECIDER_MAX_BATCH, DECIDER_MAX_WAIT_MS)
8
+ """
9
+ import asyncio, os, random, time, threading
10
+ from fastapi import FastAPI, HTTPException
11
+ from pydantic import BaseModel
12
+ from decider.engine import Engine, T_BUCKETS, _bucket
13
+ from decider.prompt import build, MAX_OPTIONS
14
+ from decider.infer import Decider, Example, Q, neutralize_options
15
+ from decider import systemone as S1
16
+
17
+ MODEL = os.environ.get("DECIDER_MODEL", "runs/r3_v2/model")
18
+ MAX_BATCH = int(os.environ.get("DECIDER_MAX_BATCH", "32"))
19
+ MAX_WAIT_MS = float(os.environ.get("DECIDER_MAX_WAIT_MS", "8"))
20
+ BATCH_WAIT_MS = float(os.environ.get("DECIDER_BATCH_WAIT_MS", "0"))
21
+ MAX_STATE_TOKENS = int(os.environ.get("DECIDER_MAX_STATE_TOKENS", "32768"))
22
+ MAX_FWD_TOKENS = int(os.environ.get("DECIDER_MAX_FWD_TOKENS", "65536")) # padded tokens per forward pass
23
+ COMPILE = os.environ.get("DECIDER_COMPILE", "1") == "1"
24
+ FP8 = os.environ.get("DECIDER_FP8", "1") == "1"
25
+ app = FastAPI(title="decider")
26
+ MODEL_NAME = "decider"; TEMP = 1.0; TEMP_SCHEMA = 1.0; RELEASE_DATE = "2026-09-17"
27
+ gpu_lock = threading.Lock() # one GPU job at a time: batched graph replays and shared-prefix requests must not interleave
28
+ SHARED_MIN_TOKENS = int(os.environ.get("DECIDER_SHARED_MIN_TOKENS", "768")) # independent rows over a state this long share one prefix pass
29
+ eng = None; queue = None; stats = dict(requests=0, batches=0, decisions=0, batch_hist={})
30
+
31
+
32
+ class Req(BaseModel):
33
+ context: str
34
+ schema_: dict = None
35
+ model_config = {"populate_by_name": True}
36
+ def __init__(self, **kw):
37
+ if "schema" in kw: kw["schema_"] = kw.pop("schema")
38
+ super().__init__(**kw)
39
+
40
+
41
+ class _NoShuffle:
42
+ def shuffle(self, x): pass
43
+ def sample(self, xs, k): return xs[:k]
44
+
45
+
46
+ def _prepare(context, schema):
47
+ qs = Decider._schema_to_questions(schema)
48
+ for q in qs:
49
+ if getattr(eng, "neutralize_none", True):
50
+ q["options"], q["_back"] = neutralize_options(q["options"])
51
+ ex = Example(context, [Q(q["question"], list(q["options"]), 0) for q in qs])
52
+ it = build(ex, eng.tok, _NoShuffle(), max_options=MAX_OPTIONS, max_ctx_tokens=eng.max_ctx)
53
+ return qs, it
54
+
55
+
56
+ def _format(schema, qs, probs):
57
+ o = {}
58
+ for (qtext, spec), q, p in zip(schema.items(), qs, probs):
59
+ p = p[:len(q["options"])].tolist(); t = spec.get("type", "choice"); j = max(range(len(p)), key=p.__getitem__)
60
+ back = q.get("_back", {}); names = [back.get(x, x) for x in q["options"]]
61
+ if t == "bool":
62
+ o[qtext] = {"noul": round(p[1], 4), "type": "noul"}
63
+ elif t == "choice":
64
+ o[qtext] = {"choice": names[j], "confidence": round(p[j], 4), "type": "choice",
65
+ "probabilities": {k: round(v, 4) for k, v in zip(names, p)}}
66
+ else:
67
+ keys = q["_keys"]; score = sum(float(k) * pi for k, pi in zip(keys, p))
68
+ o[qtext] = {"score": round(score, 2), "confidence": round(p[j], 4), "type": "scale", "legend": q["_legend"],
69
+ "probabilities": {str(keys[i]): round(pi, 4) for i, pi in enumerate(p)}}
70
+ return o
71
+
72
+
73
+ async def _collect(q):
74
+ """Continuous batching: take what is already queued and go. While a forward pass runs, new requests pile up and form the
75
+ next batch, so there is no fixed wait at low load (it cost 1.5 ms per request) and full batches at high load.
76
+ DECIDER_BATCH_WAIT_MS > 0 restores a short collection window."""
77
+ batch = [await q.get()]; deadline = time.monotonic() + BATCH_WAIT_MS / 1000
78
+ while len(batch) < MAX_BATCH:
79
+ try:
80
+ batch.append(q.get_nowait())
81
+ except asyncio.QueueEmpty:
82
+ timeout = deadline - time.monotonic()
83
+ if timeout <= 0: break
84
+ try: batch.append(await asyncio.wait_for(q.get(), timeout))
85
+ except asyncio.TimeoutError: break
86
+ return batch
87
+
88
+
89
+ async def batcher():
90
+ loop = asyncio.get_running_loop()
91
+ while True:
92
+ batch = await _collect(queue)
93
+ # sort by length; split into at most two groups when the spread is large (keeps padding small)
94
+ batch.sort(key=lambda x: len(x[2]["ids"]))
95
+ groups = [batch]
96
+ if len(batch) >= 4:
97
+ lo, hi = len(batch[0][2]["ids"]), len(batch[-1][2]["ids"])
98
+ if _bucket(hi, T_BUCKETS) != _bucket(lo, T_BUCKETS) and hi > 1.5 * lo:
99
+ cut = len(batch) // 2; groups = [batch[:cut], batch[cut:]]
100
+ capped = [] # long rows: keep every forward under MAX_FWD_TOKENS padded tokens
101
+ for g in groups:
102
+ cur = []
103
+ for x in g:
104
+ if cur and (len(cur) + 1) * len(x[2]["ids"]) > MAX_FWD_TOKENS:
105
+ capped.append(cur); cur = []
106
+ cur.append(x)
107
+ capped.append(cur)
108
+ for g in capped:
109
+ items = [it for _, _, it in g]
110
+ try:
111
+ probs = await loop.run_in_executor(None, _locked, eng.score_items, items)
112
+ for (fut, qs, it), p in zip(g, probs):
113
+ if not fut.done(): fut.set_result(p)
114
+ except Exception as e:
115
+ for fut, _, _ in g:
116
+ if not fut.done(): fut.set_exception(e)
117
+ stats["batches"] += 1; stats["batch_hist"][len(g)] = stats["batch_hist"].get(len(g), 0) + 1
118
+
119
+
120
+ @app.on_event("startup")
121
+ async def _start():
122
+ global eng, queue
123
+ eng = Engine(MODEL, compile=COMPILE, fp8=FP8, conv_patch=COMPILE); print("[serve] engine", eng.cfg, flush=True)
124
+ import json
125
+ global MODEL_NAME, TEMP
126
+ try: cfg = json.load(open(os.path.join(MODEL, "decider_config.json")))
127
+ except Exception: cfg = {}
128
+ eng.neutralize_none = bool(cfg.get("neutralize_none", True)); MODEL_NAME = "decider-" + str(cfg.get("version", "dev"))
129
+ TEMP = float(os.environ.get("DECIDER_TEMPERATURE", cfg.get("temperature", 1.0)))
130
+ global RELEASE_DATE; RELEASE_DATE = str(cfg.get("release_date", RELEASE_DATE))
131
+ global SCHEMA_FIRST, se, squeue, ISOLATED
132
+ ISOLATED = bool(cfg.get("isolated_levels", False))
133
+ # the schema cache needs the questions-first layout, which costs accuracy (about 1.5 points on fixed label sets, more on large
134
+ # label sets and long states): on when the model's config makes it the default, or with DECIDER_SCHEMA_CACHE=1
135
+ trained = bool(cfg.get("schema_first", False) or cfg.get("schema_first_trained", False))
136
+ SCHEMA_FIRST = trained and (bool(cfg.get("schema_first", False)) or os.environ.get("DECIDER_SCHEMA_CACHE", "0") == "1")
137
+ global TEMP_SCHEMA; TEMP_SCHEMA = float(cfg.get("temperature_schema_first", TEMP))
138
+ if SCHEMA_FIRST:
139
+ from decider.schema_engine import SchemaEngine
140
+ se = SchemaEngine(eng); squeue = asyncio.Queue(); asyncio.create_task(schema_batcher()); print("[serve] schema cache on", flush=True)
141
+ pre = os.environ.get("DECIDER_SCHEMAS") # JSON file: [{"questions": {...}, "independent": true, "batch_sizes": [1, 8, 32], "state_tokens": [64, 256]}]
142
+ for spec in (json.load(open(pre)) if pre else []): # known schemas: prefix computed, graphs compiled and captured before traffic
143
+ _, h, _ = _schema_handle(spec["questions"], spec.get("independent", True), compile=COMPILE)
144
+ t = se.warmup(h, spec.get("batch_sizes", (1, 8, 32)), spec.get("state_tokens", (64, 128, 256)))
145
+ print(f"[serve] preloaded schema with {h.nq} rows, prefix {sum(h.tps)} tokens, graphs ready in {t:.0f}s", flush=True)
146
+ shapes = [(B, T) for B in (1, 2, 4, 8, 16, 32) for T in T_BUCKETS if T <= eng.max_ctx + 256]
147
+ if MAX_BATCH > 32: shapes += [(64, T) for T in T_BUCKETS if T <= 512]
148
+ t = eng.warmup(shapes); print(f"[serve] captured {len(shapes)} graphs in {t:.0f}s", flush=True)
149
+ queue = asyncio.Queue()
150
+ asyncio.create_task(batcher())
151
+
152
+
153
+ @app.post("/decide")
154
+ async def decide(r: Req):
155
+ qs, it = await asyncio.get_running_loop().run_in_executor(None, _prepare, r.context, r.schema_)
156
+ fut = asyncio.get_running_loop().create_future()
157
+ await queue.put((fut, qs, it))
158
+ probs = await fut
159
+ stats["requests"] += 1; stats["decisions"] += len(qs)
160
+ return _format(r.schema_, qs, probs)
161
+
162
+
163
+ SCHEMA_FIRST = False; se = None; squeue = None; schemas = {} # schema cache (models trained on the questions-first layout, v7+)
164
+
165
+
166
+ ISOLATED = False
167
+
168
+
169
+ def _schema_handle(questions, independent, compile=False):
170
+ """Compile (or look up) the question schema: its prefix is run once, requests then only run the state."""
171
+ import json
172
+ key = (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent)
173
+ if key not in schemas:
174
+ rqs = {k: S1.render_question(v) for k, v in questions.items()}; rows, index = S1.plan_rows(rqs, ISOLATED and independent)
175
+ with gpu_lock:
176
+ if len(schemas) >= 128:
177
+ old = next(iter(schemas)); hid = schemas.pop(old)[1].id
178
+ for k in [k for k in se.graphs if k[0] == hid]: del se.graphs[k]
179
+ h = se.prepare(rows, independent=independent, compile=compile)
180
+ schemas[key] = (rqs, h, index)
181
+ return schemas[key]
182
+
183
+
184
+ seen = {}
185
+
186
+
187
+ def _worth_caching(questions, independent):
188
+ """A schema gets a cached prefix and CUDA graphs from its second request on: one-off schemas go through the generic
189
+ state-first engine, whose graphs do not depend on the questions, so ad-hoc traffic cannot thrash graph captures."""
190
+ import json
191
+ key = (json.dumps(questions, sort_keys=True, ensure_ascii=False), independent)
192
+ if key in schemas: return True
193
+ if len(seen) > 50000: seen.clear()
194
+ seen[key] = seen.get(key, 0) + 1
195
+ return seen[key] >= int(os.environ.get("DECIDER_SCHEMA_MIN_SEEN", "2"))
196
+
197
+
198
+ def _score_schema(h, rows):
199
+ with gpu_lock:
200
+ return se.score_rows(h, rows, temperature=TEMP_SCHEMA)
201
+
202
+
203
+ async def schema_batcher():
204
+ """Requests that share a schema and arrive within the window are scored in one forward pass over their states."""
205
+ loop = asyncio.get_running_loop()
206
+ while True:
207
+ batch = await _collect(squeue)
208
+ groups = {} # one forward per (schema, length bucket): short states are not padded to long ones
209
+ for fut, h, row in batch: groups.setdefault((h.id, se.bucket(len(row[0]))), (h, []))[1].append((fut, row))
210
+ for h, items in groups.values():
211
+ step = max(1, MAX_BATCH // h.P)
212
+ for i in range(0, len(items), step):
213
+ chunk = items[i:i + step]
214
+ try:
215
+ probs = await loop.run_in_executor(None, _score_schema, h, [c for _, c in chunk])
216
+ for (fut, _), p in zip(chunk, probs):
217
+ if not fut.done(): fut.set_result(p)
218
+ except Exception as e:
219
+ for fut, _ in chunk:
220
+ if not fut.done(): fut.set_exception(e)
221
+ stats["schema_batches"] = stats.get("schema_batches", 0) + 1
222
+
223
+
224
+ def _locked(fn, items):
225
+ with gpu_lock:
226
+ return fn(items, temperature=TEMP) # fitted temperature from decider_config.json
227
+
228
+
229
+ class S1Req(BaseModel):
230
+ state: object
231
+ questions: dict
232
+ model: str | None = None
233
+ independent: bool = True
234
+ layout: str | None = None # "state_first" forces the uncached layout on a schema-first model
235
+
236
+
237
+ def _prepare_s1(state, questions, independent):
238
+ ctx = S1.render_state(state); rqs = {k: S1.render_question(v) for k, v in questions.items()}
239
+ flat, index = S1.plan_rows(rqs, ISOLATED and independent)
240
+ rows = [[r] for r in flat] if independent else [flat]
241
+ items = [build(Example(ctx, [Q(r["question"], list(r["options"]), 0) for r in row]), eng.tok, _NoShuffle(), max_options=MAX_OPTIONS,
242
+ max_ctx_tokens=MAX_STATE_TOKENS) for row in rows]
243
+ return (rqs, index), items
244
+
245
+
246
+ @app.post("/v1/systemone")
247
+ async def systemone(r: S1Req):
248
+ loop = asyncio.get_running_loop()
249
+ if SCHEMA_FIRST and r.layout != "state_first" and _worth_caching(r.questions, r.independent):
250
+ try:
251
+ rqs, h, index = await loop.run_in_executor(None, _schema_handle, r.questions, r.independent)
252
+ except ValueError as e:
253
+ raise HTTPException(422, str(e))
254
+ row = await loop.run_in_executor(None, lambda: se.tokenize(h, S1.render_state(r.state), MAX_STATE_TOKENS)) # CPU work stays off the GPU lock
255
+ fut = loop.create_future(); await squeue.put((fut, h, row)); p = await fut
256
+ stats["requests"] += 1; stats["decisions"] += len(rqs); stats["schema_requests"] = stats.get("schema_requests", 0) + 1
257
+ return {"model": MODEL_NAME, "answers": S1.assemble(rqs, index, [pk.tolist() for pk in p]),
258
+ "usage": {"input_tokens": len(row[0]) * h.P, "cached_tokens": sum(h.tps), "output_tokens": 0}}
259
+ try:
260
+ (rqs, index), items = await loop.run_in_executor(None, _prepare_s1, r.state, r.questions, r.independent)
261
+ except ValueError as e:
262
+ raise HTTPException(422, str(e))
263
+ if len(items) > 1 and min(len(it["ids"]) for it in items) >= SHARED_MIN_TOKENS:
264
+ res = await loop.run_in_executor(None, _locked, eng.score_shared, items) # long state: run it once, fork the cache per question
265
+ stats["shared_prefix_requests"] = stats.get("shared_prefix_requests", 0) + 1
266
+ else:
267
+ futs = []
268
+ for it in items:
269
+ f = loop.create_future(); futs.append(f); await queue.put((f, None, it))
270
+ res = await asyncio.gather(*futs)
271
+ probs = [p for ps in res for p in ps] # one prob row per question, request order
272
+ stats["requests"] += 1; stats["decisions"] += len(rqs)
273
+ return {"model": MODEL_NAME, "answers": S1.assemble(rqs, index, [p.tolist() for p in probs]),
274
+ "usage": {"input_tokens": S1.unique_tokens(items), "output_tokens": 0}}
275
+
276
+
277
+ @app.get("/v1/models")
278
+ async def models():
279
+ return {"models": [{"name": MODEL_NAME, "description": "decider: one-pass typed decisions with calibrated probabilities", "release_date": RELEASE_DATE}]}
280
+
281
+
282
+ @app.get("/health")
283
+ async def health():
284
+ return {"ok": eng is not None, "model": MODEL}
285
+
286
+
287
+ @app.get("/stats")
288
+ async def get_stats():
289
+ return dict(stats, engine=eng.stats if eng else None, graphs=len(eng.graphs) if eng else 0)
decider/systemone.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": ..., "criteria": {"true": ..., "false": ...} (optional)}}
7
+ ids are never shown to the model. `instructions` and every description may be a string or any JSON value.
8
+ """
9
+ import json, math
10
+
11
+ MAX_CHOICE, MAX_LEVELS = 255, 10
12
+
13
+
14
+ def _txt(v):
15
+ return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
16
+
17
+
18
+ ANNOTATE_MIN = 8
19
+
20
+
21
+ def annotate_indices(x, min_len=ANNOTATE_MIN):
22
+ """Write each element's position into long arrays ({"_index": i, ...}). A path such as `records[47].text` otherwise makes
23
+ the model count 47 elements; with the index written down it is a lookup (json_k64 probe: 0.49 -> 0.57 accuracy)."""
24
+ if isinstance(x, list):
25
+ if len(x) >= min_len:
26
+ 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)]
27
+ return [annotate_indices(v, min_len) for v in x]
28
+ if isinstance(x, dict):
29
+ return {k: annotate_indices(v, min_len) for k, v in x.items()}
30
+ return x
31
+
32
+
33
+ def render_state(state, index_arrays=True):
34
+ if isinstance(state, str):
35
+ return state
36
+ return json.dumps(annotate_indices(state) if index_arrays else state, ensure_ascii=False)
37
+
38
+
39
+ def render_question(spec):
40
+ """-> dict(question=str, options=[str], type=..., names=[...]) (names: what the answer reports for each option)"""
41
+ t = spec.get("type", "choice"); ins = _txt(spec.get("instructions", spec.get("question", ""))); crit = spec.get("criteria", spec.get("options"))
42
+ if not ins:
43
+ raise ValueError("question without instructions")
44
+ if t == "choice":
45
+ if isinstance(crit, (list, tuple)):
46
+ crit = {str(c): None for c in crit}
47
+ if not isinstance(crit, dict) or not 2 <= len(crit) <= MAX_CHOICE:
48
+ raise ValueError(f"choice criteria: a map of 2..{MAX_CHOICE} options")
49
+ names = list(crit); opts = [n if crit[n] in (None, "") else f"{n}: {_txt(crit[n])}" for n in names]
50
+ elif t == "score":
51
+ if isinstance(crit, dict): # legend form {"0": "...", "1": "..."}
52
+ crit = [crit[k] for k in sorted(crit, key=float)]
53
+ if not isinstance(crit, (list, tuple)) or not 2 <= len(crit) <= MAX_LEVELS:
54
+ raise ValueError(f"score criteria: an ordered list of 2..{MAX_LEVELS} level descriptions")
55
+ names = list(range(len(crit))); opts = [f"{i}: {_txt(c)}" for i, c in enumerate(crit)]
56
+ elif t in ("noul", "bool"):
57
+ names = [False, True]; c = crit or {}
58
+ f, tr = c.get("false", c.get(False)), c.get("true", c.get(True))
59
+ opts = ["no" if f in (None, "") else f"no: {_txt(f)}", "yes" if tr in (None, "") else f"yes: {_txt(tr)}"]
60
+ else:
61
+ raise ValueError(f"unknown question type {t!r}")
62
+ 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,
63
+ isolated=bool(spec.get("isolated", True)))
64
+
65
+
66
+ # ---- isolated levels: every Score level is judged in its own row, without its number or its neighbours
67
+ ISOLATED = "{q}\nProposed answer: {level}\nDoes the proposed answer fit?"
68
+ _NUM = None
69
+
70
+
71
+ def strip_level_number(text):
72
+ """"2: somewhat" -> "somewhat" (dataset legends carry the number; an isolated level must not)."""
73
+ import re
74
+ return re.sub(r"^\s*-?\d+\s*:\s*", "", text)
75
+
76
+
77
+ def isolated_rows(question, levels):
78
+ """-> one yes/no question per level: [(question text, ["no", "yes"])]."""
79
+ return [(ISOLATED.format(q=question, level=strip_level_number(l)), ["no", "yes"]) for l in levels]
80
+
81
+
82
+ def combine_isolated(p_yes):
83
+ """Per-level P(fits), each computed without reference to any other level -> a distribution over levels.
84
+ Also returns the unnormalised mass: near 1 when exactly one level fits, low when none does, high when several do."""
85
+ tot = sum(p_yes) or 1e-9
86
+ return [x / tot for x in p_yes], tot
87
+
88
+
89
+ def plan_rows(rqs, isolated=True):
90
+ """One scoring row per question; a Score question with isolated levels becomes one yes/no row per level.
91
+ -> (rows [{"question", "options"}], index [(id, "iso" | "list", first row, n rows)])"""
92
+ rows, index = [], []
93
+ for k, r in rqs.items():
94
+ if isolated and r["type"] == "score" and r.get("isolated", True):
95
+ 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]
96
+ else:
97
+ index.append((k, "list", len(rows), 1)); rows.append(dict(question=r["question"], options=r["options"]))
98
+ return rows, index
99
+
100
+
101
+ def assemble(rqs, index, probs):
102
+ """probs: one probability list per row (plan_rows order) -> {id: answer}."""
103
+ out = {}
104
+ for k, kind, s, n in index:
105
+ if kind == "iso":
106
+ fit = [float(probs[s + j][1]) for j in range(n)]; p, mass = combine_isolated(fit); a = format_answer(rqs[k], p)
107
+ a["level_fit"] = {str(j): round(x, 4) for j, x in enumerate(fit)}; a["fit_mass"] = round(mass, 4); out[k] = a
108
+ else:
109
+ out[k] = format_answer(rqs[k], probs[s])
110
+ return out
111
+
112
+
113
+ def certainty(p):
114
+ """1 - normalised entropy: 1 when all mass is on one option, 0 when the distribution is flat."""
115
+ h = -sum(x * math.log(x) for x in p if x > 0)
116
+ return max(0.0, 1.0 - h / math.log(len(p))) if len(p) > 1 else 1.0
117
+
118
+
119
+ def format_answer(rq, p, nd=4):
120
+ """rq: render_question output; p: probabilities in option order."""
121
+ p = [float(x) for x in p[:len(rq["options"])]]; s = sum(p) or 1.0; p = [x / s for x in p]
122
+ j = max(range(len(p)), key=p.__getitem__)
123
+ if rq["type"] == "noul":
124
+ return {"type": "noul", "noul": round(p[1], nd)}
125
+ if rq["type"] == "choice":
126
+ return {"type": "choice", "choice": rq["names"][j], "confidence": round(p[j], nd), "certainty": round(certainty(p), nd),
127
+ "probabilities": {n: round(x, nd) for n, x in zip(rq["names"], p)}}
128
+ return {"type": "score", "score": round(sum(i * x for i, x in enumerate(p)), 2), "confidence": round(p[j], nd), "certainty": round(certainty(p), nd),
129
+ "legend": {str(i): d for i, d in enumerate(rq["legend"])}, "probabilities": {str(i): round(x, nd) for i, x in enumerate(p)}}
130
+
131
+
132
+ def unique_tokens(items):
133
+ """Input tokens of a request whose rows share a prefix (the state): the prefix counts once."""
134
+ ids = [it["ids"] for it in items]
135
+ if len(ids) < 2:
136
+ return sum(len(x) for x in ids)
137
+ lcp = 0; short = min(len(x) for x in ids)
138
+ while lcp < short and all(x[lcp] == ids[0][lcp] for x in ids): lcp += 1
139
+ return lcp + sum(len(x) - lcp for x in ids)
decider_config.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"temperature": 1.03, "temperature_schema_first": 1.03, "neutralize_none": false, "version": "0.8b-v1", "base": "Qwen/Qwen3.5-0.8B-Base", "max_options": 255, "max_state_tokens": 32768, "schema_first": false, "schema_first_trained": true, "isolated_levels": true, "recipe": "scripts/train.sh full (single run)", "release_date": "2026-09-19"}
generation_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "eos_token_id": 248044,
4
+ "transformers_version": "5.17.0",
5
+ "use_cache": true
6
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6926f82ef7e9ea408ad881555204daa6bb694ca82509c4d1514b7be2e713563d
3
+ size 1504827608
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523
3
+ size 19989325
tokenizer_config.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|endoftext|>",
10
+ "errors": "replace",
11
+ "image_token": "<|image_pad|>",
12
+ "is_local": false,
13
+ "local_files_only": false,
14
+ "model_max_length": 262144,
15
+ "model_specific_special_tokens": {
16
+ "audio_bos_token": "<|audio_start|>",
17
+ "audio_eos_token": "<|audio_end|>",
18
+ "audio_token": "<|audio_pad|>",
19
+ "image_token": "<|image_pad|>",
20
+ "video_token": "<|video_pad|>",
21
+ "vision_bos_token": "<|vision_start|>",
22
+ "vision_eos_token": "<|vision_end|>"
23
+ },
24
+ "pad_token": "<|endoftext|>",
25
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
26
+ "split_special_tokens": false,
27
+ "tokenizer_class": "Qwen2Tokenizer",
28
+ "unk_token": null,
29
+ "video_token": "<|video_pad|>",
30
+ "vision_bos_token": "<|vision_start|>",
31
+ "vision_eos_token": "<|vision_end|>"
32
+ }