ArkidMitra commited on
Commit
0048827
·
verified ·
1 Parent(s): ab62348

Image Hopper router v2-fast: faster serving and default-route image cap (same weights and calibration)

Browse files
Dockerfile CHANGED
@@ -9,4 +9,4 @@ COPY requirements.lock requirements.lock.sha256 /app/
9
  RUN sha256sum -c requirements.lock.sha256 && python -m pip install --no-cache-dir -r requirements.lock
10
  COPY . /app
11
  EXPOSE 8080
12
- ENTRYPOINT ["python", "-m", "open_decisions.image_jev.release.server", "--host", "0.0.0.0", "--port", "8080", "--offline", "--adapter", "adapter"]
 
9
  RUN sha256sum -c requirements.lock.sha256 && python -m pip install --no-cache-dir -r requirements.lock
10
  COPY . /app
11
  EXPOSE 8080
12
+ ENTRYPOINT ["python", "-m", "open_decisions.image_jev.release.server", "--host", "0.0.0.0", "--port", "8080", "--offline", "--adapter", "adapter", "--serving-options", "lean_lora,uint8_pixels,device_readout,image_token_cap_default=768"]
README.md CHANGED
@@ -24,7 +24,8 @@ in one forward pass and without generating text. It is built on `Qwen/Qwen3.5-9B
24
  for those letters at the last prompt position.
25
  - **Calibration.** Logits are divided by a temperature chosen by route and answer type (fitted by maximum likelihood
26
  with shrinkage toward a global temperature). Calibration never changes which option ranks first.
27
- - **Images** use the base processor's native resolution route; up to four images per request.
 
28
 
29
  | Route / answer type | Temperature |
30
  |---|---:|
@@ -58,10 +59,16 @@ checked against the benchmark's reference images for near-duplicates.
58
  The default-route temperatures were fitted on a held-out calibration split of our own data; the screen/geometry
59
  temperatures on held-out real screenshots and geometry diagrams that were never used for training.
60
 
 
 
 
 
 
 
61
  ## Usage
62
 
63
  pip install -r requirements.txt
64
- python -m open_decisions.image_jev.release.server --offline --adapter adapter --port 8080
65
 
66
  `POST /v1/systemone`:
67
 
 
24
  for those letters at the last prompt position.
25
  - **Calibration.** Logits are divided by a temperature chosen by route and answer type (fitted by maximum likelihood
26
  with shrinkage toward a global temperature). Calibration never changes which option ranks first.
27
+ - **Images** use the base processor's native resolution route; on the default route each image is capped at 768
28
+ image tokens (screen/geometry questions keep full resolution); up to four images per request.
29
 
30
  | Route / answer type | Temperature |
31
  |---|---:|
 
59
  The default-route temperatures were fitted on a held-out calibration split of our own data; the screen/geometry
60
  temperatures on held-out real screenshots and geometry diagrams that were never used for training.
61
 
62
+ ## Serving
63
+
64
+ The Dockerfile starts the server with `--serving-options lean_lora,uint8_pixels,device_readout,image_token_cap_default=768`.
65
+ The first three options change only how the computation runs, not the outputs; the image cap on the default route
66
+ reduces cost per decision.
67
+
68
  ## Usage
69
 
70
  pip install -r requirements.txt
71
+ python -m open_decisions.image_jev.release.server --offline --adapter adapter --port 8080 --serving-options "lean_lora,uint8_pixels,device_readout,image_token_cap_default=768"
72
 
73
  `POST /v1/systemone`:
74
 
RELEASE-MANIFEST.json CHANGED
@@ -15,14 +15,14 @@
15
  },
16
  "code": {
17
  "dirty": false,
18
- "git_commit": "b94f0c4507c2463f6569974cfbb36a05ccdbb764"
19
  },
20
  "files": {
21
  "BASE-MODEL-LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
22
- "Dockerfile": "904c4630d2b50f577e3e6cf0e9d71f25c06e4fb64ef1a41238b299ba0c8a5d7a",
23
  "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
24
  "NOTICE": "1d5e63ea05ed3c846593f1f4185b4355a25b2b25682bccac24995e094141b6c7",
25
- "README.md": "2b1b313bcf80dff7aaeea5904c82d3adec8e0c8079a42a28093f7538c794b8b9",
26
  "adapter/README.md": "bb29528044c9409e183ce924e4ea6f29f2b705d749287781b8ff2777b5209a55",
27
  "adapter/adapter_config.json": "4f6e55c1d151070a6e8444feff44bdcfbe4b87e14c3fde855f69159066162487",
28
  "adapter/adapter_model.safetensors": "6201fcb1c08144e465a03fd6f5355c4394b3bffaad659440fa64063dd4f71509",
@@ -31,12 +31,13 @@
31
  "open_decisions/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
32
  "open_decisions/image_jev/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
33
  "open_decisions/image_jev/release/__init__.py": "4a29d6b23b6021c0cc27d3fa5163ebbede7ab531b1a13814dfddf1fedcd71af4",
34
- "open_decisions/image_jev/release/predictor.py": "b4a469714f2cfb5b6cf8551975dc76e2b6139b6dae61b1ebcc4f31335dddb407",
35
- "open_decisions/image_jev/release/server.py": "3815ab07ca929b9e226b0a73521b2dc6cc6c426da1d0d8942ab9e372163f3ca9",
36
- "open_decisions/image_jev/release/smoke.py": "80c6ed250089724a332ea0341ad6324757b19a5407d219f539d28bcdf7864ea4",
 
37
  "open_decisions/image_jev/router.py": "e99db6dde898e7f7be4babcec2f4e545eedff51b04db8c7fe98e47e884a27c47",
38
  "open_decisions/image_jev/speed.py": "5671a56aa16d4647dca8f3c360e83f98747641d7722ae184110806375448c5cb",
39
- "open_decisions/image_jev/vision.py": "f9e988cae5184b09b0ebacec7f9baeb3db06e971862c18229dea1ee5034e2be2",
40
  "open_decisions/scoring/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
41
  "open_decisions/scoring/prompt.py": "131916c014979500e5d2daceb94817a8942de8148966d3309c8ecbc8547027ee",
42
  "requirements.lock": "974e3a19773d67b4de84842ffa4a3a537e2886240cdfb97d94216159804aa7e2",
@@ -44,11 +45,12 @@
44
  "requirements.txt": "321bb81a1bd43b4858cf724d22944ac3e5e93504a9bf231be88b6752b437a63c"
45
  },
46
  "image_route": "native",
47
- "manifest_sha256": "1403eee576ec886cf6c3bcd2dbb02761fd9cd9200af118d2fff05da6eb4ecbd3",
48
  "measurement_artifact": null,
49
  "name": "HopitAI/image-hopper",
50
  "scorer": "one-prefill-option-letter",
51
- "status": "owner-review",
 
52
  "system_kind": "routed",
53
  "version": "image-jev-release/v1"
54
  }
 
15
  },
16
  "code": {
17
  "dirty": false,
18
+ "git_commit": "78124a4fcb2123b8f5b3abb08d5751155c7d44be"
19
  },
20
  "files": {
21
  "BASE-MODEL-LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
22
+ "Dockerfile": "46c5a925edee39410700d801ec0156904e8b5808373d5c77a3600f45bce29691",
23
  "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
24
  "NOTICE": "1d5e63ea05ed3c846593f1f4185b4355a25b2b25682bccac24995e094141b6c7",
25
+ "README.md": "8486ae6e58abfeeed8aa515bb87277c88a36ba1ee7ea14c139cc9614a4f8481a",
26
  "adapter/README.md": "bb29528044c9409e183ce924e4ea6f29f2b705d749287781b8ff2777b5209a55",
27
  "adapter/adapter_config.json": "4f6e55c1d151070a6e8444feff44bdcfbe4b87e14c3fde855f69159066162487",
28
  "adapter/adapter_model.safetensors": "6201fcb1c08144e465a03fd6f5355c4394b3bffaad659440fa64063dd4f71509",
 
31
  "open_decisions/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
32
  "open_decisions/image_jev/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
33
  "open_decisions/image_jev/release/__init__.py": "4a29d6b23b6021c0cc27d3fa5163ebbede7ab531b1a13814dfddf1fedcd71af4",
34
+ "open_decisions/image_jev/release/fastpath.py": "4ad3e5e869e80393b30fd1fd90a377cae244a25ceca249c8aeb5eb0d44d61236",
35
+ "open_decisions/image_jev/release/predictor.py": "bbc178c08fb2b9a92684d759968b8792391a87ff21b804c3b723c68af61e76a3",
36
+ "open_decisions/image_jev/release/server.py": "bb994fe1abe7d29a425c4357eb80973565d6105130307f04597e8803ee97c168",
37
+ "open_decisions/image_jev/release/smoke.py": "773c339d4393d12953264918bfd3decfb46a67cee06feb2e61d29611c0313e3b",
38
  "open_decisions/image_jev/router.py": "e99db6dde898e7f7be4babcec2f4e545eedff51b04db8c7fe98e47e884a27c47",
39
  "open_decisions/image_jev/speed.py": "5671a56aa16d4647dca8f3c360e83f98747641d7722ae184110806375448c5cb",
40
+ "open_decisions/image_jev/vision.py": "de4ed892c0513ec86f954514476560cf9b20b7dd1d0e0afca548fcdf576aa882",
41
  "open_decisions/scoring/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
42
  "open_decisions/scoring/prompt.py": "131916c014979500e5d2daceb94817a8942de8148966d3309c8ecbc8547027ee",
43
  "requirements.lock": "974e3a19773d67b4de84842ffa4a3a537e2886240cdfb97d94216159804aa7e2",
 
45
  "requirements.txt": "321bb81a1bd43b4858cf724d22944ac3e5e93504a9bf231be88b6752b437a63c"
46
  },
47
  "image_route": "native",
48
+ "manifest_sha256": "be3f472213dffc9678155bdd91480bb87f63edd7bebcb8737c960646a1ed23ff",
49
  "measurement_artifact": null,
50
  "name": "HopitAI/image-hopper",
51
  "scorer": "one-prefill-option-letter",
52
+ "serving_options": "lean_lora,uint8_pixels,device_readout,image_token_cap_default=768",
53
+ "status": "DRAFT - not published; public text needs owner approval",
54
  "system_kind": "routed",
55
  "version": "image-jev-release/v1"
56
  }
open_decisions/image_jev/release/fastpath.py ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opt-in serving optimisations for the Image Hopper release server (cost audit, step 1).
2
+
3
+ Every option is off by default, so the published path is unchanged unless the server is started
4
+ with ``--serving-options``. Options are either
5
+
6
+ * **exact**: the served function is bit-identical by construction (same operations in the same
7
+ order, only Python overhead, copies or host transfers removed); or
8
+ * **tolerance-bound**: numerically close but not bit-identical. The cost audit registers the
9
+ shipping rule before measuring: argmax identical on every audit item and max |dp| <= 1e-3.
10
+
11
+ Options (``OPTIONS`` holds the one-line descriptions):
12
+
13
+ ``lean_lora`` exact. PEFT's LoRA wrappers are replaced by plain modules that call the
14
+ original base layer, plus the screen_geometry LoRA term computed with PEFT's
15
+ exact operations (input cast to the adapter dtype, B(A(x)) * scaling, add,
16
+ cast back) only while that route is active. Route change flips one flag.
17
+ ``lora_bf16`` tolerance. As lean_lora, LoRA factors held in the base dtype (bf16).
18
+ ``merged_views`` tolerance. The screen_geometry route reads a second, merged bf16 copy of each
19
+ targeted weight (W + s*B@A rounded once); the default route reads W.
20
+ ``uint8_pixels`` exact. The processor resizes and patchifies as before but returns uint8
21
+ patches; they are uploaded from pinned memory and normalised on the device with
22
+ the processor's own fused mean/std arithmetic (elementwise, so identical).
23
+ ``gpu_preprocess`` tolerance. uint8_pixels plus the resize itself on the device.
24
+ ``device_readout`` exact. Option rows gathered with a cached device index tensor; one host copy.
25
+ ``cuda_graphs`` tolerance. The language model (and the last-position vocabulary row) is
26
+ replayed from CUDA graphs recorded at padded lengths, one set per route.
27
+ Right padding after the decision position cannot change a causal model's
28
+ output there; kernels chosen for the padded shape can round differently.
29
+ ``sdpa_flash`` tolerance. Scaled-dot-product attention restricted to the flash kernel.
30
+ ``compile_text`` tolerance. ``torch.compile(dynamic=True)`` on the language model.
31
+
32
+ One parameterised option changes outputs by design (it is neither exact nor tolerance-bound):
33
+
34
+ ``image_token_cap_default=N`` output-changing. As below, but only for questions whose
35
+ router-rules/v1 text route is ``default``; ``screen_geometry`` images stay
36
+ native. The two caps are alternatives.
37
+ ``image_token_cap=N`` output-changing. The native route's processor max pixels drop to
38
+ ``N * 32 * 32`` so every image keeps at most N merged image tokens
39
+ (``vision.native_image_tokens``); N in [256, 16384]. Weights, router and
40
+ calibration are unchanged. Off (no cap) unless given.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import contextlib
46
+ import dataclasses
47
+ import time
48
+ from typing import Any, Callable, Iterable, Mapping, Sequence
49
+
50
+ OPTIONS: Mapping[str, str] = {
51
+ "lean_lora": "exact: plain base layers + route-switched LoRA term with PEFT's operations",
52
+ "lora_bf16": "tolerance: route-switched LoRA term with bf16 factors",
53
+ "merged_views": "tolerance: screen_geometry route reads merged bf16 weight copies",
54
+ "uint8_pixels": "exact: uint8 patches, pinned upload, device normalisation",
55
+ "gpu_preprocess": "tolerance: uint8_pixels plus device resize",
56
+ "device_readout": "exact: cached device index for option rows, one host copy",
57
+ "cuda_graphs": "tolerance: language model replayed from CUDA graphs at padded lengths",
58
+ "sdpa_flash": "tolerance: SDPA restricted to the flash kernel",
59
+ "compile_text": "tolerance: torch.compile(dynamic=True) on the language model",
60
+ }
61
+ EXACT_OPTIONS = frozenset({"lean_lora", "uint8_pixels", "device_readout"})
62
+ IMAGE_TOKEN_CAP = "image_token_cap"
63
+ IMAGE_TOKEN_CAP_DEFAULT = "image_token_cap_default"
64
+ PARAMETERISED_OPTIONS: Mapping[str, str] = {
65
+ IMAGE_TOKEN_CAP: "output-changing: native-route processor max pixels = N x 32 x 32 (N tokens)",
66
+ IMAGE_TOKEN_CAP_DEFAULT: "output-changing: as image_token_cap, only for questions the text "
67
+ "rule routes to default (screen_geometry stays native)",
68
+ }
69
+ ROUTE_MODES = ("lean_lora", "lora_bf16", "merged_views")
70
+ # Padded lengths for the recorded graphs: 64-token steps to 1,024, then 128, 256 and 512.
71
+ GRAPH_LENGTHS = tuple(list(range(64, 1025, 64)) + list(range(1152, 2049, 128))
72
+ + list(range(2304, 4097, 256)) + list(range(4608, 8193, 512)))
73
+
74
+
75
+ class ServingOptionError(ValueError):
76
+ """Unknown or conflicting serving options."""
77
+
78
+
79
+ def _parameter(item: str) -> tuple[str, int] | None:
80
+ name, sep, raw = item.partition("=")
81
+ if not sep:
82
+ return None
83
+ name, raw = name.strip(), raw.strip()
84
+ if name not in PARAMETERISED_OPTIONS:
85
+ raise ServingOptionError(f"unknown serving option {name!r}; parameterised options: "
86
+ f"{sorted(PARAMETERISED_OPTIONS)}")
87
+ if not raw.isdigit():
88
+ raise ServingOptionError(f"{name} needs a positive integer, got {raw!r}")
89
+ from open_decisions.image_jev import vision
90
+
91
+ try:
92
+ return name, vision.validate_image_token_cap(int(raw))
93
+ except ValueError as error:
94
+ raise ServingOptionError(str(error)) from None
95
+
96
+
97
+ def parse_options(value: str | Iterable[str] | None) -> tuple[str, ...]:
98
+ """Validate options; return them in the canonical (``OPTIONS``) order.
99
+
100
+ A parameterised option (``image_token_cap=N``) follows the named options, normalised to
101
+ ``name=N``; giving it twice is refused.
102
+ """
103
+ if value is None:
104
+ return ()
105
+ items = [item.strip() for item in value.split(",")] if isinstance(value, str) else list(value)
106
+ items = [item for item in items if item]
107
+ parameters: dict[str, int] = {}
108
+ named = []
109
+ for item in items:
110
+ parsed = _parameter(item)
111
+ if parsed is None:
112
+ named.append(item)
113
+ continue
114
+ name, number = parsed
115
+ if name in parameters and parameters[name] != number:
116
+ raise ServingOptionError(f"{name} given twice ({parameters[name]} and {number})")
117
+ parameters[name] = number
118
+ if len(parameters) > 1:
119
+ raise ServingOptionError("image_token_cap and image_token_cap_default are alternatives")
120
+ items = named
121
+ unknown = sorted(set(items) - set(OPTIONS))
122
+ if unknown:
123
+ raise ServingOptionError(f"unknown serving options {unknown}; known: {sorted(OPTIONS)}"
124
+ f" and {sorted(f'{k}=N' for k in PARAMETERISED_OPTIONS)}")
125
+ chosen = set(items)
126
+ modes = [name for name in ROUTE_MODES if name in chosen]
127
+ if "lora_bf16" in chosen and "merged_views" in chosen:
128
+ raise ServingOptionError("lora_bf16 and merged_views are alternative route modes")
129
+ if "compile_text" in chosen and "cuda_graphs" in chosen:
130
+ raise ServingOptionError("compile_text and cuda_graphs are alternatives")
131
+ if "gpu_preprocess" in chosen:
132
+ chosen.add("uint8_pixels")
133
+ if modes and "lean_lora" not in chosen:
134
+ chosen.add("lean_lora") # lora_bf16/merged_views use the same route modules
135
+ return (tuple(name for name in OPTIONS if name in chosen)
136
+ + tuple(f"{name}={parameters[name]}" for name in PARAMETERISED_OPTIONS
137
+ if name in parameters))
138
+
139
+
140
+ def image_token_cap(options: Iterable[str], name: str = IMAGE_TOKEN_CAP) -> int | None:
141
+ """The ``image_token_cap=N`` value among parsed options, or None (no cap: the default)."""
142
+ for item in options:
143
+ parsed = _parameter(item)
144
+ if parsed is not None and parsed[0] == name:
145
+ return parsed[1]
146
+ return None
147
+
148
+
149
+ def image_token_cap_default(options: Iterable[str]) -> int | None:
150
+ """The route-conditioned ``image_token_cap_default=N`` value, or None."""
151
+ return image_token_cap(options, IMAGE_TOKEN_CAP_DEFAULT)
152
+
153
+
154
+ def is_exact(options: Iterable[str]) -> bool:
155
+ """True only for options that keep the served function bit-identical (no cap)."""
156
+ return set(options) <= EXACT_OPTIONS
157
+
158
+
159
+ def route_mode(options: Iterable[str]) -> str | None:
160
+ chosen = set(options)
161
+ if "merged_views" in chosen:
162
+ return "merged"
163
+ if "lora_bf16" in chosen:
164
+ return "bf16"
165
+ if "lean_lora" in chosen:
166
+ return "exact"
167
+ return None
168
+
169
+
170
+ # ---------------------------------------------------------------------------------- route modules
171
+ class RouteState:
172
+ """One shared flag: True while the screen_geometry route is served."""
173
+
174
+ __slots__ = ("on",)
175
+
176
+ def __init__(self, on: bool = True):
177
+ self.on = bool(on)
178
+
179
+
180
+ def _route_linear_class():
181
+ import torch
182
+ from torch import nn
183
+ from torch.nn import functional as F
184
+
185
+ class RouteLinear(nn.Module):
186
+ """A LoRA-targeted linear layer with the adapter switched by ``RouteState``.
187
+
188
+ ``exact`` replicates PEFT's vanilla LoRA forward operation by operation:
189
+ ``result = base(x); x = x.to(A.dtype); result = result + B(A(x)) * scaling;
190
+ result.to(result_dtype)``. Off-route it calls the original base layer only.
191
+ """
192
+
193
+ def __init__(self, base, lora_a, lora_b, scaling: float, state: RouteState, mode: str):
194
+ super().__init__()
195
+ if mode not in ("exact", "bf16", "merged"):
196
+ raise ValueError("unknown route mode")
197
+ self.base = base
198
+ self.state = state
199
+ self.mode = mode
200
+ self.scaling = float(scaling)
201
+ weight = base.weight
202
+ if mode == "merged":
203
+ delta = (lora_b.detach().float() @ lora_a.detach().float()) * self.scaling
204
+ merged = (weight.detach().float() + delta.to(weight.device)).to(weight.dtype)
205
+ self.register_buffer("merged_weight", merged, persistent=False)
206
+ self.lora_a = self.lora_b = None
207
+ else:
208
+ dtype = weight.dtype if mode == "bf16" else lora_a.dtype
209
+ self.register_buffer("lora_a", lora_a.detach().to(dtype), persistent=False)
210
+ self.register_buffer("lora_b", lora_b.detach().to(dtype), persistent=False)
211
+
212
+ @property
213
+ def weight(self): # modules that inspect .weight keep working
214
+ return self.base.weight
215
+
216
+ def forward(self, x):
217
+ if not self.state.on:
218
+ return self.base(x)
219
+ if self.mode == "merged":
220
+ return F.linear(x, self.merged_weight, self.base.bias)
221
+ result = self.base(x)
222
+ result_dtype = result.dtype
223
+ if x.dtype != self.lora_a.dtype:
224
+ x = x.to(dtype=self.lora_a.dtype)
225
+ result = result + F.linear(F.linear(x, self.lora_a), self.lora_b) * self.scaling
226
+ return result.to(result_dtype)
227
+
228
+ return RouteLinear, torch
229
+
230
+
231
+ def _lora_layers(root) -> list[tuple[str, Any]]:
232
+ return [(name, module) for name, module in root.named_modules()
233
+ if hasattr(module, "base_layer") and hasattr(module, "lora_A")
234
+ and hasattr(module, "lora_B") and hasattr(module, "scaling")]
235
+
236
+
237
+ def install_route_modules(peft_model, *, adapter_name: str, mode: str):
238
+ """Replace PEFT LoRA wrappers by ``RouteLinear``; return (plain model, state, record)."""
239
+ from torch import nn
240
+
241
+ RouteLinear, torch = _route_linear_class()
242
+ root = peft_model.get_base_model() if hasattr(peft_model, "get_base_model") else peft_model
243
+ state = RouteState(True)
244
+ layers = _lora_layers(root)
245
+ if not layers:
246
+ raise ServingOptionError("no LoRA layers found to replace")
247
+ extra_bytes = 0
248
+ for name, module in layers:
249
+ if not isinstance(module.base_layer, nn.Linear):
250
+ raise ServingOptionError(f"{name}: LoRA on a non-linear layer is not supported")
251
+ if adapter_name not in module.lora_A or adapter_name not in module.lora_B:
252
+ raise ServingOptionError(f"{name}: adapter {adapter_name!r} missing")
253
+ if adapter_name in getattr(module, "lora_variant", {}) or getattr(module, "merged", False):
254
+ raise ServingOptionError(f"{name}: DoRA/variant or merged LoRA is not supported")
255
+ if getattr(module, "fan_in_fan_out", False):
256
+ raise ServingOptionError(f"{name}: fan_in_fan_out is not supported")
257
+ dropout = module.lora_dropout[adapter_name]
258
+ if not isinstance(dropout, nn.Identity) and getattr(dropout, "p", 0.0) != 0.0:
259
+ raise ServingOptionError(f"{name}: LoRA dropout must be zero at serving")
260
+ lora_a, lora_b = module.lora_A[adapter_name], module.lora_B[adapter_name]
261
+ if getattr(lora_a, "bias", None) is not None or getattr(lora_b, "bias", None) is not None:
262
+ raise ServingOptionError(f"{name}: LoRA bias is not supported")
263
+ replacement = RouteLinear(module.base_layer, lora_a.weight, lora_b.weight,
264
+ module.scaling[adapter_name], state, mode)
265
+ if mode == "merged":
266
+ extra_bytes += replacement.merged_weight.numel() * replacement.merged_weight.element_size()
267
+ parent_name, _, child = name.rpartition(".")
268
+ parent = root.get_submodule(parent_name) if parent_name else root
269
+ setattr(parent, child, replacement)
270
+ del peft_model
271
+ if torch.cuda.is_available():
272
+ torch.cuda.empty_cache()
273
+ record = {"route_mode": mode, "replaced_layers": len(layers),
274
+ "merged_view_bytes": extra_bytes}
275
+ return root.eval(), state, record
276
+
277
+
278
+ class RouteSwitch:
279
+ """Adapter context for ``RouteLinear`` models: one flag assignment per route change."""
280
+
281
+ def __init__(self, state: RouteState):
282
+ self.state = state
283
+
284
+ def __call__(self, route):
285
+ from open_decisions.image_jev import router
286
+
287
+ if route not in router.ROUTES:
288
+ raise ValueError("unknown adapter route")
289
+ self.state.on = route == router.SCREEN_GEOMETRY
290
+ return contextlib.nullcontext()
291
+
292
+
293
+ # ------------------------------------------------------------------------------------ processor
294
+ class PixelProcessor:
295
+ """Processor proxy returning device-normalised pixels from uint8 patches.
296
+
297
+ The image processor normalises before patchifying; normalisation is per channel and
298
+ elementwise and patchify is a permutation plus temporal duplication, so normalising the
299
+ uint8 patches with the same fused mean/std tensors gives identical float32 values.
300
+ """
301
+
302
+ def __init__(self, processor, device, *, gpu_resize: bool = False):
303
+ object.__setattr__(self, "_inner", processor)
304
+ object.__setattr__(self, "_device", device)
305
+ object.__setattr__(self, "_gpu_resize", bool(gpu_resize))
306
+ object.__setattr__(self, "_norm", None)
307
+
308
+ def __getattr__(self, name):
309
+ return getattr(self._inner, name)
310
+
311
+ def __setattr__(self, name, value):
312
+ setattr(self._inner, name, value)
313
+
314
+ def _mean_std(self, device):
315
+ import torch
316
+
317
+ if self._norm is None or self._norm[0] != device:
318
+ ip = self._inner.image_processor
319
+ mean = torch.tensor(ip.image_mean, device=device) * (1.0 / ip.rescale_factor)
320
+ std = torch.tensor(ip.image_std, device=device) * (1.0 / ip.rescale_factor)
321
+ per = int(ip.temporal_patch_size) * int(ip.patch_size) * int(ip.patch_size)
322
+ object.__setattr__(self, "_norm", (device, mean.to(torch.float32).repeat_interleave(per),
323
+ std.to(torch.float32).repeat_interleave(per)))
324
+ return self._norm[1], self._norm[2]
325
+
326
+ def normalize(self, patches):
327
+ import torch
328
+
329
+ ip = self._inner.image_processor
330
+ if not (getattr(ip, "do_rescale", True) and getattr(ip, "do_normalize", True)):
331
+ raise RuntimeError("uint8_pixels expects the processor's default rescale+normalize")
332
+ mean, std = self._mean_std(patches.device)
333
+ return patches.to(dtype=torch.float32).sub_(mean).div_(std)
334
+
335
+ def __call__(self, *args, **kwargs):
336
+ import torch
337
+
338
+ if kwargs.get("images") is None and len(args) < 2:
339
+ return self._inner(*args, **kwargs)
340
+ images_kwargs = dict(kwargs.pop("images_kwargs", None) or {})
341
+ images_kwargs.update(do_rescale=False, do_normalize=False)
342
+ if self._gpu_resize and self._device is not None:
343
+ images_kwargs["device"] = self._device
344
+ output = self._inner(*args, images_kwargs=images_kwargs, **kwargs)
345
+ pixels = output["pixel_values"]
346
+ if pixels.dtype != torch.uint8:
347
+ raise RuntimeError(f"expected uint8 patches, got {pixels.dtype}")
348
+ device = self._device
349
+ if device is not None and str(device).startswith("cuda") and pixels.device.type == "cpu":
350
+ pixels = pixels.pin_memory().to(device, non_blocking=True)
351
+ elif device is not None and pixels.device != torch.device(device):
352
+ pixels = pixels.to(device)
353
+ output["pixel_values"] = self.normalize(pixels)
354
+ return output
355
+
356
+
357
+ # --------------------------------------------------------------------------------------- readout
358
+ class DeviceReadout:
359
+ """``vision.option_probs`` with a cached device index (same operations after the gather)."""
360
+
361
+ def __init__(self):
362
+ self._index: dict[tuple, Any] = {}
363
+
364
+ def __call__(self, logits, letter_token_ids, temperature: float) -> list[float]:
365
+ import math
366
+
367
+ import torch
368
+
369
+ if not math.isfinite(temperature) or temperature <= 0:
370
+ raise ValueError("temperature must be finite and greater than zero")
371
+ key = (str(logits.device), tuple(letter_token_ids))
372
+ index = self._index.get(key)
373
+ if index is None:
374
+ if any(not 0 <= int(i) < logits.numel() for i in letter_token_ids):
375
+ raise ValueError("letter_token_ids must be valid vocabulary indices")
376
+ index = torch.tensor(list(letter_token_ids), dtype=torch.long, device=logits.device)
377
+ self._index[key] = index
378
+ selected = logits.index_select(0, index).to(dtype=torch.float32) / temperature
379
+ values = torch.log_softmax(selected, dim=-1).exp().detach().cpu().tolist()
380
+ if not all(math.isfinite(value) for value in values):
381
+ raise ValueError("option probabilities are non-finite")
382
+ return values
383
+
384
+
385
+ # ---------------------------------------------------------------------------------------- graphs
386
+ def hf_model(model):
387
+ return model.get_base_model() if hasattr(model, "get_base_model") else model
388
+
389
+
390
+ def _move(value, device):
391
+ from open_decisions.image_jev.speed import move_to_device
392
+
393
+ return move_to_device(value, device)
394
+
395
+
396
+ def embeds_positions(model, prepared, device):
397
+ """The multimodal forward up to the language model (the model's own methods, same order)."""
398
+ import torch
399
+
400
+ top = hf_model(model)
401
+ core = top.model
402
+ ids = _move(prepared.input_ids, device)
403
+ pixels = _move(prepared.pixel_values, device)
404
+ grid = _move(prepared.image_grid_thw, device)
405
+ mm = getattr(prepared, "mm_token_type_ids", None)
406
+ mm = None if mm is None else _move(mm, device)
407
+ embeds = core.get_input_embeddings()(ids)
408
+ if pixels is not None:
409
+ features = core.get_image_features(pixels, grid, return_dict=True).pooler_output
410
+ features = torch.cat(features, dim=0).to(embeds.device, embeds.dtype)
411
+ mask, _ = core.get_placeholder_mask(ids, inputs_embeds=embeds, image_features=features)
412
+ embeds = embeds.masked_scatter(mask, features)
413
+ positions = core.compute_3d_position_ids(
414
+ input_ids=ids, inputs_embeds=embeds, image_grid_thw=grid, video_grid_thw=None,
415
+ attention_mask=None, past_key_values=None, mm_token_type_ids=mm)
416
+ return embeds, positions
417
+
418
+
419
+ def pad_positions(positions, size: int):
420
+ """Right-pad (3, 1, n) position ids to ``size`` by continuing each axis from its last value."""
421
+ import torch
422
+
423
+ n = positions.shape[-1]
424
+ if size < n:
425
+ raise ValueError("padded size is shorter than the sequence")
426
+ if size == n:
427
+ return positions
428
+ tail = positions[..., -1:] + torch.arange(1, size - n + 1, device=positions.device,
429
+ dtype=positions.dtype)
430
+ return torch.cat((positions, tail), dim=-1)
431
+
432
+
433
+ def eager_last_logits(model, embeds, positions):
434
+ top = hf_model(model)
435
+ hidden = top.model.language_model(input_ids=None, position_ids=positions, attention_mask=None,
436
+ past_key_values=None, inputs_embeds=embeds,
437
+ use_cache=False).last_hidden_state
438
+ return top.lm_head(hidden[:, -1:, :])[0, 0]
439
+
440
+
441
+ class DecoderGraphs:
442
+ """CUDA graphs of language model + last-row vocabulary projection at padded lengths."""
443
+
444
+ def __init__(self, model, *, lengths: Sequence[int], routes: Sequence[Any],
445
+ set_route: Callable[[Any], Any] | None, device, log=print):
446
+ import torch
447
+
448
+ top = hf_model(model)
449
+ self.model = model
450
+ lm, head = top.model.language_model, top.lm_head
451
+ hidden = int(top.config.text_config.hidden_size)
452
+ dtype = next(lm.parameters()).dtype
453
+ self.graphs: dict[tuple[Any, int], tuple] = {}
454
+ self.lengths = sorted(set(int(n) for n in lengths))
455
+ started = time.monotonic()
456
+ pool = None
457
+ with torch.inference_mode():
458
+ for route in routes:
459
+ if set_route is not None:
460
+ set_route(route)
461
+ for n in sorted(self.lengths, reverse=True): # longest first; shorter reuse pool
462
+ embeds = torch.zeros(1, n, hidden, device=device, dtype=dtype)
463
+ positions = torch.arange(n, device=device).view(1, 1, n).expand(3, 1, n).contiguous()
464
+ last = torch.zeros(1, dtype=torch.long, device=device)
465
+
466
+ def step(embeds=embeds, positions=positions, last=last):
467
+ state = lm(input_ids=None, position_ids=positions, attention_mask=None,
468
+ past_key_values=None, inputs_embeds=embeds,
469
+ use_cache=False).last_hidden_state
470
+ return head(state[0].index_select(0, last))[0]
471
+
472
+ side = torch.cuda.Stream()
473
+ side.wait_stream(torch.cuda.current_stream())
474
+ with torch.cuda.stream(side):
475
+ for _ in range(3):
476
+ step()
477
+ torch.cuda.current_stream().wait_stream(side)
478
+ graph = torch.cuda.CUDAGraph()
479
+ with torch.cuda.graph(graph, pool=pool):
480
+ out = step()
481
+ pool = graph.pool()
482
+ self.graphs[(route, n)] = (graph, embeds, positions, last, out)
483
+ self.capture_seconds = time.monotonic() - started
484
+ self.routes = list(routes)
485
+ log(f"captured {len(self.graphs)} graphs in {self.capture_seconds:.1f} s")
486
+
487
+ def fits(self, n: int) -> bool:
488
+ return bool(self.lengths) and n <= self.lengths[-1]
489
+
490
+ def run(self, route, embeds, positions):
491
+ n = embeds.shape[1]
492
+ size = next(x for x in self.lengths if x >= n)
493
+ graph, static_embeds, static_positions, last, out = self.graphs[(route, size)]
494
+ static_embeds.zero_()
495
+ static_embeds[:, :n].copy_(embeds)
496
+ static_positions.copy_(pad_positions(positions, size))
497
+ last.fill_(n - 1)
498
+ graph.replay()
499
+ return out.clone()
500
+
501
+
502
+ class GraphLogits:
503
+ """``logits_fn`` for the Predictor: eager multimodal front, graph-replayed language model."""
504
+
505
+ def __init__(self, graphs: DecoderGraphs | None, device, *, route_key: Callable[[Any], Any]):
506
+ self.graphs, self.device, self.route_key = graphs, device, route_key
507
+ self.replayed = self.eager = 0
508
+
509
+ def __call__(self, model, prepared, *, timer, route=None):
510
+ from open_decisions.image_jev.speed import PhaseTimer
511
+
512
+ timer = timer or PhaseTimer()
513
+ with timer.phase("forward"):
514
+ embeds, positions = embeds_positions(model, prepared, self.device)
515
+ n = embeds.shape[1]
516
+ if self.graphs is not None and self.graphs.fits(n):
517
+ self.replayed += 1
518
+ return self.graphs.run(self.route_key(route), embeds, positions)
519
+ self.eager += 1
520
+ return eager_last_logits(model, embeds, positions)
521
+
522
+
523
+ # ------------------------------------------------------------------------------------------ apply
524
+ @dataclasses.dataclass
525
+ class Serving:
526
+ model: Any
527
+ processor: Any
528
+ adapter_context: Callable | None
529
+ inference_context: Callable[[], Any] | None
530
+ logits_fn: Callable | None
531
+ readout_fn: Callable | None
532
+ record: dict
533
+ image_token_cap: int | None = None
534
+ image_token_cap_default: int | None = None
535
+
536
+
537
+ def _sdpa_flash_context():
538
+ from torch.nn.attention import SDPBackend, sdpa_kernel
539
+
540
+ return sdpa_kernel([SDPBackend.FLASH_ATTENTION])
541
+
542
+
543
+ def apply_serving_options(model, processor, options: Sequence[str], *, device, routed: bool,
544
+ adapter_name: str | None, adapter_context: Callable | None,
545
+ inference_context: Callable[[], Any] | None,
546
+ graph_lengths: Sequence[int] = GRAPH_LENGTHS, log=print) -> Serving:
547
+ """Apply validated ``options`` to a loaded (PEFT or plain) model and its processor."""
548
+ import torch
549
+
550
+ options = parse_options(options)
551
+ record: dict[str, Any] = {"serving_options": list(options), "exact": is_exact(options)}
552
+ cap = image_token_cap(options)
553
+ if cap is not None:
554
+ from open_decisions.image_jev import vision
555
+
556
+ record["image_token_cap"] = cap
557
+ record["native_max_pixels"] = vision.native_max_pixels(cap)
558
+ cap_default = image_token_cap_default(options)
559
+ if cap_default is not None:
560
+ from open_decisions.image_jev import vision
561
+
562
+ record["image_token_cap_default"] = cap_default
563
+ record["native_max_pixels_default_route"] = vision.native_max_pixels(cap_default)
564
+ started = time.monotonic()
565
+ mode = route_mode(options)
566
+ if mode is not None:
567
+ if not routed or adapter_name is None:
568
+ raise ServingOptionError("route modes need the routed single-adapter system")
569
+ model, state, route_record = install_route_modules(model, adapter_name=adapter_name,
570
+ mode=mode)
571
+ adapter_context = RouteSwitch(state)
572
+ record.update(route_record)
573
+ if "uint8_pixels" in options:
574
+ processor = PixelProcessor(processor, device, gpu_resize="gpu_preprocess" in options)
575
+ readout_fn = DeviceReadout() if "device_readout" in options else None
576
+ if "sdpa_flash" in options:
577
+ base_context = inference_context
578
+
579
+ @contextlib.contextmanager
580
+ def flash_context():
581
+ with (base_context() if base_context is not None else contextlib.nullcontext()):
582
+ with _sdpa_flash_context():
583
+ yield
584
+
585
+ inference_context = flash_context
586
+ if "compile_text" in options:
587
+ core = hf_model(model).model
588
+ core.language_model = torch.compile(core.language_model, dynamic=True)
589
+ record["compile"] = "torch.compile(language_model, dynamic=True)"
590
+ logits_fn = None
591
+ if "cuda_graphs" in options:
592
+ if not str(device).startswith("cuda"):
593
+ raise ServingOptionError("cuda_graphs needs a CUDA device")
594
+ from open_decisions.image_jev import router
595
+
596
+ routes = list(router.ROUTES) if routed else [None]
597
+ set_route = adapter_context if routed else None
598
+ context = inference_context() if inference_context is not None else contextlib.nullcontext()
599
+ with context:
600
+ graphs = DecoderGraphs(model, lengths=graph_lengths, routes=routes,
601
+ set_route=set_route, device=device, log=log)
602
+ logits_fn = GraphLogits(graphs, device, route_key=(lambda route: route) if routed
603
+ else (lambda route: None))
604
+ record.update({"graph_count": len(graphs.graphs), "graph_lengths": list(graphs.lengths),
605
+ "graph_capture_seconds": round(graphs.capture_seconds, 2)})
606
+ record["apply_seconds"] = round(time.monotonic() - started, 2)
607
+ return Serving(model=model, processor=processor, adapter_context=adapter_context,
608
+ inference_context=inference_context, logits_fn=logits_fn,
609
+ readout_fn=readout_fn, record=record, image_token_cap=cap,
610
+ image_token_cap_default=cap_default)
611
+
612
+
613
+ __all__ = [
614
+ "DecoderGraphs", "DeviceReadout", "EXACT_OPTIONS", "GRAPH_LENGTHS", "GraphLogits",
615
+ "IMAGE_TOKEN_CAP", "IMAGE_TOKEN_CAP_DEFAULT", "OPTIONS", "PARAMETERISED_OPTIONS",
616
+ "image_token_cap", "image_token_cap_default",
617
+ "PixelProcessor", "RouteState", "RouteSwitch", "Serving", "ServingOptionError",
618
+ "apply_serving_options", "install_route_modules", "is_exact", "pad_positions",
619
+ "parse_options", "route_mode",
620
+ ]
open_decisions/image_jev/release/predictor.py CHANGED
@@ -228,16 +228,134 @@ class RoutedCalibration:
228
  return self.route_type_temperatures[route_name].get(bucket, self.global_temperature)
229
 
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  def serving_calibration_from_artifact(
232
  value: Mapping[str, Any], *, checkpoint_sha256: str | None = "",
233
  adapter_sha256: str | None = None, base: Mapping[str, str] | None = None,
234
- ) -> Calibration | RoutedCalibration:
 
235
  if not isinstance(value, Mapping):
236
  raise ValueError("calibration artifact must be an object")
237
  if value.get("version") == "image-jev/router-calibration/v1":
 
 
238
  return RoutedCalibration.from_artifact(
239
  value, adapter_sha256=adapter_sha256, base=base
240
  )
 
 
 
 
 
 
 
 
241
  return Calibration.from_artifact(value, checkpoint_sha256=checkpoint_sha256)
242
 
243
 
@@ -353,13 +471,29 @@ def _length(value: Any) -> int:
353
  class Predictor:
354
  """One prefill per question; calibrated option probabilities in the board's shape."""
355
 
356
- def __init__(self, model, processor, calibration: Calibration | RoutedCalibration, *, model_name: str,
357
  context_policy: str = "prepend", max_input_tokens: int | None = None,
358
  inference_context: Callable[[], Any] | None = None,
359
  adapter_context: Callable[[str], Any] | None = None,
360
- synchronize: Callable[[], Any] | None = None, forward_call=None) -> None:
 
 
 
 
361
  if context_policy not in CONTEXT_POLICIES:
362
  raise ValueError(f"context_policy must be one of {CONTEXT_POLICIES}")
 
 
 
 
 
 
 
 
 
 
 
 
363
  self.model, self.processor, self.calibration = model, processor, calibration
364
  self.model_name = model_name
365
  self.context_policy = context_policy
@@ -368,18 +502,28 @@ class Predictor:
368
  self._adapter_context = adapter_context
369
  self._synchronize = synchronize
370
  self._forward_call = forward_call
 
 
 
371
  self._cache = NativeCache(processor)
372
  try:
373
  self.device = next(model.parameters()).device
374
  except (StopIteration, AttributeError):
375
  self.device = None
376
 
377
- def _read(self, images: Sequence, question: Question, *, loaded_images=None, timer=None) -> tuple[list[float], int]:
 
378
  example = {"question": question.instructions, "options": list(question.options),
379
  "images": list(images)}
 
 
 
 
 
380
  try:
381
  prepared = vision.prepare_native(example, self.processor, loaded_images=loaded_images,
382
- cache=self._cache, timer=timer, device=self.device)
 
383
  except (TypeError, ValueError) as error:
384
  raise RequestError(str(error)) from error
385
  tokens = _length(prepared.input_ids)
@@ -390,8 +534,8 @@ class Predictor:
390
  )
391
  with timer.phase("route_adapter_switch"):
392
  route_name = None
393
- if isinstance(self.calibration, RoutedCalibration):
394
- route_name = router.route(
395
  question.route_instructions or question.instructions, question.options
396
  )
397
  temperature = self.calibration.temperature(len(question.options), route_name)
@@ -406,11 +550,18 @@ class Predictor:
406
  )
407
  with route_context:
408
  with inference_context:
409
- logits = vision.last_position_logits(
410
- self.model, prepared, timer=timer, forward_call=self._forward_call)
 
 
 
411
  with timer.phase("readout_calibration"):
412
- probabilities = vision.option_probs(
413
- logits, prepared.letter_token_ids, temperature=temperature)
 
 
 
 
414
  with timer.phase("readout_calibration"):
415
  total = math.fsum(probabilities)
416
  return [value / total for value in probabilities], tokens
@@ -421,7 +572,16 @@ class Predictor:
421
  images, questions = parse_request(body, context_policy=self.context_policy)
422
  return self.predict_questions(images, questions, timer=timer)
423
 
424
- def predict_questions(self, images, questions, *, timer=None) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
425
  timer = timer or PhaseTimer()
426
  with timer.phase("decode_load"):
427
  try:
@@ -431,7 +591,8 @@ class Predictor:
431
  raise RequestError(str(error)) from error
432
  answers, input_tokens = {}, 0
433
  for question in questions:
434
- probabilities, tokens = self._read(images, question, loaded_images=loaded, timer=timer)
 
435
  with timer.phase("response_build"):
436
  input_tokens += tokens
437
  names = [option["name"] for option in question.options]
@@ -507,9 +668,21 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
507
  adapter_path: str | Path | None = None,
508
  base_hashes_path: str | Path | None = None, device: str = "cuda",
509
  context_policy: str = "prepend", max_input_tokens: int | None = None,
510
- allow_slow_path: bool = False) -> tuple[Predictor, dict[str, Any]]:
511
- """Load the pinned frozen base in bf16 and return the predictor and its provenance."""
 
 
 
 
 
 
 
 
 
 
 
512
  import platform
 
513
 
514
  import torch
515
  import transformers
@@ -518,19 +691,35 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
518
 
519
  if not allow_slow_path:
520
  check_fast_path()
 
 
521
  adapter = None if adapter_path is None else Path(adapter_path)
522
  adapter_sha256 = "" if adapter is None else tree_sha256(adapter)
 
 
 
523
  try:
524
  calibration_value = json.loads(Path(calibration_path).read_text(encoding="utf-8"))
525
  except (OSError, json.JSONDecodeError) as error:
526
  raise ValueError(f"cannot load calibration {calibration_path}: {error}") from error
527
  base_pin = {"repo": BASE_REPO, "revision": BASE_REVISION}
528
- calibration = serving_calibration_from_artifact(
529
- calibration_value, checkpoint_sha256=adapter_sha256,
530
- adapter_sha256=adapter_sha256 or None, base=base_pin,
531
- )
532
- routed = isinstance(calibration, RoutedCalibration)
533
- if routed and adapter is None:
 
 
 
 
 
 
 
 
 
 
 
534
  raise ValueError("routed calibration requires the screen_geometry adapter")
535
  snapshot = Path(snapshot_download(BASE_REPO, revision=BASE_REVISION, cache_dir=model_cache,
536
  local_files_only=offline))
@@ -545,20 +734,53 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
545
  transformers, "AutoModelForImageTextToText")
546
  model = auto_model.from_pretrained(str(snapshot), torch_dtype=torch.bfloat16,
547
  device_map=device, trust_remote_code=False, attn_implementation="sdpa")
548
- if adapter is not None:
549
- from peft import PeftModel
 
550
 
551
- loaded = PeftModel.from_pretrained(
552
- model, str(adapter), **({"adapter_name": router.SCREEN_GEOMETRY} if routed else {})
 
 
 
 
553
  )
554
- model = loaded if routed else loaded.merge_and_unload()
555
- model = model.eval()
556
- adapter_context = AdapterSwitch(model) if routed else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  predictor = Predictor(model, processor, calibration, model_name=model_name,
558
  context_policy=context_policy, max_input_tokens=max_input_tokens,
559
- inference_context=torch.inference_mode,
560
  adapter_context=adapter_context,
561
- synchronize=(torch.cuda.synchronize if str(device).startswith("cuda") else None))
 
 
562
  provenance = {
563
  "base_repo": BASE_REPO, "base_revision": BASE_REVISION,
564
  "base_files_verified": verified,
@@ -570,17 +792,33 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
570
  "system_kind": "routed" if routed else "single-route",
571
  "router_rules_version": router.RULES_VERSION if routed else None,
572
  "router_rules_sha256": router.RULES_SHA256 if routed else None,
573
- "adapter_switching": "PEFT layer enable/disable on route transition" if routed else None,
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  "precision": "bfloat16", "attention": "sdpa", "processor_use_fast": True, "scorer": "one-prefill-option-letter",
575
  "context_policy": context_policy, "max_input_tokens": max_input_tokens,
576
  "torch_version": torch.__version__, "transformers_version": transformers.__version__,
577
  "python_version": platform.python_version(),
578
  }
 
 
579
  return predictor, provenance
580
 
581
 
582
  __all__ = [
583
- "BASE_REPO", "BASE_REVISION", "Calibration", "RoutedCalibration", "Predictor", "Question", "RequestError",
 
584
  "TYPE_BUCKET_BY_OPTION_COUNT", "check_fast_path", "load_predictor", "parse_request",
585
  "serving_calibration_from_artifact", "tree_sha256", "verify_snapshot",
586
  ]
 
228
  return self.route_type_temperatures[route_name].get(bucket, self.global_temperature)
229
 
230
 
231
+ _ROUTED_V2_CALIBRATION_FIELDS = {
232
+ "adapter_sha256_by_route", "artifact_sha256", "base", "image_route",
233
+ "image_route_config_sha256", "regularization", "route_global_temperatures",
234
+ "route_type_counts", "route_type_temperatures", "rules_sha256", "rules_version",
235
+ "sources", "version",
236
+ }
237
+ ROUTED_V2_CALIBRATION_VERSION = "image-jev/router-calibration/v2"
238
+
239
+
240
+ def _sha256_text(value: Any) -> bool:
241
+ return (isinstance(value, str) and len(value) == 64
242
+ and all(char in "0123456789abcdef" for char in value))
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class RoutedCalibrationV2:
247
+ """Serving view of ``image-jev/router-calibration/v2`` (one adapter per route)."""
248
+
249
+ route_global_temperatures: Mapping[str, float]
250
+ route_type_temperatures: Mapping[str, Mapping[str, float]]
251
+ artifact_sha256: str
252
+ identity_sha256: str
253
+ adapter_sha256_by_route: Mapping[str, str]
254
+ base: Mapping[str, str]
255
+ rules_sha256: str
256
+
257
+ @classmethod
258
+ def from_artifact(
259
+ cls, value: Mapping[str, Any], *,
260
+ adapter_sha256_by_route: Mapping[str, str] | None = None,
261
+ base: Mapping[str, str] | None = None,
262
+ ) -> "RoutedCalibrationV2":
263
+ if not isinstance(value, Mapping) or set(value) != _ROUTED_V2_CALIBRATION_FIELDS:
264
+ raise ValueError("routed calibration v2 artifact fields differ")
265
+ body = {key: item for key, item in value.items() if key != "artifact_sha256"}
266
+ if value["artifact_sha256"] != hashlib.sha256(_compact(body)).hexdigest():
267
+ raise ValueError("routed calibration artifact hash differs")
268
+ if value["version"] != ROUTED_V2_CALIBRATION_VERSION:
269
+ raise ValueError("routed calibration artifact version differs")
270
+ if value["rules_version"] != router.RULES_VERSION \
271
+ or value["rules_sha256"] != router.RULES_SHA256:
272
+ raise ValueError("routed calibration rules binding differs")
273
+ if value["image_route"] != IMAGE_ROUTE \
274
+ or value["image_route_config_sha256"] != vision.IMAGE_ROUTE_CONFIG_SHA256[IMAGE_ROUTE]:
275
+ raise ValueError("routed calibration image-route binding differs")
276
+ hashes = value["adapter_sha256_by_route"]
277
+ if (not isinstance(hashes, Mapping) or set(hashes) != set(router.ROUTES)
278
+ or not all(_sha256_text(hashes[name]) for name in router.ROUTES)):
279
+ raise ValueError("routed calibration adapter hashes are invalid")
280
+ if adapter_sha256_by_route is not None and dict(hashes) != dict(adapter_sha256_by_route):
281
+ raise ValueError("routed calibration adapter binding differs")
282
+ if not isinstance(value["base"], Mapping) or set(value["base"]) != {"repo", "revision"}:
283
+ raise ValueError("routed calibration base pin differs")
284
+ if base is not None and value["base"] != dict(base):
285
+ raise ValueError("routed calibration base binding differs")
286
+ globals_ = value["route_global_temperatures"]
287
+ route_temperatures = value["route_type_temperatures"]
288
+ route_counts = value["route_type_counts"]
289
+ for name, mapping in (("global", globals_), ("temperature", route_temperatures),
290
+ ("count", route_counts), ("source", value["sources"])):
291
+ if not isinstance(mapping, Mapping) or set(mapping) != set(router.ROUTES):
292
+ raise ValueError(f"routed calibration {name} routes differ")
293
+ checked = {}
294
+ for route_name in router.ROUTES:
295
+ values = route_temperatures[route_name]
296
+ counts = route_counts[route_name]
297
+ if (
298
+ not isinstance(values, Mapping)
299
+ or set(values) != set(TYPE_BUCKET_BY_OPTION_COUNT.values())
300
+ or not isinstance(counts, Mapping)
301
+ or set(counts) != set(TYPE_BUCKET_BY_OPTION_COUNT.values())
302
+ ):
303
+ raise ValueError("routed calibration temperatures must be objects")
304
+ if any(
305
+ isinstance(count, bool) or not isinstance(count, int) or count < 0
306
+ for count in counts.values()
307
+ ):
308
+ raise ValueError("routed calibration counts are invalid")
309
+ checked[route_name] = {
310
+ str(kind): _temperature(item, f"route_type_temperatures[{route_name}][{kind}]")
311
+ for kind, item in values.items()
312
+ }
313
+ return cls(
314
+ route_global_temperatures={
315
+ name: _temperature(globals_[name], f"route_global_temperatures[{name}]")
316
+ for name in router.ROUTES
317
+ },
318
+ route_type_temperatures=checked,
319
+ artifact_sha256=str(value["artifact_sha256"]),
320
+ identity_sha256=hashlib.sha256(_compact(dict(value))).hexdigest(),
321
+ adapter_sha256_by_route=dict(hashes), base=dict(value["base"]),
322
+ rules_sha256=str(value["rules_sha256"]),
323
+ )
324
+
325
+ def temperature(self, n_options: int, route_name: str) -> float:
326
+ if route_name not in router.ROUTES:
327
+ raise ValueError("unknown routed calibration route")
328
+ bucket = TYPE_BUCKET_BY_OPTION_COUNT.get(int(n_options))
329
+ if bucket is None:
330
+ return self.route_global_temperatures[route_name]
331
+ return self.route_type_temperatures[route_name].get(
332
+ bucket, self.route_global_temperatures[route_name])
333
+
334
+
335
+ ROUTED_CALIBRATIONS = (RoutedCalibration, RoutedCalibrationV2)
336
+
337
+
338
  def serving_calibration_from_artifact(
339
  value: Mapping[str, Any], *, checkpoint_sha256: str | None = "",
340
  adapter_sha256: str | None = None, base: Mapping[str, str] | None = None,
341
+ adapter_sha256_by_route: Mapping[str, str] | None = None,
342
+ ) -> Calibration | RoutedCalibration | RoutedCalibrationV2:
343
  if not isinstance(value, Mapping):
344
  raise ValueError("calibration artifact must be an object")
345
  if value.get("version") == "image-jev/router-calibration/v1":
346
+ if adapter_sha256_by_route is not None:
347
+ raise ValueError("router-calibration/v1 binds one adapter, not one per route")
348
  return RoutedCalibration.from_artifact(
349
  value, adapter_sha256=adapter_sha256, base=base
350
  )
351
+ if value.get("version") == ROUTED_V2_CALIBRATION_VERSION:
352
+ if adapter_sha256 is not None:
353
+ raise ValueError("router-calibration/v2 binds one adapter per route")
354
+ return RoutedCalibrationV2.from_artifact(
355
+ value, adapter_sha256_by_route=adapter_sha256_by_route, base=base
356
+ )
357
+ if adapter_sha256_by_route is not None:
358
+ raise ValueError("single-route calibration cannot bind route adapters")
359
  return Calibration.from_artifact(value, checkpoint_sha256=checkpoint_sha256)
360
 
361
 
 
471
  class Predictor:
472
  """One prefill per question; calibrated option probabilities in the board's shape."""
473
 
474
+ def __init__(self, model, processor, calibration: Calibration | RoutedCalibration | RoutedCalibrationV2, *, model_name: str,
475
  context_policy: str = "prepend", max_input_tokens: int | None = None,
476
  inference_context: Callable[[], Any] | None = None,
477
  adapter_context: Callable[[str], Any] | None = None,
478
+ synchronize: Callable[[], Any] | None = None, forward_call=None,
479
+ logits_fn: Callable[..., Any] | None = None,
480
+ readout_fn: Callable[..., list[float]] | None = None,
481
+ image_token_cap: int | None = None,
482
+ image_token_cap_default: int | None = None) -> None:
483
  if context_policy not in CONTEXT_POLICIES:
484
  raise ValueError(f"context_policy must be one of {CONTEXT_POLICIES}")
485
+ # Optional native-route image-token cap (``image_token_cap=N`` serving option); None keeps
486
+ # the processor's own maximum, i.e. the published behaviour.
487
+ self.image_token_cap = (None if image_token_cap is None
488
+ else vision.validate_image_token_cap(image_token_cap))
489
+ # Route-conditioned variant (``image_token_cap_default=N``): the cap applies only when the
490
+ # question's text rule (router-rules/v1) routes it to ``default``; screen_geometry stays
491
+ # native. Decided on the text route even under ``route_override``.
492
+ self.image_token_cap_default = (
493
+ None if image_token_cap_default is None
494
+ else vision.validate_image_token_cap(image_token_cap_default))
495
+ if self.image_token_cap is not None and self.image_token_cap_default is not None:
496
+ raise ValueError("image_token_cap and image_token_cap_default are alternatives")
497
  self.model, self.processor, self.calibration = model, processor, calibration
498
  self.model_name = model_name
499
  self.context_policy = context_policy
 
502
  self._adapter_context = adapter_context
503
  self._synchronize = synchronize
504
  self._forward_call = forward_call
505
+ # Optional serving fast paths (``release.fastpath``); None keeps the published path.
506
+ self._logits_fn = logits_fn
507
+ self._readout_fn = readout_fn
508
  self._cache = NativeCache(processor)
509
  try:
510
  self.device = next(model.parameters()).device
511
  except (StopIteration, AttributeError):
512
  self.device = None
513
 
514
+ def _read(self, images: Sequence, question: Question, *, loaded_images=None, timer=None,
515
+ route_override: str | None = None) -> tuple[list[float], int]:
516
  example = {"question": question.instructions, "options": list(question.options),
517
  "images": list(images)}
518
+ cap = {} if self.image_token_cap is None else {"image_token_cap": self.image_token_cap}
519
+ if self.image_token_cap_default is not None and router.route(
520
+ question.route_instructions or question.instructions,
521
+ question.options) == router.DEFAULT:
522
+ cap = {"image_token_cap": self.image_token_cap_default}
523
  try:
524
  prepared = vision.prepare_native(example, self.processor, loaded_images=loaded_images,
525
+ cache=self._cache, timer=timer, device=self.device,
526
+ **cap)
527
  except (TypeError, ValueError) as error:
528
  raise RequestError(str(error)) from error
529
  tokens = _length(prepared.input_ids)
 
534
  )
535
  with timer.phase("route_adapter_switch"):
536
  route_name = None
537
+ if isinstance(self.calibration, ROUTED_CALIBRATIONS):
538
+ route_name = route_override or router.route(
539
  question.route_instructions or question.instructions, question.options
540
  )
541
  temperature = self.calibration.temperature(len(question.options), route_name)
 
550
  )
551
  with route_context:
552
  with inference_context:
553
+ if self._logits_fn is None:
554
+ logits = vision.last_position_logits(
555
+ self.model, prepared, timer=timer, forward_call=self._forward_call)
556
+ else:
557
+ logits = self._logits_fn(self.model, prepared, timer=timer, route=route_name)
558
  with timer.phase("readout_calibration"):
559
+ if self._readout_fn is None:
560
+ probabilities = vision.option_probs(
561
+ logits, prepared.letter_token_ids, temperature=temperature)
562
+ else:
563
+ probabilities = self._readout_fn(
564
+ logits, prepared.letter_token_ids, temperature)
565
  with timer.phase("readout_calibration"):
566
  total = math.fsum(probabilities)
567
  return [value / total for value in probabilities], tokens
 
572
  images, questions = parse_request(body, context_policy=self.context_policy)
573
  return self.predict_questions(images, questions, timer=timer)
574
 
575
+ def predict_questions(self, images, questions, *, timer=None,
576
+ route_override: str | None = None) -> dict[str, Any]:
577
+ """Answer parsed questions. ``route_override`` (evaluation only; never set by the
578
+ server) serves a routed system on one named route, e.g. ``default`` = the frozen base
579
+ with its default-route temperatures."""
580
+ if route_override is not None:
581
+ if route_override not in router.ROUTES:
582
+ raise ValueError(f"route_override must be one of {router.ROUTES}")
583
+ if not isinstance(self.calibration, ROUTED_CALIBRATIONS):
584
+ raise ValueError("route_override needs a routed system")
585
  timer = timer or PhaseTimer()
586
  with timer.phase("decode_load"):
587
  try:
 
591
  raise RequestError(str(error)) from error
592
  answers, input_tokens = {}, 0
593
  for question in questions:
594
+ probabilities, tokens = self._read(images, question, loaded_images=loaded, timer=timer,
595
+ route_override=route_override)
596
  with timer.phase("response_build"):
597
  input_tokens += tokens
598
  names = [option["name"] for option in question.options]
 
668
  adapter_path: str | Path | None = None,
669
  base_hashes_path: str | Path | None = None, device: str = "cuda",
670
  context_policy: str = "prepend", max_input_tokens: int | None = None,
671
+ allow_slow_path: bool = False, adapters_path: str | Path | None = None,
672
+ serving_form: str = "U",
673
+ adapter_work_dir: str | Path | None = None,
674
+ serving_options: Sequence[str] | str | None = None,
675
+ ) -> tuple[Predictor, dict[str, Any]]:
676
+ """Load the pinned frozen base in bf16 and return the predictor and its provenance.
677
+
678
+ ``adapters_path`` (router v3) is a directory holding ``default/`` and
679
+ ``screen_geometry/`` adapters bound by a ``router-calibration/v2`` artifact;
680
+ ``serving_form`` selects form U (both attached) or M (merged default).
681
+ ``serving_options`` (``release.fastpath``) are off by default; any option is recorded in
682
+ the provenance and none changes weights or calibration.
683
+ """
684
  import platform
685
+ import sys
686
 
687
  import torch
688
  import transformers
 
691
 
692
  if not allow_slow_path:
693
  check_fast_path()
694
+ if adapters_path is not None and adapter_path is not None:
695
+ raise ValueError("pass either one adapter or a per-route adapters directory, not both")
696
  adapter = None if adapter_path is None else Path(adapter_path)
697
  adapter_sha256 = "" if adapter is None else tree_sha256(adapter)
698
+ route_dirs = None
699
+ if adapters_path is not None:
700
+ route_dirs = {name: Path(adapters_path) / name for name in router.ROUTES}
701
  try:
702
  calibration_value = json.loads(Path(calibration_path).read_text(encoding="utf-8"))
703
  except (OSError, json.JSONDecodeError) as error:
704
  raise ValueError(f"cannot load calibration {calibration_path}: {error}") from error
705
  base_pin = {"repo": BASE_REPO, "revision": BASE_REVISION}
706
+ if route_dirs is not None:
707
+ route_hashes = {name: tree_sha256(path) for name, path in route_dirs.items()}
708
+ calibration = serving_calibration_from_artifact(
709
+ calibration_value, base=base_pin, adapter_sha256_by_route=route_hashes,
710
+ )
711
+ if not isinstance(calibration, RoutedCalibrationV2):
712
+ raise ValueError("per-route adapters require router-calibration/v2")
713
+ else:
714
+ route_hashes = None
715
+ calibration = serving_calibration_from_artifact(
716
+ calibration_value, checkpoint_sha256=adapter_sha256,
717
+ adapter_sha256=adapter_sha256 or None, base=base_pin,
718
+ )
719
+ if isinstance(calibration, RoutedCalibrationV2):
720
+ raise ValueError("router-calibration/v2 requires the per-route adapters directory")
721
+ routed = isinstance(calibration, ROUTED_CALIBRATIONS)
722
+ if isinstance(calibration, RoutedCalibration) and adapter is None:
723
  raise ValueError("routed calibration requires the screen_geometry adapter")
724
  snapshot = Path(snapshot_download(BASE_REPO, revision=BASE_REVISION, cache_dir=model_cache,
725
  local_files_only=offline))
 
734
  transformers, "AutoModelForImageTextToText")
735
  model = auto_model.from_pretrained(str(snapshot), torch_dtype=torch.bfloat16,
736
  device_map=device, trust_remote_code=False, attn_implementation="sdpa")
737
+ route_record = None
738
+ if route_dirs is not None:
739
+ import tempfile
740
 
741
+ from open_decisions.image_jev.release.adapters import attach_route_adapters
742
+
743
+ model, adapter_context, route_record = attach_route_adapters(
744
+ model, default_dir=route_dirs[router.DEFAULT],
745
+ screen_dir=route_dirs[router.SCREEN_GEOMETRY], form=serving_form,
746
+ work_dir=adapter_work_dir or tempfile.mkdtemp(prefix="image-hopper-adapters-"),
747
  )
748
+ model = model.eval()
749
+ else:
750
+ if adapter is not None:
751
+ from peft import PeftModel
752
+
753
+ loaded = PeftModel.from_pretrained(
754
+ model, str(adapter), **({"adapter_name": router.SCREEN_GEOMETRY} if routed else {})
755
+ )
756
+ model = loaded if routed else loaded.merge_and_unload()
757
+ model = model.eval()
758
+ adapter_context = AdapterSwitch(model) if routed else None
759
+ from open_decisions.image_jev.release import fastpath
760
+
761
+ options = fastpath.parse_options(serving_options)
762
+ inference_context, logits_fn, readout_fn, serving_record = torch.inference_mode, None, None, None
763
+ token_cap = token_cap_default = None
764
+ if options:
765
+ if route_dirs is not None:
766
+ raise ValueError("serving options are implemented for the single-adapter systems")
767
+ serving = fastpath.apply_serving_options(
768
+ model, processor, options, device=device, routed=routed,
769
+ adapter_name=router.SCREEN_GEOMETRY if routed else None,
770
+ adapter_context=adapter_context, inference_context=torch.inference_mode,
771
+ log=lambda text: print(text, file=sys.stderr))
772
+ model, processor = serving.model, serving.processor
773
+ adapter_context, inference_context = serving.adapter_context, serving.inference_context
774
+ logits_fn, readout_fn, serving_record = serving.logits_fn, serving.readout_fn, serving.record
775
+ token_cap = serving.image_token_cap
776
+ token_cap_default = serving.image_token_cap_default
777
  predictor = Predictor(model, processor, calibration, model_name=model_name,
778
  context_policy=context_policy, max_input_tokens=max_input_tokens,
779
+ inference_context=inference_context,
780
  adapter_context=adapter_context,
781
+ synchronize=(torch.cuda.synchronize if str(device).startswith("cuda") else None),
782
+ logits_fn=logits_fn, readout_fn=readout_fn, image_token_cap=token_cap,
783
+ image_token_cap_default=token_cap_default)
784
  provenance = {
785
  "base_repo": BASE_REPO, "base_revision": BASE_REVISION,
786
  "base_files_verified": verified,
 
792
  "system_kind": "routed" if routed else "single-route",
793
  "router_rules_version": router.RULES_VERSION if routed else None,
794
  "router_rules_sha256": router.RULES_SHA256 if routed else None,
795
+ "adapter_switching": (
796
+ route_record["adapter_switching"] if route_record is not None
797
+ else "PEFT layer enable/disable on route transition" if routed else None
798
+ ),
799
+ "adapters_by_route": (
800
+ None if route_dirs is None else {
801
+ name: {"path": str(path), "sha256": route_hashes[name]}
802
+ for name, path in route_dirs.items()
803
+ }
804
+ ),
805
+ "serving_form": None if route_record is None else route_record["serving_form"],
806
+ "difference_adapter": (
807
+ None if route_record is None else route_record.get("difference_adapter")
808
+ ),
809
  "precision": "bfloat16", "attention": "sdpa", "processor_use_fast": True, "scorer": "one-prefill-option-letter",
810
  "context_policy": context_policy, "max_input_tokens": max_input_tokens,
811
  "torch_version": torch.__version__, "transformers_version": transformers.__version__,
812
  "python_version": platform.python_version(),
813
  }
814
+ if options: # absent unless enabled: the default provenance is unchanged
815
+ provenance["serving_options"] = serving_record
816
  return predictor, provenance
817
 
818
 
819
  __all__ = [
820
+ "BASE_REPO", "BASE_REVISION", "Calibration", "ROUTED_CALIBRATIONS", "RoutedCalibration",
821
+ "RoutedCalibrationV2", "Predictor", "Question", "RequestError",
822
  "TYPE_BUCKET_BY_OPTION_COUNT", "check_fast_path", "load_predictor", "parse_request",
823
  "serving_calibration_from_artifact", "tree_sha256", "verify_snapshot",
824
  ]
open_decisions/image_jev/release/server.py CHANGED
@@ -74,6 +74,12 @@ def build_parser() -> argparse.ArgumentParser:
74
  parser.add_argument("--calibration", default="calibration.json")
75
  parser.add_argument("--adapter", default=None,
76
  help="optional local adapter directory; calibration must bind its tree hash")
 
 
 
 
 
 
77
  parser.add_argument("--base-hashes", default="base-model-files.json",
78
  help="hash listing of the pinned base snapshot; '' skips the check")
79
  parser.add_argument("--model-cache", default=None, help="Hugging Face cache directory")
@@ -87,6 +93,9 @@ def build_parser() -> argparse.ArgumentParser:
87
  parser.add_argument("--max-input-tokens", type=int, default=None,
88
  help="refuse (HTTP 400) requests above this many processed tokens")
89
  parser.add_argument("--allow-slow-path", action="store_true")
 
 
 
90
  parser.add_argument("--phase-timing", action="store_true",
91
  help="diagnostic synchronized phase_seconds in responses; default off")
92
  return parser
@@ -98,10 +107,10 @@ def main(argv=None) -> int:
98
  args = build_parser().parse_args(argv)
99
  predictor, provenance = load_predictor(
100
  calibration_path=args.calibration, model_name=args.name, model_cache=args.model_cache,
101
- adapter_path=args.adapter,
102
  offline=args.offline, base_hashes_path=args.base_hashes or None, device=args.device,
103
  context_policy=args.context_policy, max_input_tokens=args.max_input_tokens,
104
- allow_slow_path=args.allow_slow_path,
105
  )
106
  health = {"model": args.name, **provenance}
107
  print(json.dumps(health, indent=2, sort_keys=True), file=sys.stderr)
 
74
  parser.add_argument("--calibration", default="calibration.json")
75
  parser.add_argument("--adapter", default=None,
76
  help="optional local adapter directory; calibration must bind its tree hash")
77
+ parser.add_argument("--adapters", default=None,
78
+ help="router v3: directory with default/ and screen_geometry/ adapters; "
79
+ "calibration must be router-calibration/v2 bound to both")
80
+ parser.add_argument("--serving-form", choices=("U", "M"), default="U",
81
+ help="router v3: U keeps both adapters attached (set_adapter on route "
82
+ "change); M merges default and serves an exact difference adapter")
83
  parser.add_argument("--base-hashes", default="base-model-files.json",
84
  help="hash listing of the pinned base snapshot; '' skips the check")
85
  parser.add_argument("--model-cache", default=None, help="Hugging Face cache directory")
 
93
  parser.add_argument("--max-input-tokens", type=int, default=None,
94
  help="refuse (HTTP 400) requests above this many processed tokens")
95
  parser.add_argument("--allow-slow-path", action="store_true")
96
+ parser.add_argument("--serving-options", default="",
97
+ help="comma-separated release.fastpath options (default: none, the "
98
+ "published path); recorded in /health")
99
  parser.add_argument("--phase-timing", action="store_true",
100
  help="diagnostic synchronized phase_seconds in responses; default off")
101
  return parser
 
107
  args = build_parser().parse_args(argv)
108
  predictor, provenance = load_predictor(
109
  calibration_path=args.calibration, model_name=args.name, model_cache=args.model_cache,
110
+ adapter_path=args.adapter, adapters_path=args.adapters, serving_form=args.serving_form,
111
  offline=args.offline, base_hashes_path=args.base_hashes or None, device=args.device,
112
  context_policy=args.context_policy, max_input_tokens=args.max_input_tokens,
113
+ allow_slow_path=args.allow_slow_path, serving_options=args.serving_options or None,
114
  )
115
  health = {"model": args.name, **provenance}
116
  print(json.dumps(health, indent=2, sort_keys=True), file=sys.stderr)
open_decisions/image_jev/release/smoke.py CHANGED
@@ -127,7 +127,7 @@ def _exchange(handler_type, method: str, path: str, body=None) -> tuple[int, dic
127
  def run(calibration_path: str | Path) -> dict:
128
  from open_decisions.image_jev import router
129
  from open_decisions.image_jev.release.predictor import (
130
- Predictor, RoutedCalibration, serving_calibration_from_artifact,
131
  )
132
  from open_decisions.image_jev.release.server import handler
133
 
@@ -139,6 +139,12 @@ def run(calibration_path: str | Path) -> dict:
139
  )
140
  model = _fake_model()
141
  predictor = Predictor(model, FakeProcessor(), calibration, model_name="smoke")
 
 
 
 
 
 
142
  handler_type = handler(predictor, {"model": "smoke"})
143
 
144
  def post(body) -> tuple[int, dict]:
@@ -172,19 +178,13 @@ def run(calibration_path: str | Path) -> dict:
172
  four = answers["four"]["probabilities"]
173
  assert list(four) == ["top", "middle", "bottom", "none"]
174
  assert answers["four"]["choice"] == "top"
175
- four_temperature = (
176
- calibration.temperature(4, router.DEFAULT)
177
- if isinstance(calibration, RoutedCalibration) else calibration.temperature(4)
178
- )
179
  for name, got, want in zip(four, four.values(), _expected(4, four_temperature)):
180
  assert abs(got - want) < 1e-6, (name, got, want)
181
  three = answers["three"]["probabilities"]
182
- for got, want in zip(three.values(), _expected(3, calibration.global_temperature)):
183
  assert abs(got - want) < 1e-6
184
- binary_temperature = (
185
- calibration.temperature(2, router.DEFAULT)
186
- if isinstance(calibration, RoutedCalibration) else calibration.temperature(2)
187
- )
188
  want_yes = _expected(2, binary_temperature)[0]
189
  assert abs(answers["binary"]["noul"] - want_yes) < 1e-6
190
  assert body["usage"]["output_tokens"] == 0 and body["usage"]["input_tokens"] > 0
 
127
  def run(calibration_path: str | Path) -> dict:
128
  from open_decisions.image_jev import router
129
  from open_decisions.image_jev.release.predictor import (
130
+ ROUTED_CALIBRATIONS, Predictor, serving_calibration_from_artifact,
131
  )
132
  from open_decisions.image_jev.release.server import handler
133
 
 
139
  )
140
  model = _fake_model()
141
  predictor = Predictor(model, FakeProcessor(), calibration, model_name="smoke")
142
+
143
+ def temperature(n_options: int) -> float:
144
+ # Every smoke question routes to ``default`` (no screen/geometry cues).
145
+ if isinstance(calibration, ROUTED_CALIBRATIONS):
146
+ return calibration.temperature(n_options, router.DEFAULT)
147
+ return calibration.temperature(n_options)
148
  handler_type = handler(predictor, {"model": "smoke"})
149
 
150
  def post(body) -> tuple[int, dict]:
 
178
  four = answers["four"]["probabilities"]
179
  assert list(four) == ["top", "middle", "bottom", "none"]
180
  assert answers["four"]["choice"] == "top"
181
+ four_temperature = temperature(4)
 
 
 
182
  for name, got, want in zip(four, four.values(), _expected(4, four_temperature)):
183
  assert abs(got - want) < 1e-6, (name, got, want)
184
  three = answers["three"]["probabilities"]
185
+ for got, want in zip(three.values(), _expected(3, temperature(3))):
186
  assert abs(got - want) < 1e-6
187
+ binary_temperature = temperature(2)
 
 
 
188
  want_yes = _expected(2, binary_temperature)[0]
189
  assert abs(answers["binary"]["noul"] - want_yes) < 1e-6
190
  assert body["usage"]["output_tokens"] == 0 and body["usage"]["input_tokens"] > 0
open_decisions/image_jev/vision.py CHANGED
@@ -35,6 +35,12 @@ MIN_PIXELS = 65_536
35
  MAX_PIXELS = 16_777_216
36
  MAX_IMAGE_BYTES = 20 * 1024 * 1024
37
  MAX_IMAGE_PIXELS = 20_000_000
 
 
 
 
 
 
38
  BUDGET_VERSION = "b1"
39
  BUDGETS = MappingProxyType({"photo": 160, "scene": 224, "document": 384, "dense": 768})
40
  ROUTING_VERSION = "image-jev/content-routing/v1"
@@ -128,6 +134,29 @@ def image_tokens(height: int, width: int, factor: int = FACTOR) -> int:
128
  return (resized_h // factor) * (resized_w // factor)
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def budget_pixels(content_class: str) -> int:
132
  """Return the explicit pixel-area budget for a named content class."""
133
  try:
@@ -325,12 +354,40 @@ def _output_field(output, name: str):
325
  return value
326
 
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  def _prepare(example: dict, processor, *, image_route: str,
329
  content_class: str | None = None, loaded_images=None, cache=None,
330
- timer=None, device=None) -> PreparedInput:
331
- """Prepare one decision after validating an explicit registered image route."""
 
 
 
 
332
  if image_route not in IMAGE_ROUTES:
333
  raise ValueError(f"image_route must be one of {IMAGE_ROUTES}, got {image_route!r}")
 
 
 
 
334
  try:
335
  question, options = example["question"], example["options"]
336
  except (KeyError, TypeError) as error:
@@ -366,6 +423,11 @@ def _prepare(example: dict, processor, *, image_route: str,
366
  "new_size": ready.size,
367
  "image_tokens": image_tokens(ready.height, ready.width),
368
  }
 
 
 
 
 
369
  ready_images.append(ready)
370
  records.append(record)
371
 
@@ -390,12 +452,28 @@ def _prepare(example: dict, processor, *, image_route: str,
390
  getattr(image_processor, "backend", None) == "torch" or
391
  "Fast" in type(image_processor).__name__ or
392
  any(base.__name__ == "BaseImageProcessorFast" for base in type(image_processor).__mro__))
393
- kwargs = {"images_kwargs": {"device": device}} if fast and device is not None else {}
 
 
 
 
 
 
394
  if cache is not None:
395
  cache.tokenizer.timer = timer
396
  token_before = timer.seconds["tokenise_chat_template"] if timer.seconds is not None else 0.0
397
  with timer.phase("processor_preprocess"):
398
  output = processor(text=[prompt], images=ready_images, padding=True, return_tensors="pt", **kwargs)
 
 
 
 
 
 
 
 
 
 
399
  if timer.seconds is not None:
400
  timer.seconds["processor_preprocess"] -= timer.seconds["tokenise_chat_template"] - token_before
401
  return PreparedInput(input_ids=_output_field(output, "input_ids"),
@@ -413,7 +491,10 @@ def prepare(example: dict, processor, content_class: str | None = None) -> Prepa
413
 
414
 
415
  def prepare_native(example: dict, processor, **kwargs) -> PreparedInput:
416
- """Prepare processor-default full-resolution images without budget resizing."""
 
 
 
417
  return _prepare(example, processor, image_route="native", **kwargs)
418
 
419
 
 
35
  MAX_PIXELS = 16_777_216
36
  MAX_IMAGE_BYTES = 20 * 1024 * 1024
37
  MAX_IMAGE_PIXELS = 20_000_000
38
+ # Optional native-route image-token cap (a serving option, off by default). The cap lowers the
39
+ # processor's own max pixels to ``cap * FACTOR**2`` so its smart resize keeps at most ``cap``
40
+ # merged image tokens per image. Below 256 tokens the processor's minimum-area rule and its
41
+ # one-factor edge clamp can exceed the cap for extreme aspect ratios, so smaller caps are refused.
42
+ MIN_IMAGE_TOKEN_CAP = 256
43
+ MAX_IMAGE_TOKEN_CAP = MAX_PIXELS // (FACTOR * FACTOR)
44
  BUDGET_VERSION = "b1"
45
  BUDGETS = MappingProxyType({"photo": 160, "scene": 224, "document": 384, "dense": 768})
46
  ROUTING_VERSION = "image-jev/content-routing/v1"
 
134
  return (resized_h // factor) * (resized_w // factor)
135
 
136
 
137
+ def validate_image_token_cap(value: Any) -> int:
138
+ """An explicit native-route image-token cap: an integer in [256, 16384]."""
139
+ if isinstance(value, bool) or not isinstance(value, int):
140
+ raise ValueError(f"image_token_cap must be an integer, got {value!r}")
141
+ if not MIN_IMAGE_TOKEN_CAP <= value <= MAX_IMAGE_TOKEN_CAP:
142
+ raise ValueError(f"image_token_cap must be between {MIN_IMAGE_TOKEN_CAP} and "
143
+ f"{MAX_IMAGE_TOKEN_CAP}, got {value}")
144
+ return value
145
+
146
+
147
+ def native_max_pixels(image_token_cap: int | None = None) -> int:
148
+ """The processor max pixels of the native route: its default, or ``cap * FACTOR**2``."""
149
+ if image_token_cap is None:
150
+ return MAX_PIXELS
151
+ return validate_image_token_cap(image_token_cap) * FACTOR * FACTOR
152
+
153
+
154
+ def native_image_tokens(height: int, width: int, image_token_cap: int | None = None) -> int:
155
+ """Merged image tokens of one native-route image, with the optional cap applied."""
156
+ resized_h, resized_w = smart_resize(height, width, max_pixels=native_max_pixels(image_token_cap))
157
+ return (resized_h // FACTOR) * (resized_w // FACTOR)
158
+
159
+
160
  def budget_pixels(content_class: str) -> int:
161
  """Return the explicit pixel-area budget for a named content class."""
162
  try:
 
354
  return value
355
 
356
 
357
+ def _processor_min_pixels(processor) -> int:
358
+ """The processor's own minimum area (kept unchanged when a token cap lowers the maximum)."""
359
+ image_processor = getattr(processor, "image_processor", None)
360
+ size = getattr(image_processor, "size", None)
361
+ for getter in (lambda: size["shortest_edge"], lambda: getattr(size, "shortest_edge"),
362
+ lambda: getattr(image_processor, "min_pixels")):
363
+ try:
364
+ value = getter()
365
+ except (KeyError, TypeError, AttributeError):
366
+ continue
367
+ if isinstance(value, int) and not isinstance(value, bool) and value > 0:
368
+ return value
369
+ return MIN_PIXELS
370
+
371
+
372
+ def _grid_rows(grid) -> list[list[int]]:
373
+ rows = grid.tolist() if hasattr(grid, "tolist") else [list(row) for row in grid]
374
+ return [[int(value) for value in row] for row in rows]
375
+
376
+
377
  def _prepare(example: dict, processor, *, image_route: str,
378
  content_class: str | None = None, loaded_images=None, cache=None,
379
+ timer=None, device=None, image_token_cap: int | None = None) -> PreparedInput:
380
+ """Prepare one decision after validating an explicit registered image route.
381
+
382
+ ``image_token_cap`` (native route only; default None = the processor's own maximum) lowers
383
+ the processor's max pixels so every image keeps at most that many merged image tokens.
384
+ """
385
  if image_route not in IMAGE_ROUTES:
386
  raise ValueError(f"image_route must be one of {IMAGE_ROUTES}, got {image_route!r}")
387
+ if image_token_cap is not None:
388
+ if image_route != "native":
389
+ raise ValueError("image_token_cap applies to the native image route only")
390
+ image_token_cap = validate_image_token_cap(image_token_cap)
391
  try:
392
  question, options = example["question"], example["options"]
393
  except (KeyError, TypeError) as error:
 
423
  "new_size": ready.size,
424
  "image_tokens": image_tokens(ready.height, ready.width),
425
  }
426
+ if image_token_cap is not None:
427
+ # The processor, not this function, resizes; new_size stays the decoded size.
428
+ record["image_token_cap"] = image_token_cap
429
+ record["image_tokens"] = native_image_tokens(ready.height, ready.width,
430
+ image_token_cap)
431
  ready_images.append(ready)
432
  records.append(record)
433
 
 
452
  getattr(image_processor, "backend", None) == "torch" or
453
  "Fast" in type(image_processor).__name__ or
454
  any(base.__name__ == "BaseImageProcessorFast" for base in type(image_processor).__mro__))
455
+ images_kwargs = {"device": device} if fast and device is not None else {}
456
+ if image_token_cap is not None:
457
+ # Every spelling the processor's kwargs accept, all consistent: min unchanged, max capped.
458
+ min_pixels, max_pixels = _processor_min_pixels(processor), native_max_pixels(image_token_cap)
459
+ images_kwargs.update(size={"shortest_edge": min_pixels, "longest_edge": max_pixels},
460
+ min_pixels=min_pixels, max_pixels=max_pixels)
461
+ kwargs = {"images_kwargs": images_kwargs} if images_kwargs else {}
462
  if cache is not None:
463
  cache.tokenizer.timer = timer
464
  token_before = timer.seconds["tokenise_chat_template"] if timer.seconds is not None else 0.0
465
  with timer.phase("processor_preprocess"):
466
  output = processor(text=[prompt], images=ready_images, padding=True, return_tensors="pt", **kwargs)
467
+ if image_token_cap is not None:
468
+ # Fail closed if the processor ignored the cap (a library change would do that).
469
+ merge = int(getattr(getattr(processor, "image_processor", None), "merge_size", 2) or 2)
470
+ served = [t * h * w // (merge * merge)
471
+ for t, h, w in _grid_rows(_output_field(output, "image_grid_thw"))]
472
+ if len(served) != len(records) or any(tokens > image_token_cap for tokens in served):
473
+ raise RuntimeError(f"processor returned {served} image tokens for an "
474
+ f"{image_token_cap}-token cap")
475
+ for record, tokens in zip(records, served):
476
+ record["processor_image_tokens"] = tokens
477
  if timer.seconds is not None:
478
  timer.seconds["processor_preprocess"] -= timer.seconds["tokenise_chat_template"] - token_before
479
  return PreparedInput(input_ids=_output_field(output, "input_ids"),
 
491
 
492
 
493
  def prepare_native(example: dict, processor, **kwargs) -> PreparedInput:
494
+ """Prepare processor-default full-resolution images without budget resizing.
495
+
496
+ ``image_token_cap=N`` (optional, default off) lowers only the processor's max pixels.
497
+ """
498
  return _prepare(example, processor, image_route="native", **kwargs)
499
 
500