maxffarrell commited on
Commit
ebef316
·
verified ·
1 Parent(s): b5a935d

Publish validated AMOS Core AI conversion with source provenance and runtime adapter

Browse files
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ heads.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
37
+ model/tokenizer.json filter=lfs diff=lfs merge=lfs -text
38
+ text.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
39
+ vision.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: mukaddamzaid/amos-1-small
4
+ tags: [coreai, core-ai, apple, aimodel, decisions, vision]
5
+ ---
6
+
7
+ # amos-1-small — Apple Core AI
8
+
9
+ Independent AMOS-1 conversion with **bidirectional full attention** preserved. Text backbone, 255-code readout, vision tower, span head, region head and trained box head execute through Core AI. Original prompt formatting, temperatures and quote decoding are retained. This is a decision model, not a text generator.
10
+
11
+ ## Use
12
+
13
+ Requires macOS 27 and the Core AI Python runtime. Tested with `coreai-torch==0.4.3`, PyTorch 2.14.0, Transformers 5.17 development, NumPy and Pillow. The converter warns that this PyTorch version exceeds its validated range. Host preprocessing/decoding uses PyTorch tensors; it loads no PyTorch neural weights.
14
+
15
+ ```python
16
+ import asyncio
17
+ from coreai_decoder import AmosDecoder
18
+
19
+ async def main():
20
+ m = await AmosDecoder.load(".")
21
+ print(await m.request({
22
+ "state": "Please refund the duplicate charge.",
23
+ "questions": {"refund": {
24
+ "type": "noul", "instructions": "Is a refund requested?"
25
+ }}
26
+ }))
27
+
28
+ asyncio.run(main())
29
+ ```
30
+
31
+ `predict(state, questions, images=())` takes upstream internal questions (`text`, `options`) and PIL images, including image options. `request()` takes base64 images in the original AMOS format.
32
+
33
+ ## Verified scope and limits
34
+
35
+ Batch one, **512 combined tokens**, 224×224 images (49 merged tokens), float32 backbone/vision weights with FP32 readout and heads. Requests over capacity raise an error. Original longer contexts, variable image resolution, wide-choice two-pass routing, rotations, thinking, hosted server and explanation endpoints are not implemented. Up to 255 option codes are available subject to token capacity. No iPhone, Neural Engine placement, throughput or upstream benchmark accuracy claim has been verified. Core AI uses default specialization.
36
+
37
+ Nine smoke/parity cases cover all six typed question kinds, quote extraction, Unicode, 24 options, synthetic state images and image options. Tested discrete decisions matched upstream. Maximum measured probability/box-coordinate error: **0.00001019**. Full results are in `validation.json`; these smoke cases do not establish benchmark accuracy. `runtime-smoke.json` verifies inference with PyTorch neural execution disabled.
38
+
39
+ The primary parity reference executes the pinned checkpoint in FP32. Compared with the original BF16 serving path in 8 additional cases, maximum probability/box error was 0.034142. Region confidence can change discontinuously when a box crosses a grid boundary. See `bf16-comparison.json`; this is not bit-identical BF16 serving.
40
+
41
+ ## Attribution and reproducibility
42
+
43
+ Original model and serving code: Zaid Mukaddam, [mukaddamzaid/amos-1-small](https://huggingface.co/mukaddamzaid/amos-1-small). Weights derive from Qwen3.5. Apache-2.0 applies; see `LICENSE` and `SOURCE.json` for the pinned AMOS revision and source hashes. Conversion adds no restrictions. Upstream serving sources are included under `serve/`.
44
+
45
+ From the original workspace layout, run `convert_decoder.py amos-1-small`, `validate_decoder.py amos-1-small`, then `smoke_decoder.py amos-1-small`. Release integrity is recorded in `SHA256SUMS`.
SHA256SUMS ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 LICENSE
2
+ 917c1c22fba4cc984a5e95b01cc5e1a386e013fb599782b055b909c91647c06d README.md
3
+ 9f25e439543c4493b66f609c2a4c57a3d97c11194ac247c3c043f4657725911e SOURCE.json
4
+ 19dd2e174b2f7468407243220912947feb04a9260f347223021f880b532439a1 bf16-attempt.json
5
+ 5865684cabaa8e1a23243db7d9d32569a57c7b5011bf47ed95814b9813af57dd bf16-comparison.json
6
+ f9ca221eceeea6dc52774885a8c50ad727bb8c269a7ff4fbb439131d2f5be0f2 calibration.json
7
+ 2636cc2d838bc7395655ebb3d2ba933b186a43b13ce134c9a441ee8ba45a9d0d config.json
8
+ 650d3c6c51a4fead342fba413ed8339f6e04d1f48569225e2215e14a837d62c0 convert_decoder.py
9
+ ef80ea9244fb5d713b91e077dde578ebd1e1455ea0788ecec4b3691de1084e41 coreai_decoder.py
10
+ 3708c801947fd75c057ff534edc404aacb4cc0acc9e653c12f09207e0d848d37 heads.aimodel/main.hash
11
+ 132384c42c3a5c6269a79862d6540a5a6834b532674ba847d83a5b181f85a860 heads.aimodel/main.mlirb
12
+ 3284600a3ad7c8e378bbe228ce3eb967d5da56bb40959aba7e56b177a3fc928a heads.aimodel/metadata.json
13
+ cc3b3cabf8162d51af8764eb9f645fa21c45cde4eba25a31d73caf1534511a29 manifest.json
14
+ 273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80 model/chat_template.jinja
15
+ 90c4a5672369ff2b3010f057c5dd1abafafcd29c9890ad61e7a893f6a1fa2b5e model/config.json
16
+ 62153eb6c69f2e1f426beaa8002b7186437e949c7588167085df14e10e9c0a73 model/generation_config.json
17
+ 3a159dfec9978a186a72ba085e0ad6a050f3968d8b364218d7bd13f5c89381f2 model/preprocessor_config.json
18
+ aa7582a6b90dc3711049d293d985e549f2b22c0c4e5ad11b9272a03c6294886e model/tokenizer.json
19
+ 8c54bd45ef2fc821a711502da04af1bff020fe02c2207117c7ffdbf1866e6e25 model/tokenizer_config.json
20
+ 7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13 model/video_preprocessor_config.json
21
+ d7910a3390f8073d95908f3301c0ea0e2df4551699546430003bc8226373c2bc qwen_export_helpers.py
22
+ 8d39d10513cef09276fb806d7d16b33c3c9b7d30a212bdd87f0eb16ad8796d2e runtime-smoke.json
23
+ 9e17531efd289cf720a36688d943197f9baf9db7c4f743aa4951f6b6f7dbafda serve/README.md
24
+ 0579eb845ee7af463ec88cf627d99dd636466f2c12d840a7cbe183d8f17044fd serve/amos/__init__.py
25
+ ba094d9eaedd229a99b2bdcd35a371ffdeb9aaee258a342524cc19e2f85ec421 serve/amos/calibrate.py
26
+ 0dcacb27a05d31781f8310cbe6123b0ee351c62456cc6b5f8576b40b8ddad080 serve/amos/decoder.py
27
+ b4a5a606a2dee99eedf86cab50be759eca0908a318e4f3037ba92560d837bd67 serve/amos/encoder.py
28
+ f2efd7ef25453f7f2dc204dd247c86733a55697ec48714f30560df8ed2da1f23 serve/amos/facts.py
29
+ d4f4354f3e4d3b475b4c1f832a4a9741da757a368a1d2e0e74544d06009c2c27 serve/amos/model.py
30
+ 14f027c9e15c456abb0f264c07dae06afb7ace7b64270f2374e97eabeb04f993 serve/amos/packing.py
31
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 serve/amos/py.typed
32
+ b0523591c320a074a0ec0751b68ebc205aeedd090ff5e7ca10bda3fb94a07ef8 serve/amos/serve.py
33
+ 133d1e29cc64e371f5df1f0cb0328fd77b8b9093d8465319580cf5ea4af5d081 serve/amos/vision.py
34
+ bea3f98882a5d12c891aae4a5d75eda8c5a5436e88889d94b6a7d3d648d126f2 serve/pyproject.toml
35
+ 0d43aa4e58fe6131f4ed1f4bbcbcaa4a0815f8b1fe5331c7e96489700681136b serve/sources.json
36
+ c5b0aa4de369327de6d03ba19d322557568e9d9c312b901ab42478dc679199d2 smoke_decoder.py
37
+ bfe4ef23e8e7bb5041f9e10a769b5c2169b26225ffdd7016e9062c689e593932 text.aimodel/main.hash
38
+ 1c469415a143a1d9d75486ed48dd9b32c1033f1854a6bfc394ae9eb08caa8c72 text.aimodel/main.mlirb
39
+ a678dc0dcb7ff0a16708ee3e534460d0ba3c0679c9f43fac4c003b4d68d17b35 text.aimodel/metadata.json
40
+ 94dc6f950a92250e5711be9af9c1b7893109e1dd207f97f9ae00e5584b71ae0e validate_decoder.py
41
+ 41865477766f6b49dcfa43354734bb6b2158b8395e80f6d9b711a13f1dcae541 validation.json
42
+ 7631b43ef5f505089f17d48a3a71ec71bb5fc210c933189ab779c176da39e051 vision.aimodel/main.hash
43
+ 1f646ae2677fb9ba2793ec942d2b9ed82690199d7a75685ecdf4a84ea791f38f vision.aimodel/main.mlirb
44
+ ffd58384957fd6de58cf8bb5ac50261aba88af370aa53499e065b0a635b903f8 vision.aimodel/metadata.json
SOURCE.json ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "repo": "mukaddamzaid/amos-1-small",
3
+ "revision": "09ba3e9d8f506cfcb96ea130845d5d76d9d92028",
4
+ "bytes": 4555951728,
5
+ "files": [
6
+ {
7
+ "path": ".gitattributes",
8
+ "size": 1636,
9
+ "lfs": null
10
+ },
11
+ {
12
+ "path": "LICENSE",
13
+ "size": 11358,
14
+ "lfs": null
15
+ },
16
+ {
17
+ "path": "README.md",
18
+ "size": 4347,
19
+ "lfs": null
20
+ },
21
+ {
22
+ "path": "assets/amos-1-small.png",
23
+ "size": 116696,
24
+ "lfs": {
25
+ "size": 116696,
26
+ "sha256": "fa3963e990c3e0e6f1f8f1296027b44ca080a1adab4855c7cae515a122d79089",
27
+ "pointer_size": 131
28
+ }
29
+ },
30
+ {
31
+ "path": "calibration.json",
32
+ "size": 147,
33
+ "lfs": null
34
+ },
35
+ {
36
+ "path": "config.json",
37
+ "size": 28,
38
+ "lfs": null
39
+ },
40
+ {
41
+ "path": "model/chat_template.jinja",
42
+ "size": 7755,
43
+ "lfs": null
44
+ },
45
+ {
46
+ "path": "model/config.json",
47
+ "size": 2855,
48
+ "lfs": null
49
+ },
50
+ {
51
+ "path": "model/generation_config.json",
52
+ "size": 116,
53
+ "lfs": null
54
+ },
55
+ {
56
+ "path": "model/heads.pt",
57
+ "size": 109123355,
58
+ "lfs": {
59
+ "size": 109123355,
60
+ "sha256": "30ba05e777e4a07e8aec5139eb6700d99b4d294d86ad67f312fc14e3e23575c1",
61
+ "pointer_size": 134
62
+ }
63
+ },
64
+ {
65
+ "path": "model/model.safetensors",
66
+ "size": 4426558864,
67
+ "lfs": {
68
+ "size": 4426558864,
69
+ "sha256": "790c9700fe4a0be2b79498919c04d74a3cbfaf01e451d4c589db601608f4f557",
70
+ "pointer_size": 135
71
+ }
72
+ },
73
+ {
74
+ "path": "model/preprocessor_config.json",
75
+ "size": 443,
76
+ "lfs": null
77
+ },
78
+ {
79
+ "path": "model/tokenizer.json",
80
+ "size": 19989510,
81
+ "lfs": {
82
+ "size": 19989510,
83
+ "sha256": "aa7582a6b90dc3711049d293d985e549f2b22c0c4e5ad11b9272a03c6294886e",
84
+ "pointer_size": 133
85
+ }
86
+ },
87
+ {
88
+ "path": "model/tokenizer_config.json",
89
+ "size": 1172,
90
+ "lfs": null
91
+ },
92
+ {
93
+ "path": "model/video_preprocessor_config.json",
94
+ "size": 385,
95
+ "lfs": null
96
+ },
97
+ {
98
+ "path": "serve/README.md",
99
+ "size": 2505,
100
+ "lfs": null
101
+ },
102
+ {
103
+ "path": "serve/amos/__init__.py",
104
+ "size": 42,
105
+ "lfs": null
106
+ },
107
+ {
108
+ "path": "serve/amos/calibrate.py",
109
+ "size": 1712,
110
+ "lfs": null
111
+ },
112
+ {
113
+ "path": "serve/amos/decoder.py",
114
+ "size": 43439,
115
+ "lfs": null
116
+ },
117
+ {
118
+ "path": "serve/amos/encoder.py",
119
+ "size": 7816,
120
+ "lfs": null
121
+ },
122
+ {
123
+ "path": "serve/amos/facts.py",
124
+ "size": 1356,
125
+ "lfs": null
126
+ },
127
+ {
128
+ "path": "serve/amos/model.py",
129
+ "size": 15106,
130
+ "lfs": null
131
+ },
132
+ {
133
+ "path": "serve/amos/packing.py",
134
+ "size": 10835,
135
+ "lfs": null
136
+ },
137
+ {
138
+ "path": "serve/amos/py.typed",
139
+ "size": 0,
140
+ "lfs": null
141
+ },
142
+ {
143
+ "path": "serve/amos/serve.py",
144
+ "size": 39697,
145
+ "lfs": null
146
+ },
147
+ {
148
+ "path": "serve/amos/vision.py",
149
+ "size": 6633,
150
+ "lfs": null
151
+ },
152
+ {
153
+ "path": "serve/pyproject.toml",
154
+ "size": 842,
155
+ "lfs": null
156
+ },
157
+ {
158
+ "path": "serve/sources.json",
159
+ "size": 1723,
160
+ "lfs": null
161
+ },
162
+ {
163
+ "path": "typed_decisions.json",
164
+ "size": 1355,
165
+ "lfs": null
166
+ }
167
+ ],
168
+ "local_source_hashes": {
169
+ "model/model.safetensors": "790c9700fe4a0be2b79498919c04d74a3cbfaf01e451d4c589db601608f4f557",
170
+ "model/tokenizer_config.json": "8c54bd45ef2fc821a711502da04af1bff020fe02c2207117c7ffdbf1866e6e25",
171
+ "model/config.json": "90c4a5672369ff2b3010f057c5dd1abafafcd29c9890ad61e7a893f6a1fa2b5e",
172
+ "model/tokenizer.json": "aa7582a6b90dc3711049d293d985e549f2b22c0c4e5ad11b9272a03c6294886e",
173
+ "model/heads.pt": "30ba05e777e4a07e8aec5139eb6700d99b4d294d86ad67f312fc14e3e23575c1",
174
+ "model/generation_config.json": "62153eb6c69f2e1f426beaa8002b7186437e949c7588167085df14e10e9c0a73",
175
+ "model/chat_template.jinja": "273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80",
176
+ "model/video_preprocessor_config.json": "7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13",
177
+ "model/preprocessor_config.json": "3a159dfec9978a186a72ba085e0ad6a050f3968d8b364218d7bd13f5c89381f2"
178
+ }
179
+ }
bf16-attempt.json ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "case": "refund",
4
+ "tokens": 122,
5
+ "max_probability_or_box_error": 0.0008294582366943359,
6
+ "decisions_match": true,
7
+ "upstream": [
8
+ {
9
+ "type": "noul",
10
+ "p_yes": 0.9724245667457581
11
+ },
12
+ {
13
+ "type": "choice",
14
+ "probs": [
15
+ 0.9623249769210815,
16
+ 0.01487948652356863,
17
+ 0.02279551699757576
18
+ ],
19
+ "choice": 0
20
+ }
21
+ ],
22
+ "coreai": [
23
+ {
24
+ "type": "noul",
25
+ "p_yes": 0.9723380208015442
26
+ },
27
+ {
28
+ "type": "choice",
29
+ "probs": [
30
+ 0.9614955186843872,
31
+ 0.014982474967837334,
32
+ 0.023522058501839638
33
+ ],
34
+ "choice": 0
35
+ }
36
+ ]
37
+ },
38
+ {
39
+ "case": "span",
40
+ "tokens": 123,
41
+ "max_probability_or_box_error": 0,
42
+ "decisions_match": true,
43
+ "upstream": [
44
+ {
45
+ "type": "span",
46
+ "start": 55,
47
+ "end": 63,
48
+ "text": "INV-2231",
49
+ "p": 1.0
50
+ }
51
+ ],
52
+ "coreai": [
53
+ {
54
+ "type": "span",
55
+ "start": 55,
56
+ "end": 63,
57
+ "text": "INV-2231",
58
+ "p": 1.0
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ "case": "score",
64
+ "tokens": 98,
65
+ "max_probability_or_box_error": 0.001256406307220459,
66
+ "decisions_match": true,
67
+ "upstream": [
68
+ {
69
+ "type": "score",
70
+ "probs": [
71
+ 0.011553974822163582,
72
+ 0.012325819581747055,
73
+ 0.05834781378507614,
74
+ 0.9177724123001099
75
+ ],
76
+ "choice": 3,
77
+ "expected": 2.882338684052229
78
+ }
79
+ ],
80
+ "coreai": [
81
+ {
82
+ "type": "score",
83
+ "probs": [
84
+ 0.011898641474545002,
85
+ 0.012468610890209675,
86
+ 0.059116706252098083,
87
+ 0.9165160059928894
88
+ ],
89
+ "choice": 3,
90
+ "expected": 2.880250041373074
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "case": "multi",
96
+ "tokens": 144,
97
+ "max_probability_or_box_error": 0.0004277229309082031,
98
+ "decisions_match": true,
99
+ "upstream": [
100
+ {
101
+ "type": "multi",
102
+ "probs": [
103
+ 0.9700461626052856,
104
+ 0.9614417552947998,
105
+ 0.022039415314793587
106
+ ]
107
+ }
108
+ ],
109
+ "coreai": [
110
+ {
111
+ "type": "multi",
112
+ "probs": [
113
+ 0.9697818160057068,
114
+ 0.961869478225708,
115
+ 0.02190219797194004
116
+ ]
117
+ }
118
+ ]
119
+ },
120
+ {
121
+ "case": "negative",
122
+ "tokens": 83,
123
+ "max_probability_or_box_error": 0.0006557293236255646,
124
+ "decisions_match": true,
125
+ "upstream": [
126
+ {
127
+ "type": "noul",
128
+ "p_yes": 0.049154266715049744
129
+ }
130
+ ],
131
+ "coreai": [
132
+ {
133
+ "type": "noul",
134
+ "p_yes": 0.04980999603867531
135
+ }
136
+ ]
137
+ },
138
+ {
139
+ "case": "unicode",
140
+ "tokens": 128,
141
+ "max_probability_or_box_error": 0.0008890628814697266,
142
+ "decisions_match": true,
143
+ "upstream": [
144
+ {
145
+ "type": "span",
146
+ "start": 10,
147
+ "end": 17,
148
+ "text": "\u20ac123.45",
149
+ "p": 0.9967826008796692
150
+ },
151
+ {
152
+ "type": "choice",
153
+ "probs": [
154
+ 0.9754987955093384,
155
+ 0.013173527084290981,
156
+ 0.011327654123306274
157
+ ],
158
+ "choice": 0
159
+ }
160
+ ],
161
+ "coreai": [
162
+ {
163
+ "type": "span",
164
+ "start": 10,
165
+ "end": 17,
166
+ "text": "\u20ac123.45",
167
+ "p": 0.9976716637611389
168
+ },
169
+ {
170
+ "type": "choice",
171
+ "probs": [
172
+ 0.9759361147880554,
173
+ 0.01289563812315464,
174
+ 0.011168276891112328
175
+ ],
176
+ "choice": 0
177
+ }
178
+ ]
179
+ },
180
+ {
181
+ "case": "options",
182
+ "tokens": 230,
183
+ "max_probability_or_box_error": 0.0005109310150146484,
184
+ "decisions_match": true,
185
+ "upstream": [
186
+ {
187
+ "type": "choice",
188
+ "probs": [
189
+ 0.003980829380452633,
190
+ 0.003826719941571355,
191
+ 0.004242099355906248,
192
+ 0.0034746285527944565,
193
+ 0.003326982958242297,
194
+ 0.0027517760172486305,
195
+ 0.0033690459094941616,
196
+ 0.0029055955819785595,
197
+ 0.002698659896850586,
198
+ 0.002124715130776167,
199
+ 0.00298517057672143,
200
+ 0.0031158491037786007,
201
+ 0.003178240265697241,
202
+ 0.00276554049924016,
203
+ 0.0025187907740473747,
204
+ 0.0038957030046731234,
205
+ 0.00339102977886796,
206
+ 0.9302572011947632,
207
+ 0.0037882667966187,
208
+ 0.0032934194896370173,
209
+ 0.0014340636553242803,
210
+ 0.0015281529631465673,
211
+ 0.0019327420741319656,
212
+ 0.0032147751189768314
213
+ ],
214
+ "choice": 17
215
+ }
216
+ ],
217
+ "coreai": [
218
+ {
219
+ "type": "choice",
220
+ "probs": [
221
+ 0.004058705177158117,
222
+ 0.003873375477269292,
223
+ 0.004297157749533653,
224
+ 0.0035347286611795425,
225
+ 0.003358113579452038,
226
+ 0.0027418085373938084,
227
+ 0.0034121156204491854,
228
+ 0.002912522992119193,
229
+ 0.0026929345913231373,
230
+ 0.002130611566826701,
231
+ 0.0030158632434904575,
232
+ 0.003093816339969635,
233
+ 0.0032024341635406017,
234
+ 0.002781065646559,
235
+ 0.002530055120587349,
236
+ 0.003932687919586897,
237
+ 0.0034593497402966022,
238
+ 0.9297462701797485,
239
+ 0.003791194409132004,
240
+ 0.003320459509268403,
241
+ 0.0014135388191789389,
242
+ 0.0015202258946374059,
243
+ 0.0019439932657405734,
244
+ 0.0032370148692280054
245
+ ],
246
+ "choice": 17
247
+ }
248
+ ]
249
+ },
250
+ {
251
+ "case": "vision",
252
+ "tokens": 155,
253
+ "max_probability_or_box_error": 0.047274649143218994,
254
+ "decisions_match": true,
255
+ "upstream": [
256
+ {
257
+ "type": "region",
258
+ "cell": 9,
259
+ "box": [
260
+ 0.2031,
261
+ 0.0,
262
+ 0.5674,
263
+ 0.86
264
+ ],
265
+ "p": 0.5970777273178101
266
+ },
267
+ {
268
+ "type": "choice",
269
+ "probs": [
270
+ 0.9851310849189758,
271
+ 0.014868861995637417
272
+ ],
273
+ "choice": 0
274
+ }
275
+ ],
276
+ "coreai": [
277
+ {
278
+ "type": "region",
279
+ "cell": 9,
280
+ "box": [
281
+ 0.2005,
282
+ 0.0,
283
+ 0.5598,
284
+ 0.8525
285
+ ],
286
+ "p": 0.5498030781745911
287
+ },
288
+ {
289
+ "type": "choice",
290
+ "probs": [
291
+ 0.9848902821540833,
292
+ 0.015109767206013203
293
+ ],
294
+ "choice": 0
295
+ }
296
+ ]
297
+ }
298
+ ]
bf16-comparison.json ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "case": "refund",
4
+ "max_probability_or_box_error": 0.0006063971668481827,
5
+ "upstream_bf16": [
6
+ {
7
+ "type": "noul",
8
+ "p_yes": 0.9724245667457581
9
+ },
10
+ {
11
+ "type": "choice",
12
+ "probs": [
13
+ 0.9623249769210815,
14
+ 0.01487948652356863,
15
+ 0.02279551699757576
16
+ ],
17
+ "choice": 0
18
+ }
19
+ ],
20
+ "coreai_float32": [
21
+ {
22
+ "type": "noul",
23
+ "p_yes": 0.9725583791732788
24
+ },
25
+ {
26
+ "type": "choice",
27
+ "probs": [
28
+ 0.9617581963539124,
29
+ 0.014839922077953815,
30
+ 0.023401914164423943
31
+ ],
32
+ "choice": 0
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "case": "span",
38
+ "max_probability_or_box_error": 0,
39
+ "upstream_bf16": [
40
+ {
41
+ "type": "span",
42
+ "start": 55,
43
+ "end": 63,
44
+ "text": "INV-2231",
45
+ "p": 1.0
46
+ }
47
+ ],
48
+ "coreai_float32": [
49
+ {
50
+ "type": "span",
51
+ "start": 55,
52
+ "end": 63,
53
+ "text": "INV-2231",
54
+ "p": 1.0
55
+ }
56
+ ]
57
+ },
58
+ {
59
+ "case": "score",
60
+ "max_probability_or_box_error": 0.0008858442306518555,
61
+ "upstream_bf16": [
62
+ {
63
+ "type": "score",
64
+ "probs": [
65
+ 0.011553974822163582,
66
+ 0.012325819581747055,
67
+ 0.05834781378507614,
68
+ 0.9177724123001099
69
+ ],
70
+ "choice": 3,
71
+ "expected": 2.882338684052229
72
+ }
73
+ ],
74
+ "coreai_float32": [
75
+ {
76
+ "type": "score",
77
+ "probs": [
78
+ 0.01181461475789547,
79
+ 0.012464579194784164,
80
+ 0.058834243565797806,
81
+ 0.916886568069458
82
+ ],
83
+ "choice": 3,
84
+ "expected": 2.880792770534754
85
+ }
86
+ ]
87
+ },
88
+ {
89
+ "case": "multi",
90
+ "max_probability_or_box_error": 0.00027555227279663086,
91
+ "upstream_bf16": [
92
+ {
93
+ "type": "multi",
94
+ "probs": [
95
+ 0.9700461626052856,
96
+ 0.9614417552947998,
97
+ 0.022039415314793587
98
+ ]
99
+ }
100
+ ],
101
+ "coreai_float32": [
102
+ {
103
+ "type": "multi",
104
+ "probs": [
105
+ 0.9698116779327393,
106
+ 0.9617173075675964,
107
+ 0.021801499649882317
108
+ ]
109
+ }
110
+ ]
111
+ },
112
+ {
113
+ "case": "negative",
114
+ "max_probability_or_box_error": 0.0009536556899547577,
115
+ "upstream_bf16": [
116
+ {
117
+ "type": "noul",
118
+ "p_yes": 0.049154266715049744
119
+ }
120
+ ],
121
+ "coreai_float32": [
122
+ {
123
+ "type": "noul",
124
+ "p_yes": 0.0501079224050045
125
+ }
126
+ ]
127
+ },
128
+ {
129
+ "case": "unicode",
130
+ "max_probability_or_box_error": 0.0007423162460327148,
131
+ "upstream_bf16": [
132
+ {
133
+ "type": "span",
134
+ "start": 10,
135
+ "end": 17,
136
+ "text": "\u20ac123.45",
137
+ "p": 0.9967826008796692
138
+ },
139
+ {
140
+ "type": "choice",
141
+ "probs": [
142
+ 0.9754987955093384,
143
+ 0.013173527084290981,
144
+ 0.011327654123306274
145
+ ],
146
+ "choice": 0
147
+ }
148
+ ],
149
+ "coreai_float32": [
150
+ {
151
+ "type": "span",
152
+ "start": 10,
153
+ "end": 17,
154
+ "text": "\u20ac123.45",
155
+ "p": 0.9975249171257019
156
+ },
157
+ {
158
+ "type": "choice",
159
+ "probs": [
160
+ 0.9758961200714111,
161
+ 0.012953538447618484,
162
+ 0.011150323785841465
163
+ ],
164
+ "choice": 0
165
+ }
166
+ ]
167
+ },
168
+ {
169
+ "case": "options",
170
+ "max_probability_or_box_error": 0.000368654727935791,
171
+ "upstream_bf16": [
172
+ {
173
+ "type": "choice",
174
+ "probs": [
175
+ 0.003980829380452633,
176
+ 0.003826719941571355,
177
+ 0.004242099355906248,
178
+ 0.0034746285527944565,
179
+ 0.003326982958242297,
180
+ 0.0027517760172486305,
181
+ 0.0033690459094941616,
182
+ 0.0029055955819785595,
183
+ 0.002698659896850586,
184
+ 0.002124715130776167,
185
+ 0.00298517057672143,
186
+ 0.0031158491037786007,
187
+ 0.003178240265697241,
188
+ 0.00276554049924016,
189
+ 0.0025187907740473747,
190
+ 0.0038957030046731234,
191
+ 0.00339102977886796,
192
+ 0.9302572011947632,
193
+ 0.0037882667966187,
194
+ 0.0032934194896370173,
195
+ 0.0014340636553242803,
196
+ 0.0015281529631465673,
197
+ 0.0019327420741319656,
198
+ 0.0032147751189768314
199
+ ],
200
+ "choice": 17
201
+ }
202
+ ],
203
+ "coreai_float32": [
204
+ {
205
+ "type": "choice",
206
+ "probs": [
207
+ 0.004064517095685005,
208
+ 0.00385798211209476,
209
+ 0.004268613178282976,
210
+ 0.0035152393393218517,
211
+ 0.0033423572313040495,
212
+ 0.0027638806495815516,
213
+ 0.0034003155305981636,
214
+ 0.0028926795348525047,
215
+ 0.0026991423219442368,
216
+ 0.00212881900370121,
217
+ 0.0030107346829026937,
218
+ 0.00310140079818666,
219
+ 0.0031818472780287266,
220
+ 0.002777676796540618,
221
+ 0.002535800449550152,
222
+ 0.003921480383723974,
223
+ 0.003430022159591317,
224
+ 0.9298885464668274,
225
+ 0.003782423911616206,
226
+ 0.0032975096255540848,
227
+ 0.0014300619950518012,
228
+ 0.0015298567013815045,
229
+ 0.0019381335005164146,
230
+ 0.0032409762497991323
231
+ ],
232
+ "choice": 17
233
+ }
234
+ ]
235
+ },
236
+ {
237
+ "case": "vision",
238
+ "max_probability_or_box_error": 0.03414243459701538,
239
+ "upstream_bf16": [
240
+ {
241
+ "type": "region",
242
+ "cell": 9,
243
+ "box": [
244
+ 0.2031,
245
+ 0.0,
246
+ 0.5674,
247
+ 0.86
248
+ ],
249
+ "p": 0.5970777273178101
250
+ },
251
+ {
252
+ "type": "choice",
253
+ "probs": [
254
+ 0.9851310849189758,
255
+ 0.014868861995637417
256
+ ],
257
+ "choice": 0
258
+ }
259
+ ],
260
+ "coreai_float32": [
261
+ {
262
+ "type": "region",
263
+ "cell": 9,
264
+ "box": [
265
+ 0.1936,
266
+ 0.0,
267
+ 0.5538,
268
+ 0.8461
269
+ ],
270
+ "p": 0.5629352927207947
271
+ },
272
+ {
273
+ "type": "choice",
274
+ "probs": [
275
+ 0.9847150444984436,
276
+ 0.015284995548427105
277
+ ],
278
+ "choice": 0
279
+ }
280
+ ]
281
+ }
282
+ ]
calibration.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "span": {
3
+ "cap": 30,
4
+ "penalty": 0.2,
5
+ "fitted_on": "held-out quotes: the quote-amounts slice and longquotes clean.jsonl (fit halves)"
6
+ }
7
+ }
config.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"base": "Qwen/Qwen3.5-2B"}
convert_decoder.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export AMOS Qwen3.5 bidirectional decisions and trained quote/box heads."""
2
+ import argparse,json,math,sys
3
+ from pathlib import Path
4
+ import torch
5
+ from coreai_torch import TorchConverter,get_decomp_table
6
+ from qwen_export_helpers import DecisionVision
7
+
8
+ class Text(torch.nn.Module):
9
+ def __init__(self,model,letters):
10
+ super().__init__();self.language_model=model.model.language_model
11
+ self.register_buffer('readout',model.get_output_embeddings().weight[letters].detach().float().clone())
12
+ def forward(self,input_ids,attention_mask,position_ids,visual_embeddings,visual_mask):
13
+ emb=self.language_model.embed_tokens(input_ids)
14
+ emb=torch.where(visual_mask,visual_embeddings.to(emb.dtype),emb)
15
+ h=self.language_model(inputs_embeds=emb,attention_mask={'full_attention':attention_mask[:,None,None,:].bool(),'linear_attention':None},position_ids=position_ids,use_cache=False).last_hidden_state
16
+ return h.float(),h.float()@self.readout.T
17
+
18
+ class Heads(torch.nn.Module):
19
+ def __init__(self,heads):super().__init__();self.heads=heads
20
+ def forward(self,question_hidden,state_hidden,image_hidden):
21
+ h=self.heads;d=question_hidden.shape[0];dtype=h.span_q.weight.dtype
22
+ q=question_hidden.to(dtype);s=state_hidden.to(dtype);im=image_hidden.to(dtype)
23
+ span=torch.einsum('cd,lcd->cl',h.span_q(q).view(2,d),h.span_k(s).view(-1,2,d))/math.sqrt(d)
24
+ region=h.region_k(im)@h.region_q(q)/math.sqrt(d)
25
+ box=h.box(q,im,region,7,7) if h.box is not None else torch.zeros(4)
26
+ return span.float(),region.float(),box.float()
27
+
28
+ def save(m,args,path):
29
+ with torch.no_grad():ep=torch.export.export(m,args,strict=False).run_decompositions(get_decomp_table())
30
+ program=TorchConverter().add_exported_program(ep).to_coreai();program.save_asset(path)
31
+ return list(ep.graph_signature.user_outputs)
32
+
33
+ def main():
34
+ p=argparse.ArgumentParser();p.add_argument('name');p.add_argument('--length',type=int,default=512);p.add_argument('--precision',default='bfloat16');a=p.parse_args()
35
+ source=Path('upstream')/a.name;out=Path('bundles')/(a.name+'-CoreAI');out.mkdir(parents=True,exist_ok=True)
36
+ sys.path.insert(0,str((source/'serve').resolve()))
37
+ from amos.decoder import load_model,load_heads
38
+ torch.set_num_threads(4);model,codec=load_model(str(source/'model'),'cpu',dtype=getattr(torch,a.precision));model.eval();heads=load_heads(source/'model',model,codec)
39
+ d=model.config.text_config.hidden_size;L=a.length
40
+ inputs=(torch.full((1,L),codec.pad,dtype=torch.int32),torch.ones(1,L,dtype=torch.int32),torch.arange(L,dtype=torch.int32).view(1,1,-1).expand(4,1,-1),torch.zeros(1,L,d),torch.zeros(1,L,1,dtype=torch.bool))
41
+ print('Exporting text',flush=True);outputs=save(Text(model,codec.letters).eval(),inputs,out/'text.aimodel')
42
+ print('Exporting vision',flush=True);visual=model.model.visual;vo=save(DecisionVision(visual).eval(),(torch.zeros(196,1536),),out/'vision.aimodel')
43
+ print('Exporting heads',flush=True);ho=save(Heads(heads).eval(),(torch.zeros(d),torch.zeros(L,d),torch.zeros(49,d)),out/'heads.aimodel')
44
+ (out/'manifest.json').write_text(json.dumps({'sequence_length':L,'hidden_size':d,'outputs':outputs,'vision_output':vo[0],'head_outputs':ho,'precision':a.precision,'echo':codec.echo,'kinds':heads.kinds,'keep':heads.keep,'has_box':heads.box is not None,'image_side':224},indent=2))
45
+ print('Saved',flush=True)
46
+ if __name__=='__main__':main()
coreai_decoder.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AMOS Qwen3.5 Core AI adapter. No PyTorch neural model is loaded."""
2
+ import json,sys
3
+ from pathlib import Path
4
+ import numpy as np
5
+ import torch
6
+ from coreai.runtime import AIModel,NDArray,SpecializationOptions
7
+ from transformers import AutoTokenizer,Qwen2VLImageProcessorPil
8
+
9
+ class AmosDecoder:
10
+ @classmethod
11
+ async def load(cls,bundle):
12
+ self=cls();self.root=Path(bundle).resolve();sys.path.insert(0,str(self.root/'serve'))
13
+ import amos.decoder as decoder
14
+ decoder.IMAGE_PIXELS=224*224
15
+ self.manifest=json.loads((self.root/'manifest.json').read_text())
16
+ self.codec=decoder.Codec(AutoTokenizer.from_pretrained(self.root/'model'),Qwen2VLImageProcessorPil.from_pretrained(self.root/'model',size={'shortest_edge':224*224,'longest_edge':224*224}),255)
17
+ self.codec.echo=self.manifest['echo']
18
+ self.temperature=decoder.calibration(self.root) or {};self.span=decoder.span_decoding(self.root)
19
+ self.models=[];self.functions={}
20
+ for name in ['text','vision','heads']:
21
+ m=await AIModel.load(self.root/(name+'.aimodel'),SpecializationOptions.default());self.models.append(m);self.functions[name]=m.load_function('main')
22
+ return self
23
+
24
+ async def raw(self,state,questions,images=()):
25
+ from amos.decoder import expand
26
+ flat,owner=expand(questions)
27
+ ids,at,extra=self.codec.encode_case(state,flat,images)
28
+ n=len(ids);L=self.manifest['sequence_length'];d=self.manifest['hidden_size']
29
+ if n>L:raise ValueError(f'Request needs {n} tokens; Core AI capacity is {L}.')
30
+ args={'input_ids':np.full((1,L),self.codec.pad,np.int32),'attention_mask':np.zeros((1,L),np.int32),'position_ids':np.zeros((4,1,L),np.int32),'visual_embeddings':np.zeros((1,L,d),np.float32),'visual_mask':np.zeros((1,L,1),bool)}
31
+ args['input_ids'][0,:n]=ids;args['attention_mask'][0,:n]=1;args['position_ids'][0,0,:n]=np.arange(n)
32
+ image_at={s:(rows,cols) for s,rows,cols in extra.get('images',[])}
33
+ features=[]
34
+ if image_at:
35
+ pv=extra['pixel_values'].numpy()
36
+ for start in range(0,len(pv),196):
37
+ out=await self.functions['vision']({'pixel_values':NDArray(pv[start:start+196].astype(np.float32))});features.append(out[self.manifest['vision_output']].numpy())
38
+ cursor=0;offset=0;fi=0
39
+ while cursor<n:
40
+ if cursor in image_at:
41
+ rows,cols=image_at[cursor]
42
+ if (rows,cols)!=(7,7):raise ValueError('Only 224x224 image grids are supported')
43
+ end=cursor+49;args['position_ids'][1,0,cursor:end]=offset;args['position_ids'][2,0,cursor:end]=offset+np.repeat(np.arange(7),7);args['position_ids'][3,0,cursor:end]=offset+np.tile(np.arange(7),7)
44
+ args['visual_embeddings'][0,cursor:end]=features[fi];args['visual_mask'][0,cursor:end]=True;offset+=7;cursor=end;fi+=1
45
+ else:
46
+ args['position_ids'][1:,0,cursor]=offset;offset+=1;cursor+=1
47
+ output=await self.functions['text']({k:NDArray(v) for k,v in args.items()})
48
+ hidden,logits=[output[k].numpy() for k in self.manifest['outputs']]
49
+ head_results={}
50
+ for i,(q,pos) in enumerate(zip(flat,at)):
51
+ if q['type'] not in ['span','region']:continue
52
+ first,offsets=extra['state'];state_hidden=np.zeros((L,d),np.float32);state_hidden[:len(offsets)]=hidden[0,first:first+len(offsets)]
53
+ cells=np.zeros((49,d),np.float32)
54
+ if extra.get('images'):
55
+ start,rows,cols=extra['images'][0];cells=hidden[0,start:start+49].copy()
56
+ if q['type']=='region' and not extra.get('images'):raise ValueError('Region requires a state image')
57
+ ho=await self.functions['heads']({'question_hidden':NDArray(hidden[0,pos].copy()),'state_hidden':NDArray(state_hidden),'image_hidden':NDArray(cells)})
58
+ span,region,box=[torch.from_numpy(ho[k].numpy().copy()) for k in self.manifest['head_outputs']]
59
+ head_results[i]=span[:,:len(offsets)] if q['type']=='span' else torch.cat([region,box]) if self.manifest['has_box'] else region
60
+ return {'flat':flat,'owner':owner,'at':at,'extra':extra,'logits':logits[0],'heads':head_results,'tokens':n,'hidden':hidden[0]}
61
+
62
+ async def predict(self,state,questions,images=()):
63
+ from amos.decoder import options,decode_head
64
+ raw=await self.raw(state,questions,images);out=[None]*len(questions)
65
+ for j,(q,k,pos) in enumerate(zip(raw['flat'],raw['owner'],raw['at'])):
66
+ typ=questions[k]['type']
67
+ if q['type'] in ['span','region']:
68
+ out[k]=decode_head(q,raw['heads'][j],raw['extra'],state,self.manifest['keep'],self.span);continue
69
+ logits=torch.tensor(raw['logits'][pos,:len(options(q))]);p=(logits/self.temperature.get(q['type'],1)).softmax(-1).tolist()
70
+ if typ=='multi':out[k]=out[k] or {'type':typ,'probs':[]};out[k]['probs'].append(p[0])
71
+ elif typ=='noul':out[k]={'type':typ,'p_yes':p[0]}
72
+ else:
73
+ out[k]={'type':typ,'probs':p,'choice':int(np.argmax(p))}
74
+ if typ=='score':out[k]['expected']=sum(i*v for i,v in enumerate(p))
75
+ return out
76
+
77
+ async def request(self,req):
78
+ from amos.serve import parse,answer
79
+ from PIL import Image
80
+ import base64,io
81
+ def image(s):return Image.open(io.BytesIO(base64.b64decode(s.split(',')[-1]))).convert('RGB')
82
+ state,ids,qs=parse(req);ims=[image(s) for s in req.get('images',[])]
83
+ qs=[{**q,'options':[image(o['image']) if isinstance(o,dict) else o for o in q['options']]} if 'options' in q else q for q in qs]
84
+ rs=await self.predict(state,qs,ims)
85
+ return {'answers':{i:answer(q,r) for i,q,r in zip(ids,qs,rs)}}
heads.aimodel/main.hash ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #��,:\bi��b�T
2
+ Zh4�2gK�G�:[��`
heads.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:132384c42c3a5c6269a79862d6540a5a6834b532674ba847d83a5b181f85a860
3
+ size 109141184
heads.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "creationDate" : "20261014T012237Z",
3
+ "assetVersion" : "2.0",
4
+ "producer" : "coreai-core 1.0.0b3"
5
+ }
manifest.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sequence_length": 512,
3
+ "hidden_size": 2048,
4
+ "outputs": [
5
+ "mul_1744",
6
+ "view_3923"
7
+ ],
8
+ "vision_output": "addmm_98",
9
+ "head_outputs": [
10
+ "div",
11
+ "div_1",
12
+ "clamp_2"
13
+ ],
14
+ "precision": "float32",
15
+ "echo": true,
16
+ "kinds": [
17
+ "span",
18
+ "region"
19
+ ],
20
+ "keep": 0.25,
21
+ "has_box": true,
22
+ "image_side": 224
23
+ }
model/chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is true %}
150
+ {{- '<think>\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n\n</think>\n\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
model/config.json ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "amos_export_dtype": "bfloat16",
3
+ "architectures": [
4
+ "Qwen3_5ForConditionalGeneration"
5
+ ],
6
+ "dtype": "bfloat16",
7
+ "image_token_id": 248056,
8
+ "model_type": "qwen3_5",
9
+ "text_config": {
10
+ "attention_bias": false,
11
+ "attention_dropout": 0.0,
12
+ "attn_output_gate": true,
13
+ "bos_token_id": null,
14
+ "dtype": "bfloat16",
15
+ "eos_token_id": 248044,
16
+ "full_attention_interval": 4,
17
+ "head_dim": 256,
18
+ "hidden_act": "silu",
19
+ "hidden_size": 2048,
20
+ "initializer_range": 0.02,
21
+ "intermediate_size": 6144,
22
+ "layer_types": [
23
+ "linear_attention",
24
+ "linear_attention",
25
+ "linear_attention",
26
+ "full_attention",
27
+ "linear_attention",
28
+ "linear_attention",
29
+ "linear_attention",
30
+ "full_attention",
31
+ "linear_attention",
32
+ "linear_attention",
33
+ "linear_attention",
34
+ "full_attention",
35
+ "linear_attention",
36
+ "linear_attention",
37
+ "linear_attention",
38
+ "full_attention",
39
+ "linear_attention",
40
+ "linear_attention",
41
+ "linear_attention",
42
+ "full_attention",
43
+ "linear_attention",
44
+ "linear_attention",
45
+ "linear_attention",
46
+ "full_attention"
47
+ ],
48
+ "linear_conv_kernel_dim": 4,
49
+ "linear_key_head_dim": 128,
50
+ "linear_num_key_heads": 16,
51
+ "linear_num_value_heads": 16,
52
+ "linear_value_head_dim": 128,
53
+ "mamba_ssm_dtype": "float32",
54
+ "max_position_embeddings": 262144,
55
+ "mlp_only_layers": [],
56
+ "model_type": "qwen3_5_text",
57
+ "mtp_num_hidden_layers": 1,
58
+ "mtp_use_dedicated_embeddings": false,
59
+ "num_attention_heads": 8,
60
+ "num_hidden_layers": 24,
61
+ "num_key_value_heads": 2,
62
+ "pad_token_id": null,
63
+ "partial_rotary_factor": 0.25,
64
+ "rms_norm_eps": 1e-06,
65
+ "rope_parameters": {
66
+ "mrope_interleaved": true,
67
+ "mrope_section": [
68
+ 11,
69
+ 11,
70
+ 10
71
+ ],
72
+ "partial_rotary_factor": 0.25,
73
+ "rope_theta": 10000000,
74
+ "rope_type": "default"
75
+ },
76
+ "tie_word_embeddings": true,
77
+ "use_cache": false,
78
+ "vocab_size": 248320,
79
+ "transformers_version": "5.17.0"
80
+ },
81
+ "tie_word_embeddings": true,
82
+ "transformers_version": "5.17.0",
83
+ "use_cache": false,
84
+ "video_token_id": 248057,
85
+ "vision_config": {
86
+ "deepstack_visual_indexes": [],
87
+ "depth": 24,
88
+ "dtype": "bfloat16",
89
+ "hidden_act": "gelu_pytorch_tanh",
90
+ "hidden_size": 1024,
91
+ "in_channels": 3,
92
+ "initializer_range": 0.02,
93
+ "intermediate_size": 4096,
94
+ "model_type": "qwen3_5_vision",
95
+ "num_heads": 16,
96
+ "num_position_embeddings": 2304,
97
+ "out_hidden_size": 2048,
98
+ "patch_size": 16,
99
+ "rope_parameters": {
100
+ "rope_theta": 10000.0,
101
+ "rope_type": "axial"
102
+ },
103
+ "spatial_merge_size": 2,
104
+ "temporal_patch_size": 2
105
+ },
106
+ "vision_end_token_id": 248054,
107
+ "vision_start_token_id": 248053,
108
+ "amos_codes": 255,
109
+ "amos_noncausal": true
110
+ }
model/generation_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "eos_token_id": 248044,
4
+ "transformers_version": "5.17.0",
5
+ "use_cache": true
6
+ }
model/preprocessor_config.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_convert_rgb": true,
3
+ "do_normalize": true,
4
+ "do_rescale": true,
5
+ "do_resize": true,
6
+ "image_mean": [
7
+ 0.5,
8
+ 0.5,
9
+ 0.5
10
+ ],
11
+ "image_processor_type": "Qwen2VLImageProcessor",
12
+ "image_std": [
13
+ 0.5,
14
+ 0.5,
15
+ 0.5
16
+ ],
17
+ "merge_size": 2,
18
+ "patch_size": 16,
19
+ "resample": 3,
20
+ "rescale_factor": 0.00392156862745098,
21
+ "size": {
22
+ "longest_edge": 16777216,
23
+ "shortest_edge": 65536
24
+ },
25
+ "temporal_patch_size": 2
26
+ }
model/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aa7582a6b90dc3711049d293d985e549f2b22c0c4e5ad11b9272a03c6294886e
3
+ size 19989510
model/tokenizer_config.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|im_end|>",
10
+ "errors": "replace",
11
+ "extra_special_tokens": [
12
+ "<answer>"
13
+ ],
14
+ "image_token": "<|image_pad|>",
15
+ "is_local": true,
16
+ "local_files_only": false,
17
+ "model_max_length": 262144,
18
+ "model_specific_special_tokens": {
19
+ "audio_bos_token": "<|audio_start|>",
20
+ "audio_eos_token": "<|audio_end|>",
21
+ "audio_token": "<|audio_pad|>",
22
+ "image_token": "<|image_pad|>",
23
+ "video_token": "<|video_pad|>",
24
+ "vision_bos_token": "<|vision_start|>",
25
+ "vision_eos_token": "<|vision_end|>"
26
+ },
27
+ "pad_token": "<|endoftext|>",
28
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
29
+ "split_special_tokens": false,
30
+ "tokenizer_class": "Qwen2Tokenizer",
31
+ "unk_token": null,
32
+ "video_token": "<|video_pad|>",
33
+ "vision_bos_token": "<|vision_start|>",
34
+ "vision_eos_token": "<|vision_end|>"
35
+ }
model/video_preprocessor_config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "size": {
3
+ "longest_edge": 25165824,
4
+ "shortest_edge": 4096
5
+ },
6
+ "patch_size": 16,
7
+ "temporal_patch_size": 2,
8
+ "merge_size": 2,
9
+ "image_mean": [
10
+ 0.5,
11
+ 0.5,
12
+ 0.5
13
+ ],
14
+ "image_std": [
15
+ 0.5,
16
+ 0.5,
17
+ 0.5
18
+ ],
19
+ "processor_class": "Qwen3VLProcessor",
20
+ "video_processor_type": "Qwen3VLVideoProcessor"
21
+ }
qwen_export_helpers.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export pinned Decision 3.0 text inference; vision export is separate."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import torch
8
+ import torch.nn.functional as F
9
+ from coreai_torch import TorchConverter, get_decomp_table
10
+ from safetensors.torch import load_file
11
+ from transformers import Qwen3_5Model
12
+ from transformers.models.qwen3_5.modeling_qwen3_5 import apply_rotary_pos_emb_vision
13
+ from transformers.vision_utils import (
14
+ get_vision_interpolation_indices_and_weights,
15
+ get_vision_position_ids,
16
+ )
17
+
18
+
19
+ class DecisionText(torch.nn.Module):
20
+ def __init__(self, backbone, readout):
21
+ super().__init__()
22
+ self.language_model = backbone.language_model
23
+ self.register_buffer("readout", readout.float())
24
+
25
+ def forward(
26
+ self, input_ids, attention_mask, position_ids, visual_embeddings, visual_mask
27
+ ):
28
+ embeddings = self.language_model.embed_tokens(input_ids)
29
+ embeddings = torch.where(
30
+ visual_mask, visual_embeddings.to(embeddings.dtype), embeddings
31
+ )
32
+ hidden = self.language_model(
33
+ inputs_embeds=embeddings,
34
+ attention_mask={
35
+ "full_attention": attention_mask[:, None, None, :].bool(),
36
+ "linear_attention": attention_mask,
37
+ },
38
+ position_ids=position_ids,
39
+ use_cache=False,
40
+ ).last_hidden_state
41
+ return hidden[:, -1].float() @ self.readout.T
42
+
43
+
44
+ class DecisionVision(torch.nn.Module):
45
+ """The unchanged vision weights at a fixed one-image 224px grid."""
46
+
47
+ def __init__(self, visual, side=224):
48
+ super().__init__()
49
+ self.visual = visual
50
+ grid = torch.tensor(
51
+ [[1, side // visual.config.patch_size, side // visual.config.patch_size]]
52
+ )
53
+ indices, weights = get_vision_interpolation_indices_and_weights(
54
+ grid,
55
+ num_grid_per_side=visual.num_grid_per_side,
56
+ mode=visual.interpolation_mode,
57
+ align_corners=visual.interpolation_align_corners,
58
+ spatial_merge_size=visual.config.spatial_merge_size,
59
+ kwargs={},
60
+ )
61
+ self.register_buffer("indices", indices)
62
+ self.register_buffer("weights", weights)
63
+ self.register_buffer(
64
+ "positions",
65
+ get_vision_position_ids(grid, visual.spatial_merge_size, kwargs={}),
66
+ )
67
+
68
+ def forward(self, pixel_values):
69
+ v = self.visual
70
+ hidden = F.linear(
71
+ pixel_values.to(v.patch_embed.proj.weight.dtype),
72
+ v.patch_embed.proj.weight.flatten(1),
73
+ v.patch_embed.proj.bias,
74
+ )
75
+ hidden = hidden + (v.pos_embed(self.indices) * self.weights[:, :, None]).sum(
76
+ 1
77
+ ).to(hidden.dtype)
78
+ cos, sin = v.rotary_pos_emb(hidden, self.positions)
79
+ for block in v.blocks:
80
+ x = block.norm1(hidden)
81
+ attn = block.attn
82
+ q, k, value = (
83
+ attn.qkv(x)
84
+ .reshape(x.shape[0], 3, attn.num_heads, -1)
85
+ .permute(1, 0, 2, 3)
86
+ .unbind(0)
87
+ )
88
+ q, k = apply_rotary_pos_emb_vision(q, k, cos, sin)
89
+ y = F.scaled_dot_product_attention(
90
+ q.transpose(0, 1).unsqueeze(0),
91
+ k.transpose(0, 1).unsqueeze(0),
92
+ value.transpose(0, 1).unsqueeze(0),
93
+ scale=attn.scaling,
94
+ )
95
+ hidden = hidden + attn.proj(
96
+ y.squeeze(0).transpose(0, 1).reshape(x.shape[0], -1)
97
+ )
98
+ hidden = hidden + block.mlp(block.norm2(hidden))
99
+ return v.merger(hidden).float()
100
+
101
+
102
+ def save(model, sample, path):
103
+ with torch.no_grad():
104
+ exported = torch.export.export(model, sample, strict=False)
105
+ print("Decomposing", flush=True)
106
+ exported = exported.run_decompositions(get_decomp_table())
107
+ print("Converting", flush=True)
108
+ program = TorchConverter().add_exported_program(exported).to_coreai()
109
+ program.save_asset(path)
110
+ return exported.graph_signature.user_outputs[0]
111
+
112
+
113
+ def main():
114
+ p = argparse.ArgumentParser()
115
+ p.add_argument("--source", default="upstream/d3-edge")
116
+ p.add_argument("--output", default="bundles/d3-edge-CoreAI")
117
+ p.add_argument("--length", type=int, default=512)
118
+ p.add_argument("--precision", choices=["bfloat16", "float32"], default="bfloat16")
119
+ a = p.parse_args()
120
+ torch.set_num_threads(4)
121
+ source = Path(a.source)
122
+ output = Path(a.output)
123
+ output.mkdir(parents=True, exist_ok=True)
124
+ print("Loading backbone", flush=True)
125
+ backbone = Qwen3_5Model.from_pretrained(
126
+ source, dtype=getattr(torch, a.precision), attn_implementation="sdpa"
127
+ ).eval()
128
+ model = DecisionText(
129
+ backbone, load_file(str(source / "readout.safetensors"))["weight"]
130
+ ).eval()
131
+ hidden = backbone.config.text_config.hidden_size
132
+ sample = (
133
+ torch.ones(1, a.length, dtype=torch.int32),
134
+ torch.ones(1, a.length, dtype=torch.int32),
135
+ torch.arange(a.length, dtype=torch.int32).view(1, 1, -1).expand(4, 1, -1),
136
+ torch.zeros(1, a.length, hidden),
137
+ torch.zeros(1, a.length, 1, dtype=torch.bool),
138
+ )
139
+ print("Exporting", flush=True)
140
+ out = save(model, sample, output / "decision.aimodel")
141
+ vision = DecisionVision(backbone.visual).eval()
142
+ patches = (224 // backbone.config.vision_config.patch_size) ** 2
143
+ width = (
144
+ 3
145
+ * backbone.config.vision_config.temporal_patch_size
146
+ * backbone.config.vision_config.patch_size**2
147
+ )
148
+ vision_out = save(vision, (torch.zeros(patches, width),), output / "vision.aimodel")
149
+ (output / "manifest.json").write_text(
150
+ json.dumps(
151
+ {
152
+ "source": source.name,
153
+ "sequence_length": a.length,
154
+ "hidden_size": hidden,
155
+ "inputs": [
156
+ "input_ids",
157
+ "attention_mask",
158
+ "position_ids",
159
+ "visual_embeddings",
160
+ "visual_mask",
161
+ ],
162
+ "output": out,
163
+ "vision_output": vision_out,
164
+ "image_side": 224,
165
+ "precision": a.precision,
166
+ },
167
+ indent=2,
168
+ )
169
+ + "\n"
170
+ )
171
+ print("Saved", flush=True)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
runtime-smoke.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "passed": true,
3
+ "pytorch_neural_execution_disabled": true,
4
+ "result": {
5
+ "answers": {
6
+ "refund": {
7
+ "type": "noul",
8
+ "noul": 0.9714967012405396
9
+ }
10
+ }
11
+ }
12
+ }
serve/README.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # amos-serve
2
+
3
+ The serving code of the Amos 1 decision models (`amos-1-large`, `amos-1-medium`, `amos-1-small`, `amos-1-mini`): one
4
+ HTTP server that takes a case and typed questions and answers every question from one forward pass, each with a
5
+ probability. No text is generated. This is the code behind api.amos.scira.ai and the code the Decision Index runs.
6
+
7
+ Install into a fresh environment (Python 3.12 or newer, an NVIDIA GPU):
8
+
9
+ ```bash
10
+ pip install torch==2.14.0 --index-url https://download.pytorch.org/whl/cu130
11
+ pip install .
12
+ ```
13
+
14
+ The dependencies are pinned (torch 2.14.0, transformers 5.17.0, flash-linear-attention 0.5.2) because they are the
15
+ versions every published number was measured with: with them the package answers byte for byte like our own servers.
16
+ Other versions run, but probabilities can move by up to 0.02.
17
+
18
+ Serve a model directory (a Hugging Face checkout with `model/`, `config.json` and `calibration.json`):
19
+
20
+ ```bash
21
+ python -m amos.serve amos-1-large --device cuda --port 8000 --rotations 1 --max-len 131072 --strict --no-state-cache
22
+ ```
23
+
24
+ - `--strict` keeps question and option text exactly as sent and refuses requests over `--max-len` tokens instead of
25
+ truncating them.
26
+ - `--rotations N` averages each choice question over N option orders (1 is what the Index latency rule allows).
27
+ - `--no-state-cache` encodes every request from scratch.
28
+ - `GET /v1/info` reports the model name; `POST /v1/systemone` takes the System One request shape:
29
+
30
+ ```bash
31
+ curl -s localhost:8000/v1/systemone -H 'content-type: application/json' -d '{
32
+ "state": "Invoice INV-2231 for 306.00 was charged twice this morning. Please refund one of them today.",
33
+ "questions": {
34
+ "refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
35
+ "team": {"type": "choice", "instructions": "Which team should handle this?",
36
+ "criteria": {"billing": null, "technical": null, "sales": null}},
37
+ "urgency": {"type": "score", "instructions": "How urgent is this?", "criteria": ["Can wait", "This week", "Today"]}
38
+ }
39
+ }'
40
+ ```
41
+
42
+ Choice questions take 2 to 255 options and are read in one pass. Photos go in `images` as base64 data URLs; every
43
+ question sees them. `span` (quote the case) and `region` (point at the photo) need the model's `heads.pt`.
44
+
45
+ `sources.json` lists every file with the repository commit and sha256 it was taken from. `decoder.py` and
46
+ `calibrate.py` are the repository's modules cut to the definitions the server reaches; the other modules are verbatim.
serve/amos/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Amos 1 serving code. See README.md."""
serve/amos/calibrate.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cut from amos/calibrate.py: only the definitions amos.serve reaches (see sources.json)."""
2
+ import argparse
3
+ import json
4
+ import math
5
+ import sys
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+
9
+ SET_TYPES = ("noul", "choice", "score")
10
+
11
+ def _log(p):
12
+ return math.log(min(max(p, 1e-9), 1 - 1e-9))
13
+
14
+ def distribution(pred) -> list[float]:
15
+ return [1 - pred["p_yes"], pred["p_yes"]] if pred["type"] == "noul" else pred["probs"]
16
+
17
+ def rescale(probs, T):
18
+ """Softmax(log p / T): temperature applied to the model's logits (log-probs up to a constant)."""
19
+ z = [_log(p) / T for p in probs]
20
+ m = max(z)
21
+ e = [math.exp(x - m) for x in z]
22
+ s = sum(e)
23
+ return [x / s for x in e]
24
+
25
+ def rescale_binary(p, T):
26
+ return 1 / (1 + math.exp(-(_log(p) - _log(1 - p)) / T))
27
+
28
+ def apply(pred: dict, cal: dict, alpha: float | None = None) -> dict:
29
+ """Recalibrated copy of a decoded prediction; with alpha, adds the conformal set and abstain flag."""
30
+ typ, T = pred["type"], cal["temperature"].get(pred["type"], 1.0)
31
+ out = dict(pred)
32
+ if typ in SET_TYPES:
33
+ probs = rescale(distribution(pred), T)
34
+ if typ == "noul":
35
+ out["p_yes"] = probs[1]
36
+ else:
37
+ out["probs"] = probs
38
+ if typ == "score":
39
+ out["expected"] = sum(i * p for i, p in enumerate(probs))
40
+ if alpha is not None:
41
+ qhat = cal["qhat"][typ][str(alpha)]
42
+ out["set"] = [i for i, p in enumerate(probs) if p >= 1 - qhat]
43
+ out["abstain"] = len(out["set"]) != 1
44
+ elif typ == "multi":
45
+ out["probs"] = [rescale_binary(p, T) for p in pred["probs"]]
46
+ else:
47
+ out["p"] = rescale_binary(pred["p"], T)
48
+ return out
49
+
serve/amos/decoder.py ADDED
@@ -0,0 +1,727 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cut from amos/decoder.py: only the definitions amos.serve reaches (see sources.json)."""
2
+ import argparse
3
+ import glob
4
+ import hashlib
5
+ import itertools
6
+ import json
7
+ import math
8
+ import os
9
+ import random
10
+ import shutil
11
+ import string
12
+ import time
13
+ from pathlib import Path
14
+ import torch
15
+
16
+ ANSWER = "<answer>"
17
+
18
+ IMAGE_PAD = "<|image_pad|>"
19
+
20
+ LETTERS = string.ascii_uppercase + string.ascii_lowercase # the first 52 option codes; runs before amos_codes use these alone
21
+
22
+ KINDS = ("noul", "choice", "score") # answered by option letters
23
+
24
+ HEADED = ("span", "region") # answered by Heads
25
+
26
+ SPAN_LENGTH_PENALTY = 1.6
27
+
28
+ SPAN_CAP = 30
29
+
30
+ HINTS = {"score": " (levels from lowest to highest)", "span": " (quote the case)", "region": " (point to it in the image)"}
31
+
32
+ IMAGE_PIXELS = int(os.environ.get("AMOS_IMAGE_PIXELS", 1 << 20))
33
+
34
+ HEADS = "heads.pt"
35
+
36
+ REGION_KEEP = 0.5 # a point's box: cells joined to the peak holding this share of its probability (vision.peak_box)
37
+
38
+ SYSTEM = ("You make decisions about a case. Read the case, then answer every question with the letter of the option "
39
+ "that is most likely right.")
40
+
41
+ def options(q: dict) -> list[str]:
42
+ """Option lines in answer order; yes/no is yes first, so its target is [P(yes), P(no)]."""
43
+ if q["type"] == "noul":
44
+ c = q.get("criteria") or {}
45
+ return [f"yes: {c['true']}" if c.get("true") else "yes", f"no: {c['false']}" if c.get("false") else "no"]
46
+ return list(q["options"])
47
+
48
+ def render(state: str, questions: list[dict], images=(), codes=LETTERS) -> list[dict]:
49
+ """Chat messages for a case. Images (the state's after "Case:", image options where they are listed, a pick-any
50
+ option's image after its question) make the user message a list of text and image items; without any it is plain
51
+ text."""
52
+ parts = ["Case:\n", *images, f"{state}\n\nQuestions:\n"]
53
+ for i, q in enumerate(questions, start=1):
54
+ parts += ["\n" if i > 1 else "", f"q{i}. {q['text']}" + HINTS.get(q["type"], "")]
55
+ if "subject" in q:
56
+ parts += [" ", q["subject"]]
57
+ for j, o in enumerate(options(q) if q["type"] in KINDS else ()):
58
+ parts += [f"\n{codes[j]}) ", o]
59
+ merged = []
60
+ for x in parts:
61
+ if isinstance(x, str) and merged and isinstance(merged[-1], str):
62
+ merged[-1] += x
63
+ else:
64
+ merged.append(x)
65
+ user = merged[0] if len(merged) == 1 else [{"type": "text", "text": x} if isinstance(x, str) else {"type": "image", "image": x}
66
+ for x in merged if x != ""]
67
+ answers = "\n".join(f"q{i}: {ANSWER}" for i in range(1, len(questions) + 1))
68
+ reply = {"role": "assistant", "content": answers}
69
+ if len(questions) == 1 and questions[0].get("reasoning"): # a trace goes in the thinking block, as think() reads
70
+ reply["reasoning_content"] = questions[0]["reasoning"]
71
+ return [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}, reply]
72
+
73
+ def expand(questions: list[dict]) -> tuple[list[dict], list[int]]:
74
+ """Pick-any questions become one yes/no per option ("subject") in the same prompt, each with its own answer ->
75
+ (questions, index of each one's original question)."""
76
+ flat, owner = [], []
77
+ for k, q in enumerate(questions):
78
+ if q["type"] != "multi":
79
+ flat.append(q)
80
+ owner.append(k)
81
+ continue
82
+ for j, o in enumerate(q["options"]):
83
+ flat.append({"type": "noul", "text": q["text"] + " Does this option apply?", "subject": o,
84
+ **({"target": q["target"][j]} if "target" in q else {})})
85
+ owner.append(k)
86
+ return flat, owner
87
+
88
+ PLAIN_TEMPLATE = ("{% for m in messages %}{{ m['role'] }}:\n{% if m['content'] is string %}{{ m['content'] }}{% else %}"
89
+ "{% for x in m['content'] %}{{ x['text'] if x['type'] == 'text' else '' }}{% endfor %}{% endif %}"
90
+ "\n\n{% endfor %}")
91
+
92
+ def option_codes(tokenizer, n: int) -> list[str]:
93
+ """The first n option codes: the 52 letters, then two-letter codes the tokenizer keeps as one token."""
94
+ two = ("".join(p) for p in itertools.product(string.ascii_uppercase, repeat=2))
95
+ codes = list(LETTERS)[:n] + [c for c in two if len(tokenizer.encode(c, add_special_tokens=False)) == 1][:max(0, n - 52)]
96
+ if len(codes) != n:
97
+ raise ValueError(f"the tokenizer has only {len(codes)} one-token option codes, not {n}")
98
+ return codes
99
+
100
+ class Codec:
101
+ def __init__(self, tokenizer, image_processor=None, n_codes: int = len(LETTERS)):
102
+ self.tok, self.image_processor = tokenizer, image_processor
103
+ if getattr(tokenizer, "chat_template", "") is None:
104
+ tokenizer.chat_template = PLAIN_TEMPLATE
105
+ if ANSWER not in tokenizer.get_added_vocab():
106
+ tokenizer.add_special_tokens({"additional_special_tokens": [ANSWER]})
107
+ self.marker = tokenizer.convert_tokens_to_ids(ANSWER)
108
+ self.codes = option_codes(tokenizer, n_codes)
109
+ ids = [tokenizer.encode(x, add_special_tokens=False) for x in self.codes]
110
+ if any(len(i) != 1 for i in ids):
111
+ raise ValueError("every option letter must be one token")
112
+ self.letters = [i[0] for i in ids]
113
+ self.pad = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
114
+ # A unified model (Gemma 4) comes with its whole processor, which turns each image placeholder into its own run
115
+ # of tokens; Qwen comes with an image processor alone, and the placeholder is expanded here
116
+ self.unified = image_processor is not None and hasattr(image_processor, "image_token")
117
+ pad = image_processor.image_token if self.unified else IMAGE_PAD
118
+ self.image_pad = tokenizer.convert_tokens_to_ids(pad) if image_processor is not None else None
119
+ self.echo = False # set by load_heads when the quote head was trained on an echoed case (see encode_case)
120
+ # the tokenizer's added tokens (the answer marker, chat roles, <think>, image placeholders) as user text would
121
+ # tokenize as the tokens themselves and break the prompt; defuse() swaps their "<" for a look-alike
122
+ self.specials = sorted((t for t in tokenizer.get_added_vocab() if t.startswith("<")), key=len, reverse=True)
123
+
124
+ def defuse(self, text):
125
+ """User text with any added-token string made ordinary text; same length, so character offsets still hold."""
126
+ if not isinstance(text, str) or "<" not in text:
127
+ return text
128
+ for t in self.specials:
129
+ text = text.replace(t, "‹" + t[1:])
130
+ return text
131
+
132
+ def safe(self, state: str, questions: list[dict]) -> tuple[str, list[dict]]:
133
+ """state and questions with every user-written string defused (images and other values pass through)."""
134
+ d = self.defuse
135
+ qs = [{**q, **{k: d(q[k]) for k in ("text", "subject") if k in q},
136
+ **({"options": [d(o) for o in q["options"]]} if "options" in q else {}),
137
+ **({"criteria": {k: d(v) for k, v in q["criteria"].items()}} if isinstance(q.get("criteria"), dict) else {})}
138
+ for q in questions]
139
+ return d(state), qs
140
+
141
+ def encode(self, state: str, questions: list[dict]) -> tuple[list[int], list[int]]:
142
+ """-> (token ids, the position whose next-token logits answer each question)."""
143
+ state, questions = self.safe(state, questions)
144
+ text = self.tok.apply_chat_template(render(state, questions, codes=self.codes), tokenize=False,
145
+ enable_thinking=False)
146
+ ids = self.tok(text, add_special_tokens=False)["input_ids"]
147
+ at = [i - 1 for i, t in enumerate(ids) if t == self.marker]
148
+ if len(at) != len(questions):
149
+ raise ValueError("one answer marker per question")
150
+ return ids, at
151
+
152
+ def reason_span(self, ids: list[int]) -> tuple[int, int]:
153
+ """The tokens inside the thinking block, (first, end): the trace a reasoning record teaches the model to write."""
154
+ start, end = (self.tok.convert_tokens_to_ids(t) for t in ("<think>", "</think>"))
155
+ return ids.index(start) + 1, ids.index(end)
156
+
157
+ def encode_case(self, state: str, questions: list[dict], images=()) -> tuple[list[int], list[int], dict]:
158
+ """-> (token ids, answer positions, extra); without images the ids are exactly encode()'s. extra["state"] is
159
+ (first state token, each state token's char offsets in the state). With images extra also holds the
160
+ image processor's pixel_values and image_grid_thw, and "images": (first token, rows, cols) of each image's
161
+ merged token grid; each image placeholder is expanded to its token count, as the model's processor does."""
162
+ state, questions = self.safe(state, questions)
163
+ messages = render(state, questions, images, self.codes)
164
+ user = messages[1]["content"]
165
+ pics = [] if isinstance(user, str) else [x["image"] for x in user if x["type"] == "image"]
166
+ text = self.tok.apply_chat_template(messages, tokenize=False, enable_thinking=False)
167
+ enc = self.tok(text, add_special_tokens=False, return_offsets_mapping=True)
168
+ ids, spans = enc["input_ids"], enc["offset_mapping"]
169
+ # an empty state has no text to find (and Gemma's template trims the newlines that would surround it)
170
+ s0 = text.index(state + "\n\nQuestions:\n") if state else 0
171
+ s1 = s0 + len(state)
172
+ toks = [i for i, (a, b) in enumerate(spans) if a < s1 and b > s0]
173
+ offsets = [(max(spans[i][0], s0) - s0, min(spans[i][1], s1) - s0) for i in toks]
174
+ where, extra = list(range(len(ids))), {}
175
+ if pics and self.unified:
176
+ ids, where, extra = self._unified_images(text, ids, pics)
177
+ elif pics:
178
+ if self.image_processor is None:
179
+ raise ValueError("this model has no vision tower")
180
+ ip = self.image_processor
181
+ v = ip(images=[p.convert("RGB") for p in pics], max_pixels=IMAGE_PIXELS, return_tensors="pt")
182
+ grid, m = v["image_grid_thw"], ip.merge_size
183
+ counts, out, where, starts = iter((grid.prod(-1) // m**2).tolist()), [], [], []
184
+ for t in ids:
185
+ where.append(len(out))
186
+ if t == self.image_pad:
187
+ starts.append(len(out))
188
+ out += [t] * next(counts)
189
+ else:
190
+ out.append(t)
191
+ if len(starts) != len(pics):
192
+ raise ValueError("one image placeholder per image")
193
+ ids = out
194
+ extra = {"pixel_values": v["pixel_values"], "image_grid_thw": grid,
195
+ "images": [(s, int(g[1]) // m, int(g[2]) // m) for s, g in zip(starts, grid)]}
196
+ extra["state"] = (where[toks[0]] if toks else 0, offsets)
197
+ at = [i - 1 for i, t in enumerate(ids) if t == self.marker]
198
+ if len(at) != len(questions):
199
+ raise ValueError("one answer marker per question")
200
+ if self.echo and toks and any(q["type"] == "span" for q in questions):
201
+ # Echo: the case again after every answer marker, and quotes are read from this copy. Reading left to
202
+ # right, a case token cannot see what follows it ("Total due" after a line item), but every copied token
203
+ # has seen the whole case and each question. Attention only looks back, so no earlier answer changes.
204
+ sep = self.tok("\n\nThe case again:\n", add_special_tokens=False)["input_ids"]
205
+ copy = self.tok(state, add_special_tokens=False, return_offsets_mapping=True)
206
+ extra["state"] = (len(ids) + len(sep), [tuple(o) for o in copy["offset_mapping"]])
207
+ ids = ids + sep + copy["input_ids"]
208
+ return ids, at, extra
209
+
210
+ def _unified_images(self, text: str, ids: list[int], pics) -> tuple[list[int], list[int], dict]:
211
+ """A unified model's processor expands each image placeholder into <|image>, its image tokens, <image|>; ->
212
+ (expanded ids, each original token's new position, the model's image inputs). The images have no separate
213
+ grid of merged tokens here, so "images" is empty and the point and box heads do not apply."""
214
+ ip = self.image_processor
215
+ v = ip(text=text, images=[p.convert("RGB") for p in pics], return_tensors="pt", add_special_tokens=False)
216
+ new = v["input_ids"][0].tolist()
217
+ run = {self.image_pad, self.tok.convert_tokens_to_ids(ip.boi_token), self.tok.convert_tokens_to_ids(ip.eoi_token)}
218
+ where, j = [], 0
219
+ for t in ids:
220
+ where.append(j)
221
+ if t == self.image_pad:
222
+ while j < len(new) and new[j] in run:
223
+ j += 1
224
+ elif j < len(new) and new[j] == t:
225
+ j += 1
226
+ else:
227
+ raise ValueError("the processor changed a text token")
228
+ if j != len(new):
229
+ raise ValueError("the processor added tokens after the text")
230
+ return new, where, {"pixel_values": v["pixel_values"], "image_position_ids": v["image_position_ids"],
231
+ "mm_token_type_ids": v["mm_token_type_ids"][0], "images": []}
232
+
233
+ def load_model(path: str, device: str, *, dtype=None, vision=None):
234
+ """-> (model, codec). vision=None loads the image-text model when the checkpoint has a vision tower and an image
235
+ processor config beside it, else the text model alone. Qwen's image processor runs on PIL, never torchvision
236
+ (not installed, and the Auto classes demand it), so training and serving resize alike on every machine."""
237
+ from transformers import (
238
+ AutoConfig,
239
+ AutoModelForCausalLM,
240
+ AutoModelForImageTextToText,
241
+ AutoTokenizer,
242
+ Qwen2VLImageProcessorPil,
243
+ )
244
+
245
+ if device == "cuda": # as in amos.train: cuDNN attention fails to load in bf16 with the pip CUDA wheels
246
+ torch.backends.cuda.enable_cudnn_sdp(False)
247
+
248
+ tok = AutoTokenizer.from_pretrained(path)
249
+ config = AutoConfig.from_pretrained(path)
250
+ unified = config.model_type.startswith("gemma4") # images through its own processor (which needs torchvision)
251
+ if vision is None:
252
+ vision = hasattr(config, "vision_config") and (Path(path) / ("processor_config.json" if unified else
253
+ "preprocessor_config.json")).exists()
254
+ processor = None
255
+ if vision:
256
+ from transformers import AutoProcessor
257
+
258
+ processor = AutoProcessor.from_pretrained(path) if unified else Qwen2VLImageProcessorPil.from_pretrained(path)
259
+ codec = Codec(tok, processor, getattr(config, "amos_codes", len(LETTERS)))
260
+ if dtype is None:
261
+ dtype = torch.float32 if getattr(config, "amos_export_dtype", None) == "float32" else torch.bfloat16
262
+ from transformers.models.auto.modeling_auto import MODEL_FOR_CAUSAL_LM_MAPPING_NAMES
263
+
264
+ # some image-text models (Muse Glimmer) have no causal-LM class: load the whole model, its vision tower unused
265
+ causal = config.model_type in MODEL_FOR_CAUSAL_LM_MAPPING_NAMES
266
+ model = (AutoModelForImageTextToText if vision or not causal else AutoModelForCausalLM).from_pretrained(
267
+ path, config=config, dtype=dtype)
268
+ if vision and not unified:
269
+ patchify_as_matmul(model.model.visual.patch_embed)
270
+ if model.get_input_embeddings().weight.shape[0] < len(tok):
271
+ model.resize_token_embeddings(len(tok), mean_resizing=True)
272
+ return model.to(device), codec
273
+
274
+ def patchify_as_matmul(patch_embed):
275
+ """Qwen's patch embedding is a conv3d whose kernel is the whole flattened patch, so it is one matmul with the same
276
+ weights; run it as one. cuDNN's conv3d fails to load on some hosts and PyTorch's own takes ~5 s per photo."""
277
+ proj = patch_embed.proj
278
+ patch_embed.forward = lambda x: torch.nn.functional.linear(x.to(proj.weight.dtype), proj.weight.flatten(1), proj.bias)
279
+
280
+ def noncausal(model) -> bool:
281
+ """Whether the run was trained with bidirectional full attention (train --noncausal), so it must be read that way."""
282
+ return bool(getattr(model.config, "amos_noncausal", False))
283
+
284
+ def bidirectional_mask(lengths: list[int], width: int, device) -> torch.Tensor:
285
+ """[B, 1, L, L], True where the key is one of its row's real tokens: every query sees the whole case, never pads."""
286
+ keys = torch.arange(width, device=device) < torch.tensor(lengths, device=device)[:, None]
287
+ return keys[:, None, None, :].expand(-1, 1, width, -1)
288
+
289
+ PREFILL_CHUNK = 16384
290
+
291
+ def chunked_hidden(decoder, x: torch.Tensor) -> torch.Tensor:
292
+ """Last hidden states [1, L, d] for one causal text row, read PREFILL_CHUNK tokens at a time."""
293
+ from transformers import DynamicCache
294
+
295
+ cache, out = DynamicCache(config=decoder.config), []
296
+ for s in range(0, x.shape[1], PREFILL_CHUNK):
297
+ out.append(decoder(input_ids=x[:, s : s + PREFILL_CHUNK], past_key_values=cache, use_cache=True).last_hidden_state)
298
+ del cache
299
+ return torch.cat(out, 1)
300
+
301
+ def layerwise_hidden(decoder, x: torch.Tensor) -> torch.Tensor:
302
+ """Last hidden states [1, L, d] for one noncausal text row, read a layer at a time.
303
+
304
+ A noncausal run's full-attention layers let every token see the whole case, so it cannot be read in chunks the way
305
+ a causal one can. Instead the whole case goes through each layer before the next, PREFILL_CHUNK tokens at a time:
306
+ a linear-attention layer is a forward recurrence, so its chunks carry its conv and recurrent state; a full-attention
307
+ layer first takes keys and values for every token, then each chunk of queries attends to all of them. The same
308
+ computation as one pass, holding one layer's activations for one chunk instead of every layer's for the whole case."""
309
+ import torch.nn.functional as F
310
+ from transformers import DynamicCache
311
+ from transformers.models.qwen3_5.modeling_qwen3_5 import apply_rotary_pos_emb
312
+
313
+ n, C = x.shape[1], PREFILL_CHUNK
314
+ h = decoder.embed_tokens(x)
315
+ pos = torch.arange(n, device=x.device).view(1, 1, -1).expand(4, 1, -1)
316
+ cos, sin = decoder.rotary_emb(h, pos[1:])
317
+ text_pos, cache = pos[0], DynamicCache(config=decoder.config)
318
+ for i, layer in enumerate(decoder.layers[: decoder.config.num_hidden_layers]):
319
+ if decoder.config.layer_types[i] == "linear_attention":
320
+ for s in range(0, n, C): # each chunk is overwritten after it is read; later chunks are still this layer's input
321
+ h[:, s : s + C] = layer(h[:, s : s + C], position_embeddings=(cos[:, s : s + C], sin[:, s : s + C]),
322
+ attention_mask=None, position_ids=text_pos[:, s : s + C], past_key_values=cache)
323
+ continue
324
+ at = layer.self_attn
325
+ keys, values = [], []
326
+ for s in range(0, n, C):
327
+ hn = layer.input_layernorm(h[:, s : s + C])
328
+ shape = (*hn.shape[:-1], -1, at.head_dim)
329
+ k = at.k_norm(at.k_proj(hn).view(shape)).transpose(1, 2)
330
+ _, k = apply_rotary_pos_emb(k, k, cos[:, s : s + C], sin[:, s : s + C])
331
+ keys.append(k)
332
+ values.append(at.v_proj(hn).view(shape).transpose(1, 2))
333
+ keys, values = torch.cat(keys, 2), torch.cat(values, 2)
334
+ for s in range(0, n, C):
335
+ res = h[:, s : s + C]
336
+ hn = layer.input_layernorm(res)
337
+ shape = hn.shape[:-1]
338
+ q, gate = torch.chunk(at.q_proj(hn).view(*shape, -1, at.head_dim * 2), 2, dim=-1)
339
+ q = at.q_norm(q).transpose(1, 2)
340
+ q, _ = apply_rotary_pos_emb(q, q, cos[:, s : s + C], sin[:, s : s + C])
341
+ o = F.scaled_dot_product_attention(q, keys, values, scale=at.scaling, enable_gqa=True)
342
+ o = at.o_proj(o.transpose(1, 2).reshape(*shape, -1) * torch.sigmoid(gate.reshape(*shape, -1)))
343
+ mid = res + o
344
+ h[:, s : s + C] = mid + layer.mlp(layer.post_attention_layernorm(mid))
345
+ del keys, values
346
+ del cache
347
+ for s in range(0, n, C):
348
+ h[:, s : s + C] = decoder.norm(h[:, s : s + C])
349
+ return h
350
+
351
+ def hidden(model, codec, batch: list[tuple]) -> torch.Tensor:
352
+ """Last hidden states [B, L, d] for a batch of (ids, answer positions, questions[, extra]).
353
+
354
+ Sequences are right-padded without a mask: the model is causal (its linear-attention layers are recurrences that
355
+ only run forward), so padding after a sequence cannot change anything read from inside it. A noncausal run's
356
+ full-attention layers see the whole sequence, so they get a mask hiding the pads; the recurrences stay causal.
357
+ Entries with images go through the image-text model, which fills image tokens from the vision tower and gives
358
+ them 2-D positions."""
359
+ width = max(len(e[0]) for e in batch)
360
+ x = torch.full((len(batch), width), codec.pad, dtype=torch.long, device=model.device)
361
+ for b, e in enumerate(batch):
362
+ x[b, : len(e[0])] = torch.tensor(e[0], device=model.device)
363
+ vis = [e[3] for e in batch if len(e) > 3 and "pixel_values" in e[3]]
364
+ kw = {} if not noncausal(model) else {"attention_mask": {
365
+ "full_attention": bidirectional_mask([len(e[0]) for e in batch], width, x.device), "linear_attention": None}}
366
+ if not vis and len(batch) == 1 and width > PREFILL_CHUNK:
367
+ return layerwise_hidden(model.get_decoder(), x) if kw else chunked_hidden(model.get_decoder(), x)
368
+ if not vis:
369
+ return model.get_decoder()(input_ids=x, **kw).last_hidden_state
370
+ if codec.unified and kw:
371
+ raise ValueError("noncausal attention is built for Qwen3.5's layer types, not Gemma 4's")
372
+ if codec.unified: # Gemma 4: image patches go through the same layers, placed by their token types and positions
373
+ types = torch.zeros_like(x)
374
+ for b, e in enumerate(batch):
375
+ if len(e) > 3 and "mm_token_type_ids" in e[3]:
376
+ types[b, : len(e[3]["mm_token_type_ids"])] = e[3]["mm_token_type_ids"].to(x.device)
377
+ return model.model(input_ids=x, mm_token_type_ids=types,
378
+ pixel_values=torch.cat([v["pixel_values"] for v in vis]).to(model.device, model.dtype),
379
+ image_position_ids=torch.cat([v["image_position_ids"] for v in vis]).to(model.device)
380
+ ).last_hidden_state
381
+ grid, types = torch.cat([v["image_grid_thw"] for v in vis]).to(model.device), (x == codec.image_pad).int()
382
+ if kw: # its rope index would read a dict mask as per-row padding, so the positions come unmasked, as causal
383
+ kw["position_ids"] = model.model.get_rope_index(x, types, image_grid_thw=grid)[0]
384
+ return model.model(input_ids=x, pixel_values=torch.cat([v["pixel_values"] for v in vis]).to(model.device),
385
+ image_grid_thw=grid, mm_token_type_ids=types, **kw).last_hidden_state
386
+
387
+ def finish_logits(model, logits: torch.Tensor) -> torch.Tensor:
388
+ """The model's own last step from raw output-layer logits, as its forward would apply it: Granite divides by
389
+ logits_scaling, Gemma caps with final_logit_softcapping. Qwen has neither, so its logits pass unchanged."""
390
+ cfg = getattr(model.config, "text_config", None) or model.config
391
+ if scaling := getattr(cfg, "logits_scaling", None):
392
+ logits = logits / scaling
393
+ if cap := getattr(cfg, "final_logit_softcapping", None):
394
+ logits = cap * torch.tanh(logits / cap)
395
+ return logits
396
+
397
+ def distributions(model, codec, batch: list[tuple], h: torch.Tensor | None = None) -> list[torch.Tensor]:
398
+ """Log-probabilities over the option letters of each yes/no, choice and score question in a batch of (ids, answer
399
+ positions, questions[, extra]), in order. Only the 52 letter logits are computed, from the tied output
400
+ embeddings."""
401
+ h = hidden(model, codec, batch) if h is None else h
402
+ picks = [(b, p, q) for b, e in enumerate(batch) for p, q in zip(e[1], e[2]) if q["type"] in KINDS]
403
+ if not picks:
404
+ return []
405
+ rows = torch.tensor([b for b, _, _ in picks], device=h.device)
406
+ cols = torch.tensor([p for _, p, _ in picks], device=h.device)
407
+ logits = finish_logits(model, h[rows, cols].float() @ model.get_output_embeddings().weight[codec.letters].float().T)
408
+ return [logits[k, : len(options(q))].log_softmax(-1) for k, (_, _, q) in enumerate(picks)]
409
+
410
+ class BoxHead(torch.nn.Module):
411
+ """A point's box, predicted rather than grown from cells: reads the question's vector, the image features pooled
412
+ by the point head's own cell distribution, and that distribution's centre and spread, and moves the centre and
413
+ sets the size. Cells alone draw a box around only the few cells the point head is sure of (a car's tail light
414
+ rather than the car); this head is trained on overlap with the whole object."""
415
+
416
+ def __init__(self, d: int, width: int = 512):
417
+ super().__init__()
418
+ self.net = torch.nn.Sequential(torch.nn.Linear(2 * d + 4, width), torch.nn.GELU(), torch.nn.Linear(width, 4))
419
+ torch.nn.init.zeros_(self.net[-1].weight) # starts as a half-image box on the cells' centre
420
+ torch.nn.init.zeros_(self.net[-1].bias)
421
+
422
+ def forward(self, hq: torch.Tensor, cells: torch.Tensor, logits: torch.Tensor, rows: int, cols: int) -> torch.Tensor:
423
+ p = logits.detach().float().softmax(-1) # the point head stays as it is; only the box learns
424
+ ys, xs = torch.meshgrid((torch.arange(rows, device=p.device) + 0.5) / rows,
425
+ (torch.arange(cols, device=p.device) + 0.5) / cols, indexing="ij")
426
+ xs, ys = xs.flatten(), ys.flatten()
427
+ mx, my = (p * xs).sum(), (p * ys).sum()
428
+ sx, sy = (p * (xs - mx) ** 2).sum().sqrt(), (p * (ys - my) ** 2).sum().sqrt()
429
+ pooled = (p[:, None] * cells.float()).sum(0)
430
+ o = self.net(torch.cat([hq.float(), pooled, torch.stack([mx, my, sx, sy])]).to(self.net[0].weight.dtype)).float()
431
+ cx, cy = (mx + 0.5 * torch.tanh(o[0])).clamp(0, 1), (my + 0.5 * torch.tanh(o[1])).clamp(0, 1)
432
+ w, hgt = torch.sigmoid(o[2]), torch.sigmoid(o[3])
433
+ return torch.stack([cx - w / 2, cy - hgt / 2, cx + w / 2, cy + hgt / 2]).clamp(0, 1)
434
+
435
+ class Heads(torch.nn.Module):
436
+ """Quote and point heads, as amos.model has them: a question's answer-position vector scored against the state's
437
+ tokens (start and end of a quote) or against the first state image's tokens (one cell each); `keep` is the
438
+ region readout's share (decode_head). With a BoxHead, a region's logits carry four more values: its box."""
439
+
440
+ def __init__(self, d: int, kinds=HEADED, keep: float = REGION_KEEP, box: bool = False):
441
+ super().__init__()
442
+ self.kinds, self.keep = list(kinds), keep
443
+ self.span = (SPAN_CAP, SPAN_LENGTH_PENALTY) # the quote decoder's cap and penalty; serving sets the run's own
444
+ self.span_q, self.span_k = torch.nn.Linear(d, 2 * d), torch.nn.Linear(d, 2 * d)
445
+ self.region_q, self.region_k = torch.nn.Linear(d, d), torch.nn.Linear(d, d)
446
+ for m in (self.span_q, self.span_k, self.region_q, self.region_k):
447
+ torch.nn.init.normal_(m.weight, std=0.02)
448
+ torch.nn.init.zeros_(m.bias)
449
+ self.box = BoxHead(d) if box else None
450
+
451
+ def forward(self, h: torch.Tensor, batch: list[tuple]) -> list[torch.Tensor]:
452
+ """Logits of each span ([2, state tokens]) and region ([cells], then the 4 box edges when there is a box head)
453
+ question in the batch, in order."""
454
+ h, d, out = h.to(self.span_q.weight.dtype), h.shape[-1], []
455
+ for b, e in enumerate(batch):
456
+ for p, q in zip(e[1], e[2]):
457
+ if q["type"] == "span":
458
+ first, offsets = e[3]["state"]
459
+ k = self.span_k(h[b, first : first + len(offsets)]).view(-1, 2, d)
460
+ out.append(torch.einsum("cd,lcd->cl", self.span_q(h[b, p]).view(2, d), k) / math.sqrt(d))
461
+ elif q["type"] == "region":
462
+ if not e[3].get("images"):
463
+ raise ValueError("a region question needs a state image")
464
+ start, rows, cols = e[3]["images"][0]
465
+ cells = h[b, start : start + rows * cols]
466
+ x = self.region_k(cells) @ self.region_q(h[b, p]) / math.sqrt(d)
467
+ if self.box is not None:
468
+ x = torch.cat([x, self.box(h[b, p], cells, x, rows, cols).to(x.dtype)])
469
+ out.append(x)
470
+ return out
471
+
472
+ def decode_head(q: dict, x: torch.Tensor, extra: dict, state: str, keep: float = REGION_KEEP,
473
+ span: tuple[int, float] = (SPAN_CAP, SPAN_LENGTH_PENALTY)) -> dict:
474
+ """A span or region result in amos.model.decode's format; a quote is always cut from the state, at most span[0]
475
+ tokens long, each token past the start scaled by exp(-span[1]). A point is the most likely cell and the box of the
476
+ cells around it holding `keep` of its probability, with the box's mass."""
477
+ from types import SimpleNamespace
478
+
479
+ from .model import decode_span
480
+ from .vision import peak_box
481
+
482
+ x = x.float().cpu()
483
+ if q["type"] == "span":
484
+ x = torch.cat([x.new_full((2, 1), -torch.inf), x], 1) # decode_span skips a leading [CLS] slot
485
+ a, z, _, p = decode_span(x[0], x[1], SimpleNamespace(offsets=extra["state"][1], state=state), span[1], span[0])
486
+ # Qwen fuses a JSON string's closing quote with the last word ('detail."', 'approves.",'), so a quote cut at
487
+ # token edges can end (or start) on an unmatched quote mark; drop it and what follows it
488
+ while state[a:z].count('"') % 2:
489
+ if state[z - 1] in '",}]':
490
+ z -= 1
491
+ elif state[a] in '"{[':
492
+ a += 1
493
+ else:
494
+ break
495
+ t = state[a:z]
496
+ a, z = a + len(t) - len(t.lstrip()), z - (len(t) - len(t.rstrip()))
497
+ return {"type": "span", "start": a, "end": max(a, z), "text": state[a : max(a, z)], "p": p}
498
+ _, rows, cols = extra["images"][0]
499
+ cells = x[: rows * cols].softmax(-1)
500
+ if len(x) > rows * cols: # a box head's box; its mass is the cells it covers
501
+ box = [round(v, 4) for v in x[rows * cols :].tolist()]
502
+ r0, r1 = int(box[1] * rows), max(int(box[1] * rows) + 1, math.ceil(box[3] * rows))
503
+ c0, c1 = int(box[0] * cols), max(int(box[0] * cols) + 1, math.ceil(box[2] * cols))
504
+ return {"type": "region", "cell": int(cells.argmax()), "box": box,
505
+ "p": float(cells.view(rows, cols)[r0:r1, c0:c1].sum())}
506
+ box, p = peak_box(cells, rows, cols, keep)
507
+ return {"type": "region", "cell": int(cells.argmax()), "box": box, "p": p}
508
+
509
+ def load_heads(path, model, codec: Codec | None = None) -> Heads | None:
510
+ """The trained heads saved beside a checkpoint, or None. A quote head trained on echoed cases switches the codec's
511
+ echo on, since it reads nothing else."""
512
+ f = Path(path) / HEADS
513
+ if not f.exists():
514
+ return None
515
+ saved = torch.load(f, map_location="cpu", weights_only=True)
516
+ heads = Heads(model.get_input_embeddings().weight.shape[1], saved["kinds"], saved.get("keep", REGION_KEEP),
517
+ box=any(k.startswith("box.") for k in saved["state"]))
518
+ heads.load_state_dict(saved["state"])
519
+ if codec is not None:
520
+ codec.echo = bool(saved.get("echo", False))
521
+ return heads.to(model.device).eval()
522
+
523
+ def tempered(lp: torch.Tensor, t: float | None = None) -> list[float]:
524
+ """Probabilities from log-probabilities, sharpened (t < 1) or softened (t > 1) by a fitted temperature."""
525
+ return (lp / t).softmax(-1).tolist() if t else lp.exp().tolist()
526
+
527
+ def rotate(q: dict, r: int) -> dict:
528
+ """A choice question with its options rotated r places; anything else as it is (a scale's order is its meaning)."""
529
+ if q["type"] != "choice" or not r:
530
+ return q
531
+ s = r % len(q["options"])
532
+ return {**q, "options": q["options"][s:] + q["options"][:s]}
533
+
534
+ def read(model, codec, state: str, questions: list[dict], images=(), max_len: int | None = None,
535
+ temperature: dict | None = None):
536
+ """-> (option distributions for letter-answered questions, input tokens), one forward pass."""
537
+ ids, at, extra = codec.encode_case(state, questions, images)
538
+ if max_len and len(ids) > max_len:
539
+ raise ValueError(f"maximum context length: request has {len(ids)} tokens, limit {max_len}")
540
+ entry = [(ids, at, questions, extra)]
541
+ t = temperature or {}
542
+ picked = [q for q in questions if q["type"] in KINDS]
543
+ return [tempered(lp, t.get(q["type"])) for lp, q in
544
+ zip(distributions(model, codec, entry, hidden(model, codec, entry)), picked)], len(ids)
545
+
546
+ def wide_choice(model, codec, state: str, q: dict, images=(), max_len: int | None = None,
547
+ temperature: dict | None = None):
548
+ """A choice over more options than there are letters, in two passes -> (distribution, most input tokens).
549
+
550
+ First the options are split into chunks, each asked as its own question; then the leading options of every chunk
551
+ are asked again together. Finalists share their first-pass mass (each chunk weighted equally) in proportion to the
552
+ second pass; every other option keeps its first-pass share, so the distribution sums to one and its top answer is
553
+ the second pass's."""
554
+ n, per = len(q["options"]), len(codec.codes)
555
+ size = -(-n // -(-n // per)) # balanced chunks of at most `per`
556
+ chunks = [list(range(s, min(s + size, n))) for s in range(0, n, size)]
557
+ first, t1 = read(model, codec, state, [{**q, "options": [q["options"][i] for i in c]} for c in chunks], images, max_len,
558
+ temperature)
559
+ prior = [0.0] * n
560
+ for c, p in zip(chunks, first):
561
+ for i, x in zip(c, p):
562
+ prior[i] = x / len(chunks)
563
+ keep = per // len(chunks)
564
+ finalists = sorted(i for c, p in zip(chunks, first) for i, _ in sorted(zip(c, p), key=lambda t: -t[1])[:keep])
565
+ (second,), t2 = read(model, codec, state, [{**q, "options": [q["options"][i] for i in finalists]}], images, max_len,
566
+ temperature)
567
+ mass, out = sum(prior[i] for i in finalists), prior[:]
568
+ for i, x in zip(finalists, second):
569
+ out[i] = mass * x
570
+ return out, max(t1, t2)
571
+
572
+ @torch.no_grad()
573
+ def sureness(r: dict) -> float:
574
+ """How sure a yes/no, choice or score result is: the probability of its answer."""
575
+ return max(r["p_yes"], 1 - r["p_yes"]) if r["type"] == "noul" else max(r["probs"])
576
+
577
+ @torch.no_grad()
578
+ def think_many(model, codec, state: str, qs: list[dict], max_tokens: int, temperature: dict | None = None,
579
+ generate=None):
580
+ """think for several questions about one case at once: their thoughts are generated as one left-padded batch, so a
581
+ case with eight unsure questions costs about one generation instead of eight. generate(prefixes, max_tokens) ->
582
+ [(text, tokens)] writes the thoughts elsewhere instead (a vLLM server: serve's AMOS_THINK_URL); the letters are
583
+ still read here, so the answers are the same either way."""
584
+ if noncausal(model):
585
+ raise ValueError("a noncausal run cannot think: generation reads a causal model's cache")
586
+ state, qs = codec.safe(state, qs)
587
+ prefixes = [codec.tok.apply_chat_template(render(state, [q], codes=codec.codes)[:2], tokenize=False,
588
+ add_generation_prompt=True, enable_thinking=True) for q in qs]
589
+ if generate is not None:
590
+ thoughts = generate(prefixes, max_tokens)
591
+ else:
592
+ ids = [codec.tok(p, add_special_tokens=False)["input_ids"] for p in prefixes]
593
+ width, pad = max(map(len, ids)), codec.tok.pad_token_id
594
+ batch = torch.tensor([[pad] * (width - len(x)) + x for x in ids], device=model.device)
595
+ mask = torch.tensor([[0] * (width - len(x)) + [1] * len(x) for x in ids], device=model.device)
596
+ out = model.generate(batch, attention_mask=mask, max_new_tokens=max_tokens, do_sample=False, use_cache=True,
597
+ pad_token_id=pad)
598
+ thoughts = [(codec.tok.decode(row, skip_special_tokens=False).replace(codec.tok.pad_token or "\0", ""),
599
+ int((row != pad).sum())) for row in out[:, width:]]
600
+ results = []
601
+ for prefix, q, (text, n) in zip(prefixes, qs, thoughts):
602
+ reasoning = text.split("</think>")[0].replace("<|im_end|>", "").strip()
603
+ full = codec.tok(prefix + reasoning + "\n</think>\n\n" + f"q1: {ANSWER}", add_special_tokens=False)["input_ids"]
604
+ at = [i - 1 for i, t in enumerate(full) if t == codec.marker]
605
+ lp = distributions(model, codec, [(full, at, [q])])[0]
606
+ results.append((tempered(lp, (temperature or {}).get(q["type"])), n))
607
+ return results
608
+
609
+ def results(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None,
610
+ max_len: int | None = None, rotations: int = 1, temperature: dict | None = None,
611
+ think_below: float | None = None, think_tokens: int = 512, generate=None) -> tuple[list[dict], int]:
612
+ """One typed result per question in amos.model.decode's format (noul p_yes; choice/score probs, choice, expected;
613
+ multi independent probs; span start/end/text/p; region cell/box/p), and the number of input tokens. A choice over
614
+ more options than there are letters is answered on its own (wide_choice). rotations and temperature: see
615
+ one_pass. think_below: a yes/no, choice or score answer less sure than this is asked again after the model
616
+ thinks (think), as GLiDE spends compute only where a decision is uncertain; the result then carries "thought"."""
617
+ if think_below and noncausal(model): # on every request, not only the unsure ones
618
+ raise ValueError("a noncausal run cannot think: generation reads a causal model's cache")
619
+ out, tokens = _results(model, codec, state, questions, images, heads, max_len, rotations, temperature)
620
+ if think_below and not images:
621
+ unsure = [k for k, (q, r) in enumerate(zip(questions, out))
622
+ if q["type"] in KINDS and len(options(q)) <= len(codec.codes) and sureness(r) < think_below]
623
+ for k, (p, n) in zip(unsure, think_many(model, codec, state, [questions[k] for k in unsure], think_tokens,
624
+ temperature, generate) if unsure else []):
625
+ q = questions[k]
626
+ if q["type"] == "noul":
627
+ out[k] = {"type": "noul", "p_yes": p[0], "thought": n}
628
+ else:
629
+ out[k] = {"type": q["type"], "probs": p, "choice": max(range(len(p)), key=p.__getitem__), "thought": n}
630
+ if q["type"] == "score":
631
+ out[k]["expected"] = sum(i * x for i, x in enumerate(p))
632
+ return out, tokens
633
+
634
+ def _results(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None,
635
+ max_len: int | None = None, rotations: int = 1, temperature: dict | None = None) -> tuple[list[dict], int]:
636
+ wide = {k for k, q in enumerate(questions) if q["type"] == "choice" and len(q["options"]) > len(codec.codes)}
637
+ out, tokens = [None] * len(questions), 0
638
+ for k in sorted(wide):
639
+ p, n = wide_choice(model, codec, state, questions[k], images, max_len, temperature)
640
+ out[k], tokens = {"type": "choice", "probs": p, "choice": max(range(len(p)), key=p.__getitem__)}, max(tokens, n)
641
+ rest = [k for k in range(len(questions)) if k not in wide]
642
+ if rest:
643
+ sub, n = one_pass(model, codec, state, [questions[k] for k in rest], images, heads, max_len, rotations,
644
+ temperature)
645
+ for k, r in zip(rest, sub):
646
+ out[k] = r
647
+ tokens = max(tokens, n)
648
+ return out, tokens
649
+
650
+ ROTATION_BATCH_TOKENS = 65536
651
+
652
+ def one_pass(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None,
653
+ max_len: int | None = None, rotations: int = 1, temperature: dict | None = None) -> tuple[list[dict], int]:
654
+ """results() for questions that fit the letters, in one forward pass.
655
+
656
+ rotations > 1 (and a choice question present): the case is also read with every choice question's options rotated,
657
+ each rotation one more row of the same batched forward, and choice distributions are averaged in the original order.
658
+ temperature: {question type: t} fitted by `decoder calibrate` (pick-any's per-option yes/no uses noul's)."""
659
+ flat, owner = expand(questions)
660
+ rotations = rotations if any(q["type"] == "choice" for q in flat) else 1
661
+ entries = []
662
+ for r in range(rotations):
663
+ qs = [rotate(q, r) for q in flat]
664
+ ids, at, extra = codec.encode_case(state, qs, images)
665
+ if max_len and len(ids) > max_len:
666
+ raise ValueError(f"maximum context length: request has {len(ids)} tokens, limit {max_len}")
667
+ entries.append((ids, at, qs, extra))
668
+ ids, extra = entries[0][0], entries[0][3]
669
+ if heads is None and any(q["type"] in HEADED for q in flat):
670
+ raise ValueError("this model has no span or region head")
671
+ if sum(len(e[0]) for e in entries) <= ROTATION_BATCH_TOKENS:
672
+ h = hidden(model, codec, entries)
673
+ lps = distributions(model, codec, entries, h)
674
+ else: # a long case: one rotation at a time, so memory stays that of a single row
675
+ h, lps = None, []
676
+ for e in entries:
677
+ he = hidden(model, codec, [e])
678
+ lps += distributions(model, codec, [e], he)
679
+ h = he if h is None else h
680
+ head = iter(heads(h[:1], entries[:1]) if heads is not None else [])
681
+ picked = [j for j, q in enumerate(flat) if q["type"] in KINDS]
682
+ temps = temperature or {}
683
+ mean = {}
684
+ for r in range(rotations):
685
+ for m, j in enumerate(picked):
686
+ p, n = tempered(lps[r * len(picked) + m], temps.get(flat[j]["type"])), len(options(flat[j]))
687
+ s = r % n if flat[j]["type"] == "choice" else 0
688
+ acc = mean.setdefault(j, [0.0] * n)
689
+ for i in range(n): # rotated position (i - s) mod n holds original option i
690
+ acc[i] += p[(i - s) % n] / rotations
691
+ lps = iter(mean[j] for j in picked)
692
+ out = [None] * len(questions)
693
+ for q, k in zip(flat, owner):
694
+ t = questions[k]["type"]
695
+ if q["type"] in HEADED:
696
+ out[k] = decode_head(q, next(head), extra, state, heads.keep, heads.span)
697
+ continue
698
+ p = next(lps)
699
+ if t == "multi":
700
+ out[k] = out[k] or {"type": t, "probs": []}
701
+ out[k]["probs"].append(p[0])
702
+ elif t == "noul":
703
+ out[k] = {"type": t, "p_yes": p[0]}
704
+ else:
705
+ out[k] = {"type": t, "probs": p, "choice": max(range(len(p)), key=p.__getitem__)}
706
+ if t == "score":
707
+ out[k]["expected"] = sum(i * x for i, x in enumerate(p))
708
+ return out, len(ids)
709
+
710
+ CALIBRATION = "calibration.json"
711
+
712
+ def calibration(run) -> dict | None:
713
+ """The per-type temperatures `decoder calibrate` fitted for a run, or None."""
714
+ f = Path(run) / CALIBRATION
715
+ return json.loads(f.read_text()).get("temperature") if f.exists() else None
716
+
717
+ def span_decoding(run) -> tuple[int, float]:
718
+ """The quote decoder's (cap, penalty) fitted for a run on held-out quote records, or the defaults."""
719
+ f = Path(run) / CALIBRATION
720
+ s = (json.loads(f.read_text()).get("span") or {}) if f.exists() else {}
721
+ return int(s.get("cap", SPAN_CAP)), float(s.get("penalty", SPAN_LENGTH_PENALTY))
722
+
723
+ def multi_cut(run) -> float | None:
724
+ """The pick-any cut fitted on our own held-out pick-any questions (an option is picked at p >= cut), or None."""
725
+ f = Path(run) / CALIBRATION
726
+ return json.loads(f.read_text()).get("multi_cut") if f.exists() else None
727
+
serve/amos/encoder.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ModernBERT-architecture encoder (Ettin weights), written from scratch.
2
+
3
+ Attention is controlled by per-token vectors instead of a precomputed mask:
4
+ position_ids: RoPE positions (branches restart after the state).
5
+ segment_ids: 0 = state, k > 0 = question branch k, -1 = padding.
6
+ option_ids: 0 = not an option, m > 0 = option m of its question.
7
+ A token attends to state tokens, its question's own tokens, and (for option tokens)
8
+ its own option. Local layers additionally restrict attention to
9
+ |pos_i - pos_j| <= window // 2, measured in position ids (not sequence index) so
10
+ packed and separate requests match.
11
+ Comparison mode lets each question and its options attend to the whole question branch.
12
+ """
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+
17
+ import torch
18
+ import torch.nn.functional as F
19
+ from huggingface_hub import hf_hub_download
20
+ from torch import nn
21
+
22
+
23
+ @dataclass
24
+ class Config:
25
+ vocab_size: int
26
+ hidden_size: int
27
+ intermediate_size: int
28
+ num_hidden_layers: int
29
+ num_attention_heads: int
30
+ global_attn_every_n_layers: int = 3
31
+ local_attention: int = 128
32
+ global_rope_theta: float = 160000.0
33
+ local_rope_theta: float = 10000.0
34
+ norm_eps: float = 1e-5
35
+ pad_token_id: int = 50283
36
+ cls_token_id: int = 50281
37
+ sep_token_id: int = 50282
38
+ model_type: str = "modernbert"
39
+ compare_options: bool = False
40
+ date_facts: bool = False # an input-format setting: facts.date_facts runs on every state (model.pack_request)
41
+
42
+ @classmethod
43
+ def from_hf(cls, d: dict) -> "Config":
44
+ """Reads a Hugging Face config or a run's saved config, ignoring fields this class does not define."""
45
+ return cls(**{k: d[k] for k in cls.__dataclass_fields__ if k in d})
46
+
47
+
48
+ def rope(x: torch.Tensor, pos: torch.Tensor, theta: float) -> torch.Tensor:
49
+ """x: [B, H, L, D], pos: [B, L]. Half-split rotation (rotate_half convention)."""
50
+ d = x.shape[-1]
51
+ inv_freq = 1.0 / (theta ** (torch.arange(0, d, 2, device=x.device, dtype=torch.float32) / d))
52
+ freqs = pos[:, None, :, None].float() * inv_freq # [B, 1, L, D/2]
53
+ cos, sin = freqs.cos().to(x.dtype), freqs.sin().to(x.dtype)
54
+ x1, x2 = x[..., : d // 2], x[..., d // 2 :]
55
+ return torch.cat((x1 * cos - x2 * sin, x2 * cos + x1 * sin), dim=-1)
56
+
57
+
58
+ class Layer(nn.Module):
59
+ def __init__(self, c: Config, idx: int):
60
+ super().__init__()
61
+ self.is_global = idx % c.global_attn_every_n_layers == 0
62
+ self.theta = c.global_rope_theta if self.is_global else c.local_rope_theta
63
+ self.heads = c.num_attention_heads
64
+ self.attn_norm = nn.LayerNorm(c.hidden_size, eps=c.norm_eps, bias=False) if idx else nn.Identity()
65
+ self.Wqkv = nn.Linear(c.hidden_size, 3 * c.hidden_size, bias=False)
66
+ self.Wo = nn.Linear(c.hidden_size, c.hidden_size, bias=False)
67
+ self.mlp_norm = nn.LayerNorm(c.hidden_size, eps=c.norm_eps, bias=False)
68
+ self.Wi = nn.Linear(c.hidden_size, 2 * c.intermediate_size, bias=False)
69
+ self.Wo_mlp = nn.Linear(c.intermediate_size, c.hidden_size, bias=False)
70
+
71
+ def forward(self, h, pos, global_mask, local_mask, prefix=None):
72
+ """prefix: cached (k, v) of earlier tokens, prepended to this call's keys and values."""
73
+ B, L, _ = h.shape
74
+ q, k, v = self.Wqkv(self.attn_norm(h)).view(B, L, 3, self.heads, -1).permute(2, 0, 3, 1, 4)
75
+ q, k = rope(q, pos, self.theta), rope(k, pos, self.theta)
76
+ kv = (k, v)
77
+ if prefix is not None:
78
+ k, v = torch.cat([prefix[0], k], 2), torch.cat([prefix[1], v], 2)
79
+ mask = global_mask if self.is_global else local_mask
80
+ a = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
81
+ h = h + self.Wo(a.transpose(1, 2).reshape(B, L, -1))
82
+ x, gate = self.Wi(self.mlp_norm(h)).chunk(2, dim=-1)
83
+ return h + self.Wo_mlp(F.gelu(x) * gate), kv
84
+
85
+
86
+ class Encoder(nn.Module):
87
+ def __init__(self, c: Config):
88
+ super().__init__()
89
+ self.config = c
90
+ self.tok_embeddings = nn.Embedding(c.vocab_size, c.hidden_size)
91
+ self.emb_norm = nn.LayerNorm(c.hidden_size, eps=c.norm_eps, bias=False)
92
+ self.layers = nn.ModuleList(Layer(c, i) for i in range(c.num_hidden_layers))
93
+ self.final_norm = nn.LayerNorm(c.hidden_size, eps=c.norm_eps, bias=False)
94
+
95
+ @staticmethod
96
+ def masks(pos, seg, opt, window, pos_k=None, seg_k=None, opt_k=None, *, compare_options=False):
97
+ """Boolean [B, 1, Lq, Lk] masks, True = may attend. Global and local variants.
98
+
99
+ Keys default to the queries; pass *_k to attend over a longer key sequence (cached state + queries).
100
+ The masks are dense, so their memory grows with Lq * Lk.
101
+ """
102
+ pos_k, seg_k, opt_k = (x if x is not None else y for x, y in ((pos_k, pos), (seg_k, seg), (opt_k, opt)))
103
+ si, sj, oi, oj = seg[:, :, None], seg_k[:, None, :], opt[:, :, None], opt_k[:, None, :]
104
+ within = (sj == si) & ((si > 0) if compare_options else ((oj == 0) | (oj == oi)))
105
+ allowed = (sj != -1) & ((sj == 0) | within)
106
+ local = allowed & ((pos[:, :, None] - pos_k[:, None, :]).abs() <= window // 2)
107
+ return allowed[:, None], local[:, None]
108
+
109
+ def forward(self, input_ids, position_ids=None, segment_ids=None, option_ids=None, inputs_embeds=None):
110
+ B, L = input_ids.shape
111
+ if position_ids is None:
112
+ position_ids = torch.arange(L, device=input_ids.device).expand(B, L)
113
+ if segment_ids is None:
114
+ segment_ids = torch.zeros_like(input_ids)
115
+ if option_ids is None:
116
+ option_ids = torch.zeros_like(input_ids)
117
+ emb = self.tok_embeddings(input_ids) if inputs_embeds is None else inputs_embeds
118
+ return self.run(emb, position_ids, segment_ids, option_ids)[0]
119
+
120
+ def run(self, emb, pos, seg, opt, cache=None):
121
+ """Encode embeddings, optionally on top of a cached prefix. Returns (hidden, cache for these tokens).
122
+
123
+ A cache holds each layer's rotated keys and values plus the pos/seg/opt ids of its tokens. The state
124
+ never attends to questions, so a state encoded once can serve any number of later questions.
125
+ """
126
+ if cache is None:
127
+ g, l = self.masks(pos, seg, opt, self.config.local_attention, compare_options=self.config.compare_options)
128
+ else:
129
+ g, l = self.masks(pos, seg, opt, self.config.local_attention,
130
+ torch.cat([cache["pos"], pos], 1), torch.cat([cache["seg"], seg], 1), torch.cat([cache["opt"], opt], 1),
131
+ compare_options=self.config.compare_options)
132
+ h, kvs = self.emb_norm(emb), []
133
+ for i, layer in enumerate(self.layers):
134
+ h, kv = layer(h, pos, g, l, None if cache is None else cache["kv"][i])
135
+ kvs.append(kv)
136
+ return self.final_norm(h), {"kv": kvs, "pos": pos, "seg": seg, "opt": opt}
137
+
138
+ @classmethod
139
+ def from_pretrained(cls, repo: str) -> "Encoder":
140
+ with open(hf_hub_download(repo, "config.json")) as f:
141
+ model = cls(Config.from_hf(json.load(f)))
142
+ sd = torch.load(hf_hub_download(repo, "pytorch_model.bin"), map_location="cpu", weights_only=True)
143
+ rename = {
144
+ "model.embeddings.tok_embeddings.": "tok_embeddings.",
145
+ "model.embeddings.norm.": "emb_norm.",
146
+ "model.final_norm.": "final_norm.",
147
+ ".attn.Wqkv.": ".Wqkv.",
148
+ ".attn.Wo.": ".Wo.",
149
+ ".mlp.Wi.": ".Wi.",
150
+ ".mlp.Wo.": ".Wo_mlp.",
151
+ "model.layers.": "layers.",
152
+ }
153
+ out = {}
154
+ for k, v in sd.items():
155
+ if not k.startswith("model."): # MLM head, unused
156
+ continue
157
+ for a, b in rename.items():
158
+ k = k.replace(a, b)
159
+ out[k] = v
160
+ model.load_state_dict(out, strict=True)
161
+ return model.eval()
serve/amos/facts.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Computed facts appended to a state, so the model reads date gaps instead of doing calendar arithmetic.
2
+
3
+ The same function runs in training (train.py) and at inference (model.pack_request), switched by the run's
4
+ encoder config (date_facts), so a model always sees the input format it was trained on.
5
+ """
6
+
7
+ import re
8
+ from datetime import date
9
+ from itertools import combinations
10
+
11
+ # An ISO date, optionally the value of a JSON key and optionally followed by a time ("2026-03-18T09:00:00Z").
12
+ DATE = re.compile(r'(?:"([^"\n]{1,60})":\s*")?(\d{4})-(\d{2})-(\d{2})(?=[T "\s,.;)\]}]|$)')
13
+ MAX_DATES = 6 # 15 pairs at most, so long records gain a few lines, not a page
14
+
15
+
16
+ def date_facts(state: str) -> str:
17
+ """The state plus one line per pair of distinct dates it mentions, or the state unchanged if it has fewer than 2."""
18
+ seen = {}
19
+ for m in DATE.finditer(state):
20
+ try:
21
+ d = date(int(m[2]), int(m[3]), int(m[4]))
22
+ except ValueError:
23
+ continue
24
+ seen.setdefault(d, m[1] or "a date in the text")
25
+ if len(seen) == MAX_DATES:
26
+ break
27
+ if len(seen) < 2:
28
+ return state
29
+ lines = []
30
+ for (a, ka), (b, kb) in combinations(sorted(seen.items()), 2):
31
+ lines.append(f"- {ka} ({a}) is {(b - a).days} days before {kb} ({b}).")
32
+ return f"{state}\n\nDate facts:\n" + "\n".join(lines)
serve/amos/model.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Amos: encoder + typed decision heads. Every output is an option, a span of the state, or a P(yes).
2
+
3
+ All heads read the question's [DECIDE_t] vector:
4
+ noul softmax over two implicit options ["no: ...", "yes: ..."]; P(yes) is the second
5
+ choice/score softmax over bilinear(h_decide, mean-pooled option tokens)
6
+ multi independent sigmoid over the same pointer logits (+ bias)
7
+ span start/end softmax, bilinear(h_decide, state tokens); decoded to an exact substring
8
+ region softmax over the first state image's 49 cells, bilinear(h_decide, image tokens)
9
+
10
+ With a vision tower, image slots are filled with projected vision features before the first layer;
11
+ the tower itself stays frozen and is not part of saved checkpoints.
12
+ """
13
+
14
+ import json
15
+ import math
16
+ from pathlib import Path
17
+
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from torch import nn
21
+
22
+ from .encoder import Config, Encoder
23
+ from .facts import date_facts
24
+ from .packing import (
25
+ IMAGE_TOKENS,
26
+ TYPES,
27
+ Codec,
28
+ Packed,
29
+ char_span_to_tokens,
30
+ collate,
31
+ pack,
32
+ )
33
+ from .vision import Projector, VisionTower, box_target, cell_boxes
34
+
35
+ NOUL, CHOICE, SCORE, MULTI, SPAN, REGION = range(len(TYPES))
36
+ MAX_SPAN_TOKENS = 30
37
+
38
+
39
+ def _init(*mods):
40
+ for m in mods:
41
+ nn.init.normal_(m.weight, std=0.02)
42
+ nn.init.zeros_(m.bias)
43
+
44
+
45
+ class Amos(nn.Module):
46
+ def __init__(self, encoder: Encoder, vision: VisionTower | None = None):
47
+ super().__init__()
48
+ d = encoder.config.hidden_size
49
+ self.encoder = encoder
50
+ self.ptr_q, self.ptr_o = nn.Linear(d, d), nn.Linear(d, d)
51
+ self.multi_bias = nn.Parameter(torch.zeros(()))
52
+ self.span_q, self.span_k = nn.Linear(d, 2 * d), nn.Linear(d, 2 * d)
53
+ _init(self.ptr_q, self.ptr_o, self.span_q, self.span_k)
54
+ self.vision = vision
55
+ if vision is not None:
56
+ self.projector = Projector(vision.hidden_size, d)
57
+ self.region_q, self.region_k = nn.Linear(d, d), nn.Linear(d, d)
58
+ _init(self.region_q, self.region_k)
59
+
60
+ @classmethod
61
+ def from_encoder(cls, repo: str, codec: Codec, vision_repo: str | None = None) -> "Amos":
62
+ enc = Encoder.from_pretrained(repo)
63
+ init_markers(enc, codec)
64
+ return cls(enc, VisionTower.from_pretrained(vision_repo) if vision_repo else None)
65
+
66
+ def saved_state(self) -> dict:
67
+ """Weights to checkpoint: everything except the frozen, separately downloadable vision tower."""
68
+ return {k: v for k, v in self.state_dict().items() if not k.startswith("vision.")}
69
+
70
+ def embed(self, b: dict) -> torch.Tensor:
71
+ emb = self.encoder.tok_embeddings(b["ids"])
72
+ if "images" not in b:
73
+ return emb
74
+ x = b["images"]
75
+ if x.dim() == 4: # pixels -> frozen tower; precomputed features skip it
76
+ with torch.no_grad():
77
+ x = self.vision(x)
78
+ tokens = self.projector(x.to(emb.dtype))
79
+ cols = b["img_at"][:, None] + torch.arange(IMAGE_TOKENS, device=emb.device)
80
+ rows = b["img_row"][:, None].expand_as(cols)
81
+ return emb.index_put((rows.reshape(-1), cols.reshape(-1)), tokens.reshape(-1, tokens.shape[-1]))
82
+
83
+ def forward(self, b: dict) -> dict:
84
+ return self.heads(self.encoder(b["ids"], b["pos"], b["seg"], b["opt"], inputs_embeds=self.embed(b)), b)
85
+
86
+ def forward_cached(self, b: dict, state_len: int, cache: dict | None):
87
+ """One pack (B = 1) with a reusable state. Returns (outputs, state cache); same result as forward().
88
+
89
+ The state never attends to the questions, so its keys, values and hidden states in a full pass are
90
+ exactly what a state-only pass would produce: a miss runs one normal pass and slices the cache out
91
+ of it; a hit encodes only the question tokens on top of the cached state.
92
+ """
93
+ emb, s = self.embed(b), state_len
94
+ if cache is None:
95
+ h, kv = self.encoder.run(emb, b["pos"], b["seg"], b["opt"])
96
+ cache = {"kv": [(k[:, :, :s], v[:, :, :s]) for k, v in kv["kv"]], "hidden": h[:, :s],
97
+ **{k: b[k][:, :s] for k in ("pos", "seg", "opt")}}
98
+ return self.heads(h, b), cache
99
+ h_q, _ = self.encoder.run(emb[:, s:], b["pos"][:, s:], b["seg"][:, s:], b["opt"][:, s:], cache)
100
+ return self.heads(torch.cat([cache["hidden"], h_q], 1), b), cache
101
+
102
+ def heads(self, h: torch.Tensor, b: dict) -> dict:
103
+ d = h.shape[-1]
104
+ hq = h[b["q_row"], b["q_tok"]]
105
+ out = {}
106
+
107
+ Q, O = len(hq), len(b["o_q"])
108
+ opt_logits = h.new_full((Q, opt_width(b)), -torch.inf)
109
+ if O:
110
+ of = b["option_of"]
111
+ valid = of >= 0
112
+ pooled = h.new_zeros(O, d).index_add_(0, of[valid], h[valid])
113
+ pooled = pooled / torch.bincount(of[valid], minlength=O).clamp(min=1)[:, None]
114
+ ptr = (self.ptr_q(hq)[b["o_q"]] * self.ptr_o(pooled)).sum(-1) / math.sqrt(d)
115
+ ptr = ptr + (b["q_type"][b["o_q"]] == MULTI) * self.multi_bias
116
+ opt_logits = opt_logits.index_put((b["o_q"], b["o_rank"]), ptr)
117
+ out["opt"] = opt_logits
118
+
119
+ sq = (b["q_type"] == SPAN).nonzero().squeeze(-1)
120
+ out["span_q"] = sq
121
+ if len(sq):
122
+ rows = b["q_row"][sq]
123
+ qs = self.span_q(hq[sq]).view(len(sq), 2, d)
124
+ ks = self.span_k(h[rows]).view(len(sq), -1, 2, d)
125
+ logits = torch.einsum("qcd,qlcd->qcl", qs, ks) / math.sqrt(d)
126
+ out["span"] = logits.masked_fill(~b["state_mask"][rows][:, None, :], -torch.inf)
127
+
128
+ rq = (b["q_type"] == REGION).nonzero().squeeze(-1)
129
+ out["region_q"] = rq
130
+ if len(rq):
131
+ rows = b["q_row"][rq]
132
+ logits = torch.einsum("qd,qld->ql", self.region_q(hq[rq]), self.region_k(h[rows])) / math.sqrt(d)
133
+ out["region"] = logits.masked_fill(~b["region_mask"][rows], -torch.inf)
134
+ return out
135
+
136
+
137
+ def init_markers(enc: nn.Module, codec: Codec, markers=None):
138
+ """Reserved markers start as copies of [CLS] (their pretrained rows were never trained)."""
139
+ with torch.no_grad():
140
+ for i in markers or (*codec.decide.values(), codec.opt, codec.img, codec.img_end, codec.patch):
141
+ enc.tok_embeddings.weight[i] = enc.tok_embeddings.weight[codec.cls]
142
+
143
+
144
+ def add_targets(b: dict, packs: list[Packed], questions: list[list[dict]]) -> dict:
145
+ """Attach target tensors to a collated batch. Targets are probabilities (hard labels are one-hot).
146
+
147
+ noul: float P(yes). choice/score: distribution over options. multi: independent P per option.
148
+ span: list of accepted [start, end) char spans in the state. region: normalized (x0, y0, x1, y1) box
149
+ in the first state image, spread over the 49 cells by overlap.
150
+ """
151
+ Q, L = len(b["q_row"]), b["ids"].shape[1]
152
+ t_opt = torch.zeros(Q, opt_width(b))
153
+ t_span, t_region, has = [], [], torch.ones(Q, dtype=torch.bool)
154
+ qi = 0
155
+ for row, (p, qs) in enumerate(zip(packs, questions)):
156
+ ok = b["state_mask"][row]
157
+ for q in qs:
158
+ tgt, typ = q["target"], q["type"]
159
+ if typ == "noul": # implicit options are ["no", "yes"]
160
+ t_opt[qi, :2] = torch.tensor([1 - float(tgt), float(tgt)])
161
+ elif typ == "region":
162
+ dist = torch.zeros(L)
163
+ dist[p.image_at[0] : p.image_at[0] + IMAGE_TOKENS] = box_target(tgt)
164
+ t_region.append(dist)
165
+ elif typ == "span":
166
+ dist = torch.zeros(2, L)
167
+ for s, e in tgt:
168
+ tok = char_span_to_tokens(p.offsets, s, e)
169
+ if tok and ok[tok[0]] and ok[tok[1]]: # boundaries must be decodable (not truncated/blank)
170
+ dist[0, tok[0]] += 1
171
+ dist[1, tok[1]] += 1
172
+ has[qi] = bool(dist.sum())
173
+ t_span.append(dist / dist.sum(-1, keepdim=True).clamp(min=1))
174
+ else:
175
+ t_opt[qi, : len(tgt)] = torch.tensor(tgt, dtype=torch.float)
176
+ qi += 1
177
+ b["t_opt"], b["has_target"] = t_opt, has
178
+ b["t_span"] = torch.stack(t_span) if t_span else torch.zeros(0, 2, L)
179
+ b["t_region"] = torch.stack(t_region) if t_region else torch.zeros(0, L)
180
+ return b
181
+
182
+
183
+ def opt_width(b: dict) -> int:
184
+ return max(1, int(b["q_nopt"].max())) if len(b["q_nopt"]) else 1
185
+
186
+
187
+ def emd2(p: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
188
+ """Squared earth mover's distance between ordinal distributions [N, K] (padded with zeros)."""
189
+ return (p.cumsum(-1) - t.cumsum(-1)).pow(2).sum(-1)
190
+
191
+
192
+ def soft_ce(logits: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
193
+ """Cross-entropy against a (soft) target distribution; masked (-inf) logits must have zero target."""
194
+ logp = logits.log_softmax(-1).masked_fill(target == 0, 0)
195
+ return -(target * logp).sum(-1)
196
+
197
+
198
+ def brier(p: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
199
+ """Half the squared distance between distributions [N, K]; padded options have p = t = 0."""
200
+ return 0.5 * (p - t).pow(2).sum(-1)
201
+
202
+
203
+ def loss_fn(out: dict, b: dict, ordinal_weight: float = 1.0, brier_weight: float = 0.0,
204
+ type_weights: dict | None = None) -> tuple[torch.Tensor, dict]:
205
+ qt, has = b["q_type"], b["has_target"]
206
+ parts = {}
207
+ for typ in (NOUL, CHOICE, SCORE):
208
+ m = (qt == typ) & has
209
+ if m.any():
210
+ ce = soft_ce(out["opt"][m], b["t_opt"][m])
211
+ if brier_weight:
212
+ ce = ce + brier_weight * brier(out["opt"][m].softmax(-1), b["t_opt"][m])
213
+ if typ == SCORE:
214
+ ce = ce + ordinal_weight * emd2(out["opt"][m].softmax(-1), b["t_opt"][m])
215
+ parts[TYPES[typ]] = ce
216
+ m = (qt == MULTI) & has
217
+ if m.any():
218
+ logits, valid = out["opt"][m], out["opt"][m].isfinite()
219
+ bce = F.binary_cross_entropy_with_logits(logits.masked_fill(~valid, 0), b["t_opt"][m], reduction="none")
220
+ parts["multi"] = (bce * valid).sum(-1) / valid.sum(-1)
221
+ if len(out["span_q"]):
222
+ m = has[out["span_q"]]
223
+ if m.any():
224
+ parts["span"] = soft_ce(out["span"][m], b["t_span"][m]).mean(-1)
225
+ if len(out["region_q"]):
226
+ parts["region"] = soft_ce(out["region"], b["t_region"])
227
+ w = type_weights or {}
228
+ total = torch.cat([v * w.get(k, 1.0) for k, v in parts.items()]).mean()
229
+ return total, {k: v.mean().item() for k, v in parts.items()}
230
+
231
+
232
+ def to_device(b: dict, device) -> dict:
233
+ return {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in b.items()}
234
+
235
+
236
+ def decode_span(start: torch.Tensor, end: torch.Tensor, p: Packed, length_penalty: float = 0.0,
237
+ max_tokens: int = MAX_SPAN_TOKENS):
238
+ """Best valid span under independent start/end softmax. Returns (char_start, char_end, text, prob).
239
+ length_penalty: each token past the start scales a span's score by exp(-length_penalty). max_tokens: the longest
240
+ span considered.
241
+
242
+ The answer is always p.state[char_start:char_end]: it is cut from the input, never generated.
243
+ """
244
+ n = len(p.offsets)
245
+ if not n or not start.isfinite().any():
246
+ return 0, 0, "", 0.0
247
+ ps, pe = start.softmax(-1)[1 : n + 1], end.softmax(-1)[1 : n + 1] # state text tokens only
248
+ K = min(max_tokens, n)
249
+ pe_pad = F.pad(pe, (0, K))
250
+ joint = torch.stack([ps * pe_pad[k : k + n] for k in range(K)], 1) # [start, length - 1]
251
+ joint = joint * torch.exp(-length_penalty * torch.arange(K, dtype=joint.dtype))
252
+ joint = joint / joint.sum().clamp(min=1e-12)
253
+ s, k = divmod(int(joint.argmax()), K)
254
+ prob = float(joint[s, k])
255
+ a, z = p.offsets[s][0], p.offsets[s + k][1]
256
+ raw = p.state[a:z]
257
+ a, z = a + len(raw) - len(raw.lstrip()), z - (len(raw) - len(raw.rstrip()))
258
+ return a, max(a, z), p.state[a : max(a, z)], prob
259
+
260
+
261
+ def decode(out: dict, packs: list[Packed]) -> list[list[dict]]:
262
+ """Turn a batch forward into one typed result per question, grouped per pack."""
263
+ opt = out["opt"].float().cpu()
264
+ span = out["span"].float().cpu() if "span" in out else None
265
+ region = out["region"].float().cpu() if "region" in out else None
266
+ res, qi, si, ri = [], 0, 0, 0
267
+ for p in packs:
268
+ rs = []
269
+ for t, n in zip(p.types, p.n_options):
270
+ if t == "noul":
271
+ rs.append({"type": t, "p_yes": opt[qi, :2].softmax(-1)[1].item()})
272
+ elif t in ("choice", "score"):
273
+ probs = opt[qi, :n].softmax(-1)
274
+ r = {"type": t, "probs": probs.tolist(), "choice": int(probs.argmax())}
275
+ if t == "score":
276
+ r["expected"] = float((probs * torch.arange(n)).sum())
277
+ rs.append(r)
278
+ elif t == "multi":
279
+ rs.append({"type": t, "probs": torch.sigmoid(opt[qi, :n]).tolist()})
280
+ elif t == "region":
281
+ cells = region[ri].softmax(-1)[p.image_at[0] : p.image_at[0] + IMAGE_TOKENS]
282
+ k = int(cells.argmax())
283
+ rs.append({"type": t, "cell": k, "box": cell_boxes()[k].tolist(), "p": float(cells[k]), "heatmap": cells.tolist()})
284
+ ri += 1
285
+ else:
286
+ a, z, text, prob = decode_span(span[si, 0], span[si, 1], p)
287
+ rs.append({"type": t, "start": a, "end": z, "text": text, "p": prob})
288
+ si += 1
289
+ qi += 1
290
+ res.append(rs)
291
+ return res
292
+
293
+
294
+ def load_run(run, device="cpu"):
295
+ """A trained run directory (config.json + model.pt) -> (model in eval mode, its codec)."""
296
+ cfg = json.loads((Path(run) / "config.json").read_text())
297
+ enc = cfg["encoder"]
298
+ if cfg.get("readout", "pointer") != "pointer" or enc.get("question_reads") or enc.get("model_type", "modernbert") != "modernbert":
299
+ raise ValueError(f"{run}: trained with a removed mode (MLM readout, question reads or DeBERTa)")
300
+ codec = Codec(cfg["repo"], max_state=cfg["max_state"], digits=cfg.get("digits", False),
301
+ score_rank=cfg.get("score_rank", False))
302
+ vision = VisionTower.from_pretrained(cfg["vision"]) if cfg.get("vision") else None
303
+ model = Amos(Encoder(Config.from_hf(enc)), vision)
304
+ missing, unexpected = model.load_state_dict(torch.load(Path(run) / "model.pt", map_location="cpu", weights_only=True), strict=False)
305
+ if unexpected or any(not k.startswith("vision.") for k in missing):
306
+ raise ValueError(f"checkpoint mismatch: missing {missing}, unexpected {unexpected}")
307
+ return model.to(device).eval(), codec
308
+
309
+
310
+ def pack_request(model: Amos, codec: Codec, state: str, questions: list[dict], images=(), *, strict=False) -> list[Packed]:
311
+ """One pack holding every question (a list, so callers batch requests as rows)."""
312
+ if model.encoder.config.date_facts:
313
+ state = date_facts(state)
314
+ return [pack(codec, state, questions, images, strict=strict)]
315
+
316
+
317
+ @torch.no_grad()
318
+ def predict(model: Amos, codec: Codec, state: str, questions: list[dict], device="cpu", images=()) -> list[dict]:
319
+ """Run one state with its questions; returns one typed result per question."""
320
+ ps = pack_request(model, codec, state, questions, images)
321
+ return [r for rs in decode(model(to_device(collate(ps, codec.pad), device)), ps) for r in rs]
serve/amos/packing.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pack one state and N typed questions into a single sequence.
2
+
3
+ Layout: [CLS] state text [IMG] image tokens [/IMG] [SEP] | [DECIDE_t] q1 [SEP] [OPT] o1 [OPT] o2 ... | ...
4
+
5
+ Attention tree (see Encoder.masks): state sees state; a question sees state + itself;
6
+ an option sees state + its question + itself. Positions restart after the state for
7
+ every question and after the question for every option, so results do not depend on
8
+ question order, option order, or which siblings share the pack.
9
+
10
+ Images (state images or image options) occupy IMAGE_TOKENS placeholder slots each; the model
11
+ fills those slots with projected vision features before the first encoder layer.
12
+
13
+ Structural tokens are inserted by id. User text is tokenized with special-token matching
14
+ disabled, and the reserved [unusedN] markers are made special, so no text can forge them.
15
+ """
16
+
17
+ import json
18
+ from dataclasses import dataclass, field
19
+
20
+ import torch
21
+ from huggingface_hub import hf_hub_download
22
+ from tokenizers import Tokenizer, pre_tokenizers
23
+
24
+ TYPES =("noul", "choice", "score", "multi", "span", "region")
25
+ OPTION_TYPES = ("choice", "score", "multi")
26
+ MAX_OPTIONS = 255
27
+ IMAGE_TOKENS = 49 # merged patch tokens per image (vision.MERGED)
28
+ # Fixed [unusedN] slots, so adding a marker never moves one a trained checkpoint already uses.
29
+ MARKERS = {"noul": 0, "choice": 1, "score": 2, "multi": 3, "span": 4, "opt": 5, "region": 6, "img": 7, "img_end": 8, "patch": 9}
30
+
31
+
32
+ class Codec:
33
+ def __init__(self, repo: str, max_state: int = 512, max_question: int = 128, max_option: int = 48,
34
+ digits: bool = False, score_rank: bool = False):
35
+ with open(hf_hub_download(repo, "tokenizer.json")) as f:
36
+ spec = json.load(f)
37
+ for t in spec["added_tokens"]:
38
+ if t["content"].startswith("[unused"):
39
+ t["special"] = True
40
+ self.tok = Tokenizer.from_str(json.dumps(spec))
41
+ if digits:
42
+ # BPE cuts numbers into arbitrary chunks (300020 -> "3000" "20"), so two numbers of the same size
43
+ # rarely line up. One token per digit lets the model compare them place by place.
44
+ self.tok.pre_tokenizer = pre_tokenizers.Sequence(
45
+ [pre_tokenizers.Digits(individual_digits=True), self.tok.pre_tokenizer])
46
+ self.digits = digits
47
+ self.score_rank = score_rank
48
+ self.tok.encode_special_tokens = True # literal "[SEP]" / "[unused0]" in text stays plain text
49
+ self.tok.no_truncation()
50
+ self.tok.no_padding()
51
+ tid = self.tok.token_to_id
52
+ self.cls, self.sep, self.pad = tid("[CLS]"), tid("[SEP]"), tid("[PAD]")
53
+ marker = {k: tid(f"[unused{i}]") for k, i in MARKERS.items()}
54
+ assert None not in (self.cls, self.sep, self.pad, *marker.values()), f"{repo}: special tokens missing"
55
+ self.decide = {t: marker[t] for t in TYPES}
56
+ self.opt, self.img, self.img_end, self.patch = marker["opt"], marker["img"], marker["img_end"], marker["patch"]
57
+ self.reserved = {self.cls, self.sep, self.pad, *marker.values()}
58
+ self.max_state, self.max_question, self.max_option = max_state, max_question, max_option
59
+
60
+ def encode(self, text: str) -> list[int]:
61
+ return self.tok.encode(text, add_special_tokens=False).ids
62
+
63
+
64
+ @dataclass
65
+ class Packed:
66
+ ids: list[int] = field(default_factory=list)
67
+ pos: list[int] = field(default_factory=list)
68
+ seg: list[int] = field(default_factory=list) # 0 state, k question k
69
+ opt: list[int] = field(default_factory=list) # 0 not an option, m option m of its question
70
+ decide: list[int] = field(default_factory=list) # token index of each question's DECIDE token
71
+ types: list[str] = field(default_factory=list)
72
+ n_options: list[int] = field(default_factory=list)
73
+ offsets: list[tuple[int, int]] = field(default_factory=list) # char offsets of state text tokens 1..n
74
+ state: str = ""
75
+ images: list = field(default_factory=list) # pixels [3, H, W] or vision features [patches, d], in slot order
76
+ image_at: list[int] = field(default_factory=list) # token index of each image's first placeholder
77
+
78
+ def __len__(self):
79
+ return len(self.ids)
80
+
81
+
82
+ def noul_options(q: dict) -> list[str]:
83
+ """noul is scored as two implicit options, carrying the optional criteria descriptions."""
84
+ c = q.get("criteria") or {}
85
+ return [f"no: {c['false']}" if c.get("false") else "no", f"yes: {c['true']}" if c.get("true") else "yes"]
86
+
87
+
88
+ def _image_tokens(codec: Codec, p: Packed, image, start: int) -> list[int]:
89
+ p.images.append(image)
90
+ p.image_at.append(start + 1)
91
+ return [codec.img, *[codec.patch] * IMAGE_TOKENS, codec.img_end]
92
+
93
+
94
+ def pack(codec: Codec, state: str, questions: list[dict], images=(), *, strict: bool = False) -> Packed:
95
+ """questions: [{"type": ..., "text": ..., "options": [...], "criteria": {...}}].
96
+
97
+ options: required for choice/score/multi (score: ordered low to high); an option may be an image
98
+ tensor instead of text. criteria: optional {"true": ..., "false": ...} for noul. span and region
99
+ take neither. images: state images, placed after the state text.
100
+ """
101
+ enc = codec.tok.encode(state, add_special_tokens=False)
102
+ if strict and len(enc.ids) > codec.max_state:
103
+ raise ValueError(f"maximum context length: state has {len(enc.ids)} tokens, limit {codec.max_state}")
104
+ keep = min(len(enc.ids), codec.max_state)
105
+ p = Packed(state=state, offsets=enc.offsets[:keep])
106
+ p.ids = [codec.cls, *enc.ids[:keep]]
107
+ for im in images:
108
+ p.ids += _image_tokens(codec, p, im, len(p.ids))
109
+ p.ids.append(codec.sep)
110
+ n = len(p.ids)
111
+ p.pos, p.seg, p.opt = list(range(n)), [0] * n, [0] * n
112
+ for k, q in enumerate(questions, start=1):
113
+ t = q["type"]
114
+ if t not in TYPES:
115
+ raise ValueError(f"unknown question type {t!r}")
116
+ if t == "region" and not images:
117
+ raise ValueError("region questions need a state image")
118
+ options = noul_options(q) if t == "noul" else q.get("options") or []
119
+ if t != "noul" and ((t in OPTION_TYPES) != bool(options) or len(options) > MAX_OPTIONS):
120
+ raise ValueError(f"{t} question needs {'1-255' if t in OPTION_TYPES else 'no'} options")
121
+ text = codec.encode(q["text"])
122
+ if not strict:
123
+ text = text[: codec.max_question]
124
+ branch = [codec.decide[t], *text, codec.sep]
125
+ p.decide.append(len(p.ids))
126
+ p.types.append(t)
127
+ p.n_options.append(len(options))
128
+ p.ids += branch
129
+ p.pos += range(n, n + len(branch))
130
+ p.seg += [k] * len(branch)
131
+ p.opt += [0] * len(branch)
132
+ o_start = n + len(branch)
133
+ for m, o in enumerate(options, start=1):
134
+ if isinstance(o, str):
135
+ if t == "score" and codec.score_rank:
136
+ o = f"Level {m - 1} of {len(options) - 1}: {o}"
137
+ text = codec.encode(o)
138
+ toks = [codec.opt, *(text if strict else text[: codec.max_option])]
139
+ else:
140
+ toks = [codec.opt, *_image_tokens(codec, p, o, len(p.ids))]
141
+ p.ids += toks
142
+ p.pos += range(o_start, o_start + len(toks))
143
+ p.seg += [k] * len(toks)
144
+ p.opt += [m] * len(toks)
145
+ return p
146
+
147
+
148
+ def char_span_to_tokens(offsets, start: int, end: int):
149
+ """Map a char span [start, end) of the state to inclusive state-token indices (1-based), or None."""
150
+ toks = [i for i, (a, b) in enumerate(offsets, start=1) if a < end and b > start]
151
+ return (toks[0], toks[-1]) if toks else None
152
+
153
+
154
+ PAD_TO = 64 # rows are padded to a multiple of this: few distinct shapes, so few cached kernels/graphs
155
+
156
+
157
+ def padded(n: int) -> int:
158
+ return -(-n // PAD_TO) * PAD_TO
159
+
160
+
161
+ def batches(lengths: list[int], budget: int, rng=None, chunk: int = 4096) -> list[list[int]]:
162
+ """Group indices into batches with rows * L <= budget and rows * L^2 <= budget * 512 (L = padded longest row).
163
+
164
+ The second bound caps attention memory, which grows with L^2. Sorting by length limits padding;
165
+ with an rng, sorting happens within random chunks and the batch order is shuffled, so batches
166
+ differ every epoch.
167
+ """
168
+ idx = list(range(len(lengths)))
169
+ if rng:
170
+ rng.shuffle(idx)
171
+ else:
172
+ chunk = len(idx) or 1
173
+
174
+ def fits(rows, longest):
175
+ L = padded(longest)
176
+ return rows * L <= budget and rows * L * L <= budget * 512
177
+
178
+ out = []
179
+ for c in range(0, len(idx), chunk):
180
+ cur, longest = [], 0
181
+ for i in sorted(idx[c : c + chunk], key=lengths.__getitem__):
182
+ if cur and not fits(len(cur) + 1, max(longest, lengths[i])):
183
+ out.append(cur)
184
+ cur, longest = [], 0
185
+ cur.append(i)
186
+ longest = max(longest, lengths[i])
187
+ if cur:
188
+ out.append(cur)
189
+ if rng:
190
+ rng.shuffle(out)
191
+ return out
192
+
193
+
194
+ def collate(packs: list[Packed], pad_id: int) -> dict:
195
+ """Right-pad packs into [B, L] tensors and index every question, option, and image in the batch."""
196
+ B, L = len(packs), padded(max(len(p) for p in packs))
197
+ t = {k: torch.full((B, L), v, dtype=torch.long) for k, v in [("ids", pad_id), ("pos", 0), ("seg", -1), ("opt", 0)]}
198
+ t["option_of"] = torch.full((B, L), -1, dtype=torch.long) # global option index per token
199
+ t["state_mask"] = torch.zeros(B, L, dtype=torch.bool) # span boundary candidates: non-blank state tokens
200
+ t["region_mask"] = torch.zeros(B, L, dtype=torch.bool) # region candidates: first state image's tokens
201
+ q_row, q_tok, q_type, q_nopt, o_q, o_rank, images, img_row, img_at = [], [], [], [], [], [], [], [], []
202
+ for b, p in enumerate(packs):
203
+ n = len(p)
204
+ for k in ("ids", "pos", "seg", "opt"):
205
+ t[k][b, :n] = torch.tensor(getattr(p, k))
206
+ t["state_mask"][b, 1 : 1 + len(p.offsets)] = torch.tensor([bool(p.state[a:z].strip()) for a, z in p.offsets])
207
+ if p.image_at and p.seg[p.image_at[0]] == 0:
208
+ t["region_mask"][b, p.image_at[0] : p.image_at[0] + IMAGE_TOKENS] = True
209
+ images += p.images
210
+ img_row += [b] * len(p.images)
211
+ img_at += p.image_at
212
+ base = []
213
+ for tok, typ, n_opt in zip(p.decide, p.types, p.n_options):
214
+ base.append(len(o_q))
215
+ o_q += [len(q_row)] * n_opt
216
+ o_rank += range(n_opt)
217
+ q_row.append(b), q_tok.append(tok), q_type.append(TYPES.index(typ)), q_nopt.append(n_opt)
218
+ t["option_of"][b, :n] = torch.tensor([base[s - 1] + o - 1 if o else -1 for s, o in zip(p.seg, p.opt)])
219
+ for k, v in [("q_row", q_row), ("q_tok", q_tok), ("q_type", q_type), ("q_nopt", q_nopt), ("o_q", o_q),
220
+ ("o_rank", o_rank), ("img_row", img_row), ("img_at", img_at)]:
221
+ t[k] = torch.tensor(v, dtype=torch.long)
222
+ if images:
223
+ t["images"] = torch.stack([im.float() for im in images])
224
+ return t
serve/amos/py.typed ADDED
File without changes
serve/amos/serve.py ADDED
@@ -0,0 +1,728 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local HTTP server: POST /v1/systemone.
2
+
3
+ uv run python -m amos.serve runs/distilled --port 8000
4
+ uv run python -m amos.serve runs/distilled --bench
5
+ uv run python -m amos.serve runs/dec --device cuda # a decoder run (RUN/model/config.json) is detected
6
+ uv run python -m amos.serve runs/dec --also amos-1-reason=runs/reason # a second model, picked by "model"
7
+
8
+ Request:
9
+ {"state": str | object | array,
10
+ "images": ["<base64 image>", ...], # optional state images (vision runs)
11
+ "questions": {"<id>": {"type": "noul" | "choice" | "score" | "multi" | "span" | "region",
12
+ "instructions": str | object | array,
13
+ "criteria": ...}},
14
+ "abstain": 0.1} # optional: conformal sets at this alpha
15
+
16
+ criteria: noul {"true": ..., "false": ...} (optional); choice and multi {"<option>": description | null |
17
+ {"image": "<base64>"}}; score [level descriptions, lowest first]; span and region none. Objects and
18
+ arrays are rendered as JSON text. Encoded images are cached by content hash.
19
+
20
+ Response: {"model", "answers": {"<id>": {...}}, "usage": {"input_tokens", "output_tokens": 0}}
21
+ noul {"type", "noul": P(yes)}
22
+ choice {"type", "choice", "probabilities": {option: p}, "confidence"}
23
+ score {"type", "score": expected level, "legend": {"0": ...}, "probabilities": {"0": p}, "confidence"}
24
+ multi {"type", "probabilities": {option: p}}
25
+ span {"type", "span": text, "start", "end", "confidence"}
26
+ region {"type", "box": [x0, y0, x1, y1] normalized, "cell": 0-48 (a decoder: its image's row-major token grid),
27
+ "confidence"}
28
+ A decoder run answers what its checkpoint supports (/v1/info card.types) and refuses the rest, and abstain, with 422.
29
+ With "abstain", noul/choice/score answers add "set" (options kept at that alpha) and "abstain" (set size != 1).
30
+ """
31
+
32
+ import argparse
33
+ import base64
34
+ import hashlib
35
+ import io
36
+ import json
37
+ import math
38
+ import mimetypes
39
+ import os
40
+ import queue
41
+ import re
42
+ import shutil
43
+ import sys
44
+ import threading
45
+ import time
46
+ import traceback
47
+ import urllib.request
48
+ from collections import OrderedDict
49
+ from concurrent.futures import ThreadPoolExecutor
50
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
51
+ from pathlib import Path
52
+ from urllib.parse import parse_qs, urlsplit
53
+
54
+ import torch
55
+
56
+ from .calibrate import apply
57
+ from .model import decode, pack_request, to_device
58
+ from .packing import MAX_OPTIONS, TYPES, batches, collate, pack
59
+
60
+ STATE_CACHE_TOKENS = 16384 # cached state tokens per engine, across all cached states
61
+
62
+
63
+ class BadRequest(ValueError):
64
+ pass
65
+
66
+
67
+ def render(x) -> str:
68
+ return x if isinstance(x, str) else json.dumps(x, indent=2, ensure_ascii=False)
69
+
70
+
71
+ def image_bytes(x: str) -> bytes:
72
+ """An image as sent: plain base64, or a data URL ("data:image/png;base64,...")."""
73
+ return base64.b64decode(x.split(",", 1)[1] if x.startswith("data:") else x)
74
+
75
+
76
+ def parse(req: dict):
77
+ """Validate a request -> (state text, [question id], [internal question])."""
78
+ if not isinstance(req, dict) or "state" not in req or not isinstance(req.get("questions"), dict) or not req["questions"]:
79
+ raise BadRequest("need 'state' and a non-empty 'questions' object")
80
+ ids, qs = [], []
81
+ for qid, q in req["questions"].items():
82
+ if not isinstance(q, dict) or q.get("type") not in TYPES or "instructions" not in q:
83
+ raise BadRequest(f"question {qid!r}: need 'type' in {list(TYPES)} and 'instructions'")
84
+ t, c = q["type"], q.get("criteria")
85
+ out = {"type": t, "text": render(q["instructions"])}
86
+ if t == "noul" and c is not None:
87
+ if not isinstance(c, dict):
88
+ raise BadRequest(f"question {qid!r}: noul criteria must be an object")
89
+ out["criteria"] = {k: render(v) for k, v in c.items() if k in ("true", "false") and v is not None}
90
+ elif t in ("choice", "multi"):
91
+ if not isinstance(c, dict) or not 2 <= len(c) <= MAX_OPTIONS:
92
+ raise BadRequest(f"question {qid!r}: {t} criteria must map 2-{MAX_OPTIONS} options to descriptions")
93
+ out["names"] = list(c)
94
+ out["options"] = [k if v is None else v if isinstance(v, dict) and "image" in v else f"{k}: {render(v)}"
95
+ for k, v in c.items()]
96
+ elif t == "score":
97
+ if not isinstance(c, list) or not 2 <= len(c) <= MAX_OPTIONS:
98
+ raise BadRequest(f"question {qid!r}: score criteria must be a list of 2-{MAX_OPTIONS} levels")
99
+ out["options"] = [render(v) for v in c]
100
+ ids.append(qid)
101
+ qs.append(out)
102
+ return render(req["state"]), ids, qs
103
+
104
+
105
+ def free_gpu_cache(min_unused_bytes: int = 1 << 30) -> bool:
106
+ """Hand the CUDA caching allocator's unused blocks back to the device when they pass min_unused_bytes. #3 serves
107
+ two models on one GPU: a long request leaves the large holding gigabytes it no longer uses, and the medium then
108
+ runs out of memory on a long request of its own (2026-10-09, JevBench's hard tier). True when it freed."""
109
+ try:
110
+ import torch
111
+ except ImportError:
112
+ return False
113
+ if not torch.cuda.is_available() or torch.cuda.memory_reserved() - torch.cuda.memory_allocated() < min_unused_bytes:
114
+ return False
115
+ torch.cuda.empty_cache()
116
+ return True
117
+
118
+
119
+ def shift(probs, cut: float) -> list[float]:
120
+ """Pick-any probabilities moved on the logit scale so 0.5 falls where the fitted cut was: p >= cut <=> p' >= 0.5."""
121
+ b = math.log(cut / (1 - cut))
122
+ return [1 / (1 + math.exp(b - math.log(max(p, 1e-9) / max(1 - p, 1e-9)))) for p in probs]
123
+
124
+
125
+ def choice_confidence(probs):
126
+ k = len(probs)
127
+ return (max(probs) - 1 / k) / (1 - 1 / k)
128
+
129
+
130
+ def score_confidence(probs):
131
+ L = len(probs)
132
+ mode = max(range(L), key=probs.__getitem__)
133
+ spread = sum(abs(i - (L - 1) / 2) for i in range(L)) / L
134
+ return max(0.0, 1 - sum(p * abs(i - mode) for i, p in enumerate(probs)) / spread)
135
+
136
+
137
+ def answer(q: dict, r: dict) -> dict:
138
+ t = q["type"]
139
+ if t == "noul":
140
+ a = {"type": t, "noul": r["p_yes"]}
141
+ elif t == "choice":
142
+ a = {"type": t, "choice": q["names"][r["choice"]], "probabilities": dict(zip(q["names"], r["probs"])),
143
+ "confidence": choice_confidence(r["probs"])}
144
+ elif t == "score":
145
+ a = {"type": t, "score": r["expected"], "legend": {str(i): o for i, o in enumerate(q["options"])},
146
+ "probabilities": {str(i): p for i, p in enumerate(r["probs"])}, "confidence": score_confidence(r["probs"])}
147
+ elif t == "multi":
148
+ a = {"type": t, "probabilities": dict(zip(q["names"], r["probs"]))}
149
+ elif t == "region":
150
+ a = {"type": t, "box": r["box"], "cell": r["cell"], "confidence": r["p"]}
151
+ else:
152
+ a = {"type": t, "span": r["text"], "start": r["start"], "end": r["end"], "confidence": r["p"]}
153
+ if "set" in r:
154
+ labels = [False, True] if t == "noul" else q.get("names") or [str(i) for i in range(len(q["options"]))]
155
+ a["set"], a["abstain"] = [labels[i] for i in r["set"]], r["abstain"]
156
+ return a
157
+
158
+
159
+ class Engine:
160
+ def __init__(self, run: str, device: str, name: str | None = None, *, strict: bool = False,
161
+ state_cache: bool = True):
162
+ from .model import load_run
163
+
164
+ self.model, self.codec = load_run(run, device)
165
+ self.device, self.run = device, run
166
+ self.vision = self.model.vision is not None
167
+ self.strict = strict
168
+ self.use_state_cache = state_cache
169
+ cal = Path(run) / "calibration.json"
170
+ report = json.loads(cal.read_text()) if cal.exists() else {}
171
+ self.cal = report.get("calibration")
172
+ cfg = Path(run) / "config.json"
173
+ cfg = json.loads(cfg.read_text()) if cfg.exists() else {}
174
+ enc, args = cfg.get("encoder", {}), cfg.get("args", {})
175
+ self.strict = strict or args.get("strict_inputs", False)
176
+ self.max_len = args.get("max_len", self.codec.max_state + 1024)
177
+ size = (cfg.get("repo") or "").rsplit("-", 1)[-1] # jhu-clsp/ettin-encoder-32m -> 32m
178
+ self.name = name or {"32m": "amos-1-mini", "68m": "amos-1"}.get(size, f"amos-1-{size}")
179
+ self.card = { # what /v1/info reports about the model
180
+ "base": cfg.get("repo"),
181
+ "parameters": sum(p.numel() for p in self.model.parameters()),
182
+ "layers": enc.get("num_hidden_layers"),
183
+ "hidden": enc.get("hidden_size"),
184
+ "heads": enc.get("num_attention_heads"),
185
+ "max_state": cfg.get("max_state"),
186
+ "max_len": args.get("max_len"),
187
+ "strict_inputs": self.strict,
188
+ "compare_options": self.model.encoder.config.compare_options,
189
+ # a run may have trained a region head yet be served without a vision tower: only then can it point
190
+ "types": [*(t for t in args.get("types", "").split(",") if t and t != "region"),
191
+ *(["region"] if self.model.vision else [])],
192
+ "temperature": (self.cal or {}).get("temperature", {}),
193
+ "held_out": report.get("test_held_out", {}),
194
+ "in_distribution": report.get("test_in_distribution", {}),
195
+ "benchmarks": {},
196
+ }
197
+ td = Path(run) / "typed_decisions.json" # written by `python -m amos.typed_decisions RUN --out ...`
198
+ if td.exists():
199
+ day = time.strftime("%Y-%m-%d", time.localtime(td.stat().st_mtime))
200
+ self.card["benchmarks"]["typed_decisions"] = {"measured": day, **json.loads(td.read_text())["all"]}
201
+ self.lock = threading.Lock() # one forward at a time; requests queue on it
202
+ self.image_cache: OrderedDict[str, torch.Tensor] = OrderedDict() # content hash -> vision features
203
+ # state + image hashes -> encoded state; bounded by cached tokens, since one 8k-token state is ~2 GB of keys
204
+ # and values on a 400m encoder
205
+ self.state_cache: OrderedDict[str, dict] = OrderedDict()
206
+
207
+ def image(self, data: str) -> torch.Tensor:
208
+ """Base64 image -> frozen-tower features, cached by content hash (LRU, 1024 images)."""
209
+ from PIL import Image, UnidentifiedImageError
210
+
211
+ from .vision import preprocess
212
+
213
+ if self.model.vision is None:
214
+ raise BadRequest("this model has no vision tower")
215
+ key = hashlib.sha256(data.encode()).hexdigest()
216
+ if key not in self.image_cache:
217
+ try:
218
+ pixels = preprocess(Image.open(io.BytesIO(image_bytes(data))))
219
+ except (ValueError, UnidentifiedImageError) as e:
220
+ raise BadRequest(f"could not decode image: {e}") from e
221
+ self.image_cache[key] = self.model.vision(pixels[None].to(self.device))[0]
222
+ if len(self.image_cache) > 1024:
223
+ self.image_cache.popitem(last=False)
224
+ self.image_cache.move_to_end(key)
225
+ return self.image_cache[key]
226
+
227
+ @torch.no_grad()
228
+ def __call__(self, req: dict) -> dict:
229
+ state, ids, qs = parse(req)
230
+ alpha = req.get("abstain")
231
+ if alpha is not None and (self.cal is None or str(alpha) not in self.cal["qhat"].get("choice", {})):
232
+ raise BadRequest(f"abstain={alpha!r} needs a calibration.json fitted at that alpha")
233
+ with self.lock:
234
+ images = [self.image(x) for x in req.get("images") or []]
235
+ qs_in = [{**q, "options": [self.image(o["image"]) if isinstance(o, dict) else o for o in q["options"]]}
236
+ if "options" in q else q for q in qs]
237
+ try:
238
+ ps = pack_request(self.model, self.codec, state, qs_in, images, strict=self.strict)
239
+ (p,) = ps
240
+ if self.strict and len(p) > self.max_len:
241
+ raise ValueError(f"maximum context length: request has {len(p)} tokens, limit {self.max_len}")
242
+ except ValueError as e:
243
+ raise BadRequest(str(e)) from e
244
+ key = hashlib.sha256(json.dumps([state, [hashlib.sha256(x.encode()).hexdigest() for x in req.get("images") or []]]).encode()).hexdigest()
245
+ batch = to_device(collate(ps, self.codec.pad), self.device)
246
+ if self.use_state_cache:
247
+ out, self.state_cache[key] = self.model.forward_cached(
248
+ batch, p.decide[0], self.state_cache.get(key))
249
+ self.state_cache.move_to_end(key)
250
+ while len(self.state_cache) > 1 and (
251
+ len(self.state_cache) > 32
252
+ or sum(c["pos"].shape[1] for c in self.state_cache.values()) > STATE_CACHE_TOKENS):
253
+ self.state_cache.popitem(last=False)
254
+ else:
255
+ out = self.model(batch)
256
+ results = [r for rs in decode(out, ps) for r in rs]
257
+ if self.cal:
258
+ results = [apply(r, self.cal, alpha if r["type"] in ("noul", "choice", "score") else None) for r in results]
259
+ return {"model": self.name, "answers": {i: answer(q, r) for i, q, r in zip(ids, qs, results)},
260
+ "usage": {"input_tokens": len(p), "output_tokens": 0, "state_tokens": p.decide[0],
261
+ "branch_tokens": {i: p.seg.count(k) for k, i in enumerate(ids, start=1)}}}
262
+
263
+ @torch.no_grad()
264
+ def explain(self, req: dict) -> dict:
265
+ """How much each sentence of the state moves one question's answer.
266
+
267
+ Every variant of the state with one sentence removed is packed as its own row and all rows run in a
268
+ single batched forward pass. Influence = answer score with the full state minus the score without
269
+ that sentence (positive: the sentence supports the current answer).
270
+ """
271
+ state, ids, qs = parse(req)
272
+ qid = req.get("question")
273
+ if qid not in ids:
274
+ raise BadRequest("'question' must name one of the questions")
275
+ q = qs[ids.index(qid)]
276
+ if q["type"] == "region":
277
+ raise BadRequest("explanations cover text states; region questions are not supported")
278
+ sents = [(m.start(), m.end()) for m in re.finditer(r"[^\n.!?]+[.!?]*[\"'”’)]*", state) if m.group().strip()][:64]
279
+ variants = [state] + [state[:a] + " " * (b - a) + state[b:] for a, b in sents] # blank, keep offsets
280
+ with self.lock:
281
+ images = [self.image(x) for x in req.get("images") or []]
282
+ q_in = {**q, "options": [self.image(o["image"]) if isinstance(o, dict) else o for o in q["options"]]} if "options" in q else q
283
+ packs = [pack(self.codec, s, [q_in], images) for s in variants]
284
+ res = [None] * len(packs)
285
+ for idx in batches([len(p) for p in packs], 24000): # 65 variants of an 8k state do not fit in one batch
286
+ chunk = [packs[i] for i in idx]
287
+ for i, r in zip(idx, decode(self.model(to_device(collate(chunk, self.codec.pad), self.device)), chunk)):
288
+ res[i] = r[0]
289
+
290
+ def score(r):
291
+ if r["type"] == "noul":
292
+ return r["p_yes"]
293
+ if r["type"] in ("choice", "score"):
294
+ return r["probs"][res[0]["choice"]]
295
+ if r["type"] == "multi":
296
+ return sum(r["probs"]) / len(r["probs"])
297
+ return r["p"] if r["text"] == res[0]["text"] else 0.0
298
+
299
+ base = score(res[0])
300
+ return {"question": qid, "base": base,
301
+ "sentences": [{"start": a, "end": b, "influence": base - score(r)} for (a, b), r in zip(sents, res[1:])]}
302
+
303
+
304
+ class DecoderEngine:
305
+ """A decoder run (amos.decoder: RUN/model is a Hugging Face checkpoint) behind the same API. Yes/no, choice and
306
+ score read option letters, pick-any asks one yes/no per option in the same prompt; state images and image options
307
+ need a checkpoint with a vision tower (decoder vision-merge); quote and point need trained heads."""
308
+
309
+ MAX_LEN = 16384 # tokens per request, images included; inputs are never shortened
310
+
311
+ def __init__(self, run: str, device: str, name: str | None = None, max_len: int | None = None,
312
+ rotations: int = 1, think_below: float | None = None, think_tokens: int = 512):
313
+ from .decoder import (
314
+ calibration,
315
+ load_heads,
316
+ load_model,
317
+ multi_cut,
318
+ span_decoding,
319
+ )
320
+
321
+ self.multi_cut = multi_cut(run)
322
+ self.think_below, self.think_tokens = think_below, think_tokens # decoder.results: think when unsure
323
+ self.MAX_LEN = max_len or self.MAX_LEN
324
+ path = Path(run) / "model"
325
+ self.model, self.codec = load_model(str(path), device)
326
+ self.model.eval()
327
+ self.heads = load_heads(path, self.model, self.codec)
328
+ if self.heads is not None:
329
+ self.heads.span = span_decoding(run) # the run's own quote cap and penalty, if fitted
330
+ self.rotations, self.temperature = rotations, calibration(run) # temperatures from `decoder calibrate`, if fitted
331
+ self.device, self.run, self.cal = device, run, None
332
+ self.vision = self.codec.image_processor is not None
333
+ self.autocast = device == "cuda" and getattr(self.model.config, "amos_export_dtype", None) != "float32"
334
+ cfg = Path(run) / "config.json"
335
+ cfg = json.loads(cfg.read_text()) if cfg.exists() else {}
336
+ text = getattr(self.model.config, "text_config", self.model.config)
337
+ self.name = name or f"amos-1-{(cfg.get('base') or 'decoder').rsplit('-', 1)[-1].lower()}"
338
+ heads = self.heads.kinds if self.heads is not None else []
339
+ self.types = ["noul", "choice", "score", "multi", *(k for k in heads if k == "span" or self.vision)]
340
+ self.card = {
341
+ "base": cfg.get("base"),
342
+ "parameters": sum(p.numel() for p in self.model.parameters()),
343
+ "layers": text.num_hidden_layers,
344
+ "hidden": text.hidden_size,
345
+ "heads": text.num_attention_heads,
346
+ "max_state": None,
347
+ "max_len": self.MAX_LEN,
348
+ "strict_inputs": True,
349
+ "compare_options": None,
350
+ "types": self.types,
351
+ "explain": False, # no /v1/explain for decoder runs
352
+ "temperature": self.temperature or {},
353
+ "rotations": self.rotations,
354
+ "held_out": {},
355
+ "in_distribution": {},
356
+ "benchmarks": {},
357
+ }
358
+ td = Path(run) / "typed_decisions.json"
359
+ if td.exists():
360
+ day = time.strftime("%Y-%m-%d", time.localtime(td.stat().st_mtime))
361
+ self.card["benchmarks"]["typed_decisions"] = {"measured": day, **json.loads(td.read_text())["all"]}
362
+ self.lock = threading.Lock()
363
+ # thoughts written by a vLLM server serving the same checkpoint (AMOS_THINK_URL, e.g. http://10.0.0.5:8100):
364
+ # batched across requests there, while the letters are still read here; this model is free meanwhile
365
+ self.think_url = os.environ.get("AMOS_THINK_URL")
366
+
367
+ def remote_thoughts(self, prefixes: list[str], max_tokens: int) -> list[tuple[str, int]]:
368
+ def one(prefix):
369
+ body = json.dumps({"model": "amos", "prompt": prefix, "max_tokens": max_tokens, "temperature": 0,
370
+ "stop": ["</think>"], "skip_special_tokens": False}).encode()
371
+ req = urllib.request.Request(f"{self.think_url}/v1/completions", body, {"Content-Type": "application/json"})
372
+ out = json.loads(urllib.request.urlopen(req, timeout=1800).read())
373
+ return out["choices"][0]["text"], out["usage"]["completion_tokens"]
374
+
375
+ self.lock.release() # held by __call__; other requests use the model while this one waits for its thoughts
376
+ try:
377
+ with ThreadPoolExecutor(len(prefixes)) as pool:
378
+ return list(pool.map(one, prefixes))
379
+ finally:
380
+ self.lock.acquire()
381
+
382
+ def image(self, data: str):
383
+ from PIL import Image, UnidentifiedImageError
384
+
385
+ if not self.vision:
386
+ raise BadRequest("this model has no vision tower")
387
+ try:
388
+ return Image.open(io.BytesIO(image_bytes(data))).convert("RGB")
389
+ except (ValueError, UnidentifiedImageError) as e:
390
+ raise BadRequest(f"could not decode image: {e}") from e
391
+
392
+ @torch.no_grad()
393
+ def __call__(self, req: dict) -> dict:
394
+ from .decoder import results
395
+
396
+ state, ids, qs = parse(req)
397
+ if req.get("abstain") is not None:
398
+ raise BadRequest("abstain is not available for this model yet: it has no calibration.json")
399
+ for qid, q in zip(ids, qs):
400
+ if q["type"] not in self.types:
401
+ raise BadRequest(f"question {qid!r}: {q['type']} is not available for this model yet "
402
+ f"(it has no trained {q['type']} head{'' if self.vision else ' or no vision tower'})")
403
+ images = [self.image(x) for x in req.get("images") or []]
404
+ qs_in = [{**q, "options": [self.image(o["image"]) if isinstance(o, dict) else o for o in q["options"]]}
405
+ if "options" in q else q for q in qs]
406
+ with self.lock, torch.autocast("cuda", dtype=torch.bfloat16, enabled=self.autocast):
407
+ try:
408
+ rs, n = results(self.model, self.codec, state, qs_in, images, self.heads, self.MAX_LEN, self.rotations,
409
+ self.temperature, self.think_below, self.think_tokens,
410
+ self.remote_thoughts if self.think_url else None)
411
+ except ValueError as e:
412
+ raise BadRequest(str(e)) from e
413
+ if self.multi_cut:
414
+ rs = [{**r, "probs": shift(r["probs"], self.multi_cut)} if r["type"] == "multi" else r for r in rs]
415
+ return {"model": self.name, "answers": {i: answer(q, r) for i, q, r in zip(ids, qs, rs)},
416
+ "usage": {"input_tokens": n, "output_tokens": 0}}
417
+
418
+ def explain(self, req: dict) -> dict:
419
+ raise BadRequest("explanations are not available for this model yet")
420
+
421
+
422
+ def load_engine(run: str, device: str, name: str | None = None, max_len: int | None = None, rotations: int = 1, **kw):
423
+ """DecoderEngine when the run holds a decoder checkpoint (RUN/model/config.json), else the encoder Engine."""
424
+ think = {k: kw.pop(k) for k in ("think_below", "think_tokens") if k in kw}
425
+ if (Path(run) / "model" / "config.json").exists():
426
+ return DecoderEngine(run, device, name, max_len, rotations, **think)
427
+ return Engine(run, device, name, **kw)
428
+
429
+
430
+ UI = Path(__file__).parent / "ui" # the playground's static build (cd playground && bun run build)
431
+ MAX_BODY = 4 << 20 # bytes
432
+
433
+
434
+ class Server(ThreadingHTTPServer):
435
+ """socketserver's default backlog of 5 resets connections under a burst; queued requests wait in max_queue instead."""
436
+
437
+ request_queue_size = 256
438
+ daemon_threads = True
439
+ RELEASE_DATE = "2026-09-30"
440
+
441
+
442
+ class RequestLog:
443
+ """Every answered request, kept to improve the models (the site's /privacy page says so). One folder per UTC day:
444
+ requests.jsonl and, under images/, each photo once by content hash. A record holds the time, model, source
445
+ (playground or api), the case, questions, photo hashes, answers and server time; never the caller's address.
446
+ Folders older than `days` are deleted.
447
+
448
+ Writes run on a background thread, so the disk never slows an answer; when it falls behind, records are dropped
449
+ rather than queued without bound."""
450
+
451
+ def __init__(self, root, days: int = 90, clock=time.time):
452
+ self.root, self.days, self.clock = Path(root), days, clock
453
+ self.root.mkdir(parents=True, exist_ok=True)
454
+ self.queue: queue.Queue = queue.Queue(maxsize=1000)
455
+ self.pruned = None
456
+ threading.Thread(target=self.run, daemon=True).start()
457
+
458
+ def add(self, record: dict, images: list) -> bool:
459
+ try:
460
+ self.queue.put_nowait((record, images))
461
+ return True
462
+ except queue.Full:
463
+ return False
464
+
465
+ def run(self):
466
+ while True:
467
+ record, images = self.queue.get()
468
+ try:
469
+ self.write(record, images)
470
+ except Exception:
471
+ traceback.print_exc()
472
+ finally:
473
+ self.queue.task_done()
474
+
475
+ def write(self, record: dict, images: list):
476
+ now = self.clock()
477
+ day = time.strftime("%Y-%m-%d", time.gmtime(now))
478
+ folder = self.root / day
479
+ (folder / "images").mkdir(parents=True, exist_ok=True)
480
+ refs = []
481
+ for x in images:
482
+ raw = image_bytes(x)
483
+ ext = ".jpg" if raw[:2] == b"\xff\xd8" else ".png" if raw[:4] == b"\x89PNG" else \
484
+ ".webp" if raw[8:12] == b"WEBP" else ".bin"
485
+ name = hashlib.sha256(raw).hexdigest() + ext
486
+ if not (folder / "images" / name).exists():
487
+ (folder / "images" / name).write_bytes(raw)
488
+ refs.append(name)
489
+ line = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)), **record, "images": refs}
490
+ with open(folder / "requests.jsonl", "a") as f:
491
+ f.write(json.dumps(line, ensure_ascii=False) + "\n")
492
+ if self.pruned != day: # once a day: drop folders past retention
493
+ self.pruned = day
494
+ cutoff = time.strftime("%Y-%m-%d", time.gmtime(now - self.days * 86400))
495
+ for old in self.root.iterdir():
496
+ if old.is_dir() and re.fullmatch(r"\d{4}-\d{2}-\d{2}", old.name) and old.name < cutoff:
497
+ shutil.rmtree(old)
498
+
499
+
500
+ class RateLimit:
501
+ """Per-client token bucket: per_min requests a minute on average, bursts of up to burst."""
502
+
503
+ def __init__(self, per_min: float, burst: int, clock=time.monotonic):
504
+ self.rate, self.burst, self.clock = per_min / 60, burst, clock
505
+ self.buckets, self.lock = {}, threading.Lock()
506
+
507
+ def wait(self, key: str) -> float:
508
+ """0 if this request may go now (and it is counted), else the seconds until one may."""
509
+ with self.lock:
510
+ now = self.clock()
511
+ if len(self.buckets) > 10000: # forget clients whose bucket has refilled
512
+ self.buckets = {k: v for k, v in self.buckets.items() if (now - v[1]) * self.rate < self.burst}
513
+ tokens, last = self.buckets.get(key, (self.burst, now))
514
+ tokens = min(self.burst, tokens + (now - last) * self.rate)
515
+ self.buckets[key] = (tokens - 1 if tokens >= 1 else tokens, now)
516
+ return 0.0 if tokens >= 1 else (1 - tokens) / self.rate
517
+
518
+
519
+ PLAYGROUND_ORIGINS = ("https://amos.scira.ai",)
520
+
521
+
522
+ def handler(engine: Engine, cors: str | None = None, max_queue: int = 8, limit: RateLimit | None = None,
523
+ extra: list | None = None, log: RequestLog | None = None, retired: dict[str, str] | None = None):
524
+ """engine answers by default; extra engines answer requests whose "model" names them. With a log, every answered
525
+ /v1/systemone request is kept. retired maps a withdrawn model's name to its replacement: asking for it gets 410
526
+ rather than another model's answers under the old name."""
527
+ retired = retired or {}
528
+
529
+ def gone(name):
530
+ return {"error": f"{name} has been retired; use {retired[name]} instead"}
531
+ # Forwards run one at a time (Engine.lock); past max_queue waiting requests, refuse rather than pile up.
532
+ slots = threading.BoundedSemaphore(max_queue)
533
+ extra = extra or []
534
+ models = {e.name: e for e in extra} # lookups fall back to engine, so it needs no entry
535
+
536
+ class H(BaseHTTPRequestHandler):
537
+ def client(self) -> str:
538
+ # Behind Vercel: x-vercel-forwarded-for is set by Vercel, while a caller's own x-forwarded-for passes
539
+ # through untouched, so it is only the fallback when Vercel is not in front. These headers are trusted as
540
+ # sent: a caller who reaches the server directly can set any of them.
541
+ forwarded = (self.headers.get("x-vercel-forwarded-for") or self.headers.get("x-real-ip")
542
+ or (self.headers.get("x-forwarded-for") or "").split(",")[0])
543
+ return forwarded.strip() or self.client_address[0]
544
+
545
+ def address_string(self): # the access log names the caller, not the proxy in front
546
+ return self.client()
547
+
548
+ def do_GET(self):
549
+ path = self.path.split("?")[0]
550
+ if path == "/v1/info": # ?model=NAME for a model other than the default
551
+ name = parse_qs(urlsplit(self.path).query).get("model", [engine.name])[0]
552
+ if name in retired:
553
+ return self.send(410, gone(name))
554
+ e = engine if name == engine.name else models.get(name)
555
+ if e is None:
556
+ return self.send(404, {"error": f"no model {name!r}; served: {[engine.name, *models]}"})
557
+ alphas = sorted(e.cal["qhat"].get("choice", {})) if e.cal else []
558
+ return self.send(200, {"model": e.name, "vision": e.vision,
559
+ "abstain": [float(a) for a in alphas], "card": e.card})
560
+ if path == "/v1/models": # what the TypeSafe SDKs' models.list() reads
561
+ return self.send(200, {"models": [{"name": e.name, "release_date": RELEASE_DATE,
562
+ "description": f"{e.card['parameters'] / 1e9:.1f}B parameters: "
563
+ + ", ".join(e.card["types"])}
564
+ for e in [engine, *extra]]})
565
+ if not UI.is_dir():
566
+ return self.send(404, {"error": "playground not built: run `bun install && bun run build` in playground/"})
567
+ file = (UI / path.lstrip("/")).resolve()
568
+ if file.is_dir():
569
+ file = file / "index.html"
570
+ if not file.is_relative_to(UI.resolve()) or not file.is_file():
571
+ return self.send(404, {"error": "not found"})
572
+ self.send_bytes(200, file.read_bytes(), mimetypes.guess_type(file.name)[0] or "application/octet-stream")
573
+
574
+ def do_OPTIONS(self): # CORS preflight, only when a dev origin was allowed
575
+ self.send_bytes(204, b"", "text/plain")
576
+
577
+ def do_POST(self):
578
+ if self.path not in ("/v1/systemone", "/v1/explain"):
579
+ return self.send(404, {"error": "not found"})
580
+ try:
581
+ size = int(self.headers.get("Content-Length", 0))
582
+ except ValueError:
583
+ return self.send(400, {"error": "bad Content-Length"})
584
+ if size > MAX_BODY:
585
+ self.close_connection = True # the unread body cannot be reused as the next request
586
+ return self.send(413, {"error": f"request body over {MAX_BODY >> 20} MB"})
587
+ if limit and (wait := limit.wait(self.client())):
588
+ self.close_connection = True
589
+ return self.send(429, {"error": "rate limit reached; retry after the Retry-After header"},
590
+ {"Retry-After": str(math.ceil(wait))})
591
+ if not slots.acquire(blocking=False):
592
+ self.close_connection = True
593
+ return self.send(503, {"error": "busy, retry shortly"}, {"Retry-After": "1"})
594
+ try:
595
+ req = json.loads(self.rfile.read(size))
596
+ if isinstance(req, dict) and req.get("model") in retired:
597
+ return self.send(410, gone(req["model"]))
598
+ # the request's model picks among served models; any other name (the SDKs send jev-latest) gets the first
599
+ eng = models.get(req.get("model"), engine) if isinstance(req, dict) else engine
600
+ if self.path != "/v1/systemone":
601
+ return self.send(200, eng.explain(req))
602
+ t = time.perf_counter()
603
+ try:
604
+ res = eng(req)
605
+ except Exception as e: # out of GPU memory: give back the cache and try once more
606
+ if type(e).__name__ != "OutOfMemoryError" or not free_gpu_cache(0):
607
+ raise
608
+ res = eng(req)
609
+ if log:
610
+ log.add({"model": res.get("model"), "source": "playground" if self.headers.get("Origin") in
611
+ PLAYGROUND_ORIGINS else "api", "state": req["state"], "questions": req["questions"],
612
+ "answers": res.get("answers"), "ms": round((time.perf_counter() - t) * 1000, 1)},
613
+ list(req.get("images") or []))
614
+ self.send(200, res)
615
+ except (BadRequest, json.JSONDecodeError, ValueError) as e:
616
+ self.send(422, {"error": str(e)})
617
+ except Exception: # a bug, not the request: answer instead of dropping the connection
618
+ traceback.print_exc()
619
+ self.send(500, {"error": "internal error: the server failed to answer this request"})
620
+ finally:
621
+ slots.release()
622
+ free_gpu_cache() # so the other model on this GPU can use what this request no longer needs
623
+
624
+ def send(self, code, body, headers=None):
625
+ self.send_bytes(code, json.dumps(body).encode(), "application/json", headers)
626
+
627
+ def send_bytes(self, code, data, content_type, headers=None):
628
+ self.send_response(code)
629
+ self.send_header("Content-Type", content_type)
630
+ self.send_header("Content-Length", str(len(data)))
631
+ for k, v in (headers or {}).items():
632
+ self.send_header(k, v)
633
+ if cors:
634
+ self.send_header("Access-Control-Allow-Origin", cors)
635
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
636
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
637
+ self.send_header("Access-Control-Max-Age", "7200") # a far caller would otherwise pay a preflight each call
638
+ self.end_headers()
639
+ self.wfile.write(data)
640
+
641
+ def log_message(self, *args):
642
+ pass
643
+
644
+ return H
645
+
646
+
647
+ def bench(engine: Engine, runs=20):
648
+ """Median latency by state length and number of questions: new state (cold) and cached state (warm)."""
649
+ word = "The shipment of 40 units arrived late and two boxes were damaged in transit. "
650
+ rows = []
651
+ for words in (50, 200, 800):
652
+ for n_q in (1, 8, 32):
653
+ req = {"state": (word * (words // 13 + 1))[: words * 6],
654
+ "questions": {f"q{i}": [{"type": "noul", "instructions": "Was anything damaged?"},
655
+ {"type": "choice", "instructions": "Main issue?", "criteria": {"late": None, "damage": None, "missing": None}},
656
+ {"type": "score", "instructions": "How severe?", "criteria": ["minor", "moderate", "major"]}][i % 3]
657
+ for i in range(n_q)}}
658
+ engine(req)
659
+ med = {}
660
+ for mode in ("cold", "warm"):
661
+ times = []
662
+ for _ in range(runs):
663
+ if mode == "cold":
664
+ getattr(engine, "state_cache", {}).clear() # a decoder keeps no state cache
665
+ t = time.perf_counter()
666
+ engine(req)
667
+ times.append(time.perf_counter() - t)
668
+ med[mode] = sorted(times)[runs // 2] * 1000
669
+ rows.append((words, n_q, med["cold"], med["warm"]))
670
+ print(f"{engine.device} state ~{words} words {n_q:>2} questions cold {med['cold']:6.1f} ms warm {med['warm']:6.1f} ms", flush=True)
671
+ return rows
672
+
673
+
674
+ def main(argv=None):
675
+ ap = argparse.ArgumentParser()
676
+ ap.add_argument("run", help="a run directory (encoder, or decoder with RUN/model)")
677
+ ap.add_argument("--host", default="127.0.0.1", help="0.0.0.0 inside a container")
678
+ ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8000)))
679
+ ap.add_argument("--max-queue", type=int, default=8, help="requests allowed to wait for the model before 503")
680
+ ap.add_argument("--rate-limit", type=float, default=0,
681
+ help="requests a minute per caller before 429 (zero: no limit); for a public endpoint")
682
+ ap.add_argument("--burst", type=int, default=20, help="requests a caller may send at once under --rate-limit")
683
+ ap.add_argument("--device", default="cpu")
684
+ ap.add_argument("--name", help="model name to report (default: amos-1 for 68m, amos-1-mini for 32m)")
685
+ ap.add_argument("--bench", action="store_true")
686
+ ap.add_argument("--check", action="store_true", help="load the model and exit (an image build caches the tokenizer)")
687
+ ap.add_argument("--strict", action="store_true", help="preserve question/option text and reject context overflow")
688
+ ap.add_argument("--max-len", type=int, help="decoder runs: tokens allowed per request (default DecoderEngine.MAX_LEN)")
689
+ ap.add_argument("--rotations", type=int, default=1, help="decoder runs: average choice reads over this many option orders")
690
+ ap.add_argument("--think-below", type=float, help="decoder runs: re-ask an answer less sure than this after the "
691
+ "model reasons in its thinking block")
692
+ ap.add_argument("--think-tokens", type=int, default=512, help="decoder runs: the most tokens a thought may take")
693
+ ap.add_argument("--no-state-cache", action="store_true", help="encode each request without retaining state tensors")
694
+ ap.add_argument("--cors", help="allow this browser origin to call the API (e.g. http://localhost:3000 for `bun dev`)")
695
+ ap.add_argument("--also", action="append", default=[], metavar="NAME=RUN",
696
+ help="serve another run beside the first, answering requests whose \"model\" is NAME")
697
+ ap.add_argument("--retired", action="append", default=[], metavar="NAME=REPLACEMENT",
698
+ help="a withdrawn model: requests naming it get 410 and the replacement's name")
699
+ ap.add_argument("--typed-decisions", help="typed-decisions metrics JSON for /v1/info (default: the run's "
700
+ "typed_decisions.json)")
701
+ ap.add_argument("--log-dir", help="keep every answered request here to improve the models (RequestLog). Publish "
702
+ "the notice (/privacy) before turning this on")
703
+ ap.add_argument("--log-days", type=int, default=90, help="days of logged requests to keep")
704
+ a = ap.parse_args(argv)
705
+ engine = load_engine(a.run, a.device, a.name, a.max_len, a.rotations, strict=a.strict,
706
+ state_cache=not a.no_state_cache, think_below=a.think_below, think_tokens=a.think_tokens)
707
+ if a.typed_decisions:
708
+ td = Path(a.typed_decisions)
709
+ day = time.strftime("%Y-%m-%d", time.localtime(td.stat().st_mtime))
710
+ engine.card["benchmarks"]["typed_decisions"] = {"measured": day, **json.loads(td.read_text())["all"]}
711
+ if a.check:
712
+ print(f"{engine.name}: {engine.card['parameters']:,} parameters, max_state {engine.card['max_state']}", flush=True)
713
+ return
714
+ if a.bench:
715
+ bench(engine)
716
+ return
717
+ extra = [load_engine(run, a.device, name, a.max_len, a.rotations)
718
+ for name, _, run in (s.partition("=") for s in a.also)]
719
+ print(f"serving {a.run}{''.join(f', {e.name}' for e in extra)}: playground http://{a.host}:{a.port}/, "
720
+ f"API POST http://{a.host}:{a.port}/v1/systemone", flush=True)
721
+ limit = RateLimit(a.rate_limit, a.burst) if a.rate_limit else None
722
+ log = RequestLog(a.log_dir, a.log_days) if a.log_dir else None
723
+ Server((a.host, a.port), handler(engine, a.cors, a.max_queue, limit, extra, log,
724
+ dict(s.split("=", 1) for s in a.retired))).serve_forever()
725
+
726
+
727
+ if __name__ == "__main__":
728
+ main(sys.argv[1:])
serve/amos/vision.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SigLIP-architecture vision tower written from scratch (loads SigLIP 2 weights), plus the projector.
2
+
3
+ An image becomes 14 x 14 patch vectors; the projector merges each 2 x 2 block (4x fewer tokens) and maps
4
+ it into the text encoder's embedding space with a 2-layer MLP, giving 49 image tokens.
5
+ """
6
+
7
+ import json
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from huggingface_hub import hf_hub_download
12
+ from safetensors.torch import load_file
13
+ from torch import nn
14
+
15
+ IMAGE_SIZE, PATCH, GRID = 224, 16, 14
16
+ MERGED = (GRID // 2) ** 2 # image tokens per image after 2 x 2 merging
17
+
18
+
19
+ def preprocess(image) -> torch.Tensor:
20
+ """PIL image -> [3, 224, 224] in [-1, 1] (bilinear resize, as SigLIP's processor does)."""
21
+ from PIL import Image
22
+
23
+ image = image.convert("RGB").resize((IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.BILINEAR)
24
+ x = torch.frombuffer(bytearray(image.tobytes()), dtype=torch.uint8).view(IMAGE_SIZE, IMAGE_SIZE, 3)
25
+ return x.permute(2, 0, 1).float() / 127.5 - 1
26
+
27
+
28
+ class Block(nn.Module):
29
+ def __init__(self, d, heads, ff, eps):
30
+ super().__init__()
31
+ self.heads = heads
32
+ self.layer_norm1, self.layer_norm2 = nn.LayerNorm(d, eps=eps), nn.LayerNorm(d, eps=eps)
33
+ self.q_proj, self.k_proj, self.v_proj, self.out_proj = (nn.Linear(d, d) for _ in range(4))
34
+ self.fc1, self.fc2 = nn.Linear(d, ff), nn.Linear(ff, d)
35
+
36
+ def forward(self, x):
37
+ B, N, d = x.shape
38
+ h = self.layer_norm1(x)
39
+ q, k, v = (p(h).view(B, N, self.heads, -1).transpose(1, 2) for p in (self.q_proj, self.k_proj, self.v_proj))
40
+ x = x + self.out_proj(F.scaled_dot_product_attention(q, k, v).transpose(1, 2).reshape(B, N, d))
41
+ return x + self.fc2(F.gelu(self.fc1(self.layer_norm2(x)), approximate="tanh"))
42
+
43
+
44
+ class VisionTower(nn.Module):
45
+ def __init__(self, d=768, layers=12, heads=12, ff=3072, eps=1e-6):
46
+ super().__init__()
47
+ self.patch_embedding = nn.Conv2d(3, d, PATCH, PATCH)
48
+ self.position_embedding = nn.Parameter(torch.zeros(GRID * GRID, d))
49
+ self.blocks = nn.ModuleList(Block(d, heads, ff, eps) for _ in range(layers))
50
+ self.post_layernorm = nn.LayerNorm(d, eps=eps)
51
+ self.hidden_size = d
52
+
53
+ def forward(self, pixels): # [B, 3, 224, 224] -> [B, 196, d]
54
+ # cuDNN disabled: its runtime-compiled engines fail to load on some CUDA hosts. The one conv is a cheap patchify.
55
+ from torch.nn.attention import SDPBackend, sdpa_kernel
56
+
57
+ with torch.backends.cudnn.flags(enabled=False), sdpa_kernel([SDPBackend.FLASH_ATTENTION, SDPBackend.EFFICIENT_ATTENTION, SDPBackend.MATH]):
58
+ x = self.patch_embedding(pixels).flatten(2).transpose(1, 2) + self.position_embedding
59
+ for b in self.blocks:
60
+ x = b(x)
61
+ return self.post_layernorm(x)
62
+
63
+ @classmethod
64
+ def from_pretrained(cls, repo: str) -> "VisionTower":
65
+ with open(hf_hub_download(repo, "config.json")) as f:
66
+ vc = json.load(f).get("vision_config", {})
67
+ m = cls(vc.get("hidden_size", 768), vc.get("num_hidden_layers", 12), vc.get("num_attention_heads", 12),
68
+ vc.get("intermediate_size", 3072), vc.get("layer_norm_eps", 1e-6))
69
+ sd = {}
70
+ for k, v in load_file(hf_hub_download(repo, "model.safetensors")).items():
71
+ if not k.startswith("vision_model.") or ".head." in k:
72
+ continue
73
+ k = k.removeprefix("vision_model.").replace("embeddings.", "").replace("encoder.layers.", "blocks.")
74
+ k = k.replace("self_attn.", "").replace("mlp.", "")
75
+ sd["position_embedding" if k == "position_embedding.weight" else k] = v
76
+ m.load_state_dict(sd, strict=True)
77
+ return m.eval().requires_grad_(False)
78
+
79
+
80
+ class Projector(nn.Module):
81
+ """2 x 2 patch merge (concatenate neighbours) + 2-layer MLP into the text embedding space."""
82
+
83
+ def __init__(self, d_vision: int, d_text: int):
84
+ super().__init__()
85
+ self.fc1, self.fc2 = nn.Linear(4 * d_vision, d_text), nn.Linear(d_text, d_text)
86
+
87
+ def forward(self, feats): # [N, 196, dv] -> [N, 49, dt]
88
+ N, _, dv = feats.shape
89
+ g = GRID // 2
90
+ x = feats.view(N, g, 2, g, 2, dv).permute(0, 1, 3, 2, 4, 5).reshape(N, g * g, 4 * dv)
91
+ return self.fc2(F.gelu(self.fc1(x)))
92
+
93
+
94
+ def cell_boxes(rows: int = GRID // 2, cols: int = GRID // 2) -> torch.Tensor:
95
+ """[rows * cols, 4] normalized (x0, y0, x1, y1) of each merged image token, row-major (49 by default)."""
96
+ i = torch.arange(rows * cols)
97
+ r, c = i // cols, i % cols
98
+ return torch.stack([c / cols, r / rows, (c + 1) / cols, (r + 1) / rows], 1).float()
99
+
100
+
101
+ def box_target(box, rows: int = GRID // 2, cols: int = GRID // 2) -> torch.Tensor:
102
+ """Distribution over the cells proportional to overlap with a normalized box (x0, y0, x1, y1)."""
103
+ cells, (x0, y0, x1, y1) = cell_boxes(rows, cols), box
104
+ w = (cells[:, 2].clamp(max=x1) - cells[:, 0].clamp(min=x0)).clamp(min=0)
105
+ h = (cells[:, 3].clamp(max=y1) - cells[:, 1].clamp(min=y0)).clamp(min=0)
106
+ area = w * h
107
+ return area / area.sum() if area.sum() > 0 else torch.full((len(cells),), 1 / len(cells))
108
+
109
+
110
+ def peak_box(p: torch.Tensor, rows: int, cols: int, keep: float) -> tuple[list[float], float]:
111
+ """Box around the most likely cell and the cells 4-connected to it that hold at least `keep` of its probability ->
112
+ (normalized (x0, y0, x1, y1), probability inside the box). keep=1 is (about) the most likely cell alone."""
113
+ g = p.view(rows, cols)
114
+ r, c = divmod(int(p.argmax()), cols)
115
+ on, seen, todo = (g >= keep * g[r, c]).tolist(), {(r, c)}, [(r, c)]
116
+ while todo:
117
+ i, j = todo.pop()
118
+ for y, x in ((i + 1, j), (i - 1, j), (i, j + 1), (i, j - 1)):
119
+ if 0 <= y < rows and 0 <= x < cols and on[y][x] and (y, x) not in seen:
120
+ seen.add((y, x))
121
+ todo.append((y, x))
122
+ r0, r1 = min(y for y, _ in seen), max(y for y, _ in seen) + 1
123
+ c0, c1 = min(x for _, x in seen), max(x for _, x in seen) + 1
124
+ return [c0 / cols, r0 / rows, c1 / cols, r1 / rows], float(g[r0:r1, c0:c1].sum())
125
+
126
+
127
+ def box_scores(box, gold) -> tuple[bool, float]:
128
+ """(the box's centre lies inside the gold box, intersection over union) of normalized (x0, y0, x1, y1) boxes."""
129
+ cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
130
+ w = max(0.0, min(box[2], gold[2]) - max(box[0], gold[0]))
131
+ h = max(0.0, min(box[3], gold[3]) - max(box[1], gold[1]))
132
+ union = (box[2] - box[0]) * (box[3] - box[1]) + (gold[2] - gold[0]) * (gold[3] - gold[1]) - w * h
133
+ return gold[0] <= cx <= gold[2] and gold[1] <= cy <= gold[3], w * h / union if union > 0 else 0.0
serve/pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "amos-serve"
3
+ version = "1.0.0"
4
+ description = "Serving code for the Amos 1 decision models: one HTTP server, POST /v1/systemone, answers typed questions from one forward pass"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ # pinned to the versions the published numbers were measured with: with these three the package answers byte for
9
+ # byte like our own servers; transformers 5.19 or the pure-PyTorch linear attention move probabilities by up to 0.02
10
+ dependencies = [
11
+ "torch==2.14.0",
12
+ "transformers==5.17.0",
13
+ "flash-linear-attention==0.5.2",
14
+ "safetensors>=0.8.0",
15
+ "tokenizers>=0.23.2",
16
+ "pillow>=12.3.0",
17
+ "huggingface-hub>=1.33.0",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=75"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["amos*"]
serve/sources.json ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "commit": "cd57f1762f3029565fb3a877dce9e598739b971e",
3
+ "version": "1.0.0",
4
+ "files": {
5
+ "amos/serve.py": {
6
+ "from": "src/amos/serve.py",
7
+ "verbatim": true,
8
+ "sha256": "b0523591c320a074a0ec0751b68ebc205aeedd090ff5e7ca10bda3fb94a07ef8"
9
+ },
10
+ "amos/model.py": {
11
+ "from": "src/amos/model.py",
12
+ "verbatim": true,
13
+ "sha256": "d4f4354f3e4d3b475b4c1f832a4a9741da757a368a1d2e0e74544d06009c2c27"
14
+ },
15
+ "amos/packing.py": {
16
+ "from": "src/amos/packing.py",
17
+ "verbatim": true,
18
+ "sha256": "14f027c9e15c456abb0f264c07dae06afb7ace7b64270f2374e97eabeb04f993"
19
+ },
20
+ "amos/encoder.py": {
21
+ "from": "src/amos/encoder.py",
22
+ "verbatim": true,
23
+ "sha256": "b4a5a606a2dee99eedf86cab50be759eca0908a318e4f3037ba92560d837bd67"
24
+ },
25
+ "amos/facts.py": {
26
+ "from": "src/amos/facts.py",
27
+ "verbatim": true,
28
+ "sha256": "f2efd7ef25453f7f2dc204dd247c86733a55697ec48714f30560df8ed2da1f23"
29
+ },
30
+ "amos/vision.py": {
31
+ "from": "src/amos/vision.py",
32
+ "verbatim": true,
33
+ "sha256": "133d1e29cc64e371f5df1f0cb0328fd77b8b9093d8465319580cf5ea4af5d081"
34
+ },
35
+ "amos/decoder.py": {
36
+ "from": "src/amos/decoder.py",
37
+ "verbatim": false,
38
+ "kept": [
39
+ "load_model",
40
+ "load_heads",
41
+ "results",
42
+ "calibration",
43
+ "span_decoding",
44
+ "multi_cut"
45
+ ],
46
+ "source_sha256": "8d76889e60e05606ea2de24ee40648208dac2f49b3a0e0b5277285397ddcbb73",
47
+ "sha256": "0dcacb27a05d31781f8310cbe6123b0ee351c62456cc6b5f8576b40b8ddad080"
48
+ },
49
+ "amos/calibrate.py": {
50
+ "from": "src/amos/calibrate.py",
51
+ "verbatim": false,
52
+ "kept": [
53
+ "apply"
54
+ ],
55
+ "source_sha256": "552322fa793eea43148ed0b7860a5bca1484fec806dfe485a2ddb664f17b8524",
56
+ "sha256": "ba094d9eaedd229a99b2bdcd35a371ffdeb9aaee258a342524cc19e2f85ec421"
57
+ }
58
+ }
59
+ }
smoke_decoder.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse,asyncio,json
2
+ from pathlib import Path
3
+ import torch
4
+ from coreai_decoder import AmosDecoder
5
+ async def main(name):
6
+ def forbidden(*a,**kw):raise AssertionError('PyTorch neural execution forbidden')
7
+ torch.nn.Linear.forward=forbidden;torch.nn.Embedding.forward=forbidden;torch.nn.Conv2d.forward=forbidden;torch.nn.Conv3d.forward=forbidden
8
+ root=Path('bundles')/(name+'-CoreAI');m=await AmosDecoder.load(root)
9
+ out=await m.request({'state':'Please refund the duplicate charge.','questions':{'refund':{'type':'noul','instructions':'Is a refund requested?'}}})
10
+ assert out['answers']['refund']['noul']>0.5,out
11
+ (root/'runtime-smoke.json').write_text(json.dumps({'passed':True,'pytorch_neural_execution_disabled':True,'result':out},indent=2));print(out)
12
+ if __name__=='__main__':
13
+ p=argparse.ArgumentParser();p.add_argument('name');a=p.parse_args();asyncio.run(main(a.name))
text.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ F��C���T��Hݛ2�?T��Ô������r
text.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c469415a143a1d9d75486ed48dd9b32c1033f1854a6bfc394ae9eb08caa8c72
3
+ size 7534240163
text.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "producer" : "coreai-core 1.0.0b3",
3
+ "assetVersion" : "2.0",
4
+ "creationDate" : "20261014T012228Z"
5
+ }
validate_decoder.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse,asyncio,json,sys,shutil
2
+ from pathlib import Path
3
+ import numpy as np
4
+ import torch
5
+ from PIL import Image
6
+ from coreai_decoder import AmosDecoder
7
+
8
+ async def main(name,mode='both'):
9
+ source=(Path('upstream')/name).resolve();root=Path('bundles')/(name+'-CoreAI')
10
+ shutil.copytree(source/'serve',root/'serve',dirs_exist_ok=True)
11
+ (root/'model').mkdir(exist_ok=True)
12
+ for f in (source/'model').iterdir():
13
+ if f.suffix not in ['.safetensors','.pt']:shutil.copy2(f,root/'model'/f.name)
14
+ for f in ['config.json','calibration.json','LICENSE']:shutil.copy2(source/f,root/f)
15
+ sys.path.insert(0,str(source/'serve'))
16
+ import amos.decoder as decoder
17
+ from transformers import Qwen2VLImageProcessorPil
18
+ decoder.IMAGE_PIXELS=224*224
19
+ precision=json.loads((root/'manifest.json').read_text())['precision']
20
+ torch.set_num_threads(4)
21
+ if mode!='coreai':
22
+ model,codec=decoder.load_model(str(source/'model'),'cpu',dtype=getattr(torch,precision));model.eval()
23
+ codec.image_processor=Qwen2VLImageProcessorPil.from_pretrained(source/'model',size={'shortest_edge':224*224,'longest_edge':224*224})
24
+ heads=decoder.load_heads(source/'model',model,codec);heads.span=decoder.span_decoding(source)
25
+ if mode!='reference':ai=await AmosDecoder.load(root)
26
+ pixels=np.zeros((224,224,3),np.uint8);pixels[:,:112]=[255,0,0];pixels[:,112:]=[0,0,255];im=Image.fromarray(pixels)
27
+ cases=[
28
+ ('refund','Invoice INV-2231 for £306.00 was charged twice. Please refund one.',[{'type':'noul','text':'Is a refund requested?'},{'type':'choice','text':'Which team?','options':['billing','technical','sales']}],[]),
29
+ ('span','The invoice total is £306.00 and the invoice number is INV-2231.',[{'type':'span','text':'Quote the invoice number.'}],[]),
30
+ ('score','The service is down for every customer.',[{'type':'score','text':'How severe is the outage?','options':['none','minor','major','complete']}],[]),
31
+ ('multi','I need a refund and a password reset.',[{'type':'multi','text':'Which topics apply?','options':['refund','password','delivery']}],[]),
32
+ ('negative','The customer explicitly says no refund is needed.',[{'type':'noul','text':'Is a refund requested?'}],[]),
33
+ ('unicode','José paid €123.45 for café supplies.',[{'type':'span','text':'Quote the amount paid.'},{'type':'choice','text':'Who paid?','options':['José','Maria','Bob']}],[]),
34
+ ('options','This is about category 17.',[{'type':'choice','text':'Which category?','options':[f'category {i}' for i in range(24)]}],[]),
35
+ ('vision','Two colored halves.',[{'type':'region','text':'Point to the red half.'},{'type':'choice','text':'Which colors?','options':['red and blue','green and yellow']}],[im]),
36
+ ('image-option','Find the red-blue image.',[{'type':'choice','text':'Which image matches?','options':[im,Image.new('RGB',(224,224),'green')]}],[]),
37
+ ]
38
+ records=[];refs=json.loads((root/'reference.json').read_text()) if mode=='coreai' else []
39
+ for case,state,qs,ims in cases:
40
+ if mode=='coreai':
41
+ ref=next(r for r in refs if r['case']==case);expected,tokens=ref['upstream'],ref['tokens']
42
+ else:
43
+ with torch.no_grad():expected,tokens=decoder.results(model,codec,state,qs,ims,heads,512,temperature=decoder.calibration(source))
44
+ if mode=='reference':
45
+ records.append({'case':case,'tokens':tokens,'reference_precision':precision,'upstream':expected});print(case,tokens,flush=True);continue
46
+ actual=await ai.predict(state,qs,ims)
47
+ error=0;match=True
48
+ for x,y in zip(expected,actual):
49
+ for key in ['p_yes','probs','p','box']:
50
+ if key in x:error=max(error,float(np.abs(np.asarray(x[key])-np.asarray(y[key])).max()))
51
+ for key in ['choice','start','end','text','cell']:
52
+ if key in x and x[key]!=y[key]:match=False
53
+ rec={'case':case,'tokens':tokens,'reference_precision':precision,'max_probability_or_box_error':error,'decisions_match':match,'upstream':expected,'coreai':actual};records.append(rec);print(json.dumps(rec),flush=True)
54
+ if not match or error>0.02:raise AssertionError(rec)
55
+ (root/('reference.json' if mode=='reference' else 'validation.json')).write_text(json.dumps(records,indent=2));print('PASS',flush=True)
56
+ if __name__=='__main__':
57
+ p=argparse.ArgumentParser();p.add_argument('name');p.add_argument('--mode',choices=['both','reference','coreai'],default='both');a=p.parse_args();asyncio.run(main(a.name,a.mode))
validation.json ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "case": "refund",
4
+ "tokens": 122,
5
+ "reference_precision": "float32",
6
+ "max_probability_or_box_error": 1.1920928955078125e-07,
7
+ "decisions_match": true,
8
+ "upstream": [
9
+ {
10
+ "type": "noul",
11
+ "p_yes": 0.9725584983825684
12
+ },
13
+ {
14
+ "type": "choice",
15
+ "probs": [
16
+ 0.9617580771446228,
17
+ 0.014839934185147285,
18
+ 0.02340202033519745
19
+ ],
20
+ "choice": 0
21
+ }
22
+ ],
23
+ "coreai": [
24
+ {
25
+ "type": "noul",
26
+ "p_yes": 0.9725583791732788
27
+ },
28
+ {
29
+ "type": "choice",
30
+ "probs": [
31
+ 0.9617581963539124,
32
+ 0.014839922077953815,
33
+ 0.023401914164423943
34
+ ],
35
+ "choice": 0
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "case": "span",
41
+ "tokens": 123,
42
+ "reference_precision": "float32",
43
+ "max_probability_or_box_error": 0,
44
+ "decisions_match": true,
45
+ "upstream": [
46
+ {
47
+ "type": "span",
48
+ "start": 55,
49
+ "end": 63,
50
+ "text": "INV-2231",
51
+ "p": 1.0
52
+ }
53
+ ],
54
+ "coreai": [
55
+ {
56
+ "type": "span",
57
+ "start": 55,
58
+ "end": 63,
59
+ "text": "INV-2231",
60
+ "p": 1.0
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "case": "score",
66
+ "tokens": 98,
67
+ "reference_precision": "float32",
68
+ "max_probability_or_box_error": 1.7285346984863281e-06,
69
+ "decisions_match": true,
70
+ "upstream": [
71
+ {
72
+ "type": "score",
73
+ "probs": [
74
+ 0.011814874596893787,
75
+ 0.012464769184589386,
76
+ 0.05883542448282242,
77
+ 0.9168848395347595
78
+ ],
79
+ "choice": 3,
80
+ "expected": 2.880790136754513
81
+ }
82
+ ],
83
+ "coreai": [
84
+ {
85
+ "type": "score",
86
+ "probs": [
87
+ 0.01181461475789547,
88
+ 0.012464579194784164,
89
+ 0.058834243565797806,
90
+ 0.916886568069458
91
+ ],
92
+ "choice": 3,
93
+ "expected": 2.880792770534754
94
+ }
95
+ ]
96
+ },
97
+ {
98
+ "case": "multi",
99
+ "tokens": 144,
100
+ "reference_precision": "float32",
101
+ "max_probability_or_box_error": 2.384185791015625e-07,
102
+ "decisions_match": true,
103
+ "upstream": [
104
+ {
105
+ "type": "multi",
106
+ "probs": [
107
+ 0.9698117971420288,
108
+ 0.9617175459861755,
109
+ 0.021801624447107315
110
+ ]
111
+ }
112
+ ],
113
+ "coreai": [
114
+ {
115
+ "type": "multi",
116
+ "probs": [
117
+ 0.9698116779327393,
118
+ 0.9617173075675964,
119
+ 0.021801499649882317
120
+ ]
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ "case": "negative",
126
+ "tokens": 83,
127
+ "reference_precision": "float32",
128
+ "max_probability_or_box_error": 1.825392246246338e-07,
129
+ "decisions_match": true,
130
+ "upstream": [
131
+ {
132
+ "type": "noul",
133
+ "p_yes": 0.050108104944229126
134
+ }
135
+ ],
136
+ "coreai": [
137
+ {
138
+ "type": "noul",
139
+ "p_yes": 0.0501079224050045
140
+ }
141
+ ]
142
+ },
143
+ {
144
+ "case": "unicode",
145
+ "tokens": 128,
146
+ "reference_precision": "float32",
147
+ "max_probability_or_box_error": 2.384185791015625e-07,
148
+ "decisions_match": true,
149
+ "upstream": [
150
+ {
151
+ "type": "span",
152
+ "start": 10,
153
+ "end": 17,
154
+ "text": "\u20ac123.45",
155
+ "p": 0.9975249171257019
156
+ },
157
+ {
158
+ "type": "choice",
159
+ "probs": [
160
+ 0.975895881652832,
161
+ 0.012953696772456169,
162
+ 0.011150466278195381
163
+ ],
164
+ "choice": 0
165
+ }
166
+ ],
167
+ "coreai": [
168
+ {
169
+ "type": "span",
170
+ "start": 10,
171
+ "end": 17,
172
+ "text": "\u20ac123.45",
173
+ "p": 0.9975249171257019
174
+ },
175
+ {
176
+ "type": "choice",
177
+ "probs": [
178
+ 0.9758961200714111,
179
+ 0.012953538447618484,
180
+ 0.011150323785841465
181
+ ],
182
+ "choice": 0
183
+ }
184
+ ]
185
+ },
186
+ {
187
+ "case": "options",
188
+ "tokens": 230,
189
+ "reference_precision": "float32",
190
+ "max_probability_or_box_error": 5.960464477539063e-08,
191
+ "decisions_match": true,
192
+ "upstream": [
193
+ {
194
+ "type": "choice",
195
+ "probs": [
196
+ 0.004064494278281927,
197
+ 0.0038580188993364573,
198
+ 0.004268646240234375,
199
+ 0.0035152093041688204,
200
+ 0.0033423479180783033,
201
+ 0.0027638780884444714,
202
+ 0.0034003546461462975,
203
+ 0.0028926823288202286,
204
+ 0.002699152799323201,
205
+ 0.002128811087459326,
206
+ 0.0030107321217656136,
207
+ 0.0031014070846140385,
208
+ 0.0031818263232707977,
209
+ 0.0027776346541941166,
210
+ 0.002535774139687419,
211
+ 0.003921506926417351,
212
+ 0.0034300386905670166,
213
+ 0.9298884868621826,
214
+ 0.003782431362196803,
215
+ 0.0032975035719573498,
216
+ 0.0014300656039267778,
217
+ 0.001529851695522666,
218
+ 0.0019381233723834157,
219
+ 0.0032409545965492725
220
+ ],
221
+ "choice": 17
222
+ }
223
+ ],
224
+ "coreai": [
225
+ {
226
+ "type": "choice",
227
+ "probs": [
228
+ 0.004064517095685005,
229
+ 0.00385798211209476,
230
+ 0.004268613178282976,
231
+ 0.0035152393393218517,
232
+ 0.0033423572313040495,
233
+ 0.0027638806495815516,
234
+ 0.0034003155305981636,
235
+ 0.0028926795348525047,
236
+ 0.0026991423219442368,
237
+ 0.00212881900370121,
238
+ 0.0030107346829026937,
239
+ 0.00310140079818666,
240
+ 0.0031818472780287266,
241
+ 0.002777676796540618,
242
+ 0.002535800449550152,
243
+ 0.003921480383723974,
244
+ 0.003430022159591317,
245
+ 0.9298885464668274,
246
+ 0.003782423911616206,
247
+ 0.0032975096255540848,
248
+ 0.0014300619950518012,
249
+ 0.0015298567013815045,
250
+ 0.0019381335005164146,
251
+ 0.0032409762497991323
252
+ ],
253
+ "choice": 17
254
+ }
255
+ ]
256
+ },
257
+ {
258
+ "case": "vision",
259
+ "tokens": 155,
260
+ "reference_precision": "float32",
261
+ "max_probability_or_box_error": 1.0192394256591797e-05,
262
+ "decisions_match": true,
263
+ "upstream": [
264
+ {
265
+ "type": "region",
266
+ "cell": 9,
267
+ "box": [
268
+ 0.1936,
269
+ 0.0,
270
+ 0.5538,
271
+ 0.8461
272
+ ],
273
+ "p": 0.5629251003265381
274
+ },
275
+ {
276
+ "type": "choice",
277
+ "probs": [
278
+ 0.9847151041030884,
279
+ 0.015284925699234009
280
+ ],
281
+ "choice": 0
282
+ }
283
+ ],
284
+ "coreai": [
285
+ {
286
+ "type": "region",
287
+ "cell": 9,
288
+ "box": [
289
+ 0.1936,
290
+ 0.0,
291
+ 0.5538,
292
+ 0.8461
293
+ ],
294
+ "p": 0.5629352927207947
295
+ },
296
+ {
297
+ "type": "choice",
298
+ "probs": [
299
+ 0.9847150444984436,
300
+ 0.015284995548427105
301
+ ],
302
+ "choice": 0
303
+ }
304
+ ]
305
+ },
306
+ {
307
+ "case": "image-option",
308
+ "tokens": 181,
309
+ "reference_precision": "float32",
310
+ "max_probability_or_box_error": 3.3527612686157227e-08,
311
+ "decisions_match": true,
312
+ "upstream": [
313
+ {
314
+ "type": "choice",
315
+ "probs": [
316
+ 0.9767023324966431,
317
+ 0.023297706618905067
318
+ ],
319
+ "choice": 0
320
+ }
321
+ ],
322
+ "coreai": [
323
+ {
324
+ "type": "choice",
325
+ "probs": [
326
+ 0.9767023324966431,
327
+ 0.02329767309129238
328
+ ],
329
+ "choice": 0
330
+ }
331
+ ]
332
+ }
333
+ ]
vision.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ dj�g��'��-+��&��zuh^���N���
vision.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f646ae2677fb9ba2793ec942d2b9ed82690199d7a75685ecdf4a84ea791f38f
3
+ size 1317394842
vision.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "assetVersion" : "2.0",
3
+ "creationDate" : "20261014T012236Z",
4
+ "producer" : "coreai-core 1.0.0b3"
5
+ }