multimodalart HF Staff commited on
Commit
85300f6
·
verified ·
1 Parent(s): 20049ad

Upload folder using huggingface_hub

Browse files
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ https://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "{}"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright {yyyy} {name of copyright owner}
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ https://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
NOTICE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Jev-Spatial — Apache License, Version 2.0
2
+
3
+ Based on Molmo2-ER and Molmo2 by the Allen Institute for AI and contributors.
4
+ Model: https://huggingface.co/allenai/Molmo2-ER
5
+ Source: https://github.com/allenai/molmo2
6
+ Pinned revision: dab22564403d2607855bb1fffb0721285b445081
7
+ Upstream license: Apache License, Version 2.0 (retained in LICENSE).
8
+
9
+ The five native Python modules under spatial_jev/molmo2 are unmodified copies
10
+ of the model/processor implementation at the pinned revision. Jev-Spatial adds
11
+ a shared classification head, hierarchical inference, training and evaluation
12
+ code. The released weights merge the trained LoRA into Molmo2-ER and omit the
13
+ unused autoregressive vocabulary head. Configuration metadata is adapted to
14
+ load the implementation from the installed source package.
15
+
16
+ Jev / System One by TypeSafe is the project's conceptual inspiration.
17
+ This is an independent project, not an official TypeSafe implementation or
18
+ a reproduction of proprietary RLCD. Full acknowledgements are in README.md.
19
+
20
+ Training and benchmark media are not included. Their original attribution
21
+ and terms remain with their respective authors and distributors.
README.md CHANGED
@@ -1,13 +1,32 @@
1
  ---
2
- title: Mjev Spatial
3
- emoji: 🔥
4
- colorFrom: indigo
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MJev-Spatial
3
+ emoji: 🧭
4
+ colorFrom: yellow
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.30.0
 
8
  app_file: app.py
9
+ short_description: Spatial VLM that decides in one forward pass
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # MJev-Spatial
15
+
16
+ Demo of **MJev-Spatial: System One Spatial Intelligence via Native Attention Matching**
17
+ ([arXiv 2610.22276](https://arxiv.org/abs/2610.22276) · [GitHub](https://github.com/Fr0zenCrane/MJev-Spatial)).
18
+
19
+ MJev-Spatial is a 4B multimodal decision model built on Qwen3.5-4B. It turns a spatial
20
+ question into a finite candidate-selection problem and scores those candidates in a
21
+ **single forward pass** with no generated answer tokens, reusing the language backbone's
22
+ final full-attention layer's native query/key projections as the readout.
23
+
24
+ The demo exposes the three answer spaces from the paper:
25
+
26
+ - **Choice** — pick one of the supplied options (e.g. relative-direction questions).
27
+ - **Scalar** — estimate a nonnegative measurement in meters via an interval hierarchy.
28
+ - **Pointing** — point at a requested target in the image; the predicted point is
29
+ drawn on the image (the model makes three 3×3 region decisions, refining a crop).
30
+
31
+ Example image: `examples/images/scene.png` from the official
32
+ [MJev-Spatial repository](https://github.com/Fr0zenCrane/MJev-Spatial) (Apache-2.0).
app.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MJev-Spatial — one-forward-pass spatial decision demo.
2
+
3
+ Model: https://huggingface.co/Fr0zenCr4nE/MJev-Spatial-Qwen-3.5-4B
4
+ Paper: MJev-Spatial: System One Spatial Intelligence via Native Attention Matching
5
+ (arXiv 2610.22276). Inference follows the official scripts/predict.py path:
6
+ the model scores a finite candidate set with the language backbone's native
7
+ final-attention Q/K projections and returns the argmax decision — no text is generated.
8
+ """
9
+
10
+ import os
11
+
12
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
13
+
14
+ import spaces # noqa: E402 -- MUST precede torch / CUDA-touching imports
15
+ import json # noqa: E402
16
+ import sys # noqa: E402
17
+ import tempfile # noqa: E402
18
+ import time # noqa: E402
19
+ from pathlib import Path # noqa: E402
20
+
21
+ import gradio as gr # noqa: E402
22
+ import numpy as np # noqa: E402
23
+ import torch # noqa: E402
24
+ from PIL import Image, ImageDraw # noqa: E402
25
+
26
+ ROOT = Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(ROOT / "src"))
28
+
29
+ # Vendored inference runtime (Apache-2.0) from github.com/Fr0zenCrane/MJev-Spatial
30
+ from spatial_jev.kev import KevBuilder, build_model, make_processor, predict_batch # noqa: E402
31
+ from spatial_jev.numeric_tasks import ANGLE_CODEBOOK, COUNT_CODEBOOK # noqa: E402
32
+
33
+ CHECKPOINT = "Fr0zencr4nE/MJev-Spatial-Qwen-3.5-4B"
34
+ MAX_SIDE = 512 # keep encode time and sequence length comfortable on ZeroGPU
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Model loading (module scope, eager .to("cuda") — intercepted by the spaces hijack)
38
+ # ---------------------------------------------------------------------------
39
+
40
+ ckpt_dir = CHECKPOINT # from_pretrained accepts repo ids directly
41
+ config = json.loads(Path(hf_hub_file(ckpt_dir, "config.json")).read_text()) if False else None
42
+
43
+
44
+ def hf_hub_file(repo_id, filename):
45
+ from huggingface_hub import hf_hub_download
46
+ return hf_hub_download(repo_id, filename)
47
+
48
+
49
+ config = json.loads(hf_hub_file(ckpt_dir, "config.json").read_text())
50
+ config.update(
51
+ preserve_option_order=True,
52
+ max_image_tokens=1024,
53
+ max_sequence_length=max(8192, int(config.get("max_sequence_length", 4096))),
54
+ inference_language_dtype="bf16",
55
+ )
56
+
57
+ print("[mjev] loading processor…", flush=True)
58
+ processor = make_processor(config)
59
+ print("[mjev] loading model…", flush=True)
60
+ builder = KevBuilder(processor, config)
61
+ MODEL = build_model(config, ckpt_dir, trainable=False)
62
+ MODEL = MODEL.to("cuda").eval()
63
+ print("[mjev] model ready", flush=True)
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Helpers
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def load_image(image, max_side=MAX_SIDE):
71
+ """PIL RGB image scaled so neon-style huge uploads don't blow the token budget."""
72
+ if image is None:
73
+ raise gr.Error("Please provide an image.")
74
+ img = image if isinstance(image, Image.Image) else Image.open(image)
75
+ img = img.convert("RGB")
76
+ w, h = img.size
77
+ scale = max_side / max(w, h)
78
+ if scale < 1:
79
+ img = img.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.Resampling.BILINEAR)
80
+ return img
81
+
82
+
83
+ def parse_options(text):
84
+ """One option per line; 'id | text' optional — falls back to the text as id."""
85
+ options = []
86
+ for line in (text or "").strip().splitlines():
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+ if "|" in line:
91
+ oid, otext = line.split("|", 1)
92
+ oid, otext = oid.strip(), otext.strip() or oid.strip()
93
+ else:
94
+ oid = otext = line
95
+ options.append({"id": oid, "text": otext})
96
+ if len(options) < 2:
97
+ raise gr.Error("Choice tasks need at least 2 options (one per line).")
98
+ if len(options) > config["max_choices"]:
99
+ raise gr.Error(f"Too many options — this checkpoint supports at most {config['max_choices']}.")
100
+ return options
101
+
102
+
103
+ def draw_point(image, xy):
104
+ """Overlay the predicted normalized point + crosshair on a copy of the image."""
105
+ img = image.copy()
106
+ w, h = img.size
107
+ x, y = xy[0] * w, xy[1] * h
108
+ draw = ImageDraw.Draw(img)
109
+ r = max(6, int(min(w, h) * 0.02))
110
+ for color, width in ((255, 255, 255, 255), (255, 70, 0, 255)):
111
+ draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=width)
112
+ draw.line([x - 1.6 * r, y, x + 1.6 * r, y], fill=color, width=width)
113
+ draw.line([x, y - 1.6 * r, x, y + 1.6 * r], fill=color, width=width)
114
+ return img
115
+
116
+
117
+ def overlay_probability_grid(image, result):
118
+ """Pointing: warm heatmap of the final-stage 9-way scores over the selected crop."""
119
+ if "logits" not in result or result.get("pointing_method") != "visual_tokens":
120
+ return None
121
+ probs = np.asarray(result.get("probabilities", []), dtype=np.float64)
122
+ if probs.size == 0:
123
+ return None
124
+ height, width = result.get("grid_hw", [1, 1])
125
+ if probs.shape != (height, width):
126
+ return None
127
+ heat = np.zeros((height, width, 4), dtype=np.uint8)
128
+ heat[..., 0] = 255
129
+ heat[..., 3] = (probs / probs.max() * 255).astype(np.uint8) if probs.max() > 0 else 0
130
+ heat_img = Image.fromarray(heat, "RGBA").resize(image.size, Image.Resampling.BILINEAR)
131
+ out = image.copy()
132
+ out.paste(heat_img, (0, 0), heat_img)
133
+ return out
134
+
135
+
136
+ def describe_path(result, kind, option_map=None):
137
+ """Human-readable trace of the decision path."""
138
+ lines = []
139
+ logits = result.get("logits") or []
140
+ path = result.get("path") or []
141
+ names = {
142
+ "top-center": "top-center", "top-left": "top-left", "top-right": "top-right",
143
+ "center": "center", "middle-left": "middle-left", "middle-right": "middle-right",
144
+ "bottom-center": "bottom-center", "bottom-left": "bottom-left",
145
+ "bottom-right": "bottom-right",
146
+ }
147
+ from spatial_jev.hierarchy import POINT_NAMES, decode_point, decode_scalar # local import
148
+ if kind == "choice":
149
+ if option_map:
150
+ lines.append(f"selected option: **{option_map[path[0]]}**" if path else "")
151
+ elif result.get("prediction") is not None:
152
+ lines.append(f"selected option: **{result['prediction']}**")
153
+ elif kind == "scalar":
154
+ if path:
155
+ edges = config["scalar_codebook"]["edges_m"]
156
+ if path[0] == 0:
157
+ lines.append("coarse stage: exactly zero")
158
+ elif path[0] == len(edges):
159
+ lines.append(f"coarse stage: greater than {edges[-1]:g} m (overflow)")
160
+ else:
161
+ from spatial_jev.hierarchy import scalar_interval, scalar_fine_bins
162
+ lo, hi = scalar_interval(path[0], config["scalar_codebook"])
163
+ lines.append(f"coarse stage: interval {lo:.6g} – {hi:.6g} m")
164
+ if len(path) > 1:
165
+ fine = scalar_fine_bins(path[0], config["scalar_codebook"])
166
+ stepw = (hi - lo) / fine
167
+ lines.append(f"fine stage: refined bin {path[1] + 1}/{fine} "
168
+ f"≈ {lo + path[1] * stepw:.6g} – {lo + (path[1] + 1) * stepw:.6g} m")
169
+ lines.append(f"decoded estimate: **{result['prediction']:.6g} m**")
170
+ elif kind == "point":
171
+ steps = ["whole image", *(f"zoom #{i}" for i in range(1, len(path)))]
172
+ for stage, (choice, scores) in enumerate(zip(path, logits)):
173
+ label = names.get(POINT_NAMES[choice], POINT_NAMES[choice])
174
+ best = scores[choice] if stage < len(scores) and choice < len(scores) else float("nan")
175
+ lines.append(f"stage {stage + 1} ({steps[stage] if stage < len(steps) else ''}): "
176
+ f"region **{label}** (score {best:.3f})")
177
+ if kind == "point" and result.get("prediction"):
178
+ x, y = result["prediction"]
179
+ lines.append(f"decoded point: **({x:.3f}, {y:.3f})** normalized "
180
+ f"(origin top-left, x→right, y→down)")
181
+ return "\n".join(f"- {l}" for l in lines if l)
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Inference endpoint
186
+ # ---------------------------------------------------------------------------
187
+
188
+ @spaces.GPU(duration=75)
189
+ def run_spatial(image, task, question, options_text, quantity, unit, progress=gr.Progress()):
190
+ """Answer a spatial question about an image in one forward pass per decision stage.
191
+
192
+ Args:
193
+ image: input image (PIL).
194
+ task: one of "choice" | "scalar" | "point".
195
+ question: the spatial question about the image.
196
+ options_text: for choice tasks, one option per line ("id | text" accepted).
197
+ quantity: for scalar tasks, what is being estimated (e.g. "distance").
198
+ unit: scalar unit — "m", "count" or "deg".
199
+ progress: Gradio progress bar.
200
+ Returns:
201
+ (answer markdown, decision-path markdown, annotated image, raw JSON result)
202
+ """
203
+ t0 = time.perf_counter()
204
+ img = load_image(image)
205
+ if not (question or "").strip():
206
+ raise gr.Error("Please provide a question.")
207
+
208
+ question = question.strip()
209
+ with tempfile.TemporaryDirectory() as tmp:
210
+ path = Path(tmp) / "input.png"
211
+ img.save(path)
212
+ if task == "choice":
213
+ space = {"kind": "choice", "options": parse_options(options_text)}
214
+ elif task == "scalar":
215
+ space = {"kind": "scalar", "quantity": (quantity or "value").strip() or "value",
216
+ "unit": unit}
217
+ else:
218
+ space = {"kind": "point", "coordinate_system": "normalized_xy", "num_points": 1}
219
+ request = {"id": "demo-request", "input": {
220
+ "media": [{"kind": "image", "uri": str(path)}],
221
+ "question": question,
222
+ "answer_space": space,
223
+ }}
224
+ result = predict_batch(MODEL, builder, [request], torch.device("cuda"))[0]
225
+
226
+ prediction = result["prediction"]
227
+ if task == "point":
228
+ point = prediction[0] if isinstance(prediction, list) else prediction
229
+ overlay = draw_point(img, point)
230
+ else:
231
+ overlay = img.copy()
232
+
233
+ if task == "choice":
234
+ answer = str(prediction)
235
+ elif task == "scalar":
236
+ label = {"m": "m", "count": "", "deg": "°"}[unit]
237
+ answer = f"{prediction:g}{label}" if unit != "count" else f"{int(prediction)} {quantity or 'objects'}"
238
+ else:
239
+ answer = f"point (x, y) = ({point[0]:.3f}, {point[1]:.3f})"
240
+
241
+ trace = describe_path(result, task)
242
+ seconds = time.perf_counter() - t0
243
+ summary = f"## {answer}\n\n*(one forward pass per decision stage, {seconds:.1f}s)*"
244
+ raw = json.dumps({"prediction": result.get("prediction"), "path": result.get("path"),
245
+ "logits": result.get("logits")}, ensure_ascii=False)
246
+ return summary, trace, overlay, raw
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # UI
251
+ # ---------------------------------------------------------------------------
252
+
253
+ CSS = """
254
+ #col-container { max-width: 1100px; margin: 0 auto; }
255
+ .dark .gradio-container { color: var(--body-text-color); }
256
+ """
257
+
258
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="MJev-Spatial") as demo:
259
+ with gr.Column(elem_id="col-container"):
260
+ gr.Markdown(
261
+ "# 🧭 MJev-Spatial\n"
262
+ "Spatial intelligence via **native attention matching** — a 4B VLM that "
263
+ "answers spatial questions as a single candidate-scoring decision, with no "
264
+ "generated answer tokens. [paper](https://arxiv.org/abs/2610.22276) · "
265
+ "[code](https://github.com/Fr0zenCrane/MJev-Spatial) · "
266
+ "[checkpoint](https://huggingface.co/Fr0zencr4nE/MJev-Spatial-Qwen-3.5-4B)"
267
+ )
268
+
269
+ with gr.Row():
270
+ with gr.Column(scale=1):
271
+ image_in = gr.Image(type="pil", label="Image", height=300)
272
+ task = gr.Radio(
273
+ ["choice", "scalar", "point"], value="choice", label="Answer space",
274
+ info="choice: pick an option · scalar: estimate a value · point: locate a target",
275
+ )
276
+ question = gr.Textbox(
277
+ label="Question", placeholder="e.g. Where is the red square relative to the blue circle?",
278
+ value="Where is the red square relative to the blue circle?",
279
+ )
280
+ options_text = gr.Textbox(
281
+ label="Options (choice only) — one per line, 'id | text' optional",
282
+ value="left | left\nright | right", lines=3,
283
+ )
284
+ quantity = gr.Textbox(
285
+ label="Quantity (scalar only)", value="distance",
286
+ info="What is being estimated, e.g. distance, width, height.",
287
+ )
288
+ unit = gr.Radio(["m", "count", "deg"], value="m", label="Unit (scalar only)")
289
+ run_btn = gr.Button("Decide", variant="primary")
290
+
291
+ with gr.Column(scale=1):
292
+ answer_md = gr.Markdown(label="Answer")
293
+ annotated = gr.Image(label="Predicted point (pointing tasks)", height=300)
294
+ trace_md = gr.Markdown("Decision path will appear here.")
295
+ with gr.Accordion("Raw result (JSON)", open=False):
296
+ raw_json = gr.JSON(label="scores / path")
297
+
298
+ gr.Examples(
299
+ examples=[
300
+ ["examples/images/scene.png", "choice",
301
+ "Where is the red square relative to the blue circle?",
302
+ "left | left\nright | right", "distance", "m"],
303
+ ["examples/images/scene.png", "scalar",
304
+ "What is the approximate distance between the two objects?",
305
+ "", "distance", "m"],
306
+ ["examples/images/scene.png", "point",
307
+ "Point to the blue circle.", "", "distance", "m"],
308
+ ],
309
+ inputs=[image_in, task, question, options_text, quantity, unit],
310
+ outputs=[answer_md, trace_md, annotated, raw_json],
311
+ fn=run_spatial,
312
+ label="Official examples (scene.png from the MJev-Spatial repo)",
313
+ cache_examples=True,
314
+ cache_mode="lazy",
315
+ )
316
+
317
+ gr.Markdown(
318
+ "Pointing decodes three 3×3 region decisions into a normalized point; the "
319
+ "score of each stage's picked region is shown in the decision path. "
320
+ "Scalar questions are answered through an interval hierarchy over meters "
321
+ "(or an integer count / degree codebook)."
322
+ )
323
+
324
+ run_btn.click(
325
+ run_spatial,
326
+ inputs=[image_in, task, question, options_text, quantity, unit],
327
+ outputs=[answer_md, trace_md, annotated, raw_json],
328
+ api_name="decide",
329
+ )
330
+
331
+ demo.launch(mcp_server=True)
examples/images/scene.png ADDED
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ transformers==5.17.0
2
+ accelerate
3
+ numpy
4
+ pillow
5
+ safetensors
6
+ sentencepiece
src/spatial_jev/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Jev-Spatial: a shared classifier for structured spatial decisions."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["JevSpatial"]
6
+
7
+
8
+ def __getattr__(name):
9
+ if name == "JevSpatial":
10
+ from .inference import JevSpatial
11
+ return JevSpatial
12
+ raise AttributeError(name)
src/spatial_jev/hierarchy.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic scalar/point classification codebooks and visual ROI geometry."""
2
+
3
+ import bisect
4
+ import math
5
+
6
+ import numpy as np
7
+
8
+ # User-specified order: 正上、左上、右上、正中、左中、右中、正下、左下、右下.
9
+ POINT_NAMES = ("top-center", "top-left", "top-right", "center", "middle-left",
10
+ "middle-right", "bottom-center", "bottom-left", "bottom-right")
11
+ POINT_RC = ((0, 1), (0, 0), (0, 2), (1, 1), (1, 0), (1, 2), (2, 1), (2, 0), (2, 2))
12
+ ROOT_BOX = (0.0, 0.0, 1.0, 1.0)
13
+
14
+
15
+ def child_box(box, choice):
16
+ if not 0 <= choice < 9:
17
+ raise ValueError("Invalid nine-way choice")
18
+ x0, y0, x1, y1 = box
19
+ row, col = POINT_RC[choice]
20
+ w, h = (x1 - x0) / 3, (y1 - y0) / 3
21
+ return (x0 + col * w, y0 + row * h, x0 + (col + 1) * w, y0 + (row + 1) * h)
22
+
23
+
24
+ def point_label(xy, box):
25
+ x0, y0, x1, y1 = box
26
+ x, y = xy
27
+ if not all(math.isfinite(v) for v in xy) or not (x0-1e-9 <= x <= x1+1e-9
28
+ and y0-1e-9 <= y <= y1+1e-9):
29
+ raise ValueError("Point outside parent")
30
+ col = min(2, max(0, math.floor((x - x0) / (x1 - x0) * 3 + 1e-12)))
31
+ row = min(2, max(0, math.floor((y - y0) / (y1 - y0) * 3 + 1e-12)))
32
+ return POINT_RC.index((row, col))
33
+
34
+
35
+ def point_path(xy, depth=3):
36
+ box, path = ROOT_BOX, []
37
+ for _ in range(depth):
38
+ choice = point_label(xy, box)
39
+ path.append(choice)
40
+ box = child_box(box, choice)
41
+ return path
42
+
43
+
44
+ def decode_point(path):
45
+ box = ROOT_BOX
46
+ for choice in path:
47
+ box = child_box(box, choice)
48
+ return [(box[0] + box[2]) / 2, (box[1] + box[3]) / 2]
49
+
50
+
51
+ def scalar_fine_bins(root_choice, codebook):
52
+ """Refinement cardinality is fixed by the selected root, never by the target."""
53
+ if not 1 <= root_choice < len(codebook["edges_m"]):
54
+ raise ValueError("Zero/overflow has no refinement candidates")
55
+ by_root = codebook.get("fine_bins_by_root")
56
+ if by_root is not None:
57
+ if len(by_root) != len(codebook["edges_m"]) - 1:
58
+ raise ValueError("Refinement counts must cover every positive root")
59
+ fine = by_root[root_choice - 1]
60
+ else:
61
+ fine = codebook["fine_bins"]
62
+ if type(fine) is not int or not 1 <= fine <= 32:
63
+ raise ValueError("Refinement counts must be integers in [1, 32]")
64
+ return fine
65
+
66
+
67
+ def scalar_path(value, codebook):
68
+ edges = codebook["edges_m"]
69
+ if not math.isfinite(value) or value < 0:
70
+ raise ValueError("Invalid nonnegative scalar")
71
+ if value == 0:
72
+ return [0]
73
+ if value > edges[-1]:
74
+ return [len(edges)] # Explicit overflow, never silently clipped.
75
+ bucket = min(bisect.bisect_right(edges, value) - 1, len(edges) - 2)
76
+ fine = scalar_fine_bins(bucket + 1, codebook)
77
+ lo, hi = edges[bucket:bucket+2]
78
+ local = min(fine - 1, max(0, int((value - lo) / (hi - lo) * fine)))
79
+ return [bucket + 1, local]
80
+
81
+
82
+ def scalar_interval(root_choice, codebook):
83
+ edges = codebook["edges_m"]
84
+ if not 1 <= root_choice < len(edges):
85
+ raise ValueError("Zero/overflow has no refinement interval")
86
+ return edges[root_choice - 1], edges[root_choice]
87
+
88
+
89
+ def decode_scalar(path, codebook):
90
+ if path[0] == 0:
91
+ return 0.0
92
+ if path[0] == len(codebook["edges_m"]):
93
+ return codebook["overflow_representative_m"]
94
+ fine = scalar_fine_bins(path[0], codebook)
95
+ if len(path) != 2 or not 0 <= path[1] < fine:
96
+ raise ValueError("Missing/invalid scalar refinement")
97
+ lo, hi = scalar_interval(path[0], codebook)
98
+ return lo + (path[1] + .5) * (hi - lo) / fine
99
+
100
+
101
+ def decode_scalar_expectation(root_choice, fine_logits, codebook):
102
+ """Conditional mean of fine-bin centers, T=1, within one argmax coarse root.
103
+
104
+ No averaging across coarse roots and no target-dependent decisions. Reserved
105
+ zero/overflow roots retain their existing exact representatives.
106
+ """
107
+ if root_choice in (0, len(codebook["edges_m"])):
108
+ if len(fine_logits):
109
+ raise ValueError("Zero/overflow must not have a second-stage distribution")
110
+ return decode_scalar([root_choice], codebook)
111
+ fine = scalar_fine_bins(root_choice, codebook)
112
+ logits = np.asarray(fine_logits, dtype=np.float64)
113
+ if logits.shape != (fine,) or not np.isfinite(logits).all():
114
+ raise ValueError("Expectation requires exactly the finite valid fine logits")
115
+ probabilities = np.exp(logits - logits.max())
116
+ probabilities /= probabilities.sum()
117
+ centers = np.asarray([decode_scalar([root_choice, i], codebook) for i in range(fine)])
118
+ return float(probabilities @ centers)
119
+
120
+
121
+ def visual_token_boxes(metadata, image_grids, image_num_crops, patches_per_crop):
122
+ """Map pooled tokens to normalized original-image support boxes, including padding.
123
+
124
+ Uses native subpatch_mapping (stretched stitched crop grid), not token-index slicing.
125
+ Each image's global crop and high-resolution crop mapping have their own geometry.
126
+ """
127
+ pooling = np.asarray(metadata["token_pooling"])
128
+ offset, token_offset, result = 0, 0, []
129
+ side = math.isqrt(patches_per_crop)
130
+ if side * side != patches_per_crop:
131
+ raise ValueError("Expected square ViT crop patch grid")
132
+ for image_i, (grid, crops) in enumerate(zip(image_grids, image_num_crops)):
133
+ mapping = np.asarray(metadata["subpatch_mapping"][image_i])
134
+ h, w = mapping.shape
135
+ patch_boxes = {}
136
+ for row in range(side):
137
+ for col in range(side):
138
+ patch_boxes[offset + row * side + col] = (
139
+ col / side, row / side, (col + 1) / side, (row + 1) / side)
140
+ for row in range(h):
141
+ for col in range(w):
142
+ patch_boxes[int(mapping[row, col])] = (col / w, row / h,
143
+ (col + 1) / w, (row + 1) / h)
144
+ count = int(grid[0] * grid[1] + grid[2] * grid[3])
145
+ for indices in pooling[token_offset:token_offset + count]:
146
+ boxes = [patch_boxes[int(i)] for i in indices if i >= 0]
147
+ if not boxes:
148
+ raise ValueError("Pooled visual token has no valid support")
149
+ result.append((image_i, min(b[0] for b in boxes), min(b[1] for b in boxes),
150
+ max(b[2] for b in boxes), max(b[3] for b in boxes)))
151
+ token_offset += count
152
+ offset += int(crops) * patches_per_crop
153
+ if token_offset != len(pooling):
154
+ raise ValueError("Unmapped visual tokens")
155
+ return np.array(result, dtype=np.float32)
156
+
157
+
158
+ def roi_visible(boxes, roi, image_index=0):
159
+ boxes = np.asarray(boxes)
160
+ x0, y0, x1, y1 = roi
161
+ return ((boxes[:, 0] == image_index) & (boxes[:, 1] < x1) & (boxes[:, 3] > x0)
162
+ & (boxes[:, 2] < y1) & (boxes[:, 4] > y0))
src/spatial_jev/kev.py ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal KEV decisions using the existing spatial schemas and controllers.
2
+
3
+ Pointer formula and delimiters follow jaredpalmer/kev@f1535963 (Apache-2.0).
4
+ Each original QA is a private causal branch, including its successive crops.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import random
11
+ import re
12
+ from contextlib import nullcontext
13
+ from functools import lru_cache
14
+ from pathlib import Path
15
+
16
+ import torch
17
+ from torch import nn
18
+
19
+ from .hierarchy import ROOT_BOX, child_box, decode_point, decode_scalar_expectation, point_path, scalar_fine_bins
20
+ from .numeric_tasks import ANGLE_CODEBOOK, COUNT_CODEBOOK, codebook_for, decode_numeric, numeric_done, numeric_path
21
+ from .qwen35 import LORA_TARGETS, configure_full_language, sample_seed, train_mode
22
+ from .qwen35 import make_processor as make_processor # noqa: PLC0414 -- explicit public re-export
23
+ from .roi_replay import (
24
+ EMPTY_ROI_IMAGE,
25
+ KV_REPLAY,
26
+ REPLAY_VARIANTS,
27
+ expand_replay_inputs,
28
+ kv_replay_hidden,
29
+ )
30
+ from .unified import UnifiedBuilder
31
+ from .visual_token_pointing import (
32
+ ORIGINAL_IMAGE,
33
+ VISUAL_TOKENS,
34
+ decode_visual_points,
35
+ image_token_candidates,
36
+ target_cells,
37
+ validate_pointing_config,
38
+ )
39
+
40
+ UPSTREAM_REVISION = "f1535963cea021439370c23127bc970b6788e730"
41
+ SPECIAL = ("<|fim_prefix|>", "<|fim_middle|>", "<|box_start|>", "<|box_end|>", "<|fim_suffix|>")
42
+ IMAGE = "<|vision_start|><|image_pad|><|vision_end|>"
43
+ CONTROL = re.compile(r"<\|([A-Za-z0-9_]+)\|>")
44
+
45
+
46
+ def escape(text):
47
+ return CONTROL.sub(r"<¦\1¦>", text)
48
+
49
+
50
+ @lru_cache(maxsize=100000)
51
+ def _file_hash(path, size, mtime_ns):
52
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
53
+
54
+
55
+ def media_key(media):
56
+ # Content identities, not image-pad token IDs or mutable paths, identify images.
57
+ path = Path(media["uri"])
58
+ stat = path.stat()
59
+ return _file_hash(str(path), stat.st_size, stat.st_mtime_ns)
60
+
61
+
62
+ def state_key(inp):
63
+ return tuple(media_key(m) for m in inp["media"])
64
+
65
+
66
+ def to_device(value, device):
67
+ if isinstance(value, torch.Tensor):
68
+ return value.to(device, non_blocking=True)
69
+ if isinstance(value, dict):
70
+ return {k: to_device(v, device) for k, v in value.items()}
71
+ return value
72
+
73
+
74
+ class PointerHead(nn.Module):
75
+ """KEV's affine query/key projections and scaled dot product, in FP32."""
76
+
77
+ def __init__(self, hidden_size, pointer_dim=256):
78
+ super().__init__()
79
+ self.q = nn.Linear(hidden_size, pointer_dim)
80
+ self.k = nn.Linear(hidden_size, pointer_dim)
81
+ self.scale = 1 / math.sqrt(pointer_dim)
82
+
83
+ def forward(self, decisions, options, owner, slot, counts):
84
+ with torch.autocast(device_type=decisions.device.type, enabled=False):
85
+ scores = (self.k(options.float()) * self.q(decisions.float())[owner]).sum(-1)
86
+ scores = scores * self.scale
87
+ logits = scores.new_full((len(decisions), int(counts.max())), -torch.inf)
88
+ return logits.index_put((owner, slot), scores)
89
+
90
+
91
+ class KevBuilder(UnifiedBuilder):
92
+ def __init__(self, processor, config):
93
+ validate_pointing_config(config)
94
+ if (config.get("numeric_codebooks") is not None
95
+ and config["numeric_codebooks"] != {"count": COUNT_CODEBOOK, "deg": ANGLE_CODEBOOK}):
96
+ raise ValueError("Checkpoint numeric codebooks differ from this implementation")
97
+ self.processor, self.config = processor, config
98
+ self.codebook, self.variant = config["scalar_codebook"], config.get("point_variant", "crop_refill")
99
+ if self.codebook.get("fine_bins_by_root") is not None:
100
+ for root in range(1, len(self.codebook["edges_m"])):
101
+ if scalar_fine_bins(root, self.codebook) > config["max_choices"]:
102
+ raise ValueError("Scalar refinement exceeds model candidate budget")
103
+ self.visual_token_pointing = self.variant == VISUAL_TOKENS
104
+ self.markers = [processor.tokenizer.convert_tokens_to_ids(t) for t in SPECIAL]
105
+ if len(set(self.markers)) != 5:
106
+ raise ValueError("KEV requires five distinct existing delimiter tokens")
107
+
108
+ def stage(self, inp, seed, box=ROOT_BOX, level=0, scalar_root=None):
109
+ task = inp["answer_space"]["kind"]
110
+ if task == "point" and self.visual_token_pointing:
111
+ instruction = f"Request: {inp['question']}\nLocate the requested target in the image."
112
+ # No artificial region options: the candidates are image-token positions.
113
+ return SPECIAL[1] + escape(instruction) + SPECIAL[4], 0, []
114
+ mapping = []
115
+ if task == "choice":
116
+ options = list(inp["answer_space"]["options"])
117
+ if not (self.config.get("preserve_option_order", False)
118
+ or inp["answer_space"].get("preserve_option_order", False)):
119
+ random.Random(seed).shuffle(options)
120
+ mapping = [o["id"] for o in options]
121
+ instruction = f"Question: {inp['question']}\nSelect exactly one option."
122
+ options = [o["text"] for o in options]
123
+ else:
124
+ # Use the existing authoritative spatial prompts, including interval semantics.
125
+ prompt = (
126
+ self.point_prompt(inp, box, level)
127
+ if task == "point"
128
+ else self.scalar_prompt(inp, scalar_root)[0]
129
+ )
130
+ instruction, tail = prompt.rsplit("\nOptions:\n", 1)
131
+ option_text, suffix = tail.rsplit("\nSelect exactly one option.", 1)
132
+ if suffix:
133
+ raise ValueError("Unexpected spatial prompt suffix")
134
+ options = [line.split(". ", 1)[1] for line in option_text.splitlines()]
135
+ instruction += "\nSelect exactly one option."
136
+ text = SPECIAL[1] + escape(instruction)
137
+ text += "".join(SPECIAL[2] + escape(o) + SPECIAL[3] for o in options)
138
+ return text + SPECIAL[4], len(options), mapping
139
+
140
+ def initial_spec(self, inp, identity):
141
+ if self.variant in (VISUAL_TOKENS, ORIGINAL_IMAGE, *REPLAY_VARIANTS) and inp["answer_space"]["kind"] == "point":
142
+ if len(inp["media"]) != 1 or inp["media"][0].get("kind", "image") != "image":
143
+ raise ValueError("Original-image / visual-token pointing requires exactly one still image")
144
+ if inp["answer_space"].get("coordinate_system", "normalized_xy") != "normalized_xy":
145
+ raise ValueError("Original-image / visual-token pointing requires normalized_xy coordinates")
146
+ text, count, mapping = self.stage(inp, sample_seed(identity, self.config["seed"]))
147
+ keys = state_key(inp)
148
+ return {
149
+ "text": SPECIAL[0] + IMAGE * len(keys) + text,
150
+ "images": self.load_images(inp),
151
+ "image_keys": list(keys),
152
+ "state_key": keys,
153
+ "sample_id": identity,
154
+ "counts": [count],
155
+ "mapping": mapping,
156
+ "box": ROOT_BOX,
157
+ "path": [],
158
+ "input": inp,
159
+ }
160
+
161
+ def advance(self, spec, choice):
162
+ inp, path = spec["input"], spec["path"]
163
+ task = inp["answer_space"]["kind"]
164
+ if task == "point" and self.visual_token_pointing:
165
+ raise ValueError("Visual-token pointing does not advance or crop")
166
+ extra = ""
167
+ if task == "point":
168
+ spec["box"] = child_box(spec["box"], choice)
169
+ if self.variant in REPLAY_VARIANTS:
170
+ spec.setdefault('roi_boxes', []).append(spec['box'])
171
+ extra = EMPTY_ROI_IMAGE
172
+ elif self.variant != ORIGINAL_IMAGE:
173
+ spec["images"].append(self.crop(spec["images"][0], spec["box"]))
174
+ spec["image_keys"].append(spec["state_key"][0] + ":crop:" + repr(spec["box"]))
175
+ extra = IMAGE
176
+ text, count, _ = self.stage(
177
+ inp,
178
+ self.config["seed"],
179
+ spec["box"],
180
+ len(path),
181
+ scalar_root=path[0] if task == "scalar" else None,
182
+ )
183
+ spec["text"] += f"Selected option: {choice + 1}." + extra + text
184
+ spec["counts"].append(count)
185
+
186
+ def training_spec(self, row):
187
+ spec = self.initial_spec(row["input"], row["sample_id"])
188
+ kind = row["input"]["answer_space"]["kind"]
189
+ target = row["target"]
190
+ if kind == "point" and self.visual_token_pointing:
191
+ # Labels stay out of text/images; cell indices depend on the processor's grid.
192
+ spec.update(target_points=target["points"], weight=row.get("_weight", 1.0))
193
+ return spec
194
+ path = (
195
+ [spec["mapping"].index(target["option_id"])]
196
+ if kind == "choice"
197
+ else numeric_path(target["value"], codebook_for(row["input"], self.codebook))
198
+ if kind == "scalar"
199
+ else point_path(target["points"][0])
200
+ )
201
+ for choice in path[:-1]:
202
+ spec["path"].append(choice)
203
+ self.advance(spec, choice)
204
+ spec.update(targets=path, weight=row.get("_weight", 1.0))
205
+ return spec
206
+
207
+ def encode_specs(self, specs, training=True):
208
+ encoded = dict(
209
+ self.processor(
210
+ text=[s["text"] for s in specs],
211
+ images=[im for s in specs for im in s["images"]],
212
+ padding=True,
213
+ add_special_tokens=False,
214
+ return_tensors="pt",
215
+ device="cpu",
216
+ )
217
+ )
218
+ encoded.pop("token_type_ids", None)
219
+ if self.variant in REPLAY_VARIANTS:
220
+ encoded = expand_replay_inputs(self, specs, encoded)
221
+ lengths = encoded["attention_mask"].sum(-1).tolist()
222
+ if max(lengths) > self.config["max_sequence_length"]:
223
+ raise ValueError(f"KEV sequence budget exceeded without truncation: {lengths}")
224
+ if "mm_token_type_ids" not in encoded:
225
+ encoded["mm_token_type_ids"] = (
226
+ encoded["input_ids"]
227
+ == self.processor.tokenizer.convert_tokens_to_ids("<|image_pad|>")
228
+ ).long()
229
+ positions, option_positions, owner, slot, counts, targets, weights = (
230
+ [],
231
+ [],
232
+ [],
233
+ [],
234
+ [],
235
+ [],
236
+ [],
237
+ )
238
+ prefix_lengths = []
239
+ image_cursor = 0
240
+ visual_owners, visual_slots, visual_mass = [], [], []
241
+ has_visual = False
242
+ for row, spec in enumerate(specs):
243
+ ids = encoded["input_ids"][row, : lengths[row]].tolist()
244
+ prefix_lengths.append(ids.index(self.markers[1]))
245
+ ends = [i for i, t in enumerate(ids) if t == self.markers[4]]
246
+ opt_ends = [i for i, t in enumerate(ids) if t == self.markers[3]]
247
+ first_grid = encoded["image_grid_thw"][image_cursor]
248
+ image_cursor += len(spec["images"])
249
+ if self.visual_token_pointing and spec["input"]["answer_space"]["kind"] == "point":
250
+ if len(ends) != 1 or opt_ends or len(spec["images"]) != 1:
251
+ raise ValueError("Visual pointing must have one query, one image and no text candidates")
252
+ candidates, shape = image_token_candidates(
253
+ ids, self.processor.tokenizer.convert_tokens_to_ids("<|image_pad|>"),
254
+ first_grid.tolist(), self.processor.image_processor.merge_size,
255
+ )
256
+ if candidates[-1] >= prefix_lengths[-1] or prefix_lengths[-1] >= ends[0]:
257
+ raise ValueError("Visual keys must precede the private question and query")
258
+ decision = len(positions)
259
+ count = len(candidates)
260
+ positions.append((row, ends[0]))
261
+ option_positions.extend((row, p) for p in candidates)
262
+ owner.extend([decision] * count)
263
+ slot.extend(range(count))
264
+ counts.append(count)
265
+ spec.update(counts=[count], visual_grid=shape)
266
+ has_visual = True
267
+ if training:
268
+ cells = target_cells(spec["target_points"], shape)
269
+ targets.append(cells[0])
270
+ weights.append(spec.get("weight", 1.0))
271
+ visual_owners.extend([decision] * len(cells))
272
+ visual_slots.extend(cells)
273
+ visual_mass.extend([1 / len(cells)] * len(cells))
274
+ continue
275
+ if len(ends) != len(spec["counts"]) or len(opt_ends) != sum(spec["counts"]):
276
+ raise ValueError(f"Decision/candidate markers mismatch: {spec['sample_id']}")
277
+ cursor = 0
278
+ for stage, (pos, count) in enumerate(zip(ends, spec["counts"])):
279
+ options = opt_ends[cursor : cursor + count]
280
+ cursor += count
281
+ if not all((ends[stage - 1] if stage else -1) < p < pos for p in options):
282
+ raise ValueError("Candidate belongs to a different decision stage")
283
+ if not training and stage != len(ends) - 1:
284
+ continue
285
+ owner.extend([len(positions)] * count)
286
+ slot.extend(range(count))
287
+ positions.append((row, pos))
288
+ option_positions.extend((row, p) for p in options)
289
+ counts.append(count)
290
+ if training:
291
+ targets.append(spec["targets"][stage])
292
+ weights.append(spec.get("weight", 1.0) / len(ends))
293
+ # One frozen vision forward per unique image/crop in this batch. Keep full grids
294
+ # for each row's native M-RoPE, and an explicit map back to each image occurrence.
295
+ keys = [key for s in specs for key in s["image_keys"]]
296
+ chunks = encoded["pixel_values"].split(encoded["image_grid_thw"].prod(-1).tolist())
297
+ unique, grids, pixels, image_owner = {}, [], [], []
298
+ for occurrence, (key, grid, pixel) in enumerate(
299
+ zip(keys, encoded["image_grid_thw"], chunks, strict=True)
300
+ ):
301
+ signature = (key, tuple(grid.tolist()))
302
+ if not self.config.get("deduplicate_vision", True):
303
+ signature = (*signature, occurrence)
304
+ if signature not in unique:
305
+ unique[signature] = len(grids)
306
+ grids.append(grid)
307
+ pixels.append(pixel)
308
+ image_owner.append(unique[signature])
309
+ encoded["pixel_values"] = torch.cat(pixels)
310
+ encoded["unique_grid_thw"] = torch.stack(grids)
311
+ encoded["image_owner"] = torch.tensor(image_owner)
312
+ packed = {
313
+ "inputs": encoded,
314
+ "positions": torch.tensor(positions),
315
+ "option_positions": torch.tensor(option_positions),
316
+ "owner": torch.tensor(owner),
317
+ "slot": torch.tensor(slot),
318
+ "counts": torch.tensor(counts),
319
+ "targets": torch.tensor(targets),
320
+ "weights": torch.tensor(weights),
321
+ "prefix_lengths": torch.tensor(prefix_lengths),
322
+ "lengths": torch.tensor(lengths),
323
+ "state_keys": [s["state_key"] for s in specs],
324
+ "image_keys": list(unique),
325
+ "qa_count": sum(s.get("weight", 1.0) for s in specs),
326
+ "tokens": sum(lengths),
327
+ "padded_tokens": encoded["input_ids"].numel(),
328
+ }
329
+ if self.variant == KV_REPLAY and 'roi_kv_sources' in encoded:
330
+ packed['roi_kv_copy'] = True
331
+ if has_visual:
332
+ packed["visual_pointing"] = True
333
+ if training:
334
+ packed.update(visual_target_owner=torch.tensor(visual_owners, dtype=torch.long),
335
+ visual_target_slot=torch.tensor(visual_slots, dtype=torch.long),
336
+ visual_target_mass=torch.tensor(visual_mass, dtype=torch.float32))
337
+ return packed
338
+
339
+ def collate(self, rows):
340
+ return self.encode_specs([self.training_spec(row) for row in rows])
341
+
342
+
343
+ class KevSpatialModel(nn.Module):
344
+ def __init__(self, backbone, pointer):
345
+ super().__init__()
346
+ self.backbone, self.pointer = backbone, pointer
347
+ self.language_fp32 = False
348
+
349
+ @property
350
+ def native(self):
351
+ return (
352
+ self.backbone.get_base_model()
353
+ if hasattr(self.backbone, "get_base_model")
354
+ else self.backbone
355
+ )
356
+
357
+ def train(self, mode=True):
358
+ super().train(mode)
359
+ self.native.visual.eval()
360
+ return self
361
+
362
+ def language_context(self, device_type):
363
+ return (
364
+ torch.autocast(device_type=device_type, enabled=False)
365
+ if self.language_fp32
366
+ else nullcontext()
367
+ )
368
+
369
+ def embeddings(self, inputs, features=None):
370
+ native = self.native
371
+ hidden = native.get_input_embeddings()(inputs["input_ids"])
372
+ if features is None:
373
+ with torch.no_grad():
374
+ features = native.get_image_features(
375
+ inputs["pixel_values"], inputs["unique_grid_thw"], return_dict=True
376
+ ).pooler_output
377
+ features = torch.cat([features[i] for i in inputs["image_owner"].tolist()]).to(hidden.dtype)
378
+ if 'roi_feature_sources' in inputs:
379
+ features = features.index_select(0, inputs['roi_feature_sources'])
380
+ mask, _ = native.get_placeholder_mask(inputs["input_ids"], hidden, image_features=features)
381
+ hidden = hidden.masked_scatter(mask, features)
382
+ positions, _ = native.get_rope_index(
383
+ inputs.get('roi_base_ids', inputs["input_ids"]),
384
+ inputs.get('roi_base_types', inputs["mm_token_type_ids"]),
385
+ image_grid_thw=inputs["image_grid_thw"],
386
+ attention_mask=inputs.get('roi_base_mask', inputs["attention_mask"]),
387
+ )
388
+ if 'roi_position_sources' in inputs:
389
+ positions = positions.gather(2, inputs['roi_position_sources'][None].expand(3, -1, -1))
390
+ return hidden, positions
391
+
392
+ def readout(self, hidden, packed, branch_only=False):
393
+ if self.pointer is None:
394
+ raise ValueError("Native QK requires multimodal positions; use model.forward")
395
+ p, o = packed["positions"], packed["option_positions"]
396
+ if branch_only:
397
+ p = p.clone()
398
+ o = o.clone()
399
+ p[:, 1] -= packed["prefix_lengths"][p[:, 0]]
400
+ o[:, 1] -= packed["prefix_lengths"][o[:, 0]]
401
+ return self.pointer(
402
+ hidden[p[:, 0], p[:, 1]],
403
+ hidden[o[:, 0], o[:, 1]],
404
+ packed["owner"],
405
+ packed["slot"],
406
+ packed["counts"],
407
+ )
408
+
409
+ def forward(self, packed, shared_prefix=False):
410
+ if packed.get("visual_pointing", False) and shared_prefix:
411
+ raise ValueError("Visual-token keys are in the prefix; use the full causal-row forward")
412
+ if self.pointer is None and shared_prefix:
413
+ raise ValueError("Native QK currently requires independent causal rows")
414
+ hidden, positions = self.embeddings(packed["inputs"])
415
+ with self.language_context(hidden.device.type):
416
+ if self.pointer is None and getattr(self.native.language_model.config, "kev_readout_layer_indices", None):
417
+ from .kev_multilayer_qk import multilayer_forward
418
+
419
+ return multilayer_forward(self.native.language_model, hidden, positions,
420
+ packed["inputs"]["attention_mask"], packed)
421
+ if packed.get('roi_kv_copy', False):
422
+ hidden = kv_replay_hidden(self.native.language_model, hidden, positions,
423
+ packed['inputs']['attention_mask'], packed['inputs']['roi_kv_sources'])
424
+ elif shared_prefix:
425
+ from .kev_prefix import shared_hidden
426
+
427
+ hidden = shared_hidden(self.native.language_model, hidden, positions, packed)
428
+ else:
429
+ hidden = self.native.language_model(
430
+ inputs_embeds=hidden,
431
+ position_ids=positions,
432
+ attention_mask=packed["inputs"]["attention_mask"],
433
+ use_cache=False,
434
+ ).last_hidden_state
435
+ if self.pointer is None:
436
+ lm = self.native.language_model
437
+ rope_positions = positions[1:] if positions.shape[0] == 4 else positions
438
+ return lm.native_qk(hidden, packed, lm.rotary_emb(hidden, rope_positions))
439
+ return self.readout(hidden, packed, branch_only=shared_prefix)
440
+
441
+
442
+ def build_model(config, checkpoint=None, trainable=True):
443
+ from transformers import Qwen3_5ForConditionalGeneration, Qwen3_5TextConfig, Qwen3_5TextModel
444
+
445
+ validate_pointing_config(config)
446
+ mode = train_mode(config)
447
+ kind = config.get("readout_kind", "added_pointer")
448
+ if kind not in (
449
+ "added_pointer",
450
+ "native_last_qk_mean",
451
+ "native_merged_qk256",
452
+ "native_last_qk_concat",
453
+ ):
454
+ raise ValueError(f"Unsupported KEV readout: {kind}")
455
+ native_qk = kind != "added_pointer"
456
+ fusion_layers = config.get("readout_layer_indices")
457
+ if fusion_layers is not None and (kind != "native_last_qk_mean"
458
+ or config.get("layer_fusion") != "equal_logits" or config.get("gradient_checkpointing", False)
459
+ or config.get("point_variant", "crop_refill") != "crop_refill"):
460
+ raise ValueError("Multilayer ablation fixes mean heads, equal logits, old pointing and no gradient checkpointing")
461
+ if checkpoint:
462
+ saved = json.loads((Path(checkpoint) / "config.json").read_text())
463
+ if train_mode(saved) != mode:
464
+ raise ValueError("Cannot load a KEV checkpoint with a different training mode")
465
+ if saved.get("readout_kind", "added_pointer") != kind:
466
+ raise ValueError("Cannot load a KEV checkpoint with a different readout")
467
+ if saved.get("readout_layer_indices") != fusion_layers or saved.get("layer_fusion") != config.get("layer_fusion"):
468
+ raise ValueError("Cannot load a checkpoint with a different multilayer readout")
469
+ if saved.get("point_variant", "crop_refill") != config.get("point_variant", "crop_refill"):
470
+ raise ValueError("Cannot load a checkpoint with a different pointing method")
471
+ if native_qk and (mode != "full_language" or config.get("shared_prefix", False)):
472
+ raise ValueError("Native QK requires full_language and independent causal rows")
473
+ full = Qwen3_5ForConditionalGeneration.from_pretrained(
474
+ config["base_model"],
475
+ local_files_only=config.get("local_files_only", False),
476
+ dtype=torch.bfloat16,
477
+ attn_implementation="sdpa",
478
+ )
479
+ backbone, hidden_size = full.model, full.config.text_config.hidden_size
480
+ del full
481
+ backbone.requires_grad_(False)
482
+ if mode == "full_language":
483
+ if checkpoint:
484
+ directory = Path(checkpoint) / "language_model"
485
+ text_config = Qwen3_5TextConfig.from_pretrained(
486
+ directory, local_files_only=config.get("local_files_only", False)
487
+ )
488
+ text_class = Qwen3_5TextModel
489
+ if native_qk:
490
+ if getattr(text_config, "kev_readout_kind", None) != kind:
491
+ raise ValueError("Language checkpoint readout marker disagrees with config")
492
+ if (getattr(text_config, "kev_readout_layer_indices", None) != fusion_layers
493
+ or getattr(text_config, "kev_layer_fusion", None) != config.get("layer_fusion")):
494
+ raise ValueError("Language checkpoint multilayer markers disagree with config")
495
+ if getattr(text_config, "kev_readout_fp32", False) != config.get(
496
+ "native_readout_fp32", False
497
+ ):
498
+ raise ValueError("Language checkpoint readout precision disagrees with config")
499
+ expected_layer = config.get(
500
+ "readout_layer_index", text_config.num_hidden_layers - 1
501
+ )
502
+ if (
503
+ getattr(
504
+ text_config, "kev_readout_layer_index", text_config.num_hidden_layers - 1
505
+ )
506
+ != expected_layer
507
+ ):
508
+ raise ValueError("Language checkpoint readout layer disagrees with config")
509
+ from .kev_native_qk import NativeQKTextModel
510
+
511
+ text_class = NativeQKTextModel
512
+ backbone.language_model, loading = text_class.from_pretrained(
513
+ directory,
514
+ config=text_config,
515
+ local_files_only=config.get("local_files_only", False),
516
+ dtype=torch.float32,
517
+ attn_implementation="sdpa",
518
+ output_loading_info=True,
519
+ )
520
+ if any(
521
+ loading.get(k)
522
+ for k in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
523
+ ):
524
+ raise ValueError(f"Incomplete language checkpoint load: {loading}")
525
+ backbone.config.text_config = backbone.language_model.config
526
+ elif native_qk:
527
+ from .kev_native_qk import prune_native_qk
528
+
529
+ prune_native_qk(backbone.language_model, kind, config.get("readout_layer_index"))
530
+ configure_full_language(backbone, trainable=trainable)
531
+ if native_qk:
532
+ if fusion_layers is not None:
533
+ from .kev_multilayer_qk import configure_multilayer
534
+
535
+ configure_multilayer(backbone.language_model, fusion_layers)
536
+ backbone.language_model.native_qk.readout_fp32 = config.get(
537
+ "native_readout_fp32", False
538
+ )
539
+ backbone.language_model.config.kev_readout_fp32 = config.get(
540
+ "native_readout_fp32", False
541
+ )
542
+ else:
543
+ from peft import LoraConfig, PeftModel, get_peft_model
544
+
545
+ if checkpoint:
546
+ backbone = PeftModel.from_pretrained(
547
+ backbone, str(Path(checkpoint) / "adapter"), is_trainable=trainable
548
+ )
549
+ else:
550
+ backbone = get_peft_model(
551
+ backbone,
552
+ LoraConfig(
553
+ r=config["lora_rank"],
554
+ lora_alpha=config["lora_alpha"],
555
+ lora_dropout=config["lora_dropout"],
556
+ target_modules=LORA_TARGETS,
557
+ bias="none",
558
+ ),
559
+ )
560
+ from .training_alignment import seed_head
561
+
562
+ pointer = (
563
+ None
564
+ if native_qk
565
+ else seed_head(config, lambda: PointerHead(hidden_size, config.get("pointer_dim", 256)))
566
+ )
567
+ if checkpoint and pointer is not None:
568
+ from safetensors.torch import load_file
569
+
570
+ pointer.load_state_dict(load_file(str(Path(checkpoint) / "pointer.safetensors")))
571
+ if trainable and config.get("gradient_checkpointing", False):
572
+ native = backbone.get_base_model() if mode == "lora" else backbone
573
+ native.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
574
+ model = KevSpatialModel(backbone, pointer)
575
+ inference_dtype = config.get("inference_language_dtype", "bf16")
576
+ if inference_dtype not in ("bf16", "fp32"):
577
+ raise ValueError("inference_language_dtype must be bf16 or fp32")
578
+ if inference_dtype == "fp32":
579
+ if trainable:
580
+ raise ValueError("FP32 language compute is an inference verification option")
581
+ model.native.language_model.float()
582
+ model.language_fp32 = True
583
+ if any(p.requires_grad for p in model.native.visual.parameters()):
584
+ raise ValueError("Vision and merger must remain frozen")
585
+ if trainable and mode == "full_language":
586
+ if not all(
587
+ p.requires_grad and p.dtype == torch.float32
588
+ for p in model.native.language_model.parameters()
589
+ ):
590
+ raise ValueError("Full language training requires every language parameter in FP32")
591
+ if any("lora_" in n for n, _ in model.named_parameters()):
592
+ raise ValueError("Full language training must not contain LoRA adapters")
593
+ if not trainable:
594
+ model.requires_grad_(False).eval()
595
+ return model
596
+
597
+
598
+ def save_checkpoint(model, processor, config, path, step):
599
+ from safetensors.torch import save_file
600
+
601
+ path = Path(path)
602
+ path.mkdir(parents=True, exist_ok=False)
603
+ mode = train_mode(config)
604
+ if mode == "full_language":
605
+ model.native.language_model.save_pretrained(
606
+ path / "language_model", safe_serialization=True, max_shard_size="4GB"
607
+ )
608
+ else:
609
+ model.backbone.save_pretrained(path / "adapter", safe_serialization=True)
610
+ if model.pointer is not None:
611
+ save_file(
612
+ {k: v.detach().cpu().contiguous() for k, v in model.pointer.state_dict().items()},
613
+ str(path / "pointer.safetensors"),
614
+ )
615
+ else:
616
+ metadata_path = path / "language_model/config.json"
617
+ metadata = json.loads(metadata_path.read_text())
618
+ metadata["architectures"] = ["NativeQKTextModel"]
619
+ metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
620
+ processor.save_pretrained(path / "processor")
621
+ (path / "config.json").write_text(json.dumps(config, indent=2) + "\n")
622
+ (path / "checkpoint.json").write_text(
623
+ json.dumps(
624
+ {
625
+ "step": step,
626
+ "format": "kev-spatial/v1",
627
+ "train_mode": mode,
628
+ "readout_kind": config.get("readout_kind", "added_pointer"),
629
+ "point_variant": config.get("point_variant", "crop_refill"),
630
+ "readout_layer_index": getattr(
631
+ model.native.language_model.config, "kev_readout_layer_index", None
632
+ ),
633
+ "readout_layer_indices": config.get("readout_layer_indices"),
634
+ "layer_fusion": config.get("layer_fusion"),
635
+ "checkpoint_policy": config.get("checkpoint_policy", "full"),
636
+ "upstream_revision": UPSTREAM_REVISION,
637
+ "base_revision": config["base_revision"],
638
+ },
639
+ indent=2,
640
+ )
641
+ + "\n"
642
+ )
643
+
644
+
645
+ def validate_checkpoint_parameters(model, config, checkpoint):
646
+ """Compare saved tensors shard by shard without loading a second full model."""
647
+ from safetensors import safe_open
648
+ from safetensors.torch import load_file
649
+
650
+ checkpoint = Path(checkpoint)
651
+ if train_mode(config) == "full_language":
652
+ loaded = model.native.language_model.state_dict()
653
+ seen = set()
654
+ for shard in sorted((checkpoint / "language_model").glob("*.safetensors")):
655
+ with safe_open(shard, framework="pt", device="cpu") as stream:
656
+ for name in stream.keys(): # noqa: SIM118 -- safetensors reader is not a mapping
657
+ if name in seen or name not in loaded:
658
+ raise ValueError(f"Unexpected checkpoint parameter: {name}")
659
+ tensor = stream.get_tensor(name)
660
+ if not torch.equal(tensor, loaded[name].detach().cpu()):
661
+ raise ValueError(f"Language checkpoint round trip differs: {name}")
662
+ seen.add(name)
663
+ if seen != loaded.keys():
664
+ raise ValueError("Language checkpoint is missing state tensors")
665
+ else:
666
+ from peft import get_peft_model_state_dict
667
+
668
+ saved = load_file(str(checkpoint / "adapter/adapter_model.safetensors"))
669
+ loaded = get_peft_model_state_dict(model.backbone)
670
+ if saved.keys() != loaded.keys() or any(
671
+ not torch.equal(saved[k], loaded[k].detach().cpu()) for k in saved
672
+ ):
673
+ raise ValueError("Adapter checkpoint round trip is not exact")
674
+ if model.pointer is None:
675
+ if (checkpoint / "pointer.safetensors").exists():
676
+ raise ValueError("Native QK checkpoint must not contain an added pointer")
677
+ return
678
+ head = load_file(str(checkpoint / "pointer.safetensors"))
679
+ loaded_head = model.pointer.state_dict()
680
+ if head.keys() != loaded_head.keys() or any(
681
+ not torch.equal(v, loaded_head[k].detach().cpu()) for k, v in head.items()
682
+ ):
683
+ raise ValueError("Pointer checkpoint round trip is not exact")
684
+
685
+
686
+ @torch.inference_mode()
687
+ def predict_batch(model, builder, requests, device, runtime=None, point_decoding=None, scalar_decoding="argmax"):
688
+ """Free rollout: only requests (no targets), with a private crop history per QA."""
689
+ visual = getattr(builder, "visual_token_pointing", False)
690
+ if scalar_decoding not in ("argmax", "fine_expectation"):
691
+ raise ValueError("Unknown scalar decoding mode")
692
+ if point_decoding is not None and (not visual or point_decoding not in ("argmax", "components")):
693
+ raise ValueError("point_decoding overrides require visual-token pointing")
694
+ if visual and runtime is not None:
695
+ raise ValueError("Visual-token pointing currently uses the uncached causal-row forward")
696
+ states, results = [], [None] * len(requests)
697
+ for index, request in enumerate(requests):
698
+ spec = builder.initial_spec(request["input"], request["id"])
699
+ spec.update(index=index, logits=[])
700
+ states.append(spec)
701
+ while states:
702
+ packed = to_device(builder.encode_specs(states, training=False), device)
703
+ logits = model(packed) if runtime is None else runtime(packed)
704
+ choices = logits.argmax(-1).tolist()
705
+ following = []
706
+ for state, choice, scores in zip(states, choices, logits.float().cpu()):
707
+ path, inp = state["path"], state["input"]
708
+ if visual and inp["answer_space"]["kind"] == "point":
709
+ values = scores[:state["counts"][-1]].tolist()
710
+ results[state["index"]] = {
711
+ **decode_visual_points(values, state["visual_grid"],
712
+ decoder=point_decoding or builder.config.get("point_decoder", "argmax"),
713
+ relative_threshold=builder.config.get("point_component_threshold", 0.1)),
714
+ "path": [], "logits": [values],
715
+ }
716
+ continue
717
+ path.append(choice)
718
+ state["logits"].append(scores[: state["counts"][-1]].tolist())
719
+ kind = inp["answer_space"]["kind"]
720
+ done = (
721
+ kind == "choice"
722
+ or (kind == "point" and len(path) == 3)
723
+ or (
724
+ kind == "scalar"
725
+ and numeric_done(path, codebook_for(inp, builder.codebook))
726
+ )
727
+ )
728
+ if done:
729
+ prediction = (
730
+ state["mapping"][choice]
731
+ if kind == "choice"
732
+ else decode_point(path)
733
+ if kind == "point"
734
+ else decode_numeric(path, codebook_for(inp, builder.codebook))
735
+ )
736
+ if (kind == "scalar" and inp["answer_space"].get("unit", "m") == "m"
737
+ and scalar_decoding == "fine_expectation"):
738
+ prediction = decode_scalar_expectation(
739
+ path[0], state["logits"][-1] if len(path) == 2 else [], builder.codebook)
740
+ results[state["index"]] = {
741
+ "prediction": prediction,
742
+ "path": path,
743
+ "logits": state["logits"],
744
+ }
745
+ else:
746
+ builder.advance(state, choice)
747
+ following.append(state)
748
+ states = following
749
+ return results
src/spatial_jev/kev_native_qk.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spatial decisions from the pretrained final attention's native Q/K scores.
2
+
3
+ Only existing parameters survive: the first N-1 layers and the last input norm,
4
+ Q-only rows, K projection and Q/K norms. No final model norm or learned pointer.
5
+ """
6
+
7
+ from contextlib import nullcontext
8
+
9
+ import torch
10
+ from torch import nn
11
+ from transformers import Qwen3_5TextModel
12
+ from transformers.models.qwen3_5.modeling_qwen3_5 import rotate_half
13
+
14
+ NATIVE_QK = "native_last_qk_mean"
15
+ MERGED_QK = "native_merged_qk256"
16
+ CONCAT_QK = "native_last_qk_concat"
17
+ READOUT_KINDS = (NATIVE_QK, MERGED_QK, CONCAT_QK)
18
+
19
+
20
+ class NativeQKReadout(nn.Module):
21
+ def __init__(self, layer, merge_heads=False, concat_heads=False):
22
+ super().__init__()
23
+ if merge_heads and concat_heads:
24
+ raise ValueError("Weight merging and feature concatenation are distinct readouts")
25
+ self.concat_heads = concat_heads
26
+ if layer.block_type != "full_attention":
27
+ raise ValueError("Native QK readout requires a final full-attention layer")
28
+ attention = layer.self_attn
29
+ if attention.q_proj.bias is not None or attention.k_proj.bias is not None:
30
+ raise ValueError("This readout expects the Qwen3.5 bias-free projections")
31
+ self.head_dim = attention.head_dim
32
+ self.groups = attention.num_key_value_groups
33
+ self.heads = attention.q_proj.out_features // (2 * self.head_dim)
34
+ self.kv_heads = self.heads // self.groups
35
+ self.scale = attention.scaling
36
+ self.readout_fp32 = False
37
+ self.input_layernorm = layer.input_layernorm
38
+ self.k_proj = attention.k_proj
39
+ self.q_norm, self.k_norm = attention.q_norm, attention.k_norm
40
+ # Q and output gate are interleaved PER HEAD, not two global halves.
41
+ weight = attention.q_proj.weight
42
+ selected = weight.view(self.heads, 2 * self.head_dim, -1)[:, : self.head_dim]
43
+ if merge_heads:
44
+ # Average PRETRAINED WEIGHTS in FP32, before applying the shared
45
+ # Q/K Norms. Averaging the four GQA keys equals averaging their
46
+ # sixteen aligned copies. No learned fusion or random initialization.
47
+ selected = selected.float().mean(dim=0)
48
+ key_weight = attention.k_proj.weight
49
+ merged_key = key_weight.view(self.kv_heads, self.head_dim, -1).float().mean(dim=0)
50
+ self.k_proj = nn.Linear(weight.shape[1], self.head_dim, bias=False, device="meta")
51
+ self.k_proj.weight = nn.Parameter(
52
+ merged_key.detach().clone(), requires_grad=key_weight.requires_grad
53
+ )
54
+ self.heads = self.kv_heads = self.groups = 1
55
+ self.q_proj = nn.Linear(
56
+ weight.shape[1], self.heads * self.head_dim, bias=False, device="meta"
57
+ )
58
+ self.q_proj.weight = nn.Parameter(
59
+ selected.reshape(self.heads * self.head_dim, -1).detach().clone(),
60
+ requires_grad=weight.requires_grad,
61
+ )
62
+
63
+ def readout_parameters(self):
64
+ """Merged projections and their norms; input_layernorm stays in backbone."""
65
+ for module in (self.q_proj, self.k_proj, self.q_norm, self.k_norm):
66
+ yield from module.parameters()
67
+
68
+ @staticmethod
69
+ def rotate(states, indices, cos, sin):
70
+ # Same partial RoPE as native apply_rotary_pos_emb; gather original
71
+ # multimodal positions rather than renumbering selected candidates.
72
+ cos = cos[indices[:, 0], indices[:, 1]][:, None]
73
+ sin = sin[indices[:, 0], indices[:, 1]][:, None]
74
+ width = cos.shape[-1]
75
+ rotated, unchanged = states[..., :width], states[..., width:]
76
+ return torch.cat((rotated * cos + rotate_half(rotated) * sin, unchanged), dim=-1)
77
+
78
+ def forward(self, hidden, packed, position_embeddings):
79
+ context = (
80
+ torch.autocast(device_type=hidden.device.type, enabled=False)
81
+ if self.readout_fp32
82
+ else nullcontext()
83
+ )
84
+ with context:
85
+ return self._scores(hidden, packed, position_embeddings)
86
+
87
+ def _scores(self, hidden, packed, position_embeddings):
88
+ p, o = packed["positions"], packed["option_positions"]
89
+ decisions, options = hidden[p[:, 0], p[:, 1]], hidden[o[:, 0], o[:, 1]]
90
+ if self.readout_fp32:
91
+ decisions, options = decisions.float(), options.float()
92
+ position_embeddings = tuple(p.float() for p in position_embeddings)
93
+ decisions = self.input_layernorm(decisions)
94
+ options = self.input_layernorm(options)
95
+ queries = self.q_norm(self.q_proj(decisions).view(-1, self.heads, self.head_dim))
96
+ keys = self.k_norm(self.k_proj(options).view(-1, self.kv_heads, self.head_dim))
97
+ queries = self.rotate(queries, p, *position_embeddings)
98
+ keys = self.rotate(keys, o, *position_embeddings)
99
+ with torch.autocast(device_type=hidden.device.type, enabled=False):
100
+ keys = keys.float().repeat_interleave(self.groups, dim=1)
101
+ if self.concat_heads:
102
+ # Retain all per-head Norm/RoPE operations, then concatenate
103
+ # channels on BOTH sides. Repeated GQA keys stay weight-tied.
104
+ # For H heads this is sqrt(H) times the mean-head logit,
105
+ # not cross-head mixing. No additional norm or learned scale.
106
+ query_features = queries.float().flatten(start_dim=1)
107
+ key_features = keys.flatten(start_dim=1)
108
+ scores = (query_features[packed["owner"]] * key_features).sum(-1)
109
+ scores = scores * (self.heads * self.head_dim) ** -0.5
110
+ else:
111
+ scores = (queries.float()[packed["owner"]] * keys).sum(-1) * self.scale
112
+ # Average logits, NOT per-head probabilities. Candidate masking
113
+ # is independent for every QA/stage; padded slots carry no mass.
114
+ scores = scores.mean(dim=-1)
115
+ logits = scores.new_full((len(p), int(packed["counts"].max())), -torch.inf)
116
+ return logits.index_put((packed["owner"], packed["slot"]), scores)
117
+
118
+
119
+ def prune_native_qk(language_model, kind=NATIVE_QK, layer_index=None):
120
+ if kind not in READOUT_KINDS:
121
+ raise ValueError(f"Unsupported native readout: {kind}")
122
+ if hasattr(language_model, "native_qk"):
123
+ raise ValueError("Native QK pruning was already applied")
124
+ layer_index = len(language_model.layers) - 1 if layer_index is None else layer_index
125
+ if not isinstance(layer_index, int) or not 0 <= layer_index < len(language_model.layers):
126
+ raise ValueError(f"Invalid readout layer index: {layer_index}")
127
+ last = language_model.layers[layer_index]
128
+ language_model.native_qk = NativeQKReadout(
129
+ last, merge_heads=kind == MERGED_QK, concat_heads=kind == CONCAT_QK
130
+ )
131
+ language_model.native_qk.readout_fp32 = getattr(
132
+ language_model.config, "kev_readout_fp32", False
133
+ )
134
+ language_model.layers = nn.ModuleList(list(language_model.layers[:layer_index]))
135
+ # Execute only the original layers BEFORE the selected full attention.
136
+ # Later layers cannot affect this readout and must not enter the optimizer.
137
+ language_model.norm = nn.Identity()
138
+ # Keep the original layer configuration for checkpoint reconstruction.
139
+ language_model.config.kev_readout_kind = kind
140
+ language_model.config.kev_readout_layer_index = layer_index
141
+ return language_model
142
+
143
+
144
+ class NativeQKTextModel(Qwen3_5TextModel):
145
+ """Explicit loader for the pruned checkpoint; never load it as an ordinary LM."""
146
+
147
+ def __init__(self, config):
148
+ if getattr(config, "kev_readout_kind", None) not in READOUT_KINDS:
149
+ raise ValueError("Missing native QK checkpoint architecture marker")
150
+ super().__init__(config)
151
+ prune_native_qk(
152
+ self,
153
+ config.kev_readout_kind,
154
+ getattr(config, "kev_readout_layer_index", config.num_hidden_layers - 1),
155
+ )
src/spatial_jev/kev_prefix.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Functional hybrid prefixes with native multimodal embeddings and 3-axis RoPE.
2
+
3
+ Prefix and the layer-wise training schedule are adapted from
4
+ jaredpalmer/kev@f1535963cea021439370c23127bc970b6788e730/kev/shared_prefix.py
5
+ (Apache-2.0; see third_party/kev/). Branches never mutate a saved prefix.
6
+ """
7
+
8
+ import os
9
+ from collections import OrderedDict
10
+
11
+ import torch
12
+ from torch.nn import functional as F
13
+ from torch.utils.checkpoint import checkpoint
14
+
15
+
16
+ class Prefix:
17
+ record_past = True
18
+
19
+ def __init__(self):
20
+ self.keys = self.values = self.conv = None
21
+ self.recurrent_states = {}
22
+
23
+ @property
24
+ def layers(self):
25
+ return self
26
+
27
+ def __getitem__(self, layer_idx):
28
+ return self
29
+
30
+ def has_previous_state(self, layer_idx=None, state_idx=0):
31
+ return self.conv is not None
32
+
33
+ def update_conv_state(self, conv_input, layer_idx, conv_kernel_size, **kwargs):
34
+ if self.conv is not None:
35
+ return torch.cat([self.conv, conv_input], -1)
36
+ self.conv = F.pad(conv_input, (max(0, conv_kernel_size - conv_input.shape[-1]), 0))[
37
+ ..., -conv_kernel_size:
38
+ ]
39
+ return conv_input
40
+
41
+ def update_recurrent_state(self, recurrent_state, layer_idx, **kwargs):
42
+ self.recurrent_states.setdefault(0, recurrent_state)
43
+ return recurrent_state
44
+
45
+ def update(self, keys, values, layer_idx, *args, **kwargs):
46
+ if self.keys is None:
47
+ self.keys, self.values = keys, values
48
+ return keys, values
49
+ return torch.cat([self.keys, keys], -2), torch.cat([self.values, values], -2)
50
+
51
+ def branch(self, owner):
52
+ other = Prefix()
53
+ pick = lambda t: None if t is None else t.index_select(0, owner)
54
+ other.keys, other.values, other.conv = pick(self.keys), pick(self.values), pick(self.conv)
55
+ other.recurrent_states = {k: pick(v) for k, v in self.recurrent_states.items()}
56
+ return other
57
+
58
+ def nbytes(self):
59
+ tensors = [self.keys, self.values, self.conv, *self.recurrent_states.values()]
60
+ return sum(t.numel() * t.element_size() for t in tensors if t is not None)
61
+
62
+ def compact(self):
63
+ # Cached slices must own their storage so byte accounting does not hide a
64
+ # complete prefill tensor behind a small convolution window or KV view.
65
+ self.keys = None if self.keys is None else self.keys.clone().contiguous()
66
+ self.values = None if self.values is None else self.values.clone().contiguous()
67
+ self.conv = None if self.conv is None else self.conv.clone().contiguous()
68
+ self.recurrent_states = {
69
+ k: v.clone().contiguous() for k, v in self.recurrent_states.items()
70
+ }
71
+ return self
72
+
73
+
74
+ def attention_mask(allow, hidden, implementation):
75
+ allow = allow[:, None]
76
+ if implementation == "sdpa":
77
+ return allow
78
+ return hidden.new_zeros(allow.shape).masked_fill(~allow, torch.finfo(hidden.dtype).min)
79
+
80
+
81
+ def split_rows(hidden, positions, packed):
82
+ prefixes = packed["prefix_lengths"].tolist()
83
+ lengths = packed["lengths"].tolist()
84
+ unique, first, owners = {}, [], []
85
+ for i, key in enumerate(packed["state_keys"]):
86
+ if key not in unique:
87
+ unique[key] = len(first)
88
+ first.append(i)
89
+ reference = first[unique[key]]
90
+ n = prefixes[i]
91
+ if n != prefixes[reference] or not torch.equal(
92
+ packed["inputs"]["input_ids"][i, :n], packed["inputs"]["input_ids"][reference, :n]
93
+ ):
94
+ raise ValueError("Same state identity compiled to different prefix tokens")
95
+ if not torch.equal(positions[:, i, :n], positions[:, reference, :n]):
96
+ raise ValueError("Same state identity compiled to different multimodal positions")
97
+ owners.append(unique[key])
98
+ ns, nb = max(prefixes), max(n - p for n, p in zip(lengths, prefixes))
99
+ s = hidden.new_zeros((len(first), ns, hidden.shape[-1]))
100
+ b = hidden.new_zeros((len(lengths), nb, hidden.shape[-1]))
101
+ sp = positions.new_zeros((3, len(first), ns))
102
+ bp = positions.new_zeros((3, len(lengths), nb))
103
+ sm = torch.zeros(s.shape[:2], dtype=torch.bool, device=hidden.device)
104
+ bm = torch.zeros(b.shape[:2], dtype=torch.bool, device=hidden.device)
105
+ for j, i in enumerate(first):
106
+ n = prefixes[i]
107
+ s[j, -n:], sp[:, j, -n:], sm[j, -n:] = hidden[i, :n], positions[:, i, :n], True
108
+ for i, (p, n) in enumerate(zip(prefixes, lengths)):
109
+ b[i, : n - p], bp[:, i, : n - p], bm[i, : n - p] = (
110
+ hidden[i, p:n],
111
+ positions[:, i, p:n],
112
+ True,
113
+ )
114
+ return s, b, sp, bp, sm, bm, torch.tensor(owners, device=hidden.device)
115
+
116
+
117
+ def masks(lm, s, b, sm, bm, owner):
118
+ ns, nb, device = s.shape[1], b.shape[1], s.device
119
+ causal = lambda n: torch.ones(n, n, dtype=torch.bool, device=device).tril()
120
+ eye = lambda n: torch.eye(n, dtype=torch.bool, device=device)
121
+ impl = lm.config._attn_implementation
122
+ s_allow = (causal(ns)[None] & sm[:, None, :]) | eye(ns)[None]
123
+ b_allow = torch.cat(
124
+ [
125
+ sm[owner][:, None, :].expand(-1, nb, -1),
126
+ (causal(nb)[None] & bm[:, None, :]) | eye(nb)[None],
127
+ ],
128
+ -1,
129
+ )
130
+ ms = {
131
+ "full_attention": None
132
+ if impl == "sdpa" and bool(sm.all())
133
+ else attention_mask(s_allow, s, impl),
134
+ "linear_attention": sm.to(s.dtype),
135
+ }
136
+ mb = {"full_attention": attention_mask(b_allow, b, impl), "linear_attention": bm.to(b.dtype)}
137
+ return ms, mb
138
+
139
+
140
+ def shared_hidden(lm, hidden, positions, packed):
141
+ s, b, sp, bp, sm, bm, owner = split_rows(hidden, positions, packed)
142
+ ms, mb = masks(lm, s, b, sm, bm, owner)
143
+ rs, rb = lm.rotary_emb(s, sp), lm.rotary_emb(b, bp)
144
+ for layer in lm.layers:
145
+ # Capture the current layer, never a late-bound loop variable in recomputation.
146
+ def step(s, b, layer=layer):
147
+ prefix = Prefix()
148
+ s = layer.forward(
149
+ s,
150
+ position_embeddings=rs,
151
+ attention_mask=ms[layer.block_type],
152
+ past_key_values=prefix,
153
+ )
154
+ b = layer.forward(
155
+ b,
156
+ position_embeddings=rb,
157
+ attention_mask=mb[layer.block_type],
158
+ past_key_values=prefix.branch(owner),
159
+ )
160
+ return s * sm[..., None], b
161
+
162
+ if lm.gradient_checkpointing and lm.training and torch.is_grad_enabled():
163
+ s, b = checkpoint(step, s, b, use_reentrant=False)
164
+ else:
165
+ s, b = step(s, b)
166
+ return lm.norm(b)
167
+
168
+
169
+ class ByteLRU:
170
+ def __init__(self, max_bytes):
171
+ self.max_bytes, self.bytes = max_bytes, 0
172
+ self.entries = OrderedDict()
173
+ self.hits = self.misses = 0
174
+
175
+ def get(self, key):
176
+ if key not in self.entries:
177
+ self.misses += 1
178
+ return None
179
+ self.hits += 1
180
+ self.entries.move_to_end(key)
181
+ return self.entries[key][0]
182
+
183
+ def put(self, key, value, size):
184
+ if size > self.max_bytes:
185
+ return
186
+ if key in self.entries:
187
+ self.bytes -= self.entries.pop(key)[1]
188
+ while self.entries and self.bytes + size > self.max_bytes:
189
+ self.bytes -= self.entries.popitem(last=False)[1][1]
190
+ self.entries[key] = (value, size)
191
+ self.bytes += size
192
+
193
+ def clear(self):
194
+ self.entries.clear()
195
+ self.bytes = 0
196
+
197
+
198
+ class KevRuntime:
199
+ """An immutable-model session with byte-bounded image and hybrid prefix caches.
200
+
201
+ Every round recomputes only the private question history from the original state.
202
+ All branches of one state are batched; attention/conv/recurrent prefixes are read-only.
203
+ """
204
+
205
+ def __init__(self, model, config, prefix_bytes=8 * 1024**3, image_bytes=2 * 1024**3,
206
+ enable_native_qk=False):
207
+ from .roi_replay import REPLAY_VARIANTS
208
+ if config.get('point_variant') in REPLAY_VARIANTS:
209
+ raise ValueError('ROI replay uses uncached full rows, not the persistent hybrid-prefix runtime')
210
+ if config.get("point_variant") == "visual_tokens":
211
+ raise ValueError("Visual-token pointing requires prefix keys; cached branch-only readout is unsupported")
212
+ if not enable_native_qk and config.get("readout_kind") in (
213
+ "native_last_qk_mean",
214
+ "native_merged_qk256",
215
+ "native_last_qk_concat",
216
+ ):
217
+ raise ValueError("Native QK currently uses the uncached causal-row forward")
218
+ if enable_native_qk and (config.get("readout_kind") != "native_last_qk_mean"
219
+ or config.get("readout_layer_indices") is not None
220
+ or config.get("gradient_checkpointing", False)):
221
+ raise ValueError("Experimental native cache requires single-layer mean and no checkpointing")
222
+ import hashlib
223
+ import json
224
+
225
+ self.model = model
226
+ self.signature = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
227
+ self.versions = tuple((p._version, p.dtype, p.device) for p in model.parameters())
228
+ self.prefix_cache, self.image_cache = ByteLRU(prefix_bytes), ByteLRU(image_bytes)
229
+
230
+ def clear(self):
231
+ self.prefix_cache.clear()
232
+ self.image_cache.clear()
233
+
234
+ @torch.inference_mode()
235
+ def __call__(self, packed):
236
+ if packed.get("visual_pointing", False):
237
+ raise ValueError("Visual-token pointing requires the uncached causal-row forward")
238
+ if self.model.training or self.versions != tuple(
239
+ (p._version, p.dtype, p.device) for p in self.model.parameters()
240
+ ):
241
+ raise ValueError("Create a fresh runtime after changing model weights or training mode")
242
+ model, inputs = self.model, packed["inputs"]
243
+ device_type = inputs["input_ids"].device.type
244
+ precision = (
245
+ torch.is_autocast_enabled(device_type),
246
+ str(torch.get_autocast_dtype(device_type)),
247
+ torch.get_float32_matmul_precision(),
248
+ os.environ.get("TRITON_F32_DEFAULT", "tf32"),
249
+ )
250
+ scope = (self.signature, precision)
251
+ features = [self.image_cache.get((scope, k)) for k in packed["image_keys"]]
252
+ missing = [i for i, f in enumerate(features) if f is None]
253
+ if missing:
254
+ chunks = inputs["pixel_values"].split(inputs["unique_grid_thw"].prod(-1).tolist())
255
+ fs = model.native.get_image_features(
256
+ torch.cat([chunks[i] for i in missing]),
257
+ inputs["unique_grid_thw"][missing],
258
+ return_dict=True,
259
+ ).pooler_output
260
+ for i, f in zip(missing, fs):
261
+ f = f.clone().contiguous()
262
+ features[i] = f
263
+ self.image_cache.put(
264
+ (scope, packed["image_keys"][i]), f, f.numel() * f.element_size()
265
+ )
266
+ hidden, positions = model.embeddings(inputs, features=features)
267
+ with model.language_context(hidden.device.type):
268
+ return self.language_forward(packed, hidden, positions, scope)
269
+
270
+ def language_forward(self, packed, hidden, positions, scope):
271
+ model, inputs = self.model, packed["inputs"]
272
+ s, b, sp, bp, sm, bm, owner = split_rows(hidden, positions, packed)
273
+ lm = model.native.language_model
274
+ result = torch.zeros_like(b)
275
+ for state in range(len(s)):
276
+ rows = (owner == state).nonzero().flatten()
277
+ first = int(rows[0])
278
+ ns = int(packed["prefix_lengths"][first])
279
+ key = (
280
+ scope,
281
+ packed["state_keys"][first],
282
+ str(hidden.dtype),
283
+ tuple(inputs["input_ids"][first, :ns].tolist()),
284
+ tuple(positions[:, first, :ns].flatten().tolist()),
285
+ )
286
+ entry = self.prefix_cache.get(key)
287
+ if entry is None:
288
+ hs = s[state : state + 1, -ns:]
289
+ rs = lm.rotary_emb(hs, sp[:, state : state + 1, -ns:])
290
+ entry = []
291
+ for layer in lm.layers:
292
+ prefix = Prefix()
293
+ hs = layer.forward(hs, position_embeddings=rs, past_key_values=prefix)
294
+ entry.append(prefix.compact())
295
+ self.prefix_cache.put(key, entry, sum(p.nbytes() for p in entry))
296
+ # Different states need not inherit the longest unrelated question's
297
+ # padding. Their final readouts only index these real branch lengths.
298
+ branch_length = int(bm[rows].sum(-1).max())
299
+ hb, mask = b[rows, :branch_length], bm[rows, :branch_length]
300
+ rb = lm.rotary_emb(hb, bp[:, rows, :branch_length])
301
+ owners = torch.zeros(len(rows), dtype=torch.long, device=hb.device)
302
+ _, mb = masks(
303
+ lm, s[state : state + 1, -ns:], hb, sm[state : state + 1, -ns:], mask, owners
304
+ )
305
+ for layer, prefix in zip(lm.layers, entry):
306
+ hb = layer.forward(
307
+ hb,
308
+ position_embeddings=rb,
309
+ attention_mask=mb[layer.block_type],
310
+ past_key_values=prefix.branch(owners),
311
+ )
312
+ result[rows, :branch_length] = lm.norm(hb)
313
+ if model.pointer is None:
314
+ # The cached image prefix is unchanged. Candidates and decisions are
315
+ # private text suffix tokens; retain their ORIGINAL M-RoPE coordinates
316
+ # while translating tensor indices into the returned suffix buffer.
317
+ selected = dict(packed)
318
+ for key in ("positions", "option_positions"):
319
+ indices = packed[key].clone()
320
+ indices[:, 1] -= packed["prefix_lengths"][indices[:, 0]]
321
+ if (indices[:, 1] < 0).any():
322
+ raise ValueError("Native suffix cache cannot read keys inside the prefix")
323
+ selected[key] = indices
324
+ return lm.native_qk(result, selected, lm.rotary_emb(result, bp))
325
+ return model.readout(result, packed, branch_only=True)
src/spatial_jev/numeric_tasks.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Input-selected numeric codebooks; never interpret counts or degrees as meters."""
2
+
3
+ import math
4
+
5
+ COUNT_CODEBOOK = {"version": "integer-32x32-v1", "unit": "count", "fine_bins": 32,
6
+ "maximum": 1023}
7
+ ANGLE_CODEBOOK = {"version": "degrees-30x32-v1", "unit": "deg", "fine_bins": 32,
8
+ "edges_m": list(range(0, 361, 30)), "overflow_representative_m": 360.0}
9
+ # edges_m is the historical controller's internal key, not the physical unit.
10
+ # Public answer spaces, prompts, validators and decoded units remain explicit.
11
+
12
+
13
+ def codebook_for(inp, metric):
14
+ unit = inp["answer_space"].get("unit", "m")
15
+ if unit == "m":
16
+ return metric
17
+ if unit == "count":
18
+ return COUNT_CODEBOOK
19
+ if unit == "deg":
20
+ return ANGLE_CODEBOOK
21
+ raise ValueError(f"Unsupported numeric unit: {unit}")
22
+
23
+
24
+ def numeric_path(value, codebook):
25
+ from .hierarchy import scalar_path
26
+ if codebook.get("unit", "m") != "count":
27
+ if codebook.get("unit") == "deg" and not 0 <= value <= 360:
28
+ raise ValueError("Angle outside [0, 360]")
29
+ return scalar_path(value, codebook)
30
+ if isinstance(value, bool) or not math.isfinite(value) or int(value) != value or not 0 <= value <= 1023:
31
+ raise ValueError("Count outside integer codebook [0, 1023]")
32
+ return [int(value) // 32, int(value) % 32]
33
+
34
+
35
+ def numeric_done(path, codebook):
36
+ return len(path) == 2 or (codebook.get("unit", "m") != "count" and path[0] in (0, len(codebook["edges_m"])))
37
+
38
+
39
+ def decode_numeric(path, codebook):
40
+ from .hierarchy import decode_scalar
41
+ if codebook.get("unit", "m") != "count":
42
+ return decode_scalar(path, codebook)
43
+ if len(path) != 2 or any(not 0 <= n < 32 for n in path):
44
+ raise ValueError("Invalid integer decision path")
45
+ return 32 * path[0] + path[1]
46
+
47
+
48
+ def numeric_prompt(inp, root, codebook):
49
+ """Non-metric prompts. Metric prompts intentionally keep their old exact bytes."""
50
+ if codebook["unit"] == "count":
51
+ options = ([f"count {32*i} through {32*i+31} (inclusive)" for i in range(32)]
52
+ if root is None else [f"count exactly {32*root+i}" for i in range(32)])
53
+ instruction = "Answer the counting question. Select the integer count or inclusive count range."
54
+ else:
55
+ from .hierarchy import scalar_interval
56
+ edges = codebook["edges_m"]
57
+ if root is None:
58
+ options = ["exactly zero degrees"]
59
+ options += [f"{lo:g} to {hi:g} degrees" for lo, hi in zip(edges, edges[1:])]
60
+ options[1] = "greater than zero and less than 30 degrees"
61
+ options += ["greater than 360 degrees (out of supported range)"]
62
+ else:
63
+ lo, hi = scalar_interval(root, codebook)
64
+ step = (hi - lo) / codebook["fine_bins"]
65
+ options = [f"{lo+i*step:.6g} to {lo+(i+1)*step:.6g} degrees" for i in range(32)]
66
+ instruction = ("Estimate the angle in degrees, preserving the reference frame and rotation "
67
+ "direction specified in the question. Ranges include the lower endpoint and "
68
+ "exclude the upper, except that 360 is included. Exact zero has its own class.")
69
+ return (f"Question: {inp['question']}\n{instruction}\nOptions:\n"
70
+ + "\n".join(f"{i+1}. {s}" for i, s in enumerate(options))
71
+ + "\nSelect exactly one option."), len(options)
src/spatial_jev/point_probability_map.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nine-way conditional trees and connected-component multi-point readout."""
2
+
3
+ import numpy as np
4
+
5
+ from .hierarchy import POINT_RC
6
+
7
+
8
+ def softmax9(logits):
9
+ values = np.asarray(logits, dtype=np.float64)
10
+ if values.shape != (9,) or not np.isfinite(values).all():
11
+ raise ValueError("Expected nine finite logits")
12
+ probabilities = np.exp(values - values.max())
13
+ return probabilities / probabilities.sum()
14
+
15
+
16
+ def grid_location(path):
17
+ row = col = 0
18
+ for choice in path:
19
+ dr, dc = POINT_RC[choice]
20
+ row, col = row * 3 + dr, col * 3 + dc
21
+ return row, col
22
+
23
+
24
+ def branch_spec(builder, parent, choice):
25
+ # Copy mutable histories, but share immutable PIL images and input metadata.
26
+ child = dict(parent)
27
+ for key in ("images", "image_keys", "counts", "mapping", "path"):
28
+ child[key] = list(parent[key])
29
+ if 'roi_boxes' in parent:
30
+ child['roi_boxes'] = list(parent['roi_boxes'])
31
+ child["path"].append(choice)
32
+ builder.advance(child, choice)
33
+ return child
34
+
35
+
36
+ def probability_map(builder, request, evaluate_nodes, mode="full", root_relative_cutoff=0.01):
37
+ """Three levels, 729 leaves; labels/masks/counts are not accepted as arguments.
38
+
39
+ root_pruned uses only the first decision to omit low-mass root subtrees.
40
+ Unvisited leaves remain explicitly unknown, with their missing mass reported.
41
+ No probability renormalization is applied after pruning.
42
+ """
43
+ if mode not in ("full", "root_pruned"):
44
+ raise ValueError("Unknown tree policy")
45
+ if not 0 <= root_relative_cutoff <= 1:
46
+ raise ValueError("Invalid root cutoff")
47
+ if request["input"]["answer_space"]["kind"] != "point":
48
+ raise ValueError("Point input required")
49
+ grid = np.zeros((27, 27), dtype=np.float64)
50
+ known = np.zeros((27, 27), dtype=bool)
51
+ nodes, pruned = [], []
52
+ frontier = [(builder.initial_spec(request["input"], request["id"]), 1.0)]
53
+ for depth in range(3):
54
+ logits = evaluate_nodes([state for state, _ in frontier])
55
+ if len(logits) != len(frontier):
56
+ raise ValueError("Node evaluator returned a different batch size")
57
+ following = []
58
+ for (state, mass), scores in zip(frontier, logits):
59
+ probabilities = softmax9(scores)
60
+ path = list(state["path"])
61
+ nodes.append({"path": path, "mass": mass, "logits": list(map(float, scores)),
62
+ "probabilities": probabilities.tolist()})
63
+ cutoff = probabilities.max() * root_relative_cutoff
64
+ for choice, probability in enumerate(probabilities):
65
+ child_path = [*path, choice]
66
+ child_mass = mass * float(probability)
67
+ if depth == 2:
68
+ row, col = grid_location(child_path)
69
+ if known[row, col]:
70
+ raise ValueError("Duplicate leaf location")
71
+ grid[row, col], known[row, col] = child_mass, True
72
+ elif depth == 0 and mode == "root_pruned" and probability < cutoff:
73
+ pruned.append({"path": child_path, "mass": child_mass})
74
+ else:
75
+ following.append((branch_spec(builder, state, choice), child_mass))
76
+ frontier = following
77
+ retained, omitted = float(grid.sum()), sum(r["mass"] for r in pruned)
78
+ if not np.isclose(retained + omitted, 1, atol=1e-12, rtol=0):
79
+ raise ValueError("Probability mass was lost or duplicated")
80
+ return {"probabilities": grid, "known": known, "nodes": nodes,
81
+ "evaluated_nodes": len(nodes), "evaluated_leaves": int(known.sum()),
82
+ "retained_mass": retained, "pruned_mass": omitted, "pruned_branches": pruned}
83
+
84
+
85
+ def component_points(probabilities, known=None, relative_threshold=0.1, connectivity=8):
86
+ """Pick each superlevel component's maximum cell, with deterministic tie breaks."""
87
+ grid = np.asarray(probabilities, dtype=np.float64)
88
+ if grid.ndim != 2 or not np.isfinite(grid).all() or (grid < 0).any():
89
+ raise ValueError("Invalid spatial probability grid")
90
+ if not 0 < relative_threshold <= 1 or connectivity not in (4, 8):
91
+ raise ValueError("Invalid connected-component settings")
92
+ known = np.ones_like(grid, dtype=bool) if known is None else np.asarray(known, dtype=bool)
93
+ if known.shape != grid.shape:
94
+ raise ValueError("Known-cell mask shape mismatch")
95
+ peak = float(grid[known].max()) if known.any() else 0.0
96
+ threshold = peak * relative_threshold
97
+ mask = known & (grid >= threshold) & (grid > 0)
98
+ visited = np.zeros_like(mask)
99
+ neighbors = [(-1, 0), (1, 0), (0, -1), (0, 1)]
100
+ if connectivity == 8:
101
+ neighbors += [(-1, -1), (-1, 1), (1, -1), (1, 1)]
102
+ height, width = grid.shape
103
+ components, points = [], []
104
+ for row, col in zip(*np.where(mask)):
105
+ if visited[row, col]:
106
+ continue
107
+ visited[row, col] = True
108
+ stack, cells = [(int(row), int(col))], []
109
+ while stack:
110
+ y, x = stack.pop()
111
+ cells.append((y, x))
112
+ for dy, dx in neighbors:
113
+ yy, xx = y + dy, x + dx
114
+ if 0 <= yy < height and 0 <= xx < width and mask[yy, xx] and not visited[yy, xx]:
115
+ visited[yy, xx] = True
116
+ stack.append((yy, xx))
117
+ # Prefer the highest probability, then topmost/leftmost on exact ties.
118
+ y, x = min(cells, key=lambda cell: (-grid[cell], cell[0], cell[1]))
119
+ point = [(x + 0.5) / width, (y + 0.5) / height]
120
+ points.append(point)
121
+ components.append({"cells": [list(cell) for cell in cells], "peak_cell": [y, x],
122
+ "peak_probability": float(grid[y, x]),
123
+ "mass": sum(float(grid[cell]) for cell in cells)})
124
+ return {"points": points, "components": components, "threshold": threshold,
125
+ "relative_threshold": relative_threshold, "connectivity": connectivity}
src/spatial_jev/qwen35.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen3.5 multimodal backbone with the existing spatial decision contract.
2
+
3
+ Each QA is one causal row, with crops revealed at their actual decision stage.
4
+ This baseline deliberately uses complete-prefix rollout before cache optimization.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ from torch import nn
13
+
14
+ from .hierarchy import ROOT_BOX, child_box, decode_point, decode_scalar, point_path, scalar_path
15
+ from .unified import SharedClassifier, UnifiedBuilder
16
+
17
+ LORA_TARGETS = (
18
+ r"language_model\.layers\.\d+\.(?:"
19
+ r"self_attn\.(?:q_proj|k_proj|v_proj|o_proj)|"
20
+ r"linear_attn\.(?:in_proj_qkv|in_proj_z|in_proj_a|in_proj_b|out_proj)|"
21
+ r"mlp\.(?:gate_proj|up_proj|down_proj))"
22
+ )
23
+
24
+
25
+ def sample_seed(sample_id, seed):
26
+ return seed ^ int(hashlib.sha256(sample_id.encode()).hexdigest()[:8], 16)
27
+
28
+
29
+ def train_mode(config):
30
+ mode = config.get("train_mode", "lora")
31
+ if mode not in ("lora", "full_language"):
32
+ raise ValueError(f"Unsupported train mode: {mode}")
33
+ return mode
34
+
35
+
36
+ def configure_full_language(backbone, trainable=True):
37
+ """Keep the original vision/merger frozen; FP32 master weights for full updates."""
38
+ backbone.requires_grad_(False)
39
+ backbone.language_model.float().requires_grad_(trainable)
40
+
41
+
42
+ def make_processor(config):
43
+ from transformers import AutoProcessor
44
+
45
+ processor = AutoProcessor.from_pretrained(
46
+ config["base_model"], local_files_only=config.get("local_files_only", False)
47
+ )
48
+ processor.tokenizer.padding_side = "right"
49
+ processor.image_processor.size = {
50
+ "shortest_edge": config["min_image_tokens"] * 32 * 32,
51
+ "longest_edge": config["max_image_tokens"] * 32 * 32,
52
+ }
53
+ return processor
54
+
55
+
56
+ class Qwen35SpatialModel(nn.Module):
57
+ def __init__(self, backbone, classifier):
58
+ super().__init__()
59
+ self.backbone, self.classifier = backbone, classifier
60
+
61
+ def forward(self, inputs, positions, counts):
62
+ out = self.backbone(**inputs, use_cache=False)
63
+ hidden = out.last_hidden_state[positions[:, 0], positions[:, 1]]
64
+ return self.classifier(hidden, counts)
65
+
66
+
67
+ def build_model(config, checkpoint=None, trainable=True):
68
+ from transformers import Qwen3_5ForConditionalGeneration, Qwen3_5TextConfig, Qwen3_5TextModel
69
+
70
+ mode = train_mode(config)
71
+ full = Qwen3_5ForConditionalGeneration.from_pretrained(
72
+ config["base_model"],
73
+ local_files_only=True,
74
+ dtype=torch.bfloat16,
75
+ attn_implementation="sdpa",
76
+ )
77
+ backbone, hidden = full.model, full.config.text_config.hidden_size
78
+ del full
79
+ backbone.requires_grad_(False)
80
+ if mode == "full_language":
81
+ if checkpoint:
82
+ text_directory = Path(checkpoint) / "language_model"
83
+ text_config = Qwen3_5TextConfig.from_pretrained(text_directory, local_files_only=True)
84
+ backbone.language_model = Qwen3_5TextModel.from_pretrained(
85
+ text_directory,
86
+ config=text_config,
87
+ local_files_only=True,
88
+ dtype=torch.float32,
89
+ attn_implementation="sdpa",
90
+ )
91
+ backbone.config.text_config = backbone.language_model.config
92
+ configure_full_language(backbone, trainable)
93
+ else:
94
+ from peft import LoraConfig, PeftModel, get_peft_model
95
+
96
+ if checkpoint:
97
+ backbone = PeftModel.from_pretrained(
98
+ backbone, str(Path(checkpoint) / "adapter"), is_trainable=trainable
99
+ )
100
+ else:
101
+ backbone = get_peft_model(
102
+ backbone,
103
+ LoraConfig(
104
+ r=config["lora_rank"],
105
+ lora_alpha=config["lora_alpha"],
106
+ lora_dropout=config["lora_dropout"],
107
+ target_modules=LORA_TARGETS,
108
+ bias="none",
109
+ ),
110
+ )
111
+ from .training_alignment import seed_head
112
+
113
+ classifier = seed_head(config, lambda: SharedClassifier(hidden, config["max_choices"]))
114
+ if checkpoint:
115
+ from safetensors.torch import load_file
116
+
117
+ classifier.load_state_dict(load_file(str(Path(checkpoint) / "classifier.safetensors")))
118
+ if trainable and config.get("gradient_checkpointing", False):
119
+ native = backbone.get_base_model() if mode == "lora" else backbone
120
+ native.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
121
+ model = Qwen35SpatialModel(backbone, classifier)
122
+ if any(
123
+ p.requires_grad
124
+ for n, p in backbone.named_parameters()
125
+ if n.startswith("visual.") or ".visual." in n
126
+ ):
127
+ raise ValueError("Vision encoder and merger must remain frozen")
128
+ if trainable and not any(
129
+ "linear_attn" in n and p.requires_grad for n, p in backbone.named_parameters()
130
+ ):
131
+ raise ValueError("DeltaNet must have trainable parameters")
132
+ if not trainable:
133
+ model.requires_grad_(False).eval()
134
+ return model
135
+
136
+
137
+ def move_inputs(inputs, device):
138
+ return {
139
+ key: value.to(
140
+ device,
141
+ dtype=torch.bfloat16 if value.is_floating_point() else value.dtype,
142
+ non_blocking=True,
143
+ )
144
+ for key, value in inputs.items()
145
+ }
146
+
147
+
148
+ class Qwen35Builder(UnifiedBuilder):
149
+ def __init__(self, processor, config):
150
+ self.processor, self.config = processor, config
151
+ self.codebook, self.variant = config["scalar_codebook"], "crop_refill"
152
+ rendered = self.render("Example question", 0)
153
+ suffix = rendered[rendered.rindex("<|im_start|>assistant") :]
154
+ self.header = processor.tokenizer.encode(suffix, add_special_tokens=False)
155
+
156
+ def render(self, text, image_count):
157
+ content = [{"type": "image"} for _ in range(image_count)]
158
+ content.append({"type": "text", "text": text})
159
+ return self.processor.apply_chat_template(
160
+ [{"role": "user", "content": content}],
161
+ tokenize=False,
162
+ add_generation_prompt=True,
163
+ enable_thinking=False,
164
+ )
165
+
166
+ def training_spec(self, row, seed):
167
+ inp, task = row["input"], row["input"]["answer_space"]["kind"]
168
+ text, count, mapping = self.initial(inp, seed)
169
+ images = self.load_images(inp)
170
+ original, box = images[0], ROOT_BOX
171
+ if task == "choice":
172
+ path = [mapping.index(row["target"]["option_id"])]
173
+ elif task == "scalar":
174
+ path = scalar_path(row["target"]["value"], self.codebook)
175
+ else:
176
+ path = point_path(row["target"]["points"][0])
177
+ counts = [count]
178
+ for stage in range(1, len(path)):
179
+ if task == "point":
180
+ box = child_box(box, path[stage - 1])
181
+ question, count = self.point_prompt(inp, box, stage), 9
182
+ extra = [self.crop(original, box)]
183
+ else:
184
+ question, count = self.scalar_prompt(inp, path[0])
185
+ extra = []
186
+ text += f"{path[stage - 1] + 1}<|im_end|>\n" + self.render(question, len(extra))
187
+ images.extend(extra)
188
+ counts.append(count)
189
+ return {
190
+ "text": text,
191
+ "images": images,
192
+ "counts": counts,
193
+ "targets": path,
194
+ "sample_id": row["sample_id"],
195
+ "weight": row.get("_weight", 1.0),
196
+ }
197
+
198
+ def encode_specs(self, specs, training=True):
199
+ encoded = dict(
200
+ self.processor(
201
+ text=[s["text"] for s in specs],
202
+ images=[im for s in specs for im in s["images"]],
203
+ padding=True,
204
+ return_tensors="pt",
205
+ device="cpu",
206
+ )
207
+ )
208
+ encoded.pop("token_type_ids", None)
209
+ lengths = encoded["attention_mask"].sum(-1).tolist()
210
+ if max(lengths) > self.config["max_sequence_length"]:
211
+ raise ValueError(f"Sequence budget exceeded without truncation: {lengths}")
212
+ positions, counts, targets, weights = [], [], [], []
213
+ if training:
214
+ for index, spec in enumerate(specs):
215
+ ids = encoded["input_ids"][index, : lengths[index]].tolist()
216
+ ends = [
217
+ i + len(self.header) - 1
218
+ for i in range(len(ids) - len(self.header) + 1)
219
+ if ids[i : i + len(self.header)] == self.header
220
+ ]
221
+ if len(ends) != len(spec["targets"]):
222
+ raise ValueError(f"Decision positions mismatch: {spec['sample_id']}")
223
+ positions.extend((index, p) for p in ends)
224
+ counts.extend(spec["counts"])
225
+ targets.extend(spec["targets"])
226
+ weights.extend([spec["weight"] / len(ends)] * len(ends))
227
+ else:
228
+ positions = [(i, n - 1) for i, n in enumerate(lengths)]
229
+ counts = [s["count"] for s in specs]
230
+ return {
231
+ "inputs": encoded,
232
+ "positions": torch.tensor(positions),
233
+ "counts": torch.tensor(counts),
234
+ "targets": torch.tensor(targets),
235
+ "weights": torch.tensor(weights, dtype=torch.float32),
236
+ "qa_count": sum(s.get("weight", 1.0) for s in specs),
237
+ "tokens": sum(lengths),
238
+ "padded_tokens": encoded["input_ids"].numel(),
239
+ }
240
+
241
+ def collate(self, rows):
242
+ return self.encode_specs(
243
+ [
244
+ self.training_spec(row, sample_seed(row["sample_id"], self.config["seed"]))
245
+ for row in rows
246
+ ]
247
+ )
248
+
249
+
250
+ @torch.inference_mode()
251
+ def predict_batch(model, builder, requests, device):
252
+ """Free rollout: this function accepts inputs and IDs, never target labels."""
253
+ states, results = [], [None] * len(requests)
254
+ for index, request in enumerate(requests):
255
+ inp = request["input"]
256
+ text, count, mapping = builder.initial(
257
+ inp, sample_seed(request["id"], builder.config["seed"])
258
+ )
259
+ states.append(
260
+ {
261
+ "index": index,
262
+ "input": inp,
263
+ "text": text,
264
+ "count": count,
265
+ "mapping": mapping,
266
+ "images": builder.load_images(inp),
267
+ "path": [],
268
+ "logits": [],
269
+ "box": ROOT_BOX,
270
+ }
271
+ )
272
+ while states:
273
+ packed = builder.encode_specs(states, training=False)
274
+ logits = model(
275
+ move_inputs(packed["inputs"], device),
276
+ packed["positions"].to(device),
277
+ packed["counts"].to(device),
278
+ )
279
+ choices = logits.argmax(-1).tolist()
280
+ following = []
281
+ for state, choice, scores in zip(states, choices, logits.float().cpu()):
282
+ path, inp = state["path"], state["input"]
283
+ path.append(choice)
284
+ state["logits"].append(scores[: state["count"]].tolist())
285
+ task = inp["answer_space"]["kind"]
286
+ done = (
287
+ task == "choice"
288
+ or (task == "point" and len(path) == 3)
289
+ or (
290
+ task == "scalar"
291
+ and (len(path) == 2 or choice in (0, len(builder.codebook["edges_m"])))
292
+ )
293
+ )
294
+ if done:
295
+ prediction = (
296
+ state["mapping"][choice]
297
+ if task == "choice"
298
+ else decode_point(path)
299
+ if task == "point"
300
+ else decode_scalar(path, builder.codebook)
301
+ )
302
+ results[state["index"]] = {
303
+ "prediction": prediction,
304
+ "path": path,
305
+ "logits": state["logits"],
306
+ }
307
+ continue
308
+ if task == "point":
309
+ state["box"] = child_box(state["box"], choice)
310
+ question, count = builder.point_prompt(inp, state["box"], len(path)), 9
311
+ extra = [builder.crop(state["images"][0], state["box"])]
312
+ else:
313
+ question, count = builder.scalar_prompt(inp, choice)
314
+ extra = []
315
+ state["text"] += f"{choice + 1}<|im_end|>\n" + builder.render(question, len(extra))
316
+ state["images"].extend(extra)
317
+ state["count"] = count
318
+ following.append(state)
319
+ states = following
320
+ return results
321
+
322
+
323
+ def save_checkpoint(model, processor, config, path, step):
324
+ from safetensors.torch import save_file
325
+
326
+ path = Path(path)
327
+ path.mkdir(parents=True, exist_ok=False)
328
+ mode = train_mode(config)
329
+ if mode == "full_language":
330
+ model.backbone.language_model.save_pretrained(
331
+ path / "language_model", safe_serialization=True, max_shard_size="4GB"
332
+ )
333
+ else:
334
+ model.backbone.save_pretrained(path / "adapter", safe_serialization=True)
335
+ save_file(
336
+ {k: v.detach().cpu().contiguous() for k, v in model.classifier.state_dict().items()},
337
+ str(path / "classifier.safetensors"),
338
+ )
339
+ processor.save_pretrained(path / "processor")
340
+ (path / "config.json").write_text(json.dumps(config, indent=2) + "\n")
341
+ (path / "checkpoint.json").write_text(
342
+ json.dumps(
343
+ {
344
+ "step": step,
345
+ "format": f"qwen35-{mode}-shared-classifier",
346
+ "train_mode": mode,
347
+ "base_revision": config["base_revision"],
348
+ },
349
+ indent=2,
350
+ )
351
+ + "\n"
352
+ )
src/spatial_jev/roi_replay.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local original-image token replay, optionally transplanting attention K/V.
2
+
3
+ No pixels are cropped/resized after the original image. Copy merged vision
4
+ tokens whose support intersects the current rectangle, in original row-major
5
+ order, retaining original M-RoPE coordinates. The KV variant also gathers the
6
+ original post-RoPE K and V at every intact full-attention layer. DeltaNet, Q,
7
+ gates, residuals and MLPs are computed normally; this is a representation ablation,
8
+ not a claim that all computations at replayed token slots have been eliminated.
9
+ """
10
+
11
+ import math
12
+
13
+ import torch
14
+
15
+ VIT_REPLAY = 'roi_vit_replay'
16
+ KV_REPLAY = 'roi_attention_kv_replay'
17
+ REPLAY_VARIANTS = (VIT_REPLAY, KV_REPLAY)
18
+ EMPTY_ROI_IMAGE = '<|vision_start|><|vision_end|>'
19
+
20
+
21
+ def roi_cells(box, shape):
22
+ """Positive-area overlap with a normalized rectangle, no target-dependent zoom."""
23
+ h, w = map(int, shape)
24
+ x0, y0, x1, y1 = box
25
+ if h < 1 or w < 1 or not (0 <= x0 < x1 <= 1 and 0 <= y0 < y1 <= 1):
26
+ raise ValueError('Invalid original-image ROI/grid')
27
+ # Avoid including a neighboring cell from floating-point noise at exact edges.
28
+ left, top = math.floor(x0*w+1e-9), math.floor(y0*h+1e-9)
29
+ right, bottom = math.ceil(x1*w-1e-9), math.ceil(y1*h-1e-9)
30
+ return [y*w+x for y in range(top, bottom) for x in range(left, right)]
31
+
32
+
33
+ def expand_replay_inputs(builder, specs, encoded):
34
+ """Insert virtual local-image slots after processing only the real original(s).
35
+
36
+ Base input tensors are retained solely for authoritative native M-RoPE creation;
37
+ gather maps align expanded slots, original visual features and original positions.
38
+ The ordinary causal mask still uses SEQUENCE order, not repeated spatial IDs.
39
+ """
40
+ if not any(s.get('roi_boxes') for s in specs):
41
+ return encoded
42
+ tokenizer = builder.processor.tokenizer
43
+ image_id, start_id, end_id = [tokenizer.convert_tokens_to_ids(s) for s in
44
+ ('<|image_pad|>', '<|vision_start|>', '<|vision_end|>')]
45
+ merge = builder.processor.image_processor.merge_size
46
+ original_ids, original_mask = encoded['input_ids'], encoded['attention_mask']
47
+ original_types = encoded.get('mm_token_type_ids', (original_ids == image_id).long())
48
+ new_ids, rope_sources, kv_sources, feature_sources = [], [], [], []
49
+ grid_cursor = feature_cursor = 0
50
+ for row, spec in enumerate(specs):
51
+ n = int(original_mask[row].sum())
52
+ ids = original_ids[row, :n].tolist()
53
+ grids = encoded['image_grid_thw'][grid_cursor:grid_cursor+len(spec['images'])]
54
+ n_features = sum(int(g.prod())//merge**2 for g in grids)
55
+ image_positions = [i for i, token in enumerate(ids) if token == image_id]
56
+ if len(image_positions) != n_features:
57
+ raise ValueError('Original visual token/grid count mismatch')
58
+ original_feature = {pos: feature_cursor+i for i, pos in enumerate(image_positions)}
59
+ boxes = spec.get('roi_boxes', [])
60
+ selected = []
61
+ if boxes:
62
+ if len(grids) != 1 or int(grids[0, 0]) != 1:
63
+ raise ValueError('ROI replay requires one still original image')
64
+ shape = (int(grids[0, 1])//merge, int(grids[0, 2])//merge)
65
+ selected = [roi_cells(box, shape) for box in boxes]
66
+ tokens, positions, aliases = [], [], []
67
+ slot = 0
68
+ for i, token in enumerate(ids):
69
+ if token == end_id and i and ids[i-1] == start_id:
70
+ if slot >= len(selected):
71
+ raise ValueError('Unregistered local-image slot')
72
+ for cell in selected[slot]:
73
+ source = image_positions[cell]
74
+ tokens.append(image_id)
75
+ positions.append(source)
76
+ # All originals precede every insertion, hence their sequence
77
+ # indices remain identical in the expanded row.
78
+ if source >= len(tokens)-1:
79
+ raise ValueError('Replay may only reference earlier original tokens')
80
+ aliases.append(source)
81
+ feature_sources.append(feature_cursor+cell)
82
+ slot += 1
83
+ tokens.append(token)
84
+ positions.append(i)
85
+ aliases.append(len(tokens)-1)
86
+ if token == image_id:
87
+ feature_sources.append(original_feature[i])
88
+ if slot != len(boxes):
89
+ raise ValueError('Missing local-image marker')
90
+ new_ids.append(tokens)
91
+ rope_sources.append(positions)
92
+ kv_sources.append(aliases)
93
+ grid_cursor += len(grids)
94
+ feature_cursor += n_features
95
+ length = max(map(len, new_ids))
96
+ if length > builder.config['max_sequence_length']:
97
+ raise ValueError('ROI replay exceeds sequence budget; refusing truncation')
98
+ batch = len(specs)
99
+ ids = original_ids.new_full((batch, length), tokenizer.pad_token_id)
100
+ mask = original_mask.new_zeros((batch, length))
101
+ rope = original_ids.new_zeros((batch, length))
102
+ aliases = torch.arange(length, dtype=original_ids.dtype).expand(batch, -1).clone()
103
+ for row, tokens in enumerate(new_ids):
104
+ n = len(tokens)
105
+ ids[row, :n] = torch.tensor(tokens)
106
+ mask[row, :n] = 1
107
+ rope[row, :n] = torch.tensor(rope_sources[row])
108
+ aliases[row, :n] = torch.tensor(kv_sources[row])
109
+ encoded.update(input_ids=ids, attention_mask=mask, mm_token_type_ids=(ids == image_id).long(),
110
+ roi_base_ids=original_ids, roi_base_mask=original_mask, roi_base_types=original_types,
111
+ roi_position_sources=rope, roi_kv_sources=aliases,
112
+ roi_feature_sources=torch.tensor(feature_sources, dtype=torch.long))
113
+ return encoded
114
+
115
+
116
+ class CopyAttentionKV:
117
+ """Stateless, differentiable aliasing at the native post-RoPE cache boundary."""
118
+
119
+ def __init__(self, sources):
120
+ self.sources = sources
121
+ self.calls = 0
122
+
123
+ def update(self, keys, values, layer_idx, *args, **kwargs):
124
+ if keys.shape[:3] != values.shape[:3] or keys.shape[0] != self.sources.shape[0] or keys.shape[2] != self.sources.shape[1]:
125
+ raise ValueError('KV replay shape mismatch')
126
+ self.calls += 1
127
+ def gather(tensor):
128
+ indices = self.sources[:, None, :, None].expand(-1, tensor.shape[1], -1, tensor.shape[-1])
129
+ return tensor.gather(2, indices)
130
+ return gather(keys), gather(values)
131
+
132
+
133
+ def kv_replay_hidden(lm, hidden, positions, attention_mask, sources):
134
+ """Scoped native-attention hooks; never modify recurrent/conv states or weights."""
135
+ if lm.gradient_checkpointing:
136
+ raise ValueError('Scoped KV replay requires gradient_checkpointing=False')
137
+ cache, handles, count = CopyAttentionKV(sources), [], 0
138
+ def inject(module, args, kwargs):
139
+ if kwargs.get('past_key_values') is not None:
140
+ raise ValueError('ROI replay must not mix with a persistent prefix cache')
141
+ return args, {**kwargs, 'past_key_values': cache}
142
+ try:
143
+ for layer in lm.layers:
144
+ if layer.block_type == 'full_attention':
145
+ count += 1
146
+ handles.append(layer.self_attn.register_forward_pre_hook(inject, with_kwargs=True))
147
+ result = lm(inputs_embeds=hidden, position_ids=positions,
148
+ attention_mask=attention_mask, use_cache=False).last_hidden_state
149
+ if cache.calls != count or not count:
150
+ raise ValueError('Every intact full-attention layer must copy its own K/V')
151
+ return result
152
+ finally:
153
+ for handle in handles:
154
+ handle.remove()
src/spatial_jev/schema.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed pilot records, strict target parsing and answer-free prompt construction."""
2
+
3
+ import ast
4
+ import math
5
+ import re
6
+
7
+ SCHEMA_VERSION = "spatial-decision/v2"
8
+ EXTENDED_SCHEMA_VERSION = "spatial-decision/v3"
9
+ TASKS = ("choice", "scalar", "point")
10
+ UNIT_FACTORS = {"m": 1.0, "meter": 1.0, "meters": 1.0,
11
+ "cm": 0.01, "centimeter": 0.01, "centimeters": 0.01,
12
+ "mm": 0.001, "millimeter": 0.001, "millimeters": 0.001,
13
+ "inch": 0.0254, "inches": 0.0254, "ft": 0.3048,
14
+ "foot": 0.3048, "feet": 0.3048}
15
+ _NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
16
+
17
+
18
+ def parse_measurement(answer, question=""):
19
+ """Accept a scalar, optionally with an explicit length unit; never extract CoT numbers."""
20
+ match = re.fullmatch(rf"\s*({_NUMBER})\s*([a-zA-Z]+)?\s*\.?\s*", answer)
21
+ if not match:
22
+ raise ValueError("not_a_scalar_answer")
23
+ unit = match.group(2)
24
+ if unit is None:
25
+ units = re.findall(r"\b(" + "|".join(UNIT_FACTORS) + r")\b", question.lower())
26
+ factors = {UNIT_FACTORS[u] for u in units}
27
+ if len(factors) != 1:
28
+ raise ValueError("missing_or_ambiguous_unit")
29
+ unit = units[-1]
30
+ unit = unit.lower()
31
+ if unit not in UNIT_FACTORS:
32
+ raise ValueError("unsupported_unit")
33
+ value = float(match.group(1)) * UNIT_FACTORS[unit]
34
+ if not math.isfinite(value) or value < 0:
35
+ raise ValueError("invalid_measurement")
36
+ return value, unit
37
+
38
+
39
+ def parse_single_point(answer):
40
+ try:
41
+ value = ast.literal_eval(answer.strip())
42
+ except (ValueError, SyntaxError) as exc:
43
+ raise ValueError("not_a_point_literal") from exc
44
+ if not isinstance(value, (list, tuple)) or len(value) != 1:
45
+ raise ValueError("not_a_single_point")
46
+ point = value[0]
47
+ if not isinstance(point, (list, tuple)) or len(point) != 2:
48
+ raise ValueError("not_xy")
49
+ if any(isinstance(x, bool) or not isinstance(x, (int, float)) for x in point):
50
+ raise ValueError("non_numeric_point")
51
+ if any(not math.isfinite(x) or not 0 <= x <= 1 for x in point):
52
+ raise ValueError("point_out_of_range")
53
+ return [float(x) for x in point]
54
+
55
+
56
+ def clean_question(question):
57
+ question = question.replace("<image>", "").strip()
58
+ question = re.split(r"\s*Your answer should be formatted", question, flags=re.I)[0]
59
+ question = re.sub(r"\s*Answer the question using a single word or phrase\.?", "",
60
+ question, flags=re.I)
61
+ return question.strip()
62
+
63
+
64
+ def canonical_metric_question(question):
65
+ question = clean_question(question)
66
+ # Do not rewrite numeric lengths in a premise without also converting their values.
67
+ if re.search(rf"{_NUMBER}\s*(?:cm|mm|meters?|centimeters?|inches|feet)\b",
68
+ question, re.I):
69
+ raise ValueError("numeric_unit_in_question_premise")
70
+ return re.sub(r"\b(?:centimeters?|cm|millimeters?|mm|inches|feet|foot|ft)\b",
71
+ "meters", question, flags=re.I)
72
+
73
+
74
+ def validate_record(record, max_choices=32):
75
+ if record.get("schema_version") not in (SCHEMA_VERSION, EXTENDED_SCHEMA_VERSION):
76
+ raise ValueError("unsupported_schema")
77
+ inp = record["input"]
78
+ if not inp["question"].strip() or not inp["media"]:
79
+ raise ValueError("empty_input")
80
+ if any(m["kind"] != "image" for m in inp["media"]):
81
+ raise ValueError("pilot_requires_images")
82
+ space, target = inp["answer_space"], record["target"]
83
+ kind = space["kind"]
84
+ if kind == "choice":
85
+ options = space["options"]
86
+ ids = [o["id"] for o in options]
87
+ if not 2 <= len(options) <= max_choices or len(set(ids)) != len(ids):
88
+ raise ValueError("invalid_options")
89
+ if target["option_id"] not in ids:
90
+ raise ValueError("target_missing_from_options")
91
+ elif kind == "scalar":
92
+ allowed = ("m", "count", "deg") if record["schema_version"] == EXTENDED_SCHEMA_VERSION else ("m",)
93
+ if space["unit"] not in allowed or target["unit"] != space["unit"]:
94
+ raise ValueError("pilot_scalar_requires_meters")
95
+ if isinstance(target["value"], bool) or not math.isfinite(target["value"]) or target["value"] < 0:
96
+ raise ValueError("invalid_scalar")
97
+ if space["unit"] == "count":
98
+ if space.get("quantity") != "count" or int(target["value"]) != target["value"] or target["value"] > 1023:
99
+ raise ValueError("invalid_integer_count")
100
+ if space["unit"] == "deg":
101
+ if target["value"] > 360 or not space.get("angle_semantics"):
102
+ raise ValueError("invalid_angle_semantics_or_range")
103
+ elif kind == "point":
104
+ if len(inp["media"]) != 1:
105
+ raise ValueError("pilot_point_requires_one_image")
106
+ if space["coordinate_system"] != "normalized_xy" or space["num_points"] != 1:
107
+ raise ValueError("unsupported_point_space")
108
+ parse_single_point(repr(target["points"]))
109
+ else:
110
+ raise ValueError("unsupported_task")
111
+
112
+
113
+ def compile_prompt(inp, permutation=None):
114
+ """Only input is accepted; targets and provenance cannot enter the prompt."""
115
+ space = inp["answer_space"]
116
+ kind = space["kind"]
117
+ text = f"Question: {inp['question']}\n"
118
+ option_ids = []
119
+ if kind == "choice":
120
+ options = space["options"]
121
+ order = list(range(len(options))) if permutation is None else list(permutation)
122
+ if sorted(order) != list(range(len(options))):
123
+ raise ValueError("invalid_permutation")
124
+ text += "Options:\n"
125
+ for i, index in enumerate(order):
126
+ option = options[index]
127
+ text += f"{i + 1}. {option['text']}\n"
128
+ option_ids.append(option["id"])
129
+ text += "Select exactly one option."
130
+ elif kind == "scalar":
131
+ unit = {"m": "meters", "count": "count units (an integer)", "deg": "degrees"}[space["unit"]]
132
+ text += f"Estimate {space['quantity']}. Return a nonnegative value in {unit}."
133
+ elif kind == "point":
134
+ text += ("Return one valid point (x, y) in the original image, normalized to [0, 1]. "
135
+ "The origin is top-left; x increases rightward and y increases downward.")
136
+ else:
137
+ raise ValueError("unsupported_task")
138
+ return text, option_ids
src/spatial_jev/training_alignment.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared controls for the three full-language architecture experiments."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+
7
+ import torch
8
+
9
+
10
+ def old_head_factors(config, step, total_steps):
11
+ """Exactly the old runner's zero-based, pre-optimizer-step LR multipliers."""
12
+ decay = 0.1 + 0.9 * (1 + math.cos(math.pi * step / total_steps)) / 2
13
+ warm = min(1, (step + 1) / config["warmup_steps"])
14
+ backbone_warm = min(1, max(0, step + 1 - config["head_warmup_steps"]) / config["warmup_steps"])
15
+ return backbone_warm * decay, warm * decay
16
+
17
+
18
+ def seed_head(config, factory):
19
+ if "head_seed" not in config:
20
+ return factory()
21
+ with torch.random.fork_rng(devices=[]):
22
+ torch.random.default_generator.manual_seed(config["head_seed"])
23
+ return factory()
24
+
25
+
26
+ def state_hash(module):
27
+ return hashlib.sha256(
28
+ b"".join(
29
+ value.detach().float().cpu().contiguous().numpy().tobytes()
30
+ for value in module.state_dict().values()
31
+ )
32
+ ).hexdigest()
33
+
34
+
35
+ def canonical_rows(rows, path, expected_sha256):
36
+ """Restore the ORIGINAL flat order before shuffling, including padding row 0."""
37
+ if hashlib.sha256(path.read_bytes()).hexdigest() != expected_sha256:
38
+ raise ValueError("Canonical training file hash changed")
39
+ by_id = {row["sample_id"]: row for row in rows}
40
+ identities = []
41
+ with path.open() as stream:
42
+ for line in stream:
43
+ source = json.loads(line)
44
+ identity = source["sample_id"]
45
+ identities.append(identity)
46
+ if identity not in by_id or any(
47
+ source.get(key) != by_id[identity].get(key) for key in ("input", "target")
48
+ ):
49
+ raise ValueError(f"Canonical QA contents differ: {identity}")
50
+ if (
51
+ len(by_id) != len(rows)
52
+ or len(set(identities)) != len(identities)
53
+ or set(identities) != by_id.keys()
54
+ ):
55
+ raise ValueError("Canonical QA identities do not match the training dataset")
56
+ return [by_id[identity] for identity in identities]
src/spatial_jev/unified.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One shared classifier with scalar interval decisions and three-step pointing."""
2
+
3
+ import json
4
+ import random
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+ from PIL import Image
10
+ from torch import nn
11
+
12
+ from .hierarchy import (POINT_NAMES, ROOT_BOX, child_box, decode_point, decode_scalar,
13
+ point_path, roi_visible, scalar_fine_bins, scalar_interval, scalar_path,
14
+ visual_token_boxes)
15
+ from .schema import compile_prompt
16
+ from .numeric_tasks import codebook_for, numeric_prompt
17
+
18
+
19
+ def causal_visual_mask(length, visual_types, stages, query_start=0):
20
+ """Causal text plus bidirectional vision ONLY within the same reveal stage.
21
+
22
+ Native Molmo2's all-image bidirectionality would leak future GT-selected crops.
23
+ Old prefix queries are not recomputed during incremental inference.
24
+ """
25
+ keys = torch.arange(length)
26
+ queries = keys[query_start:]
27
+ allowed = keys[None] <= queries[:, None]
28
+ visual_types = torch.as_tensor(visual_types, dtype=torch.bool)
29
+ stages = torch.as_tensor(stages, dtype=torch.long)
30
+ allowed |= (visual_types[query_start:, None] & visual_types[None]
31
+ & (stages[query_start:, None] == stages[None]))
32
+ return torch.zeros((1, 1, len(queries), length)).masked_fill(~allowed[None, None], -torch.inf)
33
+
34
+
35
+ def apply_roi_mask(mask, visual_positions, boxes, roi, start=0, end=None):
36
+ keep = roi_visible(boxes, roi)
37
+ if not keep.any():
38
+ raise ValueError("ROI has no visual support")
39
+ blocked = torch.as_tensor(np.asarray(visual_positions)[~keep], dtype=torch.long)
40
+ mask[:, :, start:end, blocked] = -torch.inf
41
+ return int(keep.sum())
42
+
43
+
44
+ def move_inputs(inputs, device):
45
+ return {key: value.to(device=device, dtype=torch.bfloat16)
46
+ if value.is_floating_point() else value.to(device) for key, value in inputs.items()}
47
+
48
+
49
+ def collate_training(examples, pad_id):
50
+ """Pad independent examples without cross-example attention or image-pooling offsets."""
51
+ length = max(p["inputs"]["input_ids"].shape[1] for p in examples)
52
+ batch = len(examples)
53
+ ids = torch.full((batch, length), pad_id, dtype=torch.long)
54
+ mask = torch.full((batch, 1, length, length), -torch.inf)
55
+ positions, counts, targets, weights = [], [], [], []
56
+ for index, packed in enumerate(examples):
57
+ n = packed["inputs"]["input_ids"].shape[1]
58
+ ids[index, :n] = packed["inputs"]["input_ids"][0]
59
+ mask[index, :, :n, :n] = packed["inputs"]["attention_mask"][0]
60
+ padding = torch.arange(n, length)
61
+ mask[index, 0, padding, padding] = 0
62
+ positions.extend((index, int(p)) for p in packed["positions"])
63
+ counts.extend(packed["counts"].tolist())
64
+ targets.extend(packed["targets"].tolist())
65
+ weights.extend([1 / len(packed["targets"])] * len(packed["targets"]))
66
+ inputs = {"input_ids": ids, "attention_mask": mask}
67
+ for key in ("pixel_values", "image_token_pooling", "image_grids", "image_num_crops"):
68
+ # Native pooling indices are local to each image; the model adds offsets itself.
69
+ inputs[key] = torch.cat([p["inputs"][key] for p in examples], dim=0)
70
+ return {"inputs": inputs, "positions": torch.tensor(positions), "counts": torch.tensor(counts),
71
+ "targets": torch.tensor(targets), "loss_weights": torch.tensor(weights) / batch}
72
+
73
+
74
+ class SharedClassifier(nn.Module):
75
+ def __init__(self, hidden_size, max_choices=32):
76
+ super().__init__()
77
+ self.norm = nn.LayerNorm(hidden_size)
78
+ self.linear = nn.Linear(hidden_size, max_choices)
79
+
80
+ def forward(self, hidden, counts):
81
+ with torch.autocast(device_type=hidden.device.type, enabled=False):
82
+ logits = self.linear(self.norm(hidden.float()))
83
+ invalid = torch.arange(logits.shape[-1], device=logits.device)[None] >= counts[:, None]
84
+ return logits.masked_fill(invalid, -torch.inf)
85
+
86
+
87
+ class UnifiedSpatialModel(nn.Module):
88
+ def __init__(self, backbone, classifier):
89
+ super().__init__()
90
+ self.backbone, self.classifier = backbone, classifier
91
+
92
+ def forward(self, inputs, positions, counts):
93
+ out = self.backbone(**inputs, use_cache=False)
94
+ hidden = (out.last_hidden_state[positions[:, 0], positions[:, 1]] if positions.ndim == 2
95
+ else out.last_hidden_state[0, positions])
96
+ return self.classifier(hidden, counts)
97
+
98
+
99
+ def build_unified_model(config, checkpoint=None, trainable=True):
100
+ from peft import LoraConfig, PeftModel, get_peft_model
101
+ from transformers import AutoModelForImageTextToText
102
+
103
+ full = AutoModelForImageTextToText.from_pretrained(
104
+ config["base_model"], trust_remote_code=True, local_files_only=True,
105
+ dtype=torch.bfloat16, attn_implementation="sdpa")
106
+ backbone, hidden = full.model, full.config.text_config.hidden_size
107
+ del full
108
+ if checkpoint:
109
+ backbone = PeftModel.from_pretrained(backbone, str(Path(checkpoint) / "adapter"),
110
+ is_trainable=trainable)
111
+ else:
112
+ backbone = get_peft_model(backbone, LoraConfig(
113
+ r=config["lora_rank"], lora_alpha=config["lora_alpha"],
114
+ lora_dropout=config["lora_dropout"], target_modules=config["lora_targets"], bias="none"))
115
+ classifier = SharedClassifier(hidden, config["max_choices"])
116
+ if checkpoint:
117
+ from safetensors.torch import load_file
118
+ classifier.load_state_dict(load_file(str(Path(checkpoint) / "classifier.safetensors")))
119
+ if trainable and config.get("gradient_checkpointing", True):
120
+ backbone.get_base_model().gradient_checkpointing_enable(
121
+ gradient_checkpointing_kwargs={"use_reentrant": False})
122
+ model = UnifiedSpatialModel(backbone, classifier)
123
+ if any(p.requires_grad for n, p in backbone.named_parameters() if "vision_backbone" in n):
124
+ raise ValueError("Visual encoder/projector must be frozen in this ablation")
125
+ if not trainable:
126
+ model.requires_grad_(False).eval()
127
+ return model
128
+
129
+
130
+ def save_unified_checkpoint(model, processor, config, output, step, optimizer=None):
131
+ from safetensors.torch import save_file
132
+
133
+ output = Path(output)
134
+ output.mkdir(parents=True, exist_ok=False)
135
+ model.backbone.save_pretrained(output / "adapter", safe_serialization=True)
136
+ save_file({k: v.detach().cpu().contiguous() for k, v in model.classifier.state_dict().items()},
137
+ str(output / "classifier.safetensors"))
138
+ processor.save_pretrained(output / "processor")
139
+ (output / "config.json").write_text(json.dumps(config, indent=2) + "\n")
140
+ if optimizer:
141
+ torch.save({"step": step, "optimizer": optimizer.state_dict()}, output / "optimizer.pt")
142
+ (output / "checkpoint.json").write_text(json.dumps({
143
+ "step": step, "format": "base_plus_lora_plus_one_classifier",
144
+ "point_variant": config["point_variant"], "base_revision": config["base_revision"],
145
+ "calibrated": False}, indent=2) + "\n")
146
+
147
+
148
+ class UnifiedBuilder:
149
+ def __init__(self, processor, config):
150
+ self.processor, self.config = processor, config
151
+ self.codebook = config["scalar_codebook"]
152
+ self.variant = config["point_variant"]
153
+ if self.variant not in ("single_image", "crop_refill", "roi_mask"):
154
+ raise ValueError("Unknown point ablation")
155
+ self.patch_id = processor.tokenizer.convert_tokens_to_ids("<im_patch>")
156
+ self.header = processor.tokenizer.encode("<|im_start|>assistant\n", add_special_tokens=False)
157
+
158
+ def render(self, text, image_count):
159
+ content = [{"type": "image"} for _ in range(image_count)]
160
+ content.append({"type": "text", "text": text})
161
+ # Render each reveal separately. Rendering all messages at once hoists future crops.
162
+ return self.processor.apply_chat_template([{"role": "user", "content": content}],
163
+ tokenize=False, add_generation_prompt=True)
164
+
165
+ @staticmethod
166
+ def load_images(inp):
167
+ images = []
168
+ for media in inp["media"]:
169
+ with Image.open(media["uri"]) as image:
170
+ images.append(image.convert("RGB"))
171
+ return images
172
+
173
+ @staticmethod
174
+ def crop(original, box):
175
+ w, h = original.size
176
+ extent = (box[0] * w, box[1] * h, box[2] * w, box[3] * h)
177
+ size = (max(1, round(extent[2] - extent[0])), max(1, round(extent[3] - extent[1])))
178
+ return original.transform(size, Image.Transform.EXTENT, extent, Image.Resampling.BILINEAR)
179
+
180
+ def point_prompt(self, inp, box, level):
181
+ bounds = ", ".join(f"{x:.6f}" for x in box)
182
+ options = "\n".join(f"{i+1}. {name}" for i, name in enumerate(POINT_NAMES))
183
+ return (f"Request: {inp['question']}\nChoose one valid point satisfying the request. "
184
+ f"Localization step {level+1} of 3. The current rectangle in the ORIGINAL image "
185
+ f"is (left, top, right, bottom) = ({bounds}), normalized to [0, 1]. "
186
+ "The origin is top-left, x increases rightward and y downward. "
187
+ "Any image appended after the original is a zoomed view of its current rectangle. "
188
+ "Divide the CURRENT rectangle into three equal rows and three equal columns. "
189
+ f"Which region contains the point?\nOptions:\n{options}\nSelect exactly one option.")
190
+
191
+ def scalar_prompt(self, inp, root=None):
192
+ cb = codebook_for(inp, self.codebook)
193
+ if cb.get("unit", "m") != "m":
194
+ return numeric_prompt(inp, root, cb)
195
+ if root is None:
196
+ options = ["exactly zero meters"]
197
+ options += [f"{lo:g} to {hi:g} meters" for lo, hi in zip(cb["edges_m"], cb["edges_m"][1:])]
198
+ options[1] = f"greater than zero and less than {cb['edges_m'][1]:g} meters"
199
+ options += [f"greater than {cb['edges_m'][-1]:g} meters"]
200
+ instruction = "Select the range containing the nonnegative measurement."
201
+ else:
202
+ lo, hi = scalar_interval(root, cb)
203
+ fine = scalar_fine_bins(root, cb)
204
+ step = (hi - lo) / fine
205
+ options = [f"{lo+i*step:.6g} to {lo+(i+1)*step:.6g} meters"
206
+ for i in range(fine)]
207
+ instruction = f"Refine the previously selected range {lo:g} to {hi:g} meters."
208
+ text = (f"Question: {inp['question']}\n{instruction} "
209
+ "Ranges include the lower endpoint and exclude the upper endpoint, "
210
+ "except that the maximum finite endpoint is included. Exact zero has its own class."
211
+ "\nOptions:\n" + "\n".join(f"{i+1}. {s}" for i, s in enumerate(options))
212
+ + "\nSelect exactly one option.")
213
+ return text, len(options)
214
+
215
+ def initial(self, inp, seed):
216
+ task, mapping = inp["answer_space"]["kind"], []
217
+ if task == "choice":
218
+ order = list(range(len(inp["answer_space"]["options"])))
219
+ if not self.config.get("preserve_option_order", False):
220
+ random.Random(seed).shuffle(order)
221
+ text, mapping = compile_prompt(inp, order)
222
+ count = len(mapping)
223
+ elif task == "scalar":
224
+ text, count = self.scalar_prompt(inp)
225
+ else:
226
+ text, count = self.point_prompt(inp, ROOT_BOX, 0), 9
227
+ return self.render(text, len(inp["media"])), count, mapping
228
+
229
+ def encode(self, text, images, metadata=False, continuation=False):
230
+ kwargs = {"text": [text], "return_tensors": "pt", "padding": False,
231
+ "return_mm_token_type_ids": True}
232
+ if images:
233
+ kwargs.update(images=images, return_pointing_metadata=metadata)
234
+ encoded = self.processor(**kwargs)
235
+ meta = encoded.pop("metadata", None)
236
+ if continuation:
237
+ bos = self.processor.tokenizer.bos_token_id or self.processor.tokenizer.eos_token_id
238
+ if encoded["input_ids"][0, 0].item() != bos:
239
+ raise ValueError("Unexpected continuation BOS")
240
+ for key in ("input_ids", "attention_mask", "token_type_ids"):
241
+ encoded[key] = encoded[key][:, 1:]
242
+ return dict(encoded), meta
243
+
244
+ def geometry(self, inputs, metadata):
245
+ positions = (inputs["input_ids"][0] == self.patch_id).nonzero().flatten().numpy()
246
+ boxes = visual_token_boxes(metadata, inputs["image_grids"].tolist(),
247
+ inputs["image_num_crops"].tolist(), inputs["pixel_values"].shape[1])
248
+ if len(positions) != len(boxes):
249
+ raise ValueError("Patch positions and geometric supports differ")
250
+ return positions, boxes
251
+
252
+ def training_example(self, row, seed):
253
+ inp, task = row["input"], row["input"]["answer_space"]["kind"]
254
+ text, count, mapping = self.initial(inp, seed)
255
+ images = self.load_images(inp)
256
+ original = images[0]
257
+ if task == "choice":
258
+ path = [mapping.index(row["target"]["option_id"])]
259
+ elif task == "scalar":
260
+ path = scalar_path(row["target"]["value"], self.codebook)
261
+ else:
262
+ path = point_path(row["target"]["points"][0])
263
+ counts, boxes, box = [count], [ROOT_BOX], ROOT_BOX
264
+ for stage in range(1, len(path)):
265
+ if task == "point":
266
+ box = child_box(box, path[stage - 1])
267
+ question, count = self.point_prompt(inp, box, stage), 9
268
+ extra = [self.crop(original, box)] if self.variant == "crop_refill" else []
269
+ else:
270
+ question, count = self.scalar_prompt(inp, path[0])
271
+ extra = []
272
+ text += f"{path[stage-1]+1}<|im_end|>\n" + self.render(question, len(extra))
273
+ images.extend(extra)
274
+ counts.append(count)
275
+ boxes.append(box)
276
+ needs_geometry = task == "point" and self.variant == "roi_mask"
277
+ inputs, metadata = self.encode(text, images, metadata=needs_geometry)
278
+ ids = inputs["input_ids"][0].tolist()
279
+ positions = [i + len(self.header) - 1 for i in range(len(ids) - len(self.header) + 1)
280
+ if ids[i:i+len(self.header)] == self.header]
281
+ if len(positions) != len(path):
282
+ raise ValueError("Decision marker count mismatch")
283
+ if len(ids) > self.config["max_sequence_length"]:
284
+ raise ValueError("Sequence budget exceeded")
285
+ types = inputs.pop("token_type_ids")[0]
286
+ stages = torch.zeros(len(ids), dtype=torch.long)
287
+ for stage in range(1, len(path)):
288
+ stages[positions[stage-1]+1:positions[stage]+1] = stage
289
+ mask = causal_visual_mask(len(ids), types, stages)
290
+ if needs_geometry:
291
+ visual_positions, supports = self.geometry(inputs, metadata)
292
+ for stage in range(1, len(path)):
293
+ apply_roi_mask(mask, visual_positions, supports, boxes[stage],
294
+ positions[stage-1]+1, positions[stage]+1)
295
+ inputs["attention_mask"] = mask
296
+ return {"inputs": inputs, "positions": torch.tensor(positions),
297
+ "counts": torch.tensor(counts), "targets": torch.tensor(path),
298
+ "mapping": mapping, "boxes": boxes}
299
+
300
+
301
+ @torch.inference_mode()
302
+ def predict_unified(model, builder, inp, device, seed, forced_path=None):
303
+ """No targets are accepted. forced_path is solely for cache-equivalence integration tests."""
304
+ task = inp["answer_space"]["kind"]
305
+ images = builder.load_images(inp)
306
+ original = images[0]
307
+ text, count, mapping = builder.initial(inp, seed)
308
+ need_geometry = task == "point" and builder.variant == "roi_mask"
309
+ encoded, meta = builder.encode(text, images, metadata=need_geometry)
310
+ geom = builder.geometry(encoded, meta) if need_geometry else None
311
+ all_types, all_stages = [], []
312
+ path, logits_trace, support_counts = [], [], []
313
+ box, cache, previous_length, total_input_tokens = ROOT_BOX, None, 0, 0
314
+ for stage in range(3 if task == "point" else 2 if task == "scalar" else 1):
315
+ types = encoded.pop("token_type_ids")[0].tolist()
316
+ all_types.extend(types)
317
+ all_stages.extend([stage] * len(types))
318
+ total_input_tokens += encoded["input_ids"].shape[1]
319
+ if total_input_tokens > builder.config["max_sequence_length"]:
320
+ raise ValueError("Rollout sequence budget exceeded")
321
+ mask = causal_visual_mask(total_input_tokens, all_types, all_stages, previous_length)
322
+ if need_geometry and stage > 0:
323
+ support_counts.append(apply_roi_mask(mask, *geom, box))
324
+ encoded["attention_mask"] = mask
325
+ inputs = move_inputs(encoded, device)
326
+ inputs["cache_position"] = torch.arange(previous_length, total_input_tokens, device=device)
327
+ output = model.backbone(**inputs, past_key_values=cache, use_cache=True)
328
+ logits = model.classifier(output.last_hidden_state[:, -1],
329
+ torch.tensor([count], device=device))
330
+ logits_trace.append(logits[0, :count].float().cpu().tolist())
331
+ choice = int(logits.argmax(-1).item()) if forced_path is None else forced_path[stage]
332
+ path.append(choice)
333
+ cache, previous_length = output.past_key_values, total_input_tokens
334
+ if task == "choice":
335
+ break
336
+ if task == "scalar":
337
+ if stage == 1 or choice in (0, len(builder.codebook["edges_m"])):
338
+ break
339
+ question, count = builder.scalar_prompt(inp, choice)
340
+ extra = []
341
+ else:
342
+ box = child_box(box, choice)
343
+ if stage == 2:
344
+ break
345
+ question, count = builder.point_prompt(inp, box, stage + 1), 9
346
+ extra = [builder.crop(original, box)] if builder.variant == "crop_refill" else []
347
+ text = f"{choice+1}<|im_end|>\n" + builder.render(question, len(extra))
348
+ encoded, _ = builder.encode(text, extra, continuation=True)
349
+ if task == "choice":
350
+ prediction = mapping[path[0]]
351
+ elif task == "scalar":
352
+ prediction = decode_scalar(path, builder.codebook)
353
+ else:
354
+ prediction = decode_point(path)
355
+ return {"prediction": prediction, "path": path, "logits": logits_trace,
356
+ "mapping": mapping, "input_tokens": total_input_tokens,
357
+ "roi_support_counts": support_counts}
src/spatial_jev/visual_token_pointing.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Direct question-Q / image-token-K pointing on Qwen's merged visual grid.
2
+
3
+ The candidates are LLM image-token positions, not raw ViT keys (a different
4
+ projection space). Geometry follows image_grid_thw after spatial merging, in
5
+ row-major order, with no crop, label-dependent input, or learned coordinate head.
6
+ """
7
+
8
+ import math
9
+
10
+ import numpy as np
11
+ import torch
12
+ from torch.nn import functional as F
13
+
14
+ from .point_probability_map import component_points
15
+ from .roi_replay import REPLAY_VARIANTS
16
+
17
+ VISUAL_TOKENS = "visual_tokens"
18
+ ORIGINAL_IMAGE = "original_image_nine_way"
19
+
20
+
21
+ def validate_pointing_config(config):
22
+ variant = config.get("point_variant", "crop_refill")
23
+ if variant not in ("crop_refill", VISUAL_TOKENS, ORIGINAL_IMAGE, *REPLAY_VARIANTS):
24
+ raise ValueError(f"Unsupported KEV pointing method: {variant}")
25
+ if variant == ORIGINAL_IMAGE and config.get("point_depth", 3) != 3:
26
+ raise ValueError("Original-image nine-way pointing requires three decisions")
27
+ if variant in REPLAY_VARIANTS and (
28
+ config.get('point_depth', 3) != 3 or config.get('readout_kind') != 'native_last_qk_mean'
29
+ or config.get('readout_layer_indices') is not None or config.get('readout_layer_index', 31) != 31
30
+ or config.get('shared_prefix', False) or config.get('gradient_checkpointing', False)):
31
+ raise ValueError('ROI replay fixes L32 mean, three decisions and no persistent cache/checkpointing')
32
+ if variant != VISUAL_TOKENS:
33
+ return
34
+ if config.get("point_depth", 1) != 1:
35
+ raise ValueError("Visual-token pointing is a single decision, not a crop tree")
36
+ if config.get("shared_prefix", False):
37
+ raise ValueError("Visual-token keys require the full causal row; shared_prefix is unsupported")
38
+ if config.get("point_loss", "uniform_cell_ce") != "uniform_cell_ce":
39
+ raise ValueError("Visual-token pointing requires uniform_cell_ce")
40
+ if config.get("point_decoder", "argmax") not in ("argmax", "components"):
41
+ raise ValueError("Unknown visual-token point decoder")
42
+ if not 0 < config.get("point_component_threshold", 0.1) <= 1:
43
+ raise ValueError("Invalid visual-token component threshold")
44
+
45
+
46
+ def image_token_candidates(ids, image_token_id, grid_thw, merge_size):
47
+ """One original image only; reject video, missing tokens and ambiguous grids."""
48
+ t, h, w = [int(v) for v in grid_thw]
49
+ if merge_size < 1 or t != 1 or min(h, w) < 1 or h % merge_size or w % merge_size:
50
+ raise ValueError("Expected one spatially mergeable still-image grid")
51
+ shape = (h // merge_size, w // merge_size)
52
+ positions = [i for i, token in enumerate(ids) if token == image_token_id]
53
+ if len(positions) != math.prod(shape) or positions != list(range(positions[0], positions[-1] + 1)):
54
+ raise ValueError("Image-token positions do not match the merged visual grid")
55
+ return positions, shape
56
+
57
+
58
+ def target_cells(points, shape):
59
+ """Map normalized training labels to occupied cells, without modifying inputs."""
60
+ if not isinstance(points, (list, tuple)) or not points:
61
+ raise ValueError("Point supervision must contain at least one normalized point")
62
+ height, width = shape
63
+ if min(height, width) < 1:
64
+ raise ValueError("Empty visual grid")
65
+ cells = set()
66
+ for point in points:
67
+ if (not isinstance(point, (list, tuple)) or len(point) != 2
68
+ or any(isinstance(v, bool) or not isinstance(v, (int, float))
69
+ or not math.isfinite(v) or not 0 <= v <= 1 for v in point)):
70
+ raise ValueError("Point supervision must use finite normalized coordinates")
71
+ x, y = point
72
+ col, row = min(int(x * width), width - 1), min(int(y * height), height - 1)
73
+ cells.add(row * width + col)
74
+ return sorted(cells)
75
+
76
+
77
+ def decision_losses(logits, packed):
78
+ """Ordinary CE unchanged; multiple occupied visual cells share one QA's mass.
79
+
80
+ Sparse targets avoid 0 * -inf on padded candidate slots. Coordinates in the
81
+ same visual cell are intentionally indistinguishable at this resolution.
82
+ """
83
+ targets = packed["targets"].to(logits.device)
84
+ if "visual_target_owner" not in packed:
85
+ return F.cross_entropy(logits, targets, reduction="none")
86
+ log_probs = F.log_softmax(logits.float(), dim=-1)
87
+ losses = -log_probs.gather(1, targets[:, None]).squeeze(1)
88
+ owners = packed["visual_target_owner"].to(logits.device)
89
+ slots = packed["visual_target_slot"].to(logits.device)
90
+ mass = packed["visual_target_mass"].to(logits.device)
91
+ visual_losses = torch.zeros_like(losses).index_add(0, owners, -log_probs[owners, slots] * mass)
92
+ selected = owners.unique()
93
+ return losses.index_copy(0, selected, visual_losses[selected])
94
+
95
+
96
+ def decode_visual_points(logits, shape, decoder="argmax", relative_threshold=0.1):
97
+ """Decode only model scores and known geometry; never receive GT/count/masks."""
98
+ values = np.asarray(logits, dtype=np.float64)
99
+ height, width = shape
100
+ if min(height, width) < 1 or values.shape != (height * width,) or not np.isfinite(values).all():
101
+ raise ValueError("Expected one finite score per merged visual token")
102
+ probabilities = np.exp(values - values.max())
103
+ probabilities /= probabilities.sum()
104
+ grid = probabilities.reshape(height, width)
105
+ if decoder == "argmax":
106
+ index = int(values.argmax())
107
+ y, x = divmod(index, width)
108
+ points = [[(x + 0.5) / width, (y + 0.5) / height]]
109
+ elif decoder == "components":
110
+ components = component_points(grid, relative_threshold=relative_threshold, connectivity=8)["components"]
111
+ components.sort(key=lambda c: (-c["peak_probability"], *c["peak_cell"]))
112
+ points = [[(c["peak_cell"][1] + 0.5) / width, (c["peak_cell"][0] + 0.5) / height]
113
+ for c in components]
114
+ else:
115
+ raise ValueError(f"Unknown visual-token point decoder: {decoder}")
116
+ return {"prediction": points[0], "points": points, "grid_hw": list(shape),
117
+ "probabilities": grid.tolist(), "pointing_method": VISUAL_TOKENS,
118
+ "point_decoder": decoder, "point_component_threshold": relative_threshold if decoder == "components" else None}