yacht commited on
Commit
e649e50
·
verified ·
1 Parent(s): ec59389

OpenThai-SystemOne v0.3 (f3709948) — bnb-8bit

Browse files
Files changed (12) hide show
  1. .gitattributes +1 -0
  2. README.md +82 -0
  3. chat_template.jinja +154 -0
  4. config.json +131 -0
  5. configuration.py +64 -0
  6. formatting.py +543 -0
  7. images.py +234 -0
  8. model.safetensors +3 -0
  9. modeling.py +509 -0
  10. tokenizer.json +3 -0
  11. tokenizer_config.json +32 -0
  12. types.py +205 -0
.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,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language: [th, en]
4
+ base_model: iapp/OpenThai-SystemOne
5
+ base_model_relation: quantized
6
+ library_name: transformers
7
+ pipeline_tag: text-classification
8
+ tags: [system-one, decision-model, thai, qwen3.5, quantized, bnb]
9
+ ---
10
+
11
+ # OpenThai-SystemOne — bnb-8bit
12
+
13
+ [OpenThai-SystemOne](https://huggingface.co/iapp/OpenThai-SystemOne) is an open Thai + English **System One decision model**: one forward
14
+ pass answers typed questions (`choice` over up to 255 options, ordinal `score`, yes/no `noul`) about a text / JSON state with
15
+ calibrated probabilities, no text generation. It is a Qwen3.5-0.8B text tower (Thai continued pre-training) plus a 256-slot
16
+ decision head. This repo is a quantization of **v0.3** (commit `f3709948`).
17
+
18
+ **What is quantized:** the Linear layers of the tower. The token embeddings, the 256-slot decision head and the per-type temperatures stay in bf16. Quantization therefore only perturbs the hidden state the head reads.
19
+
20
+ **Format:** bitsandbytes LLM.int8 8-bit. bitsandbytes checkpoint, stays quantized in GPU memory (CUDA).
21
+
22
+ Size: 1009 MB (bf16 original: 1,509 MB).
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ pip install torch transformers safetensors pydantic && pip install bitsandbytes
28
+ ```
29
+
30
+ ```python
31
+ from transformers import AutoModel, AutoTokenizer # trust_remote_code files are in this repo
32
+ model = AutoModel.from_pretrained("iapp/OpenThai-SystemOne-bnb-8bit", trust_remote_code=True)
33
+ # or with the pip package (git+https://github.com/iapp-technology/openthai-systemone):
34
+ from openthai_systemone import SystemOneClient
35
+ c = SystemOneClient("iapp/OpenThai-SystemOne-bnb-8bit")
36
+ r = c.system_one("ร้านนี้อาหารอร่อยมาก แต่รอนานเกือบชั่วโมง", {"sentiment": {"type": "choice", "instructions": "ความรู้สึก",
37
+ "criteria": {"บวก": None, "ลบ": None, "กลาง": None}}})
38
+ ```
39
+
40
+ ## Accuracy vs the bf16 original (same records, single option order, first 800 per set)
41
+
42
+ Macro: public 73.9 (original 74.3), Thai 80.0 (original 80.1).
43
+
44
+ | subset | bf16 original | this | Δ |
45
+ |---|---|---|---|
46
+ | **public 13-subset bench** | | | |
47
+ | aegis2 (noul) | 83.2 | 83.2 | +0.0 |
48
+ | boolq (noul) | 79.7 | 79.3 | -0.3 |
49
+ | civil_comments (noul) | 79.0 | 78.0 | -1.0 |
50
+ | helpsteer2 (score) | 41.6 | 41.6 | +0.0 |
51
+ | massive-de-DE (choice) | 88.3 | 87.4 | -0.9 |
52
+ | massive-en-US (choice) | 88.3 | 88.0 | -0.3 |
53
+ | multinli (choice) | 89.0 | 89.6 | +0.7 |
54
+ | paws (noul) | 94.0 | 93.6 | -0.4 |
55
+ | pubmedqa (choice) | 64.0 | 64.0 | +0.0 |
56
+ | squad2 (noul) | 89.3 | 88.0 | -1.3 |
57
+ | summeval-consistency (score) | 75.0 | 75.7 | +0.7 |
58
+ | summeval-relevance (score) | 21.7 | 20.8 | -0.8 |
59
+ | vitaminc-dev (choice) | 72.5 | 71.3 | -1.2 |
60
+ | *macro, public 13-subset bench* | *74.3* | *73.9* | *-0.4* |
61
+ | **Thai held-out / eval sets** | | | |
62
+ | banking77 (choice) | 59.1 | 59.5 | +0.4 |
63
+ | contrastive_th (choice) | 80.7 | 81.4 | +0.7 |
64
+ | contrastive_th (noul) | 83.5 | 83.9 | +0.4 |
65
+ | contrastive_th (score) | 78.6 | 75.0 | -3.6 |
66
+ | massive_th (choice) | 90.6 | 90.5 | -0.1 |
67
+ | prachathai (choice) | 98.3 | 98.5 | +0.2 |
68
+ | prachathai (noul) | 93.4 | 93.2 | -0.2 |
69
+ | sib200_th (choice) | 77.9 | 78.4 | +0.5 |
70
+ | wisesight (choice) | 48.9 | 49.4 | +0.5 |
71
+ | wongnai (score) | 64.5 | 64.5 | +0.0 |
72
+ | xlam_tools (choice) | 99.4 | 99.4 | +0.0 |
73
+ | xnli_th (choice) | 79.8 | 79.1 | -0.6 |
74
+ | xnli_th (noul) | 86.8 | 86.5 | -0.2 |
75
+ | *macro, Thai held-out / eval sets* | *80.1* | *80.0* | *-0.2* |
76
+
77
+ ## Notes
78
+
79
+ - Scores are single-option-order accuracy on the first 800 records of each set (`scripts/06_eval.py --limit 800`), the same
80
+ records for the original and the quantization. `score` subsets report exact level accuracy.
81
+ - Base model, data, training and the full benchmark tables: [iapp/OpenThai-SystemOne](https://huggingface.co/iapp/OpenThai-SystemOne).
82
+ - License Apache-2.0 (same as the base). Built by iApp Technology / OpenThaiGPT.
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,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "abstain_slot": 255,
3
+ "answer_token_id": 248082,
4
+ "architectures": [
5
+ "OpenThaiSystemOneForDecision"
6
+ ],
7
+ "auto_map": {
8
+ "AutoConfig": "configuration.OpenThaiSystemOneConfig",
9
+ "AutoModel": "modeling.OpenThaiSystemOneForDecision"
10
+ },
11
+ "dtype": "bfloat16",
12
+ "head_bias": true,
13
+ "image_token_id": null,
14
+ "model_type": "openthai_systemone",
15
+ "n_slots": 256,
16
+ "n_temperatures": 3,
17
+ "pad_token_id": 248044,
18
+ "point_head": null,
19
+ "point_token_id": null,
20
+ "quantization_config": {
21
+ "_load_in_4bit": false,
22
+ "_load_in_8bit": true,
23
+ "bnb_4bit_compute_dtype": "float32",
24
+ "bnb_4bit_quant_storage": "uint8",
25
+ "bnb_4bit_quant_type": "fp4",
26
+ "bnb_4bit_use_double_quant": false,
27
+ "llm_int8_enable_fp32_cpu_offload": false,
28
+ "llm_int8_has_fp16_weight": false,
29
+ "llm_int8_skip_modules": [
30
+ "slot_head",
31
+ "embed_tokens"
32
+ ],
33
+ "llm_int8_threshold": 6.0,
34
+ "load_in_4bit": false,
35
+ "load_in_8bit": true,
36
+ "quant_method": "bitsandbytes"
37
+ },
38
+ "text_config": {
39
+ "_name_or_path": "runs/cpt/latest",
40
+ "architectures": [
41
+ "Qwen3_5ForCausalLM"
42
+ ],
43
+ "attention_bias": false,
44
+ "attention_dropout": 0.0,
45
+ "attn_output_gate": true,
46
+ "bos_token_id": null,
47
+ "chunk_size_feed_forward": 0,
48
+ "dtype": "bfloat16",
49
+ "eos_token_id": 248044,
50
+ "full_attention_interval": 4,
51
+ "head_dim": 256,
52
+ "hidden_act": "silu",
53
+ "hidden_size": 1024,
54
+ "id2label": {
55
+ "0": "LABEL_0",
56
+ "1": "LABEL_1"
57
+ },
58
+ "initializer_range": 0.02,
59
+ "intermediate_size": 3584,
60
+ "is_encoder_decoder": false,
61
+ "label2id": {
62
+ "LABEL_0": 0,
63
+ "LABEL_1": 1
64
+ },
65
+ "layer_types": [
66
+ "linear_attention",
67
+ "linear_attention",
68
+ "linear_attention",
69
+ "full_attention",
70
+ "linear_attention",
71
+ "linear_attention",
72
+ "linear_attention",
73
+ "full_attention",
74
+ "linear_attention",
75
+ "linear_attention",
76
+ "linear_attention",
77
+ "full_attention",
78
+ "linear_attention",
79
+ "linear_attention",
80
+ "linear_attention",
81
+ "full_attention",
82
+ "linear_attention",
83
+ "linear_attention",
84
+ "linear_attention",
85
+ "full_attention",
86
+ "linear_attention",
87
+ "linear_attention",
88
+ "linear_attention",
89
+ "full_attention"
90
+ ],
91
+ "linear_conv_kernel_dim": 4,
92
+ "linear_key_head_dim": 128,
93
+ "linear_num_key_heads": 16,
94
+ "linear_num_value_heads": 16,
95
+ "linear_value_head_dim": 128,
96
+ "mamba_ssm_dtype": "float32",
97
+ "max_position_embeddings": 262144,
98
+ "mlp_only_layers": [],
99
+ "model_type": "qwen3_5_text",
100
+ "mtp_num_hidden_layers": 1,
101
+ "mtp_use_dedicated_embeddings": false,
102
+ "num_attention_heads": 8,
103
+ "num_hidden_layers": 24,
104
+ "num_key_value_heads": 2,
105
+ "output_attentions": false,
106
+ "output_hidden_states": false,
107
+ "pad_token_id": null,
108
+ "partial_rotary_factor": 0.25,
109
+ "problem_type": null,
110
+ "return_dict": true,
111
+ "rms_norm_eps": 1e-06,
112
+ "rope_parameters": {
113
+ "mrope_interleaved": true,
114
+ "mrope_section": [
115
+ 11,
116
+ 11,
117
+ 10
118
+ ],
119
+ "partial_rotary_factor": 0.25,
120
+ "rope_theta": 10000000,
121
+ "rope_type": "default"
122
+ },
123
+ "tie_word_embeddings": false,
124
+ "use_cache": true,
125
+ "vocab_size": 248339
126
+ },
127
+ "transformers_version": "5.14.1",
128
+ "vision_config": null,
129
+ "vision_end_token_id": null,
130
+ "vision_start_token_id": null
131
+ }
configuration.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from transformers import AutoConfig, PretrainedConfig
6
+
7
+
8
+ class OpenThaiSystemOneConfig(PretrainedConfig):
9
+ """Config = a text-tower config (Qwen3.5 text) + slot-head settings [+ vision tower + point head for the -Vision variant].
10
+
11
+ vision_config is None for the text-only line (v0.x): the model then wraps `Qwen3_5TextModel` and its state-dict
12
+ layout is unchanged. With a vision_config the model wraps the multimodal `Qwen3_5Model` (visual + language_model)
13
+ and gains a PointHead for `point` questions.
14
+ """
15
+
16
+ model_type = "openthai_systemone"
17
+ sub_configs = {"text_config": AutoConfig, "vision_config": AutoConfig}
18
+
19
+ def __init__(
20
+ self,
21
+ text_config: Optional[Dict[str, Any] | PretrainedConfig] = None,
22
+ n_slots: int = 256,
23
+ abstain_slot: int = 255,
24
+ answer_token_id: Optional[int] = None,
25
+ head_bias: bool = True,
26
+ n_temperatures: int = 3, # per question type: choice / score / noul (/ point)
27
+ vision_config: Optional[Dict[str, Any] | PretrainedConfig] = None,
28
+ image_token_id: Optional[int] = None,
29
+ vision_start_token_id: Optional[int] = None,
30
+ vision_end_token_id: Optional[int] = None,
31
+ point_token_id: Optional[int] = None,
32
+ point_head: Optional[Dict[str, Any]] = None, # {"dim": 256, "n_sa_layers": 1, "n_heads": 8}
33
+ **kwargs,
34
+ ):
35
+ if isinstance(text_config, dict):
36
+ text_config = AutoConfig.for_model(**text_config) if "model_type" in text_config else AutoConfig.for_model("qwen3_5_text", **text_config)
37
+ if isinstance(vision_config, dict):
38
+ vc = dict(vision_config)
39
+ vision_config = AutoConfig.for_model(**vc) if "model_type" in vc else AutoConfig.for_model("qwen3_5_vision", **vc)
40
+ self.text_config = text_config
41
+ self.vision_config = vision_config
42
+ self.n_slots = n_slots
43
+ self.abstain_slot = abstain_slot
44
+ self.answer_token_id = answer_token_id
45
+ self.head_bias = head_bias
46
+ self.n_temperatures = n_temperatures
47
+ self.image_token_id = image_token_id
48
+ self.vision_start_token_id = vision_start_token_id
49
+ self.vision_end_token_id = vision_end_token_id
50
+ self.point_token_id = point_token_id
51
+ self.point_head = point_head
52
+ super().__init__(**kwargs)
53
+
54
+ @property
55
+ def is_vision(self) -> bool:
56
+ return self.vision_config is not None
57
+
58
+ @property
59
+ def hidden_size(self) -> int:
60
+ return self.text_config.hidden_size
61
+
62
+ @property
63
+ def vocab_size(self) -> int:
64
+ return self.text_config.vocab_size
formatting.py ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn (state, questions) into token ids + slot bookkeeping.
2
+
3
+ Layout (one sequence, causal):
4
+
5
+ <|ts_state|> {state text}
6
+ <|ts_q|><|ts_choice|> {instructions}
7
+ <|ts_opt_0|> {option name}: {description}
8
+ <|ts_opt_1|> {option name}
9
+ ...
10
+ <|ts_answer|> <- hidden state here -> SlotHead (256 logits)
11
+ <|ts_q|><|ts_noul|> {instructions}
12
+ <|ts_opt_0|> no
13
+ <|ts_opt_1|> yes
14
+ <|ts_answer|>
15
+ ...
16
+
17
+ Slot i (0..254) means "the option introduced by <|ts_opt_i|>"; slot 255 = abstain.
18
+ All answers for all questions are read out from one forward pass.
19
+
20
+ Vision variant (OpenThai-SystemOne-Vision) adds, before the state (or inline where the state text says <image:id>):
21
+
22
+ <|ts_img|> [image:screen 1280x800 screenshot]
23
+ <|vision_start|><|image_pad|> x N <|vision_end|>
24
+
25
+ and a fourth question type whose readout is the PointHead over that image's N visual tokens:
26
+
27
+ <|ts_q|><|ts_point|> [image:screen] {instructions}
28
+ <|ts_answer|>
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import random
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
36
+
37
+ from .types import Choice, Noul, Point, Score, Question, MAX_OPTIONS
38
+
39
+ N_SLOTS = 256
40
+ ABSTAIN_SLOT = 255
41
+
42
+ TOK_STATE = "<|ts_state|>"
43
+ TOK_Q = "<|ts_q|>"
44
+ TOK_CHOICE = "<|ts_choice|>"
45
+ TOK_SCORE = "<|ts_score|>"
46
+ TOK_NOUL = "<|ts_noul|>"
47
+ TOK_ANSWER = "<|ts_answer|>"
48
+ TOK_OPT = [f"<|ts_opt_{i}|>" for i in range(N_SLOTS)]
49
+ TOK_POINT = "<|ts_point|>" # vision variant only
50
+ TOK_IMG = "<|ts_img|>" # vision variant only
51
+
52
+ SPECIAL_TOKENS: List[str] = [TOK_STATE, TOK_Q, TOK_CHOICE, TOK_SCORE, TOK_NOUL, TOK_ANSWER] + TOK_OPT
53
+ VISION_TOKENS: List[str] = [TOK_POINT, TOK_IMG] # added on top of SPECIAL_TOKENS for the -Vision variant
54
+ # Qwen's own multimodal tokens (already in the base tokenizer)
55
+ QWEN_VISION_START, QWEN_VISION_END, QWEN_IMAGE_PAD = "<|vision_start|>", "<|vision_end|>", "<|image_pad|>"
56
+ QTYPES = ("choice", "score", "noul", "point")
57
+
58
+ NOUL_OPTIONS = ("no", "yes") # slot 0 = no, slot 1 = yes -> noul = p(slot 1)
59
+
60
+ DEFAULT_MAX_TOTAL_TOKENS = 65536
61
+ DEFAULT_MAX_STATE_TOKENS = 32768
62
+
63
+
64
+ def add_special_tokens(tokenizer, *, vision: bool = False) -> int:
65
+ """Register the control tokens (plus the vision ones when vision=True). Returns number of tokens added."""
66
+ existing = set(tokenizer.get_vocab())
67
+ wanted = SPECIAL_TOKENS + (VISION_TOKENS if vision else [])
68
+ new = [t for t in wanted if t not in existing]
69
+ if not new:
70
+ return 0
71
+ return tokenizer.add_tokens(new, special_tokens=True)
72
+
73
+
74
+ def sanitize(text: str) -> str:
75
+ """Stop user content from smuggling control tokens into the sequence."""
76
+ return text.replace("<|ts_", "<​|ts_") if "<|ts_" in text else text
77
+
78
+
79
+ def state_to_text(state: Union[str, Dict[str, Any], List[Any]], *, indent: Optional[int] = None) -> str:
80
+ if isinstance(state, str):
81
+ return state
82
+ return json.dumps(state, ensure_ascii=False, indent=indent)
83
+
84
+
85
+ @dataclass
86
+ class QuestionSpec:
87
+ """A question flattened to option strings + slot bookkeeping."""
88
+
89
+ qid: str
90
+ qtype: str # choice | score | noul
91
+ instructions: str
92
+ option_names: List[str] # in slot order (after any permutation)
93
+ option_descs: List[Optional[str]]
94
+ perm: List[int] # perm[slot] = original index of the option at that slot
95
+ label_slot: Optional[int] = None # training only
96
+ image: Optional[str] = None # point questions: id of the referenced image (None = first image)
97
+ point_bbox: Optional[List[float]] = None # training only: normalised [x0,y0,x1,y1]; None with point_label_given -> null
98
+ point_label_given: bool = False
99
+
100
+
101
+ def question_to_spec(
102
+ qid: str,
103
+ q: Question,
104
+ *,
105
+ label: Optional[Union[str, int, bool]] = None,
106
+ shuffle: bool = False,
107
+ rng: Optional[random.Random] = None,
108
+ drop_label: bool = False,
109
+ perm: Optional[Sequence[int]] = None,
110
+ ) -> QuestionSpec:
111
+ """Flatten a typed question.
112
+
113
+ label: for training. Choice -> option name; Score -> level index (int); Noul -> bool.
114
+ shuffle: permute option order (Choice only; Score/Noul order is semantic).
115
+ drop_label: remove the correct option from a Choice so the target becomes ABSTAIN_SLOT.
116
+ perm: explicit option order for a Choice (list of original indices), e.g. a cyclic shift for order-invariant inference.
117
+ """
118
+ if isinstance(q, Choice):
119
+ names = list(q.criteria.keys())
120
+ descs = [q.criteria[n] for n in names]
121
+ idx = list(range(len(names)))
122
+ label_idx = None
123
+ if label is not None:
124
+ if label not in q.criteria:
125
+ raise ValueError(f"label {label!r} is not one of the options")
126
+ label_idx = names.index(str(label))
127
+ if drop_label and label_idx is not None:
128
+ if len(idx) < 2:
129
+ raise ValueError("cannot drop the only option")
130
+ idx.remove(label_idx)
131
+ label_idx = None
132
+ if perm is not None:
133
+ idx = [i for i in perm if i in idx]
134
+ elif shuffle:
135
+ (rng or random).shuffle(idx)
136
+ names_p = [names[i] for i in idx]
137
+ descs_p = [descs[i] for i in idx]
138
+ if label is None:
139
+ slot = None
140
+ elif label_idx is None:
141
+ slot = ABSTAIN_SLOT
142
+ else:
143
+ slot = idx.index(label_idx)
144
+ return QuestionSpec(qid, "choice", q.instructions, names_p, descs_p, idx, slot)
145
+
146
+ if isinstance(q, Score):
147
+ names = [str(i) for i in range(len(q.criteria))]
148
+ descs = list(q.criteria)
149
+ slot = int(label) if label is not None else None
150
+ if slot is not None and not (0 <= slot < len(descs)):
151
+ raise ValueError("score label out of range")
152
+ return QuestionSpec(qid, "score", q.instructions, names, descs, list(range(len(names))), slot)
153
+
154
+ if isinstance(q, Noul):
155
+ c = q.criteria or {}
156
+ descs = [c.get("false"), c.get("true")]
157
+ slot = None if label is None else int(bool(label))
158
+ return QuestionSpec(qid, "noul", q.instructions, list(NOUL_OPTIONS), descs, [0, 1], slot)
159
+
160
+ if isinstance(q, Point):
161
+ # label: {"bbox": [x0,y0,x1,y1]} | [x0,y0,x1,y1] | {"bbox": None} (= not on screen) | None (inference)
162
+ bbox, given = None, False
163
+ if label is not None:
164
+ given = True
165
+ bbox = label.get("bbox") if isinstance(label, dict) else list(label)
166
+ if bbox is not None:
167
+ bbox = [float(v) for v in bbox]
168
+ if len(bbox) != 4:
169
+ raise ValueError("point label bbox needs 4 numbers")
170
+ return QuestionSpec(qid, "point", q.instructions, [], [], [], None, image=q.image, point_bbox=bbox, point_label_given=given)
171
+
172
+ raise TypeError(type(q))
173
+
174
+
175
+ def spec_to_text(spec: QuestionSpec, *, image_id: Optional[str] = None) -> str:
176
+ if spec.qtype == "point":
177
+ tag = f"[image:{sanitize(str(image_id if image_id is not None else spec.image or ''))}] "
178
+ return f"{TOK_Q}{TOK_POINT} {tag}{sanitize(spec.instructions).strip()}\n{TOK_ANSWER}\n"
179
+ head = {"choice": TOK_CHOICE, "score": TOK_SCORE, "noul": TOK_NOUL}[spec.qtype]
180
+ lines = [f"{TOK_Q}{head} {sanitize(spec.instructions).strip()}"]
181
+ for i, (name, desc) in enumerate(zip(spec.option_names, spec.option_descs)):
182
+ name = sanitize(str(name)).strip()
183
+ if desc:
184
+ lines.append(f"{TOK_OPT[i]} {name}: {sanitize(str(desc)).strip()}")
185
+ else:
186
+ lines.append(f"{TOK_OPT[i]} {name}")
187
+ lines.append(TOK_ANSWER)
188
+ return "\n".join(lines) + "\n"
189
+
190
+
191
+ @dataclass
192
+ class Encoded:
193
+ input_ids: List[int]
194
+ answer_positions: List[int] # index of each <|ts_answer|> token, question order
195
+ option_counts: List[int] # k per question (valid slots 0..k-1)
196
+ specs: List[QuestionSpec]
197
+ labels: List[int] = field(default_factory=list) # -100 if unknown
198
+ truncated_state: bool = False
199
+ # vision variant
200
+ image_ids: List[str] = field(default_factory=list)
201
+ image_grid_thw: List[List[int]] = field(default_factory=list) # per image (t, h, w) in patches
202
+ image_spans: List[Tuple[int, int]] = field(default_factory=list) # [start, end) positions of each image's tokens
203
+ image_sizes: List[Tuple[int, int]] = field(default_factory=list) # original (W, H)
204
+ pixel_values: Any = None # torch.Tensor (sum patches, C*T*P*P) or None
205
+ merge_size: int = 2
206
+ point_image_index: List[int] = field(default_factory=list) # per question: image index or -1
207
+ point_targets: List[Any] = field(default_factory=list) # per question: tensor (n_tokens+1) or None
208
+
209
+ @property
210
+ def n_tokens(self) -> int:
211
+ return len(self.input_ids)
212
+
213
+ @property
214
+ def n_visual_tokens(self) -> int:
215
+ return sum(e - s for s, e in self.image_spans)
216
+
217
+ def merged_grid(self, i: int) -> Tuple[int, int]:
218
+ t, h, w = self.image_grid_thw[i]
219
+ return h // self.merge_size, w // self.merge_size
220
+
221
+ def n_tokens_of_image(self, i: int) -> int:
222
+ s, e = self.image_spans[i]
223
+ return e - s
224
+
225
+
226
+ class Formatter:
227
+ """Tokenizer-aware encoder shared by training and inference."""
228
+
229
+ def __init__(
230
+ self,
231
+ tokenizer,
232
+ *,
233
+ max_total_tokens: int = DEFAULT_MAX_TOTAL_TOKENS,
234
+ max_state_tokens: int = DEFAULT_MAX_STATE_TOKENS,
235
+ image_processor=None,
236
+ max_pixels: Optional[int] = None,
237
+ ):
238
+ self.tok = tokenizer
239
+ self.image_processor = image_processor
240
+ self.vision = image_processor is not None
241
+ add_special_tokens(self.tok, vision=self.vision)
242
+ self.max_total_tokens = max_total_tokens
243
+ self.max_state_tokens = max_state_tokens
244
+ self.max_pixels = max_pixels
245
+ self.image_device = None # set by the client: patchify maths runs on the model device
246
+ self.answer_id = self.tok.convert_tokens_to_ids(TOK_ANSWER)
247
+ self.state_id = self.tok.convert_tokens_to_ids(TOK_STATE)
248
+ self.opt_ids = self.tok.convert_tokens_to_ids(TOK_OPT)
249
+ assert self.answer_id is not None and self.answer_id != self.tok.unk_token_id
250
+ if self.vision:
251
+ self.image_pad_id = self.tok.convert_tokens_to_ids(QWEN_IMAGE_PAD)
252
+ self.vision_start_id = self.tok.convert_tokens_to_ids(QWEN_VISION_START)
253
+ self.vision_end_id = self.tok.convert_tokens_to_ids(QWEN_VISION_END)
254
+ self.point_id = self.tok.convert_tokens_to_ids(TOK_POINT)
255
+ for v in (self.image_pad_id, self.vision_start_id, self.vision_end_id, self.point_id):
256
+ assert v is not None and v != self.tok.unk_token_id, "tokenizer lacks the Qwen vision tokens"
257
+
258
+ def _image_block(self, ref, n_tokens: int, size: Tuple[int, int]) -> List[int]:
259
+ """<|ts_img|> [image:id WxH role]\n<|vision_start|> pad*n <|vision_end|>\n -> ids; the pad run is contiguous."""
260
+ role = f" {sanitize(str(ref.role))}" if getattr(ref, "role", None) else ""
261
+ head = self._ids(f"{TOK_IMG} [image:{sanitize(ref.id)} {size[0]}x{size[1]}{role}]\n")
262
+ return head + [self.vision_start_id] + [self.image_pad_id] * n_tokens + [self.vision_end_id] + self._ids("\n")
263
+
264
+ def _ids(self, text: str) -> List[int]:
265
+ return self.tok(text, add_special_tokens=False)["input_ids"]
266
+
267
+ def encode(
268
+ self,
269
+ state: Union[str, Dict[str, Any], List[Any]],
270
+ questions: Dict[str, Question],
271
+ *,
272
+ labels: Optional[Dict[str, Union[str, int, bool]]] = None,
273
+ shuffle_options: bool = False,
274
+ shuffle_questions: bool = False,
275
+ drop_label_for: Optional[Sequence[str]] = None,
276
+ rng: Optional[random.Random] = None,
277
+ state_indent: Optional[int] = None,
278
+ option_orders: Optional[Dict[str, Sequence[int]]] = None,
279
+ images: Optional[Sequence[Any]] = None,
280
+ processed_images=None,
281
+ ) -> Encoded:
282
+ """images: list of ImageRef / dicts (vision variant). processed_images: a ProcessedImages to reuse (e.g. across
283
+ the option-order permutations of one request) instead of running the image processor again."""
284
+ rng = rng or random.Random()
285
+ labels = labels or {}
286
+ drop = set(drop_label_for or [])
287
+ option_orders = option_orders or {}
288
+ qids = list(questions.keys())
289
+ if shuffle_questions:
290
+ rng.shuffle(qids)
291
+
292
+ # ---- images (vision variant)
293
+ proc = processed_images
294
+ refs: List[Any] = []
295
+ if images:
296
+ if not self.vision:
297
+ raise ValueError("this model is text-only; images and point questions need OpenThai-SystemOne-Vision")
298
+ from .images import ImageRef, process_images
299
+
300
+ refs = [ImageRef.parse(im) for im in images]
301
+ if proc is None:
302
+ kw = {"max_pixels": self.max_pixels} if self.max_pixels else {}
303
+ proc = process_images(self.image_processor, refs, device=self.image_device, **kw)
304
+ has_point = any(isinstance(questions[q], Point) for q in qids)
305
+ if has_point and not refs:
306
+ raise ValueError("point questions need at least one image")
307
+ image_index = {r.id: i for i, r in enumerate(refs)}
308
+
309
+ specs = [
310
+ question_to_spec(
311
+ qid,
312
+ questions[qid],
313
+ label=labels.get(qid),
314
+ shuffle=shuffle_options,
315
+ rng=rng,
316
+ drop_label=qid in drop,
317
+ perm=option_orders.get(qid),
318
+ )
319
+ for qid in qids
320
+ ]
321
+ point_image_index: List[int] = []
322
+ for sp in specs:
323
+ if sp.qtype != "point":
324
+ point_image_index.append(-1)
325
+ continue
326
+ if sp.image is None:
327
+ point_image_index.append(0)
328
+ elif sp.image in image_index:
329
+ point_image_index.append(image_index[sp.image])
330
+ else:
331
+ raise ValueError(f"point question {sp.qid!r} references unknown image {sp.image!r}")
332
+ q_texts = [spec_to_text(s, image_id=(refs[point_image_index[i]].id if s.qtype == "point" else None)) for i, s in enumerate(specs)]
333
+ q_ids = [self._ids(t) for t in q_texts]
334
+ q_total = sum(len(x) for x in q_ids)
335
+
336
+ # image blocks: inline where the state text says <image:id>, otherwise all before the state
337
+ blocks: Dict[str, List[int]] = {}
338
+ if refs:
339
+ for i, r in enumerate(refs):
340
+ blocks[r.id] = self._image_block(r, proc.n_tokens[i], proc.sizes[i])
341
+ state_text = sanitize(state_to_text(state, indent=state_indent)).strip()
342
+ inline = [r.id for r in refs if f"<image:{r.id}>" in state_text]
343
+ prefix_ids: List[int] = []
344
+ for r in refs:
345
+ if r.id not in inline:
346
+ prefix_ids += blocks[r.id]
347
+ body_parts: List[List[int]] = []
348
+ if inline:
349
+ import re
350
+
351
+ pieces = re.split("(" + "|".join(re.escape(f"<image:{i}>") for i in inline) + ")", state_text)
352
+ first = True
353
+ for piece in pieces:
354
+ if piece.startswith("<image:") and piece[7:-1] in blocks:
355
+ body_parts.append(self._ids("\n") + blocks[piece[7:-1]])
356
+ elif piece:
357
+ body_parts.append(self._ids((TOK_STATE + " " if first else "") + piece))
358
+ first = False
359
+ if first:
360
+ body_parts.insert(0, self._ids(TOK_STATE + " "))
361
+ state_ids = [t for part in body_parts for t in part] + self._ids("\n")
362
+ else:
363
+ state_ids = self._ids(TOK_STATE + " " + state_text + "\n")
364
+ budget = min(self.max_state_tokens, self.max_total_tokens - q_total - len(prefix_ids))
365
+ truncated = False
366
+ if len(state_ids) > budget and not inline:
367
+ # keep the head (state token) and the tail of the state; the end is usually the most recent info
368
+ keep_tail = max(budget - 1, 0)
369
+ state_ids = state_ids[:1] + state_ids[len(state_ids) - keep_tail :]
370
+ truncated = True
371
+
372
+ ids: List[int] = prefix_ids + list(state_ids)
373
+ # locate each image's contiguous pad run (in `refs` order = the order the blocks were emitted)
374
+ image_spans: List[Tuple[int, int]] = []
375
+ if refs:
376
+ runs = []
377
+ i = 0
378
+ while i < len(ids):
379
+ if ids[i] == self.image_pad_id:
380
+ j = i
381
+ while j < len(ids) and ids[j] == self.image_pad_id:
382
+ j += 1
383
+ runs.append((i, j))
384
+ i = j
385
+ else:
386
+ i += 1
387
+ # runs appear in emission order: prefix blocks first (refs order minus inline), then inline ones in text order
388
+ order = [r.id for r in refs if r.id not in inline] + [m for m in re.findall(r"<image:([^>]+)>", state_text) if m in inline] if inline else [r.id for r in refs]
389
+ by_id = dict(zip(order, runs))
390
+ image_spans = [by_id[r.id] for r in refs]
391
+ assert all(e - s == proc.n_tokens[i] for i, (s, e) in enumerate(image_spans))
392
+ answer_positions: List[int] = []
393
+ for qi in q_ids:
394
+ ids.extend(qi)
395
+ # the answer token is the last non-newline token of each question block
396
+ pos = len(ids) - 1
397
+ while ids[pos] != self.answer_id:
398
+ pos -= 1
399
+ answer_positions.append(pos)
400
+
401
+ point_targets: List[Any] = []
402
+ if refs:
403
+ from .images import bbox_to_token_target
404
+
405
+ for sp, ii in zip(specs, point_image_index):
406
+ if sp.qtype == "point" and sp.point_label_given:
407
+ t, h, w = proc.grid_thw[ii]
408
+ point_targets.append(bbox_to_token_target(sp.point_bbox, h // proc.merge, w // proc.merge))
409
+ else:
410
+ point_targets.append(None)
411
+ else:
412
+ point_targets = [None] * len(specs)
413
+
414
+ return Encoded(
415
+ input_ids=ids,
416
+ answer_positions=answer_positions,
417
+ option_counts=[len(s.option_names) for s in specs],
418
+ specs=specs,
419
+ labels=[(-100 if s.label_slot is None else s.label_slot) for s in specs],
420
+ truncated_state=truncated,
421
+ image_ids=[r.id for r in refs],
422
+ image_grid_thw=list(proc.grid_thw) if refs else [],
423
+ image_spans=image_spans,
424
+ image_sizes=list(proc.sizes) if refs else [],
425
+ pixel_values=proc.pixel_values if refs else None,
426
+ merge_size=proc.merge if refs else 2,
427
+ point_image_index=point_image_index,
428
+ point_targets=point_targets,
429
+ )
430
+
431
+
432
+ def slot_mask(option_counts: Sequence[int], *, include_abstain: bool = True, n_slots: int = N_SLOTS):
433
+ """Boolean mask (Q, n_slots): True where a slot is valid for that question."""
434
+ import torch
435
+
436
+ k = torch.as_tensor(list(option_counts), dtype=torch.long)
437
+ ar = torch.arange(n_slots)
438
+ mask = ar[None, :] < k[:, None]
439
+ if include_abstain:
440
+ mask[:, ABSTAIN_SLOT] = True
441
+ return mask
442
+
443
+
444
+ def mrope_position_ids(encoded: Sequence[Encoded], T: int):
445
+ """Qwen3.5 3-D (t, h, w) position ids for a right-padded batch, computed on CPU without per-token python loops.
446
+
447
+ Text tokens: t = h = w = running position. An image with merged grid (t, hm, wm) placed at running position p gets
448
+ t = p + frame index, h = p + row, w = p + col, and advances the running position by max(hm, wm). Equals
449
+ `Qwen3_5Model.get_rope_index` (tests/test_vision.py checks it) but costs ~0.1 ms instead of tens of ms.
450
+ """
451
+ import torch
452
+
453
+ B = len(encoded)
454
+ pos = torch.zeros((3, B, T), dtype=torch.long)
455
+ for b, e in enumerate(encoded):
456
+ n = e.n_tokens
457
+ cur = 0 # running position
458
+ idx = 0 # token index
459
+ spans = sorted(zip(e.image_spans, range(len(e.image_spans))))
460
+ for (s0, e0), i in spans:
461
+ if s0 > idx: # text before the image
462
+ ar = torch.arange(s0 - idx) + cur
463
+ pos[:, b, idx:s0] = ar
464
+ cur += s0 - idx
465
+ t, h, w = e.image_grid_thw[i]
466
+ hm, wm = h // e.merge_size, w // e.merge_size
467
+ tt = torch.arange(t).view(t, 1, 1).expand(t, hm, wm)
468
+ hh = torch.arange(hm).view(1, hm, 1).expand(t, hm, wm)
469
+ ww = torch.arange(wm).view(1, 1, wm).expand(t, hm, wm)
470
+ pos[0, b, s0:e0] = tt.reshape(-1) + cur
471
+ pos[1, b, s0:e0] = hh.reshape(-1) + cur
472
+ pos[2, b, s0:e0] = ww.reshape(-1) + cur
473
+ cur += max(hm, wm)
474
+ idx = e0
475
+ if n > idx:
476
+ pos[:, b, idx:n] = torch.arange(n - idx) + cur
477
+ return pos
478
+
479
+
480
+ def collate(encoded: Sequence[Encoded], pad_id: int, *, max_questions: Optional[int] = None):
481
+ """Right-pad a batch. Returns dict of tensors for OpenThaiSystemOneForDecision.forward."""
482
+ import torch
483
+
484
+ B = len(encoded)
485
+ T = max(e.n_tokens for e in encoded)
486
+ Q = max_questions or max(len(e.answer_positions) for e in encoded)
487
+ input_ids = torch.full((B, T), pad_id, dtype=torch.long)
488
+ attention_mask = torch.zeros((B, T), dtype=torch.long)
489
+ answer_positions = torch.zeros((B, Q), dtype=torch.long)
490
+ option_counts = torch.zeros((B, Q), dtype=torch.long)
491
+ labels = torch.full((B, Q), -100, dtype=torch.long)
492
+ qtypes = torch.full((B, Q), -1, dtype=torch.long)
493
+ for b, e in enumerate(encoded):
494
+ n = e.n_tokens
495
+ input_ids[b, :n] = torch.tensor(e.input_ids)
496
+ attention_mask[b, :n] = 1
497
+ q = len(e.answer_positions)
498
+ answer_positions[b, :q] = torch.tensor(e.answer_positions)
499
+ option_counts[b, :q] = torch.tensor(e.option_counts)
500
+ qtypes[b, :q] = torch.tensor([QTYPES.index(s.qtype) for s in e.specs])
501
+ if e.labels:
502
+ labels[b, :q] = torch.tensor(e.labels)
503
+ out = {
504
+ "input_ids": input_ids,
505
+ "attention_mask": attention_mask,
506
+ "answer_positions": answer_positions,
507
+ "option_counts": option_counts,
508
+ "labels": labels,
509
+ "qtypes": qtypes,
510
+ }
511
+ if any(e.pixel_values is not None for e in encoded):
512
+ I = max(len(e.image_spans) for e in encoded)
513
+ pv = [e.pixel_values for e in encoded if e.pixel_values is not None]
514
+ grid = [torch.tensor(e.image_grid_thw, dtype=torch.long) for e in encoded if e.image_grid_thw]
515
+ image_spans = torch.full((B, I, 2), -1, dtype=torch.long)
516
+ mm_token_type_ids = torch.zeros((B, T), dtype=torch.long)
517
+ point_image_index = torch.full((B, Q), -1, dtype=torch.long)
518
+ L = max([e.n_tokens_of_image(i) for e in encoded for i in range(len(e.image_spans))] + [1])
519
+ point_targets = torch.zeros((B, Q, L + 1))
520
+ point_has_target = torch.zeros((B, Q), dtype=torch.bool)
521
+ for b, e in enumerate(encoded):
522
+ for i, (s0, e0) in enumerate(e.image_spans):
523
+ image_spans[b, i] = torch.tensor([s0, e0])
524
+ mm_token_type_ids[b, s0:e0] = 1
525
+ for qi, ii in enumerate(e.point_image_index):
526
+ point_image_index[b, qi] = ii
527
+ tgt = e.point_targets[qi] if qi < len(e.point_targets) else None
528
+ if tgt is not None:
529
+ n = tgt.shape[0] - 1
530
+ point_targets[b, qi, :n] = tgt[:n]
531
+ point_targets[b, qi, L] = tgt[n]
532
+ point_has_target[b, qi] = True
533
+ out.update({
534
+ "pixel_values": torch.cat(pv, 0),
535
+ "image_grid_thw": torch.cat(grid, 0),
536
+ "mm_token_type_ids": mm_token_type_ids,
537
+ "position_ids": mrope_position_ids(encoded, T),
538
+ "image_spans": image_spans,
539
+ "point_image_index": point_image_index,
540
+ "point_targets": point_targets,
541
+ "point_has_target": point_has_target,
542
+ })
543
+ return out
images.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image inputs for the vision variant: loading, resizing to the ViT token budget, and the patch-grid geometry
2
+ that the `point` head works in.
3
+
4
+ Geometry: the vision tower emits one token per (patch_size * spatial_merge_size)^2 pixel block (32x32 px for
5
+ Qwen3.5), row-major over the merged grid (hm, wm) = (h / merge, w / merge) where (t, h, w) = image_grid_thw.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import io
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
13
+
14
+ DEFAULT_MAX_PIXELS = 1280 * 800 # ~1000 visual tokens at 32 px/token
15
+ HIGH_DETAIL_MAX_PIXELS = 1920 * 1200 # ~2280 tokens
16
+ DEFAULT_MIN_PIXELS = 64 * 64
17
+ MAX_IMAGES_PER_REQUEST = 4
18
+ MAX_VISUAL_TOKENS = 8192
19
+
20
+
21
+ def load_image(src: Any):
22
+ """PIL image from: PIL.Image, bytes, data-URI / base64 string, http(s) URL, or a filesystem path."""
23
+ from PIL import Image
24
+
25
+ if isinstance(src, Image.Image):
26
+ return src.convert("RGB")
27
+ if isinstance(src, (bytes, bytearray)):
28
+ return Image.open(io.BytesIO(src)).convert("RGB")
29
+ if hasattr(src, "shape"): # numpy array (H, W, 3)
30
+ return Image.fromarray(src).convert("RGB")
31
+ if not isinstance(src, str):
32
+ raise TypeError(f"unsupported image source {type(src)}")
33
+ if src.startswith("data:"):
34
+ _, _, payload = src.partition(",")
35
+ return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
36
+ if src.startswith(("http://", "https://")):
37
+ import urllib.request
38
+
39
+ req = urllib.request.Request(src, headers={"User-Agent": "openthai-systemone/1.0"})
40
+ with urllib.request.urlopen(req, timeout=20) as r:
41
+ return Image.open(io.BytesIO(r.read())).convert("RGB")
42
+ if len(src) > 512 and "/" not in src[:16] and not src.startswith("."):
43
+ try: # bare base64
44
+ return Image.open(io.BytesIO(base64.b64decode(src, validate=True))).convert("RGB")
45
+ except Exception:
46
+ pass
47
+ return Image.open(src).convert("RGB")
48
+
49
+
50
+ @dataclass
51
+ class ImageRef:
52
+ """One image attached to a request."""
53
+
54
+ id: str
55
+ image: Any # anything load_image accepts
56
+ detail: str = "auto" # auto | high
57
+ role: Optional[str] = None # free text tag, shown in the sequence (screenshot, frame, photo, document)
58
+
59
+ @classmethod
60
+ def parse(cls, obj: Union["ImageRef", Dict[str, Any]]) -> "ImageRef":
61
+ if isinstance(obj, ImageRef):
62
+ return obj
63
+ d = dict(obj)
64
+ src = d.get("image", d.get("data", d.get("url", d.get("path"))))
65
+ if src is None:
66
+ raise ValueError("image needs one of: image / data / url / path")
67
+ return cls(id=str(d.get("id", "img")), image=src, detail=str(d.get("detail", "auto")), role=d.get("role"))
68
+
69
+
70
+ @dataclass
71
+ class ProcessedImages:
72
+ ids: List[str]
73
+ pixel_values: Any # torch.Tensor (sum_patches, C*T*P*P)
74
+ grid_thw: List[List[int]] # per image (t, h, w) in patches
75
+ sizes: List[Tuple[int, int]] # original (W, H)
76
+ n_tokens: List[int] = field(default_factory=list)
77
+ merge: int = 2
78
+
79
+ def tokens_for(self, i: int) -> int:
80
+ t, h, w = self.grid_thw[i]
81
+ return t * h * w // (self.merge * self.merge)
82
+
83
+ def merged_grid(self, i: int) -> Tuple[int, int]:
84
+ t, h, w = self.grid_thw[i]
85
+ return h // self.merge, w // self.merge
86
+
87
+
88
+ def fast_patchify(im, image_processor, *, min_pixels: int, max_pixels: int, device=None):
89
+ """Qwen2-VL preprocessing without the HF processor's overhead (~3 ms vs ~40 ms per frame on a busy CPU).
90
+
91
+ Same maths: smart_resize to multiples of patch*merge within [min_pixels, max_pixels], bicubic resize, rescale,
92
+ normalise with the processor's mean/std, duplicate the frame temporal_patch_size times, and flatten patches in the
93
+ processor's (t, h/merge, w/merge, merge, merge, C, T, p, p) order. tests/test_vision.py checks equality with the HF
94
+ processor output.
95
+ """
96
+ import numpy as np
97
+ import torch
98
+ from PIL import Image
99
+ from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize
100
+
101
+ patch = int(getattr(image_processor, "patch_size", 16))
102
+ merge = int(getattr(image_processor, "merge_size", 2))
103
+ tp = int(getattr(image_processor, "temporal_patch_size", 2))
104
+ mean = torch.tensor(image_processor.image_mean, dtype=torch.float32).view(3, 1, 1)
105
+ std = torch.tensor(image_processor.image_std, dtype=torch.float32).view(3, 1, 1)
106
+ w, h = im.size
107
+ rh, rw = smart_resize(h, w, factor=patch * merge, min_pixels=min_pixels, max_pixels=max_pixels)
108
+ if (rw, rh) != (w, h):
109
+ im = im.resize((rw, rh), Image.BICUBIC)
110
+ x = torch.from_numpy(np.asarray(im, dtype=np.uint8))
111
+ if device is not None: # normalise + patchify on the GPU: the CPU only resizes and uploads 3 bytes/pixel
112
+ x = x.to(device, non_blocking=True)
113
+ mean, std = mean.to(device), std.to(device)
114
+ x = x.permute(2, 0, 1).float() / 255.0 # (C, H, W)
115
+ x = (x - mean) / std
116
+ gh, gw = rh // patch, rw // patch
117
+ x = x.unsqueeze(0).expand(tp, -1, -1, -1) # (T, C, H, W)
118
+ x = x.reshape(tp, 3, gh // merge, merge, patch, gw // merge, merge, patch)
119
+ # HF order: (grid_t, temporal, C, gh//m, m, p, gw//m, m, p) -> transpose(0, 3, 6, 4, 7, 2, 1, 5, 8)
120
+ x = x.unsqueeze(0) # (1, T, C, gh/m, m, p, gw/m, m, p)
121
+ x = x.permute(0, 3, 6, 4, 7, 2, 1, 5, 8).reshape(gh * gw, 3 * tp * patch * patch)
122
+ return x, [1, gh, gw]
123
+
124
+
125
+ def process_images(image_processor, refs: Sequence[ImageRef], *, max_pixels: int = DEFAULT_MAX_PIXELS,
126
+ min_pixels: int = DEFAULT_MIN_PIXELS, fast: bool = True, device=None) -> ProcessedImages:
127
+ """Preprocess each image (own detail cap each) and concatenate. fast=True uses fast_patchify (identical output)."""
128
+ import torch
129
+
130
+ if len(refs) > MAX_IMAGES_PER_REQUEST:
131
+ raise ValueError(f"at most {MAX_IMAGES_PER_REQUEST} images per request")
132
+ merge = int(getattr(image_processor, "merge_size", 2))
133
+ pv, grids, sizes, ids, ntoks = [], [], [], [], []
134
+ for r in refs:
135
+ im = load_image(r.image)
136
+ sizes.append(im.size)
137
+ cap = HIGH_DETAIL_MAX_PIXELS if r.detail == "high" else max_pixels
138
+ if fast:
139
+ x, g = fast_patchify(im, image_processor, min_pixels=min_pixels, max_pixels=cap, device=device)
140
+ pv.append(x)
141
+ else:
142
+ out = image_processor(images=[im], size={"shortest_edge": min_pixels, "longest_edge": cap}, return_tensors="pt")
143
+ pv.append(out["pixel_values"])
144
+ g = [int(v) for v in out["image_grid_thw"][0].tolist()]
145
+ grids.append(g)
146
+ ids.append(r.id)
147
+ ntoks.append(g[0] * g[1] * g[2] // (merge * merge))
148
+ if sum(ntoks) > MAX_VISUAL_TOKENS:
149
+ raise ValueError(f"images need {sum(ntoks)} visual tokens > {MAX_VISUAL_TOKENS}; lower detail or send fewer images")
150
+ return ProcessedImages(ids=ids, pixel_values=torch.cat(pv, 0), grid_thw=grids, sizes=sizes, n_tokens=ntoks, merge=merge)
151
+
152
+
153
+ def bbox_to_token_target(bbox: Optional[Sequence[float]], hm: int, wm: int) -> "torch.Tensor":
154
+ """Soft target over the hm*wm tokens (+1 null slot at the end) for a normalised bbox [x0,y0,x1,y1] in 0..1.
155
+
156
+ Cells whose area overlaps the box get weight = overlap fraction of the cell (GUI-Actor style multi-patch
157
+ supervision); normalised to sum 1. bbox None -> all mass on the null slot ("not on screen").
158
+ """
159
+ import torch
160
+
161
+ tgt = torch.zeros(hm * wm + 1)
162
+ if bbox is None:
163
+ tgt[-1] = 1.0
164
+ return tgt
165
+ x0, y0, x1, y1 = [float(v) for v in bbox]
166
+ x0, x1 = sorted((max(0.0, min(1.0, x0)), max(0.0, min(1.0, x1))))
167
+ y0, y1 = sorted((max(0.0, min(1.0, y0)), max(0.0, min(1.0, y1))))
168
+ # a degenerate (point) box marks the cell that contains it
169
+ if x1 - x0 < 1e-6:
170
+ x0, x1 = max(0.0, x0 - 0.5 / wm), min(1.0, x0 + 0.5 / wm)
171
+ if y1 - y0 < 1e-6:
172
+ y0, y1 = max(0.0, y0 - 0.5 / hm), min(1.0, y0 + 0.5 / hm)
173
+ for r in range(hm):
174
+ cy0, cy1 = r / hm, (r + 1) / hm
175
+ oy = max(0.0, min(cy1, y1) - max(cy0, y0))
176
+ if oy <= 0:
177
+ continue
178
+ for c in range(wm):
179
+ cx0, cx1 = c / wm, (c + 1) / wm
180
+ ox = max(0.0, min(cx1, x1) - max(cx0, x0))
181
+ if ox > 0:
182
+ tgt[r * wm + c] = ox * oy * hm * wm # fraction of the cell covered
183
+ if tgt[:-1].sum() <= 0:
184
+ tgt[-1] = 1.0
185
+ else:
186
+ tgt = tgt / tgt.sum()
187
+ return tgt
188
+
189
+
190
+ def token_xy(index: int, hm: int, wm: int) -> Tuple[float, float]:
191
+ """Centre of merged token `index` in normalised image coordinates."""
192
+ r, c = divmod(index, wm)
193
+ return (c + 0.5) / wm, (r + 0.5) / hm
194
+
195
+
196
+ def heatmap_to_candidates(probs: Sequence[float], hm: int, wm: int, *, rel_threshold: float = 0.3, max_candidates: int = 5):
197
+ """GUI-Actor decoding: threshold at rel_threshold * max, 8-connected components, mass-weighted centroids.
198
+
199
+ probs: hm*wm token probabilities (null slot already removed). Returns list of {x, y, p, bbox} sorted by p.
200
+ """
201
+ n = hm * wm
202
+ p = [float(v) for v in probs[:n]]
203
+ if n == 0 or max(p) <= 0:
204
+ return []
205
+ thr = rel_threshold * max(p)
206
+ keep = {i for i, v in enumerate(p) if v >= thr}
207
+ seen, comps = set(), []
208
+ for start in sorted(keep, key=lambda i: -p[i]):
209
+ if start in seen:
210
+ continue
211
+ comp, stack = [], [start]
212
+ seen.add(start)
213
+ while stack:
214
+ i = stack.pop()
215
+ comp.append(i)
216
+ r, c = divmod(i, wm)
217
+ for dr in (-1, 0, 1):
218
+ for dc in (-1, 0, 1):
219
+ rr, cc = r + dr, c + dc
220
+ if 0 <= rr < hm and 0 <= cc < wm:
221
+ j = rr * wm + cc
222
+ if j in keep and j not in seen:
223
+ seen.add(j)
224
+ stack.append(j)
225
+ mass = sum(p[i] for i in comp)
226
+ xs = [token_xy(i, hm, wm) for i in comp]
227
+ x = sum(px * p[i] for (px, _), i in zip(xs, comp)) / mass
228
+ y = sum(py * p[i] for (_, py), i in zip(xs, comp)) / mass
229
+ rows = [i // wm for i in comp]
230
+ cols = [i % wm for i in comp]
231
+ bbox = [min(cols) / wm, min(rows) / hm, (max(cols) + 1) / wm, (max(rows) + 1) / hm]
232
+ comps.append({"x": round(x, 4), "y": round(y, 4), "p": round(mass, 4), "bbox": [round(v, 4) for v in bbox]})
233
+ comps.sort(key=lambda d: -d["p"])
234
+ return comps[:max_candidates]
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2b87567f3b76f4eacc34e55905d9a2e2e341833f6322478bd5263a7a96b66272
3
+ size 1009411440
modeling.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenThai-SystemOne decision model.
2
+
3
+ text tower (Qwen3.5-0.8B, LM head removed) -> hidden state at every <|ts_answer|> -> SlotHead (256 logits)
4
+ mask slots >= k -> softmax -> probabilities over the k options
5
+
6
+ -Vision variant: the tower is the multimodal Qwen3.5 model (ViT + merger + the same text tower). Images enter as
7
+ runs of <|image_pad|> tokens; `point` questions are read out by the PointHead, which attends from the <|ts_answer|>
8
+ hidden state over the LLM's final hidden states of that image's visual tokens (GUI-Actor recipe) plus a learned
9
+ "null" key for "not on the image". With no pixel_values the forward path is identical to the text-only model.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+ from typing import Optional
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+ from transformers import AutoModel, AutoModelForCausalLM, AutoTokenizer, PreTrainedModel
21
+ from transformers.utils import ModelOutput
22
+
23
+ from .configuration import OpenThaiSystemOneConfig
24
+ from .formatting import SPECIAL_TOKENS, TOK_ANSWER, TOK_POINT, QWEN_IMAGE_PAD, QWEN_VISION_END, QWEN_VISION_START, add_special_tokens
25
+
26
+ QTYPE_INDEX = {"choice": 0, "score": 1, "noul": 2, "point": 3}
27
+
28
+
29
+ @dataclass
30
+ class DecisionOutput(ModelOutput):
31
+ loss: Optional[torch.Tensor] = None
32
+ logits: Optional[torch.Tensor] = None # (B, Q, n_slots), masked with -inf
33
+ probs: Optional[torch.Tensor] = None # (B, Q, n_slots)
34
+ hidden_states: Optional[torch.Tensor] = None # (B, Q, H) at answer positions
35
+ point_logits: Optional[torch.Tensor] = None # (B, Q, L+1): per visual token of the referenced image + null; -inf elsewhere
36
+ point_probs: Optional[torch.Tensor] = None
37
+ slot_loss: Optional[torch.Tensor] = None
38
+ point_loss: Optional[torch.Tensor] = None
39
+
40
+
41
+ class PointHead(nn.Module):
42
+ """Attention readout over one image's visual tokens (final-layer LLM states).
43
+
44
+ q = MLP_T(h_answer), k_i = MLP_V(SA(v_i)), logits_i = q.k_i / sqrt(d) / T ; extra null key = "not on the image".
45
+ """
46
+
47
+ def __init__(self, hidden: int, dim: int = 256, n_sa_layers: int = 1, n_heads: int = 8):
48
+ super().__init__()
49
+ self.sa = nn.ModuleList(
50
+ [nn.TransformerEncoderLayer(hidden, n_heads, dim_feedforward=2 * hidden, dropout=0.0, batch_first=True, norm_first=True) for _ in range(n_sa_layers)]
51
+ )
52
+ self.q_proj = nn.Sequential(nn.Linear(hidden, dim), nn.GELU(), nn.Linear(dim, dim))
53
+ self.k_proj = nn.Sequential(nn.Linear(hidden, dim), nn.GELU(), nn.Linear(dim, dim))
54
+ self.null_key = nn.Parameter(torch.zeros(dim))
55
+ self.log_temperature = nn.Parameter(torch.zeros(()))
56
+ self.dim = dim
57
+
58
+ def forward(self, h_answer: torch.Tensor, visual: torch.Tensor, visual_mask: torch.Tensor) -> torch.Tensor:
59
+ """h_answer (N, H); visual (N, L, H) right-padded; visual_mask (N, L) True = real token. Returns (N, L+1) logits."""
60
+ x = visual
61
+ for layer in self.sa:
62
+ x = layer(x, src_key_padding_mask=~visual_mask)
63
+ k = self.k_proj(x) # (N, L, d)
64
+ q = self.q_proj(h_answer) # (N, d)
65
+ logits = torch.einsum("nd,nld->nl", q, k) / math.sqrt(self.dim)
66
+ null = (q @ self.null_key)[:, None] / math.sqrt(self.dim)
67
+ logits = torch.cat([logits, null], dim=1) / self.log_temperature.exp()
68
+ pad = torch.cat([~visual_mask, torch.zeros_like(visual_mask[:, :1])], dim=1)
69
+ return logits.masked_fill(pad, float("-inf"))
70
+
71
+
72
+ class OpenThaiSystemOneForDecision(PreTrainedModel):
73
+ config_class = OpenThaiSystemOneConfig
74
+ base_model_prefix = "model"
75
+ supports_gradient_checkpointing = True
76
+ _supports_flash_attn = True
77
+ _supports_sdpa = True
78
+
79
+ def __init__(self, config: OpenThaiSystemOneConfig):
80
+ super().__init__(config)
81
+ # Parameter layout is the SAME as the text-only line for everything they share: text tower `model.*`,
82
+ # `slot_head.*`, 3 `log_temperature`s. The vision parts sit beside it (`visual.*`, `point_head.*`), so a
83
+ # text-only v0.x client loading a -Vision checkpoint finds every weight it knows and ignores the rest.
84
+ self.model = AutoModel.from_config(config.text_config)
85
+ if config.is_vision:
86
+ self.visual = AutoModel.from_config(config.vision_config)
87
+ enable_vision_helper_caches()
88
+ ph = dict(config.point_head or {})
89
+ self.point_head = PointHead(config.hidden_size, ph.get("dim", 256), ph.get("n_sa_layers", 1), ph.get("n_heads", 8))
90
+ else:
91
+ self.visual = None
92
+ self.point_head = None
93
+ self.slot_head = nn.Linear(config.hidden_size, config.n_slots, bias=config.head_bias)
94
+ # log-temperatures per question type (choice/score/noul[/point]); learned in the calibration stage
95
+ self.log_temperature = nn.Parameter(torch.zeros(config.n_temperatures))
96
+ self.post_init()
97
+
98
+ @property
99
+ def language_model(self):
100
+ return self.model
101
+
102
+ def encode(self, input_ids, attention_mask=None, pixel_values=None, image_grid_thw=None, position_ids=None, **kwargs):
103
+ """Final hidden states. With images: ViT -> merger -> splice into the <|image_pad|> positions, 3-D (t,h,w) position
104
+ ids (computed here when not given), then the text tower. Without images: the plain text-tower forward."""
105
+ if pixel_values is None:
106
+ return self.model(input_ids=input_ids, attention_mask=attention_mask, position_ids=position_ids, **kwargs).last_hidden_state
107
+ if self.visual is None:
108
+ raise ValueError("this checkpoint is text-only; images need OpenThai-SystemOne-Vision")
109
+ _VISION_TARGET_DEVICE[0] = pixel_values.device
110
+ embeds = self.model.get_input_embeddings()(input_ids)
111
+ img = self.visual(pixel_values.to(self.visual.dtype), grid_thw=image_grid_thw).pooler_output
112
+ mask = (input_ids == self.config.image_token_id).unsqueeze(-1)
113
+ if int(mask.sum()) != img.shape[0]:
114
+ raise ValueError(f"image tokens ({int(mask.sum())}) != image features ({img.shape[0]})")
115
+ embeds = embeds.masked_scatter(mask.to(embeds.device), img.to(embeds.device, embeds.dtype))
116
+ if position_ids is None:
117
+ position_ids = mrope_positions_from_ids(input_ids, attention_mask, image_grid_thw, self.config.image_token_id,
118
+ int(getattr(self.config.vision_config, "spatial_merge_size", 2)))
119
+ return self.model(inputs_embeds=embeds, attention_mask=attention_mask, position_ids=position_ids.to(embeds.device), **kwargs).last_hidden_state
120
+
121
+ def get_input_embeddings(self):
122
+ return self.language_model.get_input_embeddings()
123
+
124
+ def set_input_embeddings(self, value):
125
+ self.language_model.set_input_embeddings(value)
126
+
127
+ # ------------------------------------------------------------------ construction
128
+ @classmethod
129
+ def from_causal_lm(
130
+ cls,
131
+ path: str,
132
+ *,
133
+ tokenizer=None,
134
+ n_slots: int = 256,
135
+ torch_dtype=torch.bfloat16,
136
+ **kwargs,
137
+ ):
138
+ """Build a text-only decision model from a (text-only) causal-LM checkpoint: drop lm_head, add tokens + head."""
139
+ tok = tokenizer or AutoTokenizer.from_pretrained(path)
140
+ added = add_special_tokens(tok)
141
+ lm = AutoModelForCausalLM.from_pretrained(path, dtype=torch_dtype, **kwargs)
142
+ base = lm.model if hasattr(lm, "model") else lm.base_model
143
+ text_cfg = base.config
144
+ if added:
145
+ lm.resize_token_embeddings(len(tok), mean_resizing=False)
146
+ text_cfg.vocab_size = lm.get_input_embeddings().weight.shape[0]
147
+ _init_new_token_embeddings(lm.get_input_embeddings().weight, tok, added)
148
+ cfg = OpenThaiSystemOneConfig(
149
+ text_config=text_cfg,
150
+ n_slots=n_slots,
151
+ answer_token_id=tok.convert_tokens_to_ids(TOK_ANSWER),
152
+ pad_token_id=tok.pad_token_id,
153
+ )
154
+ cfg.text_config.tie_word_embeddings = False # there is no LM head any more
155
+ model = cls(cfg).to(torch_dtype)
156
+ missing, unexpected = model.model.load_state_dict(base.state_dict(), strict=False)
157
+ assert not unexpected, unexpected
158
+ _init_slot_head(model.slot_head)
159
+ model.model.config = cfg.text_config
160
+ return model, tok
161
+
162
+ @classmethod
163
+ def from_text_decision_model(
164
+ cls,
165
+ text_path: str,
166
+ vision_source: str,
167
+ *,
168
+ tokenizer=None,
169
+ point_head: Optional[dict] = None,
170
+ torch_dtype=torch.bfloat16,
171
+ ):
172
+ """Build the -Vision model: text tower + slot head from a text-only decision checkpoint (e.g. v0.3), the ViT +
173
+ merger from a Qwen3.5 multimodal checkpoint (base/Qwen3.5-0.8B-Base), a fresh PointHead, and the two extra
174
+ control tokens. Text-only forward of the result equals the source model."""
175
+ import glob
176
+ import json
177
+ import os
178
+
179
+ from safetensors import safe_open
180
+ from transformers import AutoConfig
181
+
182
+ text_model = cls.from_pretrained(text_path, dtype=torch_dtype)
183
+ assert not text_model.config.is_vision, "source must be the text-only model"
184
+ tok = tokenizer or AutoTokenizer.from_pretrained(text_path)
185
+ added = add_special_tokens(tok, vision=True)
186
+ src_cfg = AutoConfig.from_pretrained(vision_source)
187
+ vcfg = src_cfg.vision_config
188
+ vcfg.out_hidden_size = text_model.config.hidden_size
189
+ cfg = OpenThaiSystemOneConfig(
190
+ text_config=text_model.config.text_config,
191
+ n_slots=text_model.config.n_slots,
192
+ abstain_slot=text_model.config.abstain_slot,
193
+ answer_token_id=text_model.config.answer_token_id,
194
+ head_bias=text_model.config.head_bias,
195
+ n_temperatures=text_model.config.n_temperatures,
196
+ vision_config=vcfg,
197
+ image_token_id=tok.convert_tokens_to_ids(QWEN_IMAGE_PAD),
198
+ vision_start_token_id=tok.convert_tokens_to_ids(QWEN_VISION_START),
199
+ vision_end_token_id=tok.convert_tokens_to_ids(QWEN_VISION_END),
200
+ point_token_id=tok.convert_tokens_to_ids(TOK_POINT),
201
+ point_head=point_head or {"dim": 256, "n_sa_layers": 1, "n_heads": 8},
202
+ pad_token_id=tok.pad_token_id,
203
+ )
204
+ cfg.text_config.vocab_size = len(tok)
205
+ model = cls(cfg).to(torch_dtype)
206
+ # text tower + heads
207
+ old_vocab = text_model.get_input_embeddings().weight.shape[0]
208
+ if len(tok) > old_vocab:
209
+ text_model.model.resize_token_embeddings(len(tok), mean_resizing=False)
210
+ _init_new_token_embeddings(text_model.get_input_embeddings().weight, tok, len(tok) - old_vocab)
211
+ missing, unexpected = model.model.load_state_dict(text_model.model.state_dict(), strict=False)
212
+ assert not unexpected and not missing, (missing, unexpected)
213
+ model.slot_head.load_state_dict(text_model.slot_head.state_dict())
214
+ with torch.no_grad():
215
+ n = min(text_model.log_temperature.numel(), model.log_temperature.numel())
216
+ model.log_temperature[:n] = text_model.log_temperature[:n]
217
+ # vision tower from the multimodal checkpoint
218
+ files = sorted(glob.glob(os.path.join(vision_source, "*.safetensors")))
219
+ sd = {}
220
+ for f in files:
221
+ with safe_open(f, "pt") as fh:
222
+ for k in fh.keys():
223
+ for prefix in ("model.visual.", "visual."):
224
+ if k.startswith(prefix):
225
+ sd[k[len(prefix):]] = fh.get_tensor(k)
226
+ break
227
+ missing, unexpected = model.visual.load_state_dict({k: v.to(torch_dtype) for k, v in sd.items()}, strict=False)
228
+ assert not unexpected and not missing, (missing, unexpected)
229
+ model.model.config = cfg.text_config
230
+ return model, tok
231
+
232
+ # ------------------------------------------------------------------ forward
233
+ def gather_answer_states(self, hidden: torch.Tensor, answer_positions: torch.Tensor) -> torch.Tensor:
234
+ idx = answer_positions.clamp(min=0).unsqueeze(-1).expand(-1, -1, hidden.shape[-1])
235
+ return torch.gather(hidden, 1, idx) # (B, Q, H)
236
+
237
+ def slot_logits(
238
+ self,
239
+ answer_hidden: torch.Tensor,
240
+ option_counts: torch.Tensor,
241
+ *,
242
+ include_abstain: bool = True,
243
+ qtypes: Optional[torch.Tensor] = None,
244
+ apply_temperature: bool = True,
245
+ ) -> torch.Tensor:
246
+ logits = self.slot_head(answer_hidden.to(self.slot_head.weight.dtype)).float()
247
+ if apply_temperature:
248
+ if qtypes is None:
249
+ t = self.log_temperature[0].exp()
250
+ else:
251
+ t = self.log_temperature.exp()[qtypes.clamp(min=0, max=self.log_temperature.numel() - 1)] # (B, Q)
252
+ t = t.unsqueeze(-1)
253
+ logits = logits / t
254
+ ar = torch.arange(logits.shape[-1], device=logits.device)
255
+ valid = ar[None, None, :] < option_counts.unsqueeze(-1)
256
+ if include_abstain:
257
+ valid = valid.clone()
258
+ valid[..., self.config.abstain_slot] = True
259
+ # questions that are padding or point questions (option_counts == 0) keep slot 0 valid to avoid NaNs
260
+ valid[..., 0] |= option_counts.unsqueeze(-1).squeeze(-1) == 0
261
+ return logits.masked_fill(~valid, float("-inf"))
262
+
263
+ def point_logits(
264
+ self,
265
+ hidden: torch.Tensor,
266
+ answer_hidden: torch.Tensor,
267
+ image_spans: torch.Tensor,
268
+ point_image_index: torch.Tensor,
269
+ *,
270
+ max_tokens: Optional[int] = None,
271
+ ) -> Optional[torch.Tensor]:
272
+ """(B, Q, L+1) logits for point questions (-inf rows elsewhere)."""
273
+ B, Q = point_image_index.shape
274
+ sel = (point_image_index >= 0).nonzero(as_tuple=False)
275
+ L = max_tokens or int((image_spans[..., 1] - image_spans[..., 0]).clamp(min=0).max().item())
276
+ out = torch.full((B, Q, L + 1), float("-inf"), device=hidden.device)
277
+ if sel.numel() == 0:
278
+ return out
279
+ n = sel.shape[0]
280
+ vis = hidden.new_zeros((n, L, hidden.shape[-1]))
281
+ mask = torch.zeros((n, L), dtype=torch.bool, device=hidden.device)
282
+ for r, (b, q) in enumerate(sel.tolist()):
283
+ s0, e0 = image_spans[b, point_image_index[b, q]].tolist()
284
+ vis[r, : e0 - s0] = hidden[b, s0:e0]
285
+ mask[r, : e0 - s0] = True
286
+ h = answer_hidden[sel[:, 0], sel[:, 1]]
287
+ pl = self.point_head(h.to(vis.dtype), vis, mask).float()
288
+ out[sel[:, 0], sel[:, 1]] = pl
289
+ return out
290
+
291
+ def forward(
292
+ self,
293
+ input_ids: torch.Tensor,
294
+ attention_mask: Optional[torch.Tensor] = None,
295
+ answer_positions: Optional[torch.Tensor] = None,
296
+ option_counts: Optional[torch.Tensor] = None,
297
+ labels: Optional[torch.Tensor] = None,
298
+ soft_labels: Optional[torch.Tensor] = None,
299
+ qtypes: Optional[torch.Tensor] = None,
300
+ include_abstain: bool = True,
301
+ label_smoothing: float = 0.0,
302
+ brier_weight: float = 0.0,
303
+ apply_temperature: bool = True,
304
+ pixel_values: Optional[torch.Tensor] = None,
305
+ image_grid_thw: Optional[torch.Tensor] = None,
306
+ mm_token_type_ids: Optional[torch.Tensor] = None,
307
+ image_spans: Optional[torch.Tensor] = None,
308
+ point_image_index: Optional[torch.Tensor] = None,
309
+ point_targets: Optional[torch.Tensor] = None,
310
+ point_has_target: Optional[torch.Tensor] = None,
311
+ point_loss_weight: float = 1.0,
312
+ position_ids: Optional[torch.Tensor] = None,
313
+ **kwargs,
314
+ ) -> DecisionOutput:
315
+ # position_ids (3, B, T) from formatting.mrope_position_ids avoid recomputing them; image_grid_thw may stay on the CPU
316
+ hidden = self.encode(input_ids, attention_mask=attention_mask, pixel_values=pixel_values, image_grid_thw=image_grid_thw,
317
+ position_ids=position_ids if pixel_values is not None else None, **kwargs)
318
+ if answer_positions is None:
319
+ answer_positions = (input_ids == self.config.answer_token_id).nonzero()[:, 1].unsqueeze(0)
320
+ if option_counts is None:
321
+ raise ValueError("option_counts required")
322
+ h = self.gather_answer_states(hidden, answer_positions)
323
+ logits = self.slot_logits(h, option_counts, include_abstain=include_abstain, qtypes=qtypes, apply_temperature=apply_temperature)
324
+ probs = logits.softmax(-1)
325
+
326
+ p_logits = p_probs = None
327
+ if self.point_head is not None and point_image_index is not None and (point_image_index >= 0).any():
328
+ p_logits = self.point_logits(hidden, h, image_spans, point_image_index,
329
+ max_tokens=(point_targets.shape[-1] - 1) if point_targets is not None else None)
330
+ p_probs = p_logits.softmax(-1)
331
+ p_probs = p_probs.masked_fill(torch.isinf(p_logits).all(-1, keepdim=True), 0.0)
332
+
333
+ loss = slot_loss = point_loss = None
334
+ if labels is not None or soft_labels is not None:
335
+ logp = logits.log_softmax(-1)
336
+ if soft_labels is not None:
337
+ valid = (option_counts > 0)
338
+ tgt = soft_labels.float()
339
+ nll = -(tgt * logp.masked_fill(torch.isinf(logp), 0.0)).sum(-1)
340
+ slot_loss = (nll * valid).sum() / valid.sum().clamp(min=1)
341
+ else:
342
+ flat_logp = logp.reshape(-1, logp.shape[-1])
343
+ flat_lab = labels.reshape(-1)
344
+ keep = flat_lab != -100
345
+ if keep.any():
346
+ lp = flat_logp[keep]
347
+ lb = flat_lab[keep]
348
+ nll = -lp.gather(1, lb[:, None]).squeeze(1)
349
+ if label_smoothing > 0:
350
+ n_valid = torch.isfinite(lp).sum(-1).clamp(min=1).float()
351
+ smooth = -(lp.masked_fill(torch.isinf(lp), 0.0)).sum(-1) / n_valid
352
+ nll = (1 - label_smoothing) * nll + label_smoothing * smooth
353
+ slot_loss = nll.mean()
354
+ if brier_weight > 0:
355
+ p = lp.exp()
356
+ onehot = F.one_hot(lb, p.shape[-1]).float()
357
+ slot_loss = slot_loss + brier_weight * ((p - onehot) ** 2).sum(-1).mean()
358
+ else:
359
+ slot_loss = logits.sum() * 0.0
360
+ loss = slot_loss
361
+ if p_logits is not None and point_targets is not None and point_has_target is not None and point_has_target.any():
362
+ lp = p_logits.log_softmax(-1).masked_fill(torch.isinf(p_logits), 0.0)
363
+ tgt = point_targets.float()
364
+ nll = -(tgt * lp).sum(-1) # cross-entropy against the soft mask (= KL up to the target entropy)
365
+ point_loss = (nll * point_has_target).sum() / point_has_target.sum().clamp(min=1)
366
+ loss = point_loss * point_loss_weight if loss is None else loss + point_loss_weight * point_loss
367
+ return DecisionOutput(loss=loss, logits=logits, probs=probs, hidden_states=h, point_logits=p_logits, point_probs=p_probs,
368
+ slot_loss=slot_loss, point_loss=point_loss)
369
+
370
+
371
+ _VISION_CACHE_ON = False
372
+ _VISION_TARGET_DEVICE = [None] # set by the forward: cached ViT helper outputs are moved here (lets image_grid_thw stay on the CPU)
373
+
374
+
375
+ def enable_vision_helper_caches(maxsize: int = 512):
376
+ """Memoise the Qwen3.5 ViT's per-call helpers by image grid.
377
+
378
+ `get_vision_interpolation_indices_and_weights`, `get_vision_position_ids` and `get_vision_attention_seqlens` rebuild
379
+ the same index tensors on every forward (tens of ms of small ops for a 320x240 frame). Their outputs depend only on
380
+ the grid (t, h, w) and the device, so they are cached; a Doom loop or a fixed-size screenshot stream hits the cache
381
+ every step. Idempotent; called automatically when a vision model is built.
382
+ """
383
+ global _VISION_CACHE_ON
384
+ if _VISION_CACHE_ON:
385
+ return
386
+ try:
387
+ from transformers.models.qwen3_5 import modeling_qwen3_5 as mq
388
+ except Exception: # pragma: no cover
389
+ return
390
+
391
+ def cached(fn, key_extra=lambda *a, **k: ()):
392
+ cache = {}
393
+
394
+ def wrapper(grid_thw, *args, **kwargs):
395
+ kw = {k: v for k, v in kwargs.items() if k != "kwargs"}
396
+ target = _VISION_TARGET_DEVICE[0] or grid_thw.device
397
+ key = (tuple(map(tuple, grid_thw.tolist())), str(target), tuple(sorted((k, str(v)) for k, v in kw.items())), tuple(str(a) for a in args))
398
+ hit = cache.get(key)
399
+ if hit is None:
400
+ hit = fn(grid_thw, *args, **kwargs)
401
+
402
+ def mv(x):
403
+ return x.to(target) if torch.is_tensor(x) else x
404
+
405
+ hit = tuple(mv(x) for x in hit) if isinstance(hit, tuple) else mv(hit)
406
+ if len(cache) >= maxsize:
407
+ cache.clear()
408
+ cache[key] = hit
409
+ return hit
410
+
411
+ wrapper.__wrapped__ = fn
412
+ return wrapper
413
+
414
+ for name in ("get_vision_interpolation_indices_and_weights", "get_vision_position_ids", "get_vision_attention_seqlens"):
415
+ fn = getattr(mq, name, None)
416
+ if fn is not None and not hasattr(fn, "__wrapped__"):
417
+ setattr(mq, name, cached(fn))
418
+ _VISION_CACHE_ON = True
419
+
420
+
421
+ def mrope_positions_from_ids(input_ids, attention_mask, image_grid_thw, image_token_id: int, merge: int):
422
+ """3-D position ids from token ids alone (same result as formatting.mrope_position_ids / Qwen's get_rope_index)."""
423
+ B, T = input_ids.shape
424
+ grids = [[int(v) for v in g] for g in image_grid_thw.tolist()]
425
+ ids = input_ids.tolist()
426
+ am = attention_mask.tolist() if attention_mask is not None else [[1] * T for _ in range(B)]
427
+ pos = torch.zeros((3, B, T), dtype=torch.long)
428
+ gi = 0
429
+ for b in range(B):
430
+ cur, i = 0, 0
431
+ n = sum(am[b])
432
+ while i < n:
433
+ if ids[b][i] == image_token_id:
434
+ t, h, w = grids[gi]; gi += 1
435
+ hm, wm = h // merge, w // merge
436
+ L = t * hm * wm
437
+ tt = torch.arange(t).view(t, 1, 1).expand(t, hm, wm).reshape(-1)
438
+ hh = torch.arange(hm).view(1, hm, 1).expand(t, hm, wm).reshape(-1)
439
+ ww = torch.arange(wm).view(1, 1, wm).expand(t, hm, wm).reshape(-1)
440
+ pos[0, b, i:i + L] = tt + cur; pos[1, b, i:i + L] = hh + cur; pos[2, b, i:i + L] = ww + cur
441
+ cur += max(hm, wm); i += L
442
+ else:
443
+ j = i
444
+ while j < n and ids[b][j] != image_token_id:
445
+ j += 1
446
+ pos[:, b, i:j] = torch.arange(j - i) + cur
447
+ cur += j - i; i = j
448
+ return pos
449
+
450
+
451
+ def convert_legacy_vision_state_dict(sd: dict) -> dict:
452
+ """Checkpoints trained before 2026-09-23 stored the vision model as a Qwen3_5Model (`model.language_model.*`,
453
+ `model.visual.*`, 4 temperatures). Map them onto the shared layout (`model.*`, `visual.*`, 3 temperatures)."""
454
+ out = {}
455
+ for k, v in sd.items():
456
+ if k.startswith("model.language_model."):
457
+ out["model." + k[len("model.language_model."):]] = v
458
+ elif k.startswith("model.visual."):
459
+ out["visual." + k[len("model.visual."):]] = v
460
+ elif k == "log_temperature" and v.numel() == 4:
461
+ out[k] = v[:3].clone()
462
+ else:
463
+ out[k] = v
464
+ return out
465
+
466
+
467
+ def use_reference_kernels():
468
+ """Force the pure-PyTorch Gated-DeltaNet / causal-conv paths.
469
+
470
+ transformers routes `chunk_gated_delta_rule` & co. to the Triton kernels (flash-linear-attention, causal-conv1d)
471
+ whenever those packages are importable, without checking the tensor device, which crashes on CPU/MPS.
472
+ Call this before running on a non-CUDA device.
473
+ """
474
+ try:
475
+ from transformers.models.qwen3_5 import modeling_qwen3_5 as m
476
+ except Exception: # pragma: no cover
477
+ return
478
+ for name in ("torch_chunk_gated_delta_rule", "torch_recurrent_gated_delta_rule", "chunk_gated_delta_rule",
479
+ "fused_recurrent_gated_delta_rule", "causal_conv1d_fn", "causal_conv1d_update"):
480
+ fn = getattr(m, name, None)
481
+ if fn is not None and hasattr(fn, "__wrapped__"):
482
+ setattr(m, name, fn.__wrapped__)
483
+
484
+
485
+ def _init_slot_head(head: nn.Linear):
486
+ nn.init.normal_(head.weight, std=0.02)
487
+ if head.bias is not None:
488
+ nn.init.zeros_(head.bias)
489
+
490
+
491
+ @torch.no_grad()
492
+ def _init_new_token_embeddings(weight: torch.Tensor, tok, n_added: int):
493
+ """New control tokens start near the mean of digit-token embeddings + small noise."""
494
+ digit_ids = [tok.convert_tokens_to_ids(d) for d in "0123456789"]
495
+ digit_ids = [i for i in digit_ids if i is not None and i != tok.unk_token_id]
496
+ mean = weight[digit_ids].float().mean(0) if digit_ids else weight[: weight.shape[0] - n_added].float().mean(0)
497
+ std = weight[: weight.shape[0] - n_added].float().std()
498
+ new = mean[None, :] + torch.randn(n_added, weight.shape[1]) * std * 0.1
499
+ weight[-n_added:] = new.to(weight.dtype)
500
+
501
+
502
+ def confidence_from_probs(p: torch.Tensor, k: int) -> float:
503
+ """1 - normalised entropy over the k valid options."""
504
+ if k <= 1:
505
+ return 1.0
506
+ p = p[:k].clamp(min=1e-12)
507
+ p = p / p.sum()
508
+ h = -(p * p.log()).sum().item()
509
+ return float(max(0.0, min(1.0, 1.0 - h / math.log(k))))
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d3400f1532d54f98d82b24c503e61908aa66f84e22e566e59fc80494add3a5c3
3
+ size 20039242
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": true,
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
+ }
types.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Request / response types.
2
+
3
+ Field names deliberately mirror the TypeSafe `POST /v1/systemone` contract so that
4
+ code written against the TypeSafe SDK can be pointed at OpenThai-SystemOne unchanged:
5
+
6
+ state : str | dict | list -- the thing to judge
7
+ questions : {id: Choice|Score|Noul} -- typed questions
8
+ answers : {id: ChoiceAnswer|ScoreAnswer|NoulAnswer}
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Literal, Optional, Union
13
+
14
+ from pydantic import BaseModel, Field, field_validator, model_validator
15
+
16
+ MAX_OPTIONS = 255 # single-stage cardinality limit (slots 0..254); slot 255 = abstain
17
+ MIN_SCORE_LEVELS = 2
18
+ MAX_SCORE_LEVELS = 10
19
+
20
+
21
+ class Noul(BaseModel):
22
+ """A yes/no question. Returns p(yes)."""
23
+
24
+ type: Literal["noul"] = "noul"
25
+ instructions: str
26
+ criteria: Optional[Dict[str, Optional[str]]] = None # {"true": "...", "false": "..."}
27
+
28
+ @field_validator("criteria")
29
+ @classmethod
30
+ def _check_criteria(cls, v):
31
+ if v is None:
32
+ return v
33
+ extra = set(v) - {"true", "false"}
34
+ if extra:
35
+ raise ValueError(f"noul criteria keys must be 'true'/'false', got {sorted(extra)}")
36
+ return v
37
+
38
+
39
+ class Choice(BaseModel):
40
+ """Pick one option. `criteria` maps option name -> description (or null)."""
41
+
42
+ type: Literal["choice"] = "choice"
43
+ instructions: str
44
+ criteria: Dict[str, Optional[str]]
45
+
46
+ @field_validator("criteria")
47
+ @classmethod
48
+ def _check_criteria(cls, v):
49
+ if len(v) < 1:
50
+ raise ValueError("choice needs at least one option")
51
+ if len(v) > MAX_OPTIONS:
52
+ raise ValueError(f"choice supports at most {MAX_OPTIONS} options in one stage")
53
+ for k in v:
54
+ if not str(k).strip():
55
+ raise ValueError("option names must be non-empty")
56
+ return v
57
+
58
+
59
+ class Score(BaseModel):
60
+ """Rate the state against ordered levels; `criteria[i]` describes level i (low -> high)."""
61
+
62
+ type: Literal["score"] = "score"
63
+ instructions: str
64
+ criteria: List[str]
65
+
66
+ @field_validator("criteria")
67
+ @classmethod
68
+ def _check_criteria(cls, v):
69
+ if not (MIN_SCORE_LEVELS <= len(v) <= MAX_SCORE_LEVELS):
70
+ raise ValueError(f"score needs {MIN_SCORE_LEVELS}..{MAX_SCORE_LEVELS} levels")
71
+ return v
72
+
73
+
74
+ class Point(BaseModel):
75
+ """Vision variant: locate what `instructions` describes on image `image` -> normalised (x, y). Answer may abstain."""
76
+
77
+ type: Literal["point"] = "point"
78
+ instructions: str
79
+ image: Optional[str] = None # id of one of the request's images; None = the first image
80
+
81
+
82
+ Question = Union[Noul, Choice, Score, Point]
83
+
84
+
85
+ class NoulAnswer(BaseModel):
86
+ type: Literal["noul"] = "noul"
87
+ noul: float
88
+
89
+
90
+ class ChoiceAnswer(BaseModel):
91
+ type: Literal["choice"] = "choice"
92
+ choice: str
93
+ probabilities: Dict[str, float]
94
+ confidence: float
95
+ abstain: Optional[float] = None # OpenThai extension: p(none of the options); not in TypeSafe
96
+
97
+
98
+ class ScoreAnswer(BaseModel):
99
+ type: Literal["score"] = "score"
100
+ score: float
101
+ legend: Dict[int, str]
102
+ probabilities: Dict[str, float]
103
+ confidence: float
104
+
105
+
106
+ class PointCandidate(BaseModel):
107
+ x: float
108
+ y: float
109
+ p: float
110
+ bbox: Optional[List[float]] = None # normalised [x0, y0, x1, y1] of the token cluster
111
+
112
+
113
+ class PointAnswer(BaseModel):
114
+ type: Literal["point"] = "point"
115
+ x: float
116
+ y: float
117
+ confidence: float # probability mass of the winning cluster
118
+ candidates: List[PointCandidate] = []
119
+ abstain: Optional[float] = None # p(target not on the image)
120
+ image: Optional[str] = None
121
+
122
+
123
+ Answer = Union[NoulAnswer, ChoiceAnswer, ScoreAnswer, PointAnswer]
124
+
125
+
126
+ class ImageInput(BaseModel):
127
+ """One image attached to the state (vision variant). Give exactly one of data / url / path."""
128
+
129
+ id: str = "img"
130
+ data: Optional[str] = None # data URI or base64
131
+ url: Optional[str] = None
132
+ path: Optional[str] = None # local file (SDK use only; the server ignores it)
133
+ role: Optional[str] = None # screenshot | frame | photo | document | ...
134
+ detail: Literal["auto", "high"] = "auto"
135
+
136
+ @model_validator(mode="after")
137
+ def _one_source(self):
138
+ if sum(v is not None for v in (self.data, self.url, self.path)) != 1:
139
+ raise ValueError("image needs exactly one of data / url / path")
140
+ return self
141
+
142
+ @property
143
+ def source(self) -> str:
144
+ return self.data or self.url or self.path # type: ignore[return-value]
145
+
146
+
147
+ class Usage(BaseModel):
148
+ input_tokens: int
149
+ output_tokens: int = 0
150
+ permutations: int = 1 # OpenThai extension: number of option orders averaged (order-invariant mode)
151
+
152
+
153
+ class SystemOneRequest(BaseModel):
154
+ state: Union[str, Dict[str, Any], List[Any]]
155
+ model: str = "openthai-systemone"
156
+ questions: Dict[str, Question] = Field(discriminator=None)
157
+ # OpenThai extensions. order_invariant=True averages the answer over several option orders (removes position
158
+ # bias, ~2x latency); None = automatic (on for choice questions with > 10 options); permutations overrides the count.
159
+ order_invariant: Optional[bool] = None
160
+ permutations: Optional[int] = Field(default=None, ge=1, le=32)
161
+ # vision variant: up to 4 images; text-only models reject requests that carry images or point questions.
162
+ images: Optional[List[ImageInput]] = None
163
+
164
+ @model_validator(mode="after")
165
+ def _non_empty(self):
166
+ if not self.questions:
167
+ raise ValueError("at least one question is required")
168
+ if self.images is not None:
169
+ if len(self.images) > 4:
170
+ raise ValueError("at most 4 images per request")
171
+ ids = [im.id for im in self.images]
172
+ if len(set(ids)) != len(ids):
173
+ raise ValueError("image ids must be unique")
174
+ for im in self.images:
175
+ if im.path is not None:
176
+ raise ValueError("image 'path' is not accepted over the API; send data or url")
177
+ for qid, q in self.questions.items():
178
+ if isinstance(q, Point):
179
+ if not self.images:
180
+ raise ValueError(f"point question {qid!r} needs at least one image")
181
+ if q.image is not None and q.image not in {im.id for im in self.images}:
182
+ raise ValueError(f"point question {qid!r} references unknown image {q.image!r}")
183
+ return self
184
+
185
+
186
+ class SystemOneResponse(BaseModel):
187
+ model: str
188
+ answers: Dict[str, Answer]
189
+ usage: Usage
190
+
191
+
192
+ def parse_question(obj: Union[Question, Dict[str, Any]]) -> Question:
193
+ """Accept a dict (raw JSON) or an already-typed question."""
194
+ if isinstance(obj, (Noul, Choice, Score, Point)):
195
+ return obj
196
+ t = obj.get("type")
197
+ if t == "noul":
198
+ return Noul(**obj)
199
+ if t == "choice":
200
+ return Choice(**obj)
201
+ if t == "score":
202
+ return Score(**obj)
203
+ if t == "point":
204
+ return Point(**obj)
205
+ raise ValueError(f"unknown question type: {t!r}")