mkirkby commited on
Commit
3903da0
·
verified ·
1 Parent(s): e94c2b1

Upload joey-1

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,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: transformers
4
+ base_model: Qwen/Qwen3.5-2B
5
+ pipeline_tag: text-classification
6
+ language:
7
+ - en
8
+ tags:
9
+ - decision-model
10
+ - classification
11
+ - calibration
12
+ - routing
13
+ - guardrails
14
+ - vision
15
+ - multimodal
16
+ - baseten
17
+ ---
18
+
19
+ # joey-1
20
+
21
+ joey-1 is a 2B parameter **decision model** post-trained from [Qwen3.5-2B](https://huggingface.co/Qwen/Qwen3.5-2B) by Base Labs at Baseten.
22
+ Provide the model a state (text, optionally with images) and a set of questions and it will return a calibrated probability for every option,
23
+ answering all questions from **one shared-prefix prefill**. On tested benchmarks:
24
+
25
+ - **63.3 on the public split of the Jev Decision Index**, ahead of every listed decision model of 2.5B parameters or fewer.
26
+ - **62.9 on a replica of the Artificial Analysis Decision Index**.
27
+
28
+ For tasks that need more accuracy, see the 9B sibling [elephant-1](https://huggingface.co/baselabs/elephant-1) and the 27B [mammoth-1](https://huggingface.co/baselabs/mammoth-1).
29
+
30
+ ## 🗒️ Model Details
31
+
32
+ | Model | Parameters | Description |
33
+ |---|---|---|
34
+ | [Qwen3.5-2B](https://huggingface.co/Qwen/Qwen3.5-2B) | 2B | General-purpose base model |
35
+ | **[joey-1](https://huggingface.co/baselabs/joey-1)** | 2B | Post-trained for single-pass, calibrated decisions |
36
+ | [elephant-1](https://huggingface.co/baselabs/elephant-1) | 9B | Larger sibling |
37
+ | [mammoth-1](https://huggingface.co/baselabs/mammoth-1) | 27B | Largest sibling, highest accuracy |
38
+
39
+ - **Inputs**: text and images
40
+ - **Training**: full fine-tune of the language model on text and image decisions; the vision encoder is frozen
41
+ - **Attention**: block-bidirectional mask on full-attention layers.
42
+ - **Max sequence length**: 16,384 tokens
43
+
44
+ ## 🏃 How to use
45
+
46
+ ### Self-hosted
47
+
48
+ `setup.sh` installs the pinned serving runtime (OneJev + `serve.patch`). It needs one CUDA GPU and the `pytorch/pytorch:2.8.0-cuda12.8-cudnn9-devel` image.
49
+
50
+ ```bash
51
+ hf download baselabs/joey-1 --local-dir joey-1 && bash joey-1/setup.sh
52
+ ```
53
+
54
+ ```python
55
+ from qev.engine import DecisionEngine
56
+ from qev.schema import SystemOneRequest
57
+
58
+ engine = DecisionEngine("baselabs/joey-1", device="cuda:0")
59
+
60
+ questions = {
61
+ "refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
62
+ "team": {
63
+ "type": "choice",
64
+ "instructions": "Which team should handle this?",
65
+ "criteria": {"billing": "Charges, refunds, invoices",
66
+ "technical": "App or site faults",
67
+ "fraud": "Suspected unauthorised use"},
68
+ },
69
+ "urgency": {
70
+ "type": "score",
71
+ "instructions": "How urgent is this?",
72
+ "criteria": ["Can wait", "Today", "Blocking the customer now"],
73
+ },
74
+ }
75
+ resp, _ = engine.decide(SystemOneRequest(
76
+ state="I was charged twice this month, please refund one of them.", questions=questions))
77
+ print(resp.answers)
78
+ ```
79
+
80
+ | `type` | `criteria` | answer fields |
81
+ |---|---|---|
82
+ | `noul`: yes or no | optional | `noul`: P(yes) |
83
+ | `choice`: one of named options | `{name: description}` | `choice`, `probabilities` |
84
+ | `score`: ordered levels | list of levels, lowest first | `score`, `probabilities` |
85
+
86
+ Requests longer than the trained length limits are refused, not truncated.
87
+
88
+ ## 📊 Performance
89
+
90
+ ### Jev Decision Index, public split (our runs)
91
+
92
+ | Model | Public score |
93
+ |---|---:|
94
+ | elephant-1 | 72.2 |
95
+ | **joey-1** | **63.3** |
96
+
97
+ | Area | joey-1 |
98
+ |---|---:|
99
+ | Knowledge & reasoning | 50.0 |
100
+ | Language | 62.8 |
101
+ | Retrieval & classification | 71.2 |
102
+ | Tools & automation | 85.8 |
103
+ | Arts & human taste | 54.8 |
104
+
105
+ ### Artificial Analysis Decision Index (our replica)
106
+
107
+ | Model | Score |
108
+ |---|---:|
109
+ | elephant-1 | 65.2 |
110
+ | **joey-1** | **62.9** |
111
+
112
+ ## 🔧 Training
113
+
114
+ - **Data**: 6.21M decision examples (Mix 8), the same mix as elephant-1.
115
+ - **Objective**: cross-entropy plus a Brier calibration term (λ = 1), with a trained "I don't know" option.
116
+ - **Recipe**: 1 epoch (43,390 steps), peak LR 1e-5, AdamW (no weight decay), 3% warm-up, cosine decay, bf16.
117
+ - **Hardware**: 2 nodes × 8 H100 (16 GPUs).
118
+
119
+ ## Citation
120
+
121
+ ```bibtex
122
+ @article{baseten2026mammoth,
123
+ author = {Base Labs, Baseten},
124
+ title = {mammoth-1 and elephant-1: Frontier Decision Models},
125
+ journal = {Base Labs},
126
+ year = {2026},
127
+ note = {https://labs.baseten.co/articles/mammoth-1-and-elephant-1-frontier-decision-models},
128
+ }
129
+ ```
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,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForConditionalGeneration"
4
+ ],
5
+ "dtype": "float32",
6
+ "image_token_id": 248056,
7
+ "model_type": "qwen3_5",
8
+ "text_config": {
9
+ "attention_bias": false,
10
+ "attention_dropout": 0.0,
11
+ "attn_output_gate": true,
12
+ "bos_token_id": null,
13
+ "dtype": "bfloat16",
14
+ "eos_token_id": 248044,
15
+ "full_attention_interval": 4,
16
+ "head_dim": 256,
17
+ "hidden_act": "silu",
18
+ "hidden_size": 2048,
19
+ "initializer_range": 0.02,
20
+ "intermediate_size": 6144,
21
+ "layer_types": [
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
+ "linear_attention",
43
+ "linear_attention",
44
+ "linear_attention",
45
+ "full_attention"
46
+ ],
47
+ "linear_conv_kernel_dim": 4,
48
+ "linear_key_head_dim": 128,
49
+ "linear_num_key_heads": 16,
50
+ "linear_num_value_heads": 16,
51
+ "linear_value_head_dim": 128,
52
+ "mamba_ssm_dtype": "float32",
53
+ "max_position_embeddings": 262144,
54
+ "mlp_only_layers": [],
55
+ "model_type": "qwen3_5_text",
56
+ "mtp_num_hidden_layers": 1,
57
+ "mtp_use_dedicated_embeddings": false,
58
+ "num_attention_heads": 8,
59
+ "num_hidden_layers": 24,
60
+ "num_key_value_heads": 2,
61
+ "pad_token_id": null,
62
+ "partial_rotary_factor": 0.25,
63
+ "rms_norm_eps": 1e-06,
64
+ "rope_parameters": {
65
+ "mrope_interleaved": true,
66
+ "mrope_section": [
67
+ 11,
68
+ 11,
69
+ 10
70
+ ],
71
+ "partial_rotary_factor": 0.25,
72
+ "rope_theta": 10000000,
73
+ "rope_type": "default"
74
+ },
75
+ "tie_word_embeddings": true,
76
+ "use_cache": true,
77
+ "vocab_size": 248320
78
+ },
79
+ "tie_word_embeddings": true,
80
+ "transformers_version": "5.19.0",
81
+ "use_cache": false,
82
+ "video_token_id": 248057,
83
+ "vision_config": {
84
+ "deepstack_visual_indexes": [],
85
+ "depth": 24,
86
+ "dtype": "bfloat16",
87
+ "hidden_act": "gelu_pytorch_tanh",
88
+ "hidden_size": 1024,
89
+ "in_channels": 3,
90
+ "initializer_range": 0.02,
91
+ "intermediate_size": 4096,
92
+ "model_type": "qwen3_5_vision",
93
+ "num_heads": 16,
94
+ "num_position_embeddings": 2304,
95
+ "out_hidden_size": 2048,
96
+ "patch_size": 16,
97
+ "rope_parameters": {
98
+ "rope_theta": 10000.0,
99
+ "rope_type": "axial"
100
+ },
101
+ "spatial_merge_size": 2,
102
+ "temporal_patch_size": 2
103
+ },
104
+ "vision_end_token_id": 248054,
105
+ "vision_start_token_id": 248053,
106
+ "modeling_overrides": {
107
+ "attention": {
108
+ "full_attention": "block_bidirectional",
109
+ "linear_attention": "causal"
110
+ },
111
+ "readout": "slot_lm",
112
+ "head_dim": 256,
113
+ "n_slots": 256
114
+ }
115
+ }
constraints.txt ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pinned package versions of the training and eval stack, minus torch's own CUDA libraries, which follow the image.
2
+ accelerate==1.15.0
3
+ annotated-doc==0.0.5
4
+ annotated-types==0.8.0
5
+ anyio==4.15.1
6
+ astunparse==1.6.3
7
+ brotli==1.2.0
8
+ causal-conv1d==1.7.0
9
+ click==8.5.0
10
+ cmake==4.0.0
11
+ dnspython==2.7.0
12
+ einops==0.8.2
13
+ expecttest==0.3.0
14
+ fastapi==0.143.1
15
+ fla-core==0.5.2
16
+ flash-linear-attention==0.5.2
17
+ fsspec==2025.3.2
18
+ googleapis-common-protos==1.75.5
19
+ h11==0.16.0
20
+ hf-xet==1.7.0
21
+ httpcore2==2.13.1
22
+ httpx2==2.13.1
23
+ huggingface_hub==2.2.0
24
+ hypothesis==6.131.7
25
+ idna==3.20
26
+ lintrunner==0.12.7
27
+ markdown-it-py==4.2.0
28
+ mdurl==0.1.2
29
+ mpmath==1.3.0
30
+ networkx==3.4.2
31
+ ninja==1.11.1.4
32
+ opentelemetry-api==1.45.1
33
+ opentelemetry-exporter-http-transport==0.66b1
34
+ opentelemetry-exporter-otlp-common==0.66b1
35
+ opentelemetry-exporter-otlp-proto-common==1.45.1
36
+ opentelemetry-exporter-otlp-proto-http==1.45.1
37
+ opentelemetry-proto==1.45.1
38
+ opentelemetry-sdk==1.45.1
39
+ opentelemetry-semantic-conventions==0.66b1
40
+ optree==0.15.0
41
+ peft==0.21.2
42
+ pillow==11.0.0
43
+ protobuf==7.36.2
44
+ pyarrow==26.0.0
45
+ pydantic==2.14.1
46
+ pydantic_core==2.50.1
47
+ python-etcd==0.4.5
48
+ regex==2026.9.29
49
+ rich==15.0.0
50
+ safetensors==0.8.0
51
+ shellingham==1.5.4
52
+ six==1.17.0
53
+ sortedcontainers==2.4.0
54
+ starlette==1.8.0
55
+ sympy==1.13.3
56
+ tokenizers==0.23.3
57
+ transformers==5.19.0
58
+ typer==0.27.3
59
+ types-dataclasses==0.6.6
60
+ typing-inspection==0.4.4
61
+ typing_extensions==4.16.0
62
+ uvicorn==0.54.0
63
+ wandb==0.30.0
64
+ xxhash==4.0.1
65
+ zstandard==0.23.0
engine.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision Index engine for this checkpoint, on OneJev's qev runtime @81ce62f + serve.patch (setup.sh installs both).
2
+
3
+ The checkpoint's readout.json (attention, readout) and optional calibration.json are read at load, so the model is
4
+ served exactly as it was trained: one shared-prefix prefill per request and one batched forward over the question
5
+ suffixes. No prompt tuning, truncation or option pruning; requests over the trained length limits are refused.
6
+
7
+ python -m decision_index pipeline --engine engine:DecisionModel --option model=<repo or path> --out runs/<name>
8
+ """
9
+ from decision_index.engines import Engine, Unsupported
10
+ from qev.engine import DecisionEngine
11
+ from qev.schema import SystemOneRequest
12
+
13
+ FIELDS = {"choice": {"type", "choice", "probabilities"}, "noul": {"type", "noul"},
14
+ "score": {"type", "score", "probabilities"}}
15
+
16
+
17
+ class DecisionModel(Engine):
18
+ name = "decision-model"
19
+ latency = "In-process qev decide() wall time including prompt rendering; excludes model loading."
20
+
21
+ def __init__(self, model, device="cuda:0", **options):
22
+ super().__init__(model=model, device=device, **options)
23
+ self.engine = DecisionEngine(model, device=device)
24
+ info = self.engine.info
25
+ self.provenance = {"kind": "full fine-tune", "runtime": "OneJev qev @81ce62f + serve.patch", "model": model,
26
+ "fork_mode": info.fork_mode, "attention": info.extra["attention"],
27
+ "readout": info.extra["readout"], "calibration": info.extra["calibration"]}
28
+
29
+ def __call__(self, state, questions):
30
+ for q in questions.values():
31
+ if q.get("type") not in FIELDS:
32
+ raise Unsupported(f"question type {q.get('type')}")
33
+ request = SystemOneRequest(state=state, questions=questions)
34
+ try:
35
+ resp, _ = self.engine.decide(request)
36
+ except ValueError as e:
37
+ if "limit is" in str(e):
38
+ raise Unsupported(str(e)) from e
39
+ raise
40
+ except RuntimeError as e:
41
+ # The batched prefix fork overflows 32-bit indexing on the largest multi-question requests; the
42
+ # sequential branches are OneJev's exact fallback for the same computation.
43
+ if "32BitIndexMath" not in str(e):
44
+ raise
45
+ mode, self.engine.info.fork_mode = self.engine.info.fork_mode, "sequential"
46
+ try:
47
+ resp, _ = self.engine.decide(request)
48
+ finally:
49
+ self.engine.info.fork_mode = mode
50
+ answers = {k: a.model_dump(include=FIELDS[a.type]) for k, a in resp.answers.items()}
51
+ return {"model": self.options["model"], "answers": answers}, None
52
+
53
+ def synchronize(self):
54
+ import torch
55
+ torch.cuda.synchronize(self.engine.device)
generation_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "eos_token_id": 248044,
4
+ "transformers_version": "5.19.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:4c503aded23bae311dff80a63326503fb720572c178f07a2142332d55f5f3afc
3
+ size 5443677680
processor_config.json ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "image_processor": {
3
+ "do_convert_rgb": true,
4
+ "do_normalize": true,
5
+ "do_rescale": true,
6
+ "do_resize": true,
7
+ "image_mean": [
8
+ 0.5,
9
+ 0.5,
10
+ 0.5
11
+ ],
12
+ "image_processor_type": "Qwen2VLImageProcessor",
13
+ "image_std": [
14
+ 0.5,
15
+ 0.5,
16
+ 0.5
17
+ ],
18
+ "merge_size": 2,
19
+ "patch_size": 16,
20
+ "resample": 3,
21
+ "rescale_factor": 0.00392156862745098,
22
+ "size": {
23
+ "longest_edge": 16777216,
24
+ "shortest_edge": 65536
25
+ },
26
+ "temporal_patch_size": 2
27
+ },
28
+ "processor_class": "Qwen3VLProcessor",
29
+ "video_processor": {
30
+ "do_convert_rgb": true,
31
+ "do_normalize": true,
32
+ "do_rescale": true,
33
+ "do_resize": true,
34
+ "do_sample_frames": true,
35
+ "fps": 2,
36
+ "image_mean": [
37
+ 0.5,
38
+ 0.5,
39
+ 0.5
40
+ ],
41
+ "image_std": [
42
+ 0.5,
43
+ 0.5,
44
+ 0.5
45
+ ],
46
+ "max_frames": 768,
47
+ "max_video_tokens": 768,
48
+ "merge_size": 2,
49
+ "min_frames": 4,
50
+ "patch_size": 16,
51
+ "resample": 3,
52
+ "rescale_factor": 0.00392156862745098,
53
+ "return_metadata": false,
54
+ "size": {
55
+ "longest_edge": 25165824,
56
+ "shortest_edge": 4096
57
+ },
58
+ "temporal_patch_size": 2,
59
+ "video_processor_type": "Qwen3VLVideoProcessor"
60
+ }
61
+ }
readout.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "readout": "slot_lm",
3
+ "head_dim": 256,
4
+ "n_slots": 256
5
+ }
serve.patch ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/qev/engine.py b/qev/engine.py
2
+ index 2e4c7b0..cb79e4f 100644
3
+ --- a/qev/engine.py
4
+ +++ b/qev/engine.py
5
+ @@ -10,6 +10,7 @@ tolerance of 0.05 and only guards against structural failures (an unknown cache
6
+ """
7
+ from __future__ import annotations
8
+
9
+ +import contextlib
10
+ import copy
11
+ import hashlib
12
+ import logging
13
+ @@ -19,6 +20,7 @@ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .answers import make_choice, make_noul, make_score
17
+ +from . import readout as ro
18
+ from .calibrate import Calibration, softmax
19
+ from .prompt import (DEFAULT_STYLE, LETTERS, MAX_SLOTS, PROMPT_VERSION, SLOT_LABELS, PromptStyle, RenderedQuestion,
20
+ build_messages, render_question)
21
+ @@ -102,11 +104,20 @@ class DecisionEngine:
22
+ self.model_type = config.model_type
23
+ self.template_kwargs = {"enable_thinking": False} if "qwen" in self.model_type else {}
24
+ self.slot_ids = self._verify_slots()
25
+ + rcfg = ro.ReadoutConfig.load(model_path) if local else ro.ReadoutConfig()
26
+ + self.readout = ro.Readout.attach(self.model, rcfg, slot_ids=self.slot_ids,
27
+ + head_path=Path(model_path) / ro.HEAD_FILE if rcfg.head else None,
28
+ + device=self.device)
29
+ + if rcfg.head:
30
+ + self.max_slots = min(self.max_slots, rcfg.n_slots)
31
+ + if calibration is None and local and (Path(model_path) / "calibration.json").exists():
32
+ + self.calibration = Calibration.load(Path(model_path) / "calibration.json")
33
+ self.pad_id = self.tokenizer.pad_token_id if self.tokenizer.pad_token_id is not None else self.tokenizer.eos_token_id
34
+ self.info = EngineInfo(model_path=model_path, model_type=self.model_type, dtype=dtype, device=str(self.device),
35
+ fork_mode="sequential", fork_max_abs_diff=None,
36
+ transformers_version=transformers.__version__, torch_version=torch.__version__,
37
+ - extra={"head_dtype": head_dtype})
38
+ + extra={"head_dtype": head_dtype, "attention": rcfg.attention, "readout": rcfg.readout,
39
+ + "calibration": self.calibration.source})
40
+ if fork_mode == "auto":
41
+ self._select_fork_mode()
42
+ else:
43
+ @@ -190,6 +201,44 @@ class DecisionEngine:
44
+ with torch.no_grad():
45
+ torch.cuda.empty_cache() if self.device.type == "cuda" else None
46
+
47
+ + def _split(self, state: Any, rendered: list[RenderedQuestion]):
48
+ + """Prefix ids, suffix ids, and per branch the option token spans relative to its suffix (None for slot_lm).
49
+ + The layout is the one train.common.batch_scores computes, so serving matches training: under noncausal
50
+ + attention the cache boundary must be the prefix block boundary."""
51
+ + prefix_ids, suffixes = self._prefix_and_suffix_ids(state, rendered)
52
+ + cfg = self.readout.config
53
+ + if not cfg.needs_layout:
54
+ + return prefix_ids, suffixes, [None] * len(rendered)
55
+ + P = len(prefix_ids)
56
+ + spans = []
57
+ + for r, suf in zip(rendered, suffixes):
58
+ + prompt = self._render_prompt(state, r.suffix)
59
+ + start = prompt.rindex(r.suffix)
60
+ + ids, prefix, sp = ro.token_layout(self.tokenizer, prompt, start,
61
+ + [(start + a, start + b) for a, b in r.option_spans or []])
62
+ + if ids != prefix_ids + suf:
63
+ + raise ValueError("layout tokenization differs from the branch tokenization")
64
+ + if cfg.noncausal and prefix != P:
65
+ + raise ValueError(f"prefix block ends at token {prefix} but the shared cache at {P}")
66
+ + if any(a < P for a, _ in sp):
67
+ + raise ValueError("an option span starts inside the shared prefix")
68
+ + spans.append([(a - P, b - P) for a, b in sp])
69
+ + return prefix_ids, suffixes, spans
70
+ +
71
+ + def _blocks(self, prefixes: list[int], lengths: list[int], width: int):
72
+ + if not self.readout.config.noncausal:
73
+ + return contextlib.nullcontext()
74
+ + return ro.blocks(ro.block_ids(prefixes, lengths, width, self.device))
75
+ +
76
+ + def _scores(self, o, hidden, row: int, logit_pos: int, end: int, n_slots: int, spans) -> tuple[list[float], float]:
77
+ + """Scores of one branch from a forward's output: the slot letters' LM logits, or the readout head over the
78
+ + captured final hidden states (`end` and `spans` index the suffix tokens of this row)."""
79
+ + if self.readout.head is None:
80
+ + return self._readout(o.logits[row, logit_pos], n_slots)
81
+ + h = hidden[row: row + 1]
82
+ + scores = self.readout.head(h[:, end], ro.pool_options(h, [spans], n_slots))[0]
83
+ + return scores.tolist(), 1.0
84
+ +
85
+ def _prefix_and_suffix_ids(self, state: Any, rendered: list[RenderedQuestion]) -> tuple[list[int], list[list[int]]]:
86
+ """Tokenize every branch fully, then find the longest common token prefix that ends at or
87
+ before the end of the state block, so the shared prefix is exact by construction."""
88
+ @@ -218,21 +267,25 @@ class DecisionEngine:
89
+ def _run_sequential(self, state: Any, rendered: list[RenderedQuestion]) -> list[BranchResult]:
90
+ import torch
91
+
92
+ - prefix_ids, suffixes = self._prefix_and_suffix_ids(state, rendered)
93
+ + prefix_ids, suffixes, spans = self._split(state, rendered)
94
+ dev = self.device
95
+ + P = len(prefix_ids)
96
+ with torch.inference_mode():
97
+ - out = self.model(input_ids=torch.tensor([prefix_ids], device=dev),
98
+ - attention_mask=torch.ones((1, len(prefix_ids)), dtype=torch.long, device=dev),
99
+ - use_cache=True, return_dict=True, logits_to_keep=1)
100
+ + with self._blocks([P], [P], P):
101
+ + out = self.model(input_ids=torch.tensor([prefix_ids], device=dev),
102
+ + attention_mask=torch.ones((1, P), dtype=torch.long, device=dev),
103
+ + use_cache=True, return_dict=True, logits_to_keep=1)
104
+ base = out.past_key_values
105
+ del out
106
+ results = []
107
+ - for r, suf in zip(rendered, suffixes):
108
+ + for r, suf, sp in zip(rendered, suffixes, spans):
109
+ cache = copy.deepcopy(base)
110
+ - o = self.model(input_ids=torch.tensor([suf], device=dev),
111
+ - attention_mask=torch.ones((1, len(prefix_ids) + len(suf)), dtype=torch.long, device=dev),
112
+ - past_key_values=cache, use_cache=True, return_dict=True, logits_to_keep=1)
113
+ - logits, mass = self._readout(o.logits[0, -1], r.n_slots)
114
+ + with self._blocks([P], [P + len(suf)], P + len(suf)):
115
+ + o = self.model(input_ids=torch.tensor([suf], device=dev),
116
+ + attention_mask=torch.ones((1, P + len(suf)), dtype=torch.long, device=dev),
117
+ + past_key_values=cache, use_cache=True, return_dict=True, logits_to_keep=1)
118
+ + hidden = self.readout.capture.pop() if self.readout.capture else None
119
+ + logits, mass = self._scores(o, hidden, 0, -1, len(suf) - 1, r.n_slots, sp)
120
+ results.append(BranchResult(r.qid, r.kind, r.labels, r.descriptions, logits, mass, len(suf)))
121
+ del o, cache
122
+ del base
123
+ @@ -266,22 +319,23 @@ class DecisionEngine:
124
+ caller falls back to sequential branches."""
125
+ import torch
126
+
127
+ - prefix_ids, suffixes = self._prefix_and_suffix_ids(state, rendered)
128
+ + prefix_ids, suffixes, spans = self._split(state, rendered)
129
+ dev = self.device
130
+ P = len(prefix_ids)
131
+ chunk = max(1, min(len(rendered), self.fork_token_budget // max(P, 1)))
132
+ results: list[BranchResult | None] = [None] * len(rendered)
133
+ with torch.inference_mode():
134
+ - out = self.model(input_ids=torch.tensor([prefix_ids], device=dev),
135
+ - attention_mask=torch.ones((1, P), dtype=torch.long, device=dev),
136
+ - use_cache=True, return_dict=True, logits_to_keep=1)
137
+ + with self._blocks([P], [P], P):
138
+ + out = self.model(input_ids=torch.tensor([prefix_ids], device=dev),
139
+ + attention_mask=torch.ones((1, P), dtype=torch.long, device=dev),
140
+ + use_cache=True, return_dict=True, logits_to_keep=1)
141
+ base = out.past_key_values
142
+ del out
143
+ start = 0
144
+ while start < len(rendered):
145
+ idx = list(range(start, min(start + chunk, len(rendered))))
146
+ try:
147
+ - self._run_chunk(base, P, idx, rendered, suffixes, results)
148
+ + self._run_chunk(base, P, idx, rendered, suffixes, results, spans)
149
+ except torch.OutOfMemoryError:
150
+ torch.cuda.empty_cache()
151
+ if chunk == 1:
152
+ @@ -294,7 +348,7 @@ class DecisionEngine:
153
+ del base
154
+ return results
155
+
156
+ - def _run_chunk(self, base, P: int, idx: list[int], rendered, suffixes, results) -> None:
157
+ + def _run_chunk(self, base, P: int, idx: list[int], rendered, suffixes, results, spans=None) -> None:
158
+ import torch
159
+
160
+ dev = self.device
161
+ @@ -311,13 +365,16 @@ class DecisionEngine:
162
+ ends.append(len(s) - 1)
163
+ keep = sorted(set(ends))
164
+ cache = self._fork(base, len(idx))
165
+ + spans = spans or [None] * len(rendered)
166
+ try:
167
+ - o = self.model(input_ids=ids, attention_mask=mask, position_ids=pos, past_key_values=cache,
168
+ - use_cache=True, return_dict=True,
169
+ - logits_to_keep=torch.tensor(keep, dtype=torch.long, device=dev))
170
+ + with self._blocks([P] * len(idx), [P + len(s) for s in sufs], P + width):
171
+ + o = self.model(input_ids=ids, attention_mask=mask, position_ids=pos, past_key_values=cache,
172
+ + use_cache=True, return_dict=True,
173
+ + logits_to_keep=torch.tensor(keep, dtype=torch.long, device=dev))
174
+ + hidden = self.readout.capture.pop() if self.readout.capture else None
175
+ for row, i in enumerate(idx):
176
+ r = rendered[i]
177
+ - logits, mass = self._readout(o.logits[row, keep.index(ends[row])], r.n_slots)
178
+ + logits, mass = self._scores(o, hidden, row, keep.index(ends[row]), ends[row], r.n_slots, spans[i])
179
+ results[i] = BranchResult(r.qid, r.kind, r.labels, r.descriptions, logits, mass, len(sufs[row]))
180
+ del o
181
+ finally:
182
+ diff --git a/qev/prompt.py b/qev/prompt.py
183
+ index 8eaadd3..de49cca 100644
184
+ --- a/qev/prompt.py
185
+ +++ b/qev/prompt.py
186
+ @@ -74,13 +74,14 @@ class RenderedQuestion:
187
+ labels: list[str]
188
+ descriptions: list[Any]
189
+ suffix: str
190
+ + option_spans: list[tuple[int, int]] | None = None
191
+ n_slots: int = field(init=False)
192
+
193
+ def __post_init__(self) -> None:
194
+ self.n_slots = len(self.labels)
195
+
196
+
197
+ -def _options_block(names: Sequence[str], descriptions: Sequence[Any], show_names: bool = True) -> str:
198
+ +def _options_lines(names: Sequence[str], descriptions: Sequence[Any], show_names: bool = True) -> list[str]:
199
+ lines = []
200
+ for letter, name, desc in zip(SLOT_LABELS, names, descriptions):
201
+ text = render_entry(desc)
202
+ @@ -90,7 +91,11 @@ def _options_block(names: Sequence[str], descriptions: Sequence[Any], show_names
203
+ lines.append(f"{letter}. {name}: {text}")
204
+ else:
205
+ lines.append(f"{letter}. {name}")
206
+ - return "\n".join(lines)
207
+ + return lines
208
+ +
209
+ +
210
+ +def _options_block(names: Sequence[str], descriptions: Sequence[Any], show_names: bool = True) -> str:
211
+ + return "\n".join(_options_lines(names, descriptions, show_names))
212
+
213
+
214
+ def render_question(qid: str, q: Question, order: Sequence[int] | None = None, style: PromptStyle = DEFAULT_STYLE) -> RenderedQuestion:
215
+ @@ -127,6 +132,7 @@ def render_question(qid: str, q: Question, order: Sequence[int] | None = None, s
216
+ descs = [descs[i] for i in order]
217
+
218
+ letters = list(SLOT_LABELS[: len(labels)])
219
+ + spans = None
220
+ if style.layout == "json":
221
+ payload = {"criterion": instructions,
222
+ "options": [{"letter": l, "description": (f"{n}: {render_entry(d)}" if (style.show_names and n is not None and render_entry(d)) else (render_entry(d) or n))}
223
+ @@ -134,12 +140,17 @@ def render_question(qid: str, q: Question, order: Sequence[int] | None = None, s
224
+ suffix = json.dumps(payload, ensure_ascii=False)[1:]
225
+ else:
226
+ header = style.question_header if kind != "score" else style.score_header
227
+ + head = f"{header} {instructions}\n\nOptions:\n"
228
+ + lines = _options_lines(names, descs, style.show_names)
229
+ + spans, pos = [], len(head)
230
+ + for line in lines:
231
+ + spans.append((pos, pos + len(line)))
232
+ + pos += len(line) + 1
233
+ suffix = (
234
+ - f"{header} {instructions}\n\n"
235
+ - f"Options:\n{_options_block(names, descs, style.show_names)}\n\n"
236
+ + head + "\n".join(lines) + "\n\n"
237
+ + (style.answer_line if len(letters) <= len(LETTERS) else style.answer_line_many).format(letters=", ".join(letters))
238
+ )
239
+ - return RenderedQuestion(qid=qid, kind=kind, labels=labels, descriptions=descs, suffix=suffix)
240
+ + return RenderedQuestion(qid=qid, kind=kind, labels=labels, descriptions=descs, suffix=suffix, option_spans=spans)
241
+
242
+
243
+ def state_block(state: Any, style: PromptStyle = DEFAULT_STYLE) -> str:
244
+ diff --git a/qev/readout.py b/qev/readout.py
245
+ new file mode 100644
246
+ index 0000000..02707ca
247
+ @@ -0,0 +1,269 @@
248
+ +"""Readout variants, shared by training (train.common / train.sft) and serving (qev.engine).
249
+ +
250
+ +attention:
251
+ + causal the base model's mask.
252
+ + noncausal in the full-attention layers every token attends bidirectionally within its block and causally to
253
+ + earlier blocks. A rendered prompt has two blocks: the shared prefix (system turn and state block) and
254
+ + the question suffix (question, options, answer line, generation prompt). The prefix never sees a
255
+ + suffix, so its cache is still shared exactly by all questions of a request, and one forward of a
256
+ + suffix scores all of its options. The GatedDeltaNet (linear-attention) layers are recurrent and stay
257
+ + causal.
258
+ +
259
+ +readout:
260
+ + slot_lm softmax over the LM-head logits of the slot letters at the answer position.
261
+ + head a learned head over the final (normed) hidden states:
262
+ + score_i = <w_i, h_ans> + <W_q h_ans, W_k mean(h over option i's line)> / sqrt(d)
263
+ + w has one row per slot and starts as the LM-head rows of the slot letters, W_k starts at zero, so the
264
+ + untrained head reproduces slot_lm exactly; the pointer term scores each option by its content.
265
+ +
266
+ +Both settings are recorded next to the weights, so a checkpoint is always served the way it was trained. The
267
+ +attention mask is declared per layer type under `modeling_overrides` in `config.json`, where serving stacks
268
+ +read it; `readout.json` holds the rest. Checkpoints without the overrides block carry `attention` in `readout.json`.
269
+ +"""
270
+ +from __future__ import annotations
271
+ +
272
+ +import bisect
273
+ +import contextlib
274
+ +import contextvars
275
+ +import importlib
276
+ +import json
277
+ +import math
278
+ +from dataclasses import asdict, dataclass
279
+ +from pathlib import Path
280
+ +from typing import Sequence
281
+ +
282
+ +import torch
283
+ +
284
+ +from .prompt import MAX_SLOTS
285
+ +
286
+ +CONFIG_FILE = "readout.json"
287
+ +MODEL_CONFIG_FILE = "config.json"
288
+ +OVERRIDES_KEY = "modeling_overrides"
289
+ +FULL_ATTENTION_MASK = {"causal": "causal", "noncausal": "block_bidirectional"}
290
+ +HEAD_FILE = "readout_head.pt"
291
+ +ATTENTION = ("causal", "noncausal")
292
+ +READOUT = ("slot_lm", "head")
293
+ +
294
+ +
295
+ +@dataclass
296
+ +class ReadoutConfig:
297
+ + attention: str = "causal"
298
+ + readout: str = "slot_lm"
299
+ + head_dim: int = 256
300
+ + n_slots: int = MAX_SLOTS
301
+ +
302
+ + def __post_init__(self) -> None:
303
+ + if self.attention not in ATTENTION:
304
+ + raise ValueError(f"attention must be one of {ATTENTION}, got {self.attention!r}")
305
+ + if self.readout not in READOUT:
306
+ + raise ValueError(f"readout must be one of {READOUT}, got {self.readout!r}")
307
+ +
308
+ + @property
309
+ + def noncausal(self) -> bool:
310
+ + return self.attention == "noncausal"
311
+ +
312
+ + @property
313
+ + def head(self) -> bool:
314
+ + return self.readout == "head"
315
+ +
316
+ + @property
317
+ + def needs_layout(self) -> bool:
318
+ + return self.noncausal or self.head
319
+ +
320
+ + @classmethod
321
+ + def load(cls, model_dir: str | Path) -> "ReadoutConfig":
322
+ + path, model_path = Path(model_dir) / CONFIG_FILE, Path(model_dir) / MODEL_CONFIG_FILE
323
+ + fields = json.loads(path.read_text()) if path.exists() else {}
324
+ + overrides = json.loads(model_path.read_text()).get(OVERRIDES_KEY) if model_path.exists() else None
325
+ + if overrides:
326
+ + mask = overrides["attention"]["full_attention"]
327
+ + attention = {v: k for k, v in FULL_ATTENTION_MASK.items()}.get(mask)
328
+ + if attention is None:
329
+ + raise ValueError(f"unsupported full_attention mask {mask!r} in {model_path}")
330
+ + if fields.get("attention", attention) != attention:
331
+ + raise ValueError(f"{path} says attention {fields['attention']!r} but {model_path} says {mask!r}")
332
+ + fields["attention"] = attention
333
+ + return cls(**fields)
334
+ +
335
+ + def save(self, model_dir: str | Path) -> None:
336
+ + fields = asdict(self)
337
+ + model_path = Path(model_dir) / MODEL_CONFIG_FILE
338
+ + if model_path.exists(): # LoRA adapters have no config.json; their readout.json keeps the attention
339
+ + attention = fields.pop("attention")
340
+ + config = json.loads(model_path.read_text())
341
+ + config[OVERRIDES_KEY] = {"attention": {"full_attention": FULL_ATTENTION_MASK[attention],
342
+ + "linear_attention": "causal"}, **fields}
343
+ + model_path.unlink() # may be a symlink to the source checkpoint (model_v5/mkvariant.py)
344
+ + model_path.write_text(json.dumps(config, indent=2) + "\n")
345
+ + (Path(model_dir) / CONFIG_FILE).write_text(json.dumps(fields, indent=2))
346
+ +
347
+ +
348
+ +_BLOCKS: contextvars.ContextVar = contextvars.ContextVar("qev_readout_blocks", default=None)
349
+ +
350
+ +
351
+ +def install_noncausal(model) -> None:
352
+ + """Route block ids (set with `blocks`) into the full-attention mask of the model's modeling module. Outside a
353
+ + `blocks` context the model is unchanged. Needs a mask-based attention implementation (sdpa, eager, flex)."""
354
+ + impl = getattr(model.config.get_text_config(), "_attn_implementation", None) or model.config._attn_implementation
355
+ + if impl not in ("sdpa", "eager", "flex_attention"):
356
+ + raise ValueError(f"noncausal attention needs sdpa, eager or flex_attention, not {impl}")
357
+ + mod = importlib.import_module(type(model).__module__)
358
+ + if getattr(mod, "_qev_noncausal", False):
359
+ + return
360
+ + if not hasattr(mod, "create_causal_mask"):
361
+ + raise ValueError(f"{mod.__name__} builds no causal mask to extend")
362
+ + original = mod.create_causal_mask
363
+ +
364
+ + def create_causal_mask(*args, **kwargs):
365
+ + ids = _BLOCKS.get()
366
+ + if ids is not None and kwargs.get("block_sequence_ids") is None:
367
+ + kwargs["block_sequence_ids"] = ids
368
+ + return original(*args, **kwargs)
369
+ +
370
+ + mod.create_causal_mask = create_causal_mask
371
+ + mod._qev_noncausal = True
372
+ +
373
+ +
374
+ +@contextlib.contextmanager
375
+ +def blocks(ids):
376
+ + """Block ids [B, kv_length] over absolute positions (cached prefix included); None leaves the mask causal."""
377
+ + token = _BLOCKS.set(ids)
378
+ + try:
379
+ + yield
380
+ + finally:
381
+ + _BLOCKS.reset(token)
382
+ +
383
+ +
384
+ +def block_ids(prefix_lens: Sequence[int], lengths: Sequence[int], width: int, device) -> torch.Tensor:
385
+ + """0 on the prefix, 1 on the suffix, -1 on right padding (causal, and masked as a key anyway)."""
386
+ + pos = torch.arange(width, device=device)[None]
387
+ + p = torch.tensor(list(prefix_lens), device=device)[:, None]
388
+ + n = torch.tensor(list(lengths), device=device)[:, None]
389
+ + return torch.where(pos < p, 0, torch.where(pos < n, 1, -1))
390
+ +
391
+ +
392
+ +def token_layout(tokenizer, prompt: str, suffix_start: int, option_spans: Sequence[tuple[int, int]] | None):
393
+ + """Token ids of `prompt`, the prefix length (tokens that end at or before the suffix) and each option line's
394
+ + token span [start, end), from the tokenizer's character offsets. Option spans are characters of the prompt."""
395
+ + enc = tokenizer(prompt, add_special_tokens=False, return_offsets_mapping=True)
396
+ + ids, offs = enc["input_ids"], enc["offset_mapping"]
397
+ + ends = [e for _, e in offs]
398
+ + starts = [s for s, _ in offs]
399
+ + prefix = bisect.bisect_right(ends, suffix_start)
400
+ + spans = []
401
+ + for cs, ce in option_spans or []:
402
+ + a = bisect.bisect_right(ends, cs)
403
+ + b = bisect.bisect_left(starts, ce)
404
+ + if b <= a:
405
+ + raise ValueError(f"option span {cs}:{ce} covers no token")
406
+ + spans.append((a, b))
407
+ + return ids, prefix, spans
408
+ +
409
+ +
410
+ +class ReadoutHead(torch.nn.Module):
411
+ + def __init__(self, hidden: int, n_slots: int = MAX_SLOTS, head_dim: int = 256):
412
+ + super().__init__()
413
+ + self.slot = torch.nn.Parameter(torch.zeros(n_slots, hidden))
414
+ + self.q = torch.nn.Linear(hidden, head_dim, bias=False)
415
+ + self.k = torch.nn.Linear(hidden, head_dim, bias=False)
416
+ + torch.nn.init.normal_(self.q.weight, std=hidden ** -0.5)
417
+ + torch.nn.init.zeros_(self.k.weight)
418
+ +
419
+ + @classmethod
420
+ + def from_lm_head(cls, lm_head_weight, slot_ids: Sequence[int], head_dim: int) -> "ReadoutHead":
421
+ + rows = lm_head_weight.detach()[list(slot_ids)].float()
422
+ + head = cls(rows.shape[1], rows.shape[0], head_dim)
423
+ + with torch.no_grad():
424
+ + head.slot.copy_(rows.to(head.slot.device))
425
+ + return head
426
+ +
427
+ + def forward(self, h_ans, h_opt):
428
+ + """h_ans [B, H], h_opt [B, K, H] -> scores [B, K] (fp32)."""
429
+ + h_ans, h_opt = h_ans.float(), h_opt.float()
430
+ + k = h_opt.shape[1]
431
+ + slot = h_ans @ self.slot[:k].T
432
+ + ptr = torch.einsum("bd,bkd->bk", self.q(h_ans), self.k(h_opt)) / math.sqrt(self.q.out_features)
433
+ + return slot + ptr
434
+ +
435
+ +
436
+ +def pool_options(hidden, spans: Sequence[Sequence[tuple[int, int]]], kmax: int):
437
+ + """Mean of hidden [B, T, H] over each option's token span -> [B, kmax, H] fp32 (zeros past a row's options)."""
438
+ + bsz, _, dim = hidden.shape
439
+ + dev = hidden.device
440
+ + rows, toks, segs = [], [], []
441
+ + for b, sp in enumerate(spans):
442
+ + for k, (s, e) in enumerate(sp):
443
+ + n = e - s
444
+ + rows.append(torch.full((n,), b, dtype=torch.long))
445
+ + toks.append(torch.arange(s, e, dtype=torch.long))
446
+ + segs.append(torch.full((n,), b * kmax + k, dtype=torch.long))
447
+ + rows, toks, segs = (torch.cat(x).to(dev) for x in (rows, toks, segs))
448
+ + flat = hidden[rows, toks].float()
449
+ + total = torch.zeros(bsz * kmax, dim, dtype=torch.float32, device=dev).index_add(0, segs, flat)
450
+ + count = torch.zeros(bsz * kmax, dtype=torch.float32, device=dev).index_add(
451
+ + 0, segs, torch.ones_like(segs, dtype=torch.float32))
452
+ + return (total / count.clamp(min=1)[:, None]).view(bsz, kmax, dim)
453
+ +
454
+ +
455
+ +def final_norm(model):
456
+ + """The final norm of a (possibly multimodal) Qwen-style decoder: its output is the readout hidden state."""
457
+ + inner = getattr(model, "model", model)
458
+ + inner = getattr(inner, "language_model", inner)
459
+ + norm = getattr(inner, "norm", None)
460
+ + if norm is None:
461
+ + raise ValueError(f"cannot find the final norm of {type(model).__name__}")
462
+ + return norm
463
+ +
464
+ +
465
+ +class HiddenCapture:
466
+ + """Keeps the final-norm output of the latest forward (with its graph, so the head trains the backbone)."""
467
+ +
468
+ + def __init__(self, model):
469
+ + self.value = None
470
+ + self.handle = final_norm(model).register_forward_hook(self._hook)
471
+ +
472
+ + def _hook(self, module, args, output):
473
+ + self.value = output
474
+ +
475
+ + def pop(self):
476
+ + out, self.value = self.value, None
477
+ + if out is None:
478
+ + raise RuntimeError("no hidden state captured; the forward did not reach the final norm")
479
+ + return out
480
+ +
481
+ +
482
+ +@dataclass
483
+ +class Readout:
484
+ + """A model's readout at runtime: its config, and for `head` the head module and the hidden-state hook."""
485
+ +
486
+ + config: ReadoutConfig
487
+ + head: ReadoutHead | None = None
488
+ + capture: HiddenCapture | None = None
489
+ +
490
+ + @classmethod
491
+ + def attach(cls, model, config: ReadoutConfig, slot_ids: Sequence[int] | None = None,
492
+ + head_path: str | Path | None = None, device=None) -> "Readout":
493
+ + """Install the variant on `model`. A head is loaded from `head_path` when given, else initialised from the
494
+ + model's LM-head rows of `slot_ids` (call before the model is sharded)."""
495
+ + if config.noncausal:
496
+ + install_noncausal(model)
497
+ + if not config.head:
498
+ + return cls(config)
499
+ + if head_path is not None:
500
+ + state = torch.load(head_path, map_location="cpu", weights_only=True)
501
+ + head = ReadoutHead(state["slot"].shape[1], state["slot"].shape[0], state["q.weight"].shape[0])
502
+ + head.load_state_dict(state)
503
+ + else:
504
+ + head = ReadoutHead.from_lm_head(model.get_output_embeddings().weight, slot_ids[: config.n_slots],
505
+ + config.head_dim)
506
+ + head = head.to(device or next(model.parameters()).device)
507
+ + return cls(config, head, HiddenCapture(model))
508
+ +
509
+ + def save(self, out_dir: str | Path) -> None:
510
+ + self.config.save(out_dir)
511
+ + if self.head is not None:
512
+ + torch.save({k: v.detach().cpu() for k, v in self.head.state_dict().items()}, Path(out_dir) / HEAD_FILE)
513
+ +
514
+ + @property
515
+ + def max_slots(self) -> int:
516
+ + return self.config.n_slots
517
+ diff --git a/train/common.py b/train/common.py
518
+ index 331206f..896e6d3 100644
519
+ --- a/train/common.py
520
+ +++ b/train/common.py
521
+ @@ -85,6 +85,8 @@ class Example:
522
+ target: list[float]
523
+ source: str
524
+ task: str
525
+ + suffix_start: int = -1
526
+ + option_spans: list[tuple[int, int]] | None = None
527
+
528
+
529
+ PAD_FAMILIES = ("next_action",)
530
+ @@ -185,6 +187,8 @@ def render_example(row: dict, tmpl, idx: int, seed: int | None = None, augment:
531
+ compact = augment and rng.random() < 0.5
532
+ state = serialize_state(row.get("state"), compact)
533
+ prompt = tmpl(state, rendered.suffix)
534
+ + suffix_start = prompt.rindex(rendered.suffix)
535
+ + spans = [(suffix_start + a, suffix_start + b) for a, b in rendered.option_spans or []]
536
+ return Example(
537
+ idx=idx,
538
+ prompt=prompt,
539
+ @@ -193,6 +197,8 @@ def render_example(row: dict, tmpl, idx: int, seed: int | None = None, augment:
540
+ target=target_vector(row["target"], rendered.labels),
541
+ source=str(row.get("source", "unknown")),
542
+ task=str(row.get("task", "unknown")),
543
+ + suffix_start=suffix_start,
544
+ + option_spans=spans or None,
545
+ )
546
+
547
+
548
+ @@ -244,6 +250,7 @@ class Bundle:
549
+ template_kwargs: dict
550
+ processor: Any = None
551
+ counter: Any = None
552
+ + readout: Any = None
553
+
554
+ @property
555
+ def multimodal(self) -> bool:
556
+ @@ -403,6 +410,65 @@ def readout_logits(model, input_ids, attention_mask, ends):
557
+ return out.logits[rows, pos]
558
+
559
+
560
+ +def batch_scores(bundle: Bundle, model, examples: Sequence[Example], rows: Sequence[dict], device):
561
+ + """Option scores [B, Kmax] (-inf past each row's options) and their mask for a right-padded batch, under the
562
+ + bundle's readout (qev.readout): causal or noncausal attention, slot_lm or head scoring. The same function scores
563
+ + training steps, in-training evals and train.eval_mm, and qev.engine reproduces it for serving."""
564
+ + import torch
565
+ +
566
+ + from qev import readout as ro
567
+ +
568
+ + rd = bundle.readout
569
+ + cfg = rd.config if rd is not None else ro.ReadoutConfig()
570
+ + ks = [len(ex.labels) for ex in examples]
571
+ + if bundle.multimodal:
572
+ + from train import mm
573
+ +
574
+ + inputs, ends = mm.mm_inputs(bundle.processor, [ex.prompt for ex in examples],
575
+ + [r.get("media") or [] for r in rows], device)
576
+ + shifts = [bundle.counter.media_extra(r.get("media") or []) for r in rows]
577
+ + else:
578
+ + input_ids, attention_mask, ends = pad_batch([bundle.encode(ex.prompt) for ex in examples], bundle.tokenizer.pad_token_id, device)
579
+ + inputs = {"input_ids": input_ids, "attention_mask": attention_mask}
580
+ + shifts = [0] * len(examples)
581
+ + ctx = ro.blocks(None)
582
+ + spans = None
583
+ + if cfg.needs_layout:
584
+ + prefixes, spans = [], []
585
+ + for ex, shift, end in zip(examples, shifts, ends):
586
+ + ids, prefix, sp = ro.token_layout(bundle.tokenizer, ex.prompt, ex.suffix_start, ex.option_spans)
587
+ + if len(ids) + shift != end + 1:
588
+ + raise ValueError(f"layout of row {ex.idx} has {len(ids) + shift} tokens, the batch has {end + 1}")
589
+ + if cfg.head and len(sp) != len(ex.labels):
590
+ + raise ValueError(f"row {ex.idx}: {len(sp)} option spans for {len(ex.labels)} options")
591
+ + prefixes.append(prefix + shift)
592
+ + spans.append([(a + shift, b + shift) for a, b in sp])
593
+ + if cfg.noncausal:
594
+ + width = inputs["input_ids"].shape[1]
595
+ + ctx = ro.blocks(ro.block_ids(prefixes, [e + 1 for e in ends], width, device))
596
+ + with ctx:
597
+ + if cfg.head:
598
+ + out = model(**inputs, use_cache=False, return_dict=True, logits_to_keep=1)
599
+ + hidden = rd.capture.pop()
600
+ + kmax = max(ks)
601
+ + rows_ix = torch.arange(len(examples), device=hidden.device)
602
+ + h_ans = hidden[rows_ix, torch.tensor(ends, device=hidden.device)]
603
+ + scores = rd.head(h_ans, ro.pool_options(hidden, spans, kmax))
604
+ + # every FSDP unit (the LM head too) must take part in the backward
605
+ + scores = scores + 0.0 * out.logits.float().sum()
606
+ + mask = torch.zeros((len(ks), kmax), dtype=torch.bool, device=scores.device)
607
+ + for i, k in enumerate(ks):
608
+ + mask[i, :k] = True
609
+ + return scores.masked_fill(~mask, float("-inf")), mask
610
+ + if bundle.multimodal:
611
+ + from train import mm
612
+ +
613
+ + vocab_logits = mm.mm_readout_logits(model, inputs, ends)
614
+ + else:
615
+ + vocab_logits = readout_logits(model, inputs["input_ids"], inputs["attention_mask"], ends)
616
+ + return slot_view(vocab_logits, bundle.slot_ids, ks)
617
+ +
618
+ +
619
+ def slot_view(vocab_logits, slot_ids, ks):
620
+ """Gather the first K slot logits per row into a padded [B, Kmax] tensor plus a mask."""
621
+ import torch
622
+ @@ -557,11 +623,7 @@ def collect_slot_logits(bundle: Bundle, rows: Sequence[dict], max_len: int = 409
623
+ readout_logits(bundle.model, ids, torch.ones_like(ids), [0])
624
+ for bi, batch in enumerate(batches):
625
+ exs = [examples[i] for i in batch]
626
+ - id_lists = [bundle.encode(ex.prompt) for ex in exs]
627
+ - input_ids, attention_mask, ends = pad_batch(id_lists, pad_id, dev)
628
+ - ks = [len(ex.labels) for ex in exs]
629
+ - vocab_logits = readout_logits(bundle.model, input_ids, attention_mask, ends)
630
+ - sel, _ = slot_view(vocab_logits, bundle.slot_ids, ks)
631
+ + sel, _ = batch_scores(bundle, bundle.model, exs, [rows[ex.idx] for ex in exs], dev)
632
+ sel = sel.float().cpu().tolist()
633
+ for r, ex in enumerate(exs):
634
+ out.append(Scored(kind=ex.kind, k=len(ex.labels), logits=sel[r][: len(ex.labels)],
635
+ diff --git a/train/eval_mm.py b/train/eval_mm.py
636
+ index e87e72d..03a6022 100644
637
+ --- a/train/eval_mm.py
638
+ +++ b/train/eval_mm.py
639
+ @@ -48,6 +48,13 @@ def main() -> int:
640
+ mm.set_root(args.media_root)
641
+ bundle = common.load_bundle(args.model, device=args.device, dtype="bfloat16", multimodal=args.multimodal,
642
+ chat_template=args.chat_template)
643
+ + from qev.readout import HEAD_FILE, Readout, ReadoutConfig
644
+ +
645
+ + rcfg = ReadoutConfig.load(args.model)
646
+ + head_path = Path(args.model) / HEAD_FILE
647
+ + bundle.readout = Readout.attach(bundle.model, rcfg, slot_ids=bundle.slot_ids,
648
+ + head_path=head_path if rcfg.head else None)
649
+ + print(f"readout: attention {rcfg.attention}, scores {rcfg.readout}", flush=True)
650
+ results = {}
651
+ for spec in args.evals:
652
+ name, _, path = spec.partition("=")
653
+ diff --git a/train/sft.py b/train/sft.py
654
+ index 4f6f7bd..ae428d9 100644
655
+ --- a/train/sft.py
656
+ +++ b/train/sft.py
657
+ @@ -108,6 +108,14 @@ def build_parser() -> argparse.ArgumentParser:
658
+ "train one step on each listed micro-batch (one GPU), peak memory reset per step, no final eval")
659
+ p.add_argument("--dump-first-grad", default=None,
660
+ help="test hook: after the first step's all-reduce, rank 0 saves the adapter gradients here")
661
+ + p.add_argument("--attention", choices=["causal", "noncausal"], default="causal",
662
+ + help="noncausal: bidirectional within the prefix and within the question suffix in the full-attention "
663
+ + "layers (qev.readout)")
664
+ + p.add_argument("--readout", choices=["slot_lm", "head"], default="slot_lm",
665
+ + help="head: learned slot + pointer head over the final hidden states instead of LM-head letter logits")
666
+ + p.add_argument("--head-dim", type=int, default=256, help="pointer projection width of the readout head")
667
+ + p.add_argument("--head-lr", type=float, default=1e-4, help="peak learning rate of the pointer projections "
668
+ + "(the head's slot rows use --lr)")
669
+ p.add_argument("--disable-fla-kernels", action="store_true",
670
+ help="force the reference PyTorch gated delta rule instead of the fla kernel "
671
+ "(needed on H100/H200 with Triton in [3.4.0, 3.7.1); correct but much slower)")
672
+ @@ -326,10 +334,7 @@ def collect_mm(bundle, rows, max_len: int, batch_tokens: int, max_rows: int, dev
673
+ common.readout_logits(bundle.model, ids, torch.ones_like(ids), [0])
674
+ for b in batches:
675
+ exs = [examples[usable[j]] for j in b]
676
+ - inputs, ends = mm.mm_inputs(bundle.processor, [ex.prompt for ex in exs],
677
+ - [rows[ex.idx].get("media") or [] for ex in exs], device)
678
+ - vocab_logits = mm.mm_readout_logits(bundle.model, inputs, ends)
679
+ - sel, _ = common.slot_view(vocab_logits, bundle.slot_ids, [len(ex.labels) for ex in exs])
680
+ + sel, _ = common.batch_scores(bundle, bundle.model, exs, [rows[ex.idx] for ex in exs], device)
681
+ sel = sel.float().cpu().tolist()
682
+ for r, ex in enumerate(exs):
683
+ out.append(common.Scored(kind=ex.kind, k=len(ex.labels), logits=sel[r][: len(ex.labels)],
684
+ @@ -339,6 +344,13 @@ def collect_mm(bundle, rows, max_len: int, batch_tokens: int, max_rows: int, dev
685
+
686
+
687
+ def save_weights(model, bundle, out_dir: Path, full: bool, sharded: bool = False, is_main: bool = True) -> None:
688
+ + _save_weights(model, bundle, out_dir, full, sharded, is_main)
689
+ + if is_main and bundle.readout is not None:
690
+ + out_dir.mkdir(parents=True, exist_ok=True)
691
+ + bundle.readout.save(out_dir)
692
+ +
693
+ +
694
+ +def _save_weights(model, bundle, out_dir: Path, full: bool, sharded: bool = False, is_main: bool = True) -> None:
695
+ """LoRA: the adapter. Full fine-tuning: the whole model in bf16 (from the fp32 master weights, copied to CPU),
696
+ with tokenizer and processor, loadable like the base checkpoint. Sharded (--fsdp): every rank must call it,
697
+ the weights are gathered to rank 0's CPU and written there as HF safetensors shards."""
698
+ @@ -560,6 +572,8 @@ def main(argv=None) -> int:
699
+ raise SystemExit("--fsdp is implemented without --share-prefix")
700
+ if args.disable_fla_kernels:
701
+ common.block_fla_kernels()
702
+ + if (args.attention != "causal" or args.readout != "slot_lm") and args.share_prefix:
703
+ + raise SystemExit("--share-prefix supports only causal slot_lm")
704
+
705
+ import torch
706
+ from peft import LoraConfig, get_peft_model
707
+ @@ -600,6 +614,16 @@ def main(argv=None) -> int:
708
+ if args.plan_only:
709
+ return plan_only(bundle, rows, args, world)
710
+ log.info("model_type=%s template_kwargs=%s", bundle.model_type, bundle.template_kwargs)
711
+ + from qev.readout import Readout, ReadoutConfig
712
+ +
713
+ + bundle.readout = Readout.attach(bundle.model, ReadoutConfig(attention=args.attention, readout=args.readout,
714
+ + head_dim=args.head_dim),
715
+ + slot_ids=bundle.slot_ids, device=torch.device(args.device))
716
+ + head = bundle.readout.head
717
+ + if head is not None and world > 1:
718
+ + for prm in head.parameters():
719
+ + dist.broadcast(prm.data, 0)
720
+ + log.info("readout: attention %s, scores %s", args.attention, args.readout)
721
+
722
+ if args.full_finetune:
723
+ model = bundle.model
724
+ @@ -696,6 +720,13 @@ def main(argv=None) -> int:
725
+ opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=args.weight_decay, betas=(0.9, 0.95), eps=1e-8,
726
+ fused=bool(args.full_finetune))
727
+ sched = get_cosine_schedule_with_warmup(opt, num_warmup_steps=warmup, num_training_steps=max(total_steps, 1))
728
+ + head_opt = head_sched = None
729
+ + if head is not None:
730
+ + head_opt = torch.optim.AdamW([{"params": [head.slot], "lr": args.lr},
731
+ + {"params": [head.q.weight, head.k.weight], "lr": args.head_lr}],
732
+ + weight_decay=0.0, betas=(0.9, 0.95), eps=1e-8)
733
+ + head_sched = get_cosine_schedule_with_warmup(head_opt, num_warmup_steps=warmup,
734
+ + num_training_steps=max(total_steps, 1))
735
+
736
+ logf = open(log_path, "a", encoding="utf-8") if is_main else open(os.devnull, "w")
737
+ step = 0
738
+ @@ -729,25 +760,16 @@ def main(argv=None) -> int:
739
+ examples.append(common.render_example(rows[i], bundle.template_for(rows[i]), i, seed=seed,
740
+ augment=args.augment, option_pool=OPTION_POOL,
741
+ pad_prob=args.pad_options))
742
+ - if bundle.multimodal:
743
+ - from train import mm
744
+ -
745
+ - inputs, ends = mm.mm_inputs(bundle.processor, [ex.prompt for ex in examples],
746
+ - [rows[i].get("media") or [] for i in idx], device)
747
+ - with torch.autocast("cuda", dtype=torch.bfloat16, enabled=use_autocast(args)):
748
+ - vocab_logits = mm.mm_readout_logits(model, inputs, ends)
749
+ - else:
750
+ - id_lists = [bundle.encode(ex.prompt) for ex in examples]
751
+ - input_ids, attention_mask, ends = common.pad_batch(id_lists, pad_id, device)
752
+ - with torch.autocast("cuda", dtype=torch.bfloat16, enabled=use_autocast(args)):
753
+ - vocab_logits = common.readout_logits(model, input_ids, attention_mask, ends)
754
+ + with torch.autocast("cuda", dtype=torch.bfloat16, enabled=use_autocast(args)):
755
+ + sel, mask = common.batch_scores(bundle, model, examples, [rows[i] for i in idx], device)
756
+ ks = [len(ex.labels) for ex in examples]
757
+ targets = [ex.target for ex in examples]
758
+ kmax = max(ks)
759
+ tgt = torch.zeros((len(ks), kmax), dtype=torch.float32, device=device)
760
+ for r, t in enumerate(targets):
761
+ tgt[r, : len(t)] = torch.tensor(t, dtype=torch.float32, device=device)
762
+ - sel, mask = common.slot_view(vocab_logits, bundle.slot_ids, ks)
763
+ + if kind == "group":
764
+ + sel, mask = common.slot_view(vocab_logits, bundle.slot_ids, ks)
765
+ return sel, mask, tgt
766
+
767
+ if eval_sets and args.eval_at_start:
768
+ @@ -802,6 +824,17 @@ def main(argv=None) -> int:
769
+ gnorm = torch.nn.utils.clip_grad_norm_(params, args.max_grad_norm)
770
+ if hasattr(gnorm, "full_tensor"):
771
+ gnorm = gnorm.full_tensor()
772
+ + if head is not None:
773
+ + for prm in head.parameters():
774
+ + if prm.grad is None:
775
+ + prm.grad = torch.zeros_like(prm)
776
+ + if world > 1:
777
+ + dist.all_reduce(prm.grad)
778
+ + prm.grad /= grad_scale
779
+ + torch.nn.utils.clip_grad_norm_(list(head.parameters()), args.max_grad_norm)
780
+ + head_opt.step()
781
+ + head_sched.step()
782
+ + head_opt.zero_grad(set_to_none=True)
783
+ try:
784
+ opt.step()
785
+ except torch.OutOfMemoryError:
setup.sh ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Usage: bash setup.sh [WORKDIR=.] (image: pytorch/pytorch:2.8.0-cuda12.8-cudnn9-devel, one CUDA GPU)
2
+ # Installs the serving runtime (OneJev @81ce62f + serve.patch) and the Decision Index kit, pinned as in our runs.
3
+ set -e
4
+ B=$(cd "$(dirname "$0")" && pwd); W=${1:-.}
5
+ ONEJEV=81ce62f1597c91e46767d4d02d6ac2e18534fe94; KIT=f5a6e4fd85fed05e77878dfc4966d0568bfb45ec
6
+ command -v git >/dev/null || { apt-get update -qq; apt-get install -y -qq git >/dev/null; }
7
+ cd "$W"
8
+ [ -d OneJev ] || git clone -q https://github.com/OmniJev/OneJev.git
9
+ git -C OneJev checkout -q -f $ONEJEV && git -C OneJev clean -fdq && git -C OneJev apply "$B/serve.patch"
10
+ [ -d decision-index ] || git clone -q https://github.com/apolinario/decision-index.git
11
+ git -C decision-index checkout -q -f $KIT
12
+ # Keep the image's CUDA build of torch: unpinned, pip resolves torch>=2.6 to the newest wheel (another CUDA).
13
+ python -c "import torch, torchvision; print(f'torch=={torch.__version__}\ntorchvision=={torchvision.__version__}')" > image-torch.txt
14
+ pip install -q -c "$B/constraints.txt" -c image-torch.txt -e "./OneJev[train]" huggingface_hub hf_xet brotli flash-linear-attention httpx
15
+ pip install -q -c "$B/constraints.txt" -c image-torch.txt --no-build-isolation causal-conv1d
16
+ pip install -q --no-deps -e ./decision-index
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5cd9732badce41de57e6efce8302930ded1c1188c5f81feb2bd6c24c4a1941f
3
+ size 19989339
tokenizer_config.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "<|im_end|>",
10
+ "errors": "replace",
11
+ "extra_special_tokens": [
12
+ "<|im_start|>",
13
+ "<|im_end|>",
14
+ "<|object_ref_start|>",
15
+ "<|object_ref_end|>",
16
+ "<|box_start|>",
17
+ "<|box_end|>",
18
+ "<|quad_start|>",
19
+ "<|quad_end|>",
20
+ "<|vision_start|>",
21
+ "<|vision_end|>",
22
+ "<|vision_pad|>",
23
+ "<|image_pad|>",
24
+ "<|video_pad|>"
25
+ ],
26
+ "image_token": "<|image_pad|>",
27
+ "is_local": true,
28
+ "local_files_only": true,
29
+ "merges_file": null,
30
+ "model_max_length": 262144,
31
+ "model_specific_special_tokens": {
32
+ "audio_bos_token": "<|audio_start|>",
33
+ "audio_eos_token": "<|audio_end|>",
34
+ "audio_token": "<|audio_pad|>",
35
+ "image_token": "<|image_pad|>",
36
+ "video_token": "<|video_pad|>",
37
+ "vision_bos_token": "<|vision_start|>",
38
+ "vision_eos_token": "<|vision_end|>"
39
+ },
40
+ "pad_token": "<|endoftext|>",
41
+ "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+",
42
+ "processor_class": "Qwen3VLProcessor",
43
+ "split_special_tokens": false,
44
+ "tokenizer_class": "Qwen3_5Tokenizer",
45
+ "unk_token": null,
46
+ "video_token": "<|video_pad|>",
47
+ "vision_bos_token": "<|vision_start|>",
48
+ "vision_eos_token": "<|vision_end|>"
49
+ }