ksingla025 commited on
Commit
827e8a5
·
verified ·
1 Parent(s): 7e0720a

Arc Tagger v0.1.0: AT10Mv2 step88000 + refit thresholds + arctag package

Browse files
LICENSE-arc_audio ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
MANIFEST.sha256 ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 432e1c0db00dca1695c84c2e0b05f9b1f6f23a45acbc1bc4173a2669f9fef9a2 ./arctag/_core/arc_audio/__init__.py
2
+ fabe47fdbed606eb9bb17cd1f8cb809d4b0e765b3bc45c8aa0b7ad30f14a6026 ./arctag/_core/arc_audio/mamba3/__init__.py
3
+ 640a5d529a810de1cddc0919b4e8744bfc5642257ce1abf6e4e65d74a54fc7c2 ./arctag/_core/arc_audio/mamba3/layer.py
4
+ 96fb2a6fba2b0aed4fed121a95bb9307adf319afdff9a897d93fc1007f897de4 ./arctag/_core/arc_audio/mamba3/scan.py
5
+ e5a5b7f94c059f01250a5e8af1063d7aaf98f338b8596048884d018485c65a90 ./arctag/_core/arc_audio/mamba3/ssd.py
6
+ d86b6d7b6ee1653bfd91dff4c4170b30244650ccf992cf8e8f67ea3ae9c9009f ./arctag/_core/arc_audio/model.py
7
+ 365ae9423a5fe89484178cb2823ab594ce58fc85f4df85a1a2019bb1c3568724 ./arctag/_core/arc_audio/ontology.py
8
+ aced3f28bd6f821e2edf522f37c127a8370ca54f508a9ed05d6206447993bcd6 ./arctag/_core/arc_audio/wordch.py
9
+ 99497a4f0ae7ed568f593c472b1edd1cf95fcd09a729c7ae666936bf868e13d1 ./arctag/_core/arc_text.py
10
+ 427234a679197cd846d13e4e9b293d34492d06814aafbbd842c527fddc162450 ./arctag/_core/engine.py
11
+ abad828a99d8e961277fdc3ce3f9c8af9fa09b61289f4882dde257efef832e17 ./arctag/_core/fast_arc.py
12
+ 02bd73a10018f86d788f8784cd12fd10e19836e95975362fbcb0d9e9dfc90381 ./arctag/_core/__init__.py
13
+ 3d3fb0551279f43413801a2e03148121f960097cab72124132ebe6a8395d4f9f ./arctag/_core/kb.py
14
+ 71352e7c576d76458431a24ee6df6ebaa95919f79fa3ad9ba66f1f7e37103b0d ./arctag/_core/kb_strfeat.py
15
+ 0ff792d9926e12a99954a0f7a1705ae2dacb2f95827f9e5ba2e334f4755881d7 ./arctag/_core/losses.py
16
+ 355e635e6935d60906ab7ee51b7921b28509bb99b4dccc89b69f9a450d3f584f ./arctag/_core/speaker_link.py
17
+ 7799029e19b871b08949af23b2bd1c25da4c720e807fbdfa6c9776b95e43ebae ./arctag/_core/stream_arc.py
18
+ 6804a7d18aa7faa6b632537e9478a95a03bfcc8c7625d044c40acea96c770284 ./arctag/_core/tagtable.py
19
+ 552c4ace1562ba31a532a86afc0fe57f7c80ce95cc72b539e2ea6c5b2736c6b6 ./arctag/_core/v2.py
20
+ 0b24e8f43145c878966f722ebfdc55e1e937c3005301d4d284306bc1eedc8f50 ./arctag/driver.py
21
+ 09b3d76601a0756ef761628112f26886cff287113024605d34eef3d1936a4a01 ./arctag/__init__.py
22
+ 505e9b93f1895cf4e29374c11041fe5316b7c44e226f63000a1945910c0d9109 ./config.json
23
+ d11cc9c244b81636f087e2165bfea9e7c3fb6d8c09a1975cb17ea3293f8d7370 ./demo_settings.json
24
+ d28c8d84e9d3829cbb2dd5b2d324812aeab248691d771d5b4b82cb9cd5ce3648 ./example.py
25
+ c45bd62ce519bcc479834b7d94396f1f29dee0b606aeff7e29adadacf6342383 ./label_norm.json
26
+ c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./LICENSE-arc_audio
27
+ e80f2c903d3f087c045c0f9482718533c7cf2d9d42817cfea5943fad72935ed8 ./model.safetensors
28
+ 503a2a7e125b7311b3198a1d8b4a37901377093c85b281def26100d2b6b56939 ./ontology/ext_cc_roles_E.npy
29
+ 9040786b540fcbfbc9137abf81a05e972e9380ae0c208bb55fae9c459aa4b97f ./ontology/ext_cc_roles.json
30
+ 05cc12d2bdafa7ef463797ca2997ecda2dd914eaf4718a86959f45f0a8840f96 ./ontology/head_E_r8_bge_small.npy
31
+ 2a2d312fcea8a6082fe8035e1e35d44ff425ed26bb14a7841205bb6054c845ea ./ontology/tags_v1_r8.json
32
+ ecabaf702a9ad51efc739a44d6c5b5ecc92403c62d5ea365f1970cc466f2752a ./ontology/text_tags.json
33
+ 90da9da1a2da0eac47fa6ea441b29179e7100ccbe222beb3d979257414a01e91 ./README.md
34
+ 174b3daab120ee5ba78a21f811f732f923b40fdfa95b5b12fd6adcedd4ab2369 ./requirements.txt
35
+ 20037d4fa059a7efe37acd4ba1c334a13c9c930c53ee116c0dcde185f21d5537 ./thresholds/events_step88000.json
36
+ 9297c10a69e3b17a360c4b1f6f62050b247fc74ca28e3cd182804fdea9756300 ./thresholds/step88000.json
37
+ 6dc720dd382f90cc514b18bbda9b1c8e083b0cba606bf3ab5129d98bc9389d3b ./tok/bpe8k/tokenizer_config.json
38
+ 2ae4623fc283d9b25b83e9efb9bf376f24c8974a01a2bbbe6cf8b0b29ac6fc35 ./tok/bpe8k/tokenizer.json
README.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: other
5
+ library_name: pytorch
6
+ pipeline_tag: token-classification
7
+ tags:
8
+ - streaming
9
+ - asr
10
+ - ner
11
+ - pii
12
+ - redaction
13
+ - intent
14
+ - dialogue-act
15
+ - mamba
16
+ ---
17
+
18
+ # Arc Tagger (10M), v0.1.0
19
+
20
+ Arc Tagger is a **text-only, streaming tagger** with about 9.8 M parameters. It reads the word stream of **any ASR** (Deepgram, Nemotron,
21
+ Whisper, our own ASR, or plain text). As each word arrives, it emits entity and number spans, PII spans for redaction, profanity and
22
+ toxicity, discourse acts, intents, safety flags, speaker roles, events (speaker change, turn yield, sports, legal and meeting events),
23
+ content state and emotion (experimental).
24
+
25
+ * Checkpoint: `AT10Mv2` step 88000. It scored best on all gold sets of its run.
26
+ * Architecture: a word channel (8k byte-level BPE, 2 causal local-attention layers) feeds a 6-layer Mamba-3 trunk. Global KV-memory
27
+ attention layers, domain FiLM and adapters condition the trunk. The read-outs are definition-keyed tag heads plus a v2 span head (BIO
28
+ and type per family), a span verifier and pooled utterance, speaker and item heads. The model is causal and runs one recurrent step
29
+ per word, so a revision re-runs only the revised words.
30
+ * Runs on CPU. Measured on one core (busy shared box, 1 thread): **engine about 16 ms per word p50, 25 ms p95**. A full
31
+ `push_words()` call, including span decoding and read-outs, takes about 24 ms p50 and 45 ms p95.
32
+
33
+ ## Tag families
34
+
35
+ 276 tags across 10 read-outs. Every tag name comes from the Whissle ontology r8 plus the extension `ext_cc_roles1`.
36
+
37
+ | family | tags (examples) | read-out |
38
+ |---|---|---|
39
+ | Entities | `entity.mention.{person,organization,team,place,event_name,legal_ref,term,...}` (10) | word spans |
40
+ | Numbers | `entity.number.{date,time_of_day,duration,count,money,percent,measure,score,clock,ordinal,id_pii,...}` (16), `entity.numeral` | word spans |
41
+ | PII / redaction | `safety.pii.{private_name,phone_number,email,street_address,card_account_number,gov_id,date_of_birth,url_handle,public_contact}` spans; `safety.pii.{request,leak}` per utterance | spans and utterance |
42
+ | Profanity / toxicity | `safety.profanity.{mild,strong}`, `safety.toxicity.*` (6) | spans and utterance |
43
+ | Safety | `safety.scam.*` (6), `safety.crisis.self_harm`, `safety.threat.escalation`, `safety.child.*`, `safety.solicitation.dating_romantic` | utterance |
44
+ | Discourse act | `discourse.act.*` (statement, question, command_request, ...), `discourse.answer`, `discourse.backchannel`, `discourse.assistant.*` | utterance |
45
+ | Intent | 26 cross-domain `intent.*` (request_information, schedule_arrange, complain_criticize, ...), 60 assistant `discourse.intent.*` (alarm_set, weather_query, ...) | utterance |
46
+ | Speaker role | `speaker.role.*` (19, including contact-centre agent and customer) | per speaker (needs `spk` ids on the words) |
47
+ | Events | `speaker.change`, `discourse.turn_yield`, `discourse.interruption`, `event.sports.*` (18), `event.legal.*`, `event.meeting.{decision,action_item}` | per word |
48
+ | Content state | `content.mode`, `content.subject`, `content.game_state`, `content.legal_phase`, `content.meeting_phase` | per word (frame) |
49
+ | Emotion (experimental) | `affect.emotion.*` (5), `affect.arousal.*` (3) | utterance; low support, mostly "neutral" |
50
+
51
+ `ontology/` holds the full definitions. `config.json["columns"]` lists every `tag@readout` column.
52
+
53
+ ## Quickstart
54
+
55
+ ```bash
56
+ pip install huggingface_hub
57
+ huggingface-cli download WhissleAI/arc-tagger --local-dir arc-tagger # private repo: huggingface-cli login first
58
+ cd arc-tagger
59
+ pip install -r requirements.txt # CPU torch is fine: pip install torch --index-url https://download.pytorch.org/whl/cpu
60
+ python example.py
61
+ ```
62
+
63
+ ```python
64
+ import sys; sys.path.insert(0, "arc-tagger") # the package ships in the repo (arctag/)
65
+ from arctag import ArcTagger
66
+
67
+ tg = ArcTagger.from_pretrained("arc-tagger") # a local dir, or a repo id: ArcTagger.from_pretrained("WhissleAI/arc-tagger")
68
+ out = tg.tag("Hi, this is Maria from Northwind Bank. Can I get your phone number?", domain="talk")
69
+ print(out["redacted_text"]); print(out["spans"]); print(out["utterances"])
70
+
71
+ # streaming (words as the ASR emits them; interim hypotheses can be revised)
72
+ st = tg.stream(domain="assistant")
73
+ st.push_words(["set", "an", "alarm", "for"], final=False) # interim
74
+ up = st.push_words(["set", "an", "alarm", "for", "seven", "thirty."], revise_from=0, final=True)
75
+ up = st.endpoint() # end of utterance -> utterance read-outs in up["utterances"]
76
+ res = st.finalize() # full result (same schema as tag())
77
+ ```
78
+
79
+ ### Input
80
+
81
+ `tag(x, domain="auto", tier="T0")`
82
+ * `x` can be a `str`, a list of words, or a list of word dicts `{"w": str, "t0": s, "t1": s, "conf": float, "spk": str|int, "final": bool}`.
83
+ Every field except `w` is optional.
84
+ * Utterances end after words ending in `. ? !`. In streaming, call `endpoint()` at the ASR's end of utterance or end of turn.
85
+
86
+ `domain` is one of `auto | talk | meeting | court | assistant | sports | news`.
87
+ * It conditions the model through domain FiLM and adapters, and picks the per-domain thresholds.
88
+ * `talk` covers contact centre, podcasts and chat. Inside `sports` and `court`, the specific league or court type is detected from
89
+ lexicon cues.
90
+ * `auto` runs the "unknown" domain, which is weaker. **Set the domain when you know it.**
91
+
92
+ `tier`
93
+ * `T0` (default; 0 words of lookahead; the thresholds were fitted on it)
94
+ * `T1` (at most 3 words of delay)
95
+ * `T2` (utterance end)
96
+
97
+ Streaming: `push_words(words, final=True, revise_from=None, endpoint=False)`
98
+ * `revise_from=k` replaces `words[k:]`. Use it for interim hypotheses such as Deepgram's `is_final=false` or a growing RNNT hypothesis.
99
+
100
+ ### Output (JSON)
101
+
102
+ ```jsonc
103
+ {
104
+ "meta": {"model": "arc-tagger", "version": "0.1.0", "step": 88000, "domain": "talk", "fine_domain": "talk", "tier": "T0", "thresholds": "..."},
105
+ "words": ["Hi,", "this", "is", "Maria", ...],
106
+ "spans": [ // all decoded spans (word indices [start, end))
107
+ {"tag": "safety.pii.private_name", "family": "pii", "start": 3, "end": 4, "text": "Maria", "p": 0.97, "thr": 0.42,
108
+ "alt": {"tag": "...", "p": 0.01}, // runner-up type
109
+ "ver": 0.99, // span verifier probability
110
+ "src": "head"} // decoded by the v2 span head
111
+ ],
112
+ "entities": [...], "pii": [...], "profanity_toxicity": [...], // spans split by family
113
+ "redacted_text": "Hi, this is [NAME] from ...", // PII / profanity / toxicity spans masked (public_contact is not masked)
114
+ "utterances": [
115
+ {"start": 0, "end": 8, "text": "...",
116
+ "intent": ["intent.request_information"], "act": ["discourse.act.question"], "safety": ["safety.pii.request"],
117
+ "role": [], "role_note": "speaker role needs diarization (no speaker id on words)",
118
+ "item": [], "state": {"content.mode": ["content.mode.call_in"]},
119
+ "emotion": [{"tag": "affect.emotion.neutral", "p": 0.93, "thr": 0.6, "on": true}, ...],
120
+ "scores": {"intent": [{"tag": ..., "p": ..., "thr": ..., "on": ...}], "act": [...], ...}} // top-k with probabilities
121
+ ],
122
+ "events": [{"tag": "speaker.change", "at": 15, "word": "Yeah,", "p": 0.94, "thr": 0.3, "tier": "T0", "read": "ev_rec"}]
123
+ }
124
+ ```
125
+
126
+ The label lists (`intent`, `act`, `safety`, ...) contain the tags at or above their threshold (`on`). `scores` keeps the top-k with
127
+ their probabilities.
128
+
129
+ Each streaming update (`push_words` and `endpoint`) returns:
130
+ * `n_words` and `changed_from`
131
+ * `spans`: the re-decoded window; replace your spans that end after its start
132
+ * `events` (new ones only)
133
+ * `utterances` (the ones that just ended)
134
+ * `running`: the live intent, act, safety and state of the open utterance
135
+
136
+ ## Latency tiers
137
+
138
+ | tier | lookahead | use |
139
+ |---|---|---|
140
+ | T0 | 0 words | live display and zero-lag redaction (default; thresholds fitted here) |
141
+ | T1 | up to 3 words | revisions a few words later |
142
+ | T2 | to utterance end (up to 8 words) | final utterance labels |
143
+
144
+ All three tiers are delayed read-outs of one causal pass, so they add no extra compute. Utterance-level labels (intent, act, safety,
145
+ role) are read at the endpoint.
146
+
147
+ ## Evaluation
148
+
149
+ Internal multi-domain dev and test sets cover contact centre, assistant, meetings, news, talk, sports and courts, using real and
150
+ simulated ASR (Deepgram and RNNT word streams).
151
+
152
+ | metric | old gold | cleaned gold |
153
+ |---|---|---|
154
+ | light eval, primary score (step 88000) | 0.693 | 0.764 |
155
+ | test, T0 macro AP | 0.719 | 0.793 |
156
+
157
+ **Caveat:** "cleaned gold" re-adjudicated the eval labels after a confident-learning label audit. The model selected the suspect
158
+ labels and Claude adjudicated them. The same audit also cleaned part of the training data, so cleaned-gold numbers are partly
159
+ favourable to this model family. Treat **old-gold numbers as the conservative estimate**. These are internal benchmarks, not public
160
+ leaderboards.
161
+
162
+ ## Files
163
+
164
+ | file | what |
165
+ |---|---|
166
+ | `model.safetensors` | weights (fp32, 45 MB) |
167
+ | `config.json` | model, ontology and read-out columns; training args with data paths removed |
168
+ | `tok/bpe8k/` | 8k byte-level BPE tokenizer (words are fed as `" " + word`) |
169
+ | `ontology/` | r8 ontology, text-tag table, `ext_cc_roles` extension, frozen tag-definition embeddings (bge-small) |
170
+ | `thresholds/step88000.json` | display thresholds: a precision-first T0 refit on the cleaned dev split, per tag, read-out and domain |
171
+ | `thresholds/events_step88000.json` | display tier, thresholds and hidden list for each event |
172
+ | `demo_settings.json` | the live demo's engine floors and settings (span threshold of at least .3; toxicity of at least .5 outside safety verticals) |
173
+ | `label_norm.json` | label-normalisation conventions behind the tags (training-time; pii1sn3) |
174
+ | `arctag/` | Python package: `ArcTagger`, `driver.py` (session to JSON), `_core/` (the demo's streaming engine and model code, vendored with only file paths changed) |
175
+ | `onnx/` | ONNX export for the C API, if present (published separately) |
176
+ | `MANIFEST.sha256` | checksums |
177
+
178
+ ## Limitations
179
+
180
+ * **English only.** Non-English input gives garbage-like output.
181
+ * Text only: no audio cues. The model cannot detect speaker identity, emotion from voice or vocal events. **Speaker roles need speaker
182
+ ids** (`spk`) on the words, for example from ASR diarization.
183
+ * Utterance-level safety and scam flags can fire on short or out-of-domain utterances; use `scores` (p) and your own policy for
184
+ anything consequential. Emotion is low-support and mostly predicts neutral.
185
+ * The `auto` domain is weaker than setting the domain. Some sports events fire loosely outside sports.
186
+ * Redaction here masks the decoded spans after the fact. The live demo's zero-lag redactor (carrier-phrase pre-arm, regex rules, hold
187
+ modes) is not included.
188
+ * Thresholds are fitted for precision first on internal dev data. Refit them for your traffic if you can.
189
+ * CPU inference only in this package, which is fp32. The ONNX export, if present, is the path for C and embedded use.
190
+
191
+ ## Licence
192
+
193
+ Weights: WhissleAI, private release (`license: other`). The vendored `arctag/_core/arc_audio` subset is from WhissleAI Arc 1.4.2,
194
+ licensed under Apache-2.0 (see `LICENSE-arc_audio`).
arctag/__init__.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc Tagger: text-only streaming tagger (~9.8 M params) for ASR word streams.
2
+
3
+ from arctag import ArcTagger
4
+ tg = ArcTagger.from_pretrained(".") # or a Hugging Face repo id
5
+ out = tg.tag("Hi, this is Dana from Acme. Can you move my appointment to Friday?")
6
+
7
+ st = tg.stream(domain="talk") # streaming: words in as the ASR emits them
8
+ st.push_words(["hi", "this", "is"], final=False) # interim
9
+ st.push_words(["hi,", "this", "is", "Dana."], revise_from=0, final=True)
10
+ st.endpoint()
11
+ res = st.finalize()
12
+
13
+ CPU only. The model code is the demo's streaming engine (arctag/_core, vendored unchanged except for file paths).
14
+ """
15
+ import importlib.util, json, os, sys
16
+
17
+ __version__ = "0.1.0"
18
+ _HERE = os.path.dirname(os.path.abspath(__file__))
19
+ _CORE = os.path.join(_HERE, "_core")
20
+ _EN = None
21
+
22
+
23
+ def _engine(home):
24
+ """import the vendored engine once (module-level path constants resolve against `home`)"""
25
+ global _EN
26
+ if _EN is None:
27
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
28
+ os.environ.setdefault("ARCTAG_HOME", home)
29
+ os.environ["ARCTAG_ROLE"] = "offline" # engine.py: no prod checkpoint-dir guard
30
+ if _CORE not in sys.path:
31
+ sys.path.insert(0, _CORE)
32
+ spec = importlib.util.spec_from_file_location("arctag_engine", os.path.join(_CORE, "engine.py"))
33
+ _EN = importlib.util.module_from_spec(spec); sys.modules["arctag_engine"] = _EN
34
+ spec.loader.exec_module(_EN)
35
+ return _EN
36
+
37
+
38
+ def _driver():
39
+ from . import driver
40
+ return driver
41
+
42
+
43
+ class ArcTagger:
44
+ def __init__(self, home, EN, loaded, cfg):
45
+ self.home, self.EN, self.loaded, self.config = home, EN, loaded, cfg
46
+
47
+ @classmethod
48
+ def from_pretrained(cls, path=".", revision=None, thresholds="refit"):
49
+ """path: release dir (model.safetensors, config.json, tok/, ontology/, thresholds/) or a Hugging Face repo id.
50
+ thresholds: "refit" (default: thresholds/step88000.json + events_step88000.json, precision-first T0 refit on cleaned dev)
51
+ or "none" (0.5 everywhere, engine display floors still apply)."""
52
+ if not os.path.isdir(path):
53
+ from huggingface_hub import snapshot_download
54
+ path = snapshot_download(path, revision=revision, allow_patterns=["*.json", "*.safetensors", "*.npy", "*.py", "tok/*",
55
+ "ontology/*", "thresholds/*", "arctag/*"])
56
+ home = os.path.abspath(path)
57
+ EN = _engine(home)
58
+ AT = sys.modules["arc_text"]; TT = sys.modules["tagtable"]
59
+ AT.BPE8K = os.path.join(home, "tok", "bpe8k", "tokenizer.json")
60
+ cfg = json.load(open(os.path.join(home, "config.json")))
61
+ ext = os.path.join(home, cfg.get("onto_ext") or "ontology/ext_cc_roles.json")
62
+ T = TT.TextTable(r8=os.path.join(home, "ontology", "tags_v1_r8.json"),
63
+ text_tags=os.path.join(home, "ontology", "text_tags.json"), ext=ext)
64
+ T.load_E(os.path.join(home, "ontology", "head_E_r8_bge_small.npy"))
65
+ if T.sha != cfg.get("ontology_sha"):
66
+ raise RuntimeError(f"ontology sha {T.sha} != config {cfg.get('ontology_sha')}")
67
+ loaded = _load_model(EN, home, cfg, T, thresholds)
68
+ return cls(home, EN, loaded, cfg)
69
+
70
+ def stream(self, domain="auto", tier="T0"):
71
+ """a streaming session (driver.Stream): push_words(words, final=True, revise_from=None, endpoint=False), endpoint(),
72
+ finalize() -> result dict"""
73
+ return _driver().Stream(self.EN, self.loaded, domain=domain, tier=tier, meta=self._meta())
74
+
75
+ def tag(self, x, domain="auto", tier="T0"):
76
+ """x: text (str), a list of words (str) or word dicts {"w", "t0", "t1", "conf", "spk", "final"}. Utterances end after
77
+ words ending in . ? ! Returns the structured result (see README "Output")."""
78
+ return _driver().tag(self.EN, self.loaded, x, domain=domain, tier=tier, meta=self._meta())
79
+
80
+ def _meta(self):
81
+ return {"model": "arc-tagger", "version": __version__, "step": self.loaded.step}
82
+
83
+
84
+ def _load_model(EN, home, cfg, T, thresholds="refit"):
85
+ """= demo/engine.py ModelManager._load + _thresholds for a single release dir"""
86
+ from safetensors.torch import load_file
87
+ AT = sys.modules["arc_text"]; SA = sys.modules["stream_arc"]
88
+ step = int(cfg.get("step", 0))
89
+ m = AT.ArcText(T, scratch=cfg.get("scratch") or AT.SCRATCH_10M)
90
+ sd = load_file(os.path.join(home, "model.safetensors"))
91
+ SA.add_modules_for(m, sd)
92
+ missing, unexpected = m.load_state_dict(sd, strict=False)
93
+ if unexpected:
94
+ raise RuntimeError(f"checkpoint keys without a module: {unexpected[:6]}")
95
+ bad = [k for k in missing if not k.startswith(("head.E",))]
96
+ if bad:
97
+ import warnings
98
+ warnings.warn(f"{len(bad)} missing keys (e.g. {bad[:3]})")
99
+ m.eval()
100
+ own = cfg.get("thresholds") or {}
101
+ pref, pstep, ev, evstep = {}, None, {}, None
102
+ if thresholds == "refit":
103
+ d = os.path.join(home, "thresholds")
104
+ pref, pstep = EN.load_prod_thresholds(step, d)
105
+ ev, evstep = EN.load_event_tiers(step, d)
106
+ elif thresholds != "none":
107
+ raise ValueError("thresholds must be 'refit' or 'none'")
108
+ thr = EN.Thresholds(own, {}, None, pref=pref, pref_step=pstep)
109
+ thr.set_events(ev, evstep)
110
+ return EN.Loaded(step, home, m, T, thr, cfg)
arctag/_core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # vendored engine modules (copies of the Arc Tagger demo/code modules; see arctag/__init__.py)
arctag/_core/arc_audio/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Subset of arc_audio 1.4.2 (WhissleAI Arc, Apache-2.0, see LICENSE-arc_audio) needed by the Arc Tagger: model.py (Trunk / TagHead /
2
+ EntityHeads), wordch.py (word channel), ontology.py, mamba3/ (pure-PyTorch Mamba-3). Files copied unchanged; the audio API is not included."""
arctag/_core/arc_audio/mamba3/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Pure-PyTorch Mamba-3 layer (Lahoti et al., arXiv 2603.15569; WhissleAI in-house implementation, no CUDA kernels):
2
+ layer.py (recurrent path + step()), scan.py (chunked scan), ssd.py (chunked-SSD parallel path used for offline inference).
3
+ Identical to the copy the model was trained with."""
4
+ from .layer import Mamba3Layer # noqa: F401
5
+ from .ssd import mamba3_layer_ssd, _f # noqa: F401
arctag/_core/arc_audio/mamba3/layer.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mamba-3 (Lahoti et al., arXiv 2603.15569), implemented to the paper.
2
+
3
+ THREE CHANGES FROM MAMBA-2, implemented here in the order the paper derives
4
+ them. Nothing is improvised: where this file departs from the paper it says
5
+ so, and the equivalence tests at the bottom are how that claim is checked.
6
+
7
+ 1. EXPONENTIAL-TRAPEZOIDAL DISCRETIZATION (Sec 3.1). Mamba-1/2 approximate
8
+
9
+ integral_{t_{k-1}}^{t_k} e^{A(t_k - tau)} B(tau) x(tau) d tau
10
+
11
+ by holding the endpoint (Euler). Mamba-3 averages both endpoints
12
+ (trapezoidal), which makes the recurrence depend on x_{t-1} as well as
13
+ x_t:
14
+
15
+ h_t = alpha_t h_{t-1} + beta_t B_{t-1} x_{t-1} + gamma_t B_t x_t
16
+
17
+ with alpha_t = exp(Delta_t A), beta_t = (Delta_t/2) alpha_t,
18
+ gamma_t = Delta_t/2. That two-tap dependence IS the implicit convolution
19
+ the paper describes - it is why Mamba-3 can drop the short causal conv
20
+ that Mamba-1/2 carry as a separate component (Sec 3.1, and eq 7's
21
+ factorisation of L into a 1-semiseparable matrix times a 2-band matrix).
22
+
23
+ 2. COMPLEX STATE via the RoPE trick (Sec 3.2, Prop 2 and 3). A complex SSM
24
+ of state N/2 is equivalent to a REAL SSM of state N whose transition is a
25
+ scalar decay times a block-diagonal of 2x2 rotations:
26
+
27
+ R_t = blockdiag({ R(Delta_t theta_t[i]) })
28
+ h_t = alpha_t R_t h_{t-1} + ...
29
+
30
+ Real non-negative eigenvalues cannot express rotation, which is why
31
+ Mamba-2 provably fails state tracking (parity). This file implements the
32
+ real-equivalent form, not complex arithmetic, exactly as Prop 2 licenses.
33
+
34
+ 3. MIMO (Sec 3.3). x_t: (P,) -> (P,R), B_t: (N,) -> (N,R), C_t likewise;
35
+ the state stays (N,P) and the update becomes a matmul B_t x_t^T rather
36
+ than an outer product. Memory traffic is dominated by h_t and barely
37
+ moves; FLOPs rise by R. Arithmetic intensity goes Theta(1) -> Theta(R),
38
+ which is the whole point during memory-bound decode.
39
+
40
+ WHAT THIS FILE DOES NOT DO. No fused CUDA scan - the paper's speed comes
41
+ from hardware-aware kernels and this runs on MPS/CPU with an explicit loop
42
+ for the recurrent path and a materialised mask for the parallel path. The
43
+ MODELLING claims are reproducible here; the LATENCY claims are not.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import math
48
+
49
+ import torch
50
+ import torch.nn as nn
51
+ import torch.nn.functional as F
52
+
53
+
54
+ def rotate_pairs(h: torch.Tensor, ang: torch.Tensor) -> torch.Tensor:
55
+ """Apply blockdiag 2x2 rotations to the state's (even, odd) channel pairs.
56
+
57
+ h: (..., N) state, N even
58
+ ang: (..., N/2) rotation angle per pair
59
+ """
60
+ a, b = h[..., 0::2], h[..., 1::2]
61
+ c, s = torch.cos(ang), torch.sin(ang)
62
+ out = torch.empty_like(h)
63
+ out[..., 0::2] = c * a - s * b
64
+ out[..., 1::2] = s * a + c * b
65
+ return out
66
+
67
+
68
+ def ssm_scan(alpha, beta, gamma, theta, S, Cm, P, state=None, prev_S=None):
69
+ """The Mamba-3 recurrence (paper eq 11), shared by Mamba3Layer and MMSE.
70
+
71
+ This is the SINGLE SOURCE OF TRUTH for the recurrence - both the single-
72
+ stream layer and the multi-stream MMSE core call it, so a paper-faithfulness
73
+ fix here (e.g. the beta-term rotation) applies to both and cannot drift.
74
+
75
+ h_t = R_t(alpha_t h_{t-1} + beta_t S_{t-1}) + gamma_t S_t
76
+ y_t = C_t^T h_t
77
+
78
+ alpha,beta,gamma: (B,T,H) per-step scalars
79
+ theta: (B,T,H,N/2) per-step rotation angles (already * dt)
80
+ S: (B,T,H,N,P) per-step write B_t x_t^T (or summed streams)
81
+ Cm: (B,T,H,N,R) readout
82
+ P: head dim; state/prev_S carry across chunks for streaming.
83
+ Returns (y_flat (B,T,H*R*P), final state h, last write S[:,-1]).
84
+ """
85
+ B, T, H, N, _ = S.shape
86
+ R = Cm.shape[-1]
87
+ h = torch.zeros(B, H, N, P, device=S.device, dtype=S.dtype) \
88
+ if state is None else state
89
+ ys = []
90
+ for t in range(T):
91
+ prev = S[:, t - 1] if t > 0 else prev_S
92
+ pre = alpha[:, t, :, None, None] * h
93
+ if prev is not None:
94
+ pre = pre + beta[:, t, :, None, None] * prev
95
+ h = rotate_pairs(pre.transpose(-1, -2), theta[:, t].unsqueeze(-2)
96
+ .expand(-1, -1, P, -1)).transpose(-1, -2)
97
+ h = h + gamma[:, t, :, None, None] * S[:, t]
98
+ ys.append((Cm[:, t].transpose(-1, -2) @ h).reshape(B, H * R * P))
99
+ return torch.stack(ys, 1), h, S[:, -1]
100
+
101
+
102
+ class Mamba3Layer(nn.Module):
103
+ """One Mamba-3 layer. Recurrent and parallel paths, provably equal.
104
+
105
+ d_model input/output width
106
+ d_state N, the SSM state size (even; pairs are rotated)
107
+ rank R of the MIMO lift. rank=1 is SISO.
108
+ head_dim P, channels sharing one (alpha, Delta, B) recurrence.
109
+ """
110
+
111
+ def __init__(self, d_model: int, d_state: int = 64, rank: int = 1,
112
+ head_dim: int = 32, dt_min: float = 1e-3, dt_max: float = 0.5):
113
+ super().__init__()
114
+ assert d_state % 2 == 0, "d_state must be even: the state rotates in pairs"
115
+ assert d_model % head_dim == 0, "d_model must divide into heads of head_dim"
116
+ self.d_model, self.N, self.R, self.P = d_model, d_state, rank, head_dim
117
+ self.H = d_model // head_dim
118
+
119
+ # Gate branch (Mamba-2/3 block, Fig 2): the SSM output is multiplied by
120
+ # SiLU(in_proj(input)) before out_proj. Dimension d_model*R to match the
121
+ # pre-out_proj SSM output (H*R*P). Was defined-but-unused before.
122
+ self.in_proj = nn.Linear(d_model, d_model * rank)
123
+ self.B_proj = nn.Linear(d_model, self.H * d_state * rank)
124
+ self.C_proj = nn.Linear(d_model, self.H * d_state * rank)
125
+ self.x_proj = nn.Linear(d_model, d_model * rank)
126
+ self.dt_proj = nn.Linear(d_model, self.H)
127
+ self.theta_proj = nn.Linear(d_model, self.H * (d_state // 2))
128
+ # lambda_t: the DATA-DEPENDENT trapezoidal weight (paper Prop 1, Remark
129
+ # 3). gamma_t = lambda_t * dt, beta_t = (1-lambda_t) * dt * alpha_t.
130
+ # lambda=1/2 is the classical trapezoid; the paper found NOT enforcing
131
+ # that (letting the model choose per step) is empirically better. Bias 0
132
+ # -> sigmoid 0.5 at init, so training starts from the classical rule.
133
+ self.lam_proj = nn.Linear(d_model, self.H)
134
+ nn.init.zeros_(self.lam_proj.weight)
135
+ nn.init.zeros_(self.lam_proj.bias)
136
+ self.out_proj = nn.Linear(d_model * rank, d_model)
137
+ self.norm = nn.RMSNorm(d_model) if hasattr(nn, "RMSNorm") else nn.LayerNorm(d_model)
138
+
139
+ # BCNorm / QKNorm (paper section 3.2). RMS normalisation AFTER the B and C
140
+ # projections, mirroring QKNorm in Transformers. The paper reports this
141
+ # is what stabilises large-scale runs, to the point that they remove
142
+ # Mamba-2's post-gate RMSNorm on the strength of it. It was missing from
143
+ # the first version of this file and E17's ssm arm then diverged
144
+ # (0.330 -> 19.313) into a constant predictor, which was patched with
145
+ # gradient clipping - a generic fix for a problem the paper had already
146
+ # solved specifically. Normalising over the N state dimensions, which is
147
+ # the axis B and C index into.
148
+ rms = nn.RMSNorm if hasattr(nn, "RMSNorm") else nn.LayerNorm
149
+ self.B_norm = rms(d_state)
150
+ self.C_norm = rms(d_state)
151
+ # B, C biases (paper appendix): learnable, head-specific, channel-wise,
152
+ # data-INDEPENDENT, added after the norm, initialised to all ones. The
153
+ # paper finds models insensitive to the init as long as it is positive,
154
+ # and that B and C biases together beat either alone.
155
+ self.B_bias = nn.Parameter(torch.ones(self.H, d_state, rank))
156
+ self.C_bias = nn.Parameter(torch.ones(self.H, d_state, rank))
157
+
158
+ # A is data-INDEPENDENT here. Remark 1: the paper found data-dependent
159
+ # A_t performs similarly and chose it for consistency; the simpler
160
+ # form is used here and the difference is recorded rather than hidden.
161
+ self.A_log = nn.Parameter(torch.log(torch.rand(self.H) * 0.9 + 0.05))
162
+ # dt bias initialised so Delta spans [dt_min, dt_max] - the standard
163
+ # Mamba init. Delta sets the memory horizon, so this is the prior on
164
+ # how long the layer remembers before it learns anything.
165
+ dt = torch.exp(torch.rand(self.H) * (math.log(dt_max) - math.log(dt_min))
166
+ + math.log(dt_min))
167
+ self.dt_proj.bias.data = dt + torch.log(-torch.expm1(-dt))
168
+
169
+ def _coeffs(self, u: torch.Tensor):
170
+ """alpha, beta, gamma, B, C, x, theta for every timestep."""
171
+ Bsz, T, _ = u.shape
172
+ H, N, R, P = self.H, self.N, self.R, self.P
173
+ dt = F.softplus(self.dt_proj(u)) # (B,T,H) > 0
174
+ A = -torch.exp(self.A_log) # (H,) < 0
175
+ alpha = torch.exp(dt * A) # (B,T,H) in (0,1)
176
+ # Exponential-trapezoidal with a DATA-DEPENDENT weight lambda_t (paper
177
+ # Prop 1 / eq 5-6): gamma_t = lambda_t*dt (current input), beta_t =
178
+ # (1-lambda_t)*dt*alpha_t (previous input). lambda=1/2 recovers the
179
+ # classical trapezoid; lambda=1 recovers Mamba-2's exponential-Euler.
180
+ lam = torch.sigmoid(self.lam_proj(u)) # (B,T,H) in (0,1)
181
+ gamma = lam * dt
182
+ beta = (1.0 - lam) * dt * alpha
183
+ # BCNorm then bias, in that order - the paper adds the bias AFTER the
184
+ # norm, so the bias is not rescaled by it.
185
+ Bm = self.B_proj(u).view(Bsz, T, H, R, N)
186
+ Bm = self.B_norm(Bm).permute(0, 1, 2, 4, 3) + self.B_bias
187
+ Cm = self.C_proj(u).view(Bsz, T, H, R, N)
188
+ Cm = self.C_norm(Cm).permute(0, 1, 2, 4, 3) + self.C_bias
189
+ x = self.x_proj(u).view(Bsz, T, H, P, R)
190
+ theta = self.theta_proj(u).view(Bsz, T, H, N // 2) * dt.unsqueeze(-1)
191
+ return alpha, beta, gamma, Bm, Cm, x, theta
192
+
193
+ def gate_and_proj(self, y_flat: torch.Tensor, nu: torch.Tensor):
194
+ """Apply the Mamba gate then the output projection.
195
+
196
+ y_flat: (..., d_model*R) SSM output before out_proj.
197
+ nu: (..., d_model) the NORMALISED layer input (in_proj sees the
198
+ same normalised input the SSM path does).
199
+ out = out_proj( y_flat * SiLU(in_proj(nu)) ).
200
+ """
201
+ return self.out_proj(y_flat * F.silu(self.in_proj(nu)))
202
+
203
+ def forward(self, u: torch.Tensor, state=None, return_state=False):
204
+ """Recurrent path. u: (B,T,d_model). This is the streaming form.
205
+
206
+ The recurrence itself lives in the shared `ssm_scan` (paper eq 11), so
207
+ this and MMSECore.forward cannot drift. Here the per-step write is the
208
+ single-stream outer product S_t = B_t x_t^T.
209
+ """
210
+ nu = self.norm(u)
211
+ alpha, beta, gamma, Bm, Cm, x, theta = self._coeffs(nu)
212
+ S = Bm @ x.transpose(-1, -2) # (B,T,H,N,P)
213
+ y_flat, h, _ = ssm_scan(alpha, beta, gamma, theta, S, Cm, self.P,
214
+ state=state)
215
+ out = self.gate_and_proj(y_flat, nu)
216
+ return (out, h) if return_state else out
217
+
218
+ def step(self, u_t: torch.Tensor, state, prev_bx=None):
219
+ """One timestep, constant memory. The deployment path.
220
+
221
+ state: (B,H,N,P) or None. prev_bx: B_{t-1}x_{t-1}^T for the beta term,
222
+ which the trapezoidal rule requires and Mamba-2 does not have.
223
+ """
224
+ Bsz = u_t.shape[0]
225
+ nu = self.norm(u_t.unsqueeze(1))
226
+ alpha, beta, gamma, Bm, Cm, x, theta = self._coeffs(nu)
227
+ H, N, P = self.H, self.N, self.P
228
+ h = torch.zeros(Bsz, H, N, P, device=u_t.device, dtype=u_t.dtype) \
229
+ if state is None else state
230
+ # Match forward/eq(11): rotate (alpha*h + beta*prev_bx) by R_t, then add
231
+ # the current (unrotated) write. prev_bx = B_{t-1}x_{t-1}^T from the last
232
+ # step; bx = B_t x_t^T is returned for the next step's beta term.
233
+ pre = alpha[:, 0, :, None, None] * h
234
+ if prev_bx is not None:
235
+ pre = pre + beta[:, 0, :, None, None] * prev_bx
236
+ h = rotate_pairs(pre.transpose(-1, -2), theta[:, 0].unsqueeze(-2)
237
+ .expand(-1, -1, P, -1)).transpose(-1, -2)
238
+ bx = Bm[:, 0] @ x[:, 0].transpose(-1, -2)
239
+ h = h + gamma[:, 0, :, None, None] * bx
240
+ y = (Cm[:, 0].transpose(-1, -2) @ h).reshape(Bsz, -1)
241
+ return self.gate_and_proj(y, nu[:, 0]), h, bx
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ def selftest(verbose: bool = True) -> dict:
246
+ """The four invariants, at float64.
247
+
248
+ These were an ad-hoc scratch script until BCNorm was added and there was
249
+ nothing to re-run. A verified file whose verification lives somewhere else
250
+ is an unverified file the moment anyone edits it.
251
+
252
+ python nir/mamba3.py
253
+ """
254
+ torch.manual_seed(0)
255
+ torch.set_default_dtype(torch.float64)
256
+ net = Mamba3Layer(d_model=32, d_state=16, rank=2, head_dim=8).eval()
257
+ B, T = 2, 20
258
+ u = torch.randn(B, T, 32)
259
+ out = {}
260
+
261
+ with torch.no_grad():
262
+ y = net(u)
263
+
264
+ # 1. CAUSALITY, as a SCALE test rather than an equality one. Appending
265
+ # future timesteps moves the past by ~1e-15 here: the permute in
266
+ # _coeffs leaves B and C non-contiguous, so matmul takes a different
267
+ # kernel path at a different T and the last bits differ. An equality
268
+ # assertion would call that a leak, and would also fail spuriously in
269
+ # float32. A real leak scales with the size of the future; rounding
270
+ # does not. Drive the future through four orders of magnitude and
271
+ # require the drift to stay put.
272
+ drifts = []
273
+ for mag in (1.0, 1e2, 1e4):
274
+ u2 = torch.cat([u, torch.randn(B, 6, 32) * mag], 1)
275
+ drifts.append(float((net(u2)[:, :T] - y).abs().max()))
276
+ out["causality"] = max(drifts)
277
+ out["causality_scaling"] = max(drifts) / max(min(drifts), 1e-300)
278
+
279
+ # 2. STREAMING == BATCHED. The deployment path must equal training.
280
+ st = bx = None
281
+ steps = []
282
+ for t in range(T):
283
+ o, st, bx = net.step(u[:, t], st, bx)
284
+ steps.append(o)
285
+ out["streaming"] = float((torch.stack(steps, 1) - y).abs().max())
286
+
287
+ # 3. ROTATION IS ORTHOGONAL. State norm preserved, so it cannot blow up.
288
+ h = torch.randn(B, 4, 16, 8)
289
+ ang = torch.randn(B, 4, 1, 8)
290
+ hr = rotate_pairs(h.transpose(-1, -2), ang).transpose(-1, -2)
291
+ out["orthogonal"] = float(
292
+ (hr.norm(dim=-2) - h.norm(dim=-2)).abs().max())
293
+
294
+ # 4. TRAPEZOIDAL TWO-TAP. Perturbing x_{t-1} moves y_t, not y_{t-2}.
295
+ up = u.clone(); up[:, 3] += 1.0
296
+ yp = net(up)
297
+ out["twotap_y4"] = float((yp[:, 4] - y[:, 4]).abs().max())
298
+ out["twotap_y2"] = float((yp[:, 2] - y[:, 2]).abs().max())
299
+
300
+ # 5. BCNORM IS ACTIVE. B's pre-bias rows should have unit RMS. Without
301
+ # the norm this is whatever the projection happens to produce, and
302
+ # the first version of this file had no norm at all.
303
+ a, b, g, Bm, Cm, x, th = net._coeffs(net.norm(u))
304
+ # Bm is (B,T,H,N,R) after the permute, so the normalised axis is -2.
305
+ rms = (Bm - net.B_bias).pow(2).mean(-2).sqrt()
306
+ out["bcnorm_rms"] = float((rms - 1.0).abs().max())
307
+
308
+ out["params"] = sum(p.numel() for p in net.parameters())
309
+ torch.set_default_dtype(torch.float32)
310
+
311
+ if verbose:
312
+ ok = lambda c: "OK" if c else "FAIL"
313
+ print("Mamba-3 selftest (float64)")
314
+ print(f" 1 causality (future->past) {out['causality']:.3e}"
315
+ f" {ok(out['causality'] < 1e-12)}")
316
+ print(f" drift x1 vs x10000 ratio {out['causality_scaling']:.3f}"
317
+ f" {ok(out['causality_scaling'] < 1.5)} (a leak would scale)")
318
+ print(f" 2 step() == forward() {out['streaming']:.3e}"
319
+ f" {ok(out['streaming'] < 1e-10)}")
320
+ print(f" 3 rotation orthogonal {out['orthogonal']:.3e}"
321
+ f" {ok(out['orthogonal'] < 1e-10)}")
322
+ print(f" 4 trapezoidal two-tap y4 {out['twotap_y4']:.3e}"
323
+ f" y2 {out['twotap_y2']:.3e}"
324
+ f" {ok(out['twotap_y4'] > 1e-6 and out['twotap_y2'] == 0.0)}")
325
+ print(f" 5 BCNorm rows unit RMS {out['bcnorm_rms']:.3e}"
326
+ f" {ok(out['bcnorm_rms'] < 1e-10)}")
327
+ print(f" {out['params']:,} params")
328
+ return out
329
+
330
+
331
+ if __name__ == "__main__":
332
+ selftest()
arctag/_core/arc_audio/mamba3/scan.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chunked parallel scan for the Mamba-3 recurrence.
2
+
3
+ WHY. The recurrent path is a Python loop of T sequential steps. Measured on
4
+ this laptop: d_model 256 / 4 layers / N 32 runs at 3,670 tok/s, while
5
+ 384 / 6 / 64 runs at 179 tok/s - 20x slower for ~4x the FLOPs. The gap is not
6
+ arithmetic, it is kernel-launch overhead: 6 layers x 128 timesteps is 768
7
+ sequential iterations each firing a handful of tiny kernels, so DEPTH
8
+ MULTIPLIES LAUNCHES. That is what blocks scaling, not parameters.
9
+
10
+ THE TRICK, and it is the paper's own (Proposition 3). The recurrence
11
+
12
+ h_t = alpha_t R(theta_t) h_{t-1} + beta_t S_{t-1} + gamma_t S_t
13
+
14
+ has a data-dependent rotation inside it, which a scan cannot factor. But
15
+ rotations compose: define the CUMULATIVE angle Phi_t = sum_{s<=t} theta_s and
16
+ change basis by R(-Phi_t). In that frame the rotation disappears and what is
17
+ left is a plain first-order linear recurrence
18
+
19
+ g_t = alpha_t g_{t-1} + w_t w_t = R(-Phi_t) (beta_t S_{t-1} + gamma_t S_t)
20
+
21
+ with h_t = R(Phi_t) g_t. The paper states this as applying data-dependent
22
+ RoPE to B and C; the same identity in state space is what makes the scan
23
+ possible at all.
24
+
25
+ THE SCAN. g_t = alpha_t g_{t-1} + w_t has the closed form
26
+
27
+ g_t = A_t * sum_{s<=t} w_s / A_s A_t = prod_{r<=t} alpha_r
28
+
29
+ which is numerically hopeless directly - A_s underflows for long sequences
30
+ and the division explodes. So this chunks: within a chunk of length L the sum
31
+ is materialised as an L x L lower-triangular decay matrix (no division, decay
32
+ is always relative to the chunk start), and chunk states are carried
33
+ recurrently. T/L sequential steps instead of T. The intra-chunk work becomes
34
+ one batched matmul, which is what GPUs are for.
35
+
36
+ CORRECTNESS IS CHECKED AGAINST THE RECURRENCE, NOT AGAINST A LOSS CURVE.
37
+ `verify()` compares this to Mamba3Layer.forward at float64. A fast scan that
38
+ computes something slightly different would show up as a slightly worse loss
39
+ curve and be indistinguishable from a hyperparameter difference - which is
40
+ exactly how this project has lost time before.
41
+
42
+ python nir/scan.py
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import math
47
+ import torch
48
+
49
+
50
+ def cumulative_angles(theta: torch.Tensor) -> torch.Tensor:
51
+ """Phi_t = sum_{s<=t} theta_s. theta: (B,T,H,N/2)."""
52
+ return torch.cumsum(theta, dim=1)
53
+
54
+
55
+ def rotate_by(v: torch.Tensor, ang: torch.Tensor) -> torch.Tensor:
56
+ """Rotate pairs of the N axis. v: (...,N,P), ang: (...,N/2) broadcastable.
57
+
58
+ Built with stack+reshape rather than writing into an empty tensor. The
59
+ first version used `out = torch.empty_like(v)` with strided assignment,
60
+ which is correct in the forward direction and BREAKS AUTOGRAD: in-place
61
+ writes bump the version counter of a tensor the graph still needs. The
62
+ forward equivalence check passed anyway, because it ran under no_grad.
63
+ """
64
+ a, b = v[..., 0::2, :], v[..., 1::2, :]
65
+ c, s = torch.cos(ang).unsqueeze(-1), torch.sin(ang).unsqueeze(-1)
66
+ even, odd = c * a - s * b, s * a + c * b
67
+ return torch.stack((even, odd), dim=-2).flatten(-3, -2)
68
+
69
+
70
+ def chunked_scan(alpha: torch.Tensor, w: torch.Tensor, chunk: int = 64,
71
+ state=None):
72
+ """g_t = alpha_t g_{t-1} + w_t, computed in chunks.
73
+
74
+ alpha: (B,T,H) per-step decay, in (0,1)
75
+ w: (B,T,H,N,P) per-step input, already in the rotated frame
76
+ returns g: (B,T,H,N,P) and the final state.
77
+
78
+ Within a chunk, g_t = d_t * g_0 + sum_{s<=t} (d_t/d_s) w_s where
79
+ d_t = prod_{r<=t} alpha_r RELATIVE TO THE CHUNK START. Both d_t and d_t/d_s
80
+ stay bounded because the product never runs longer than `chunk` steps -
81
+ that is the whole reason for chunking rather than a global cumprod.
82
+ """
83
+ B, T, H = alpha.shape
84
+ N, P = w.shape[-2], w.shape[-1]
85
+ # Accumulate chunks in a list and cat once. Writing into a preallocated
86
+ # g_out and then reading g_out[:, e-1] back as the next chunk's state is
87
+ # an in-place write into a tensor autograd still needs.
88
+ pieces = []
89
+ g = torch.zeros(B, H, N, P, device=w.device, dtype=w.dtype) \
90
+ if state is None else state
91
+ la = torch.log(alpha.clamp_min(1e-30)) # (B,T,H)
92
+ for s in range(0, T, chunk):
93
+ e = min(s + chunk, T)
94
+ L = e - s
95
+ cl = torch.cumsum(la[:, s:e], dim=1) # (B,L,H) log d_t
96
+ d = torch.exp(cl) # decay from chunk start
97
+ # (d_t / d_s) for s<=t, lower triangular, exp of a difference of logs
98
+ rel = cl.unsqueeze(2) - cl.unsqueeze(1) # (B,L,L,H) log d_t - log d_s
99
+ mask = torch.tril(torch.ones(L, L, device=w.device, dtype=torch.bool))
100
+ M = torch.where(mask.view(1, L, L, 1), torch.exp(rel),
101
+ torch.zeros((), device=w.device, dtype=w.dtype))
102
+ # intra-chunk: sum_s M[t,s] * w_s -> one batched matmul over (L,L) x (L, N*P)
103
+ wf = w[:, s:e].reshape(B, L, H, N * P).permute(0, 2, 1, 3) # (B,H,L,NP)
104
+ Mp = M.permute(0, 3, 1, 2) # (B,H,L,L)
105
+ acc = (Mp @ wf).permute(0, 2, 1, 3).reshape(B, L, H, N, P)
106
+ # carry the incoming chunk state, decayed
107
+ piece = acc + d.unsqueeze(-1).unsqueeze(-1) * g.unsqueeze(1)
108
+ pieces.append(piece)
109
+ g = piece[:, -1]
110
+ return torch.cat(pieces, dim=1), g
111
+
112
+
113
+ def scan_forward(layer, u: torch.Tensor, chunk: int = 64):
114
+ """Mamba3Layer.forward computed with the chunked scan instead of the loop."""
115
+ Bsz, T, _ = u.shape
116
+ H, N, R, P = layer.H, layer.N, layer.R, layer.P
117
+ nu = layer.norm(u)
118
+ alpha, beta, gamma, Bm, Cm, x, theta = layer._coeffs(nu)
119
+ S = Bm @ x.transpose(-1, -2) # (B,T,H,N,P)
120
+ # trapezoidal: beta carries the PREVIOUS step's write. Built by cat, not
121
+ # by assigning into a zeros tensor, for the same autograd reason.
122
+ Sprev = torch.cat([torch.zeros_like(S[:, :1]), S[:, :-1]], dim=1)
123
+ Phi = cumulative_angles(theta) # (B,T,H,N/2) = Phi_s
124
+ Phi_prev = torch.cat([torch.zeros_like(Phi[:, :1]), Phi[:, :-1]], dim=1)
125
+ # Paper eq (11): the gamma (current) write rotates by R(-Phi_s), the beta
126
+ # (previous) write by R(-Phi_{s-1}) - one step less. Rotate the two terms
127
+ # separately into the global frame, then scan.
128
+ w = (gamma[..., None, None] * rotate_by(S, -Phi)
129
+ + beta[..., None, None] * rotate_by(Sprev, -Phi_prev))
130
+ g, _ = chunked_scan(alpha, w, chunk)
131
+ h = rotate_by(g, Phi) # back out
132
+ y = (Cm.transpose(-1, -2) @ h).reshape(Bsz, T, H * R * P)
133
+ return layer.gate_and_proj(y, nu)
134
+
135
+
136
+ def verify(chunks=(8, 16, 64), verbose: bool = True) -> dict:
137
+ """Against the recurrence, at float64 - FORWARD AND BACKWARD.
138
+
139
+ The backward half is not optional. The first version of this file passed
140
+ every forward check and could not backpropagate at all: it built outputs
141
+ with in-place strided writes, which autograd rejects. The forward checks
142
+ ran under no_grad and saw nothing. A scan that cannot train is useless,
143
+ and one that trains with subtly wrong gradients is worse than useless -
144
+ it would surface as a slightly worse loss curve, indistinguishable from a
145
+ hyperparameter difference."""
146
+ import sys
147
+ from pathlib import Path
148
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
149
+ from mamba3.layer import Mamba3Layer
150
+
151
+ torch.manual_seed(0)
152
+ torch.set_default_dtype(torch.float64)
153
+ out = {}
154
+ lay = Mamba3Layer(d_model=32, d_state=16, rank=2, head_dim=8).eval()
155
+ u = torch.randn(3, 40, 32)
156
+ with torch.no_grad():
157
+ ref = lay(u)
158
+ for c in chunks:
159
+ got = scan_forward(lay, u, chunk=c)
160
+ out[f"chunk{c}"] = float((got - ref).abs().max())
161
+ # a length that is not a multiple of the chunk
162
+ u2 = torch.randn(2, 37, 32)
163
+ r2 = lay(u2)
164
+ out["ragged"] = float((scan_forward(lay, u2, chunk=16) - r2).abs().max())
165
+ # causality of the scan path itself
166
+ u3 = torch.cat([u, torch.randn(3, 9, 32) * 50], 1)
167
+ out["causality"] = float(
168
+ (scan_forward(lay, u3, chunk=16)[:, :40]
169
+ - scan_forward(lay, u, chunk=16)).abs().max())
170
+
171
+ # ---- BACKWARD ----
172
+ ug = torch.randn(3, 24, 32, requires_grad=True)
173
+ lay.zero_grad()
174
+ lay(ug).pow(2).sum().backward()
175
+ gu_rec = ug.grad.clone()
176
+ gp_rec = {n: p.grad.clone() for n, p in lay.named_parameters()
177
+ if p.grad is not None}
178
+ ug.grad = None
179
+ lay.zero_grad()
180
+ scan_forward(lay, ug, chunk=8).pow(2).sum().backward()
181
+ out["grad_input"] = float((gu_rec - ug.grad).abs().max())
182
+ out["grad_params"] = max(
183
+ float((gp_rec[n] - p.grad).abs().max())
184
+ for n, p in lay.named_parameters() if p.grad is not None)
185
+ torch.set_default_dtype(torch.float32)
186
+ if verbose:
187
+ ok = lambda c: "OK " if c else "FAIL"
188
+ print("chunked scan vs recurrence (float64)")
189
+ for k, v in out.items():
190
+ print(f" {k:<12}{v:.3e} {ok(v < 1e-9)}")
191
+ return out
192
+
193
+
194
+ if __name__ == "__main__":
195
+ verify()
arctag/_core/arc_audio/mamba3/ssd.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chunked SSD evaluation of Mamba3Layer (copied verbatim from WhissleAI's internal Mamba-3 code)."""
2
+ import torch
3
+ from .scan import rotate_by
4
+ from .layer import Mamba3Layer
5
+
6
+ def _f(t):
7
+ return t.float() if t is not None and t.dtype in (torch.float16, torch.bfloat16) else t
8
+
9
+
10
+ def _shift(x):
11
+ return torch.cat([torch.zeros_like(x[:, :1]), x[:, :-1]], dim=1)
12
+
13
+
14
+ def ssd_safe(alpha, Beff, xeff, Ctil, chunk: int = 64):
15
+ """Chunked SSD (as vendor ssd_pytorch) with the decay masked before exp. Returns y (B,T,H,R,P)."""
16
+ B, T, H, N, K = Beff.shape
17
+ P = xeff.shape[-2]
18
+ la = torch.log(alpha.clamp_min(1e-30))
19
+ g = torch.zeros(B, H, N, P, dtype=Beff.dtype, device=Beff.device)
20
+ ys = []
21
+ for s0 in range(0, T, chunk):
22
+ e = min(s0 + chunk, T)
23
+ L = e - s0
24
+ Bc, xc, Cc = Beff[:, s0:e], xeff[:, s0:e], Ctil[:, s0:e]
25
+ cl = torch.cumsum(la[:, s0:e], dim=1)
26
+ d = torch.exp(cl)
27
+ rel = cl.unsqueeze(2) - cl.unsqueeze(1) # (B,L,L,H)
28
+ tril = torch.tril(torch.ones(L, L, dtype=torch.bool, device=Beff.device)).view(1, L, L, 1)
29
+ Ldec = torch.exp(rel.masked_fill(~tril, float("-inf")))
30
+ G = torch.einsum('blhnr,bmhnk->bhlmrk', Cc, Bc)
31
+ y_intra = torch.einsum('blmh,bhlmrk,bmhpk->blhrp', Ldec, G, xc)
32
+ y_inter = torch.einsum('blh,blhnr,bhnp->blhrp', d, Cc, g)
33
+ ys.append(y_intra + y_inter)
34
+ coef = torch.exp(cl[:, -1:, :] - cl)
35
+ g = d[:, -1].unsqueeze(-1).unsqueeze(-1) * g + torch.einsum('blh,blhnk,blhpk->bhnp', coef, Bc, xc)
36
+ return torch.cat(ys, dim=1)
37
+
38
+
39
+ def _ssd_from_coeffs(alpha, beta, gamma, Bc, xc, Cm, theta, chunk):
40
+ """Bc (B,T,H,N,K) xc (B,T,H,P,K) Cm (B,T,H,N,R) -> y (B,T,H,R,P); the vendor ssd_inputs reduction."""
41
+ Phi = torch.cumsum(theta, dim=1)
42
+ Phi_prev = _shift(Phi)
43
+ Btil = rotate_by(Bc, -Phi)
44
+ Ctil = rotate_by(Cm, -Phi)
45
+ Btil_prev = rotate_by(_shift(Bc), -Phi_prev)
46
+ Beff = torch.cat([gamma[..., None, None] * Btil, beta[..., None, None] * Btil_prev], dim=-1)
47
+ xeff = torch.cat([xc, _shift(xc)], dim=-1)
48
+ return ssd_safe(alpha, Beff, xeff, Ctil, chunk)
49
+
50
+
51
+ def mamba3_layer_ssd(layer: Mamba3Layer, u: torch.Tensor, chunk: int = 64):
52
+ """Same output as Mamba3Layer.forward(u) via SSD."""
53
+ nu = layer.norm(u)
54
+ alpha, beta, gamma, Bm, Cm, x, theta = layer._coeffs(nu)
55
+ y = _ssd_from_coeffs(alpha, beta, gamma, Bm, x, Cm, theta, chunk)
56
+ Bsz, T = u.shape[:2]
57
+ return layer.gate_and_proj(y.reshape(Bsz, T, -1), nu)
58
+
59
+
60
+
61
+
62
+ # ---- stateful SSD (carried state across calls; truncated-BPTT training and reset-free inference) -------------------
63
+ def ssd_safe_state(alpha, Beff, xeff, Ctil, g0=None, chunk: int = 64):
64
+ """ssd_safe with an initial inter-chunk state g0 (B,H,N,P) (rotated frame of t = -1); returns (y, g_final)."""
65
+ B, T, H, N, K = Beff.shape
66
+ P = xeff.shape[-2]
67
+ la = torch.log(alpha.clamp_min(1e-30))
68
+ g = torch.zeros(B, H, N, P, dtype=Beff.dtype, device=Beff.device) if g0 is None else g0.to(Beff.dtype)
69
+ ys = []
70
+ for s0 in range(0, T, chunk):
71
+ e = min(s0 + chunk, T)
72
+ L = e - s0
73
+ Bc, xc, Cc = Beff[:, s0:e], xeff[:, s0:e], Ctil[:, s0:e]
74
+ cl = torch.cumsum(la[:, s0:e], dim=1)
75
+ d = torch.exp(cl)
76
+ rel = cl.unsqueeze(2) - cl.unsqueeze(1)
77
+ tril = torch.tril(torch.ones(L, L, dtype=torch.bool, device=Beff.device)).view(1, L, L, 1)
78
+ Ldec = torch.exp(rel.masked_fill(~tril, float("-inf")))
79
+ G = torch.einsum('blhnr,bmhnk->bhlmrk', Cc, Bc)
80
+ y_intra = torch.einsum('blmh,bhlmrk,bmhpk->blhrp', Ldec, G, xc)
81
+ y_inter = torch.einsum('blh,blhnr,bhnp->blhrp', d, Cc, g)
82
+ ys.append(y_intra + y_inter)
83
+ coef = torch.exp(cl[:, -1:, :] - cl)
84
+ g = d[:, -1].unsqueeze(-1).unsqueeze(-1) * g + torch.einsum('blh,blhnk,blhpk->bhnp', coef, Bc, xc)
85
+ return torch.cat(ys, dim=1), g
86
+
87
+
88
+ def mamba3_layer_ssd_state(layer: Mamba3Layer, u: torch.Tensor, state=None, chunk: int = 64):
89
+ """SSD with carried state. state = (h (B,H,N,P) absolute frame, B_last (B,H,N,K), x_last (B,H,P,K)) or None (zeros).
90
+ Returns (out, new_state). Equal to running mamba3_layer_ssd over the concatenated sequence; step()'s prev_bx = B_last @ x_last^T."""
91
+ nu = layer.norm(u)
92
+ alpha, beta, gamma, Bm, Cm, x, theta = layer._coeffs(nu)
93
+ Phi = torch.cumsum(theta, dim=1)
94
+ Phi_prev = _shift(Phi)
95
+ if state is None:
96
+ h0 = None; Bp = torch.zeros_like(Bm[:, :1]); xp = torch.zeros_like(x[:, :1])
97
+ else:
98
+ h0, Bl, xl = state; Bp = Bl[:, None].to(Bm.dtype); xp = xl[:, None].to(x.dtype)
99
+ Bsh = torch.cat([Bp, Bm[:, :-1]], 1); xsh = torch.cat([xp, x[:, :-1]], 1)
100
+ Btil = rotate_by(Bm, -Phi); Ctil = rotate_by(Cm, -Phi); Btil_prev = rotate_by(Bsh, -Phi_prev)
101
+ Beff = torch.cat([gamma[..., None, None] * Btil, beta[..., None, None] * Btil_prev], dim=-1)
102
+ xeff = torch.cat([x, xsh], dim=-1)
103
+ y, g = ssd_safe_state(alpha, Beff, xeff, Ctil, h0, chunk)
104
+ hT = rotate_by(g.unsqueeze(1), Phi[:, -1:])[:, 0]
105
+ Bsz, T = u.shape[:2]
106
+ return layer.gate_and_proj(y.reshape(Bsz, T, -1), nu), (hT, Bm[:, -1], x[:, -1])
arctag/_core/arc_audio/model.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc v1.4 network (inference only; 1.4.1 adds the word channel, arc_audio/wordch.py).
2
+
3
+ trunk: x (B,T,640) 10 Hz [mean,max]-pooled Moonshine Streaming features -> LayerNorm -> Linear 512 (+ domain emb + delay emb)
4
+ -> 5 x [FiLM(domain + delay) -> Mamba-3 + GELU MLP] -> per-domain rank-64 adapter -> RMSNorm -> h (B,T,512).
5
+ head: one flat tag-prompt head over the tag table: key_t = P e_t + r_t (e_t = frozen text embedding of the tag description,
6
+ shipped as a buffer); z = <Q_q(pool_q(h)), key_t> + b_t + b_t^q for each read-out q (frame, span_in, span_end, utt,
7
+ speaker, ev_at, ev_imm, ev_rec, item). Pooled read-outs carry (sum, n) state: utt (since the utterance start), speaker
8
+ (per speaker index), item (since the stream start).
9
+ extra: name pointer + numeral (NWT) value head (EntityHeads), diarization slots + voice embedding (DiarHeads), non-speech
10
+ vocal events (BioHead).
11
+
12
+ Two equivalent trunk paths:
13
+ Trunk.forward(x, ..., state) chunked SSD with carried state (offline / large chunks)
14
+ Trunk.step(x_t, ..., state) recurrent Mamba3Layer.step, one 100 ms frame at a time (true streaming; constant cost)
15
+ Both carry the same per-layer state; tests/test_release.py checks that they agree.
16
+ """
17
+ import math
18
+ import torch, torch.nn as nn, torch.nn.functional as F
19
+
20
+ from .mamba3 import Mamba3Layer
21
+ from .mamba3.layer import rotate_pairs
22
+ from .mamba3.ssd import mamba3_layer_ssd_state, _f
23
+ from .ontology import QUERIES, POOLED, FINE_DOMAINS
24
+
25
+ NEG = -1e4
26
+ S_MAX = 64 # speaker indices pooled per stream
27
+
28
+
29
+ class Trunk(nn.Module):
30
+ def __init__(self, d_in=640, d_model=512, n_layers=5, d_state=64, head_dim=64, dropout=0.2, in_drop=0.2, chunk=128,
31
+ n_dom=len(FINE_DOMAINS), delays=(0, 1, 2, 3, 5, 8), adapters=64, long_mem=True):
32
+ super().__init__()
33
+ self.cfg = dict(d_in=d_in, d_model=d_model, n_layers=n_layers, d_state=d_state, head_dim=head_dim, dropout=dropout, in_drop=in_drop,
34
+ chunk=chunk, n_dom=n_dom, delays=list(delays), adapters=adapters, long_mem=long_mem)
35
+ self.chunk = chunk
36
+ self.in_norm = nn.LayerNorm(d_in); self.inp = nn.Linear(d_in, d_model)
37
+ self.dom_emb = nn.Embedding(n_dom, d_model)
38
+ self.film = nn.ModuleList([nn.Linear(d_model, 2 * d_model) for _ in range(n_layers)])
39
+ self.delays = list(delays)
40
+ self.delay_emb = nn.Embedding(len(delays), d_model)
41
+ self.delay_film = nn.Embedding(len(delays), d_model)
42
+ lut = torch.full((max(delays) + 1,), -1, dtype=torch.long)
43
+ for i, d in enumerate(delays):
44
+ lut[d] = i
45
+ self.register_buffer("delay_lut", lut, persistent=False)
46
+ self.adapt_down = nn.Parameter(torch.zeros(n_dom, d_model, adapters))
47
+ self.adapt_up = nn.Parameter(torch.zeros(n_dom, adapters, d_model))
48
+ self.layers = nn.ModuleList([Mamba3Layer(d_model, d_state=d_state, rank=1, head_dim=head_dim) for _ in range(n_layers)])
49
+ self.mlps = nn.ModuleList([nn.Sequential(nn.RMSNorm(d_model), nn.Linear(d_model, 2 * d_model), nn.GELU(), nn.Dropout(dropout),
50
+ nn.Linear(2 * d_model, d_model)) for _ in range(n_layers)])
51
+ self.out_norm = nn.RMSNorm(d_model)
52
+
53
+ def _cond(self, x, dom, delay):
54
+ di = self.delay_lut[delay.clamp(0, len(self.delay_lut) - 1)]
55
+ if (di < 0).any():
56
+ raise ValueError(f"delay {delay.tolist()} frames not in the trained set {self.delays}")
57
+ h = self.inp(self.in_norm(x))
58
+ e = self.dom_emb(dom) + self.delay_film(di)
59
+ return h + (self.dom_emb(dom) + self.delay_emb(di))[:, None], e
60
+
61
+ def _adapt(self, h, dom):
62
+ Dn, Up = self.adapt_down[dom].to(h.dtype), self.adapt_up[dom].to(h.dtype)
63
+ return h + torch.einsum("btr,brd->btd", F.gelu(torch.einsum("btd,bdr->btr", h, Dn)), Up)
64
+
65
+ def forward(self, x, dom, delay, state=None):
66
+ """chunked SSD. x (B,T,d_in); dom (B,) long; delay (B,) long frames; state: list per layer of (h, B_last, x_last) or None."""
67
+ h, e = self._cond(x, dom, delay); new = []
68
+ for i, (lay, mlp) in enumerate(zip(self.layers, self.mlps)):
69
+ gb = self.film[i](e)[:, None]; h = h * (1 + gb[..., :h.shape[-1]]) + gb[..., h.shape[-1]:]
70
+ st = None if state is None else tuple(_f(t) for t in state[i][:3])
71
+ s, ns = mamba3_layer_ssd_state(lay, _f(h), st, self.chunk)
72
+ h = h + s
73
+ h = h + mlp(h)
74
+ new.append(ns)
75
+ return self.out_norm(self._adapt(h, dom)), new
76
+
77
+ def step(self, x, dom, delay, state=None):
78
+ """recurrent path over T frames (python loop of the Mamba-3 recurrence; constant cost per frame).
79
+ Same arguments and result as forward(); the state is (h, B_last, x_last, B_last x_last^T) per layer and can be
80
+ continued by forward() (which uses the first three entries) and vice versa."""
81
+ h_all, e = self._cond(x, dom, delay)
82
+ B, T, D = h_all.shape
83
+ st = [None] * len(self.layers) if state is None else list(state)
84
+ gbs = [self.film[i](e) for i in range(len(self.layers))]
85
+ outs = []
86
+ for t in range(T):
87
+ h = h_all[:, t]
88
+ for i, (lay, mlp) in enumerate(zip(self.layers, self.mlps)):
89
+ gb = gbs[i]; h = h * (1 + gb[..., :D]) + gb[..., D:]
90
+ y, st[i] = layer_step(lay, _f(h), st[i])
91
+ h = h + y
92
+ h = h + mlp(h)
93
+ outs.append(h)
94
+ h = torch.stack(outs, 1)
95
+ return self.out_norm(self._adapt(h, dom)), st
96
+
97
+
98
+ def layer_step(lay, u, state):
99
+ """one frame of Mamba3Layer (= Mamba3Layer.step with the coefficients computed once). u (B, D) -> (y (B, D), state)."""
100
+ Bsz = u.shape[0]
101
+ nu = lay.norm(u.unsqueeze(1))
102
+ alpha, beta, gamma, Bm, Cm, x, theta = lay._coeffs(nu)
103
+ H, N, P = lay.H, lay.N, lay.P
104
+ bx = Bm[:, 0] @ x[:, 0].transpose(-1, -2) # (B,H,N,P)
105
+ if state is None:
106
+ h = gamma[:, 0, :, None, None] * bx
107
+ else:
108
+ h0, Bl, xl = (_f(t) for t in state[:3])
109
+ prev_bx = _f(state[3]) if len(state) > 3 else Bl @ xl.transpose(-1, -2)
110
+ pre = alpha[:, 0, :, None, None] * h0 + beta[:, 0, :, None, None] * prev_bx
111
+ h = rotate_pairs(pre.transpose(-1, -2), theta[:, 0].unsqueeze(-2).expand(-1, -1, P, -1)).transpose(-1, -2)
112
+ h = h + gamma[:, 0, :, None, None] * bx
113
+ y = (Cm[:, 0].transpose(-1, -2) @ h).reshape(Bsz, -1)
114
+ return lay.gate_and_proj(y, nu[:, 0]), (h, Bm[:, 0], x[:, 0], bx)
115
+
116
+
117
+ # ---------------------------------------------------------------- pooling with carried state
118
+ def pool_speaker(z, spk, carry):
119
+ """z (B,T,D); spk (B,T) long speaker index (-1 none); carry (sum (B,S,D) fp64, n (B,S) fp64) -> running mean over that
120
+ speaker's frames (incl. earlier calls), new carry."""
121
+ B, T, D = z.shape
122
+ sp = torch.where((spk >= 0) & (spk < S_MAX), spk, torch.full_like(spk, -1))
123
+ key = (torch.arange(B, device=z.device)[:, None] * (S_MAX + 1) + (sp + 1)) * T + torch.arange(T, device=z.device)[None]
124
+ order = torch.argsort(key.reshape(-1), stable=True)
125
+ zs = z.reshape(B * T, D)[order].double(); gs = key.reshape(-1)[order] // T
126
+ cs = torch.cumsum(zs, 0)
127
+ start = torch.ones_like(gs, dtype=torch.bool); start[1:] = gs[1:] != gs[:-1]
128
+ pos = torch.arange(B * T, device=z.device)
129
+ first = torch.cummax(torch.where(start, pos, torch.zeros_like(pos)), 0).values
130
+ base = torch.where(first[:, None] > 0, cs[(first - 1).clamp(min=0)], torch.zeros_like(cs))
131
+ num = torch.empty_like(cs); num[order] = cs - base
132
+ cnt = torch.empty(B * T, dtype=torch.float64, device=z.device); cnt[order] = (pos - first + 1).double()
133
+ num = num.reshape(B, T, D); cnt = cnt.reshape(B, T)
134
+ cs_, cn_ = carry
135
+ sidx = sp.clamp(min=0)
136
+ num = num + torch.gather(cs_, 1, sidx[..., None].expand(B, T, D)); cnt = cnt + torch.gather(cn_, 1, sidx)
137
+ out = (num / cnt.clamp(min=1)[..., None]).float() * (sp >= 0)[..., None].float()
138
+ oh = F.one_hot(sidx, S_MAX).double() * (sp >= 0)[..., None].double()
139
+ new = (cs_ + torch.einsum("bts,btd->bsd", oh, z.double()), cn_ + oh.sum(1))
140
+ return out, new
141
+
142
+
143
+ def pool_segment(z, starts, carry):
144
+ """causal mean since the last start (True at a unit's first frame); carry (sum (B,D), n (B,)) continues the open unit.
145
+ starts=None -> one unit for the whole stream (item pooling)."""
146
+ B, T, D = z.shape
147
+ st = torch.zeros(B, T, dtype=torch.bool, device=z.device) if starts is None else starts
148
+ cs = torch.cumsum(z.double(), 1); cs0 = F.pad(cs, (0, 0, 1, 0))[:, :-1]
149
+ t = torch.arange(T, device=z.device)[None].expand(B, T)
150
+ first = torch.cummax(torch.where(st, t, torch.full_like(t, -1)), 1).values
151
+ base = torch.gather(cs0, 1, first.clamp(min=0)[..., None].expand(B, T, D))
152
+ n = (t - first.clamp(min=0) + 1).double()
153
+ open_ = (first < 0)
154
+ num = torch.where(open_[..., None], cs + carry[0][:, None], cs - base)
155
+ n = torch.where(open_, (t + 1).double() + carry[1][:, None], n)
156
+ out = (num / n.clamp(min=1)[..., None]).float()
157
+ last = first[:, -1]
158
+ new_s = torch.where((last < 0)[:, None], carry[0] + cs[:, -1], cs[:, -1] - torch.gather(cs0, 1, last.clamp(min=0)[:, None, None].expand(B, 1, D))[:, 0])
159
+ new_n = torch.where(last < 0, carry[1] + T, (T - last).double())
160
+ return out, (new_s, new_n)
161
+
162
+
163
+ def zero_carry(B, D, device):
164
+ z = lambda *s: torch.zeros(*s, dtype=torch.float64, device=device)
165
+ return dict(utt=(z(B, D), z(B)), speaker=(z(B, S_MAX, D), z(B, S_MAX)), item=(z(B, D), z(B)))
166
+
167
+
168
+ class TagHead(nn.Module):
169
+ def __init__(self, table, n_text=384, d_model=512, d_k=512):
170
+ super().__init__()
171
+ self.table = table
172
+ self.register_buffer("E", torch.zeros(table.n, n_text)) # frozen tag-text embeddings (from the checkpoint)
173
+ self.register_buffer("valid", torch.as_tensor(table.valid), persistent=False)
174
+ self.P = nn.Linear(n_text, d_k, bias=False)
175
+ self.r = nn.Parameter(torch.zeros(table.n, d_k)); self.b = nn.Parameter(torch.zeros(table.n))
176
+ self.bq = nn.ParameterDict({q: nn.Parameter(torch.zeros(table.n)) for q in QUERIES})
177
+ self.Q = nn.ModuleDict({q: nn.Linear(d_model, d_k) for q in QUERIES})
178
+ self.pool_in = nn.ModuleDict({q: nn.Sequential(nn.Linear(d_model, d_model), nn.GELU(), nn.Linear(d_model, d_model)) for q in POOLED})
179
+ for q in QUERIES:
180
+ self.register_buffer(f"cols_{q}", torch.tensor(table.cols[q], dtype=torch.long), persistent=False)
181
+
182
+ def cols(self, q):
183
+ return getattr(self, f"cols_{q}")
184
+
185
+ def keys(self):
186
+ return self.P(self.E) + self.r
187
+
188
+ def pooled(self, h, spk, ustart, carry, h_utt=None):
189
+ """h_utt: the word-fused h' for the utterance pool (word channel); speaker / item pools read the audio-only h."""
190
+ zin, new = {}, {}
191
+ for q in POOLED:
192
+ hq = h_utt if (q == "utt" and h_utt is not None) else h
193
+ z = hq + self.pool_in[q](hq)
194
+ if q == "speaker":
195
+ zin[q], new[q] = pool_speaker(z.float(), spk, carry[q])
196
+ elif q == "utt":
197
+ zin[q], new[q] = pool_segment(z.float(), ustart, carry[q])
198
+ else:
199
+ zin[q], new[q] = pool_segment(z.float(), None, carry[q])
200
+ return zin, new
201
+
202
+ def logits_q(self, q, zq, dom, K=None):
203
+ """raw (prior-free) logits for read-out q; domain-invalid columns -> NEG."""
204
+ K = self.keys() if K is None else K
205
+ c = self.cols(q)
206
+ z = self.Q[q](zq.float()) @ K[c].T + (self.b + self.bq[q])[c]
207
+ return z.masked_fill(~self.valid[dom][:, c][:, None, :], NEG)
208
+
209
+
210
+ # ---------------------------------------------------------------- entities: name pointer + numeral value head
211
+ NAME_MAX_BYTES = 40
212
+ C_KINDS = ["person", "team", "organization", "place", "work_product", "event_name", "legal_ref", "game_entity", "game_map", "other"]
213
+ MENTION_KINDS = {"entity.mention.person": [0, 9], "entity.mention.team": [1, 2], "entity.mention.organization": [2, 1, 9],
214
+ "entity.mention.place": [3, 9], "entity.mention.work_product": [4, 9], "entity.mention.event_name": [5, 9],
215
+ "entity.mention.legal_ref": [6, 9], "entity.mention.game_entity": [7, 9], "entity.mention.game_map": [8, 9]}
216
+ K_SLOTS = 4
217
+ CLOCK = 3
218
+ SPAN_G_MULT = 20.0
219
+
220
+
221
+ def encode_names(names, max_len=NAME_MAX_BYTES, keep="head"):
222
+ import numpy as np
223
+ ids = np.zeros((len(names), max_len), np.int64)
224
+ for i, n in enumerate(names):
225
+ b = " ".join(str(n).lower().split()).encode("utf-8")
226
+ b = b[:max_len] if keep == "head" else b[-max_len:]
227
+ ids[i, :len(b)] = np.frombuffer(b, np.uint8).astype(np.int64) + 1
228
+ return ids
229
+
230
+
231
+ class NameEncoder(nn.Module):
232
+ """byte embedding -> 3 residual dilated Conv1d -> [masked mean, max] -> MLP (+ kind embedding), unit norm."""
233
+ def __init__(self, d=128, char_dim=48):
234
+ super().__init__()
235
+ self.d = d
236
+ self.emb = nn.Embedding(257, char_dim, padding_idx=0)
237
+ self.conv_in = nn.Conv1d(char_dim, d, 3, padding=1)
238
+ self.convs = nn.ModuleList([nn.Conv1d(d, d, 3, padding=dl, dilation=dl) for dl in (1, 2, 4)])
239
+ self.kind = nn.Embedding(len(C_KINDS), d)
240
+ self.out = nn.Sequential(nn.Linear(2 * d, d), nn.GELU(), nn.Linear(d, d))
241
+
242
+ def forward(self, ids, kinds):
243
+ sh = ids.shape[:-1]; ids = ids.reshape(-1, ids.shape[-1]); m = (ids > 0).float()[:, None]
244
+ x = self.emb(ids).float().transpose(1, 2); h = F.gelu(self.conv_in(x)) * m
245
+ for cv in self.convs:
246
+ h = h + F.gelu(cv(h)) * m
247
+ mean = h.sum(-1) / m.sum(-1).clamp(min=1.0); mx = h.masked_fill(m == 0, -1e4).max(-1).values * (m.sum(-1) > 0).float()
248
+ e = self.out(torch.cat([mean, mx], -1)) + self.kind(kinds.reshape(-1))
249
+ return F.normalize(e, dim=-1).reshape(*sh, self.d)
250
+
251
+
252
+ class EntityHeads(nn.Module):
253
+ def __init__(self, d_model=512, d_key=512, d=128, n_units=64, span=True, span_new=True, sim=False):
254
+ super().__init__()
255
+ self.names = NameEncoder(d)
256
+ self.query = nn.Sequential(nn.Linear(d_model, d), nn.GELU(), nn.Linear(d, d))
257
+ self.key_in = nn.Linear(d_key, d_model, bias=False)
258
+ self.special = nn.Parameter(torch.zeros(2, d)) # new, generic
259
+ self.special_b = nn.Parameter(torch.zeros(2))
260
+ self.log_scale = nn.Parameter(torch.tensor([math.log(10.0)]))
261
+ self.nwt = nn.Linear(d_model, K_SLOTS * 43)
262
+ self.unit = nn.Linear(d_model, n_units + 1)
263
+ self.clock = nn.Linear(d_model, CLOCK)
264
+ self.approx = nn.Linear(d_model, 2)
265
+ self.n_units = n_units
266
+ self.span_q = nn.Linear(d, d) if span else None
267
+ if span:
268
+ self.span_g = nn.Parameter(torch.zeros(1))
269
+ if span and span_new:
270
+ self.span_new = nn.Parameter(torch.zeros(1))
271
+ self.sim_w = None
272
+ if sim: # 1.4.1: heard-span <-> candidate string / phonetic similarity features (namesim.py), per heard-text source row
273
+ self.sim_w = nn.Parameter(torch.zeros(3, 6)); self.sim_new = nn.Parameter(torch.zeros(3, 3))
274
+ if span:
275
+ self.span_g_src = nn.Parameter(torch.zeros(3))
276
+
277
+ def ptr_logits(self, h, cls_key, E, cmask, Es=None, Sf=None, src=0):
278
+ """h (B,T,D); cls_key (B,T,Dk); E (B,R,d) candidate name embeddings; cmask (B,T,R) bool; Es (B,T,d) heard-text embedding
279
+ (zeros where no heard text) or None; Sf (B,T,R,6) heard-text similarity features (namesim.sim_feats) or None; src = heard-text
280
+ source row (0 machine-ASR words, 1 the built-in decoder's words) -> (B,T,2+R) logits [new, generic, candidates]."""
281
+ qr = self.query(h.float() + self.key_in(cls_key.float()))
282
+ use_span = Es is not None and self.span_q is not None
283
+ if use_span:
284
+ qr = qr + self.span_q(Es.float())
285
+ q = F.normalize(qr, dim=-1)
286
+ sc = self.log_scale.clamp(max=math.log(50.0)).exp()
287
+ s = torch.einsum("btd,brd->btr", q, E.float()) * sc
288
+ if use_span:
289
+ g_ = self.span_g if getattr(self, "span_g_src", None) is None else self.span_g + self.span_g_src[int(src)]
290
+ s = s + SPAN_G_MULT * g_ * sc * torch.einsum("btd,brd->btr", Es.float(), E.float())
291
+ use_sim = Sf is not None and self.sim_w is not None
292
+ if use_sim:
293
+ Sf = Sf.float()
294
+ s = s + SPAN_G_MULT * sc * torch.einsum("btrf,f->btr", Sf, self.sim_w[int(src)])
295
+ s = s.masked_fill(~cmask, NEG)
296
+ sp = torch.einsum("btd,kd->btk", q, F.normalize(self.special, dim=-1)) * sc + self.special_b
297
+ if use_sim:
298
+ hs = (Sf.abs().sum((-1, -2)) > 0).float()
299
+ mb = Sf[..., 4].masked_fill(~cmask, 0.0).max(-1).values; msh = Sf[..., 5].masked_fill(~cmask, 0.0).max(-1).values
300
+ sn = self.sim_new[int(src)]
301
+ tn = sn[0] + sn[1] * (1.0 - mb) + sn[2] * (1.0 - msh)
302
+ sp = torch.cat([sp[..., :1] + (SPAN_G_MULT * sc * hs * tn)[..., None], sp[..., 1:]], -1)
303
+ if use_span and getattr(self, "span_new", None) is not None:
304
+ ms = torch.einsum("btd,brd->btr", Es.float(), E.float()).masked_fill(~cmask, -1.0).max(-1).values.clamp(min=-1.0)
305
+ has = (Es.float().abs().sum(-1) > 0).float()
306
+ sp = torch.cat([sp[..., :1] + SPAN_G_MULT * self.span_new * sc * has[..., None] * (1.0 - ms[..., None]), sp[..., 1:]], -1)
307
+ return torch.cat([sp, s], -1)
308
+
309
+ def span_embed(self, text):
310
+ """heard-span text (string) -> (d,) unit embedding through the shared name encoder (kind 0); keeps the most recent bytes."""
311
+ ids = torch.as_tensor(encode_names([text], keep="tail"), device=self.special.device)
312
+ return self.names(ids, torch.zeros(1, dtype=torch.long, device=ids.device))[0]
313
+
314
+ def value_logits(self, h):
315
+ return dict(nwt=self.nwt(h).float().reshape(*h.shape[:-1], K_SLOTS, -1), unit=self.unit(h).float(),
316
+ clock=self.clock(h).float(), approx=self.approx(h).float())
317
+
318
+
319
+ class BioHead(nn.Module):
320
+ """non-speech vocal events on the frame read-out (sneeze was never trained: masked in the output)."""
321
+ EV = ("cough", "throat_clear", "breath", "sneeze")
322
+
323
+ def __init__(self, d_model=512):
324
+ super().__init__()
325
+ self.ev = nn.Linear(d_model, len(self.EV))
326
+
327
+
328
+ class DiarHeads(nn.Module):
329
+ """arrival-ordered speaker slots ([none, slot 1..K]) + unit-norm voice embedding from h_t and the input frame x_t."""
330
+ def __init__(self, d_model=512, d_in=640, k=8, d=128, hidden=256, n_extra=0):
331
+ super().__init__()
332
+ self.k = k + n_extra
333
+ self.slot = nn.Linear(d_model, 1 + k)
334
+ self.h_proj = nn.Linear(d_model, hidden); self.x_norm = nn.LayerNorm(d_in); self.x_proj = nn.Linear(d_in, hidden)
335
+ self.out = nn.Sequential(nn.GELU(), nn.Linear(hidden, hidden), nn.GELU(), nn.Linear(hidden, d))
336
+ self.slot_ext = nn.Linear(d_model, n_extra) if n_extra else None
337
+
338
+ def slot_logits(self, h):
339
+ z = self.slot(h)
340
+ return torch.cat([z, self.slot_ext(h)], -1) if self.slot_ext is not None else z
341
+
342
+ def embed(self, h, x):
343
+ return F.normalize(self.out(self.h_proj(h.float()) + self.x_proj(self.x_norm(x.float()))), dim=-1)
344
+
345
+
346
+ class ArcNet(nn.Module):
347
+ def __init__(self, table, cfg):
348
+ super().__init__()
349
+ self.trunk = Trunk(**cfg["trunk"])
350
+ d = self.trunk.cfg["d_model"]
351
+ self.head = TagHead(table, n_text=int(cfg.get("n_text", 384)), d_model=d)
352
+ self.ent = EntityHeads(d_model=d, d_key=d, n_units=int(cfg.get("n_units", 64)), span=bool(cfg.get("ptr_span_text")),
353
+ span_new=bool(cfg.get("ptr_span_new")), sim=bool(cfg.get("ptr_sim_feat")))
354
+ self.diar = DiarHeads(d_model=d, d_in=self.trunk.cfg["d_in"], n_extra=max(0, int(cfg.get("diar_slots", 8)) - 8))
355
+ self.bio = BioHead(d) if cfg.get("bio_head") else None
356
+ self.word = None
357
+ if cfg.get("word_ch"): # 1.4.1 word channel (wordch.py)
358
+ from .wordch import WordChannel
359
+ self.word = WordChannel(d_model=d, d_k=d, n_in=len(table.cols["span_in"]), k_slots=K_SLOTS, n_nwt=int(cfg.get("n_nwt", 43)))
360
+ self.cfg = cfg
361
+
362
+ def trunk_run(self, x, dom, delay, state=None, recurrent=False):
363
+ return (self.trunk.step if recurrent else self.trunk)(x, dom, delay, state)
arctag/_core/arc_audio/ontology.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tag table (tags.json in the model repo): stable tag ids and order, exclusive groups, read-outs, domain validity.
2
+
3
+ Read-out per tag unit: F/G -> frame, W -> span_in + span_end, U/T -> utt, S -> speaker, E -> ev_at | ev_rec (.recent) |
4
+ ev_imm (.imminent), D -> item.
5
+ """
6
+ import hashlib, json
7
+ import numpy as np
8
+
9
+ FINE_DOMAINS = ["nba", "nhl", "nfl", "cricket", "soccer", "esports", "news", "talk", "stream_chat", "meeting", "court_appellate",
10
+ "court_trial", "assistant", "assistant_noisy", "short_clips", "unknown"]
11
+ QUERIES = ("frame", "span_in", "span_end", "utt", "speaker", "ev_at", "ev_imm", "ev_rec", "item")
12
+ POOLED = ("utt", "speaker", "item")
13
+ SPORTS = ("nba", "nhl", "nfl", "cricket", "soccer")
14
+
15
+
16
+ def readouts(t):
17
+ u = t["unit"]
18
+ if u in ("F", "G"):
19
+ return ("frame",)
20
+ if u == "W":
21
+ return ("span_in", "span_end")
22
+ if u in ("U", "T"):
23
+ return ("utt",)
24
+ if u == "S":
25
+ return ("speaker",)
26
+ if u == "D":
27
+ return ("item",)
28
+ if u == "E":
29
+ return ("ev_rec",) if t["id"].endswith(".recent") else ("ev_imm",) if t["id"].endswith(".imminent") else ("ev_at",)
30
+ raise ValueError(f"unknown unit {u} for {t['id']}")
31
+
32
+
33
+ def domain_index(domain):
34
+ d = (domain or "unknown").lower()
35
+ if d not in FINE_DOMAINS:
36
+ raise ValueError(f"unknown domain {domain!r}; use one of {FINE_DOMAINS}")
37
+ return FINE_DOMAINS.index(d)
38
+
39
+
40
+ class Table:
41
+ def __init__(self, path):
42
+ raw = open(path, "rb").read(); self.sha = hashlib.sha256(raw).hexdigest()
43
+ j = json.loads(raw); self.version = j["version"]; self.j = j
44
+ tags = sorted(j["tags"], key=lambda t: t["index"])
45
+ self.n = max(t["index"] for t in tags) + 1
46
+ self.tag = [None] * self.n
47
+ for t in tags:
48
+ self.tag[t["index"]] = t
49
+ self.ids = [t["id"] if t else f"_unused{i}" for i, t in enumerate(self.tag)]
50
+ self.idx = {t: i for i, t in enumerate(self.ids)}
51
+ rg = {g["id"]: g for g in j["groups"]}
52
+ self.groups, self.members = [], {}
53
+ for t in tags:
54
+ if t.get("exclusivity") not in (None, "independent"):
55
+ if t["group"] not in self.members:
56
+ self.groups.append(t["group"]); self.members[t["group"]] = []
57
+ self.members[t["group"]].append(t["index"])
58
+ self.group_of = {i: g for g, m in self.members.items() for i in m}
59
+ self.gate = {g: rg.get(g, {}).get("gate") for g in self.groups}
60
+ self.group_info = rg
61
+ self.trained = [i for i, t in enumerate(self.tag) if t and t.get("status") not in ("reserved", "derived")]
62
+ dg = j["domains"]
63
+ V = np.zeros((len(FINE_DOMAINS), self.n), bool)
64
+ for i in self.trained:
65
+ keys = self.tag[i]["domains"]
66
+ for d, fd in enumerate(FINE_DOMAINS):
67
+ V[d, i] = any(k == "*" or k == fd or fd in dg.get(k, []) for k in keys)
68
+ # 'unknown' (no domain given): every tag that is valid in some domain (the trunk still gets the 'unknown' domain tag)
69
+ V[FINE_DOMAINS.index("unknown")] |= V.any(0)
70
+ self.valid = V
71
+ self.ro = {i: readouts(self.tag[i]) for i in self.trained}
72
+ self.cols = {q: [i for i in self.trained if q in self.ro[i]] for q in QUERIES}
73
+ self.facet = {i: self.tag[i]["facet"] for i in self.trained}
74
+ self.status = {i: self.tag[i]["status"] for i in self.trained}
75
+
76
+ def describe(self, tag_id):
77
+ t = self.tag[self.idx[tag_id]]
78
+ return dict(id=t["id"], facet=t["facet"], unit=t["unit"], group=t.get("group"), description=t.get("description"),
79
+ paraphrases=t.get("paraphrases") or [], status=t.get("status"))
arctag/_core/arc_audio/wordch.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc 1.4.1 word channel (inference): heard transcript words feed the network next to the audio.
2
+
3
+ Each heard word j (any ASR: the built-in decoder, or external words) becomes a vector u_j:
4
+ e_j = LN(W1 mean_p(E[piece] + pos_p) + W2 E[last piece]) + flag / source embeddings (E = the shipped decoder's frozen
5
+ piece-embedding table; pieces of the package tokenizer, first 7 + last)
6
+ x_j = e_j + Wa [mean of the trunk state h over the word's own frames, h at its last own frame] + has-audio embedding
7
+ u_j = 2-layer causal LOCAL transformer over the words (window 32 words, RoPE) -> receptive field 62 words back.
8
+ Each label frame t then reads a window of up to 8 words around the newest available word (6 back, 2 ahead when available):
9
+ c_t = Wo attn(q = h_t, k = u_j + rel(t - f0_j) + rel(t - oe_j), v = u_j) (0 when no word is available)
10
+ h'_t = h_t + c_t feeds the frame / span / event read-outs, the utterance pool and the name / number heads;
11
+ the speaker / item pools, diarization and vocal-event heads read the audio-only h_t (as trained).
12
+ With no words, h' == h exactly (the audio-only network).
13
+ Word-aligned read-out (war_*): per-word B / I probabilities of every span tag (right context k words) + per-word numeral tokens.
14
+ """
15
+ import math, re
16
+ import numpy as np
17
+ import torch, torch.nn as nn, torch.nn.functional as F
18
+
19
+ P_MAX = 8 # pieces per word (first 7 + last when longer)
20
+ K_CTX = 8 # words per frame window
21
+ BACK = 6
22
+ ENC_WIN = 32 # WordEnc causal window (words)
23
+ N_ENC = 2 # WordEnc layers
24
+ RF = (ENC_WIN - 1) * N_ENC # receptive field in words (62)
25
+ OFF_CLIP = 32
26
+ SRC_ROW = dict(npz=0, ms=1, corrupt=2, none=3) # 0 = machine-ASR words (external ASR), 1 = the shipped (built-in) decoder's words
27
+ _PUNCT = re.compile(r"^[^\w]+|[^\w]+$")
28
+
29
+
30
+ def norm_word(w):
31
+ return _PUNCT.sub("", " ".join(str(w).lower().split()))
32
+
33
+
34
+ def own_ends(f0, f1):
35
+ f0 = np.asarray(f0, np.int64); f1 = np.asarray(f1, np.int64); oe = f1.copy()
36
+ nxt = np.r_[f0[1:], np.iinfo(np.int64).max]
37
+ m = (f0 < nxt) & (nxt <= f1)
38
+ oe[m] = nxt[m] - 1
39
+ return np.maximum(oe, f0)
40
+
41
+
42
+ def utt_ends(f0, oe, gap=4):
43
+ W = len(f0)
44
+ if not W:
45
+ return np.zeros(0, np.int64)
46
+ brk = np.r_[f0[1:] - oe[:-1] > gap, True]
47
+ ue = np.where(brk, oe, np.iinfo(np.int64).max)
48
+ return np.minimum.accumulate(ue[::-1])[::-1]
49
+
50
+
51
+ def rule_arrival(f0, oe, d, mode="final", Lf=15, L=8):
52
+ """offline word arrival (the trained / evaluated rule): a final transcript reaches Arc Lf frames after its utterance ends
53
+ (0.4 s pause rule), never before the word's own end + d; monotone. -> first label frame at which word j is readable."""
54
+ if not len(f0):
55
+ return np.zeros(0, np.int64)
56
+ a = (oe + L) if mode == "partial" else (utt_ends(f0, oe) + Lf)
57
+ a = np.maximum(a, oe + d)
58
+ return np.maximum.accumulate(a) - d
59
+
60
+
61
+ class Pieces:
62
+ """word -> piece ids of the package tokenizer (cached)."""
63
+
64
+ def __init__(self, tok_path):
65
+ from tokenizers import Tokenizer
66
+ self.t = Tokenizer.from_file(tok_path); self.c = {}
67
+
68
+ def __call__(self, w):
69
+ if w not in self.c:
70
+ ids = self.t.encode(w, add_special_tokens=False).ids if w else []
71
+ if len(ids) > P_MAX:
72
+ ids = ids[:P_MAX - 1] + ids[-1:]
73
+ if len(self.c) > 200000:
74
+ self.c.clear()
75
+ self.c[w] = ids
76
+ return self.c[w]
77
+
78
+
79
+ def word_rows(texts, f0, oe, src_row, pieces, prev_oe=None):
80
+ """-> tok (W,P), npc (W,), flags (W,5) int64 (training row_words features). prev_oe: own end of the word before the first
81
+ one (incremental encoding), for the gap flag."""
82
+ W = len(texts); tok = np.zeros((W, P_MAX), np.int64); npc = np.zeros(W, np.int64); flags = np.zeros((W, 5), np.int64)
83
+ for i, w in enumerate(texts):
84
+ p = pieces(w); npc[i] = len(p); tok[i, :len(p)] = p
85
+ hd = any(ch.isdigit() for ch in w)
86
+ flags[i, 0] = int(hd); flags[i, 1] = int(hd and w.isdigit())
87
+ flags[:, 2] = np.clip(npc, 0, P_MAX)
88
+ if W:
89
+ flags[:, 3] = np.clip(oe - f0 + 1, 1, 20)
90
+ flags[:, 4] = np.clip(np.r_[20 if prev_oe is None else f0[0] - prev_oe - 1, f0[1:] - oe[:-1] - 1], 0, 20)
91
+ return tok, npc, flags
92
+
93
+
94
+ def frame_window(f0, avail, t0, t1, K=K_CTX, back=BACK):
95
+ """(t1-t0, K) word indices (-1 none) read by frames t0..t1-1; avail monotone (available words are a prefix)."""
96
+ t = np.arange(t0, t1)
97
+ n = np.searchsorted(avail, t, side="right")
98
+ p = np.searchsorted(f0, t, side="right")
99
+ q = np.minimum(p, n)
100
+ idx = q[:, None] - back + np.arange(K)[None]
101
+ ok = (idx >= 0) & (idx < n[:, None])
102
+ return np.where(ok, idx, -1)
103
+
104
+
105
+ def _rope(x, pos):
106
+ dh = x.shape[-1]; inv = 1.0 / (10000 ** (torch.arange(0, dh, 2, device=x.device, dtype=torch.float32) / dh))
107
+ ang = pos.float()[..., None] * inv
108
+ while ang.dim() < x.dim():
109
+ ang = ang.unsqueeze(1)
110
+ c, s_ = torch.cos(ang), torch.sin(ang); x1, x2 = x[..., 0::2], x[..., 1::2]
111
+ return torch.stack([x1 * c - x2 * s_, x1 * s_ + x2 * c], -1).flatten(-2)
112
+
113
+
114
+ class EncLayer(nn.Module):
115
+ """pre-norm causal local self-attention (window ENC_WIN words incl. self) + FFN (blocked, as trained)."""
116
+
117
+ def __init__(self, d=256, h=4, ff=512, win=ENC_WIN, dropout=0.1):
118
+ super().__init__()
119
+ self.h, self.win = h, win
120
+ self.n1 = nn.LayerNorm(d); self.qkv = nn.Linear(d, 3 * d); self.o = nn.Linear(d, d)
121
+ self.n2 = nn.LayerNorm(d); self.ff = nn.Sequential(nn.Linear(d, ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff, d)); self.drop = nn.Dropout(dropout)
122
+
123
+ def forward(self, x, valid, pos0=0):
124
+ Bn, W, D = x.shape; h, w = self.h, self.win; dh = D // h
125
+ q, k, v = self.qkv(self.n1(x)).float().view(Bn, W, 3, h, dh).unbind(2)
126
+ pos = (torch.arange(W, device=x.device) + pos0)[None].expand(Bn, W)
127
+ q = _rope(q.transpose(1, 2), pos); k = _rope(k.transpose(1, 2), pos); v = v.transpose(1, 2)
128
+ nb = (W + w - 1) // w; Wp = nb * w; pad = Wp - W
129
+ if pad:
130
+ q, k, v = (F.pad(t_, (0, 0, 0, pad)) for t_ in (q, k, v))
131
+ vm = F.pad(valid, (0, pad)) if pad else valid
132
+ kp = F.pad(k, (0, 0, w, 0)).view(Bn, h, nb + 1, w, dh); vp = F.pad(v, (0, 0, w, 0)).view(Bn, h, nb + 1, w, dh)
133
+ kb = torch.cat([kp[:, :, :-1], kp[:, :, 1:]], 3); vb = torch.cat([vp[:, :, :-1], vp[:, :, 1:]], 3)
134
+ qb = q.view(Bn, h, nb, w, dh)
135
+ qi = (torch.arange(nb, device=x.device)[:, None] * w + torch.arange(w, device=x.device)[None])
136
+ ki = (torch.arange(nb, device=x.device)[:, None] * w - w + torch.arange(2 * w, device=x.device)[None])
137
+ mp = F.pad(vm, (w, 0)).view(Bn, nb + 1, w); km = torch.cat([mp[:, :-1], mp[:, 1:]], 2)
138
+ m = (ki[:, None, :] <= qi[:, :, None]) & (ki[:, None, :] > qi[:, :, None] - w) & (ki[:, None, :] >= 0)
139
+ m = m[None] & km[:, :, None, :]
140
+ m = m | (ki[:, None, :] == qi[:, :, None])[None]
141
+ a = F.scaled_dot_product_attention(qb.permute(0, 2, 1, 3, 4).reshape(Bn * nb, h, w, dh),
142
+ kb.permute(0, 2, 1, 3, 4).reshape(Bn * nb, h, 2 * w, dh), vb.permute(0, 2, 1, 3, 4).reshape(Bn * nb, h, 2 * w, dh),
143
+ attn_mask=m.reshape(Bn * nb, 1, w, 2 * w))
144
+ a = a.view(Bn, nb, h, w, dh).permute(0, 2, 1, 3, 4).reshape(Bn, h, Wp, dh)[:, :, :W].transpose(1, 2).reshape(Bn, W, D)
145
+ x = x + self.drop(self.o(a.to(x.dtype)))
146
+ return x + self.drop(self.ff(self.n2(x)))
147
+
148
+
149
+ class WordChannel(nn.Module):
150
+ def __init__(self, d_model=512, d_k=512, n_in=41, k_slots=4, n_nwt=43, d_w=256, d_c=128, heads_c=4, n_tok=32768, d_e=320):
151
+ super().__init__()
152
+ self.register_buffer("tok_E", torch.zeros(n_tok, d_e, dtype=torch.float16)) # frozen decoder piece table (from the weights)
153
+ self.ppos = nn.Parameter(torch.zeros(P_MAX, d_e))
154
+ self.W1 = nn.Linear(d_e, d_w); self.W2 = nn.Linear(d_e, d_w, bias=False); self.ln = nn.LayerNorm(d_w)
155
+ self.fl = nn.ModuleList([nn.Embedding(n, d_w) for n in (2, 2, P_MAX + 1, 21, 21)]); self.src = nn.Embedding(len(SRC_ROW) + 1, d_w)
156
+ self.Wa = nn.Linear(2 * d_model, d_w)
157
+ self.hav = nn.Embedding(2, d_w)
158
+ self.enc = nn.ModuleList([EncLayer(d_w) for _ in range(N_ENC)]); self.enc_norm = nn.LayerNorm(d_w)
159
+ self.hc = heads_c; self.fq = nn.Linear(d_model, d_c); self.fk = nn.Linear(d_w, d_c); self.fv = nn.Linear(d_w, d_c)
160
+ self.R0 = nn.Embedding(2 * OFF_CLIP + 1, d_c); self.R1 = nn.Embedding(2 * OFF_CLIP + 1, d_c)
161
+ self.fo = nn.Linear(d_c, d_model)
162
+ self.war_r = nn.Linear(d_w, d_w); self.war_k = nn.Embedding(3, d_w); self.war_ln = nn.LayerNorm(d_w)
163
+ self.QB = nn.Linear(d_w, d_k); self.QI = nn.Linear(d_w, d_k)
164
+ self.bB = nn.Parameter(torch.zeros(n_in)); self.bI = nn.Parameter(torch.zeros(n_in))
165
+ self.nwt = nn.Linear(d_w, k_slots * n_nwt); self.k_slots, self.n_nwt = k_slots, n_nwt
166
+
167
+ def embed_in(self, tok, npc, flags, src, hm, he, have):
168
+ """per-word encoder input x_j (B,W,d_w). hm / he (B,W,D) own-frame mean / last own-frame trunk state, have (B,W) bool."""
169
+ Et = self.tok_E[tok].float()
170
+ pm = (torch.arange(P_MAX, device=tok.device)[None, None] < npc[..., None]).float()[..., None]
171
+ mean = ((Et + self.ppos) * pm).sum(2) / npc.clamp(min=1)[..., None].float()
172
+ last = torch.gather(Et, 2, (npc - 1).clamp(min=0)[..., None, None].expand(-1, -1, 1, Et.shape[-1]))[:, :, 0]
173
+ e = self.ln(self.W1(mean) + self.W2(last))
174
+ for i, m in enumerate(self.fl):
175
+ e = e + m(flags[..., i].clamp(0, m.num_embeddings - 1))
176
+ e = e + self.src(src.clamp(0, self.src.num_embeddings - 1))
177
+ return e + self.Wa(torch.cat([hm, he], -1)) + self.hav(have.long())
178
+
179
+ def run_enc(self, x, valid, pos0=0):
180
+ for lay in self.enc:
181
+ x = lay(x, valid, pos0)
182
+ return self.enc_norm(x) * valid[..., None].float()
183
+
184
+ def frame_ctx(self, h, u, f0, oe, wctx, t0=0):
185
+ """h (B,T,D) trunk states of frames t0..t0+T-1; u (B,W,d_w); f0 / oe (B,W) word frames; wctx (B,T,K) word indices
186
+ (-1 none) -> c (B,T,D) (0 where the window is empty)."""
187
+ Bn, T, Kc = wctx.shape; ok = wctx >= 0; idx = wctx.clamp(min=0)
188
+ dc = self.fk.out_features; hh = self.hc; dh = dc // hh
189
+ flat = idx.reshape(Bn, T * Kc)
190
+ gk = torch.gather(self.fk(u), 1, flat[..., None].expand(-1, -1, dc)).view(Bn, T, Kc, dc)
191
+ gv = torch.gather(self.fv(u), 1, flat[..., None].expand(-1, -1, dc)).view(Bn, T, Kc, dc)
192
+ t = torch.arange(t0, t0 + T, device=h.device)[None, :, None]
193
+ o0 = (t - torch.gather(f0, 1, flat).view(Bn, T, Kc)).clamp(-OFF_CLIP, OFF_CLIP) + OFF_CLIP
194
+ o1 = (t - torch.gather(oe, 1, flat).view(Bn, T, Kc)).clamp(-OFF_CLIP, OFF_CLIP) + OFF_CLIP
195
+ gk = gk + self.R0(o0) + self.R1(o1)
196
+ q = self.fq(h).view(Bn, T, 1, hh, dh)
197
+ sc = (q * gk.view(Bn, T, Kc, hh, dh)).sum(-1).float() / math.sqrt(dh)
198
+ sc = sc.masked_fill(~ok[..., None], float("-inf"))
199
+ any_ = ok.any(-1)
200
+ att = torch.softmax(sc, 2).nan_to_num(0.0)
201
+ ctx = (att[..., None] * gv.view(Bn, T, Kc, hh, dh).float()).sum(2).reshape(Bn, T, dc)
202
+ return self.fo(ctx.to(h.dtype)) * any_[..., None].to(h.dtype)
203
+
204
+ def war_logits(self, u, valid, K_in, k):
205
+ Bn, W, D = u.shape
206
+ if k:
207
+ us = F.pad(u[:, k:], (0, 0, 0, k)); ok = F.pad(valid[:, k:], (0, k)) & valid
208
+ else:
209
+ us = torch.zeros_like(u); ok = valid
210
+ v = self.war_ln(u + self.war_r(us) + self.war_k.weight[k])
211
+ return self.QB(v).float() @ K_in.T + self.bB, self.QI(v).float() @ K_in.T + self.bI, ok, v
212
+
213
+ @torch.no_grad()
214
+ def war_probs(self, u, valid, K_in, k=1):
215
+ """-> P(B), P(I) (B,W,n_in) at right context k (falls back to k-1 / 0 where the right words do not exist yet), v."""
216
+ zB, zI, ok, v = self.war_logits(u, valid, K_in, k)
217
+ if k:
218
+ zB0, zI0, _, _ = self.war_logits(u, valid, K_in, k - 1) if k > 1 else self.war_logits(u, valid, K_in, 0)
219
+ zB = torch.where(ok[..., None], zB, zB0); zI = torch.where(ok[..., None], zI, zI0)
220
+ p = torch.softmax(torch.stack([torch.zeros_like(zB), zB, zI], -1), -1)
221
+ return p[..., 1], p[..., 2], v
222
+
223
+ def nwt_logits(self, v):
224
+ return self.nwt(v).float().reshape(*v.shape[:-1], self.k_slots, self.n_nwt)
225
+
226
+
227
+ class WordFeed:
228
+ """per-stream word-channel state: the word list (append-only; a revision of earlier words re-encodes from there), each word's
229
+ encoder input / output, its frames and the first label frame at which it is readable."""
230
+
231
+ def __init__(self, wch, pieces, dev, d):
232
+ self.w, self.P, self.dev, self.d = wch, pieces, dev, int(d)
233
+ self.keys = [] # (start, end, text) identity of each word in the list
234
+ self.txt, self.f0, self.oe, self.f1, self.src, self.av = [], [], [], [], [], []
235
+ self.X = None; self.U = None # (n_x, d_w) inputs / (n_u, d_w) outputs (prefix of the list)
236
+ self.n_x = 0
237
+ self.ver = None
238
+
239
+ def __len__(self):
240
+ return len(self.keys)
241
+
242
+ def sync(self, finals, f_now, arrival=None, src_of=None):
243
+ """bring the list in line with the store's final words. New words become readable at max(f_now, own end) (live) or at
244
+ `arrival[j]` (offline rule). -> True if the list changed."""
245
+ ws = [w for w in finals if norm_word(w["w"])]
246
+ keys = [(w["start"], w["end"], w["w"]) for w in ws]
247
+ n0 = 0; m = min(len(keys), len(self.keys))
248
+ while n0 < m and keys[n0] == self.keys[n0]:
249
+ n0 += 1
250
+ if n0 == len(keys) == len(self.keys):
251
+ return False
252
+ # truncate at the first difference, then append
253
+ for L in (self.keys, self.txt, self.f0, self.oe, self.f1, self.src, self.av):
254
+ del L[n0:]
255
+ self.n_x = min(self.n_x, n0)
256
+ if self.X is not None:
257
+ self.X = self.X[:self.n_x]
258
+ if self.U is not None:
259
+ self.U = self.U[:min(len(self.U), n0)]
260
+ for j in range(n0, len(ws)):
261
+ w = ws[j]
262
+ self.keys.append(keys[j]); self.txt.append(norm_word(w["w"])); self.f0.append(int(w["f0"])); self.f1.append(int(w["f1"]))
263
+ self.src.append(src_of(w) if src_of else SRC_ROW["ms"]); self.av.append(None)
264
+ f0 = np.asarray(self.f0, np.int64); oe = own_ends(f0, np.asarray(self.f1, np.int64))
265
+ # a word already encoded keeps the own end it was encoded with (a later word sharing its last frame does not re-open it)
266
+ old = self.oe[:min(n0, self.n_x)]
267
+ self.oe = [int(x) for x in oe]
268
+ self.oe[:len(old)] = old
269
+ if arrival is not None:
270
+ self.av = [int(x) for x in arrival]
271
+ else:
272
+ for j in range(n0, len(self.keys)):
273
+ a = max(int(f_now), self.oe[j])
274
+ if j:
275
+ a = max(a, self.av[j - 1])
276
+ self.av[j] = a
277
+ return True
278
+
279
+ @torch.no_grad()
280
+ def encode_upto(self, f_done, h_at, f_start=None, settle=4):
281
+ """encode every word whose own frames are all decided (oe < f_done). A word's own end depends on the NEXT word's start (a
282
+ shared frame belongs to the next word, as in the training tables), so the newest word is encoded only once a later word
283
+ is known or `settle` frames have passed after its last frame (no word can still start inside it). Words encoded in this
284
+ call are readable from f_start (the first frame of this feed) at the earliest. h_at(frames) -> (n, D) audio trunk states."""
285
+ n_new = 0
286
+ while self.n_x + n_new < len(self.keys) and self.oe[self.n_x + n_new] < f_done:
287
+ k = self.n_x + n_new
288
+ if k + 1 >= len(self.keys) and f_done <= self.f1[k] + settle:
289
+ break
290
+ n_new += 1
291
+ if not n_new:
292
+ return
293
+ if f_start is not None:
294
+ for k in range(self.n_x, len(self.av)):
295
+ lo_ = f_start if k < self.n_x + n_new else self.av[k - 1]
296
+ self.av[k] = max(self.av[k], lo_, self.av[k - 1] if k else lo_)
297
+ J = list(range(self.n_x, self.n_x + n_new))
298
+ f0 = np.asarray([self.f0[j] for j in J]); oe = np.asarray([self.oe[j] for j in J])
299
+ tok, npc, flags = word_rows([self.txt[j] for j in J], f0, oe, None, self.P, prev_oe=self.oe[J[0] - 1] if J[0] > 0 else None)
300
+ hm, he = [], []
301
+ for a, b in zip(f0, oe):
302
+ H = h_at(list(range(int(a), int(b) + 1))).float()
303
+ hm.append(H.mean(0)); he.append(H[-1])
304
+ T = lambda a, dt=torch.long: torch.as_tensor(np.asarray(a), device=self.dev, dtype=dt)[None]
305
+ x = self.w.embed_in(T(tok), T(npc), T(flags), T([self.src[j] for j in J]), torch.stack(hm)[None], torch.stack(he)[None],
306
+ torch.ones(1, n_new, dtype=torch.bool, device=self.dev))[0]
307
+ self.X = x if self.X is None or not len(self.X) else torch.cat([self.X, x], 0)
308
+ self.n_x += n_new
309
+ # outputs for words [n_u, n_x): run the encoder over the receptive field before them
310
+ n_u = 0 if self.U is None else len(self.U)
311
+ lo = max(0, n_u - RF)
312
+ xs = self.X[lo:self.n_x][None]
313
+ u = self.w.run_enc(xs, torch.ones(xs.shape[:2], dtype=torch.bool, device=self.dev), pos0=lo)[0][n_u - lo:]
314
+ self.U = u if self.U is None or not len(self.U) else torch.cat([self.U, u], 0)
315
+
316
+ @torch.no_grad()
317
+ def fuse(self, h, f_start):
318
+ """h (T, D) audio trunk states of frames f_start..; -> h' = h + c (T, D)."""
319
+ T = h.shape[0]; n_u = 0 if self.U is None else len(self.U)
320
+ if not n_u:
321
+ return h
322
+ f0 = np.asarray(self.f0[:n_u], np.int64); av = np.asarray(self.av[:n_u], np.int64)
323
+ idx = frame_window(f0, av, f_start, f_start + T)
324
+ if (idx < 0).all():
325
+ return h
326
+ lo = int(idx[idx >= 0].min()); hi = int(idx.max()) + 1
327
+ loc = np.where(idx >= 0, idx - lo, -1)
328
+ dv = self.dev
329
+ u = self.U[lo:hi][None]
330
+ F0 = torch.as_tensor(f0[lo:hi], device=dv)[None]; OE = torch.as_tensor(np.asarray(self.oe[lo:hi], np.int64), device=dv)[None]
331
+ c = self.w.frame_ctx(h[None], u, F0, OE, torch.as_tensor(loc, device=dv)[None], t0=f_start)[0]
332
+ return h + c.to(h.dtype)
arctag/_core/arc_text.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc Tagger = the Arc of Arc Transcriber (arc_audio 1.4.2 package, PK8a weights) with the audio path removed: the Arc's own word
2
+ channel is the input. arc_audio is imported read-only from the PK8a trainer's package (pk_config.json "arc_pkg" =
3
+ release_staging/arc_v142); nothing in it is edited.
4
+
5
+ per word j (one trunk step per word, no audio frames):
6
+ e_j = arc.word.embed_in(pieces, flags, src, hm=0, he=0, have=0) the word channel's input with "no audio" (exactly the
7
+ Arc's no-audio path): frozen 32768 x 320 piece table tok_E (moonshine-streaming-tiny tokenizer.json, = Pieces), first
8
+ 7 + last piece, flags (has digit, all digits, n pieces, duration, gap in 100 ms frames), word source row
9
+ + zero-init provider features (gap / duration buckets, final / interim, confidence, speaker change, asr_source)
10
+ u_j = arc.word.run_enc(e) 2 causal local self-attention layers (32-word window)
11
+ x_j = inp(in_norm(u_j)) NEW 256 -> 512 input projection replacing the audio one
12
+ (inp init = word.fo . word.fv, the value path by which the
13
+ Arc adds a word to its frame state)
14
+ h_j = arc.trunk (5 x [FiLM -> Mamba-3 -> MLP], domain adapters) chunked SSD in training, Trunk.step + carried state streaming
15
+ read-outs: arc.head TagHead on h (definition keys, pooled utt / speaker / item with the Arc carry); span_beg (the B of BIO) =
16
+ arc.word.war_* B read-out on u with right context k (T0 k=0, T1/T2 k<=2); entity KB match = arc.ent.ptr_logits (NameEncoder
17
+ candidates, [new, generic, KB...]); latency tiers = delayed read-outs of the same causal pass (word i read at i+d with a delay
18
+ embedding, T1 d<=3, T2 d = to the utterance end <= 8).
19
+ dropped: arc.diar (voice embedding needs audio), arc.bio (vocal events), trunk.inp/in_norm audio 640-d projection.
20
+ """
21
+ import math, os, sys
22
+ import numpy as np
23
+ import torch, torch.nn as nn, torch.nn.functional as F
24
+
25
+ ARC_PKG = os.path.dirname(os.path.abspath(__file__)) # [release] vendored arc_audio subset lives here
26
+ if ARC_PKG not in sys.path:
27
+ sys.path.insert(0, ARC_PKG)
28
+ from arc_audio.model import Trunk, TagHead, EntityHeads, zero_carry, encode_names, NEG # noqa: E402
29
+ from arc_audio.ontology import QUERIES as ARC_Q, POOLED # noqa: E402
30
+ from arc_audio.wordch import WordChannel, P_MAX, SRC_ROW # noqa: E402
31
+
32
+ HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE)
33
+ from tagtable import FINE_DOMAINS, ASR_SOURCES # noqa: E402
34
+
35
+ TOK_PATH = os.path.join(ARC_PKG, "moonshine-streaming-tiny", "tokenizer.json")
36
+ SCRATCH_10M = dict(d=256, d_w=256, d_e=256, vocab=8192, n_layers=6, adapters=16, d_state=64, head_dim=64, dropout=0.1) # 9.79 M
37
+ WORD_Q = ("frame", "span_beg", "span_in", "span_end", "ev_at", "ev_imm", "ev_rec")
38
+ FEATS = {"gap": 10, "dur": 8, "fin": 3, "conf": 7, "chg": 3, "asr": 8}
39
+ D_MAX = 8
40
+ TIER_W = {"T0": 1.0, "T1": 0.5, "T2": 0.5}
41
+ ARC_CFG = dict(d_in=640, d_model=512, n_layers=5, d_state=64, head_dim=64, dropout=0.2, in_drop=0.2, chunk=128, n_dom=16,
42
+ delays=[0, 1, 2, 3, 5, 8], adapters=64, long_mem=True) # release config.json "model.trunk"
43
+
44
+
45
+ BPE8K = os.path.join(os.environ.get("ARCTAG_HOME", os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), "tok", "bpe8k", "tokenizer.json") # [release] # own byte-level BPE (train_bpe.py), words as " w"
46
+
47
+
48
+ class ArcPieces:
49
+ """tokenizer adapter for data.Windows / the streaming engines: word -> piece ids, no specials.
50
+ moonshine (the Arc package's tokenizer): the word itself (its normalizer prepends the space marker);
51
+ own byte-level BPE (tok/bpe8k): " " + word, exactly as train_bpe.py fed it."""
52
+ def __init__(self, path=TOK_PATH):
53
+ from tokenizers import Tokenizer
54
+ self.path = path
55
+ self.t = Tokenizer.from_file(path)
56
+ self.vocab_size = self.t.get_vocab_size()
57
+ self.space = "bpe8k" in path or "/tok/" in path
58
+ self.cls_token_id = 0; self.pad_token_id = 0; self.unk_token_id = 0; self.mask_token_id = 0
59
+
60
+ def word(self, w):
61
+ return (" " + w.strip()) if self.space else w.strip()
62
+
63
+ def __call__(self, texts, add_special_tokens=False):
64
+ enc = self.t.encode_batch([self.word(t) for t in texts], add_special_tokens=False)
65
+ return {"input_ids": [e.ids for e in enc]}
66
+
67
+ def encode_word(self, w):
68
+ return self.t.encode(self.word(w), add_special_tokens=False).ids
69
+
70
+
71
+ class TableShim:
72
+ def __init__(self, T):
73
+ self.n = T.n; self.valid = T.valid
74
+ self.cols = {q: list(T.cols[q]) for q in ARC_Q}
75
+
76
+
77
+ def frames(t):
78
+ return int(round(t * 10)) if t is not None else None
79
+
80
+
81
+ class ArcText(nn.Module):
82
+ def __init__(self, table, n_in_war=41, n_units=83, d_in_new=256, train_tokE=True, scratch=None):
83
+ """scratch=None: the Arc's own dims (34.1 M, warm start from PK8a). scratch=dict(d, d_w, d_e, vocab, n_layers, adapters,
84
+ d_state, head_dim): the same block types, narrower, trained from scratch (audio-only / unused sub-modules removed)."""
85
+ super().__init__()
86
+ self.T = table; self.scratch = scratch
87
+ tc = dict(ARC_CFG); tc["d_in"] = d_in_new
88
+ if scratch:
89
+ tc.update(d_in=scratch["d_w"], d_model=scratch["d"], n_layers=scratch["n_layers"], adapters=scratch["adapters"],
90
+ d_state=scratch.get("d_state", 64), head_dim=scratch.get("head_dim", 64), delays=[0], dropout=scratch.get("dropout", 0.1))
91
+ self.trunk = Trunk(**tc)
92
+ d = tc["d_model"]; self.d = d
93
+ nn.init.ones_(self.trunk.in_norm.weight); nn.init.zeros_(self.trunk.in_norm.bias)
94
+ self.head = TagHead(TableShim(table), n_text=table.E.shape[1], d_model=d, d_k=d)
95
+ with torch.no_grad():
96
+ self.head.E.copy_(torch.as_tensor(table.E))
97
+ if scratch:
98
+ self.ent = EntityHeads(d_model=d, d_key=d, n_units=n_units, span=False, span_new=False, sim=False)
99
+ for k in ("nwt", "unit", "clock", "approx"): # numeral value heads: no stage-1 target -> removed
100
+ delattr(self.ent, k)
101
+ self.word = WordChannel(d_model=d, d_k=d, n_in=n_in_war, k_slots=4, n_nwt=43, d_w=scratch["d_w"], n_tok=scratch["vocab"],
102
+ d_e=scratch["d_e"], d_c=64)
103
+ for k in ("fq", "fk", "fv", "fo", "R0", "R1", "QI", "bI", "bB", "nwt", "Wa", "hav"): # audio-fusion / unused read-outs
104
+ delattr(self.word, k)
105
+ del self.word._buffers["tok_E"]
106
+ self.word.tok_E = nn.Parameter(torch.randn(scratch["vocab"], scratch["d_e"]) * 0.02)
107
+ else:
108
+ self.ent = EntityHeads(d_model=d, d_key=d, n_units=n_units, span=True, span_new=True, sim=True)
109
+ self.word = WordChannel(d_model=d, d_k=d, n_in=n_in_war, k_slots=4, n_nwt=43)
110
+ if train_tokE: # end-to-end: the piece table becomes a trainable fp32 parameter
111
+ del self.word._buffers["tok_E"]
112
+ self.word.tok_E = nn.Parameter(torch.zeros(32768, 320))
113
+ self.feat = nn.ModuleDict({k: nn.Embedding(n, self.word.ln.normalized_shape[0]) for k, n in FEATS.items()})
114
+ for m in self.feat.values():
115
+ nn.init.zeros_(m.weight)
116
+ self.delay_q = nn.Embedding(D_MAX + 1, d); nn.init.zeros_(self.delay_q.weight)
117
+ self.bB_all = nn.Parameter(torch.zeros(table.n)) # span_beg bias per r8 tag (war bB on the r5 columns)
118
+ self.register_buffer("cols_span_beg", torch.tensor(table.cols["span_beg"], dtype=torch.long), persistent=False)
119
+ self.ver = nn.Linear(2 * d, 1)
120
+ self.lm = nn.Linear(d, self.word.tok_E.shape[1])
121
+
122
+ # ------------------------------------------------------------------ word input
123
+ def word_inputs(self, b):
124
+ """token-level batch (data.collate, ArcPieces ids) -> pieces (B,W,P_MAX) (first 7 + last), counts, word features"""
125
+ ids, tok_word, feats = b["ids"], b["tok_word"], b["feats"]; W = b["wvalid"].shape[1]
126
+ B, S = ids.shape; dev = ids.device
127
+ valid = tok_word >= 0; tw = tok_word.clamp(min=0)
128
+ pos = torch.arange(S, device=dev)[None].expand(B, S)
129
+ first = torch.full((B, W), S, dtype=torch.long, device=dev).scatter_reduce(1, tw, torch.where(valid, pos, torch.full_like(pos, S)), "amin")
130
+ rank = pos - torch.gather(first, 1, tw)
131
+ npc_all = torch.zeros(B, W, dtype=torch.long, device=dev).scatter_add(1, tw, valid.long())
132
+ nw = torch.gather(npc_all, 1, tw)
133
+ keep = valid & ((rank < P_MAX - 1) | (rank == nw - 1))
134
+ slot = torch.where(rank < P_MAX - 1, rank, torch.full_like(rank, P_MAX - 1))
135
+ pcs = torch.zeros(B, W, P_MAX, dtype=torch.long, device=dev)
136
+ bi = torch.arange(B, device=dev)[:, None].expand(B, S)
137
+ pcs[bi[keep], tw[keep], slot[keep]] = ids[keep]
138
+ npc = npc_all.clamp(max=P_MAX)
139
+ sc = lambda src: torch.zeros(B, W, dtype=torch.long, device=dev).scatter(1, torch.where(valid, tw, torch.zeros_like(tw)),
140
+ torch.where(valid, src, torch.zeros_like(src)))
141
+ fw = {k: sc(feats[k]) for k in self.feat if k in feats}
142
+ spk = sc(feats["spk"]) if "spk" in feats else torch.zeros(B, W, dtype=torch.long, device=dev)
143
+ return pcs, npc, fw, spk
144
+
145
+ def embed(self, pcs, npc, fw, wfl, wsrc):
146
+ """arc.word.embed_in with no audio + provider features. wfl (B,W,5) Arc flags (npc filled here), wsrc (B,W) source row"""
147
+ fl = wfl.clone(); fl[..., 2] = npc.clamp(0, P_MAX)
148
+ if self.scratch: # = WordChannel.embed_in without the audio terms
149
+ w = self.word; Et = w.tok_E[pcs].float()
150
+ pm = (torch.arange(P_MAX, device=pcs.device)[None, None] < npc[..., None]).float()[..., None]
151
+ mean = ((Et + w.ppos) * pm).sum(2) / npc.clamp(min=1)[..., None].float()
152
+ last = torch.gather(Et, 2, (npc - 1).clamp(min=0)[..., None, None].expand(-1, -1, 1, Et.shape[-1]))[:, :, 0]
153
+ e = w.ln(w.W1(mean) + w.W2(last))
154
+ for i, m in enumerate(w.fl):
155
+ e = e + m(fl[..., i].clamp(0, m.num_embeddings - 1))
156
+ e = e + w.src(wsrc.clamp(0, w.src.num_embeddings - 1))
157
+ else:
158
+ z = torch.zeros(*pcs.shape[:2], self.d, device=pcs.device)
159
+ e = self.word.embed_in(pcs, npc, fl, wsrc, z, z, torch.zeros(pcs.shape[:2], dtype=torch.bool, device=pcs.device))
160
+ for k, m in self.feat.items():
161
+ if k in fw:
162
+ e = e + m(fw[k].clamp(0, m.num_embeddings - 1))
163
+ return e
164
+
165
+ def keys(self):
166
+ return self.head.keys()
167
+
168
+ def span_beg_logits(self, u, valid, k_i, dom, K):
169
+ """war B read-out (arc.word.war_*) on the word-channel output with right context k (per word, 0..2) = WordChannel.war_logits
170
+ with the text table's span_beg columns and a per-r8-tag bias"""
171
+ c = self.cols_span_beg; Kb = K[c]; w = self.word; W = u.shape[1]
172
+ out = None
173
+ for k in range(3):
174
+ us = F.pad(u[:, k:], (0, 0, 0, k)) if k else torch.zeros_like(u)
175
+ v = w.war_ln(u + w.war_r(us) + w.war_k.weight[k])
176
+ zB = w.QB(v).float() @ Kb.T + self.bB_all[c]
177
+ out = zB if out is None else torch.where((k_i == k)[..., None], zB, out)
178
+ return out.masked_fill(~self.head.valid[dom][:, c][:, None, :], NEG)
179
+
180
+ @staticmethod
181
+ def utt_last(uend, wvalid):
182
+ B, W = uend.shape; dev = uend.device
183
+ t = torch.arange(W, device=dev)[None].expand(B, W)
184
+ last_valid = (wvalid.long().sum(1) - 1).clamp(min=0)[:, None]
185
+ e = torch.where(uend | (t == last_valid), t, torch.full_like(t, W - 1))
186
+ return torch.flip(torch.cummin(torch.flip(e, [1]), 1).values, [1])
187
+
188
+ def tier_delays(self, tier, uend, wvalid):
189
+ B, W = uend.shape
190
+ t = torch.arange(W, device=uend.device)[None].expand(B, W)
191
+ to_end = self.utt_last(uend, wvalid) - t
192
+ if tier == "T0":
193
+ return torch.zeros_like(t)
194
+ return to_end.clamp(min=0, max=3 if tier == "T1" else D_MAX)
195
+
196
+ def delayed(self, h, d_i):
197
+ """read-out state of word i at delay d: h_{i+d} + delay_q(d) [+ stage 2 dmix(h_i) for d > 0: the delayed read-out sees word i
198
+ itself; stage 1 drifted toward word i+d (T1 person spans peaked +3 words off)]"""
199
+ W = h.shape[1]
200
+ j = (torch.arange(W, device=h.device)[None] + d_i).clamp(max=W - 1)
201
+ out = torch.gather(h, 1, j[..., None].expand(-1, -1, h.shape[-1])) + self.delay_q(d_i.clamp(0, D_MAX))
202
+ if hasattr(self, "dattn"): # v2 A2: attention over h_i..h_{i+d} (zero-init output)
203
+ out = out + self.dattn(h, d_i)
204
+ elif hasattr(self, "dmix"):
205
+ out = out + self.dmix(h) * (d_i > 0)[..., None].to(h.dtype)
206
+ return out
207
+
208
+ def add_delay_mix(self):
209
+ """stage 2: W_d (d x d, no bias, zero-init -> stage-1 outputs unchanged; T0 never affected) adding word i's own state to its
210
+ delayed read-out"""
211
+ self.dmix = nn.Linear(self.d, self.d, bias=False); nn.init.zeros_(self.dmix.weight)
212
+ return self
213
+
214
+ def add_v2(self, gattn_at=(1, 3), delay_attn=True, span_head=True, utt_att=True, gattn_bias=False):
215
+ """AT10M-v2 (v2.py): global attention layers after trunk blocks gattn_at, attention delayed read-out, per-family span head,
216
+ attention-pooled utterance read-out. Every new output path is zero-initialised except the span head (new read-out)."""
217
+ import v2 as V2
218
+ if gattn_at:
219
+ self.gat = nn.ModuleDict({str(i): V2.GlobalAttn(self.d, biased=gattn_bias) for i in gattn_at}) # v2.1: ALiBi + key prior + gate
220
+ if delay_attn:
221
+ self.dattn = V2.DelayAttn(self.d, D_MAX)
222
+ if hasattr(self, "dmix"):
223
+ del self.dmix
224
+ if span_head:
225
+ self.span_head = V2.SpanHead(self.d, self.T)
226
+ if utt_att:
227
+ self.utt_att = nn.Linear(self.d, 1); nn.init.zeros_(self.utt_att.weight); nn.init.zeros_(self.utt_att.bias)
228
+ return self
229
+
230
+ def add_memread(self, d_ext=0):
231
+ """v2.2 (a): session mention memory read into the span read-outs (v2.MemRead; exact no-op at insertion)"""
232
+ import v2 as V2
233
+ self.memr = V2.MemRead(self.d, d_ext=d_ext, d_u=len(self.cols_span_beg))
234
+ return self
235
+
236
+ def utt_pool_att(self, h, ustart):
237
+ """v2 A6: utterance pool = softmax(utt_att(z))-weighted running mean of z = h + pool_in.utt(h) within the utterance (zero init
238
+ -> the plain running mean of pool_segment)"""
239
+ z = (h + self.head.pool_in["utt"](h)).float()
240
+ w = torch.exp(self.utt_att(z).clamp(-10, 10)) # (B, W, 1)
241
+ cw = torch.cumsum(w, 1); cz = torch.cumsum(w * z, 1)
242
+ idx = torch.arange(h.shape[1], device=h.device)[None].expand_as(ustart)
243
+ st = torch.cummax(torch.where(ustart, idx, torch.zeros_like(idx)), 1).values # start index of the current utterance
244
+ prev = (st - 1).clamp(min=0)
245
+ has = (st > 0)[..., None]
246
+ cw0 = torch.where(has, torch.gather(cw, 1, prev[..., None]), torch.zeros_like(cw))
247
+ cz0 = torch.where(has, torch.gather(cz, 1, prev[..., None].expand(-1, -1, z.shape[-1])), torch.zeros_like(cz))
248
+ return (cz - cz0) / (cw - cw0).clamp(min=1e-9)
249
+
250
+ def encode(self, b):
251
+ pcs, npc, fw, spk = self.word_inputs(b)
252
+ e = self.embed(pcs, npc, fw, b["wfl"], b["wsrc"])
253
+ u = self.word.run_enc(e, b["wvalid"])
254
+ dom = b["dom"]
255
+ if hasattr(self, "gat"):
256
+ import v2 as V2
257
+ h = V2.trunk_forward(self.trunk, self.gat, self.trunk_in(u), dom, torch.zeros_like(dom),
258
+ ckpt=getattr(self, "grad_ckpt", False) and self.training)
259
+ return pcs, spk, u, h
260
+ if getattr(self, "grad_ckpt", False) and self.training:
261
+ h = self.trunk_ckpt(self.trunk_in(u), dom, torch.zeros_like(dom))
262
+ else:
263
+ h, _ = self.trunk(self.trunk_in(u), dom, torch.zeros_like(dom))
264
+ return pcs, spk, u, h
265
+
266
+ def trunk_ckpt(self, x, dom, delay):
267
+ """= Trunk.forward (state None) with each [FiLM -> Mamba-3 -> MLP] block activation-checkpointed (training memory)"""
268
+ from torch.utils.checkpoint import checkpoint
269
+ from arc_audio.mamba3.ssd import mamba3_layer_ssd_state, _f
270
+ tr = self.trunk; h, e = tr._cond(x, dom, delay)
271
+ for i, (lay, mlp) in enumerate(zip(tr.layers, tr.mlps)):
272
+ def blk(h, e, i=i, lay=lay, mlp=mlp):
273
+ gb = tr.film[i](e)[:, None]; h = h * (1 + gb[..., :h.shape[-1]]) + gb[..., h.shape[-1]:]
274
+ s, _ = mamba3_layer_ssd_state(lay, _f(h), None, tr.chunk)
275
+ h = h + s
276
+ return h + mlp(h)
277
+ h = checkpoint(blk, h, e, use_reentrant=False)
278
+ return tr.out_norm(tr._adapt(h, dom))
279
+
280
+ def trunk_in(self, u):
281
+ return u # Trunk applies in_norm + inp itself (d_in = 256)
282
+
283
+ def forward(self, b, tiers=("T0",), lm=False):
284
+ pcs, spk, u, h = self.encode(b)
285
+ dom = b["dom"]; W = h.shape[1]
286
+ spk_i = torch.where(spk > 0, spk - 1, torch.full_like(spk, -1))
287
+ st = b["ustart"].clone(); st[:, 0] = True
288
+ zin, _ = self.head.pooled(h, spk_i, st, zero_carry(h.shape[0], h.shape[-1], h.device))
289
+ if hasattr(self, "utt_att"):
290
+ zin["utt"] = self.utt_pool_att(h, st)
291
+ K = self.keys()
292
+ pooled = {q: self.head.logits_q(q, zin[q], dom, K) for q in POOLED}
293
+ out = {"h": h, "u": u, "tiers": {}}; mkv = None
294
+ for tier in tiers:
295
+ dl = self.tier_delays(tier, b["uend"], b["wvalid"])
296
+ hd = self.delayed(h, dl)
297
+ if hasattr(self, "memr"): # v2.2 (a): earlier-mention read (keys shared across tiers)
298
+ if mkv is None:
299
+ mkv = self.memr.keys(h, b.get("cng"))
300
+ if self.training and torch.is_grad_enabled(): # activation-checkpointed: GPU headroom is small
301
+ from torch.utils.checkpoint import checkpoint
302
+ y_, a_ = checkpoint(lambda hd_, h_, k_, v_, m_: self.memr(hd_, h_, kv=(k_, v_, m_)), hd, h, *mkv, use_reentrant=False)
303
+ else:
304
+ y_, a_ = self.memr(hd, h, kv=mkv)
305
+ hd = hd + y_.to(hd.dtype)
306
+ lg = {q: self.head.logits_q(q, hd, dom, K) for q in WORD_Q if q != "span_beg"}
307
+ lg["span_beg"] = self.span_beg_logits(u, b["wvalid"], dl.clamp(max=2), dom, K)
308
+ if hasattr(self, "memr"): # v2.2 (a): gated span_beg logit increment (read at word i)
309
+ lg["span_beg"] = lg["span_beg"] + self.memr.to_u(a_).float()
310
+ lg.update(pooled)
311
+ out["tiers"][tier] = lg
312
+ if hasattr(self, "kb_spot"):
313
+ out.setdefault("kb_spot", {})[tier] = self.kb_spot(hd).float()
314
+ if hasattr(self, "span_head"):
315
+ out.setdefault("span_head", {})[tier] = self.span_head(hd)
316
+ if tier == "T0" and hasattr(self, "pii_imm"): # stage 2: "a PII span starts within 1..3 words" (T0 only)
317
+ out["pii_imm"] = self.pii_imm(hd).float()
318
+ out["logits"] = out["tiers"][tiers[0]]
319
+ if lm:
320
+ out["lm_h"] = self.lm(h) # logits at the selected rows only (train.py)
321
+ out["lm"] = None if getattr(self, "lm_rows", False) else out["lm_h"] @ self.word.tok_E.float().t()
322
+ out["lm_target"] = torch.cat([pcs[:, 1:, 0], torch.zeros_like(pcs[:, :1, 0])], 1)
323
+ out["lm_valid"] = torch.cat([b["wvalid"][:, 1:], torch.zeros_like(b["wvalid"][:, :1])], 1)
324
+ return out
325
+
326
+ # ------------------------------------------------------------------ entities: verification + KB pointer
327
+ def span_rep(self, hw, spans):
328
+ cs = F.pad(torch.cumsum(hw, 0), (0, 0, 1, 0))
329
+ mean = (cs[spans[:, 1]] - cs[spans[:, 0]]) / (spans[:, 1] - spans[:, 0]).clamp(min=1)[:, None].to(hw.dtype)
330
+ return torch.cat([mean, hw[spans[:, 1] - 1]], -1)
331
+
332
+ # ------------------------------------------------------------------ long-list KB (stage 2, DESIGN.md section 13; +~136 k params)
333
+ def add_kb_long(self):
334
+ import kb as KBM
335
+ D = self.d; dn = self.ent.names.d
336
+ self.kb_len = nn.Embedding(7, D) # query span length 1..6 (+ longer) -> added to the span-end state
337
+ self.kb_type = nn.Embedding(len(KBM.TYPES), dn) # entry type -> added to the NameEncoder entry vector
338
+ self.kb_spot = nn.Linear(D, 6) # "a KB entry of length L ends at this word" (L = 1..6), per tier
339
+ self.ver_e = nn.Sequential(nn.Linear(2 * D + 4 * dn, 128), nn.GELU(), nn.Linear(128, 1)) # entry-aware verification
340
+ for m in (self.kb_len, self.kb_type):
341
+ nn.init.zeros_(m.weight)
342
+ nn.init.zeros_(self.kb_spot.weight); nn.init.constant_(self.kb_spot.bias, -4.0)
343
+ return self
344
+
345
+ def add_kb_str(self, cng=True, vstr=True):
346
+ """v2.2 (b) char-n-gram mention key (hashed char 3-grams of the heard span -> added to the KB query before normalisation) and
347
+ (c) ver_e string features (span text vs entry text similarity -> added to ver_e's hidden pre-activation). Both zero-init:
348
+ outputs are bit-identical until trained; used only where the caller passes the span texts / features (else skipped)."""
349
+ import kb_strfeat as KS
350
+ dn = self.ent.names.d
351
+ if cng:
352
+ self.kb_cng = nn.EmbeddingBag(KS.NB, dn, mode="mean"); nn.init.zeros_(self.kb_cng.weight)
353
+ if vstr:
354
+ self.ver_s = nn.Linear(KS.NF, self.ver_e[0].out_features, bias=False); nn.init.zeros_(self.ver_s.weight)
355
+ return self
356
+
357
+ def add_redact(self, n=8):
358
+ """stage 2 real-time redaction (DESIGN.md section 14): the PII-imminent read-out, one logit per redact.PII type
359
+ (D x 8 + 8 = 2,056 params); zero weight, bias -4 (rare event) so the stage-1 outputs are unchanged at the start"""
360
+ self.pii_imm = nn.Linear(self.d, n)
361
+ nn.init.zeros_(self.pii_imm.weight); nn.init.constant_(self.pii_imm.bias, -4.0)
362
+ return self
363
+
364
+ def add_spk_link(self, flux=True):
365
+ """stage 2 text speaker linking (DESIGN.md 14.8, speaker_link.SpkLink) + input features "spksrc" (speaker ids: none / gold /
366
+ ASR-like) and, with flux, "turn" (Flux-like turn events). New embeddings are zero-initialised (stage-1 outputs unchanged)."""
367
+ import speaker_link as SL
368
+ n_roles = len(role_cols(self.T))
369
+ self.spk_link = SL.SpkLink(self.d, n_roles)
370
+ dw = self.word.ln.normalized_shape[0]
371
+ self.feat["spksrc"] = nn.Embedding(3, dw); nn.init.zeros_(self.feat["spksrc"].weight)
372
+ if flux:
373
+ self.feat["turn"] = nn.Embedding(4, dw); nn.init.zeros_(self.feat["turn"].weight)
374
+ return self
375
+
376
+ def kb_entry_vecs(self, texts, types):
377
+ """entry texts + type ids -> (N, dn) unit vectors (the model's NameEncoder + a type embedding); precomputable per list"""
378
+ dev = self.bB_all.device
379
+ ids = torch.as_tensor(encode_names(texts), device=dev)
380
+ e = self.ent.names(ids, torch.zeros(len(texts), dtype=torch.long, device=dev))
381
+ return F.normalize(e + self.kb_type(torch.as_tensor(types, device=dev)), dim=-1)
382
+
383
+ def kb_query(self, h, bi, a, b, qtext=None):
384
+ """span queries: h (B, W, D); bi / a / b (n,) -> rep (n, 2D) [mean, end], q (n, dn) unit; qtext: heard span texts (v2.2 b)"""
385
+ cs = F.pad(torch.cumsum(h, 1), (0, 0, 1, 0))
386
+ mean = (cs[bi, b] - cs[bi, a]) / (b - a).clamp(min=1)[:, None].to(h.dtype)
387
+ hend = h[bi, b - 1]; rep = torch.cat([mean, hend], -1)
388
+ q0 = self.ent.query(hend.float() + self.kb_len((b - a).clamp(1, 6)))
389
+ if qtext is not None and hasattr(self, "kb_cng"):
390
+ import kb_strfeat as KS
391
+ ids, off = KS.cng_batch(qtext)
392
+ q0 = q0 + self.kb_cng(torch.as_tensor(ids, device=h.device), torch.as_tensor(off, device=h.device)).to(q0.dtype)
393
+ q = F.normalize(q0, dim=-1)
394
+ return rep, q
395
+
396
+ def kb_scores(self, q, E):
397
+ """q (n, dn), E (n, K, dn) or (M, dn) -> candidate logits; null logit (n,) = the pointer's 'new' slot"""
398
+ sc = self.ent.log_scale.clamp(max=math.log(50.0)).exp()
399
+ s = torch.einsum("nd,nkd->nk", q, E) if E.dim() == 3 else q @ E.t()
400
+ null = (q @ F.normalize(self.ent.special, dim=-1)[0]) * sc + self.ent.special_b[0]
401
+ return s * sc, null
402
+
403
+ def kb_ver_e(self, rep, q, e, sf=None):
404
+ """entry-aware verification logit: the span heard as exactly this entry (rejects sound-alikes / wrong digits / qualifiers);
405
+ sf (..., NF): v2.2 (c) string features of (span text, entry text)"""
406
+ x = torch.cat([rep.float(), q, e, q * e, (q - e).abs()], -1)
407
+ if sf is None or not hasattr(self, "ver_s"):
408
+ return self.ver_e(x)[..., 0]
409
+ z = self.ver_e[0](x) + self.ver_s(sf.to(x.dtype))
410
+ return self.ver_e[2](self.ver_e[1](z))[..., 0]
411
+
412
+ def kb_embed(self, names, kinds=None):
413
+ ids = torch.as_tensor(encode_names(names), device=self.bB_all.device)
414
+ kk = torch.zeros(len(names), dtype=torch.long, device=ids.device) if kinds is None else kinds
415
+ return self.ent.names(ids, kk)
416
+
417
+ def kb_logits(self, h_end, kbe):
418
+ """h at the span's last word (n,D), KB embeddings (R,d) -> (n, 2+R) [new, generic, candidates] (arc ptr head)"""
419
+ n = h_end.shape[0]
420
+ cm = torch.ones(1, n, kbe.shape[0], dtype=torch.bool, device=h_end.device)
421
+ return self.ent.ptr_logits(h_end[None], torch.zeros(1, n, self.d, device=h_end.device), kbe[None], cm)[0]
422
+
423
+
424
+ # ---------------------------------------------------------------------- warm start
425
+ @torch.no_grad()
426
+ def role_cols(T):
427
+ """positions (within the speaker read-out's columns) of the speaker.role.* tags"""
428
+ return [k for k, i in enumerate(T.cols["speaker"]) if T.ids[i].startswith("speaker.role.")]
429
+
430
+
431
+ def warm_start(model, export_dir, verbose=True):
432
+ """PK8a export (arc_pk.safetensors, keys 'arc.*') -> ArcText. Same dims everywhere except the replaced input projection."""
433
+ from safetensors.torch import load_file
434
+ sd = load_file(os.path.join(export_dir, "arc_pk.safetensors"))
435
+ sd = {k[4:]: v for k, v in sd.items() if k.startswith("arc.")}
436
+ tgt = dict(model.named_parameters()); tgt.update({k: v for k, v in model.named_buffers() if k in ("head.E", "word.tok_E")})
437
+ rep = {"exact": [], "derived": [], "new": [], "dropped": [], "shape_mismatch": []}
438
+ for k, v in sd.items():
439
+ if k in ("trunk.inp.weight", "trunk.inp.bias", "trunk.in_norm.weight", "trunk.in_norm.bias") or k.startswith(("diar.", "bio.")):
440
+ rep["dropped"].append(k); continue
441
+ if k not in tgt:
442
+ rep["dropped"].append(k); continue
443
+ if tgt[k].shape != v.shape:
444
+ rep["shape_mismatch"].append((k, tuple(v.shape), tuple(tgt[k].shape))); continue
445
+ tgt[k].copy_(v.to(tgt[k].dtype)); rep["exact"].append(k)
446
+ # input projection = the word -> frame value path of the word channel (fo . fv)
447
+ fo_w, fo_b, fv_w, fv_b = (sd[x].float() for x in ("word.fo.weight", "word.fo.bias", "word.fv.weight", "word.fv.bias"))
448
+ tgt["trunk.inp.weight"].copy_(fo_w @ fv_w); tgt["trunk.inp.bias"].copy_(fo_w @ fv_b + fo_b)
449
+ rep["derived"] += ["trunk.inp.weight", "trunk.inp.bias"]
450
+ # span_beg bias per r8 tag from war bB (r5 span_in column order = the grown table's first n5 span_in columns)
451
+ import json
452
+ g = json.load(open(os.path.join(export_dir, "arc_grow.json")))
453
+ c5 = g["cols"]["span_in"][: sd["word.bB"].shape[0]]
454
+ for j, i in enumerate(c5):
455
+ model.bB_all[i] = sd["word.bB"][j]
456
+ rep["derived"].append("bB_all")
457
+ done = set(rep["exact"]) | set(rep["derived"])
458
+ rep["new"] = [n for n, _ in model.named_parameters() if n not in done]
459
+ P = dict(model.named_parameters())
460
+ cnt = lambda names: sum((P[n] if n in P else tgt[n]).numel() for n in names if n in P or n in tgt)
461
+ rep["summary"] = {k: dict(tensors=len(rep[k]), params=cnt(rep[k])) for k in ("exact", "derived", "new")}
462
+ rep["summary"]["dropped"] = dict(tensors=len(rep["dropped"]), params=sum(sd[k].numel() for k in rep["dropped"]))
463
+ rep["summary"]["export_total"] = sum(v.numel() for v in sd.values())
464
+ if verbose:
465
+ print("warm start", rep["summary"], "mismatch", rep["shape_mismatch"])
466
+ return rep
467
+
468
+
469
+ def param_table(m):
470
+ out = {}
471
+ extra = [] if any(n == "word.tok_E" for n, _ in m.named_parameters()) else [("word.tok_E", m.word.tok_E)]
472
+ for n, p in list(m.named_parameters()) + extra:
473
+ g = n.split(".")[0]
474
+ if g == "trunk":
475
+ g = "trunk." + ("layers (Mamba-3)" if ".layers." in n else "mlps" if ".mlps." in n else "film" if ".film." in n else
476
+ "adapt" if "adapt" in n else "inp/in_norm (new text input proj)" if (".inp." in n or "in_norm" in n) else "emb/norm")
477
+ elif g == "word":
478
+ g = "word." + ("tok_E (piece table)" if n == "word.tok_E" else "enc (2 local attn layers)" if ".enc." in n else
479
+ "war/nwt (word read-outs)" if any(x in n for x in ("war", "QB", "QI", "bB", "bI", "nwt")) else
480
+ "fusion (fq/fk/fv/fo/R0/R1)" if any(x in n for x in (".fq", ".fk", ".fv", ".fo", ".R0", ".R1")) else "embed_in")
481
+ out[g] = out.get(g, 0) + p.numel()
482
+ out["TOTAL"] = sum(v for v in out.values())
483
+ out["trainable"] = sum(p.numel() for p in m.parameters() if p.requires_grad)
484
+ return dict(sorted(out.items()))
485
+
486
+
487
+ def rmsnorm_fp32():
488
+ """bf16 autocast: nn.RMSNorm (fp32 weights) on a bf16 input falls off the fused kernel (dtype-mismatch warning). Run it in fp32 on
489
+ the fused kernel and cast the result back to the input dtype."""
490
+ if getattr(nn.RMSNorm, "_fp32_patch", False):
491
+ return
492
+
493
+ def fwd(self, x):
494
+ with torch.autocast("cuda", enabled=False):
495
+ return F.rms_norm(x.float(), self.normalized_shape, self.weight, self.eps).to(x.dtype)
496
+ nn.RMSNorm.forward = fwd; nn.RMSNorm._fp32_patch = True
arctag/_core/engine.py ADDED
@@ -0,0 +1,888 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc Tagger live demo: checkpoint manager (hot reload of the newest ckpt/AT10M/step<N>) + per-session streaming engine.
2
+
3
+ ArcSession wraps code/fast_arc.FastArc (the parity-tested streaming engine; snapshot revision, T0 / T1 / T2 delayed read-outs)
4
+ and turns its raw logits into UI-ready objects:
5
+ spans per tier, decoded from span_beg / span_in / span_end (W tags: entities, numbers, PII, profanity, toxicity), with the
6
+ span verification head (model.ver) and, when a session KB is given, the KB pointer (model.kb_logits) for norm tags
7
+ events ev_at / ev_imm / ev_rec above threshold, peak-picked (speaker change, turn yield, command complete, sports, legal, meeting)
8
+ utt running pooled utt / speaker / item read-outs every word, final ones at the endpoint (intent, act, safety U tags, role)
9
+ frame F / G states of the newest word (content mode / phase / game state / subject)
10
+ Thresholds: step config.json["thresholds"][tag@q][fine domain] with a fallback chain (see Thresholds).
11
+ CPU only; nothing here touches the GPU or writes into the checkpoint dir.
12
+ """
13
+ import glob, json, math, os, re, sys, threading, time
14
+ import numpy as np
15
+
16
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
17
+ import torch
18
+ import torch.nn.functional as F
19
+
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ B = os.path.dirname(HERE)
22
+ CODE = HERE # [release] vendored modules live next to engine.py
23
+ sys.path.insert(0, CODE)
24
+ import arc_text as AT # noqa: E402
25
+ import stream_arc as SA # noqa: E402
26
+ import fast_arc as FA # noqa: E402
27
+ from tagtable import TextTable, FINE_DOMAINS, domain_index # noqa: E402
28
+ from losses import GroupIndex # noqa: E402
29
+ from arc_text import POOLED # noqa: E402
30
+
31
+ PROD_CKPT = os.path.join(B, "ckpt", "AT10M")
32
+ CKPT_DIR = os.environ.get("ARCTAG_CKPT", PROD_CKPT)
33
+ # prod stays on ckpt/AT10M (v2 pilots live in other dirs): another dir on prod needs the explicit ARCTAG_CKPT_SWITCH=1
34
+ if os.environ.get("ARCTAG_ROLE", "prod") == "prod" and os.path.realpath(CKPT_DIR) != os.path.realpath(PROD_CKPT) \
35
+ and os.environ.get("ARCTAG_CKPT_SWITCH") != "1":
36
+ raise RuntimeError(f"prod ckpt dir must be {PROD_CKPT} (got {CKPT_DIR}); set ARCTAG_CKPT_SWITCH=1 to switch explicitly")
37
+ # hot reload never changes the model architecture (v1 span_in <-> v2 span head) unless ARCTAG_ARCH_SWITCH=1 (then restart instead)
38
+ ARCH_SWITCH = os.environ.get("ARCTAG_ARCH_SWITCH") == "1"
39
+ # v2 checkpoints: display spans / redaction from the span head. auto (default): per family, the head where its dev exact F1 (T0)
40
+ # is >= the span_in typed decode's - HEAD_TOL (eval.json / ARCTAG_SPAN_THR dev_exact_f1), span_in otherwise; 1 = every family; 0 = never
41
+ SPAN_HEAD_MODE = os.environ.get("ARCTAG_SPAN_HEAD", "auto")
42
+ SPAN_HEAD = SPAN_HEAD_MODE != "0"
43
+ HEAD_TOL = 0.05 # the dev "fam:" decode is span_in at its oracle threshold, an upper bound of the demo's span_in path (pilot step2000
44
+ # probe set: head better for number / pii / profanity / toxicity at dev deficits <= .03, worse for mentions at -.09)
45
+ # ARCTAG_SPAN_THR=<json with thresholds_span> (staging tests: a checkpoint whose eval has not written thresholds_span yet)
46
+ SPAN_THR_FILE = json.load(open(os.environ["ARCTAG_SPAN_THR"])) if os.environ.get("ARCTAG_SPAN_THR") else {}
47
+
48
+ # UI domain -> fine domain (thresholds are keyed by fine domains) + fallbacks for missing threshold cells
49
+ UI_DOMAINS = {"auto": ["unknown"], "talk": ["talk", "stream_chat", "news"], "meeting": ["meeting"],
50
+ "court": ["court_appellate", "court_trial"], "assistant": ["assistant", "assistant_noisy"],
51
+ "sports": ["nba", "nhl", "nfl", "soccer", "cricket"], "news": ["news", "talk"]}
52
+
53
+ # fine engine domain within a vertical (FiLM / adapters / validity / thresholds are per fine domain; the UI only picks the vertical):
54
+ # lexicon cues over the words so far, decided once the best domain has FINE_MIN hits and FINE_RATIO x the runner-up
55
+ FINE_CUES = {
56
+ "sports": {
57
+ "nba": "basketball nba rebound rebounds dunk layup three-pointer threes jumper assist assists paint free-throw buzzer "
58
+ "lakers celtics warriors knicks bulls heat nuggets bucks suns mavericks clippers rockets spurs nets sixers raptors "
59
+ "dribble backcourt fourth-quarter timeout",
60
+ "nhl": "hockey nhl puck goalie power-play penalty-kill slapshot slap faceoff icing blue-line rink skate skates "
61
+ "bruins rangers penguins maple leafs canadiens blackhawks oilers flames capitals lightning stick",
62
+ "nfl": "nfl football touchdown quarterback yard yards field-goal end-zone interception sack sacks fumble punt kickoff "
63
+ "linebacker receiver tight-end scrimmage patriots cowboys packers chiefs eagles steelers 49ers broncos down-and",
64
+ "soccer": "soccer goalkeeper keeper offside midfield midfielder striker penalty-area corner free-kick pitch premier "
65
+ "league champions-league fifa uefa header crossbar extra-time stoppage arsenal chelsea liverpool madrid "
66
+ "barcelona united city tottenham",
67
+ "cricket": "cricket wicket wickets bowler bowling batsman batter innings over overs boundary six sixes lbw stumps "
68
+ "crease spinner pace yorker bouncer test odi t20 ipl umpire maiden run-out century fifty",
69
+ },
70
+ "court": {
71
+ "court_appellate": "chief-justice justice justices petitioner petitioners respondent respondents argument amicus "
72
+ "certiorari circuit appellate appeal brief briefs precedent statute statutory may-it-please "
73
+ "constitutional rebuttal counsel",
74
+ "court_trial": "witness witnesses objection sustained overruled jury jurors exhibit cross-examination direct-examination "
75
+ "redirect testimony testify testified sworn defendant plaintiff prosecution verdict sidebar approach "
76
+ "bailiff deposition",
77
+ },
78
+ }
79
+ FINE_MIN, FINE_RATIO = 4, 2.0
80
+ FINE_REDOMAIN = os.environ.get("ARCTAG_FINE_REDOMAIN", "1") != "0"
81
+
82
+
83
+ def _fine_index():
84
+ ix = {}
85
+ for ui, d in FINE_CUES.items():
86
+ for fine, cues in d.items():
87
+ for c in cues.split():
88
+ ix.setdefault(ui, {})[tuple(c.replace("-", " ").split())] = fine
89
+ return ix
90
+
91
+
92
+ FINE_IX = _fine_index()
93
+
94
+
95
+ def fine_hint(ui, words, last=3000):
96
+ """the fine domain the words' lexicon cues point to within UI domain `ui` (sports / court), or None (undecided / other ui)"""
97
+ ix = FINE_IX.get(ui)
98
+ if not ix or not words:
99
+ return None
100
+ toks = [re.sub(r"[^a-z0-9]", "", w["w"].lower()) for w in words[-last:]]
101
+ cnt = {}
102
+ for i in range(len(toks)):
103
+ for L in (1, 2, 3):
104
+ f = ix.get(tuple(toks[i:i + L])) if i + L <= len(toks) else None
105
+ if f:
106
+ cnt[f] = cnt.get(f, 0) + 1
107
+ if not cnt:
108
+ return None
109
+ r = sorted(cnt.items(), key=lambda x: -x[1])
110
+ if r[0][1] >= FINE_MIN and (len(r) == 1 or r[0][1] >= FINE_RATIO * r[1][1]):
111
+ return r[0][0]
112
+ return None
113
+
114
+
115
+ FAMILY = [("pii", ("safety.pii.",)), ("profanity", ("safety.profanity.", "safety.toxicity.")),
116
+ ("number", ("entity.number.", "entity.numeral")), ("entity", ("entity.mention.",))]
117
+ SKIP_PREFIX = ("affect.",) # not trained in AT10M (DESIGN 12: masked)
118
+ STATE_GROUPS = ["content.mode", "content.game_state", "content.legal_phase", "content.meeting_phase", "content.subject"]
119
+
120
+
121
+ DECODE_FAM = [("number", ("entity.number.",)), ("entity", ("entity.mention.",)), ("pii", ("safety.pii.",)),
122
+ ("profanity", ("safety.profanity.",)), ("toxicity", ("safety.toxicity.",))]
123
+ SPAN_EXCL = os.environ.get("ARCTAG_SPAN_EXCL", "1") != "0" # type-exclusive span decode per family (ARCTAG_SPAN_EXCL=0: per-tag runs)
124
+
125
+
126
+ UMBRELLA = ("entity.numeral", "safety.toxicity.toxic_any") # co-labelled WITH a subtype in the gold (82 of 253 news number spans
127
+ # overlap a numeral): never merged, never compete with the subtypes
128
+
129
+
130
+ def decode_family(tag):
131
+ if tag in UMBRELLA:
132
+ return None
133
+ for fam, pre in DECODE_FAM:
134
+ if tag.startswith(pre):
135
+ return fam
136
+ return None
137
+
138
+
139
+ def family_of(tag):
140
+ for fam, pre in FAMILY:
141
+ if tag.startswith(pre):
142
+ return fam
143
+ return "other"
144
+
145
+
146
+ def list_steps(d=CKPT_DIR):
147
+ out = []
148
+ for p in glob.glob(os.path.join(d, "step*")):
149
+ m = re.fullmatch(r"step(\d+)", os.path.basename(p))
150
+ f = os.path.join(p, "model.safetensors")
151
+ if m and os.path.exists(f):
152
+ out.append((int(m.group(1)), p))
153
+ return sorted(out)
154
+
155
+
156
+ PROD_THR_DIR = os.environ.get("ARCTAG_PROD_THR", os.path.join(HERE, "prod_thresholds"))
157
+ DISPLAY_TIER = os.environ.get("ARCTAG_DISPLAY_TIER", "T0")
158
+ AUTO_REDOMAIN = os.environ.get("ARCTAG_AUTO_REDOMAIN", "1") != "0" # auto: re-condition the engine on the detected vertical's domain # tier the demo decodes spans / events / states from by default
159
+ SAFETY_DOMS = {"talk", "stream_chat", "short_clips", "esports"} # fine domains of the safety verticals (contact centre, T&S, gaming)
160
+ CLAMP_SPAN = 0.3 # display floor of span_beg / span_in thresholds (T0-fitted values of rare tags can be tiny)
161
+ CLAMP_TOX = 0.5 # safety.toxicity.* span / utt floor outside the safety verticals (news, sports, meetings, legal, auto)
162
+
163
+
164
+ SAME_VERT = [{"nba", "nhl", "nfl", "cricket", "soccer"}, {"court_appellate", "court_trial"}, {"assistant", "assistant_noisy"}]
165
+ THR_FALLBACK = os.environ.get("ARCTAG_THR_FALLBACK", "pooled") # pooled (default) | sibling (pre-19:05 behaviour)
166
+
167
+
168
+ def lookup_thr(v, doms):
169
+ """one threshold cell {fine domain | 'unknown' | '_all': thr} for the session's fine domains (doms[0] = primary).
170
+ Tables with a pooled '_all' (the demo's T0 refit): the primary domain or a same-vertical sibling (sports leagues, the two
171
+ court domains, assistant / assistant_noisy), else the POOLED value (a domain with < 10 dev positives has no key; a sibling
172
+ from another vertical, e.g. news -> talk, is not a valid stand-in). Other tables: doms -> 'unknown' -> median."""
173
+ if not v:
174
+ return None
175
+ if THR_FALLBACK == "pooled" and "_all" in v:
176
+ d0 = doms[0] if doms else "unknown"
177
+ sib = next((g for g in SAME_VERT if d0 in g), {d0})
178
+ for d in [d0] + [d for d in doms if d in sib and d != d0]:
179
+ if d in v:
180
+ return float(v[d])
181
+ return float(v["_all"])
182
+ for d in list(doms) + ["unknown", "_all"]:
183
+ if d in v:
184
+ return float(v[d])
185
+ return float(np.median(list(v.values())))
186
+
187
+
188
+ def clamp_thr(col, doms, x):
189
+ """display clamps on a looked-up threshold (doms = the fine domains of the session, first = primary)"""
190
+ tag, _, q = col.rpartition("@")
191
+ if q in ("span_in", "span_beg"):
192
+ x = max(x, CLAMP_SPAN)
193
+ if tag.startswith("safety.toxicity.") and (not doms or doms[0] not in SAFETY_DOMS):
194
+ x = max(x, CLAMP_TOX)
195
+ return x
196
+
197
+
198
+ def load_prod_thresholds(step, d=None):
199
+ """demo/prod_thresholds/step<N>.json (T0-fitted on dev, refit_thr) for the newest N <= step -> (thresholds, N) or ({}, None)"""
200
+ d = d or PROD_THR_DIR
201
+ best = None
202
+ for p in glob.glob(os.path.join(d, "step*.json")):
203
+ m = re.fullmatch(r"step(\d+)\.json", os.path.basename(p))
204
+ if m and int(m.group(1)) <= step and (best is None or int(m.group(1)) > best[0]):
205
+ best = (int(m.group(1)), p)
206
+ if best is None:
207
+ return {}, None
208
+ try:
209
+ return json.load(open(best[1])).get("thresholds") or {}, best[0]
210
+ except Exception:
211
+ return {}, None
212
+
213
+
214
+ def load_event_tiers(step, d=None):
215
+ """demo/prod_thresholds/events_step<N>.json (newest N <= step; tests ev_tier fit): {"tier": {tag: T0|T1}, "hide": [tags],
216
+ "thresholds": {tag@q: {domain|_all: thr}} fitted AT each tag's tier} -> (doc, N) or ({}, None)"""
217
+ d = d or PROD_THR_DIR
218
+ best = None
219
+ for p in glob.glob(os.path.join(d, "events_step*.json")):
220
+ m = re.fullmatch(r"events_step(\d+)\.json", os.path.basename(p))
221
+ if m and int(m.group(1)) <= step and (best is None or int(m.group(1)) > best[0]):
222
+ best = (int(m.group(1)), p)
223
+ if best is None:
224
+ return {}, None
225
+ try:
226
+ return json.load(open(best[1])), best[0]
227
+ except Exception:
228
+ return {}, None
229
+
230
+
231
+ class Thresholds:
232
+ """thr(tag@q, ui_domain): the demo's T0-fitted table (prod_thresholds/step<=N>.json) -> the step's own per-domain value ->
233
+ the newest older step that has thresholds; within each table: the fine domains of the UI domain -> 'unknown' -> '_all'
234
+ (pooled) -> median over the domains it has; default 0.5. Display clamps (clamp_thr) on top."""
235
+ def __init__(self, own, older=None, older_step=None, default=0.5, pref=None, pref_step=None):
236
+ self.own = own or {}; self.older = older or {}; self.older_step = older_step; self.default = default
237
+ self.pref = pref or {}; self.pref_step = pref_step
238
+ self.ev = {}; self.ev_step = None # per-event display tier + tier-fitted event thresholds (load_event_tiers)
239
+
240
+ def set_events(self, doc, step):
241
+ self.ev = doc or {}; self.ev_step = step
242
+ if self.ev.get("thresholds"): # event thresholds fitted at the event's tier take precedence
243
+ self.pref = dict(self.pref, **self.ev["thresholds"])
244
+
245
+ def ev_tier(self, tag, default="T0"):
246
+ return (self.ev.get("tier") or {}).get(tag, default)
247
+
248
+ def ev_hidden(self, tag):
249
+ return tag in set(self.ev.get("hide") or ())
250
+
251
+ @staticmethod
252
+ def _look(th, col, doms):
253
+ return lookup_thr(th.get(col), doms)
254
+
255
+ def tables(self):
256
+ return [t for t in (self.pref, self.own, self.older) if t]
257
+
258
+ def __call__(self, col, ui_dom):
259
+ doms = list(ui_dom) if isinstance(ui_dom, (list, tuple)) else UI_DOMAINS.get(ui_dom, ["unknown"])
260
+ x = None
261
+ for th in self.tables():
262
+ x = self._look(th, col, doms)
263
+ if x is not None:
264
+ break
265
+ return clamp_thr(col, doms, self.default if x is None else x)
266
+
267
+ def source(self):
268
+ s = f"prod_thresholds/step{self.pref_step} (T0) + " if self.pref_step is not None else ""
269
+ if self.ev_step is not None:
270
+ s += f"events_step{self.ev_step} + "
271
+ if self.own:
272
+ return s + "own"
273
+ return s + (f"step{self.older_step}" if self.older else "default 0.5")
274
+
275
+
276
+ class Loaded:
277
+ def __init__(self, step, path, model, table, thr, cfg):
278
+ self.step, self.path, self.model, self.table, self.thr, self.cfg = step, path, model, table, thr, cfg
279
+ self.GI = GroupIndex(table)
280
+ self.has_kb = hasattr(model, "ent") and hasattr(model.ent, "ptr_logits")
281
+ self.has_head = hasattr(model, "span_head")
282
+ self.arch = tuple(sorted(k for k in ("span_head", "gat", "dattn", "dmix", "kb_spot", "pii_imm", "spk_link") if hasattr(model, k)))
283
+ self.head_tags = {f: list(v[2]) for f, v in model.span_head.fm.items() if v[0]} if self.has_head else {}
284
+ self.head_use, self.head_why = self._head_families()
285
+ self.loaded_at = time.time()
286
+
287
+ def _head_families(self):
288
+ """families decoded from the span head (see SPAN_HEAD_MODE) + why"""
289
+ if not self.head_tags or SPAN_HEAD_MODE == "0":
290
+ return set(), "off"
291
+ if SPAN_HEAD_MODE == "1":
292
+ return set(self.head_tags), "all"
293
+ f1 = SPAN_THR_FILE.get("dev_exact_f1", {}).get("T0") if SPAN_THR_FILE.get("dev_exact_f1") else None
294
+ if f1 is None:
295
+ try:
296
+ sp = json.load(open(os.path.join(self.path, "eval.json")))["dev"]["span_f1"]["spans"]["T0"]
297
+ f1 = {k: (v.get("all") or {}).get("exact_f1") for k, v in sp.items()}
298
+ except Exception:
299
+ f1 = None
300
+ if not f1:
301
+ return set(self.head_tags), "all (no dev F1)"
302
+ use = {f for f in self.head_tags if f1.get("head:" + f) is not None and (f1.get("fam:" + f) is None or f1["head:" + f] >= f1["fam:" + f] - HEAD_TOL)}
303
+ return use, {f: (f1.get("head:" + f), f1.get("fam:" + f)) for f in self.head_tags}
304
+
305
+ def head_thr(self, tier, fam):
306
+ """v2 span-head threshold on 1 - P(O) for a family (config.json thresholds_span {tier: {head:<family>: th}}, dev-fitted
307
+ by the trainer's eval), tier -> T0 -> 0.5"""
308
+ ts = (self.cfg or {}).get("thresholds_span") or SPAN_THR_FILE.get("thresholds_span") or {}
309
+ for t in (tier, "T0"):
310
+ v = (ts.get(t) or {}).get("head:" + fam)
311
+ if v is not None:
312
+ return float(v)
313
+ return 0.5
314
+
315
+
316
+ class ModelManager:
317
+ """loads the newest complete step; a watcher thread polls for newer ones (file size stable + loadable) and swaps them in."""
318
+ def __init__(self, ckpt_dir=CKPT_DIR, poll_s=20.0, log=print):
319
+ self.dir = ckpt_dir; self.poll = poll_s; self.log = log
320
+ self.table = TextTable(); self.table.load_E()
321
+ self.cur = None; self.lock = threading.Lock(); self.listeners = []; self._sizes = {}
322
+ steps = list_steps(ckpt_dir)
323
+ if not steps:
324
+ raise RuntimeError(f"no step*/model.safetensors in {ckpt_dir}")
325
+ for st, p in reversed(steps): # newest that loads
326
+ try:
327
+ self.cur = self._load(st, p); break
328
+ except Exception as e: # partially written -> try older
329
+ self.log(f"[ckpt] step{st} not loadable yet: {e!r}")
330
+ if self.cur is None:
331
+ raise RuntimeError("no loadable checkpoint")
332
+ self.log(f"[ckpt] loaded step{self.cur.step} (thresholds: {self.cur.thr.source()})"
333
+ + (f" span head: {sorted(self.cur.head_use)} ({self.cur.head_why})" if self.cur.has_head else ""))
334
+
335
+ def _thresholds(self, step):
336
+ own = {}
337
+ cj = os.path.join(self.dir, f"step{step}", "config.json")
338
+ try:
339
+ own = json.load(open(cj)).get("thresholds") or {}
340
+ except Exception:
341
+ pass
342
+ older, ostep = {}, None
343
+ if not own:
344
+ for st, p in reversed(list_steps(self.dir)):
345
+ if st >= step:
346
+ continue
347
+ try:
348
+ th = json.load(open(os.path.join(p, "config.json"))).get("thresholds") or {}
349
+ except Exception:
350
+ th = {}
351
+ if th:
352
+ older, ostep = th, st; break
353
+ pref, pstep = load_prod_thresholds(step)
354
+ t = Thresholds(own, older, ostep, pref=pref, pref_step=pstep)
355
+ t.set_events(*load_event_tiers(step))
356
+ return t
357
+
358
+ def _load(self, step, path):
359
+ from safetensors.torch import load_file
360
+ cfg = {}
361
+ try:
362
+ cfg = json.load(open(os.path.join(path, "config.json")))
363
+ except Exception:
364
+ pass
365
+ sc = cfg.get("scratch") or AT.SCRATCH_10M
366
+ m = AT.ArcText(self.table, scratch=sc)
367
+ sd = load_file(os.path.join(path, "model.safetensors"))
368
+ SA.add_modules_for(m, sd) # every stage-2 / v2 module the keys imply (kb_long, redact, spk_link, v2)
369
+ missing, unexpected = m.load_state_dict(sd, strict=False)
370
+ if unexpected:
371
+ raise RuntimeError(f"step{step}: checkpoint keys without a module: {unexpected[:6]}")
372
+ bad = [k for k in missing if not k.startswith(("head.E",))]
373
+ if bad:
374
+ self.log(f"[ckpt] step{step}: {len(bad)} missing keys (e.g. {bad[:3]})")
375
+ m.eval()
376
+ return Loaded(step, path, m, self.table, self._thresholds(step), cfg)
377
+
378
+ def refresh_thresholds(self):
379
+ """the async eval writes thresholds into config.json after the step appears: pick them up"""
380
+ c = self.cur
381
+ t = self._thresholds(c.step)
382
+ if (bool(t.own) != bool(c.thr.own)) or (t.older_step != c.thr.older_step) or (t.pref_step != c.thr.pref_step) \
383
+ or (t.ev_step != c.thr.ev_step):
384
+ c.thr = t; return True
385
+ return False
386
+
387
+ def check_new(self):
388
+ steps = list_steps(self.dir)
389
+ changed = self.refresh_thresholds()
390
+ if not steps or steps[-1][0] <= self.cur.step:
391
+ return changed
392
+ st, p = steps[-1]
393
+ f = os.path.join(p, "model.safetensors")
394
+ sz = os.path.getsize(f); prev = self._sizes.get(st); self._sizes[st] = sz
395
+ if prev != sz or time.time() - os.path.getmtime(f) < 30:
396
+ return changed # still being written: wait for a stable size
397
+ if self._sizes.get(("arch", st)):
398
+ return changed # already rejected (architecture change): do not reload it every poll
399
+ try:
400
+ new = self._load(st, p)
401
+ except Exception as e:
402
+ self.log(f"[ckpt] step{st} load failed: {e!r}"); return changed
403
+ if new.arch != self.cur.arch and not ARCH_SWITCH:
404
+ if self._sizes.get(("arch", st)) is None:
405
+ self.log(f"[ckpt] step{st}: architecture {new.arch} != running {self.cur.arch}: NOT hot-reloaded (ARCTAG_ARCH_SWITCH=1 + restart)")
406
+ self._sizes[("arch", st)] = 1
407
+ return changed
408
+ with self.lock:
409
+ self.cur = new
410
+ self.log(f"[ckpt] hot-reloaded step{st} (thresholds: {new.thr.source()})")
411
+ for cb in list(self.listeners):
412
+ try:
413
+ cb(new)
414
+ except Exception as e:
415
+ self.log(f"[ckpt] listener error {e!r}")
416
+ return True
417
+
418
+ def watch(self, stop_evt, on_change=None):
419
+ while not stop_evt.wait(self.poll):
420
+ try:
421
+ if self.check_new() and on_change:
422
+ on_change(self.cur)
423
+ except Exception as e:
424
+ self.log(f"[ckpt] watch error {e!r}")
425
+
426
+
427
+ # ---------------------------------------------------------------------------------------------------------------- session
428
+ class ArcSession:
429
+ """one live stream: provider-agnostic events (adapters.py schema) -> FastArc -> UI updates."""
430
+ SPAN_WIN = 96 # words re-decoded per update (revisions never reach further back than FastArc.KEEP = 64)
431
+
432
+ def __init__(self, loaded, ui_domain="auto", asr="deepgram", kb=None, int8=False, floor=0.0):
433
+ self.ui_domain = ui_domain if ui_domain in UI_DOMAINS else "auto"
434
+ self.fine = UI_DOMAINS[self.ui_domain][0]; self.asr = asr; self.int8 = int8; self.floor = float(floor or 0.0)
435
+ self.kb_names = [k.strip() for k in (kb or []) if k and k.strip()][:256]
436
+ self.words = [] # provider word dicts (ours; mirror of engine.words)
437
+ self.utt_bounds = [] # word indices where an utterance ended (endpoint at k)
438
+ self.events = []; self._ev_keys = set(); self.alerts = []; self._al_keys = set()
439
+ self.utt_final = [] # finished utterances: {w0, w1, readouts}
440
+ self.lat = [] # (ms, n_words) per engine update
441
+ self._spksrc = None # stage-2 / v2 input "spksrc" (0 no speaker ids, 1 gold, 2 ASR-like): set at the first words
442
+ self._bind(loaded)
443
+
444
+ # ---- model binding (also used by hot reload: rebuild + replay)
445
+ def _bind(self, loaded):
446
+ self.L = loaded; T = loaded.table
447
+ self.e = FA.FastArc(loaded.model, T, domain=self.fine, asr=self.asr)
448
+ if self.int8:
449
+ FA.quantize_fast(self.e)
450
+ self._set_xfeat()
451
+ self.store = {"T0": {}, "T1": {}, "T2": {}} # (word) -> {q: probs np}
452
+ self.pooled_now = None
453
+ # column metadata per read-out
454
+ self.cn = {q: [n.split("@")[0] for n in T.col_names(q)] for q in ("frame", "span_beg", "span_in", "span_end", "ev_at", "ev_imm", "ev_rec", "utt", "speaker", "item")}
455
+ thr = loaded.thr; d = self.doms
456
+ self.th_raw = {q: np.array([thr(f"{t}@{q}", d) for t in names], np.float32) for q, names in self.cn.items()}
457
+ self.th = {q: np.maximum(v, self.floor) for q, v in self.th_raw.items()} # optional display floor (UI), 0 = pure per-domain
458
+ valid = T.valid[domain_index(self.fine)]
459
+ self.valid = {q: np.array([bool(valid[T.idx[t]]) for t in names]) for q, names in self.cn.items()}
460
+ bi = {t: i for i, t in enumerate(self.cn["span_beg"])}
461
+ self.beg_of_in = np.array([bi.get(t, -1) for t in self.cn["span_in"]])
462
+ norm = {T.ids[i] for i in T.norm_tags}
463
+ self.is_norm = np.array([t in norm for t in self.cn["span_in"]])
464
+ self.kbe = None
465
+ if self.kb_names and loaded.has_kb:
466
+ with torch.inference_mode():
467
+ self.kbe = loaded.model.kb_embed(self.kb_names)
468
+
469
+ def _set_xfeat(self):
470
+ """session-constant stage-2 inputs: spksrc as the trainer's data.Windows sets it (2 = ASR-like ids, the live providers'
471
+ diarization; 0 = no ids) and turn = 0 (no Flux turn events). Without it FastArc embeds spksrc 0 for every word while the
472
+ model saw 2 on ASR sessions. ARCTAG_SPKSRC overrides (parity on gold-id sessions: 1)."""
473
+ if self._spksrc is not None and ("spksrc" in self.e.feat or "turn" in self.e.feat):
474
+ self.e.xfeat = {"spksrc": self._spksrc, "turn": 0}
475
+
476
+ def set_kb(self, names):
477
+ self.kb_names = [k.strip() for k in (names or []) if k and k.strip()][:256]
478
+ self.kbe = None
479
+ if self.kb_names and self.L.has_kb:
480
+ with torch.inference_mode():
481
+ self.kbe = self.L.model.kb_embed(self.kb_names)
482
+
483
+ def rebind(self, loaded):
484
+ """hot reload: new FastArc on the new weights, replay the session utterance by utterance (one chunk pass each)"""
485
+ ws = [dict(w) for w in self.words]; ub = list(self.utt_bounds)
486
+ self.utt_bounds = []
487
+ self._bind(loaded); self.words = ws
488
+ t = time.perf_counter(); lo = 0
489
+ for k in ub + [len(ws)]:
490
+ if k > lo:
491
+ self._absorb(self.e.update(lo, ws[lo:k]))
492
+ if k in ub:
493
+ self._absorb(self.e.endpoint()); self.utt_bounds.append(k)
494
+ lo = k
495
+ self.words = ws
496
+ return round(1000 * (time.perf_counter() - t), 1)
497
+
498
+ @property
499
+ def doms(self):
500
+ """threshold domains: the engine's fine domain first, then the UI domain's other codes"""
501
+ return [self.fine] + [d for d in UI_DOMAINS[self.ui_domain] if d != self.fine]
502
+
503
+ def set_domain(self, ui_domain, fine=None):
504
+ """re-condition the engine on another UI domain (auto mode, after the vertical detector locks): FastArc's FiLM /
505
+ adapters / validity are per fine domain and the model never sees 'unknown' for domain-labelled data, so 'auto' running on
506
+ 'unknown' all session shifts every read-out (parity: mean |dp| frame .11, utt max .99). Rebuild + replay (= hot reload).
507
+ Returns the replay ms, or None when unchanged."""
508
+ ui = ui_domain if ui_domain in UI_DOMAINS else "auto"
509
+ fine = fine if fine in UI_DOMAINS[ui] else UI_DOMAINS[ui][0]
510
+ if ui == self.ui_domain and fine == self.fine:
511
+ return None
512
+ self.ui_domain = ui; self.fine = fine
513
+ return self.rebind(self.L)
514
+
515
+ def _update(self, k, ws):
516
+ """FastArc.update with a snapshot AT k. FastArc snapshots only at chunk starts, so a revision from k replays words[s0:k] +
517
+ ws as one chunk from the nearest older snapshot s0 and leaves none at k. Streams that keep revising from about the same
518
+ point (Flux re-sends the whole turn every Update; nova-3 the whole interim segment) then grow that chunk on every message
519
+ until s0 falls out of the KEEP window, and from then on every update is a full-session replay from word 0 (O(session)
520
+ per message). Running the unchanged prefix words[s0:k] as its own chunk first puts a snapshot at k: revisions stay
521
+ O(revised words). Same outputs (chunked == sequential), both read-out batches are returned."""
522
+ e = self.e
523
+ if k < e.n:
524
+ s0 = max([x for x in e.snap if x <= k], default=None)
525
+ if s0 is None or s0 < k:
526
+ d1 = dict(e.update(k, []))
527
+ d2 = e.update(k, ws)
528
+ d1.update(d2)
529
+ return d1
530
+ return e.update(k, ws)
531
+
532
+ # ---- event application (= adapters.TaggerSession.apply, with timing + UI materialisation)
533
+ def apply(self, evs):
534
+ """returns (lo, changed) where lo = lowest word index whose display may have changed, or None"""
535
+ lo = None; utt_done = []
536
+ for x in evs:
537
+ t = x["type"]
538
+ if t == "words":
539
+ k = x["from"]; ws = x["words"]; j = 0
540
+ while j < len(ws) and k + j < len(self.e.words) and ws[j] == self.e.words[k + j]:
541
+ j += 1
542
+ if j == len(ws) and k + j == len(self.e.words):
543
+ continue
544
+ k2 = k + j
545
+ if self._spksrc is None and "spksrc" in self.e.feat:
546
+ ov = os.environ.get("ARCTAG_SPKSRC")
547
+ self._spksrc = int(ov) if ov else (2 if any(w.get("spk") is not None for w in ws) else 0)
548
+ self._set_xfeat()
549
+ t0 = time.perf_counter()
550
+ due = self._update(k2, ws[j:])
551
+ self.lat.append((1000 * (time.perf_counter() - t0), max(1, len(self.e.words) - min(k2, len(self.words)))))
552
+ self.words = list(self.e.words)
553
+ self._prune(); self._absorb(due); self._running()
554
+ lo = k2 if lo is None else min(lo, k2)
555
+ elif t == "finalize":
556
+ ws = self.e.words; up = min(x["upto"], len(ws))
557
+ first = next((i for i in range(up) if not ws[i].get("final", True)), None)
558
+ if first is not None:
559
+ t0 = time.perf_counter()
560
+ due = self._update(first, [dict(w, final=True) for w in ws[first:up]] + ws[up:])
561
+ self.lat.append((1000 * (time.perf_counter() - t0), max(1, len(ws) - first)))
562
+ self.words = list(self.e.words)
563
+ self._prune(); self._absorb(due); self._running()
564
+ lo = first if lo is None else min(lo, first)
565
+ elif t == "endpoint":
566
+ if not self.e.n or (self.utt_bounds and self.utt_bounds[-1] >= self.e.n):
567
+ continue
568
+ due = self.e.endpoint()
569
+ self._absorb(due)
570
+ s = self.utt_bounds[-1] if self.utt_bounds else 0
571
+ self.utt_bounds.append(self.e.n)
572
+ ro = self.readouts(self.e.n - 1, final=True)
573
+ u = {"w0": s, "w1": self.e.n, "readouts": ro, "text": " ".join(w["w"] for w in self.words[s:self.e.n])}
574
+ self.utt_final.append(u); utt_done.append(u)
575
+ self._alerts_utt(u)
576
+ lo = s if lo is None else min(lo, s)
577
+ return lo, utt_done
578
+
579
+ def _prune(self):
580
+ for tier, st in self.store.items():
581
+ have = self.e.out[tier]
582
+ for i in [i for i in st if i not in have or i >= self.e.n]:
583
+ del st[i]
584
+
585
+ def _absorb(self, due):
586
+ GI = self.L.GI
587
+ for key, lg in due.items():
588
+ tier, i = key
589
+ if tier == "pooled":
590
+ continue
591
+ P = {}
592
+ for q, z in lg.items():
593
+ if q == "span_head": # v2 per-family (type, BIO) logits -> softmax probabilities
594
+ P[q] = {f: (zt.softmax(-1).numpy(), zb.softmax(-1).numpy()) for f, (zt, zb) in z.items()}
595
+ continue
596
+ P[q] = GI.probs(q, z[None])[0].numpy() if z.numel() else np.zeros(0, np.float32)
597
+ self.store[tier][i] = P
598
+ self._events(tier, i, P)
599
+
600
+ def _running(self):
601
+ if self.e.n:
602
+ self.pooled_now = self.e.n - 1
603
+
604
+ # ---- UI materialisation
605
+ def spans(self, tier, lo=None):
606
+ """decode W-tag spans of `tier` over the last SPAN_WIN words: runs of span_in >= thr, split where span_beg >= thr
607
+ (a new B inside a run). Returns (a0, spans): every span with end > a0 (the client replaces its spans with end > a0)."""
608
+ st = self.store[tier]; n = self.e.n
609
+ a0 = max(0, n - self.SPAN_WIN)
610
+ idx = [i for i in range(max(0, a0 - 16), n) if i in st]
611
+ if not idx:
612
+ return a0, []
613
+ ON = np.stack([st[i]["span_in"] for i in idx]); PIN = ON
614
+ ON = (ON >= self.th["span_in"]) & self.valid["span_in"]
615
+ bo = self.beg_of_in; hb = bo >= 0
616
+ PB = np.stack([st[i]["span_beg"] for i in idx])
617
+ BEG = np.zeros_like(ON)
618
+ BEG[:, hb] = PB[:, bo[hb]] >= self.th["span_beg"][bo[hb]]
619
+ us = np.array([bool(self.e.ustart[i]) if i < len(self.e.ustart) else False for i in idx])
620
+ BEG[us] = True # spans never cross an utterance start
621
+ head = self._head_cols() if (self.L.has_head and SPAN_HEAD) else {}
622
+ if head:
623
+ ON[:, [c for cs in head.values() for c in cs]] = False # head families: decoded from the span head below
624
+ res = []
625
+ for c in np.nonzero(ON.any(0))[0]:
626
+ a = None
627
+ for r in range(len(idx) + 1):
628
+ on = r < len(idx) and ON[r, c]
629
+ gap = r < len(idx) and r > 0 and idx[r] != idx[r - 1] + 1
630
+ if a is not None and (not on or gap or BEG[r, c]):
631
+ w0, w1 = idx[a], idx[r - 1] + 1
632
+ if w1 > a0:
633
+ res.append((c, w0, w1, float(PIN[a:r, c].max())))
634
+ a = None
635
+ if on and a is None:
636
+ a = r
637
+ alt = {}
638
+ if SPAN_EXCL:
639
+ res, alt = self._exclusive(res, idx, PIN)
640
+ hth = {}
641
+ if head:
642
+ hres, alt2, hth = self._head_spans(tier, idx, us, a0, head)
643
+ res = res + hres; alt.update(alt2)
644
+ out = []
645
+ th_in = self.th["span_in"]
646
+ for c, a, b, p in res:
647
+ tag = self.cn["span_in"][c]
648
+ s = {"tag": tag, "fam": family_of(tag), "a": int(a), "b": int(b), "p": round(p, 3),
649
+ "thr": round(float(hth.get(c, th_in[c])), 3), "text": " ".join(w["w"] for w in self.words[a:b])}
650
+ if c in hth:
651
+ s["src"] = "head"
652
+ if (c, a, b) in alt:
653
+ s["alt"] = alt[(c, a, b)]
654
+ if self.is_norm[c]:
655
+ s.update(self._verify(a, b))
656
+ out.append(s)
657
+ out.sort(key=lambda s: (s["a"], -s["b"]))
658
+ return a0, out
659
+
660
+ def _head_cols(self):
661
+ """{family: span_in columns of its VALID members} for the v2 span head (cached per binding)"""
662
+ hc = getattr(self, "_hc", None)
663
+ if hc is None or hc[0] is not self.L:
664
+ ci = {t: c for c, t in enumerate(self.cn["span_in"])}
665
+ m = {f: [ci[t] for t in tags if t in ci and self.valid["span_in"][ci[t]]] for f, tags in self.L.head_tags.items()
666
+ if f in self.L.head_use}
667
+ hc = self._hc = (self.L, {f: cs for f, cs in m.items() if cs})
668
+ return hc[1]
669
+
670
+ def head_thr(self, tier, fam):
671
+ f_ = self.L.head_tags.get(fam) or [fam + ".x"]
672
+ return clamp_thr(f"{f_[0]}@span_in", self.doms, max(self.L.head_thr(tier, fam), self.floor))
673
+
674
+ def _head_spans(self, tier, idx, us, a0, head):
675
+ """v2 span head decode per family over the window: in-span = 1 - P(O) >= th (family threshold). T0 greedy (left to
676
+ right: a span starts at B > I, after a non-span word, a gap or an utterance start); T1 / T2 (read-outs already see the
677
+ delay window) Viterbi over O / B / I with O -> I forbidden, in-states biased by log((1 - th) / th) so a lone word follows
678
+ the same threshold. One type per span: argmax of the summed log type probabilities over the family's VALID members."""
679
+ st = self.store[tier]; ci = {t: c for c, t in enumerate(self.cn["span_in"])}
680
+ res, alt, hth = [], {}, {}
681
+ brk = np.array([bool(us[r]) or (r > 0 and idx[r] != idx[r - 1] + 1) for r in range(len(idx))])
682
+ for f, cols in head.items():
683
+ rows = [r for r, i in enumerate(idx) if "span_head" in st[i] and f in st[i]["span_head"]]
684
+ if len(rows) != len(idx):
685
+ continue
686
+ Pt = np.stack([st[i]["span_head"][f][0] for i in idx]); Pb = np.stack([st[i]["span_head"][f][1] for i in idx])
687
+ tags = self.L.head_tags[f]; mem = [k for k, t in enumerate(tags) if t in ci and ci[t] in cols]
688
+ th = self.head_thr(tier, f); pin = 1.0 - Pb[:, 0]
689
+ if tier == "T0":
690
+ lab = np.where(pin < th, 0, np.where(brk | (Pb[:, 1] > Pb[:, 2]), 1, 2))
691
+ for r in range(1, len(idx)):
692
+ if lab[r] == 2 and lab[r - 1] == 0:
693
+ lab[r] = 1
694
+ if len(idx):
695
+ lab[0] = 1 if lab[0] else 0
696
+ else:
697
+ lab = self._viterbi(Pb, th, brk)
698
+ lt = np.log(np.clip(Pt[:, mem], 1e-9, 1.0))
699
+ r = 0; W = len(idx)
700
+ while r < W:
701
+ if lab[r] == 0:
702
+ r += 1; continue
703
+ e = r + 1
704
+ while e < W and lab[e] == 2:
705
+ e += 1
706
+ w0, w1 = idx[r], idx[e - 1] + 1
707
+ if w1 > a0:
708
+ sc = lt[r:e].sum(0); o = np.argsort(-sc); c = ci[tags[mem[int(o[0])]]]
709
+ res.append((c, w0, w1, float(pin[r:e].max()))); hth[c] = th
710
+ if len(o) > 1:
711
+ pm = Pt[r:e][:, mem].mean(0)
712
+ alt[(c, w0, w1)] = {"tag": tags[mem[int(o[1])]], "p": round(float(pm[o[1]]), 3)}
713
+ r = e
714
+ return res, alt, hth
715
+
716
+ @staticmethod
717
+ def _viterbi(Pb, th, brk):
718
+ """BIO labels (0 O, 1 B, 2 I) maximising sum of log scores with O -> I and break -> I forbidden: O = log P(O), B / I =
719
+ log P(in) + log((1 - th) / th) + log(P(B or I) / max(P(B), P(I))) (a lone word is in-span exactly when P(in) >= th; B / I
720
+ only costs where the BIO constraint overrides the per-word choice)"""
721
+ W = len(Pb)
722
+ if not W:
723
+ return np.zeros(0, int)
724
+ pin = np.clip(1.0 - Pb[:, 0], 1e-9, 1.0); bias = math.log((1.0 - th) / max(th, 1e-6))
725
+ cb = np.clip(Pb[:, 1:], 1e-9, 1.0); cb = np.log(cb / cb.max(1, keepdims=True)) # B vs I, the better one costs 0
726
+ L = np.empty((W, 3)); L[:, 0] = np.log(np.clip(Pb[:, 0], 1e-9, 1.0)); L[:, 1:] = (np.log(pin) + bias)[:, None] + cb
727
+ NEG = -1e9; dp = np.full((W, 3), NEG); bp = np.zeros((W, 3), int)
728
+ dp[0, 0], dp[0, 1] = L[0, 0], L[0, 1]
729
+ for r in range(1, W):
730
+ p = dp[r - 1]
731
+ for s_ in (0, 1):
732
+ k = int(p.argmax()); dp[r, s_] = p[k] + L[r, s_]; bp[r, s_] = k
733
+ if not brk[r]:
734
+ k = 1 if p[1] >= p[2] else 2; dp[r, 2] = p[k] + L[r, 2]; bp[r, 2] = k
735
+ lab = np.zeros(W, int); lab[-1] = int(dp[-1].argmax())
736
+ for r in range(W - 1, 0, -1):
737
+ lab[r - 1] = bp[r, lab[r]]
738
+ return lab
739
+
740
+ def _exclusive(self, res, idx, PIN):
741
+ """type-exclusive decode: overlapping spans of one family (number / entity / pii / profanity / toxicity) merge into ONE span
742
+ over their union, typed by the argmax over the family's valid columns of the mean span_in over the span's words; the
743
+ runner-up type is kept ("alt"). Spans of different families may still overlap (pii.private_name + entity.mention.person)."""
744
+ fam_cols = getattr(self, "_fam_cols", None)
745
+ if fam_cols is None:
746
+ fam_cols = self._fam_cols = {}
747
+ for c, t in enumerate(self.cn["span_in"]):
748
+ f = decode_family(t)
749
+ if f and self.valid["span_in"][c]:
750
+ fam_cols.setdefault(f, []).append(c)
751
+ row = {w: r for r, w in enumerate(idx)}
752
+ by = {}; keep = []
753
+ for x in res:
754
+ f = decode_family(self.cn["span_in"][x[0]])
755
+ (by.setdefault(f, []) if f else keep).append(x)
756
+ out = list(keep); alt = {}
757
+ for f, xs in by.items():
758
+ xs.sort(key=lambda x: (x[1], x[2])); groups = []
759
+ for x in xs:
760
+ if groups and x[1] < groups[-1][1]:
761
+ groups[-1][1] = max(groups[-1][1], x[2]); groups[-1][2].append(x)
762
+ else:
763
+ groups.append([x[1], x[2], [x]])
764
+ cols = fam_cols.get(f, [])
765
+ for a, b, mem in groups:
766
+ if len(mem) == 1 or not cols:
767
+ out.append(mem[0]); continue
768
+ rr = [row[w] for w in range(a, b) if w in row]
769
+ M = PIN[rr][:, cols].mean(0)
770
+ o = np.argsort(-M); c = cols[int(o[0])]
771
+ out.append((c, a, b, float(PIN[rr, c].max())))
772
+ if len(o) > 1:
773
+ c2 = cols[int(o[1])]
774
+ alt[(c, a, b)] = {"tag": self.cn["span_in"][c2], "p": round(float(M[o[1]]), 3)}
775
+ return out, alt
776
+
777
+ @torch.inference_mode()
778
+ def _verify(self, a, b):
779
+ """span verification (+ KB pointer), cached per span: the key holds the word dicts of [a, b) by identity (FastArc
780
+ re-creates them whenever it re-runs those words), so a revision or a re-bind invalidates the entry."""
781
+ e = self.e; ws = e.words[a:b]
782
+ c = getattr(self, "_vcache", None)
783
+ if c is None or c[0] is not e or c[1] is not self.kbe:
784
+ c = self._vcache = (e, self.kbe, {})
785
+ hit = c[2].get((a, b))
786
+ if hit is not None and len(hit[0]) == len(ws) and all(x is y for x, y in zip(hit[0], ws)):
787
+ return dict(hit[1])
788
+ out = self._verify_raw(a, b)
789
+ if len(c[2]) > 4000:
790
+ c[2].clear()
791
+ c[2][(a, b)] = (tuple(ws), out)
792
+ return dict(out)
793
+
794
+ def _verify_raw(self, a, b):
795
+ m = self.L.model; H = self.e.Hbuf
796
+ sp = torch.tensor([[0, b - a]]) # local window: same mean / end state as over H[:n], O(span) not O(session)
797
+ rep = m.span_rep(H[a:b], sp)
798
+ out = {"ver": round(float(torch.sigmoid(m.ver(rep)[0, 0])), 3)}
799
+ if self.kbe is not None:
800
+ z = m.kb_logits(H[b - 1][None], self.kbe)[0].float()
801
+ p = torch.softmax(z, -1)
802
+ j = int(p[2:].argmax()) if p.numel() > 2 else -1
803
+ out["kb"] = {"p_new": round(float(p[0]), 3), "p_generic": round(float(p[1]), 3)}
804
+ if j >= 0:
805
+ out["kb"].update(entry=self.kb_names[j], p=round(float(p[2 + j]), 3), match=bool(p[2 + j] > max(p[0], p[1])))
806
+ return out
807
+
808
+ def _events(self, tier, i, P):
809
+ if tier == "T2":
810
+ return
811
+ w = self.words[i] if i < len(self.words) else {}
812
+ for q in ("ev_at", "ev_imm", "ev_rec"):
813
+ p = P.get(q)
814
+ if p is None or not len(p):
815
+ continue
816
+ on = np.nonzero((p >= self.th[q]) & self.valid[q])[0]
817
+ for c in on:
818
+ tag = self.cn[q][c]
819
+ if any((tier, tag, j) in self._ev_keys for j in range(i - 3, i + 1)):
820
+ continue
821
+ self._ev_keys.add((tier, tag, i))
822
+ self.events.append({"tier": tier, "tag": tag, "q": q, "i": int(i), "p": round(float(p[c]), 3),
823
+ "thr": round(float(self.th[q][c]), 3), "t": w.get("t1"), "wall": time.time(),
824
+ "word": w.get("w", "")})
825
+
826
+ def _alerts_utt(self, u):
827
+ for r in u["readouts"].get("safety", []):
828
+ k = (r["tag"], u["w0"])
829
+ if r["on"] and k not in self._al_keys:
830
+ self._al_keys.add(k)
831
+ self.alerts.append({"tag": r["tag"], "p": r["p"], "thr": r["thr"], "w0": u["w0"], "w1": u["w1"],
832
+ "text": u["text"][-160:], "wall": time.time(), "kind": "utterance"})
833
+
834
+ def span_alerts(self, spans, tier):
835
+ for s in spans:
836
+ if s["fam"] in ("pii", "profanity"):
837
+ k = (tier, s["tag"], s["a"])
838
+ if k not in self._al_keys:
839
+ self._al_keys.add(k)
840
+ self.alerts.append({"tag": s["tag"], "p": s["p"], "thr": s["thr"], "w0": s["a"], "w1": s["b"], "text": s["text"],
841
+ "wall": time.time(), "kind": f"span {tier}"})
842
+
843
+ def _top(self, q, P, prefix, k=3):
844
+ names = self.cn[q]
845
+ idx = [c for c, t in enumerate(names) if t.startswith(prefix) and self.valid[q][c]]
846
+ idx.sort(key=lambda c: -P[c])
847
+ return [{"tag": names[c], "p": round(float(P[c]), 3), "thr": round(float(self.th[q][c]), 3), "on": bool(P[c] >= self.th[q][c])} for c in idx[:k]]
848
+
849
+ @torch.inference_mode()
850
+ def readouts(self, j=None, final=False, tier="T0"):
851
+ """utterance read-outs (pooled utt / speaker / item at word j) + frame states of the newest word of `tier`"""
852
+ if j is None:
853
+ j = self.pooled_now
854
+ if j is None or j not in self.e.pooled:
855
+ return {}
856
+ GI = self.L.GI
857
+ pl = self.e.pooled_logits(j)
858
+ U = GI.probs("utt", pl["utt"][None])[0].numpy(); S = GI.probs("speaker", pl["speaker"][None])[0].numpy()
859
+ I = GI.probs("item", pl["item"][None])[0].numpy()
860
+ sp = self.e.spk[j] if j < len(self.e.spk) else 0
861
+ ro = {"j": int(j), "final": final,
862
+ "intent": self._top("utt", U, "intent.", 3) + self._top("utt", U, "discourse.intent.", 3),
863
+ "act": self._top("utt", U, "discourse.act.", 3) + self._top("utt", U, "discourse.answer", 1) + self._top("utt", U, "discourse.backchannel", 1)
864
+ + self._top("utt", U, "discourse.assistant.", 1),
865
+ "role": self._top("speaker", S, "speaker.role.", 3) if sp > 0 else [],
866
+ "role_note": None if sp > 0 else "speaker role needs diarization (no speaker id on words)",
867
+ "safety": [r for r in self._top("utt", U, "safety.", 20) if r["p"] >= 0.5 * r["thr"]][:6],
868
+ "item": self._top("item", I, "item.", 3)}
869
+ ro["intent"].sort(key=lambda r: -r["p"]); ro["intent"] = ro["intent"][:4]
870
+ fr = {}
871
+ jt = max([i for i in self.store[tier] if i <= j], default=None)
872
+ if jt is not None and "frame" in self.store[tier][jt]:
873
+ Fp = self.store[tier][jt]["frame"]
874
+ for g in STATE_GROUPS:
875
+ top = self._top("frame", Fp, g + ".", 3)
876
+ if top:
877
+ fr[g] = top
878
+ ro["state"] = fr
879
+ return ro
880
+
881
+ def stats(self):
882
+ L = self.lat[-500:]
883
+ if not L:
884
+ return {}
885
+ per_upd = np.array([x[0] for x in L]); per_w = np.array([x[0] / x[1] for x in L])
886
+ return {"upd_p50": round(float(np.percentile(per_upd, 50)), 2), "upd_p95": round(float(np.percentile(per_upd, 95)), 2),
887
+ "word_p50": round(float(np.percentile(per_w, 50)), 2), "word_p95": round(float(np.percentile(per_w, 95)), 2),
888
+ "n_updates": len(self.lat)}
arctag/_core/fast_arc.py ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fast streaming engine for arc_text.ArcText (same maths as stream_arc.ArcStream / the batch forward; parity-tested).
2
+
3
+ What makes it fast (vs stream_arc.py, which calls the arc_audio modules per word):
4
+ (a) Mamba-3 in true recurrent mode for any T >= 1: ALL projections of a layer (in, x, B, C, theta, dt, lambda) are ONE fused
5
+ matmul over the T new words; the state recurrence is a T-step loop on (H, N, P) tensors; out_proj / MLP batched over T.
6
+ FiLM (domain + delay 0 constants) and the trunk input bias are folded per session; Trunk.step recomputed them every word.
7
+ (b) a revision of k words = ONE pass over the k-word chunk from the restored state (not k single steps).
8
+ (c) local attention: per layer a preallocated ring buffer of the window's K / V (32 words); only the new words' q / k / v.
9
+ (d) read-outs: Q_q and the tag keys are folded into one (d x C) matrix per read-out group (only the text tag columns), so the
10
+ T0 read of word t and the T1 read of word t-3 (both read h_t, differ by a delay-embedding constant) cost one matvec;
11
+ utt / speaker / item and T2 are computed only at an endpoint (batched); the pooled carries are updated every word.
12
+ (e) preallocated buffers, torch.inference_mode, no module-call overhead.
13
+ Snapshots for revision: per word the (state, carry, ring, slots) references of the last KEEP words.
14
+ """
15
+ import argparse, bisect, json, math, os, sys, time
16
+ import numpy as np, torch, torch.nn.functional as F
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE)
19
+ import arc_text as AT # noqa: E402
20
+ from arc_text import WORD_Q, POOLED, D_MAX, P_MAX, NEG # noqa: E402
21
+ from arc_audio.mamba3.layer import rotate_pairs # noqa: E402
22
+ from tagtable import domain_index, asr_index # noqa: E402
23
+ from losses import GroupIndex # noqa: E402
24
+ import stream_arc as SA # noqa: E402
25
+
26
+ KEEP = 64
27
+ WQ = [q for q in WORD_Q if q != "span_beg"]
28
+
29
+
30
+ class FastArc:
31
+ @torch.inference_mode()
32
+ def __init__(self, model, table, domain=None, asr=None, max_words=20000):
33
+ m = self.m = model.eval(); self.T = table; self.GI = GroupIndex(table)
34
+ self.asr_name = asr or "unknown"; self.asr = asr_index(asr); di = domain_index(domain)
35
+ self.dom = torch.tensor([di])
36
+ tr = m.trunk; d = m.d; self.d = d
37
+ self.tok = AT.ArcPieces(AT.BPE8K if getattr(model, "scratch", None) else AT.TOK_PATH); self.cache = {}
38
+ f32 = lambda t: t.detach().float().contiguous()
39
+ # ---- word input constants (no audio: hm = he = 0, have = 0)
40
+ w = m.word
41
+ self.tokE = w.tok_E; self.ppos = f32(w.ppos); self.W1 = f32(w.W1.weight).t().contiguous(); self.b1 = f32(w.W1.bias)
42
+ self.W2 = f32(w.W2.weight).t().contiguous(); self.ln = (f32(w.ln.weight), f32(w.ln.bias), w.ln.eps)
43
+ self.fl = [f32(e.weight) for e in w.fl]
44
+ src = 1 if self.asr_name == "arc_transcriber" else 0
45
+ self.c_in = f32(w.src.weight[src]) + ((f32(w.Wa.bias) + f32(w.hav.weight[0])) if hasattr(w, "Wa") else 0)
46
+ self.feat = {k: f32(e.weight) for k, e in m.feat.items()}
47
+ # ---- local attention layers
48
+ self.enc = []
49
+ for lay in w.enc:
50
+ self.enc.append(dict(n1=(f32(lay.n1.weight), f32(lay.n1.bias), lay.n1.eps), Wqkv=f32(lay.qkv.weight).t().contiguous(), bqkv=f32(lay.qkv.bias),
51
+ Wo=f32(lay.o.weight).t().contiguous(), bo=f32(lay.o.bias), n2=(f32(lay.n2.weight), f32(lay.n2.bias), lay.n2.eps),
52
+ F1=f32(lay.ff[0].weight).t().contiguous(), f1=f32(lay.ff[0].bias), F2=f32(lay.ff[3].weight).t().contiguous(),
53
+ f2=f32(lay.ff[3].bias), h=lay.h, win=lay.win))
54
+ self.enc_norm = (f32(w.enc_norm.weight), f32(w.enc_norm.bias), w.enc_norm.eps)
55
+ dw = w.ln.normalized_shape[0]; hE = self.enc[0]["h"]; dh = dw // hE; self.dh = dh; self.hE = hE; self.dw = dw
56
+ self.win = self.enc[0]["win"]
57
+ inv = 1.0 / (10000 ** (torch.arange(0, dh, 2, dtype=torch.float32) / dh)); self.rope_inv = inv
58
+ ang = torch.arange(max_words, dtype=torch.float32)[:, None] * inv # RoPE table (positions x dh/2)
59
+ self.rope_c, self.rope_s = torch.cos(ang)[:, None, :], torch.sin(ang)[:, None, :]
60
+ # ---- trunk: input projection, FiLM / domain constants folded
61
+ di_ = tr.delay_lut[0]
62
+ e_ = f32(tr.dom_emb.weight[di]) + f32(tr.delay_film.weight[di_])
63
+ self.in_norm = (f32(tr.in_norm.weight), f32(tr.in_norm.bias), tr.in_norm.eps)
64
+ self.Winp = f32(tr.inp.weight).t().contiguous(); self.binp = f32(tr.inp.bias) + f32(tr.dom_emb.weight[di]) + f32(tr.delay_emb.weight[di_])
65
+ self.layers = []
66
+ for i, (lay, mlp) in enumerate(zip(tr.layers, tr.mlps)):
67
+ gb = f32(tr.film[i](e_)); g, bb = 1 + gb[:d], gb[d:]
68
+ H, N, P = lay.H, lay.N, lay.P
69
+ Ws = [lay.in_proj, lay.x_proj, lay.B_proj, lay.C_proj, lay.theta_proj, lay.dt_proj, lay.lam_proj]
70
+ Wf = torch.cat([f32(l.weight) for l in Ws], 0).t().contiguous(); bf = torch.cat([f32(l.bias) for l in Ws])
71
+ sizes = [l.out_features for l in Ws]
72
+ self.layers.append(dict(g=g, b=bb, nw=f32(lay.norm.weight), neps=lay.norm.eps, Wf=Wf, bf=bf, sizes=sizes, H=H, N=N, P=P,
73
+ A=-torch.exp(f32(lay.A_log)), Bn=(f32(lay.B_norm.weight), lay.B_norm.eps), Cn=(f32(lay.C_norm.weight), lay.C_norm.eps),
74
+ Bb=f32(lay.B_bias)[..., 0], Cb=f32(lay.C_bias)[..., 0],
75
+ BCw=torch.stack([f32(lay.B_norm.weight), f32(lay.C_norm.weight)])[:, None, :],
76
+ BCb=torch.stack([f32(lay.B_bias)[..., 0], f32(lay.C_bias)[..., 0]]), Wout=f32(lay.out_proj.weight).t().contiguous(), bout=f32(lay.out_proj.bias),
77
+ mn=(f32(mlp[0].weight), mlp[0].eps), M1=f32(mlp[1].weight).t().contiguous(), m1=f32(mlp[1].bias),
78
+ M2=f32(mlp[4].weight).t().contiguous(), m2=f32(mlp[4].bias)))
79
+ self.adD = f32(tr.adapt_down[di]); self.adU = f32(tr.adapt_up[di]); self.on = (f32(tr.out_norm.weight), tr.out_norm.eps)
80
+ # ---- AT10M-v2: global attention layers (KV memory + chunk summaries, v2.GlobalAttn), delayed attention, span head, utt pool
81
+ import v2 as V2; self.V2 = V2
82
+ self.gat = {}
83
+ for k_, G in (getattr(m, "gat", None) or {}).items():
84
+ self.gat[int(k_)] = dict(nw=f32(G.norm.weight), neps=G.norm.eps, Wqkv=f32(G.qkv.weight).t().contiguous(), qn=(f32(G.qn.weight), G.qn.eps),
85
+ kn=(f32(G.kn.weight), G.kn.eps), Wo=f32(G.o.weight).t().contiguous(), h=G.h, dh=G.dh, mem=G.mem, ch=G.chunk, slots=G.slots,
86
+ K=torch.zeros(max_words, G.h, G.dh), Kr=torch.zeros(max_words, G.h, G.dh), V=torch.zeros(max_words, G.h, G.dh))
87
+ if getattr(G, "biased", False): # v2.1: ALiBi slopes, span-bearing key prior, tanh output gate
88
+ self.gat[int(k_)].update(biased=True, slope=f32(G.lslope).exp(), kb=f32(G.kb), kmW=f32(G.kmark.weight)[0], kmb=float(G.kmark.bias),
89
+ gate=float(torch.tanh(G.gate.detach().float())), M=torch.zeros(max_words))
90
+ self.gat[int(k_)]["Wo"] = self.gat[int(k_)]["Wo"] * self.gat[int(k_)]["gate"]
91
+ if self.gat:
92
+ g0 = next(iter(self.gat.values()))
93
+ self.grc, self.grs = V2.rope_tab(torch.arange(max_words), g0["dh"])
94
+ self.da = None
95
+ if hasattr(m, "dattn"):
96
+ A = m.dattn
97
+ self.da = dict(Wq=f32(A.q.weight).t().contiguous(), bq=f32(A.q.bias), Wk=f32(A.k.weight).t().contiguous(), bk=f32(A.k.bias),
98
+ Wv=f32(A.v.weight).t().contiguous(), bv=f32(A.v.bias), qd=f32(A.qd.weight), ko=f32(A.koff.weight), Wo=f32(A.o.weight).t().contiguous(), dk=A.dk)
99
+ self.sh = None
100
+ if hasattr(m, "span_head"):
101
+ S_ = m.span_head; ws_, bs_, self.sh_sl = [], [], {}; o_ = 0
102
+ for f in S_.fm:
103
+ for kind, mod in (("t", S_.typ[f.replace(".", "_")]), ("b", S_.bio[f.replace(".", "_")])):
104
+ ws_.append(f32(mod.weight).t()); bs_.append(f32(mod.bias)); self.sh_sl[(f, kind)] = (o_, o_ + mod.out_features); o_ += mod.out_features
105
+ self.sh = (torch.cat(ws_, 1).contiguous(), torch.cat(bs_)); self.sh_dq = f32(m.delay_q.weight) @ self.sh[0]
106
+ self.Zs = torch.zeros(max_words, o_)
107
+ self.ua = (f32(m.utt_att.weight)[0], float(m.utt_att.bias)) if hasattr(m, "utt_att") else None
108
+ # ---- read-outs: fold Q_q with the keys of its (text) columns; one matrix for the word-level read-outs
109
+ h_ = m.head; K = f32(m.keys()); valid = h_.valid[di]
110
+ cols, Ms, bs, self.wslices = [], [], [], {}
111
+ o = 0
112
+ for q in WQ:
113
+ c = h_.cols(q); Kc = K[c]
114
+ Ms.append(f32(h_.Q[q].weight).t() @ Kc.t()); bs.append(f32(h_.Q[q].bias) @ Kc.t() + f32(h_.b + h_.bq[q])[c])
115
+ msk = ~valid[c]; bs[-1] = bs[-1].masked_fill(msk, NEG)
116
+ self.wslices[q] = (o, o + len(c)); o += len(c)
117
+ self.Mw = torch.cat(Ms, 1).contiguous(); self.bw = torch.cat(bs)
118
+ self.dq = f32(m.delay_q.weight) @ self.Mw # delay-embedding constant per d (D_MAX+1, Cw)
119
+ self.Md = (f32(m.dmix.weight).t() @ self.Mw).contiguous() if hasattr(m, "dmix") else None # stage 2: word i's own state (d > 0)
120
+ self.dq[:, self.bw <= NEG / 2] = 0
121
+ cb = m.cols_span_beg; Kb = K[cb]
122
+ self.war = dict(r=(f32(w.war_r.weight).t().contiguous(), f32(w.war_r.bias)), k=f32(w.war_k.weight), ln=(f32(w.war_ln.weight), f32(w.war_ln.bias), w.war_ln.eps),
123
+ M=(f32(w.QB.weight).t() @ Kb.t()).contiguous(), b=(f32(w.QB.bias) @ Kb.t() + f32(m.bB_all)[cb]).masked_fill(~valid[cb], NEG))
124
+ self.pool = {}
125
+ P1 = torch.cat([f32(h_.pool_in[q][0].weight) for q in POOLED], 0).t().contiguous(); p1 = torch.cat([f32(h_.pool_in[q][0].bias) for q in POOLED])
126
+ self.P1, self.p1 = P1, p1
127
+ self.P2 = [(f32(h_.pool_in[q][2].weight).t().contiguous(), f32(h_.pool_in[q][2].bias)) for q in POOLED]
128
+ for q in POOLED:
129
+ c = h_.cols(q); Kc = K[c]
130
+ self.pool[q] = ((f32(h_.Q[q].weight).t() @ Kc.t()).contiguous(), (f32(h_.Q[q].bias) @ Kc.t() + f32(h_.b + h_.bq[q])[c]).masked_fill(~valid[c], NEG))
131
+ # ---- buffers
132
+ self.Hbuf = torch.zeros(max_words, d); self.Ubuf = torch.zeros(max_words, dw); self.Zw = torch.zeros(max_words, self.Mw.shape[1])
133
+ self.Zd = torch.zeros(max_words, self.Mw.shape[1]) if self.Md is not None else None
134
+ self.reset()
135
+
136
+ def reset(self):
137
+ self.mr = getattr(self.m, "memr", None) # v2.2 (a) MemRead: key cache over the words so far
138
+ if self.mr is not None:
139
+ dk = self.mr.dk; mw = self.Hbuf.shape[0]
140
+ self.Kc = torch.zeros(mw, dk); self.Vc = torch.zeros(mw, dk); self.Bc = torch.zeros(mw); self._mk = 0; self._mrc = {}
141
+ self.dqv = self.m.delay_q.weight.detach().float(); self.mr_g = math.tanh(float(self.mr.gate)); self.mr_gu = math.tanh(float(self.mr.gate_u))
142
+ self.n = 0; self.words = []; self.state = None; self.ustart = []
143
+ L = len(self.enc)
144
+ self.ring = [(torch.zeros(self.win, self.hE, self.dh), torch.zeros(self.win, self.hE, self.dh)) for _ in range(L)] # K, V by word % win
145
+ self.ring_ver = 0
146
+ self.carry = dict(utt=(torch.zeros(self.d, dtype=torch.float64), 0.0), item=(torch.zeros(self.d, dtype=torch.float64), 0.0),
147
+ speaker=(torch.zeros(10, self.d, dtype=torch.float64), torch.zeros(10, dtype=torch.float64)))
148
+ self.slots = {}; self.prev = None; self.next_ustart = True; self.spk = []
149
+ self.snap = {}; self.out = {"T0": {}, "T1": {}, "T2": {}}; self.pooled = {}
150
+ self.utt0 = 0
151
+ self.gsum = {li: {} for li in self.gat} # chunk summaries per global layer: chunk -> (k roped, v)
152
+
153
+ # ------------------------------------------------------------------ primitives
154
+ @staticmethod
155
+ def _mm(x, W):
156
+ return W.mm(x) if isinstance(W, QLin) else x @ W
157
+
158
+ @staticmethod
159
+ def _ln(x, p):
160
+ return F.layer_norm(x, (x.shape[-1],), p[0], p[1], p[2])
161
+
162
+ @staticmethod
163
+ def _rms(x, w, eps):
164
+ return F.rms_norm(x, (x.shape[-1],), w, eps)
165
+
166
+ def _rope(self, x, pos):
167
+ c, s = self.rope_c[pos], self.rope_s[pos]; x1, x2 = x[..., 0::2], x[..., 1::2]
168
+ return torch.stack([x1 * c - x2 * s, x1 * s + x2 * c], -1).flatten(-2)
169
+
170
+ def _embed(self, ws, fes, fls):
171
+ T = len(ws); Pm = torch.zeros(T, P_MAX, dtype=torch.long); npc = torch.zeros(T, dtype=torch.long)
172
+ for i, p in enumerate(ws):
173
+ Pm[i, :len(p)] = torch.tensor(p); npc[i] = len(p)
174
+ Et = self.tokE[Pm].float()
175
+ pm = (torch.arange(P_MAX)[None] < npc[:, None]).float()[..., None]
176
+ mean = ((Et + self.ppos) * pm).sum(1) / npc.clamp(min=1)[:, None].float()
177
+ last = Et[torch.arange(T), (npc - 1).clamp(min=0)]
178
+ e = self._ln(mean @ self.W1 + self.b1 + last @ self.W2, self.ln) + self.c_in
179
+ fl = torch.tensor(fls); fl[:, 2] = npc.clamp(0, P_MAX)
180
+ for i, tab in enumerate(self.fl):
181
+ e = e + tab[fl[:, i].clamp(0, tab.shape[0] - 1)]
182
+ for k, tab in self.feat.items():
183
+ xf = getattr(self, "xfeat", None) or {} # stage-2 session-constant inputs (spksrc; turn = 0 without Flux events)
184
+ e = e + tab[torch.tensor([f[k] if k in f else xf.get(k, 0) for f in fes]).clamp(0, tab.shape[0] - 1)]
185
+ return e
186
+
187
+ def _enc(self, x, j0):
188
+ """local causal attention for words j0..j0+T-1 with ring-buffer K / V of the previous win-1 words"""
189
+ T = x.shape[0]; hE, dh, win = self.hE, self.dh, self.win
190
+ pos = torch.arange(j0, j0 + T)
191
+ for li, L in enumerate(self.enc):
192
+ q, k, v = (self._mm(self._ln(x, L["n1"]), L["Wqkv"]) + L["bqkv"]).view(T, 3, hE, dh).unbind(1)
193
+ q = self._rope(q, pos); k = self._rope(k, pos)
194
+ Kr, Vr = self.ring[li]
195
+ npast = min(j0, win - 1)
196
+ pidx = torch.arange(j0 - npast, j0) % win
197
+ Kall = torch.cat([Kr[pidx], k], 0); Vall = torch.cat([Vr[pidx], v], 0) # (npast+T, h, dh)
198
+ kp = torch.arange(j0 - npast, j0 + T)
199
+ msk = (kp[None] <= pos[:, None]) & (kp[None] > pos[:, None] - win)
200
+ a = F.scaled_dot_product_attention(q.transpose(0, 1)[None], Kall.transpose(0, 1)[None], Vall.transpose(0, 1)[None], attn_mask=msk[None, None])
201
+ a = a[0].transpose(0, 1).reshape(T, hE * dh)
202
+ # write the new K / V into a COPY of the ring (snapshots keep the old one)
203
+ Kn, Vn = Kr.clone(), Vr.clone(); Kn[pos % win] = k; Vn[pos % win] = v
204
+ self.ring[li] = (Kn, Vn)
205
+ x = x + self._mm(a, L["Wo"]) + L["bo"]
206
+ x = x + self._mm(F.gelu(self._mm(self._ln(x, L["n2"]), L["F1"]) + L["f1"]), L["F2"]) + L["f2"]
207
+ return self._ln(x, self.enc_norm)
208
+
209
+ def _trunk(self, u):
210
+ T = u.shape[0]
211
+ h = self._mm(self._ln(u, self.in_norm), self.Winp) + self.binp
212
+ state = self.state; new = []
213
+ for i, L in enumerate(self.layers):
214
+ h = h * L["g"] + L["b"]
215
+ nu = self._rms(h, L["nw"], L["neps"])
216
+ pr = self._mm(nu, L["Wf"]) + L["bf"]
217
+ zi, xx, Bp, Cp, th, dtp, lmp = torch.split(pr, L["sizes"], -1)
218
+ H, N, P = L["H"], L["N"], L["P"]
219
+ dt = F.softplus(dtp); alpha = torch.exp(dt * L["A"]); lam = torch.sigmoid(lmp)
220
+ gamma = lam * dt; beta = (1 - lam) * dt * alpha
221
+ BC = F.rms_norm(torch.stack([Bp.view(T, H, N), Cp.view(T, H, N)], 1), (N,), None, L["Bn"][1]) * L["BCw"] + L["BCb"]
222
+ Bm, Cm = BC[:, 0], BC[:, 1]
223
+ xv = xx.view(T, H, P); theta = th.view(T, H, N // 2) * dt[..., None]
224
+ bx = Bm[..., :, None] * xv[..., None, :] # (T,H,N,P)
225
+ st = None if state is None else state[i]
226
+ cth, sth = torch.cos(theta)[..., None], torch.sin(theta)[..., None] # (T,H,N/2,1)
227
+ ys = []
228
+ for t in range(T):
229
+ if st is None:
230
+ hs = gamma[t, :, None, None] * bx[t]
231
+ else:
232
+ pre = alpha[t, :, None, None] * st[0] + beta[t, :, None, None] * st[1] # (H,N,P); rotate the N pairs
233
+ c_, s_ = cth[t], sth[t]
234
+ a_, b_ = pre[:, 0::2], pre[:, 1::2]
235
+ hs = torch.stack([c_ * a_ - s_ * b_, s_ * a_ + c_ * b_], 2).reshape(H, N, P) + gamma[t, :, None, None] * bx[t]
236
+ st = (hs, bx[t])
237
+ ys.append((Cm[t][:, None, :] @ hs).reshape(-1))
238
+ new.append(st)
239
+ y = torch.stack(ys)
240
+ h = h + self._mm(y * F.silu(zi), L["Wout"]) + L["bout"]
241
+ h = h + self._mm(F.gelu(self._mm(self._rms(h, *L["mn"]), L["M1"]) + L["m1"]), L["M2"]) + L["m2"]
242
+ if i in self.gat:
243
+ h = h + self._gattn(i, h)
244
+ self.state = new
245
+ h = h + F.gelu(h @ self.adD) @ self.adU
246
+ return self._rms(h, *self.on)
247
+
248
+ def _gattn(self, li, h, qb=256):
249
+ """v2.GlobalAttn for words j0..j0+T-1 (j0 = self.n): q / k / v of the new words into the position-indexed buffers, then each
250
+ query attends over its direct span (last mem words) + the newest `slots` complete chunk summaries older than that"""
251
+ G = self.gat[li]; T = h.shape[0]; j0 = self.n_run; H, dh, mem, ch = G["h"], G["dh"], G["mem"], G["ch"]
252
+ hn = self._rms(h, G["nw"], G["neps"])
253
+ q, k, v = self._mm(hn, G["Wqkv"]).view(T, 3, H, dh).unbind(1)
254
+ bi = G.get("biased", False)
255
+ q = self._rms(q, *G["qn"]); k = self._rms(k, *G["kn"])
256
+ pos = torch.arange(j0, j0 + T)
257
+ c, s = self.grc[pos], self.grs[pos]
258
+ qr = self.V2.rope(q, c, s); G["K"][pos] = k; G["Kr"][pos] = self.V2.rope(k, c, s); G["V"][pos] = v
259
+ if bi:
260
+ G["M"][pos] = torch.sigmoid(hn @ G["kmW"] + G["kmb"])
261
+ cache = self.gsum[li]
262
+ for cc in [cc for cc in cache if (cc + 1) * ch > j0]: # chunks touched by (re)written words
263
+ del cache[cc]
264
+ outs = []
265
+ for a in range(0, T, qb):
266
+ b = min(T, a + qb); i0, i1 = j0 + a, j0 + b # queries i0..i1-1
267
+ lo = max(0, i0 - mem + 1)
268
+ dm, sm = self.V2.summary_mask(i1 - i0, i1, i0, mem, ch, G["slots"])
269
+ dm = dm[:, lo:]
270
+ cs = sm.any(0).nonzero().flatten().tolist()
271
+ Ks, Vs = [G["Kr"][lo:i1]], [G["V"][lo:i1]]
272
+ if cs:
273
+ for cc in cs:
274
+ if cc not in cache:
275
+ kc = G["K"][cc * ch:(cc + 1) * ch].mean(0, keepdim=True); vc = G["V"][cc * ch:(cc + 1) * ch].mean(0, keepdim=True)
276
+ c1, s1 = self.V2.rope_tab(torch.tensor([cc * ch + ch // 2]), dh)
277
+ cache[cc] = (self.V2.rope(kc, c1, s1)[0], vc[0])
278
+ Ks.append(torch.stack([cache[cc][0] for cc in cs])); Vs.append(torch.stack([cache[cc][1] for cc in cs]))
279
+ dm = torch.cat([dm, sm[:, cs]], 1)
280
+ Kall = torch.cat(Ks, 0); Vall = torch.cat(Vs, 0)
281
+ msk = dm[None, None]
282
+ if bi: # -slope_h (i - j) + kb_h m_j (j = chunk centre / mean m)
283
+ jp = torch.cat([torch.arange(lo, i1)] + ([torch.tensor([cc * ch + ch // 2 for cc in cs])] if cs else [])).float()
284
+ mj = torch.cat([G["M"][lo:i1]] + ([torch.stack([G["M"][cc * ch:(cc + 1) * ch].mean() for cc in cs])] if cs else []))
285
+ ii = torch.arange(i0, i1).float()
286
+ bias = -G["slope"][:, None, None] * (ii[None, :, None] - jp[None, None, :]) + G["kb"][:, None, None] * mj[None, None, :]
287
+ msk = torch.where(dm[None], bias, torch.full_like(bias, float("-inf")))[None]
288
+ o = F.scaled_dot_product_attention(qr[a:b].transpose(0, 1)[None], Kall.transpose(0, 1)[None], Vall.transpose(0, 1)[None],
289
+ attn_mask=msk)
290
+ outs.append(o[0].transpose(0, 1).reshape(b - a, H * dh))
291
+ return self._mm(torch.cat(outs, 0), G["Wo"])
292
+
293
+ # ------------------------------------------------------------------ public API
294
+ def _snapshot(self):
295
+ return dict(state=self.state, carry=self.carry, ring=list(self.ring), slots=dict(self.slots), prev=self.prev, nu=self.next_ustart)
296
+
297
+ def _restore(self, k):
298
+ if k >= self.n:
299
+ return True
300
+ sn = self.snap.get(k)
301
+ if sn is None:
302
+ return False
303
+ self.state, self.carry, self.ring, self.slots, self.prev, self.next_ustart = sn["state"], sn["carry"], list(sn["ring"]), dict(sn["slots"]), sn["prev"], sn["nu"]
304
+ self.n = k; del self.words[k:], self.ustart[k:], self.spk[k:]
305
+ if self.mr is not None:
306
+ self._mk = min(self._mk, k); self._mrc = {c: v for c, v in self._mrc.items() if max(c) < k}
307
+ for t in self.out.values(): # outputs that READ a word >= k (out[tier][i] = read word index)
308
+ for j in [j for j, jr in t.items() if jr >= k]:
309
+ del t[j]
310
+ for j in [j for j in self.pooled if j >= k]:
311
+ del self.pooled[j]
312
+ for j in [j for j in self.snap if j > k]:
313
+ del self.snap[j]
314
+ return True
315
+
316
+ def pieces(self, w):
317
+ x = self.cache.get(w)
318
+ if x is None:
319
+ ids = self.tok.encode_word(w) or [0]
320
+ x = self.cache[w] = ids[:P_MAX - 1] + ids[-1:] if len(ids) > P_MAX else ids
321
+ return x
322
+
323
+ @torch.inference_mode()
324
+ def update(self, k, words):
325
+ """words[k:] := words. Restores the nearest snapshot s <= k and runs words[s:k] + words as ONE chunk."""
326
+ k = min(k, self.n)
327
+ s0 = max([x for x in self.snap if x <= k], default=None)
328
+ if k < self.n and s0 is None:
329
+ ws = self.words[:k]; self.reset(); s0 = 0; rep_ = ws
330
+ else:
331
+ s0 = k if k >= self.n else s0
332
+ rep_ = self.words[s0:k]
333
+ self._restore(s0)
334
+ self._run(rep_ + [dict(w) for w in words])
335
+ return self.due
336
+
337
+ def _run(self, words):
338
+ """ONE pass over the new words (append: 1 word; revision: the revised suffix as one chunk)"""
339
+ if not words:
340
+ return
341
+ j0 = self.n; T = len(words)
342
+ self.snap[j0] = self._snapshot() # state before the chunk (words inside: replay)
343
+ fes, fls, pcs, spks, uss = [], [], [], [], []
344
+ for i, w in enumerate(words):
345
+ fe, fl, spk = SA.ArcStream.feats(self, w)
346
+ fes.append(fe); fls.append(fl); pcs.append(self.pieces(w["w"])); spks.append(spk)
347
+ uss.append(self.next_ustart or (j0 + i) == 0); self.next_ustart = False
348
+ e = self._embed(pcs, fes, fls)
349
+ self.n_run = j0
350
+ u = self._enc(e, j0)
351
+ h = self._trunk(u)
352
+ self._after(j0, words, u, h, uss, spks)
353
+
354
+ def _after(self, j0, words, u, h, uss, spks):
355
+ T = len(words)
356
+ self.Hbuf[j0:j0 + T] = h; self.Ubuf[j0:j0 + T] = u
357
+ if self.mr is not None: # keys of rewritten words are recomputed lazily
358
+ self._mk = min(self._mk, j0); self._mrc = {c: v for c, v in self._mrc.items() if max(c) < j0}
359
+ self.Zw[j0:j0 + T] = h @ self.Mw + self.bw
360
+ if self.Zd is not None:
361
+ self.Zd[j0:j0 + T] = h @ self.Md
362
+ if self.sh is not None:
363
+ self.Zs[j0:j0 + T] = h @ self.sh[0] + self.sh[1]
364
+ z1 = F.gelu(self._mm(h, self.P1) + self.p1)
365
+ zs = [h + self._mm(z1[:, i * self.d:(i + 1) * self.d], W) + b for i, (W, b) in enumerate(self.P2)]
366
+ for i in range(T):
367
+ j = j0 + i
368
+ us = uss[i]; sp = spks[i]
369
+ cu, nu_ = self.carry["utt"]; ci, ni = self.carry["item"]; cs, ns = self.carry["speaker"]
370
+ w_ = 1.0 if self.ua is None else float(torch.exp((zs[0][i] @ self.ua[0] + self.ua[1]).clamp(-10, 10)))
371
+ cu = w_ * zs[0][i].double() + (0 if us else cu); nu_ = w_ + (0 if us else nu_)
372
+ ci = ci + zs[2][i].double(); ni = ni + 1
373
+ if sp > 0:
374
+ cs = cs.clone(); ns = ns.clone(); cs[sp - 1] += zs[1][i].double(); ns[sp - 1] += 1
375
+ self.carry = dict(utt=(cu, nu_), item=(ci, ni), speaker=(cs, ns))
376
+ self.pooled[j] = dict(utt=(cu / nu_).float(), item=(ci / ni).float(),
377
+ speaker=((cs[sp - 1] / ns[sp - 1]).float() if sp > 0 else torch.zeros(self.d)))
378
+ self.words.append(words[i]); self.ustart.append(us); self.spk.append(sp)
379
+ self.n = j0 + T
380
+ for x in [x for x in self.snap if x < self.n - KEEP]:
381
+ del self.snap[x]
382
+ # T0 for the new words; T1 for words whose read word is now available
383
+ new1 = [i for i in range(max(0, j0 - D_MAX), self.n) if i not in self.out["T1"]]
384
+ for j in range(j0, self.n):
385
+ self.out["T0"][j] = j
386
+ self._delayed(final=False)
387
+ self.due = {("T0", j): self.word_logits(j, "T0") for j in range(j0, self.n)} # read-outs due now
388
+ self.due.update({("T1", i): self.word_logits(i, "T1") for i in new1 if i in self.out["T1"]})
389
+
390
+ def _delayed(self, final, lo=None):
391
+ n = self.n
392
+ lo = max(0, n - 2 * D_MAX - 2) if lo is None else lo
393
+ starts = [i for i in range(max(0, min(lo, n - 3 * D_MAX)), n) if self.ustart[i]]
394
+ for tier, cap in (("T1", 3), ("T2", D_MAX)):
395
+ if tier == "T2" and not final:
396
+ continue # T2: computed at the endpoint only
397
+ for i in range(lo, n):
398
+ if i in self.out[tier]:
399
+ continue
400
+ s_next = next((s for s in starts if s > i), None)
401
+ end = (s_next - 1) if s_next is not None else (n - 1 if final else None)
402
+ jr = min(i + cap, end) if end is not None else (i + cap if i + cap <= n - 1 else None)
403
+ if jr is None:
404
+ continue
405
+ self.out[tier][i] = jr
406
+
407
+ @torch.inference_mode()
408
+ def endpoint(self):
409
+ """utterance end: T1 / T2 of the closing utterance + its pooled read-outs (utt / speaker / item at the last word)"""
410
+ self.next_ustart = True
411
+ if not self.n:
412
+ return {}
413
+ s = max(i for i in range(self.n) if self.ustart[i])
414
+ n1 = [i for i in range(s, self.n) if i not in self.out["T1"]]; n2 = [i for i in range(s, self.n) if i not in self.out["T2"]]
415
+ self._delayed(final=True, lo=s)
416
+ due = {("T1", i): self.word_logits(i, "T1") for i in n1}
417
+ due.update({("T2", i): self.word_logits(i, "T2") for i in n2})
418
+ due[("pooled", self.n - 1)] = self.pooled_logits(self.n - 1)
419
+ return due
420
+
421
+ # ------------------------------------------------------------------ materialise logits (lazy; what an API call returns)
422
+ def word_logits(self, i, tier):
423
+ jr = self.out[tier][i]; d = jr - i
424
+ z = self.Zw[jr] + self.dq[min(d, D_MAX)]
425
+ if d > 0 and self.Zd is not None:
426
+ z = z + self.Zd[i]
427
+ ctx = None
428
+ if d > 0 and self.da is not None: # v2 A2: attention of word i over h_i..h_{i+d}
429
+ A = self.da; Hs = self.Hbuf[i:jr + 1]
430
+ kk = Hs @ A["Wk"] + A["bk"] + A["ko"][:d + 1]; vv = Hs @ A["Wv"] + A["bv"]
431
+ qv = self.Hbuf[i] @ A["Wq"] + A["bq"] + A["qd"][min(d, D_MAX)]
432
+ ctx = ((kk @ qv) / math.sqrt(A["dk"])).softmax(0) @ vv @ A["Wo"] # (d,) state increment
433
+ z = z + ctx @ self.Mw
434
+ ymr = amr = None
435
+ if self.mr is not None: # v2.2 (a): read over words < i, query = hd_i
436
+ ck_ = (i, jr) # cached per (word, read position); dropped on rewrites
437
+ if ck_ not in self._mrc:
438
+ self._mrc[ck_] = self._memread(i, self.Hbuf[jr] + self.dqv[min(d, D_MAX)] + (ctx if ctx is not None else 0))
439
+ ymr, amr = self._mrc[ck_]
440
+ z = z + ymr @ self.Mw
441
+ out = {q: z[a:b] for q, (a, b) in self.wslices.items()}
442
+ if self.sh is not None:
443
+ zs_ = self.Zs[jr] + self.sh_dq[min(d, D_MAX)] + (ctx @ self.sh[0] if ctx is not None else 0) + (ymr @ self.sh[0] if ymr is not None else 0)
444
+ out["span_head"] = {f: (zs_[self.sh_sl[(f, "t")][0]:self.sh_sl[(f, "t")][1]], zs_[self.sh_sl[(f, "b")][0]:self.sh_sl[(f, "b")][1]])
445
+ for f in self.V2.SPAN_FAMS}
446
+ k = min(d, 2); W = self.war
447
+ us = (self.Ubuf[i + k] @ W["r"][0] + W["r"][1]) if k else W["r"][1]
448
+ v = self._ln(self.Ubuf[i] + us + W["k"][k], W["ln"])
449
+ out["span_beg"] = v @ W["M"] + W["b"]
450
+ if amr is not None:
451
+ out["span_beg"] = out["span_beg"] + amr
452
+ return out
453
+
454
+ @torch.inference_mode()
455
+ def _memread(self, i, hd):
456
+ R = self.mr; n = self.n
457
+ if self._mk < n: # extend / refresh the key cache
458
+ hn = R.norm(self.Hbuf[self._mk:n]); self.Kc[self._mk:n] = R.kn(R.k(hn)); self.Vc[self._mk:n] = R.v(hn)
459
+ self.Bc[self._mk:n] = float(R.kb) * F.logsigmoid(R.kmark(hn)[:, 0]); self._mk = n
460
+ q = R.qn(R.q(R.norm(hd)))
461
+ sc = torch.cat([torch.zeros(1), self.Kc[:i] @ q / math.sqrt(R.dk) + self.Bc[:i]])
462
+ p = sc.softmax(0)[1:] # slot 0 = null key (zero value)
463
+ a = p @ self.Vc[:i]
464
+ return self.mr_g * R.o(a), self.mr_gu * R.ou(a) # state increment, span_beg logit increment
465
+
466
+ def pooled_logits(self, j):
467
+ return {q: self.pooled[j][q] @ self.pool[q][0] + self.pool[q][1] for q in POOLED}
468
+
469
+
470
+ # ---------------------------------------------------------------------- parity + bench
471
+ def parity(sample, ckpt=None, init=None, n_words=150, scratch=None):
472
+ from data import Windows, Cfg, collate
473
+ m, T = SA.load(ckpt, init, scratch)
474
+ s = json.loads(open(sample).readline())
475
+ cfg = Cfg(); cfg.mlm = False; cfg.max_sub = 32
476
+ ex = Windows(T, AT.ArcPieces(AT.BPE8K if m.scratch else AT.TOK_PATH), cfg, train=False).example(s, 0, n_words, tier="T0"); b = collate([ex])
477
+ with torch.inference_mode():
478
+ ref = m(b, tiers=("T0", "T1", "T2"))["tiers"]
479
+ st = FastArc(m, T, domain=s.get("domain"), asr=s.get("asr"))
480
+ ust = set(np.nonzero(ex["ustart"])[0].tolist())
481
+ rv = next(i for i in range(n_words - 20, 10, -1) if not any(j in ust for j in range(i - 4, i + 1)))
482
+ for i, w in enumerate(s["words"][:n_words]):
483
+ if i in ust and i > 0:
484
+ st.endpoint()
485
+ st.update(i, [w])
486
+ if i == rv:
487
+ st.update(i - 4, [{"w": "xx"}, {"w": "yy"}, {"w": "zz"}])
488
+ st.update(i - 4, s["words"][i - 4: i + 1])
489
+ st.endpoint()
490
+ res = {}
491
+ for tier in ("T0", "T1", "T2"):
492
+ for q in WORD_Q:
493
+ R = ref[tier][q][0]
494
+ if R.shape[-1] == 0:
495
+ continue
496
+ got = torch.stack([st.word_logits(i, tier)[q] for i in range(n_words)])
497
+ fin = R > NEG / 2
498
+ if fin.any():
499
+ res[f"{tier}:{q}"] = round(float((R - got).abs()[fin].max()), 6)
500
+ for q in POOLED:
501
+ R = ref["T0"][q][0]; got = torch.stack([st.pooled_logits(i)[q] for i in range(n_words)]); fin = R > NEG / 2
502
+ if fin.any():
503
+ res[q] = round(float((R - got).abs()[fin].max()), 6)
504
+ return res, max(res.values())
505
+
506
+
507
+ def bench(sample, prefix=1000, n=40, threads=1, int8=False, init=None, engine="fast"):
508
+ torch.set_num_threads(threads)
509
+ m, T = SA.load(None, init)
510
+ if int8:
511
+ import torch.ao.quantization as Q
512
+ if engine == "fast":
513
+ m = m # fast engine: weights are pre-folded fp32 matrices; int8 = quantised matmuls (qint8 per-tensor) below
514
+ else:
515
+ for name in ("trunk", "word"):
516
+ setattr(m, name, Q.quantize_dynamic(getattr(m, name), {torch.nn.Linear}, dtype=torch.qint8))
517
+ words = []
518
+ for line in open(sample):
519
+ words += json.loads(line)["words"]
520
+ if len(words) >= prefix + 2 * n:
521
+ break
522
+ st = (FastArc if engine == "fast" else SA.ArcStream)(m, T, domain="court_appellate", asr="deepgram")
523
+ if int8 and engine == "fast":
524
+ quantize_fast(st)
525
+ t = time.time()
526
+ for i in range(0, prefix, 1):
527
+ st.update(i, [words[i]])
528
+ tp = time.time() - t
529
+ ta, tr = [], []
530
+ for i in range(prefix, prefix + n):
531
+ t = time.perf_counter(); st.update(i, [words[i]]); ta.append(time.perf_counter() - t)
532
+ for _ in range(n // 2):
533
+ k = len(st.words) - 3
534
+ t = time.perf_counter(); st.update(k, [dict(w, final=True) for w in st.words[k:]]); tr.append(time.perf_counter() - t)
535
+ q = lambda x: round(1000 * float(np.median(x)), 2)
536
+ return dict(engine=engine, threads=threads, int8=int8, append_p50_ms=q(ta), append_p90_ms=round(1000 * float(np.percentile(ta, 90)), 2),
537
+ revise3_p50_ms=q(tr), revise3_p90_ms=round(1000 * float(np.percentile(tr, 90)), 2), prefix_s=round(tp, 1))
538
+
539
+
540
+ class QLin:
541
+ """int8 weight (per output channel) x fp32 activation via torch.ops.quantized dynamic linear"""
542
+ def __init__(self, W_t, b=None):
543
+ W = W_t.t().contiguous()
544
+ self.p = torch.ops.quantized.linear_prepack(torch.quantize_per_channel(W, W.abs().amax(1).clamp(min=1e-8) / 127,
545
+ torch.zeros(W.shape[0], dtype=torch.long), 0, torch.qint8), b)
546
+
547
+ def mm(self, x):
548
+ return torch.ops.quantized.linear_dynamic(x.contiguous(), self.p)
549
+
550
+
551
+ def quantize_fast(st):
552
+ """int8 for the big matrices of the fast engine (trunk fused / out / MLP, input proj, encoder); read-out matrices stay fp32"""
553
+ for L in st.layers:
554
+ for k in ("Wf", "Wout", "M1", "M2"):
555
+ L[k] = QLin(L[k])
556
+ st.Winp = QLin(st.Winp)
557
+ for L in st.enc:
558
+ for k in ("Wqkv", "Wo", "F1", "F2"):
559
+ L[k] = QLin(L[k])
560
+ st.P1 = QLin(st.P1); st.P2 = [(QLin(W), b) for W, b in st.P2]
561
+
562
+
563
+ if __name__ == "__main__":
564
+ ap = argparse.ArgumentParser()
565
+ ap.add_argument("--sample", default=os.path.join(os.path.dirname(HERE), "cache/sample/real.jsonl"))
566
+ ap.add_argument("--ckpt"); ap.add_argument("--init", default="/media/karan/Disk1/sidescreen-audiodec/v14/edge/ckpt/PK8a/step24000")
567
+ ap.add_argument("--parity", action="store_true"); ap.add_argument("--bench", action="store_true"); ap.add_argument("--int8", action="store_true")
568
+ ap.add_argument("--threads", type=int, default=1); ap.add_argument("--engine", default="fast"); ap.add_argument("--prefix", type=int, default=1000)
569
+ a = ap.parse_args()
570
+ torch.set_num_threads(a.threads)
571
+ if a.parity:
572
+ r, mx = parity(a.sample, a.ckpt, a.init)
573
+ print("fast parity max|dlogit|", mx, json.dumps(r))
574
+ if a.bench:
575
+ print("bench", json.dumps(bench(a.sample, a.prefix, threads=a.threads, int8=a.int8, init=a.init, engine=a.engine)))
arctag/_core/kb.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc Tagger long-list KB ("live RAG"): typed entries, lexical + phonetic index, distractors, per-session entry extraction.
2
+ The trained part (span query -> pointer over a K-shortlist + null, entry-aware verification, KB spotting) is in arc_text.py;
3
+ this module is pure CPU / numpy (data workers, eval, inference-side index). DESIGN.md section 13.
4
+
5
+ Entry: {"id", "type" (TYPES), "text", "aliases": [...], "value": optional canonical value, "fields": optional dict}
6
+ """
7
+ import math, re, zlib
8
+ import numpy as np
9
+
10
+ TYPES = ("name", "number", "keyphrase", "term", "product", "legal_ref", "slot_value", "fact")
11
+ TYPE_ID = {t: i for i, t in enumerate(TYPES)}
12
+ NAME_TAGS = {"person", "team", "organization", "place", "event_name", "game_entity", "game_map"}
13
+ STOP = set("""a an the and or but of to in on at for from by with as is are was were be been being it its this that these those i you he she
14
+ we they me him her us them my your his our their not no so if then than there here what which who whom whose when where why how all any
15
+ each some such very just also do does did done have has had having will would can could should may might must shall up down out over
16
+ into onto about after before again more most other own same too only s t don now ok okay yeah yes uh um oh like well right know think
17
+ going get got go say said see let one two three""".split())
18
+ WEB_SLOT_SRC = ("web_sgd", "web_mwoz", "web_abcd", "web_tm1", "web_tm2", "web_tm3")
19
+
20
+
21
+ # ---------------------------------------------------------------------------------------------------------- normalisation / keys
22
+ def norm(t):
23
+ return " ".join(re.findall(r"[a-z0-9]+", str(t).lower()))
24
+
25
+
26
+ _PH = [(r"ph", "f"), (r"gh(?=[^aeiou]|$)", ""), (r"kn|gn|pn|wr|ps", lambda m: m.group(0)[1]), (r"ck|q", "k"), (r"x", "ks"),
27
+ (r"c(?=[iey])", "s"), (r"c", "k"), (r"dg(?=[iey])", "j"), (r"g(?=[iey])", "j"), (r"sch", "sk"), (r"sh|ch|tch", "x"),
28
+ (r"th", "0"), (r"tion|sion", "xn"), (r"z", "s"), (r"v", "f"), (r"w(?=[^aeiou]|$)", ""), (r"h(?=[^aeiou]|$)", ""),
29
+ (r"b|p", "p"), (r"d|t", "t"), (r"m", "m"), (r"(.)\1+", r"\1")]
30
+ _DIG = {"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four", "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine"}
31
+
32
+
33
+ _PHC = [(re.compile(a), b) for a, b in _PH]
34
+ _NONA = re.compile(r"[^a-z]"); _VOW = re.compile(r"[aeiouy]")
35
+
36
+
37
+ @__import__("functools").lru_cache(maxsize=1 << 18)
38
+ def phon_word(w):
39
+ """compact metaphone-style key of one word: first letter kept, vowels dropped after it, consonant classes merged"""
40
+ w = _NONA.sub("", w.lower())
41
+ if not w:
42
+ return ""
43
+ k = w
44
+ for a, b in _PHC:
45
+ k = a.sub(b, k)
46
+ return (k[0] + _VOW.sub("", k[1:]))[:6] if k else w[:1]
47
+
48
+
49
+ def phon(t):
50
+ """phonetic key of a phrase (digits spelled out, so '2' ~ 'two')"""
51
+ ws = []
52
+ for w in re.findall(r"[a-z]+|[0-9]", str(t).lower()):
53
+ ws.append(phon_word(_DIG.get(w, w)))
54
+ return " ".join(x for x in ws if x)
55
+
56
+
57
+ def ngrams(t, n=3):
58
+ s = f" {norm(t)} "
59
+ return [s[i:i + n] for i in range(max(1, len(s) - n + 1))]
60
+
61
+
62
+ H = 1 << 18
63
+
64
+
65
+ def _h(g):
66
+ return zlib.crc32(g.encode()) & (H - 1)
67
+
68
+
69
+ @__import__("functools").lru_cache(maxsize=1 << 19)
70
+ def grams_of(t):
71
+ """hashed char-3gram ids of a text (cached: pool entries are re-indexed in every sampled list)"""
72
+ return np.unique(np.fromiter((_h(x) for x in ngrams(t)), np.int64))
73
+
74
+
75
+ @__import__("functools").lru_cache(maxsize=1 << 19)
76
+ def phon_c(t):
77
+ return phon(t)
78
+
79
+
80
+ # ---------------------------------------------------------------------------------------------------------- lexical + phonetic index
81
+ class LexIndex:
82
+ """char-3gram TF-IDF (hashed, sparse) over entry texts + aliases, plus phonetic-key buckets (exact phrase key and per-word keys).
83
+ query(texts, K) -> (n, K) entry indices (-1 pad) ranked by max(cosine, phonetic bonus)."""
84
+ def __init__(self, entries):
85
+ import scipy.sparse as sp
86
+ self.entries = entries; rows, cols, vals, owner = [], [], [], []
87
+ surf = []
88
+ for i, e in enumerate(entries):
89
+ for t in [e["text"]] + list(e.get("aliases") or []):
90
+ surf.append(t); owner.append(i)
91
+ self.owner = np.array(owner, np.int64)
92
+ grams = [grams_of(t) for t in surf]
93
+ df = np.bincount(np.concatenate(grams), minlength=H).astype(np.float32) if grams else np.zeros(H, np.float32)
94
+ self.idf = np.log((1 + len(surf)) / (1 + df)).astype(np.float32) + 1
95
+ lens = np.array([len(g) for g in grams], np.int64); cols = np.concatenate(grams) if grams else np.zeros(0, np.int64)
96
+ rows = np.repeat(np.arange(len(surf)), lens); w = self.idf[cols]
97
+ nrm = np.sqrt(np.bincount(rows, w ** 2, minlength=len(surf))); w = w / np.maximum(nrm[rows], 1e-6)
98
+ self.M = sp.csr_matrix((w, (rows, cols)), shape=(len(surf), H), dtype=np.float32)
99
+ self.MT = self.M.T.tocsr()
100
+ self.pk = {}
101
+ for r, t in enumerate(surf):
102
+ self.pk.setdefault(phon_c(t), set()).add(int(owner[r]))
103
+
104
+ def query(self, texts, K=64):
105
+ import scipy.sparse as sp
106
+ rows, cols, vals = [], [], []
107
+ for r, t in enumerate(texts):
108
+ g = grams_of(t); w = self.idf[g]; w = w / max(1e-6, float(np.sqrt((w ** 2).sum())))
109
+ rows += [r] * len(g); cols += g.tolist(); vals += w.tolist()
110
+ Q = sp.csr_matrix((vals, (rows, cols)), shape=(len(texts), H), dtype=np.float32)
111
+ S = (Q @ self.MT).toarray() if len(texts) * self.M.shape[0] <= 4e7 else None
112
+ out = np.full((len(texts), K), -1, np.int64); sc = np.zeros((len(texts), K), np.float32)
113
+ for r, t in enumerate(texts):
114
+ s = S[r] if S is not None else (Q[r] @ self.MT).toarray()[0]
115
+ best = {}
116
+ nz = np.nonzero(s > 0.05)[0]
117
+ if len(nz) > 4 * K:
118
+ nz = nz[np.argpartition(-s[nz], 4 * K)[:4 * K]]
119
+ for j in nz:
120
+ o = int(self.owner[j]); best[o] = max(best.get(o, 0.0), float(s[j]))
121
+ for o in self.pk.get(phon_c(t), ()): # sound-alike: phonetic key equal
122
+ best[o] = max(best.get(o, 0.0), 0.5 + 0.5 * best.get(o, 0.0))
123
+ top = sorted(best.items(), key=lambda x: -x[1])[:K]
124
+ for k, (o, v) in enumerate(top):
125
+ out[r, k] = o; sc[r, k] = v
126
+ return out, sc
127
+
128
+
129
+ # ---------------------------------------------------------------------------------------------------------- distractors
130
+ def asr_corrupt(t, rng):
131
+ """an ASR-style variant: char substitution / deletion / merge-split / homophone-ish vowel change / word drop"""
132
+ ws = t.split()
133
+ if not ws:
134
+ return t
135
+ r = rng.random(); i = rng.randrange(len(ws)); w = ws[i]
136
+ if r < 0.25 and len(w) > 3:
137
+ j = rng.randrange(1, len(w)); w = w[:j] + rng.choice("aeioubdgkmnprst") + w[j + 1:]
138
+ elif r < 0.45 and len(w) > 3:
139
+ j = rng.randrange(1, len(w)); w = w[:j] + w[j + 1:]
140
+ elif r < 0.6 and len(ws) > 1:
141
+ ws = ws[:i] + ws[i + 1:]; return " ".join(ws)
142
+ elif r < 0.75 and len(w) > 5:
143
+ j = rng.randrange(2, len(w) - 2); w = w[:j] + " " + w[j:]
144
+ else:
145
+ w = re.sub(r"[aeiou]", lambda m: rng.choice("aeiou"), w, count=1)
146
+ ws[i] = w
147
+ return " ".join(ws)
148
+
149
+
150
+ def near_number(t, rng):
151
+ """near-miss number: one digit changed, two digits swapped, or the scale changed"""
152
+ ds = [m.start() for m in re.finditer(r"\d", t)]
153
+ if not ds:
154
+ return asr_corrupt(t, rng)
155
+ r = rng.random(); s = list(t)
156
+ if r < 0.5:
157
+ j = rng.choice(ds); s[j] = rng.choice([d for d in "0123456789" if d != s[j]])
158
+ elif r < 0.8 and len(ds) > 1:
159
+ j = rng.randrange(len(ds) - 1); a, b = ds[j], ds[j + 1]; s[a], s[b] = s[b], s[a]
160
+ else:
161
+ return t + "0" if rng.random() < 0.5 else (t[:-1] if len(t) > 1 else t + "1")
162
+ return "".join(s)
163
+
164
+
165
+ def wrong_qualifier(t, rng):
166
+ """'close but wrong' variant that keeps the head: qualifier swapped (Inc / Corp, north / south, first / second, v. reversed)"""
167
+ swaps = [("inc", "corp"), ("north", "south"), ("east", "west"), ("first", "second"), ("senior", "junior"), ("new", "old"),
168
+ ("upper", "lower"), ("international", "national"), ("v.", "vs."), ("i", "ii")]
169
+ low = t.lower()
170
+ for a, b in swaps:
171
+ if re.search(rf"\b{re.escape(a)}\b", low):
172
+ return re.sub(rf"\b{re.escape(a)}\b", b, low, count=1)
173
+ if re.search(rf"\b{re.escape(b)}\b", low):
174
+ return re.sub(rf"\b{re.escape(b)}\b", a, low, count=1)
175
+ return None
176
+
177
+
178
+ def distractors(entry, pool_by_type, pool_by_phon, rng, n=8):
179
+ """hard distractors for one entry: phonetic neighbours (pool), same-type entries (pool), ASR-corrupted / near-miss / wrong-qualifier
180
+ variants of its own text. Returns entries tagged with 'dkind'."""
181
+ out = []
182
+ t = entry["text"]; ty = entry["type"]
183
+ for o in list(pool_by_phon.get(phon(t), ()))[:3]:
184
+ if norm(o["text"]) != norm(t):
185
+ out.append(dict(o, dkind="phonetic"))
186
+ same = pool_by_type.get(ty) or []
187
+ for _ in range(min(3, len(same))):
188
+ o = same[rng.randrange(len(same))]
189
+ if norm(o["text"]) != norm(t):
190
+ out.append(dict(o, dkind="same_type"))
191
+ v = near_number(t, rng) if ty == "number" else asr_corrupt(t, rng)
192
+ if norm(v) and norm(v) != norm(t):
193
+ out.append({"text": v, "type": ty, "aliases": [], "dkind": "near_miss" if ty == "number" else "asr_variant"})
194
+ q = wrong_qualifier(t, rng)
195
+ if q and norm(q) != norm(t):
196
+ out.append({"text": q, "type": ty, "aliases": [], "dkind": "wrong_qualifier"})
197
+ return out[:n]
198
+
199
+
200
+ def list_size(rng, lo=16, hi=10000):
201
+ return int(round(math.exp(rng.uniform(math.log(lo), math.log(hi)))))
202
+
203
+
204
+ # ---------------------------------------------------------------------------------------------------------- per-session extraction
205
+ def _type_of(tag, src_file, ex):
206
+ if tag.startswith("entity.number.") or tag == "entity.numeral":
207
+ return "number"
208
+ if not tag.startswith("entity.mention."):
209
+ return None
210
+ k = tag.split(".")[2]
211
+ if any(src_file.startswith(w) for w in WEB_SLOT_SRC) and (ex or {}).get("src") == "dataset":
212
+ return "slot_value"
213
+ return {"work_product": "product", "legal_ref": "legal_ref", "term": "term"}.get(k, "name" if k in NAME_TAGS else None)
214
+
215
+
216
+ LEGAL_RE = re.compile(r"\b(\d+\s+U\.?\s?S\.?\s?C\.?|§+\s*\d+|[A-Z][a-z]+\s+v\.?\s+[A-Z][a-z]+|Section\s+\d+[a-z]?|Rule\s+\d+)", re.I)
217
+
218
+
219
+ def keyphrases(words, ent_mask, idf, k=24, nmax=4):
220
+ """salient non-entity n-grams (1-4 words, no stop word at either end, >= 1 letter word of >= 3 chars), scored by
221
+ session tf x idf (idf over a sample of sessions; unseen = high); multi-word preferred; non-overlapping with entity spans"""
222
+ low = [norm(w) for w in words]; cand = {}
223
+ for n in range(1, nmax + 1):
224
+ for i in range(len(low) - n + 1):
225
+ g = low[i:i + n]
226
+ if any(ent_mask[i:i + n]) or not all(g) or g[0] in STOP or g[-1] in STOP or not any(len(x) >= 3 and x.isalpha() for x in g):
227
+ continue
228
+ if n == 1 and (len(g[0]) < 5 or not g[0].isalpha()):
229
+ continue
230
+ key = " ".join(g); c = cand.setdefault(key, [0, i, n]); c[0] += 1
231
+ scored = []
232
+ for key, (tf, i, n) in cand.items():
233
+ if n == 1 and tf < 2:
234
+ continue
235
+ s = (1 + math.log(tf)) * idf.get(key, 8.0) * (1.0 + 0.5 * (n - 1))
236
+ scored.append((s, key, i, n))
237
+ scored.sort(reverse=True); out, used = [], np.zeros(len(low), bool)
238
+ for s, key, i, n in scored:
239
+ if used[i:i + n].any():
240
+ continue
241
+ used[i:i + n] = True; out.append((key, i, i + n))
242
+ if len(out) >= k:
243
+ break
244
+ return out
245
+
246
+
247
+ def session_entries(s, src_file, idf, k_key=24):
248
+ """true KB entries of a session from its own text + labels: [{"text", "type", "occ": [[a, b], ...], "value"?, "tag"?}]"""
249
+ W = [w["w"] for w in s.get("words") or []]; ent = {}; mask = np.zeros(len(W), bool)
250
+ for sp in (s.get("labels") or {}).get("span", []):
251
+ tag, a, b = sp[0], int(sp[1]), int(sp[2]); ex = sp[3] if len(sp) > 3 and isinstance(sp[3], dict) else {}
252
+ ty = _type_of(tag, src_file, ex)
253
+ if ty is None or b <= a:
254
+ continue
255
+ txt = " ".join(W[a:b]).strip(" .,;:!?\"'")
256
+ if len(norm(txt)) < 1 or (ty != "number" and len(norm(txt)) < 2):
257
+ continue
258
+ key = (ty, norm(txt)); e = ent.setdefault(key, {"text": txt, "type": ty, "occ": [], "tag": tag})
259
+ e["occ"].append([a, b]); mask[a:b] = True
260
+ if ex.get("canon") and ty == "number":
261
+ e["value"] = ex["canon"]
262
+ for e0 in (s.get("kb") or []): # the session's own KB list (names), as aliases-free entries
263
+ t = e0[0] if isinstance(e0, (list, tuple)) else e0
264
+ key = ("name", norm(t))
265
+ if norm(t) and key not in ent:
266
+ ent[key] = {"text": str(t), "type": "name", "occ": [], "tag": "kb"}
267
+ txt = " ".join(W)
268
+ for m in LEGAL_RE.finditer(txt):
269
+ key = ("legal_ref", norm(m.group(0)))
270
+ if key not in ent and norm(m.group(0)):
271
+ ent[key] = {"text": m.group(0), "type": "legal_ref", "occ": [], "tag": "regex"}
272
+ for key, a, b in keyphrases(W, mask, idf, k=k_key):
273
+ ent.setdefault(("keyphrase", key), {"text": " ".join(W[a:b]).strip(" .,;:!?\"'"), "type": "keyphrase", "occ": [[a, b]], "tag": "salience"})
274
+ locate_all(list(ent.values()), W)
275
+ return list(ent.values())
276
+
277
+
278
+ def locate_all(E, W, nmax=8):
279
+ """fill e["occ"] (if empty) with every occurrence (<= 20) of the entry's normalised text in the words: one n-gram -> positions map"""
280
+ need = [e for e in E if not e["occ"]]
281
+ if not need:
282
+ return
283
+ low = [norm(w) for w in W]; lens = {len(norm(e["text"]).split()) for e in need}; pos = {}
284
+ for n in lens:
285
+ if 1 <= n <= nmax:
286
+ for i in range(len(low) - n + 1):
287
+ pos.setdefault(" ".join(low[i:i + n]), []).append(i)
288
+ for e in need:
289
+ nt = norm(e["text"]); n = len(nt.split())
290
+ e["occ"] = [[i, i + n] for i in pos.get(nt, [])[:20]]
arctag/_core/kb_strfeat.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """v2.2 (b) / (c) string features for the long-list KB (zero-init add-ons, arc_text.add_kb_str):
2
+ (b) char-n-gram mention key: hashed char 3-grams of the heard span text -> EmbeddingBag added to the KB query before normalisation;
3
+ (c) ver_e string features: explicit (span text, entry text) similarity features into the entry-aware verifier's hidden layer."""
4
+ import zlib, difflib, functools
5
+ import kb as KBM
6
+ NB = 4096 # hash buckets
7
+ NF = 8 # string features
8
+
9
+
10
+ def grams(t):
11
+ t = f"#{KBM.norm(t)}#"
12
+ return [zlib.crc32(t[i:i + 3].encode()) % NB for i in range(max(1, len(t) - 2))]
13
+
14
+
15
+ def cng_batch(texts):
16
+ """-> (ids list, offsets list) for nn.EmbeddingBag"""
17
+ ids, off = [], []
18
+ for t in texts:
19
+ off.append(len(ids)); ids += grams(t)
20
+ return ids, off
21
+
22
+
23
+ def _g3(t):
24
+ t = f"#{t}#"; return {t[i:i + 3] for i in range(max(1, len(t) - 2))}
25
+
26
+
27
+ @functools.lru_cache(maxsize=200000)
28
+ def sfeat(q, e):
29
+ """(span text, entry text) -> NF floats in [0, 1]"""
30
+ qn, en = KBM.norm(q), KBM.norm(e)
31
+ if not qn or not en:
32
+ return [0.0] * NF
33
+ a, b = _g3(qn), _g3(en); qt, et = set(qn.split()), set(en.split())
34
+ qd, ed = "".join(c for c in qn if c.isdigit()), "".join(c for c in en if c.isdigit())
35
+ return [len(a & b) / max(1, len(a | b)), len(qt & et) / max(1, len(qt | et)), difflib.SequenceMatcher(None, qn, en).ratio(),
36
+ min(len(qn), len(en)) / max(len(qn), len(en)), float(qn == en), float(qn[:3] == en[:3]),
37
+ float(qd == ed) if (qd or ed) else 0.5, float(KBM.phon(qn) == KBM.phon(en))]
arctag/_core/losses.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arc Tagger losses: masked BCE (independent tags), soft-CE with a null class over exclusive-group members (members whose target
2
+ is NaN = not labelled-for drop out of the softmax, as pk_safety.readout_loss_null(excl=...)), KD on teacher probabilities with the
3
+ same form, span verification / KB-match, MLM."""
4
+ import torch, torch.nn.functional as F
5
+
6
+ from tagtable import QUERIES
7
+
8
+
9
+ class GroupIndex:
10
+ """per read-out: column positions of independent tags and of each exclusive group"""
11
+ def __init__(self, T, w_weak=0.3):
12
+ self.indep, self.groups, self.colw = {}, {}, {}
13
+ for q in QUERIES:
14
+ cols = T.cols[q]
15
+ self.indep[q] = torch.tensor([k for k, i in enumerate(cols) if i not in T.group_of], dtype=torch.long)
16
+ g = {}
17
+ for k, i in enumerate(cols):
18
+ if i in T.group_of:
19
+ g.setdefault(T.group_of[i], []).append(k)
20
+ self.groups[q] = {n: torch.tensor(v, dtype=torch.long) for n, v in g.items()}
21
+ self.colw[q] = torch.tensor([w_weak if i in T.weak else 1.0 for i in cols])
22
+ self.posw = None # stage 2: {q: (C,) false-negative weight} used at T0 only (set_fn_weights)
23
+ self.ls = {} # stage 2: {(q, group): label smoothing} for exclusive-group soft-CE (e.g. discourse.intent .1)
24
+ self.clsw = {} # m4 (11-06): {(q, group): (M,) per-member positive-row multiplier} (e.g. ritual_procedural 2.0);
25
+ # normaliser unchanged (like posw). Empty = identical to before.
26
+ self.typw = {} # m5/m6 (11-06): {q: (C,) positive-target multiplier, ALL tiers, independent columns} (--type_pos_w,
27
+ # e.g. entity.mention.organization 1.5); normaliser unchanged. Empty = identical to before.
28
+ self._dev = {}
29
+
30
+ def set_fn_weights(self, T, weights, queries=("span_beg", "span_in", "span_end")):
31
+ """asymmetric T0 loss: positive-target multiplier per tag (redact.FN_W_T0) on the span read-outs; 1 elsewhere"""
32
+ self.posw = {q: torch.tensor([float(weights.get(T.ids[i], 1.0)) if q in queries else 1.0 for i in T.cols[q]]) for q in QUERIES}
33
+ return self
34
+
35
+ def set_type_pos_w(self, T, spec, queries=("span_beg", "span_in", "span_end")):
36
+ """spec 'tag=w,...' -> positive-target multiplier on the span read-outs of independent (non-exclusive) columns, every tier"""
37
+ want = {}
38
+ for x in (spec or "").split(","):
39
+ if x.strip():
40
+ k, v = x.rsplit("=", 1); want[k.strip()] = float(v)
41
+ hit = set()
42
+ for q in queries:
43
+ ind = set(self.indep[q].tolist()); v = []
44
+ for k, i in enumerate(T.cols[q]):
45
+ x = want.get(T.ids[i], 1.0) if k in ind else 1.0; v.append(x)
46
+ if x != 1.0: hit.add(T.ids[i])
47
+ if any(x != 1.0 for x in v):
48
+ self.typw[q] = torch.tensor(v)
49
+ miss = set(want) - hit
50
+ if miss:
51
+ raise ValueError(f"--type_pos_w: not independent span columns: {sorted(miss)}")
52
+ return self
53
+
54
+ def set_class_w(self, T, spec):
55
+ """spec 'tag=w,tag=w' -> per-member multipliers of exclusive groups (tags outside exclusive groups are rejected)"""
56
+ want = {}
57
+ for x in (spec or "").split(","):
58
+ if x.strip():
59
+ k, v = x.rsplit("=", 1); want[k.strip()] = float(v)
60
+ hit = set()
61
+ for q in QUERIES:
62
+ cols = T.cols[q]
63
+ for n, m in self.groups[q].items():
64
+ v = [want.get(T.ids[cols[k]], 1.0) for k in m.tolist()]
65
+ if any(x != 1.0 for x in v):
66
+ self.clsw[(q, n)] = torch.tensor(v); hit.update(T.ids[cols[k]] for k in m.tolist() if T.ids[cols[k]] in want)
67
+ miss = set(want) - hit
68
+ if miss:
69
+ raise ValueError(f"--class_w: not members of an exclusive group: {sorted(miss)}")
70
+ return self
71
+
72
+ def on(self, dev):
73
+ """index tensors resident on dev (cached: no per-call host->device copies)"""
74
+ dev = str(dev)
75
+ if dev not in self._dev:
76
+ self._dev[dev] = ({q: v.to(dev) for q, v in self.indep.items()},
77
+ {q: {n: m.to(dev) for n, m in g.items()} for q, g in self.groups.items()},
78
+ {q: v.to(dev) for q, v in self.colw.items()},
79
+ {q: {n: float(self.colw[q][m][0]) for n, m in g.items()} for q, g in self.groups.items()})
80
+ return self._dev[dev]
81
+
82
+ def probs(self, q, z):
83
+ """logits (..., C) -> probabilities with the same layout (sigmoid / group softmax with null logit 0)"""
84
+ p = torch.sigmoid(z.float())
85
+ for n, m in self.groups[q].items():
86
+ zz = torch.cat([z[..., m].float(), torch.zeros_like(z[..., :1]).float()], -1)
87
+ p[..., m] = torch.softmax(zz, -1)[..., :-1]
88
+ return p
89
+
90
+
91
+ def readout_loss(GI, q, z, tg, wq, wvalid, posw=None, cw=None):
92
+ """z, tg (B,W,C); wq (B,W) word weight; posw (C,) optional false-negative weight (positive-target multiplier, stage-2 T0);
93
+ returns (sum loss, sum weight). The normaliser stays the unweighted labelled weight, so posw raises the FN cost only."""
94
+ dev = z.device; z = z.float()
95
+ fin = torch.isfinite(tg); t = torch.nan_to_num(tg)
96
+ w_word = wq * wvalid
97
+ tot = z.new_zeros(()); den = z.new_zeros(())
98
+ ind = GI.indep[q].to(dev)
99
+ if len(ind):
100
+ zi, ti, fi = z[..., ind], t[..., ind], fin[..., ind]
101
+ w = fi * w_word[..., None] * GI.colw[q].to(dev)[ind]
102
+ if cw is not None: # v2 per-cell weights (flagged gap tags, toxicity negatives)
103
+ w = w * cw.to(dev)[..., ind]
104
+ lw = w if posw is None else w * (1 + (posw.to(dev)[ind] - 1) * ti)
105
+ if q in GI.typw:
106
+ lw = lw * (1 + (GI.typw[q].to(dev)[ind] - 1) * ti)
107
+ tot = tot + (F.binary_cross_entropy_with_logits(zi, ti, reduction="none") * lw).sum(); den = den + w.sum()
108
+ for n, m in GI.groups[q].items():
109
+ m = m.to(dev); fm = fin[..., m]
110
+ row = fm.any(-1) & (w_word > 0)
111
+ if not row.any():
112
+ continue
113
+ zm = z[..., m].masked_fill(~fm, -1e4)
114
+ zz = torch.cat([zm, torch.zeros_like(zm[..., :1])], -1)[row]
115
+ tm = t[..., m][row]
116
+ s = tm.sum(-1, keepdim=True)
117
+ tt = torch.cat([tm, (1 - s).clamp(min=0)], -1)
118
+ tt = tt / tt.sum(-1, keepdim=True).clamp(min=1e-6)
119
+ eps_ = GI.ls.get((q, n), 0.0)
120
+ if eps_: # smoothing over the labelled members + null
121
+ av = torch.cat([fm[row].float(), torch.ones_like(tt[:, :1])], -1)
122
+ tt = (1 - eps_) * tt + eps_ * av / av.sum(-1, keepdim=True)
123
+ cw_g = GI.colw[q].to(dev)[m][0]
124
+ ww = w_word[row] * cw_g
125
+ if cw is not None:
126
+ ww = ww * cw.to(dev)[..., m].min(-1).values[row]
127
+ lw = ww if posw is None else ww * (1 + ((posw.to(dev)[m] - 1) * tm).sum(-1))
128
+ if (q, n) in GI.clsw:
129
+ lw = lw * (1 + ((GI.clsw[(q, n)].to(dev) - 1) * tm).sum(-1))
130
+ tot = tot + (-(tt * torch.log_softmax(zz, -1)).sum(-1) * lw).sum(); den = den + ww.sum()
131
+ return tot, den
132
+
133
+
134
+ LAZY = False # train.py --lazy 1: no host syncs (no .any()/float()/boolean indexing); per-readout losses stay tensors
135
+
136
+
137
+ def readout_loss_lazy(GI, q, z, tg, wq, wvalid, posw=None, cw=None):
138
+ """= readout_loss with masked weights instead of row selection (same value, no device->host sync)"""
139
+ ind_, grp_, colw_, cw_ = GI.on(z.device); z = z.float()
140
+ fin = torch.isfinite(tg); t = torch.nan_to_num(tg)
141
+ w_word = wq * wvalid
142
+ tot = z.new_zeros(()); den = z.new_zeros(())
143
+ ind = ind_[q]
144
+ if len(ind):
145
+ zi, ti, fi = z[..., ind], t[..., ind], fin[..., ind]
146
+ w = fi * w_word[..., None] * colw_[q][ind]
147
+ if cw is not None:
148
+ w = w * cw[..., ind]
149
+ lw = w if posw is None else w * (1 + (posw.to(z.device)[ind] - 1) * ti)
150
+ if q in GI.typw:
151
+ lw = lw * (1 + (GI.typw[q].to(z.device)[ind] - 1) * ti)
152
+ tot = tot + (F.binary_cross_entropy_with_logits(zi, ti, reduction="none") * lw).sum(); den = den + w.sum()
153
+ for n, m in grp_[q].items():
154
+ fm = fin[..., m]
155
+ row = (fm.any(-1) & (w_word > 0)).float()
156
+ zm = z[..., m].masked_fill(~fm, -1e4)
157
+ zz = torch.cat([zm, torch.zeros_like(zm[..., :1])], -1)
158
+ tm = t[..., m]
159
+ s = tm.sum(-1, keepdim=True)
160
+ tt = torch.cat([tm, (1 - s).clamp(min=0)], -1)
161
+ tt = tt / tt.sum(-1, keepdim=True).clamp(min=1e-6)
162
+ eps_ = GI.ls.get((q, n), 0.0)
163
+ if eps_:
164
+ av = torch.cat([fm.float(), torch.ones_like(tt[..., :1])], -1)
165
+ tt = (1 - eps_) * tt + eps_ * av / av.sum(-1, keepdim=True).clamp(min=1.0)
166
+ ww = w_word * row * cw_[q][n]
167
+ if cw is not None:
168
+ ww = ww * cw[..., m].min(-1).values
169
+ lw = ww if posw is None else ww * (1 + ((posw.to(z.device)[m] - 1) * tm).sum(-1))
170
+ if (q, n) in GI.clsw:
171
+ lw = lw * (1 + ((GI.clsw[(q, n)].to(z.device) - 1) * tm).sum(-1))
172
+ tot = tot + (-(tt * torch.log_softmax(zz, -1)).sum(-1) * lw).sum(); den = den + ww.sum()
173
+ return tot, den
174
+
175
+
176
+ def tag_loss(GI, logits, b, key="tg", fn_weighted=False):
177
+ """mean over all labelled (word, column) units of every read-out (Arc: one pooled tag loss); fn_weighted: GI.posw (stage-2 T0)"""
178
+ z0 = next(iter(logits.values()))
179
+ tot, den, per = z0.new_zeros((), dtype=torch.float32), z0.new_zeros((), dtype=torch.float32), {}
180
+ for q, z in logits.items():
181
+ pw = GI.posw[q] if (fn_weighted and GI.posw is not None) else None
182
+ if LAZY:
183
+ l, d = readout_loss_lazy(GI, q, z, b[key][q], b["wq"][q], b["wvalid"].float(), pw, (b.get("tgw") or {}).get(q) if key == "tg" else None)
184
+ per[q] = (l / d.clamp(min=1e-6)).detach(); tot = tot + l; den = den + d
185
+ continue
186
+ l, d = readout_loss(GI, q, z, b[key][q].to(z.device), b["wq"][q].to(z.device), b["wvalid"].to(z.device).float(), pw,
187
+ (b.get("tgw") or {}).get(q) if key == "tg" else None)
188
+ if d > 0:
189
+ per[q] = float(l / d)
190
+ tot = tot + l; den = den + d
191
+ return tot / den.clamp(min=1.0), per
192
+
193
+
194
+ def norm_loss(model, out, b):
195
+ """span verification BCE + KB-match CE over the session KB (+ null)"""
196
+ dev = out["h"].device; lv, nv, lk, nk = 0.0, 0, 0.0, 0
197
+ for k, spans in enumerate(b["spans"]):
198
+ if not spans:
199
+ continue
200
+ sp = torch.tensor([[x[0], x[1]] for x in spans], device=dev)
201
+ rep = model.norm.span_rep(out["h"][k], sp)
202
+ kbe = None
203
+ kbt = torch.tensor([x[3] for x in spans], device=dev)
204
+ vw = torch.tensor([x[4] if len(x) > 4 else 1.0 for x in spans], device=dev)
205
+ if (kbt != -100).any() and b["kb_ids"][k]:
206
+ kbe = model.encode_kb([torch.tensor(x, device=dev) for x in b["kb_ids"][k]])
207
+ v, m = model.norm(rep, kbe)
208
+ vt = torch.tensor([x[2] for x in spans], device=dev, dtype=torch.float32)
209
+ lv = lv + (F.binary_cross_entropy_with_logits(v.float(), vt, reduction="none") * vw).sum(); nv += float(vw.sum())
210
+ if m is not None:
211
+ sel = kbt != -100
212
+ tgt = torch.where((kbt >= 0) & (kbt < m.shape[1] - 1), kbt, torch.full_like(kbt, m.shape[1] - 1))
213
+ lk = lk + F.cross_entropy(m.float()[sel], tgt[sel], reduction="sum"); nk += int(sel.sum())
214
+ return (lv / max(nv, 1)), (lk / max(nk, 1) if nk else 0.0), nv, nk
215
+
216
+
217
+ def mlm_loss(out, b):
218
+ t = b["mlm"].to(out["mlm"].device)
219
+ if not (t != -100).any():
220
+ return 0.0
221
+ return F.cross_entropy(out["mlm"].float().reshape(-1, out["mlm"].shape[-1]), t.reshape(-1), ignore_index=-100)
222
+
223
+
224
+ SF1_Q = ("ev_at", "ev_imm", "ev_rec", "span_beg", "span_in", "span_end")
225
+
226
+
227
+ def soft_f1_loss(GI, logits, b, queries=SF1_Q, min_pos=1.0, eps=0.1):
228
+ """stage 2 (--w_sf1): differentiable soft-F1 per tag column over the whole batch, 1 - F1 averaged over the columns with >= min_pos
229
+ labelled positives in the batch (no host sync). p = GI.probs (sigmoid / group softmax), masked where the target is NaN or the
230
+ word is padding / has zero read-out weight. soft TP = sum p*y, FP = sum p*(1-y), FN = sum (1-p)*y; eps smooths empty columns.
231
+ Complements BCE for rare, timing-sensitive events (score, substitution, speaker.change), where AUC is high but F1 is low."""
232
+ tot = None; n = None
233
+ for q in queries:
234
+ if q not in logits:
235
+ continue
236
+ z = logits[q]; tg = b["tg"][q].to(z.device)
237
+ if z.shape[-1] == 0:
238
+ continue
239
+ m = (torch.isfinite(tg) & ((b["wq"][q].to(z.device) * b["wvalid"].to(z.device).float()) > 0)[..., None]).float()
240
+ y = torch.nan_to_num(tg) * m
241
+ p = torch.sigmoid(z.float()) # = GI.probs without in-place writes (autograd-safe)
242
+ for _, gm in GI.groups[q].items():
243
+ gm = gm.to(z.device)
244
+ zz = torch.cat([z[..., gm].float(), torch.zeros_like(z[..., :1]).float()], -1)
245
+ p = p.index_copy(-1, gm, torch.softmax(zz, -1)[..., :-1])
246
+ p = p * m
247
+ tp = (p * y).sum((0, 1)); fp = (p * (1 - y) * m).sum((0, 1)); fn = ((1 - p) * y).sum((0, 1))
248
+ f1 = 2 * tp / (2 * tp + fp + fn + eps)
249
+ use = (y.sum((0, 1)) >= min_pos).float()
250
+ l = ((1 - f1) * use).sum(); c = use.sum()
251
+ tot = l if tot is None else tot + l; n = c if n is None else n + c
252
+ if tot is None:
253
+ return next(iter(logits.values())).sum() * 0.0
254
+ return tot / n.clamp(min=1.0)
arctag/_core/speaker_link.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage-2 text speaker linking (DESIGN.md 14.8): at every turn start, a pointer over the window's speaker memory (one slot per
2
+ speaker cluster seen so far, <= data.LINK_K, = the mean of that cluster's turn embeddings) plus a NEW-speaker slot answers "same
3
+ speaker as cluster c". Fusion with ASR diarization: the input speaker ids (gold, simulated-ASR or provider) enter the trunk through the
4
+ existing spk / chg features and the pointer through two learned scalars (same-ASR-id evidence per cluster, unseen-ASR-id evidence for
5
+ NEW). Per-cluster roles: a role read-out on the cluster memory (incl. the current turn), trained to the cluster's majority gold role.
6
+
7
+ Training is teacher-forced (memory built from the gold clusters of earlier turns) and fully vectorised: with Z the turn embeddings and
8
+ q the turn queries, score(j, k) = sum_{i<j, C_i=k} q_j . z_i / n_jk. Inference / eval (free-running) assigns turns one by one and
9
+ builds the memory from its own decisions (`assign`).
10
+
11
+ Losses: pointer CE (T0 / T1 / T2 queries = turn start, start+2, start+7 clamped to the turn end), supervised-contrastive InfoNCE on
12
+ the turn embeddings (same speaker = positive, every other turn of the batch = negative), cluster-role soft-CE.
13
+ """
14
+ import numpy as np
15
+ import torch, torch.nn as nn, torch.nn.functional as F
16
+
17
+ TIER_OFF = {"T0": 0, "T1": 2, "T2": 7}
18
+
19
+
20
+ class SpkLink(nn.Module):
21
+ def __init__(self, d, n_roles, dz=128):
22
+ super().__init__()
23
+ self.att = nn.Linear(d, 1); self.zp = nn.Linear(d, dz); self.qp = nn.Linear(d, dz)
24
+ self.tier = nn.Embedding(3, dz); nn.init.zeros_(self.tier.weight)
25
+ self.new_v = nn.Parameter(torch.randn(dz) * 0.02); self.b_new = nn.Parameter(torch.zeros(()))
26
+ self.scale = nn.Parameter(torch.tensor(np.log(10.0), dtype=torch.float32))
27
+ self.w_same = nn.Parameter(torch.zeros(())); self.w_newid = nn.Parameter(torch.zeros(()))
28
+ self.role = nn.Linear(dz, max(1, n_roles)); self.n_roles = n_roles
29
+
30
+ # -------------------------------------------------------------- turn embeddings and queries (whole batch at once)
31
+ def turn_emb(self, h, turns):
32
+ """h (B,W,d); turns [(b, s, e)] -> Z (T, dz) unit vectors: attention pool of h over each turn's words"""
33
+ dev = h.device
34
+ bi = torch.cat([torch.full((e - s,), b, dtype=torch.long) for b, s, e in turns]).to(dev)
35
+ wi = torch.cat([torch.arange(s, e) for b, s, e in turns]).to(dev)
36
+ ti = torch.cat([torch.full((e - s,), t, dtype=torch.long) for t, (b, s, e) in enumerate(turns)]).to(dev)
37
+ x = h[bi, wi].float(); a = self.att(x)[:, 0]
38
+ T = len(turns)
39
+ mx = torch.full((T,), -1e30, device=dev).scatter_reduce(0, ti, a, "amax")
40
+ ea = torch.exp(a - mx[ti]); den = torch.zeros(T, device=dev).index_add(0, ti, ea)
41
+ pooled = torch.zeros(T, x.shape[1], device=dev).index_add(0, ti, x * (ea / den[ti])[:, None])
42
+ return F.normalize(self.zp(pooled), dim=-1)
43
+
44
+ def queries(self, h, turns, tier):
45
+ k = list(TIER_OFF).index(tier); off = TIER_OFF[tier]
46
+ bi = torch.tensor([b for b, s, e in turns], device=h.device)
47
+ pi = torch.tensor([min(s + off, e - 1) for b, s, e in turns], device=h.device)
48
+ return F.normalize(self.qp(h[bi, pi].float()) + self.tier.weight[k], dim=-1)
49
+
50
+ # -------------------------------------------------------------- pointer logits for one window (teacher forced)
51
+ def logits(self, q, Z, C, A):
52
+ """q, Z (T,dz); C (T,) memory clusters (-1 = not in memory); A (T,) input ASR-id slot at each turn start (0 = none)
53
+ -> (T, 1+K) logits [NEW, cluster 0..K-1], unseen clusters at -inf; n (T,K) memory counts"""
54
+ T = q.shape[0]; K = int(C.max().item()) + 1 if (C >= 0).any() else 1
55
+ O = F.one_hot(C.clamp(min=0), K).float() * (C >= 0).float()[:, None] # (T,K)
56
+ low = torch.tril(torch.ones(T, T, device=q.device), -1) # i < j
57
+ n = low @ O # (T,K) earlier turns per cluster
58
+ S = (q @ Z.t()) * low
59
+ sc = (S @ O) / n.clamp(min=1)
60
+ same = ((A[:, None] == A[None, :]) & (A[:, None] > 0)).float() * low # earlier turns with the same ASR id
61
+ fs = (same @ O) / n.clamp(min=1)
62
+ newid = ((A > 0) & (same.sum(1) == 0)).float()
63
+ lc = self.scale.exp() * sc + self.w_same * fs
64
+ lc = lc.masked_fill(n == 0, -1e4)
65
+ ln = self.scale.exp() * (q @ self.new_v) + self.b_new + self.w_newid * newid
66
+ return torch.cat([ln[:, None], lc], 1), n
67
+
68
+ def role_logits(self, Z, C):
69
+ """cluster memory INCLUDING the current turn -> role logits (T, n_roles)"""
70
+ T = Z.shape[0]
71
+ same = ((C[:, None] == C[None, :]) & (C[:, None] >= 0)).float() * torch.tril(torch.ones(T, T, device=Z.device))
72
+ m = (same @ Z) / same.sum(1, keepdim=True).clamp(min=1)
73
+ return self.role(m)
74
+
75
+ # -------------------------------------------------------------- free-running assignment (eval / inference)
76
+ @torch.no_grad()
77
+ def assign(self, q, Z, A, K_max=16):
78
+ """turn-by-turn decisions, memory from the model's own clusters -> predicted cluster per turn (new ids in order)"""
79
+ T = q.shape[0]; pred = torch.full((T,), -1, dtype=torch.long, device=q.device); nk = 0
80
+ for j in range(T):
81
+ if j == 0:
82
+ pred[0] = 0; nk = 1; continue
83
+ lg, _ = self.logits(q[: j + 1], Z[: j + 1], torch.cat([pred[:j], pred.new_full((1,), -1)]), A[: j + 1])
84
+ lg = lg[j]
85
+ if nk >= K_max:
86
+ lg = lg.clone(); lg[0] = -1e9
87
+ c = int(lg.argmax())
88
+ if c == 0:
89
+ pred[j] = nk; nk += 1
90
+ else:
91
+ pred[j] = c - 1
92
+ return pred
93
+
94
+
95
+ def batch_turns(b):
96
+ """collated batch -> flat turn list [(window, s, e)], gold clusters C, role targets R, window index per turn, ASR slot A"""
97
+ turns, C, R, wi, A = [], [], [], [], []
98
+ spk = b["spk"].cpu() if torch.is_tensor(b["spk"]) else torch.as_tensor(b["spk"])
99
+ for k, L in enumerate(b.get("link") or []):
100
+ if not L:
101
+ continue
102
+ roles = L.get("role") or [-1] * len(L["turns"])
103
+ for (s, e, c), r in zip(L["turns"], roles):
104
+ turns.append((k, s, e)); C.append(c); R.append(r); wi.append(k); A.append(int(spk[k, s]))
105
+ return turns, torch.tensor(C, dtype=torch.long), torch.tensor(R, dtype=torch.long), torch.tensor(wi, dtype=torch.long), \
106
+ torch.tensor(A, dtype=torch.long)
107
+
108
+
109
+ def link_loss(model, h, b, tiers=("T0", "T1", "T2"), tier_w=None, tau=0.1, max_anchor=1024):
110
+ """-> (pointer CE, InfoNCE, role CE, stats); zero tensors when the batch has no linkable windows"""
111
+ m = model.spk_link; z0 = h.sum() * 0.0
112
+ turns, C, R, wi, A = batch_turns(b)
113
+ if len(turns) < 2:
114
+ return z0, z0, z0, {}
115
+ dev = h.device; C, R, wi, A = C.to(dev), R.to(dev), wi.to(dev), A.to(dev)
116
+ Z = m.turn_emb(h, turns)
117
+ Q = {t: m.queries(h, turns, t) for t in tiers}
118
+ tw = tier_w or {"T0": 1.0, "T1": 0.5, "T2": 0.5}
119
+ lp = z0; npt = 0; acc = {}
120
+ lr_ = z0; nr = 0
121
+ for k in torch.unique(wi).tolist():
122
+ sel = (wi == k).nonzero()[:, 0]
123
+ if len(sel) < 2:
124
+ continue
125
+ Ck, Ak, Zk = C[sel], A[sel], Z[sel]
126
+ for t in tiers:
127
+ lg, n = m.logits(Q[t][sel], Zk, Ck, Ak)
128
+ seen = n.gather(1, Ck.clamp(min=0)[:, None])[:, 0] > 0
129
+ y = torch.where(seen, Ck + 1, torch.zeros_like(Ck))
130
+ ok = Ck >= 0
131
+ lp = lp + tw[t] * F.cross_entropy(lg[ok], y[ok], reduction="sum")
132
+ if t == "T1":
133
+ acc.setdefault("n", 0); acc["n"] += int(ok.sum()); acc.setdefault("c", 0)
134
+ acc["c"] += int((lg[ok].argmax(1) == y[ok]).sum())
135
+ npt += int((Ck >= 0).sum())
136
+ Rk = R[sel]
137
+ if m.n_roles and (Rk >= 0).any(): # cluster-majority role (whole window) per turn
138
+ rl = m.role_logits(Zk, Ck)
139
+ oh = F.one_hot(Rk.clamp(min=0), m.n_roles).float() * (Rk >= 0).float()[:, None]
140
+ same = ((Ck[:, None] == Ck[None, :]) & (Ck[:, None] >= 0)).float()
141
+ dist = same @ oh; has = dist.sum(1) > 0
142
+ if has.any():
143
+ dist = dist[has] / dist[has].sum(1, keepdim=True)
144
+ lr_ = lr_ + (-(dist * F.log_softmax(rl[has].float(), -1)).sum(-1)).sum(); nr += int(has.sum())
145
+ lp = lp / max(1, npt) / sum(tw[t] for t in tiers)
146
+ lr_ = lr_ / max(1, nr)
147
+ # supervised-contrastive InfoNCE over the batch's turns
148
+ ok = (C >= 0).nonzero()[:, 0]
149
+ if len(ok) > max_anchor:
150
+ ok = ok[torch.randperm(len(ok), device=dev)[:max_anchor]]
151
+ lab = wi[ok] * 1000 + C[ok]; Zs = Z[ok]
152
+ sim = (Zs @ Zs.t()) / tau
153
+ eye = torch.eye(len(ok), device=dev, dtype=torch.bool)
154
+ sim = sim.masked_fill(eye, -1e4)
155
+ pos = (lab[:, None] == lab[None, :]) & ~eye
156
+ hp = pos.any(1)
157
+ if hp.any():
158
+ lsm = F.log_softmax(sim, 1)
159
+ ln = -((lsm * pos.float()).sum(1)[hp] / pos.float().sum(1)[hp]).mean()
160
+ else:
161
+ ln = z0
162
+ st = {"link_acc_T1": acc.get("c", 0) / max(1, acc.get("n", 0)), "link_turns": npt}
163
+ return lp, ln, lr_, st
164
+
165
+
166
+ # ------------------------------------------------------------------ eval
167
+ def _heur(C):
168
+ """teacher-forced heuristics per turn: two-back (2-party alternation) and most-frequent-other-than-previous"""
169
+ T = len(C); out = {"two_back": [], "most_freq": []}
170
+ for j in range(T):
171
+ seen = set(C[:j])
172
+ y = C[j] if C[j] in seen else -1
173
+ p2 = C[j - 2] if j >= 2 else -1
174
+ cnt = {}
175
+ for c in C[:j]:
176
+ cnt[c] = cnt.get(c, 0) + 1
177
+ prev = C[j - 1] if j else None
178
+ cand = sorted(((v, c) for c, v in cnt.items() if c != prev), reverse=True)
179
+ pm = cand[0][1] if cand else -1
180
+ out["two_back"].append(p2 == y); out["most_freq"].append(pm == y)
181
+ return out
182
+
183
+
184
+ def _asr_only(C, A):
185
+ """teacher-forced 'ASR ids alone': a seen ASR id -> the gold cluster most associated with it so far; unseen / none -> NEW"""
186
+ hist = {}; out = []
187
+ for j, (c, a) in enumerate(zip(C, A)):
188
+ y = c if c in set(C[:j]) else -1
189
+ if a > 0 and a in hist:
190
+ p = max(hist[a].items(), key=lambda x: x[1])[0]
191
+ else:
192
+ p = -1
193
+ out.append(p == y)
194
+ if a > 0:
195
+ hist.setdefault(a, {}); hist[a][c] = hist[a].get(c, 0) + 1
196
+ return out
197
+
198
+
199
+ def der_like(gold, pred, w):
200
+ """word-weighted speaker error after the optimal one-to-one cluster mapping (Hungarian) + purity / coverage"""
201
+ from scipy.optimize import linear_sum_assignment
202
+ g = {c: i for i, c in enumerate(sorted(set(gold)))}; p = {c: i for i, c in enumerate(sorted(set(pred)))}
203
+ M = np.zeros((len(g), len(p)))
204
+ for a, b_, x in zip(gold, pred, w):
205
+ M[g[a], p[b_]] += x
206
+ r, c = linear_sum_assignment(-M)
207
+ tot = M.sum()
208
+ return dict(err=1 - M[r, c].sum() / max(tot, 1), purity=M.max(0).sum() / max(tot, 1), coverage=M.max(1).sum() / max(tot, 1))
209
+
210
+
211
+ @torch.no_grad()
212
+ def eval_windows(model, h, b, exs, tiers=("T0", "T1", "T2")):
213
+ """one record per linkable window of a collated eval batch: teacher-forced accuracy per tier, heuristics, ASR-id-alone,
214
+ free-running DER-like (T1 queries), role consistency"""
215
+ m = model.spk_link; recs = []
216
+ turns, C, R, wi, A = batch_turns(b)
217
+ if not turns:
218
+ return recs
219
+ dev = h.device
220
+ Z = m.turn_emb(h, turns); Q = {t: m.queries(h, turns, t) for t in tiers}
221
+ C, R, wi, A = C.to(dev), R.to(dev), wi.to(dev), A.to(dev)
222
+ for k in torch.unique(wi).tolist():
223
+ sel = (wi == k).nonzero()[:, 0]
224
+ if len(sel) < 2:
225
+ continue
226
+ Ck, Ak, Zk = C[sel], A[sel], Z[sel]
227
+ ok = (Ck >= 0).cpu().numpy()
228
+ rec = dict(dom=exs[k]["dom"], asr=exs[k]["asr"], sid=exs[k]["sid"], turns=int(ok.sum()))
229
+ for t in tiers:
230
+ lg, n = m.logits(Q[t][sel], Zk, Ck, Ak)
231
+ seen = n.gather(1, Ck.clamp(min=0)[:, None])[:, 0] > 0
232
+ y = torch.where(seen, Ck + 1, torch.zeros_like(Ck))
233
+ rec[f"acc_{t}"] = int(((lg.argmax(1) == y).cpu().numpy() & ok).sum())
234
+ Cl = Ck.cpu().tolist(); Al = Ak.cpu().tolist()
235
+ hz = _heur(Cl); rec["heur"] = {kk: int(np.sum(np.array(v) & ok)) for kk, v in hz.items()}
236
+ rec["asr_id"] = int(np.sum(np.array(_asr_only(Cl, Al)) & ok)) if any(a > 0 for a in Al) else None
237
+ pred = m.assign(Q["T1"][sel] if "T1" in Q else Q[tiers[0]][sel], Zk, Ak).cpu().tolist()
238
+ wts = [e - s for (_, s, e) in [turns[i] for i in sel.tolist()]]
239
+ gg = [(c, p_, w_) for c, p_, w_ in zip(Cl, pred, wts) if c >= 0]
240
+ rec["der"] = der_like([x[0] for x in gg], [x[1] for x in gg], [x[2] for x in gg]) if gg else None
241
+ if m.n_roles:
242
+ rp = m.role_logits(Zk, Ck).argmax(1).cpu().tolist(); Rl = R[sel].cpu().tolist()
243
+ cons = []; racc = []
244
+ for c in set(x for x in Cl if x >= 0):
245
+ idx = [i for i, x in enumerate(Cl) if x == c]
246
+ rr = [rp[i] for i in idx]; maj = max(set(rr), key=rr.count)
247
+ cons += [x == maj for x in rr]
248
+ gl = [Rl[i] for i in idx if Rl[i] >= 0]
249
+ if gl:
250
+ gm = max(set(gl), key=gl.count); racc += [rp[i] == gm for i in idx]
251
+ rec["role_cons"] = (int(sum(cons)), len(cons)); rec["role_acc"] = (int(sum(racc)), len(racc))
252
+ recs.append(rec)
253
+ return recs
254
+
255
+
256
+ def report(recs, dom_name=lambda d: str(d), tier="T1"):
257
+ """-> {"all" | "dom:<d>" | "asr:<a>": {turns, acc_T0/T1/T2, acc_text (= acc_<tier>), acc_heur (best heuristic), acc_asr_id,
258
+ der / purity / coverage (word-weighted), role_consistency, role_acc}}"""
259
+ groups = {}
260
+ for r in recs:
261
+ for g in ("all", f"dom:{dom_name(r['dom'])}", f"asr:{r['asr']}"):
262
+ groups.setdefault(g, []).append(r)
263
+ out = {}
264
+ for g, rs in groups.items():
265
+ n = sum(r["turns"] for r in rs)
266
+ if not n:
267
+ continue
268
+ x = {"turns": n, "windows": len(rs)}
269
+ for t in ("T0", "T1", "T2"):
270
+ if f"acc_{t}" in rs[0]:
271
+ x[f"acc_{t}"] = round(sum(r[f"acc_{t}"] for r in rs) / n, 4)
272
+ x["acc_text"] = x.get(f"acc_{tier}")
273
+ hk = rs[0]["heur"].keys()
274
+ x["acc_heur_each"] = {k: round(sum(r["heur"][k] for r in rs) / n, 4) for k in hk}
275
+ x["acc_heur"] = max(x["acc_heur_each"].values())
276
+ ra = [r for r in rs if r.get("asr_id") is not None]
277
+ x["acc_asr_id"] = round(sum(r["asr_id"] for r in ra) / max(1, sum(r["turns"] for r in ra)), 4) if ra else None
278
+ dd = [r for r in rs if r.get("der")]
279
+ if dd:
280
+ w = [r["turns"] for r in dd]
281
+ for k in ("err", "purity", "coverage"):
282
+ x["der_" + k] = round(float(np.average([r["der"][k] for r in dd], weights=w)), 4)
283
+ rc = [r["role_cons"] for r in rs if r.get("role_cons") and r["role_cons"][1]]
284
+ x["role_consistency"] = round(sum(a for a, _ in rc) / sum(b_ for _, b_ in rc), 4) if rc else None
285
+ ra_ = [r["role_acc"] for r in rs if r.get("role_acc") and r["role_acc"][1]]
286
+ x["role_acc"] = round(sum(a for a, _ in ra_) / sum(b_ for _, b_ in ra_), 4) if ra_ else None
287
+ out[g] = x
288
+ return out