maxffarrell commited on
Commit
5cb93ca
·
verified ·
1 Parent(s): b4a8569

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

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ 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
+ text.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
37
+ vision.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
DEPENDENCIES.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "repo": "jhu-clsp/ettin-encoder-400m",
4
+ "revision": "7662476d60abb071a5bd319c9f3074f3072c062d"
5
+ },
6
+ {
7
+ "repo": "google/siglip2-base-patch16-224",
8
+ "revision": "75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2"
9
+ }
10
+ ]
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,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: mukaddamzaid/amos-1-mini
4
+ tags: [coreai, core-ai, apple, aimodel, decisions, vision]
5
+ ---
6
+
7
+ # AMOS-1 mini — Apple Core AI
8
+
9
+ An independently converted AMOS-1 mini decision model. Neural execution (encoder, trained projections, vision tower and projector) runs through Apple Core AI `.aimodel` assets. Upstream Python packing, pooling, probability normalization and decoding run on the host using PyTorch tensors. This is a decision model, not a text generator.
10
+
11
+ ## Use
12
+
13
+ Requires macOS 27 with the Core AI Python runtime. Tested with `coreai-torch==0.4.3`, PyTorch 2.14.0, NumPy, Pillow, tokenizers and huggingface_hub. The converter warns that this PyTorch version is newer than its validated range.
14
+
15
+ ```python
16
+ import asyncio
17
+ from coreai_mini import AmosMini
18
+
19
+ async def main():
20
+ model = await AmosMini.load(".")
21
+ print(await model.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=())` also accepts upstream internal questions (`text`, `options`) and PIL images, including image options. The request API accepts base64 images in the original AMOS format.
32
+
33
+ ## Capacity and verification
34
+
35
+ Batch one, **512 combined tokens**, 224×224 images, FP32 weights. Requests exceeding capacity raise an error. This conversion does not provide the original 9,216-token capacity, state-cache acceleration, hosted API server, explain endpoint or benchmark claims. No iPhone or Neural Engine placement/performance has been verified. Core AI uses default specialization.
36
+
37
+ Nine reproducible smoke/parity cases cover noul, choice, score, multi, span, region, Unicode, 24 options, state images and image options. All tested choices, spans and region cells matched upstream. Maximum measured logit error was 0.000833. These smoke cases do not establish benchmark accuracy. `validation.json` contains exact results. `runtime-smoke.json` records inference with PyTorch Linear/Embedding/Conv2d execution disabled.
38
+
39
+ ## Reproduce and provenance
40
+
41
+ The original AMOS author is Zaid Mukaddam. AMOS source and weights are Apache-2.0; see `LICENSE`, `SOURCE.json`, and upstream [AMOS-1 mini](https://huggingface.co/mukaddamzaid/amos-1-mini). The encoder derives from jhu-clsp/ettin-encoder-400m and the frozen vision tower from google/siglip2-base-patch16-224; dependency revisions are in `DEPENDENCIES.json`. Conversion adds no restrictions.
42
+
43
+ Run `convert_mini.py --source PATH_TO_PINNED_AMOS_MINI --output OUTPUT`, then `validate_mini.py` and `smoke_mini.py` from the original workspace layout. Original serving sources are included in `serve/`. `SHA256SUMS` covers release files.
SHA256SUMS ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 630e9474f3de7d236bb679a3ce81e5d2beda5f925cba2b5d19f06268a42ca3a6 DEPENDENCIES.json
2
+ cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 LICENSE
3
+ 2c7ae2534f71c5b654eab75d7a974ebdb21ab137354bb3d0bd59aca2a736b8fe README.md
4
+ 9a27b3030743c234d6971b79592d0c39bf29f5e469ec406071b303a2f917a551 SOURCE.json
5
+ 13b2a7499c445d25eb9e6c7be9c242b30f7f88e2f6f1d70bc3b3becd50b0cc55 config.json
6
+ d720cb43ee361fb162de1c5b8fb0460a9fde9f5a5f7f463e415293efd9e954cf convert_mini.py
7
+ 6e91d7729b36183cbbb7df684d1601efcd8ef25858f9c93fa31bf96a1c720bb8 coreai_mini.py
8
+ 3a83da88f746a41c45a23f3fcb3306978191bea6d93177a9207c4c479599c456 manifest.json
9
+ 755a21be5ba30e8469e6625539a549883ecd974e36ef342a6ab0284f079d9b6f runtime-smoke.json
10
+ 9e17531efd289cf720a36688d943197f9baf9db7c4f743aa4951f6b6f7dbafda serve/README.md
11
+ 0579eb845ee7af463ec88cf627d99dd636466f2c12d840a7cbe183d8f17044fd serve/amos/__init__.py
12
+ ba094d9eaedd229a99b2bdcd35a371ffdeb9aaee258a342524cc19e2f85ec421 serve/amos/calibrate.py
13
+ 0dcacb27a05d31781f8310cbe6123b0ee351c62456cc6b5f8576b40b8ddad080 serve/amos/decoder.py
14
+ b4a5a606a2dee99eedf86cab50be759eca0908a318e4f3037ba92560d837bd67 serve/amos/encoder.py
15
+ f2efd7ef25453f7f2dc204dd247c86733a55697ec48714f30560df8ed2da1f23 serve/amos/facts.py
16
+ d4f4354f3e4d3b475b4c1f832a4a9741da757a368a1d2e0e74544d06009c2c27 serve/amos/model.py
17
+ 14f027c9e15c456abb0f264c07dae06afb7ace7b64270f2374e97eabeb04f993 serve/amos/packing.py
18
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 serve/amos/py.typed
19
+ b0523591c320a074a0ec0751b68ebc205aeedd090ff5e7ca10bda3fb94a07ef8 serve/amos/serve.py
20
+ 133d1e29cc64e371f5df1f0cb0328fd77b8b9093d8465319580cf5ea4af5d081 serve/amos/vision.py
21
+ bea3f98882a5d12c891aae4a5d75eda8c5a5436e88889d94b6a7d3d648d126f2 serve/pyproject.toml
22
+ 0d43aa4e58fe6131f4ed1f4bbcbcaa4a0815f8b1fe5331c7e96489700681136b serve/sources.json
23
+ 5ebfa87f58e3eaf3a7f9d6e493a906c1df59b3890f805d87859a52378644646b smoke_mini.py
24
+ a8d97fff1f3efd130401987198943d69c67202f47e7f029d8887fea06bbcf573 text.aimodel/main.hash
25
+ b330fa3eef9565b6d4d2743ab3f2c71e87bed2b1246f92d7d7702be7da4bff33 text.aimodel/main.mlirb
26
+ 4c017ad5ee82a62b803dd371fa4a1b5585839d1896aee649ca1c909480304292 text.aimodel/metadata.json
27
+ 9fd55248d51d33976b324fc11592e28071da7d41e0e9401dfb7082e30574b7b1 tokenizer.json
28
+ d733722602caf019fa2243033a62d890ac69339da38af6ddcbd99d79f07d6813 validate_mini.py
29
+ 72225fef1b5ebbdf0813c64b2bd63f491a77a36d06452eb77f9a50f7deae23aa validation.json
30
+ 046186ca6d28b322f5bc0d83cf26f9be271d2f1df6070f5c84c92e00ff9bb8c6 vision.aimodel/main.hash
31
+ 3c25d133ca87ec965d1e579e5027e9d71bc7aabe9356d98361806dddfd8ff596 vision.aimodel/main.mlirb
32
+ 65296e091507f9f0775a538bb5a28d21464522b3b8051cdd07e722a97dfb29ae vision.aimodel/metadata.json
SOURCE.json ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "repo": "mukaddamzaid/amos-1-mini",
3
+ "revision": "340d8af4bfbf420b2cec31b79d27a528958eaf3a",
4
+ "bytes": 1629805383,
5
+ "files": [
6
+ {
7
+ "path": ".gitattributes",
8
+ "size": 1519,
9
+ "lfs": null
10
+ },
11
+ {
12
+ "path": "LICENSE",
13
+ "size": 11358,
14
+ "lfs": null
15
+ },
16
+ {
17
+ "path": "README.md",
18
+ "size": 4150,
19
+ "lfs": null
20
+ },
21
+ {
22
+ "path": "assets/amos-1-mini.png",
23
+ "size": 94372,
24
+ "lfs": null
25
+ },
26
+ {
27
+ "path": "config.json",
28
+ "size": 600,
29
+ "lfs": null
30
+ },
31
+ {
32
+ "path": "model.pt",
33
+ "size": 1629560074,
34
+ "lfs": {
35
+ "size": 1629560074,
36
+ "sha256": "5e415a743b2d568bad8525b9bd85c73a511136eb6b7ef60f26d51134f83dadec",
37
+ "pointer_size": 135
38
+ }
39
+ },
40
+ {
41
+ "path": "serve/README.md",
42
+ "size": 2505,
43
+ "lfs": null
44
+ },
45
+ {
46
+ "path": "serve/amos/__init__.py",
47
+ "size": 42,
48
+ "lfs": null
49
+ },
50
+ {
51
+ "path": "serve/amos/calibrate.py",
52
+ "size": 1712,
53
+ "lfs": null
54
+ },
55
+ {
56
+ "path": "serve/amos/decoder.py",
57
+ "size": 43439,
58
+ "lfs": null
59
+ },
60
+ {
61
+ "path": "serve/amos/encoder.py",
62
+ "size": 7816,
63
+ "lfs": null
64
+ },
65
+ {
66
+ "path": "serve/amos/facts.py",
67
+ "size": 1356,
68
+ "lfs": null
69
+ },
70
+ {
71
+ "path": "serve/amos/model.py",
72
+ "size": 15106,
73
+ "lfs": null
74
+ },
75
+ {
76
+ "path": "serve/amos/packing.py",
77
+ "size": 10835,
78
+ "lfs": null
79
+ },
80
+ {
81
+ "path": "serve/amos/py.typed",
82
+ "size": 0,
83
+ "lfs": null
84
+ },
85
+ {
86
+ "path": "serve/amos/serve.py",
87
+ "size": 39697,
88
+ "lfs": null
89
+ },
90
+ {
91
+ "path": "serve/amos/vision.py",
92
+ "size": 6633,
93
+ "lfs": null
94
+ },
95
+ {
96
+ "path": "serve/pyproject.toml",
97
+ "size": 842,
98
+ "lfs": null
99
+ },
100
+ {
101
+ "path": "serve/sources.json",
102
+ "size": 1723,
103
+ "lfs": null
104
+ },
105
+ {
106
+ "path": "typed_decisions.json",
107
+ "size": 1604,
108
+ "lfs": null
109
+ }
110
+ ],
111
+ "checkpoint_sha256": "5e415a743b2d568bad8525b9bd85c73a511136eb6b7ef60f26d51134f83dadec"
112
+ }
config.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "repo": "jhu-clsp/ettin-encoder-400m",
3
+ "max_state": 8192,
4
+ "digits": false,
5
+ "score_rank": false,
6
+ "encoder": {
7
+ "vocab_size": 50368,
8
+ "hidden_size": 1024,
9
+ "intermediate_size": 2624,
10
+ "num_hidden_layers": 28,
11
+ "num_attention_heads": 16,
12
+ "global_attn_every_n_layers": 3,
13
+ "local_attention": 128,
14
+ "global_rope_theta": 160000.0,
15
+ "local_rope_theta": 160000.0,
16
+ "norm_eps": 1e-05,
17
+ "pad_token_id": 50283,
18
+ "cls_token_id": 50281,
19
+ "sep_token_id": 50282,
20
+ "model_type": "modernbert",
21
+ "compare_options": false,
22
+ "date_facts": false
23
+ },
24
+ "vision": "google/siglip2-base-patch16-224"
25
+ }
convert_mini.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproduce the AMOS mini Core AI export from pinned source files."""
2
+ import argparse,json,sys
3
+ from pathlib import Path
4
+ import torch
5
+ from coreai_torch import TorchConverter,get_decomp_table
6
+
7
+ class Text(torch.nn.Module):
8
+ def __init__(self,m):
9
+ super().__init__(); self.m=m
10
+ def forward(self,input_ids,position_ids,segment_ids,option_ids,visual_embeddings,visual_mask):
11
+ m=self.m
12
+ emb=torch.where(visual_mask,visual_embeddings,m.encoder.tok_embeddings(input_ids))
13
+ h=m.encoder(input_ids,position_ids,segment_ids,option_ids,inputs_embeds=emb)
14
+ return m.ptr_q(h),m.ptr_o(h),m.span_q(h),m.span_k(h),m.region_q(h),m.region_k(h),m.multi_bias
15
+
16
+ class Vision(torch.nn.Module):
17
+ def __init__(self,m):
18
+ super().__init__(); self.vision=m.vision; self.projector=m.projector
19
+ def forward(self,pixel_values):
20
+ v=self.vision
21
+ x=v.patch_embedding(pixel_values).flatten(2).transpose(1,2)+v.position_embedding
22
+ for b in v.blocks:x=b(x)
23
+ return self.projector(v.post_layernorm(x))
24
+
25
+ def save(m,args,path):
26
+ ep=torch.export.export(m,args).run_decompositions(get_decomp_table())
27
+ program=TorchConverter().add_exported_program(ep).to_coreai()
28
+ program.save_asset(path)
29
+ return list(ep.graph_signature.user_outputs)
30
+
31
+ def main():
32
+ p=argparse.ArgumentParser();p.add_argument('--source',default='upstream/amos-1-mini');p.add_argument('--output',default='bundles/amos-1-mini-CoreAI');p.add_argument('--length',type=int,default=512);a=p.parse_args()
33
+ source=Path(a.source).resolve();out=Path(a.output);out.mkdir(parents=True,exist_ok=True)
34
+ sys.path.insert(0,str(source/'serve'))
35
+ from amos.model import load_run
36
+ torch.set_num_threads(4)
37
+ m,codec=load_run(source);m.eval()
38
+ args=(torch.full((1,a.length),codec.pad,dtype=torch.int32),torch.arange(a.length,dtype=torch.int32)[None],torch.zeros(1,a.length,dtype=torch.int32),torch.zeros(1,a.length,dtype=torch.int32),torch.zeros(1,a.length,m.encoder.config.hidden_size),torch.zeros(1,a.length,1,dtype=torch.bool))
39
+ print('Exporting text',flush=True)
40
+ outputs=save(Text(m).eval(),args,out/'text.aimodel')
41
+ print('Exporting vision',flush=True)
42
+ vo=save(Vision(m).eval(),(torch.zeros(1,3,224,224),),out/'vision.aimodel')
43
+ (out/'manifest.json').write_text(json.dumps({'sequence_length':a.length,'hidden_size':m.encoder.config.hidden_size,'outputs':outputs,'vision_output':vo[0],'precision':'float32'},indent=2))
44
+ print('Saved',flush=True)
45
+ if __name__=='__main__':main()
coreai_mini.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core AI neural execution; upstream packing/decoding uses PyTorch tensors only."""
2
+ import json,math,sys
3
+ from pathlib import Path
4
+ import numpy as np
5
+ import torch
6
+ from coreai.runtime import AIModel,NDArray,SpecializationOptions
7
+
8
+ class AmosMini:
9
+ @classmethod
10
+ async def load(cls,bundle):
11
+ self=cls();self.root=Path(bundle).resolve();sys.path.insert(0,str(self.root/'serve'))
12
+ from amos.packing import Codec
13
+ cfg=json.loads((self.root/'config.json').read_text())
14
+ # Codec's loader accepts a locally pinned tokenizer through this scoped patch.
15
+ import amos.packing as packing
16
+ old=packing.hf_hub_download
17
+ packing.hf_hub_download=lambda repo,filename: str(self.root/filename)
18
+ try:self.codec=Codec(cfg['repo'],max_state=cfg['max_state'],digits=cfg.get('digits',False),score_rank=cfg.get('score_rank',False))
19
+ finally:packing.hf_hub_download=old
20
+ self.manifest=json.loads((self.root/'manifest.json').read_text())
21
+ self.text=await AIModel.load(self.root/'text.aimodel',SpecializationOptions.default());self.tf=self.text.load_function('main')
22
+ self.vision=await AIModel.load(self.root/'vision.aimodel',SpecializationOptions.default());self.vf=self.vision.load_function('main')
23
+ return self
24
+
25
+ async def raw(self,state,questions,images=()):
26
+ from amos.packing import pack,collate
27
+ from amos.vision import preprocess
28
+ feats=[]
29
+ async def feature(im):
30
+ pix=preprocess(im)[None].numpy()
31
+ out=await self.vf({'pixel_values':NDArray(pix)})
32
+ return torch.from_numpy(out[self.manifest['vision_output']].numpy()[0].copy())
33
+ # Pack expects pre-projection 196-cell features. Instead use placeholders;
34
+ # inject the Core AI projected 49-cell vectors directly into embedding slots.
35
+ for im in images:feats.append(await feature(im))
36
+ qs=[]
37
+ for q in questions:
38
+ q=dict(q)
39
+ if 'options' in q:
40
+ opts=[]
41
+ for o in q['options']:
42
+ if isinstance(o,str):opts.append(o)
43
+ else:opts.append(torch.zeros(196,768));feats.append(await feature(o))
44
+ q['options']=opts
45
+ qs.append(q)
46
+ p=pack(self.codec,state,qs,[torch.zeros(196,768) for _ in images],strict=True)
47
+ n=len(p);L=self.manifest['sequence_length'];d=self.manifest['hidden_size']
48
+ if n>L:raise ValueError(f'Request has {n} tokens; Core AI capacity is {L}.')
49
+ b=collate([p],self.codec.pad)
50
+ args={}
51
+ for src,dst,pad in [('ids','input_ids',self.codec.pad),('pos','position_ids',0),('seg','segment_ids',-1),('opt','option_ids',0)]:
52
+ arr=np.full((1,L),pad,dtype=np.int32);arr[0,:n]=getattr(p,src);args[dst]=NDArray(arr)
53
+ ve=np.zeros((1,L,d),np.float32);vm=np.zeros((1,L,1),bool)
54
+ for at,f in zip(p.image_at,feats):ve[0,at:at+49]=f.numpy();vm[0,at:at+49]=True
55
+ args['visual_embeddings']=NDArray(ve);args['visual_mask']=NDArray(vm)
56
+ result=await self.tf(args)
57
+ pq,po,sq,sk,rq,rk,bias=[torch.from_numpy(result[k].numpy().copy()) for k in self.manifest['outputs']]
58
+ Q=len(p.decide);O=len(b['o_q']);out={'opt':torch.full((Q,max(1,max(p.n_options or [0]))), -torch.inf)}
59
+ for i in range(O):
60
+ valid=b['option_of'][0]==i
61
+ pooled=po[0,:valid.shape[0]][valid].mean(0)
62
+ qi=int(b['o_q'][i]);rank=int(b['o_rank'][i]);at=p.decide[qi]
63
+ out['opt'][qi,rank]=(pq[0,at]*pooled).sum()/math.sqrt(d)+(bias if p.types[qi]=='multi' else 0)
64
+ spans=[i for i,t in enumerate(p.types) if t=='span'];regions=[i for i,t in enumerate(p.types) if t=='region']
65
+ out['span_q']=torch.tensor(spans);out['region_q']=torch.tensor(regions)
66
+ if spans:
67
+ q=sq[0,p.decide].view(Q,2,d)[spans];k=sk[0,:b['ids'].shape[1]].view(-1,2,d)
68
+ out['span']=torch.einsum('qcd,lcd->qcl',q,k)/math.sqrt(d)
69
+ out['span'].masked_fill_(~b['state_mask'][:,None,:],-torch.inf)
70
+ if regions:
71
+ out['region']=rq[0,[p.decide[i] for i in regions]]@rk[0,:b['ids'].shape[1]].T/math.sqrt(d)
72
+ out['region'].masked_fill_(~b['region_mask'],-torch.inf)
73
+ return out,[p]
74
+
75
+ async def predict(self,state,questions,images=()):
76
+ from amos.model import decode
77
+ out,ps=await self.raw(state,questions,images)
78
+ return decode(out,ps)[0]
79
+
80
+ async def request(self,req):
81
+ from amos.serve import parse,answer
82
+ from PIL import Image
83
+ import base64,io
84
+ state,ids,qs=parse(req)
85
+ def image(s):return Image.open(io.BytesIO(base64.b64decode(s.split(',')[-1])))
86
+ ims=[image(s) for s in req.get('images',[])]
87
+ 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]
88
+ results=await self.predict(state,qs,ims)
89
+ return {'answers':{i:answer(q,r) for i,q,r in zip(ids,qs,results)}}
manifest.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sequence_length": 512,
3
+ "hidden_size": 1024,
4
+ "outputs": [
5
+ "view_281",
6
+ "view_283",
7
+ "view_285",
8
+ "view_287",
9
+ "view_289",
10
+ "view_291",
11
+ "p_m_multi_bias"
12
+ ],
13
+ "vision_output": "view_198",
14
+ "precision": "float32"
15
+ }
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.916654646396637
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_mini.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio,json
2
+ from pathlib import Path
3
+ import torch
4
+ from coreai_mini import AmosMini
5
+ async def main():
6
+ def forbidden(*a,**kw):raise AssertionError('PyTorch neural execution is forbidden in runtime smoke')
7
+ torch.nn.Linear.forward=forbidden;torch.nn.Embedding.forward=forbidden;torch.nn.Conv2d.forward=forbidden
8
+ root=Path('bundles/amos-1-mini-CoreAI');m=await AmosMini.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))
12
+ print(out)
13
+ if __name__=='__main__':asyncio.run(main())
text.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ �0�>�e���t:�����ұ$o���p+��K�3
text.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b330fa3eef9565b6d4d2743ab3f2c71e87bed2b1246f92d7d7702be7da4bff33
3
+ size 1613036241
text.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "creationDate" : "20261014T011510Z",
3
+ "assetVersion" : "2.0",
4
+ "producer" : "coreai-core 1.0.0b3"
5
+ }
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
validate_mini.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio,json,sys,shutil
2
+ from pathlib import Path
3
+ import numpy as np
4
+ import torch
5
+ from PIL import Image
6
+ from huggingface_hub import hf_hub_download,HfApi
7
+ from coreai_mini import AmosMini
8
+
9
+ async def main():
10
+ root=Path('bundles/amos-1-mini-CoreAI');source=Path('upstream/amos-1-mini').resolve()
11
+ shutil.copytree(source/'serve',root/'serve',dirs_exist_ok=True)
12
+ for f in ['config.json','LICENSE']:shutil.copy2(source/f,root/f)
13
+ cfg=json.loads((source/'config.json').read_text());api=HfApi();deps=[]
14
+ for repo in [cfg['repo'],cfg['vision']]:
15
+ info=api.model_info(repo);deps.append({'repo':repo,'revision':info.sha})
16
+ if repo==cfg['repo']:shutil.copy2(hf_hub_download(repo,'tokenizer.json',revision=info.sha),root/'tokenizer.json')
17
+ (root/'DEPENDENCIES.json').write_text(json.dumps(deps,indent=2))
18
+ sys.path.insert(0,str(source/'serve'))
19
+ from amos.model import load_run,pack_request,decode
20
+ from amos.packing import collate
21
+ from amos.vision import preprocess
22
+ torch.set_num_threads(4);reference,codec=load_run(source)
23
+ ai=await AmosMini.load(root)
24
+ cases=[
25
+ ('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']}],[]),
26
+ ('span','The invoice total is £306.00 and the invoice number is INV-2231.',[{'type':'span','text':'Quote the invoice number.'}],[]),
27
+ ('score','The service is down for every customer.',[{'type':'score','text':'How severe is the outage?','options':['none','minor','major','complete']}],[]),
28
+ ('multi','I need a refund and a password reset.',[{'type':'multi','text':'Which topics apply?','options':['refund','password','delivery']}],[]),
29
+ ('negative','The customer explicitly says no refund is needed.',[{'type':'noul','text':'Is a refund requested?'}],[]),
30
+ ('unicode','José paid €123.45 for café supplies.',[{'type':'span','text':'Quote the amount paid.'},{'type':'choice','text':'Who paid?','options':['José','Maria','Bob']}],[]),
31
+ ('options','This is about category 17.',[{'type':'choice','text':'Which category?','options':[f'category {i}' for i in range(24)]}],[]),
32
+ ]
33
+ pixels=np.zeros((224,224,3),np.uint8);pixels[:,:112]=[255,0,0];pixels[:,112:]=[0,0,255];im=Image.fromarray(pixels)
34
+ cases += [('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]),('image-option','Find the red-blue image.',[{'type':'choice','text':'Which image matches?','options':[im,Image.new('RGB',(224,224),'green')]}],[])]
35
+ records=[]
36
+ for name,state,qs,ims in cases:
37
+ refqs=[{**q,'options':[preprocess(o) if isinstance(o,Image.Image) else o for o in q['options']]} if 'options' in q else q for q in qs]
38
+ with torch.no_grad():
39
+ ps=pack_request(reference,codec,state,refqs,[preprocess(x) for x in ims]);expected=reference(collate(ps,codec.pad));ed=decode(expected,ps)[0]
40
+ actual,aps=await ai.raw(state,qs,ims);ad=decode(actual,aps)[0]
41
+ errors={}
42
+ for key in ['opt','span','region']:
43
+ if key not in expected:continue
44
+ x=expected[key];y=actual[key];mask=torch.isfinite(x)
45
+ if not torch.equal(mask,torch.isfinite(y)):raise AssertionError((name,key,x.shape,y.shape,x,y))
46
+ errors[key]=float((x[mask]-y[mask]).abs().max()) if mask.any() else 0
47
+ if errors[key]>0.005:raise AssertionError((name,key,errors))
48
+ def decisions(rs):
49
+ return [{k:v for k,v in r.items() if k in ['type','text','start','end','cell','choice','index']} for r in rs]
50
+ if decisions(ed)!=decisions(ad):raise AssertionError((name,ed,ad))
51
+ rec={'case':name,'tokens':len(ps[0]),'max_logit_error':errors,'upstream':ed,'coreai':ad};records.append(rec);print(json.dumps(rec),flush=True)
52
+ (root/'validation.json').write_text(json.dumps(records,indent=2));print('PASS',flush=True)
53
+ if __name__=='__main__':asyncio.run(main())
validation.json ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "case": "refund",
4
+ "tokens": 45,
5
+ "max_logit_error": {
6
+ "opt": 2.6226043701171875e-06
7
+ },
8
+ "upstream": [
9
+ {
10
+ "type": "noul",
11
+ "p_yes": 0.94333815574646
12
+ },
13
+ {
14
+ "type": "choice",
15
+ "probs": [
16
+ 0.7296061515808105,
17
+ 0.016437293961644173,
18
+ 0.2539565861225128
19
+ ],
20
+ "choice": 0
21
+ }
22
+ ],
23
+ "coreai": [
24
+ {
25
+ "type": "noul",
26
+ "p_yes": 0.94333815574646
27
+ },
28
+ {
29
+ "type": "choice",
30
+ "probs": [
31
+ 0.7296063899993896,
32
+ 0.016437331214547157,
33
+ 0.25395628809928894
34
+ ],
35
+ "choice": 0
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "case": "span",
41
+ "tokens": 28,
42
+ "max_logit_error": {
43
+ "opt": 0,
44
+ "span": 1.52587890625e-05
45
+ },
46
+ "upstream": [
47
+ {
48
+ "type": "span",
49
+ "start": 21,
50
+ "end": 63,
51
+ "text": "\u00a3306.00 and the invoice number is INV-2231",
52
+ "p": 0.16281552612781525
53
+ }
54
+ ],
55
+ "coreai": [
56
+ {
57
+ "type": "span",
58
+ "start": 21,
59
+ "end": 63,
60
+ "text": "\u00a3306.00 and the invoice number is INV-2231",
61
+ "p": 0.16281506419181824
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ "case": "score",
67
+ "tokens": 27,
68
+ "max_logit_error": {
69
+ "opt": 3.0994415283203125e-06
70
+ },
71
+ "upstream": [
72
+ {
73
+ "type": "score",
74
+ "probs": [
75
+ 0.06895732879638672,
76
+ 0.02768923155963421,
77
+ 0.4086642265319824,
78
+ 0.49468913674354553
79
+ ],
80
+ "choice": 3,
81
+ "expected": 2.329085111618042
82
+ }
83
+ ],
84
+ "coreai": [
85
+ {
86
+ "type": "score",
87
+ "probs": [
88
+ 0.0689573660492897,
89
+ 0.027689190581440926,
90
+ 0.40866488218307495,
91
+ 0.49468863010406494
92
+ ],
93
+ "choice": 3,
94
+ "expected": 2.329084873199463
95
+ }
96
+ ]
97
+ },
98
+ {
99
+ "case": "multi",
100
+ "tokens": 25,
101
+ "max_logit_error": {
102
+ "opt": 2.86102294921875e-06
103
+ },
104
+ "upstream": [
105
+ {
106
+ "type": "multi",
107
+ "probs": [
108
+ 0.965849757194519,
109
+ 0.9332621097564697,
110
+ 0.07576480507850647
111
+ ]
112
+ }
113
+ ],
114
+ "coreai": [
115
+ {
116
+ "type": "multi",
117
+ "probs": [
118
+ 0.9658498764038086,
119
+ 0.9332619309425354,
120
+ 0.07576475292444229
121
+ ]
122
+ }
123
+ ]
124
+ },
125
+ {
126
+ "case": "negative",
127
+ "tokens": 22,
128
+ "max_logit_error": {
129
+ "opt": 1.6093254089355469e-06
130
+ },
131
+ "upstream": [
132
+ {
133
+ "type": "noul",
134
+ "p_yes": 0.077876016497612
135
+ }
136
+ ],
137
+ "coreai": [
138
+ {
139
+ "type": "noul",
140
+ "p_yes": 0.07787591218948364
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ "case": "unicode",
146
+ "tokens": 32,
147
+ "max_logit_error": {
148
+ "opt": 9.775161743164062e-06,
149
+ "span": 1.1444091796875e-05
150
+ },
151
+ "upstream": [
152
+ {
153
+ "type": "span",
154
+ "start": 10,
155
+ "end": 17,
156
+ "text": "\u20ac123.45",
157
+ "p": 0.44328346848487854
158
+ },
159
+ {
160
+ "type": "choice",
161
+ "probs": [
162
+ 0.896871030330658,
163
+ 0.03730686381459236,
164
+ 0.06582211703062057
165
+ ],
166
+ "choice": 0
167
+ }
168
+ ],
169
+ "coreai": [
170
+ {
171
+ "type": "span",
172
+ "start": 10,
173
+ "end": 17,
174
+ "text": "\u20ac123.45",
175
+ "p": 0.44328081607818604
176
+ },
177
+ {
178
+ "type": "choice",
179
+ "probs": [
180
+ 0.8968717455863953,
181
+ 0.03730650246143341,
182
+ 0.06582175195217133
183
+ ],
184
+ "choice": 0
185
+ }
186
+ ]
187
+ },
188
+ {
189
+ "case": "options",
190
+ "tokens": 85,
191
+ "max_logit_error": {
192
+ "opt": 4.410743713378906e-06
193
+ },
194
+ "upstream": [
195
+ {
196
+ "type": "choice",
197
+ "probs": [
198
+ 0.021480435505509377,
199
+ 0.012768608517944813,
200
+ 0.014833449386060238,
201
+ 0.02430936135351658,
202
+ 0.015337659977376461,
203
+ 0.014616258442401886,
204
+ 0.01716616190969944,
205
+ 0.019972851499915123,
206
+ 0.018173672258853912,
207
+ 0.02504866011440754,
208
+ 0.026172664016485214,
209
+ 0.02132749743759632,
210
+ 0.024641145020723343,
211
+ 0.023125993087887764,
212
+ 0.0218864344060421,
213
+ 0.020913584157824516,
214
+ 0.0224751066416502,
215
+ 0.5114471316337585,
216
+ 0.029352843761444092,
217
+ 0.027540743350982666,
218
+ 0.024416472762823105,
219
+ 0.020348869264125824,
220
+ 0.019210753962397575,
221
+ 0.023433661088347435
222
+ ],
223
+ "choice": 17
224
+ }
225
+ ],
226
+ "coreai": [
227
+ {
228
+ "type": "choice",
229
+ "probs": [
230
+ 0.021480349823832512,
231
+ 0.01276865229010582,
232
+ 0.014833447523415089,
233
+ 0.02430935949087143,
234
+ 0.015337690711021423,
235
+ 0.014616241678595543,
236
+ 0.017166148871183395,
237
+ 0.019972823560237885,
238
+ 0.01817365735769272,
239
+ 0.025048643350601196,
240
+ 0.02617262303829193,
241
+ 0.021327510476112366,
242
+ 0.024641163647174835,
243
+ 0.023125983774662018,
244
+ 0.021886436268687248,
245
+ 0.02091355249285698,
246
+ 0.022474996745586395,
247
+ 0.5114476680755615,
248
+ 0.029352732002735138,
249
+ 0.027540666982531548,
250
+ 0.02441641129553318,
251
+ 0.020348837599158287,
252
+ 0.01921072229743004,
253
+ 0.023433679714798927
254
+ ],
255
+ "choice": 17
256
+ }
257
+ ]
258
+ },
259
+ {
260
+ "case": "vision",
261
+ "tokens": 78,
262
+ "max_logit_error": {
263
+ "opt": 1.4901161193847656e-05,
264
+ "region": 0.0008322000503540039
265
+ },
266
+ "upstream": [
267
+ {
268
+ "type": "region",
269
+ "cell": 12,
270
+ "box": [
271
+ 0.7142857313156128,
272
+ 0.1428571492433548,
273
+ 0.8571428656578064,
274
+ 0.2857142984867096
275
+ ],
276
+ "p": 0.10726314783096313,
277
+ "heatmap": [
278
+ 0.03254103288054466,
279
+ 0.040585536509752274,
280
+ 0.01753769814968109,
281
+ 0.008257847279310226,
282
+ 0.011045021004974842,
283
+ 0.00599908921867609,
284
+ 0.004235319793224335,
285
+ 0.0024749385192990303,
286
+ 0.017212174832820892,
287
+ 0.005958267953246832,
288
+ 0.0028916022274643183,
289
+ 0.07630306482315063,
290
+ 0.10726314783096313,
291
+ 0.016866952180862427,
292
+ 0.01265009492635727,
293
+ 0.026430141180753708,
294
+ 0.0291758943349123,
295
+ 0.01452744659036398,
296
+ 0.01801556907594204,
297
+ 0.030956637114286423,
298
+ 0.022234106436371803,
299
+ 0.01916903629899025,
300
+ 0.015629546716809273,
301
+ 0.02098844386637211,
302
+ 0.028073374181985855,
303
+ 0.03741556778550148,
304
+ 0.02767399698495865,
305
+ 0.03067740611732006,
306
+ 0.01079087145626545,
307
+ 0.04043445363640785,
308
+ 0.05705111101269722,
309
+ 0.02413317747414112,
310
+ 0.02471594698727131,
311
+ 0.01658044196665287,
312
+ 0.012068545445799828,
313
+ 0.003539793426170945,
314
+ 0.014276671223342419,
315
+ 0.008514068089425564,
316
+ 0.007855590432882309,
317
+ 0.005705713294446468,
318
+ 0.003973916172981262,
319
+ 0.001432417775504291,
320
+ 0.0023278759326785803,
321
+ 0.010151573456823826,
322
+ 0.027151266112923622,
323
+ 0.019837509840726852,
324
+ 0.010044840164482594,
325
+ 0.0030451989732682705,
326
+ 0.013580089434981346
327
+ ]
328
+ },
329
+ {
330
+ "type": "choice",
331
+ "probs": [
332
+ 0.6527983546257019,
333
+ 0.3472016155719757
334
+ ],
335
+ "choice": 0
336
+ }
337
+ ],
338
+ "coreai": [
339
+ {
340
+ "type": "region",
341
+ "cell": 12,
342
+ "box": [
343
+ 0.7142857313156128,
344
+ 0.1428571492433548,
345
+ 0.8571428656578064,
346
+ 0.2857142984867096
347
+ ],
348
+ "p": 0.10718144476413727,
349
+ "heatmap": [
350
+ 0.032547224313020706,
351
+ 0.04059035703539848,
352
+ 0.017539363354444504,
353
+ 0.008259006775915623,
354
+ 0.01104627177119255,
355
+ 0.005999898537993431,
356
+ 0.004236394539475441,
357
+ 0.002475050510838628,
358
+ 0.017213277518749237,
359
+ 0.005958556197583675,
360
+ 0.0028917340096086264,
361
+ 0.07630617171525955,
362
+ 0.10718144476413727,
363
+ 0.01685546711087227,
364
+ 0.012653937563300133,
365
+ 0.02643437869846821,
366
+ 0.029178880155086517,
367
+ 0.014528198167681694,
368
+ 0.01801961474120617,
369
+ 0.03096183016896248,
370
+ 0.02223971113562584,
371
+ 0.019169794395565987,
372
+ 0.015628913417458534,
373
+ 0.02098875492811203,
374
+ 0.028077254071831703,
375
+ 0.03741911053657532,
376
+ 0.027675963938236237,
377
+ 0.03067953512072563,
378
+ 0.010791921056807041,
379
+ 0.04043751209974289,
380
+ 0.05705570429563522,
381
+ 0.024136871099472046,
382
+ 0.02471994049847126,
383
+ 0.0165830347687006,
384
+ 0.012070251628756523,
385
+ 0.003540291218087077,
386
+ 0.01427785586565733,
387
+ 0.00851534865796566,
388
+ 0.007856183685362339,
389
+ 0.0057067349553108215,
390
+ 0.003974433988332748,
391
+ 0.001432594726793468,
392
+ 0.0023280533496290445,
393
+ 0.010152487084269524,
394
+ 0.027152718976140022,
395
+ 0.019839033484458923,
396
+ 0.010046061128377914,
397
+ 0.0030458515975624323,
398
+ 0.01358106080442667
399
+ ]
400
+ },
401
+ {
402
+ "type": "choice",
403
+ "probs": [
404
+ 0.6527963280677795,
405
+ 0.34720364212989807
406
+ ],
407
+ "choice": 0
408
+ }
409
+ ]
410
+ },
411
+ {
412
+ "case": "image-option",
413
+ "tokens": 119,
414
+ "max_logit_error": {
415
+ "opt": 3.2901763916015625e-05
416
+ },
417
+ "upstream": [
418
+ {
419
+ "type": "choice",
420
+ "probs": [
421
+ 0.2963208258152008,
422
+ 0.7036792039871216
423
+ ],
424
+ "choice": 1
425
+ }
426
+ ],
427
+ "coreai": [
428
+ {
429
+ "type": "choice",
430
+ "probs": [
431
+ 0.2963253855705261,
432
+ 0.7036746144294739
433
+ ],
434
+ "choice": 1
435
+ }
436
+ ]
437
+ }
438
+ ]
vision.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ <%�3ʇ�]W�P'��Ǫ��Vكa�m�����
vision.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c25d133ca87ec965d1e579e5027e9d71bc7aabe9356d98361806dddfd8ff596
3
+ size 360088096
vision.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "assetVersion" : "2.0",
3
+ "producer" : "coreai-core 1.0.0b3",
4
+ "creationDate" : "20261014T011512Z"
5
+ }