MakeItMore commited on
Commit
63a6829
·
verified ·
1 Parent(s): de6c56d

SpellSpeak Tone rc4

Browse files
MANIFEST.json CHANGED
@@ -1,33 +1,33 @@
1
  {
2
  "name": "SpellSpeak Tone",
3
- "release": "rc3",
4
- "package": "spellspeak-tone-rc3.1",
5
- "created": "2026-10-13T18:24:17Z",
6
- "contract": "line-tags-0.2",
7
  "files": {
8
  "README.md": {
9
- "bytes": 13322,
10
- "sha256": "02f6f2312c332c2e43995d0da70b04452b2c03e47ee516ff45066b20957ba793"
11
  },
12
  "config.json": {
13
- "bytes": 1512,
14
- "sha256": "a951d88defbccbe43fa103e8a24e9b233ea6cfe2cda3fd8fec574f67510f7ca8"
15
  },
16
  "example.py": {
17
- "bytes": 2926,
18
- "sha256": "efd2134c6eb28ac84bab1c9ad932bb61d8de8bc123e4a372ffd98629abdaad97"
19
  },
20
  "model.onnx": {
21
- "bytes": 111170339,
22
- "sha256": "dc5aa341edd3e21f83f782900b7a9737f88e671fb2a9ce44b190236f65c4a199"
23
  },
24
  "model.opt.onnx": {
25
- "bytes": 111132591,
26
- "sha256": "f53f61ddc0823ff6ffbecd5cdeac49888e08b5156adcebf7ce5ae6e69f3ed425"
27
  },
28
  "model_fp16.onnx": {
29
- "bytes": 55694715,
30
- "sha256": "59754462606ddc50086c4cbba14b41bf336d504047f4160792f747cab257355b"
31
  },
32
  "requirements.txt": {
33
  "bytes": 287,
@@ -42,8 +42,8 @@
42
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
43
  },
44
  "runtime/contracts/schemas/line_tags.py": {
45
- "bytes": 8535,
46
- "sha256": "d3e57a5ad685538cd0d94f2a631f6f5092bd3cc9944e96bb44cf80bcfd850430"
47
  },
48
  "runtime/harness/__init__.py": {
49
  "bytes": 0,
@@ -54,12 +54,12 @@
54
  "sha256": "faed14f74aaded53734860d18b128475e8724c6961b978fc6100ae533ee2c38c"
55
  },
56
  "runtime/harness/expression/calibrate.py": {
57
- "bytes": 2674,
58
- "sha256": "79774b330a98949865f3c0b22b1f72b5d9510effaa3d9635579c2b09ea9db04f"
59
  },
60
  "runtime/harness/expression/classifier.py": {
61
- "bytes": 7321,
62
- "sha256": "4b9f54a4e3d9390f7b080996cc5cead459132a78835f93d0b0f734b53a8cc965"
63
  },
64
  "runtime/harness/expression/decide.py": {
65
  "bytes": 1410,
 
1
  {
2
  "name": "SpellSpeak Tone",
3
+ "release": "rc4",
4
+ "package": "spellspeak-tone-rc4.1",
5
+ "created": "2026-10-13T21:14:11Z",
6
+ "contract": "line-tags-0.3",
7
  "files": {
8
  "README.md": {
9
+ "bytes": 14522,
10
+ "sha256": "9991b7a10156b5b26bcb11220db3347f73f502ffc11e0b356402fcd327a55963"
11
  },
12
  "config.json": {
13
+ "bytes": 1933,
14
+ "sha256": "61eea9b2b6c9463460319e7e484f675b8558f4e7ec87439bbf6cf2042a65c2a2"
15
  },
16
  "example.py": {
17
+ "bytes": 3225,
18
+ "sha256": "8ddf42e1dbd41c4f75e7f23f0ea21e7bcdd4a723f92e2fc4e15b646a9726d4e0"
19
  },
20
  "model.onnx": {
21
+ "bytes": 111766436,
22
+ "sha256": "0e4c32cae657161a27b2a1b486f1f78781351cbb6745d78bea4c1a7e7e7dbe3d"
23
  },
24
  "model.opt.onnx": {
25
+ "bytes": 111727815,
26
+ "sha256": "727035a576887a119e60dae1d6271b1f23b1f3fddf386cbd45b8cb38a9494730"
27
  },
28
  "model_fp16.onnx": {
29
+ "bytes": 55995133,
30
+ "sha256": "7d59948b90d24614da8b95a9e09c080d50e60feb32b7ecbbad3c22c537eb91a1"
31
  },
32
  "requirements.txt": {
33
  "bytes": 287,
 
42
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
43
  },
44
  "runtime/contracts/schemas/line_tags.py": {
45
+ "bytes": 10306,
46
+ "sha256": "e48834e280559c1b181cf3fd544bad474f08cb257d42a9be8577fe9e968eb418"
47
  },
48
  "runtime/harness/__init__.py": {
49
  "bytes": 0,
 
54
  "sha256": "faed14f74aaded53734860d18b128475e8724c6961b978fc6100ae533ee2c38c"
55
  },
56
  "runtime/harness/expression/calibrate.py": {
57
+ "bytes": 2943,
58
+ "sha256": "09820027f15c353b20b2d1102d4858c6de7cf48b3ed10015e2d94da1becd5634"
59
  },
60
  "runtime/harness/expression/classifier.py": {
61
+ "bytes": 8759,
62
+ "sha256": "effed190273e2f4dcb7c8b73ea809058873a4d4464aa6e3eb9f85929f12b3bad"
63
  },
64
  "runtime/harness/expression/decide.py": {
65
  "bytes": 1410,
README.md CHANGED
@@ -13,6 +13,7 @@ tags:
13
  - multi-party-conversation
14
  - emotion-recognition
15
  - dialogue-acts
 
16
  - turn-taking
17
  - real-time
18
  - pipecat
@@ -20,28 +21,27 @@ tags:
20
  - dialogue
21
  ---
22
 
23
- # SpellSpeak Tone · RC3
24
 
25
  When several AI characters share one conversation, as in a game, an interactive story or any other multi-character voice experience, each of them needs a structured reading of every line: how the speaker sounds, what the line does to each person present, and whether it leaves a question open for someone else to answer. The language model voicing a character could produce that reading, but only through extra output tokens, training or tool calls. That adds latency to every turn, takes capacity from the dialogue, risks malformed output, and repeats the work for each character in the scene.
26
 
27
- SpellSpeak Tone treats the reading as multi-task classification by a separate small model. One pass over a line predicts the speaker's emotion, the social act directed at each person present with a hostility probability per person, and the move the line makes in the conversation: does it ask, answer, pass on a rumour, say it does not know, hold back, or close the exchange. The application uses the tags for faces, feelings and who speaks next. Its constraints are millisecond inference on a CPU, robustness to unstructured user input, and rare false hostility.
28
 
29
  [Examples and source](https://github.com/spellspeak/models/tree/main/tone)
30
 
31
- ## What is new in RC3
32
 
33
- - **It reads sarcasm.** On a held-out test of game dialogue in settings it never saw, it catches 73% of the sarcastic lines that are hostile toward someone (rc2: 52%), and reads 6% of sarcastic lines at face value, as thanks or praise (rc2: 14%). "Sure you will", "what gave it away?" and "wow, so helpful" now read as the jabs they are. Sarcasm about the weather, about oneself or between friends, and sincere lines with the same words ("thanks a lot" after good news), are called hostile less often than before: 3.3% against 5.5%.
34
- - **Insults and teases in a row.** A short follow-up such as "a disgrace" after an insult is read as the insult it continues, and a complaint about a thing followed by the same words is not. Scripted feeling-meter scenes replayed with the model's own tags: 26 of 27 (rc2: 24).
35
- - **A 16-bit file, as in rc2.1.** `model_fp16.onnx` is half the size (55.7 MB) and gives the same tags on 99.96% or more of the test lines in every input setting.
36
- - **What it costs.** Emotion macro-F1 on the main test is 0.649 against rc2's 0.677, most of it in rare emotions (disgust, fear). The move on lines between characters is weaker (0.633 against 0.754). Without who a line is said to, harmless player lines are called hostile more often (3.9% against 1.9%).
37
 
38
  | | |
39
  |---|---|
40
- | **Released** | 2026-10-13 · rc3 (package `spellspeak-tone-rc3.1`) |
41
- | **What it does** | Reads one line of dialogue and tags the speaker's emotion, the social act toward each person present, and the line's move in the conversation |
42
- | **What changed from rc2** | The encoder was trained again with sarcasm and its look-alikes, and the move head rebuilt on it. Same outputs, same size, same speed. The hostility threshold is 0.675 (rc2: 0.775), set so that harmless player lines are called hostile no more often than with rc2 |
43
- | **Size** | 27.9M parameters · 111.1 MB ONNX in full precision (fp32), 55.7 MB with the weights in 16 bits (`model_fp16.onnx`) |
44
- | **Speed** | p95 per line on 4 CPU threads (Apple M1 Max): 9.6 ms for a scene of 8 people, the same as rc2 timed side by side. On 1 thread: 28.6 ms. The 16-bit file takes the same time |
45
  | **Runtime** | ONNX Runtime (1.24 or later) and tokenizers, on CPU. A Python reference runtime ships with it |
46
  | **Files** | `model.opt.onnx` (the graph to run), `model.onnx` (the same graph unoptimized), `model_fp16.onnx` (the graph to run with its weights in 16 bits), `tokenizer.json` and `config.json`, with the reference runtime (`runtime/`), `example.py` and `requirements.txt`. `MANIFEST.json` lists every file with its size and sha256 |
47
  | **Licence** | Apache-2.0 |
@@ -50,13 +50,13 @@ SpellSpeak Tone treats the reading as multi-task classification by a separate sm
50
 
51
  ```bash
52
  pip install -U huggingface_hub
53
- hf download spellspeak/tone --revision rc3 --local-dir spellspeak-tone
54
  cd spellspeak-tone
55
  pip install -r requirements.txt
56
  python example.py
57
  ```
58
 
59
- The example puts three characters and the player in a room. The player asks everyone a question, the characters answer in different ways, and two more lines follow:
60
 
61
  ```
62
  player to everyone: 'Who knows where the mill key is?'
@@ -73,12 +73,14 @@ player to Mara: "thanks, that's all i needed"
73
  move closes (0.98) · emotion happy · acts: Mara thanks
74
  player to Tomas: "Touch my horse again and you'll regret it."
75
  move other (0.71) · emotion angry · acts: Tomas threat
 
 
76
  ```
77
 
78
  The small download leaves out the two full-precision graphs and runs the 16-bit file. It prints the same tags:
79
 
80
  ```bash
81
- hf download spellspeak/tone --revision rc3 --local-dir spellspeak-tone-small --exclude model.onnx --exclude model.opt.onnx
82
  cd spellspeak-tone-small
83
  pip install -r requirements.txt
84
  python example.py --fp16
@@ -97,45 +99,52 @@ tone = LineClassifier(".") # full precision (model.opt.onnx), the default
97
  # tone = LineClassifier(".", precision="fp16") # the 16-bit file (model_fp16.onnx), for the small download
98
  tags = tone.tag(LineInput(speaker="player", speaker_kind="player", to="Mara", targets=["Mara", "Tomas"],
99
  text="oh great, thanks a lot", previous="Mara: Sorry, we're out of rooms tonight."))
100
- print(tags.emotion, tags.acts["Mara"]) # angry act='insult' ... after bad news; after good news the same words read as thanks
101
  ```
102
 
103
  ## Input and output
104
 
105
  - **Input:** the line, the line before it, who speaks (the player or a character), who it is said to (or the room), and everyone present (tested up to 8 people; 160 tokens at most). Who it is said to and the line before are optional, and help: see the table below. Sarcasm is mostly read from the line before.
106
- - **Per line:** an emotion (neutral, happy, amused, angry, sad, afraid, surprised, disgusted, contemptuous) with intensity (low, medium, high) and a confidence. Whether the line is only a listening sound such as "mm-hm" (backchannel). The **move**, with a confidence.
107
  - **Per person:** an act (insult, threat, accusation, tease, demand, dismiss, refusal, request, thanks, praise, apology, comfort, warning, none) with intensity and a confidence.
108
  - **The moves.** asks: wants a reply or an action. answers: gives what was asked for, or offers information. hedges: offers something second-hand or unsure. doesnt_know: says it does not know. withholds: declines, deflects or dodges. closes: winds the exchange down and wants no reply. other: anything else.
109
  - **Hostile** means insult, threat, accusation, or demand at high intensity. A tag is hostile when the hostility score reaches 0.675.
 
110
  - **Confidence** is the chance the tag is right (calibrated). For a hostile tag it is the chance the line is hostile toward that person.
111
 
112
  ## Performance (held-out test sets, committee labels)
113
 
114
- Sarcasm, on 600 lines of game dialogue in six settings that no training line uses (397 sarcastic, 203 sincere look-alikes):
115
 
116
- | Measure | rc2 | rc3 |
117
- |---|---|---|
118
- | Sarcastic lines hostile toward someone: caught (141 pairs) | 52% | 73% (21 points more; 95% interval 13 to 30) |
119
- | Sarcastic lines read at face value (294) | 14% | 6% |
120
- | Look-alikes and harmless sarcasm called hostile | 5.5% | 3.3% |
121
 
122
- On sarcasm from sitcoms and Reddit, which is mostly not aimed at anyone present, rc3 is about level with rc2.
 
 
 
 
 
 
 
 
123
 
124
- The emotion, the acts and the move:
125
 
126
- | Measure | rc2 | rc3 |
127
- |---|---|---|
128
- | Act macro-F1, main test (1,905 lines) | 0.678 | 0.661 (0.630 to 0.689) |
129
- | Emotion macro-F1, main test | 0.677 | 0.649 |
130
- | Natural player speech (900 lines): act F1, emotion F1 | 0.761, 0.711 | 0.781, 0.687 |
131
- | Game settings never seen in training (900 lines): act F1 | 0.784 | 0.771 |
132
- | Harmless lines called hostile: prose-style, player speech | 1.1%, 1.7% | 1.4%, 2.3% |
133
- | Hostile lines caught: main test, player speech | 72%, 78% | 76%, 84% |
134
- | Move macro-F1 (824 lines) | 0.812 | 0.816 (0.785 to 0.845) |
135
- | Answered or still open (454 lines) | 82.6% | 83.0% |
136
- | Asks or not (824 lines) | 95.3% | 96.0% |
137
- | Move macro-F1 on player lines, between characters | 0.737, 0.754 | 0.774, 0.633 |
138
- | Calibration error: act tags, emotion, move | 0.08, 0.05, 0.04 | 0.07, 0.05, 0.05 |
 
 
139
 
140
  For scale, two frontier labellers agree with each other at 0.766 act F1, 0.709 emotion F1 and 0.845 move F1.
141
 
@@ -145,6 +154,8 @@ The same test lines, scored again without who a line is said to, without the lin
145
 
146
  | Measure | As given | Without `to` | Without the line before | Without either |
147
  |---|---|---|---|---|
 
 
148
  | Act macro-F1, main test | 0.661 | 0.564 | 0.656 | 0.552 |
149
  | Emotion macro-F1, main test | 0.649 | 0.649 | 0.640 | 0.636 |
150
  | Harmless lines called hostile, prose | 1.4% | 1.5% | 1.3% | 1.8% |
@@ -153,35 +164,37 @@ The same test lines, scored again without who a line is said to, without the lin
153
  | Move macro-F1 | 0.816 | 0.805 | 0.722 | 0.683 |
154
  | Answered or still open | 83.0% | 80.0% | 66.1% | 60.1% |
155
 
 
156
  - **Send who a line is said to when you know it.** Without it, more harmless player lines are called hostile.
157
- - **Send the line before.** Sarcasm and the move are both read from it.
158
 
159
  ### The 16-bit file
160
 
161
- The same 5,204 test lines, tagged by `model_fp16.onnx` and by full precision, in the same four columns:
162
 
163
  | | As given | Without `to` | Without the line before | Without either |
164
  |---|---|---|---|---|
165
  | Lines with every tag the same as full precision | 100% | 99.98% (one act toward one person differs) | 100% | 99.96% (two lines differ) |
 
166
 
167
- Every measure in the tables above is the same with the 16-bit file. It computes in 32 bits, so it saves the download and the disk, not time or memory (224 MB resident against 205 MB).
168
 
169
  ## How it was made
170
 
171
- - **Model:** MiniLM-L6 (`sentence-transformers/all-MiniLM-L6-v2` at 1110a24, Apache-2.0) with six small heads. For the move, trainable copies of its top two layers read its fourth layer's output, and a small head reads them.
172
  - **Distillation:** the emotion and the acts from three Ettin-1B teachers (`jhu-clsp/ettin-encoder-1b`, MIT) over 374,534 lines, plus 20,286 lines labelled directly. The move from rc2's three move teachers over 113,201 lines, plus 3,296 labelled training lines.
 
173
  - **Labels:** the majority vote of a committee of three large language models.
174
- - **Lines:** our own game dialogue, Project Gutenberg dialogue, GoEmotions (Apache-2.0), Civil Comments (CC0), and lines written for this project by Qwen3.8-27B. New in rc3: 40,758 lines of sarcasm in twelve kinds with sincere look-alikes, and 8,284 of a speaker's two lines in a row. Sitcom and Reddit sarcasm was used only as a test.
175
- - **Calibration:** the hostility threshold was matched on a player-speech dev set; the act, emotion and move curves were fitted on dev.
176
  - **The 16-bit file:** full precision's graph with every weight tensor of 1,024 values or more stored in 16 bits and turned back into 32 bits by the graph itself, so the same runtime and calibration serve both files.
177
 
178
  ## Use and limits
179
 
180
  - **Use it** for character reactions and turn-taking in multi-character conversations: faces, feeling meters, moods, and who speaks next. **Do not use it** for moderation, safety decisions or judging real people.
181
  - **English only.** It reads one previous line.
182
- - **It misses things.** It misses about one in four hostile sarcastic lines in game dialogue, and most sarcasm in sitcom and Reddit text. It gets "answered or still open" wrong about one time in six.
183
- - **Rare emotions are weaker than in rc2**, disgust and fear most.
184
- - **The move on lines between characters is weaker than in rc2** (0.633 against 0.754).
185
  - **It has only been tested in game settings.** Its test lines are committee-labelled generated and game dialogue, not logs from real players or from other multi-character voice experiences.
186
 
187
- The numbers come from the training repository's report for this release, `2026-10-13-linecls-r9-sarcasm.md` (at commit ecaafed), with the open-data sarcasm test in `2026-10-13-linecls-r9-sarcasm-test.md`. The full-precision files were packaged from git tag `spellspeak-tone-rc3` (commit c85d633), and this release was packaged from git tag `spellspeak-tone-rc3.1`. That repository is private.
 
13
  - multi-party-conversation
14
  - emotion-recognition
15
  - dialogue-acts
16
+ - sarcasm-detection
17
  - turn-taking
18
  - real-time
19
  - pipecat
 
21
  - dialogue
22
  ---
23
 
24
+ # SpellSpeak Tone · RC4
25
 
26
  When several AI characters share one conversation, as in a game, an interactive story or any other multi-character voice experience, each of them needs a structured reading of every line: how the speaker sounds, what the line does to each person present, and whether it leaves a question open for someone else to answer. The language model voicing a character could produce that reading, but only through extra output tokens, training or tool calls. That adds latency to every turn, takes capacity from the dialogue, risks malformed output, and repeats the work for each character in the scene.
27
 
28
+ SpellSpeak Tone treats the reading as multi-task classification by a separate small model. One pass over a line predicts the speaker's emotion, the social act directed at each person present with a hostility probability per person, the move the line makes in the conversation (does it ask, answer, pass on a rumour, say it does not know, hold back, or close the exchange), and whether the line is sarcastic. The application uses the tags for faces, feelings and who speaks next. Its constraints are millisecond inference on a CPU, robustness to unstructured user input, and rare false alarms.
29
 
30
  [Examples and source](https://github.com/spellspeak/models/tree/main/tone)
31
 
32
+ ## What is new in RC4
33
 
34
+ - **A sarcasm output, with its probability.** Every line now also says whether it is sarcastic: `sarcasm: {sarcastic, probability}`. Sarcastic means the line means something other than, usually the opposite of, what its words say: mock thanks, mock praise, mock agreement, ironic understatement, whether hostile, friendly or about the situation. On game dialogue in settings it never saw, it finds 73% of sarcastic lines and is right 86% of the time it says so (F1 0.79; two frontier labellers agree with each other at 0.91). It calls 1.6% of sincere everyday lines sarcastic.
35
+ - **The probability is calibrated** (error 0.03), so you can show it, or use your own threshold instead of the model's.
36
+ - **Everything else is rc3's.** Every rc3 tag, intensity, confidence and move is identical on 3,754 test lines. The sarcasm head reads the same pass: the same speed, 0.6 MB more.
 
37
 
38
  | | |
39
  |---|---|
40
+ | **Released** | 2026-10-13 · rc4 (package `spellspeak-tone-rc4.1`) |
41
+ | **What it does** | Reads one line of dialogue and tags the speaker's emotion, the social act toward each person present, the line's move in the conversation, and whether it is sarcastic |
42
+ | **What changed from rc3** | A small sarcasm head on rc3's encoder, in the same pass. rc3's outputs are unchanged. Contract `line-tags-0.3`: one optional field per line, so code written for rc3 runs unchanged |
43
+ | **Size** | 27.9M parameters in the graph (rc3's and 0.15M for the sarcasm head) · 111.7 MB ONNX in full precision (fp32), 56.0 MB with the weights in 16 bits (`model_fp16.onnx`) |
44
+ | **Speed** | p95 per line on 4 CPU threads (Apple M1 Max): 9.7 ms for a scene of 8 people, rc3 9.6 ms timed side by side. On 1 thread: 28.0 ms. The 16-bit file takes about the same time |
45
  | **Runtime** | ONNX Runtime (1.24 or later) and tokenizers, on CPU. A Python reference runtime ships with it |
46
  | **Files** | `model.opt.onnx` (the graph to run), `model.onnx` (the same graph unoptimized), `model_fp16.onnx` (the graph to run with its weights in 16 bits), `tokenizer.json` and `config.json`, with the reference runtime (`runtime/`), `example.py` and `requirements.txt`. `MANIFEST.json` lists every file with its size and sha256 |
47
  | **Licence** | Apache-2.0 |
 
50
 
51
  ```bash
52
  pip install -U huggingface_hub
53
+ hf download spellspeak/tone --revision rc4 --local-dir spellspeak-tone
54
  cd spellspeak-tone
55
  pip install -r requirements.txt
56
  python example.py
57
  ```
58
 
59
+ The example puts three characters and the player in a room. The player asks everyone a question, the characters answer in different ways, and three more lines follow:
60
 
61
  ```
62
  player to everyone: 'Who knows where the mill key is?'
 
73
  move closes (0.98) · emotion happy · acts: Mara thanks
74
  player to Tomas: "Touch my horse again and you'll regret it."
75
  move other (0.71) · emotion angry · acts: Tomas threat
76
+ Wren to player: 'Oh, brilliant. You lost the key. Again.'
77
+ move other (0.72) · emotion contemptuous · acts: player insult · sarcastic (0.97)
78
  ```
79
 
80
  The small download leaves out the two full-precision graphs and runs the 16-bit file. It prints the same tags:
81
 
82
  ```bash
83
+ hf download spellspeak/tone --revision rc4 --local-dir spellspeak-tone-small --exclude model.onnx --exclude model.opt.onnx
84
  cd spellspeak-tone-small
85
  pip install -r requirements.txt
86
  python example.py --fp16
 
99
  # tone = LineClassifier(".", precision="fp16") # the 16-bit file (model_fp16.onnx), for the small download
100
  tags = tone.tag(LineInput(speaker="player", speaker_kind="player", to="Mara", targets=["Mara", "Tomas"],
101
  text="oh great, thanks a lot", previous="Mara: Sorry, we're out of rooms tonight."))
102
+ print(tags.emotion, tags.acts["Mara"].act, tags.sarcasm) # sarcastic=True with its probability, after bad news
103
  ```
104
 
105
  ## Input and output
106
 
107
  - **Input:** the line, the line before it, who speaks (the player or a character), who it is said to (or the room), and everyone present (tested up to 8 people; 160 tokens at most). Who it is said to and the line before are optional, and help: see the table below. Sarcasm is mostly read from the line before.
108
+ - **Per line:** an emotion (neutral, happy, amused, angry, sad, afraid, surprised, disgusted, contemptuous) with intensity (low, medium, high) and a confidence. Whether the line is only a listening sound such as "mm-hm" (backchannel). The **move**, with a confidence. **Sarcasm**: `sarcastic` (true or false) and `probability`, the calibrated chance the line is sarcastic.
109
  - **Per person:** an act (insult, threat, accusation, tease, demand, dismiss, refusal, request, thanks, praise, apology, comfort, warning, none) with intensity and a confidence.
110
  - **The moves.** asks: wants a reply or an action. answers: gives what was asked for, or offers information. hedges: offers something second-hand or unsure. doesnt_know: says it does not know. withholds: declines, deflects or dodges. closes: winds the exchange down and wants no reply. other: anything else.
111
  - **Hostile** means insult, threat, accusation, or demand at high intensity. A tag is hostile when the hostility score reaches 0.675.
112
+ - **Sarcastic** is decided at the model's threshold, set so that at most 2% of sincere everyday lines are called sarcastic. The act and the emotion already say how hostile a sarcastic line is.
113
  - **Confidence** is the chance the tag is right (calibrated). For a hostile tag it is the chance the line is hostile toward that person.
114
 
115
  ## Performance (held-out test sets, committee labels)
116
 
117
+ ### Sarcasm
118
 
119
+ 900 lines of game dialogue in six settings that no training line uses (263 sarcastic: sarcasm in twelve kinds, sincere look-alikes with the same words, and a speaker's two lines in a row), and 1,781 everyday lines (900 lines of natural player speech and 881 of game dialogue, 34 of them sarcastic):
 
 
 
 
120
 
121
+ | Measure | rc4 |
122
+ |---|---|
123
+ | F1, game dialogue in unseen settings (900 lines) | 0.793 (0.755 to 0.827; precision 0.862, recall 0.734) |
124
+ | Sincere everyday lines called sarcastic (1,747) | 1.55% (1.1% to 2.2%) |
125
+ | Calibration error of the probability (2,681 lines) | 0.028 |
126
+
127
+ For scale, two frontier labellers agree with each other at 0.905 F1 on the same 900 lines. It finds sarcasm after a setback ("Oh, wonderful. Another storm.") and about the situation best, friendly teasing sarcasm least.
128
+
129
+ On sitcom and Reddit sarcasm, labelled by what each writer meant, it is precise but finds little: 4% of the sitcom lines and 9% of the Reddit lines their sources call sarcastic, with 0.7% and 3.4% of their sincere lines called sarcastic.
130
 
131
+ ### The emotion, the acts and the move
132
 
133
+ The same as rc3, tag for tag:
134
+
135
+ | Measure | rc4 (and rc3) |
136
+ |---|---|
137
+ | Act macro-F1, main test (1,905 lines) | 0.661 (0.630 to 0.689) |
138
+ | Emotion macro-F1, main test | 0.649 |
139
+ | Natural player speech (900 lines): act F1, emotion F1 | 0.781, 0.687 |
140
+ | Game settings never seen in training (900 lines): act F1 | 0.771 |
141
+ | Harmless lines called hostile: prose-style, player speech | 1.4%, 2.3% |
142
+ | Hostile lines caught: main test, player speech | 76%, 84% |
143
+ | Sarcastic lines hostile toward someone, caught (141 pairs in unseen settings) | 73% |
144
+ | Move macro-F1 (824 lines) | 0.816 (0.785 to 0.845) |
145
+ | Answered or still open (454 lines) | 83.0% |
146
+ | Asks or not (824 lines) | 96.0% |
147
+ | Calibration error: act tags, emotion, move | 0.07, 0.05, 0.05 |
148
 
149
  For scale, two frontier labellers agree with each other at 0.766 act F1, 0.709 emotion F1 and 0.845 move F1.
150
 
 
154
 
155
  | Measure | As given | Without `to` | Without the line before | Without either |
156
  |---|---|---|---|---|
157
+ | Sarcasm F1, unseen settings | 0.793 | 0.790 | 0.472 | 0.426 |
158
+ | Sincere everyday lines called sarcastic | 1.55% | 1.20% | 0.29% | 0.23% |
159
  | Act macro-F1, main test | 0.661 | 0.564 | 0.656 | 0.552 |
160
  | Emotion macro-F1, main test | 0.649 | 0.649 | 0.640 | 0.636 |
161
  | Harmless lines called hostile, prose | 1.4% | 1.5% | 1.3% | 1.8% |
 
164
  | Move macro-F1 | 0.816 | 0.805 | 0.722 | 0.683 |
165
  | Answered or still open | 83.0% | 80.0% | 66.1% | 60.1% |
166
 
167
+ - **Send the line before.** Without it, sarcasm is found less than half as often (it stays precise), and the move is weaker.
168
  - **Send who a line is said to when you know it.** Without it, more harmless player lines are called hostile.
 
169
 
170
  ### The 16-bit file
171
 
172
+ The same 5,204 test lines, and the 2,681 sarcasm test lines, tagged by `model_fp16.onnx` and by full precision, in the same four columns:
173
 
174
  | | As given | Without `to` | Without the line before | Without either |
175
  |---|---|---|---|---|
176
  | Lines with every tag the same as full precision | 100% | 99.98% (one act toward one person differs) | 100% | 99.96% (two lines differ) |
177
+ | The sarcasm decision the same | 100% | 100% | 100% | 100% |
178
 
179
+ Every measure in the tables above is the same with the 16-bit file. It computes in 32 bits, so it saves the download and the disk, not time or memory (247 MB resident against 201 MB).
180
 
181
  ## How it was made
182
 
183
+ - **Model:** MiniLM-L6 (`sentence-transformers/all-MiniLM-L6-v2` at 1110a24, Apache-2.0) with six small heads. For the move, trainable copies of its top two layers read its fourth layer's output, and a small head reads them. The sarcasm head (148,610 parameters) reads the encoder's last layer, which stays as rc3 trained it.
184
  - **Distillation:** the emotion and the acts from three Ettin-1B teachers (`jhu-clsp/ettin-encoder-1b`, MIT) over 374,534 lines, plus 20,286 lines labelled directly. The move from rc2's three move teachers over 113,201 lines, plus 3,296 labelled training lines.
185
+ - **Sarcasm:** trained on 4,000 lines labelled by the committee (1,137 sarcastic): our game dialogue's sarcasm in twelve kinds with its sincere look-alikes, a speaker's two lines in a row, and everyday lines. The threshold was chosen on dev lines, and its probability curve fitted on them.
186
  - **Labels:** the majority vote of a committee of three large language models.
187
+ - **Lines:** our own game dialogue, Project Gutenberg dialogue, GoEmotions (Apache-2.0), Civil Comments (CC0), and lines written for this project by Qwen3.8-27B. Sitcom and Reddit sarcasm was used only as a test.
188
+ - **Calibration:** the hostility threshold was matched on a player-speech dev set; the act, emotion, move and sarcasm curves were fitted on dev.
189
  - **The 16-bit file:** full precision's graph with every weight tensor of 1,024 values or more stored in 16 bits and turned back into 32 bits by the graph itself, so the same runtime and calibration serve both files.
190
 
191
  ## Use and limits
192
 
193
  - **Use it** for character reactions and turn-taking in multi-character conversations: faces, feeling meters, moods, and who speaks next. **Do not use it** for moderation, safety decisions or judging real people.
194
  - **English only.** It reads one previous line.
195
+ - **It misses sarcasm.** It misses about one sarcastic line in four in game dialogue, most friendly teasing sarcasm, and most sitcom and Reddit sarcasm. Without the line before, it finds less than half as much.
196
+ - **It misses other things.** It misses about one in four hostile sarcastic lines, and gets "answered or still open" wrong about one time in six.
197
+ - **Rare emotions are weaker than in rc2**, disgust and fear most. **The move on lines between characters is weaker than in rc2** (0.633 against 0.754).
198
  - **It has only been tested in game settings.** Its test lines are committee-labelled generated and game dialogue, not logs from real players or from other multi-character voice experiences.
199
 
200
+ The numbers come from the training repository's report for this release, `2026-10-13-linecls-r10-sarcasm-output.md` (at commit 2cf51c7). The full-precision files were packaged from git tag `spellspeak-tone-rc4` (commit 78d74e8), and this release was packaged from git tag `spellspeak-tone-rc4.1`. That repository is private.
config.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "run_id": "linecls-r9-exchange",
3
  "encoder": "minilm-l6",
4
  "repo": "sentence-transformers/all-MiniLM-L6-v2",
5
  "revision": "1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
@@ -60,9 +60,14 @@
60
  "a": 1.063282,
61
  "b": 0.528266,
62
  "items": 680
 
 
 
 
 
63
  }
64
  },
65
- "calibration_note": "Platt curves fitted on dev (exp-237); a confidence is the chance the tag is right; r8: the exchange curve fitted on the exchange dev lines (exp-247)",
66
  "base_run_id": "linecls-r2-student-minilm-l6-r9bs1-seed1",
67
  "exchanges": [
68
  "asks",
@@ -80,5 +85,22 @@
80
  "seed": 0,
81
  "params": 4290823
82
  },
83
- "contract": "line-tags-0.2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
 
1
  {
2
+ "run_id": "linecls-r10-sarcasm",
3
  "encoder": "minilm-l6",
4
  "repo": "sentence-transformers/all-MiniLM-L6-v2",
5
  "revision": "1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
 
60
  "a": 1.063282,
61
  "b": 0.528266,
62
  "items": 680
63
+ },
64
+ "sarcasm": {
65
+ "a": 1.126858,
66
+ "b": -0.9161,
67
+ "items": 1857
68
  }
69
  },
70
+ "calibration_note": "Platt curves fitted on dev (exp-237); a confidence is the chance the tag is right; r8: the exchange curve fitted on the exchange dev lines (exp-247); r10: the sarcasm curve fitted on the sarcasm dev lines (exp-274)",
71
  "base_run_id": "linecls-r2-student-minilm-l6-r9bs1-seed1",
72
  "exchanges": [
73
  "asks",
 
85
  "seed": 0,
86
  "params": 4290823
87
  },
88
+ "contract": "line-tags-0.3",
89
+ "exchange_run_id": "linecls-r9-exchange",
90
+ "sarcasm": {
91
+ "tau_raw": 0.69,
92
+ "classes": [
93
+ "sincere",
94
+ "sarcastic"
95
+ ],
96
+ "head": {
97
+ "variant": "mean",
98
+ "kind": "mean",
99
+ "feature": null,
100
+ "branch": null,
101
+ "branch_kind": null,
102
+ "seed": 0,
103
+ "params": 148610
104
+ }
105
+ }
106
  }
example.py CHANGED
@@ -5,7 +5,7 @@
5
  python example.py --fp16 # the 16-bit file: model_fp16.onnx, half the size
6
 
7
  The model files are read from this folder. Without them, as in a git checkout, they are downloaded once from Hugging
8
- Face (spellspeak/tone, release rc3). With --fp16, only model_fp16.onnx, tokenizer.json and config.json are needed.
9
  """
10
  import pathlib
11
  import sys
@@ -23,7 +23,7 @@ model_dir = HERE
23
  if not all((HERE / f).exists() for f in MODEL_FILES):
24
  from huggingface_hub import snapshot_download
25
 
26
- model_dir = snapshot_download("spellspeak/tone", revision="rc3", allow_patterns=MODEL_FILES)
27
  tone = LineClassifier(model_dir, threads=4, precision=PRECISION)
28
 
29
  # Three characters and the player. Each line names who speaks, who it is to (None: the room) and the line before it.
@@ -37,6 +37,7 @@ LINES = [
37
  ("Tomas", "player", "No idea, sorry.", QUESTION),
38
  ("player", "Mara", "thanks, that's all i needed", "Mara: It's under the counter, love."),
39
  ("player", "Tomas", "Touch my horse again and you'll regret it.", None),
 
40
  ]
41
 
42
  for speaker, to, text, previous in LINES:
@@ -45,9 +46,11 @@ for speaker, to, text, previous in LINES:
45
  tags = tone.tag(line)
46
  acts = ", ".join(f"{who} {a.act}" for who, a in tags.acts.items() if a.act != "none") or "none"
47
  move = getattr(tags, "exchange", None) # models from rc2 on
 
 
48
  print(f"{speaker} to {to or 'everyone'}: {text!r}")
49
- print(f" move {move.label} ({move.confidence:.2f}) · emotion {tags.emotion} · acts: {acts}" if move else
50
- f" emotion {tags.emotion} · acts: {acts}")
51
 
52
  # The full output for one line, as JSON
53
  print(tone.tag(LineInput(speaker="Wren", speaker_kind="npc", to="Tomas", targets=["Tomas", "Mara", "player"],
 
5
  python example.py --fp16 # the 16-bit file: model_fp16.onnx, half the size
6
 
7
  The model files are read from this folder. Without them, as in a git checkout, they are downloaded once from Hugging
8
+ Face (spellspeak/tone, release rc4). With --fp16, only model_fp16.onnx, tokenizer.json and config.json are needed.
9
  """
10
  import pathlib
11
  import sys
 
23
  if not all((HERE / f).exists() for f in MODEL_FILES):
24
  from huggingface_hub import snapshot_download
25
 
26
+ model_dir = snapshot_download("spellspeak/tone", revision="rc4", allow_patterns=MODEL_FILES)
27
  tone = LineClassifier(model_dir, threads=4, precision=PRECISION)
28
 
29
  # Three characters and the player. Each line names who speaks, who it is to (None: the room) and the line before it.
 
37
  ("Tomas", "player", "No idea, sorry.", QUESTION),
38
  ("player", "Mara", "thanks, that's all i needed", "Mara: It's under the counter, love."),
39
  ("player", "Tomas", "Touch my horse again and you'll regret it.", None),
40
+ ("Wren", "player", "Oh, brilliant. You lost the key. Again.", "player: I think I dropped the mill key in the river."),
41
  ]
42
 
43
  for speaker, to, text, previous in LINES:
 
46
  tags = tone.tag(line)
47
  acts = ", ".join(f"{who} {a.act}" for who, a in tags.acts.items() if a.act != "none") or "none"
48
  move = getattr(tags, "exchange", None) # models from rc2 on
49
+ sarcasm = getattr(tags, "sarcasm", None) # models from rc4 on
50
+ said = f" · sarcastic ({sarcasm.probability:.2f})" if sarcasm and sarcasm.sarcastic else ""
51
  print(f"{speaker} to {to or 'everyone'}: {text!r}")
52
+ print(f" move {move.label} ({move.confidence:.2f}) · emotion {tags.emotion} · acts: {acts}{said}" if move else
53
+ f" emotion {tags.emotion} · acts: {acts}{said}")
54
 
55
  # The full output for one line, as JSON
56
  print(tone.tag(LineInput(speaker="Wren", speaker_kind="npc", to="Tomas", targets=["Tomas", "Mara", "player"],
model.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:dc5aa341edd3e21f83f782900b7a9737f88e671fb2a9ce44b190236f65c4a199
3
- size 111170339
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0e4c32cae657161a27b2a1b486f1f78781351cbb6745d78bea4c1a7e7e7dbe3d
3
+ size 111766436
model.opt.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:f53f61ddc0823ff6ffbecd5cdeac49888e08b5156adcebf7ce5ae6e69f3ed425
3
- size 111132591
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:727035a576887a119e60dae1d6271b1f23b1f3fddf386cbd45b8cb38a9494730
3
+ size 111727815
model_fp16.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:59754462606ddc50086c4cbba14b41bf336d504047f4160792f747cab257355b
3
- size 55694715
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d59948b90d24614da8b95a9e09c080d50e60feb32b7ecbbad3c22c537eb91a1
3
+ size 55995133
runtime/contracts/schemas/line_tags.py CHANGED
@@ -190,3 +190,43 @@ def load_examples_02() -> list[LineTagRecord02]:
190
  import yaml
191
 
192
  return [LineTagRecord02.model_validate(r) for r in yaml.safe_load(EXAMPLES_02_PATH.read_text())]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  import yaml
191
 
192
  return [LineTagRecord02.model_validate(r) for r in yaml.safe_load(EXAMPLES_02_PATH.read_text())]
193
+
194
+
195
+ # --- line-tags-0.3: the sarcasm output (rc4, revision r10) -----------------------------------------------------------
196
+
197
+ SCHEMA_VERSION_03 = "line-tags-0.3"
198
+
199
+
200
+ class SarcasmTag(Strict):
201
+ """Whether a line is sarcastic, read with the line before it (guide 0.3): it means something other than, usually the
202
+ opposite of, what its words say. How hostile it is stays with the act and the emotion."""
203
+
204
+ sarcastic: bool
205
+ probability: Probability | None = None # the calibrated chance the line is sarcastic; labels may carry none
206
+
207
+
208
+ class LineTags03(LineTags02):
209
+ """What the classifier returns for one line under line-tags-0.3: the 0.2 tags plus sarcasm, when the model has the
210
+ sarcasm head."""
211
+
212
+ sarcasm: SarcasmTag | None = None
213
+
214
+
215
+ class LineTagRecord03(LineTagRecord02):
216
+ """A labelled line under line-tags-0.3. Every 0.1 and 0.2 record validates here unchanged; a record that carries a
217
+ sarcasm label says line-tags-0.3."""
218
+
219
+ schema_version: Literal["line-tags-0.1", "line-tags-0.2", "line-tags-0.3"] = SCHEMA_VERSION_03
220
+ tags: LineTags03
221
+
222
+ @model_validator(mode="after")
223
+ def _exchange_needs_02(self) -> "LineTagRecord03": # overrides 0.2's check: 0.3 carries the move too
224
+ if self.tags.exchange is not None and self.schema_version not in (SCHEMA_VERSION_02, SCHEMA_VERSION_03):
225
+ raise ValueError("`exchange` is a line-tags-0.2 field: the record must say line-tags-0.2 or later")
226
+ return self
227
+
228
+ @model_validator(mode="after")
229
+ def _sarcasm_needs_03(self) -> "LineTagRecord03":
230
+ if self.tags.sarcasm is not None and self.schema_version != SCHEMA_VERSION_03:
231
+ raise ValueError("`sarcasm` is a line-tags-0.3 field: the record must say schema_version line-tags-0.3")
232
+ return self
runtime/harness/expression/calibrate.py CHANGED
@@ -49,6 +49,13 @@ def exchange_confidence(raw: float, cal: dict | None) -> float:
49
  return platt(raw, cal["exchange"])
50
 
51
 
 
 
 
 
 
 
 
52
  def fit_platt(p, y, l2: float = 1e-3, iters: int = 100) -> dict:
53
  """Two parameters by Newton's method on the log loss of y (0 or 1) given logit(p); a little L2 for stability."""
54
  import numpy as np
 
49
  return platt(raw, cal["exchange"])
50
 
51
 
52
+ def sarcasm_probability(raw: float, cal: dict | None) -> float:
53
+ """r10: the chance the line is sarcastic; the raw probability when the model has no sarcasm curve."""
54
+ if not cal or "sarcasm" not in cal:
55
+ return raw
56
+ return platt(raw, cal["sarcasm"])
57
+
58
+
59
  def fit_platt(p, y, l2: float = 1e-3, iters: int = 100) -> dict:
60
  """Two parameters by Newton's method on the log loss of y (0 or 1) given logit(p); a little L2 for stability."""
61
  import numpy as np
runtime/harness/expression/classifier.py CHANGED
@@ -12,6 +12,10 @@ r8 (line-tags-0.2): a model whose graph has an `exchange` output and whose confi
12
  line's exchange move (asks, answers, hedges, doesnt_know, withholds, closes, other) with its confidence, as
13
  `LineTags02`. A model without it (RC1) returns `LineTags` exactly as before.
14
 
 
 
 
 
15
  Precision: full precision ("fp32") is the default and the reference. From release rc2.1 a folder may also hold
16
  `model_fp16.onnx`, the same graph with its weights stored in 16 bits (half the size, computed in 32 bits), picked with
17
  `precision="fp16"`.
@@ -22,8 +26,8 @@ import json
22
  import pathlib
23
  import time
24
 
25
- from contracts.schemas.line_tags import ActTag, ExchangeTag, LineInput, LineTags, LineTags02, is_hostile
26
- from harness.expression.calibrate import emotion_confidence, exchange_confidence, tag_confidence
27
  from harness.expression.decide import decide, summed_hostility
28
  from harness.expression.render import render, span_mask
29
 
@@ -68,6 +72,9 @@ class LineClassifier:
68
  # r8: the exchange head, when the graph has it; RC1's graph does not
69
  self.exchanges = self.config.get("exchanges") if "exchange" in outputs else None
70
  self._exchange_at = outputs.index("exchange") if self.exchanges else None
 
 
 
71
 
72
  def logits(self, inp: LineInput):
73
  """Raw outputs for one line: emotion, emotion intensity, act per person, act intensity per person, backchannel."""
@@ -93,10 +100,10 @@ class LineClassifier:
93
  pe, pei, pa, pai = softmax(le[0]), softmax(lei[0]), softmax(la), softmax(lai)
94
  emo = self.emotions[int(pe.argmax())]
95
  if float(softmax(lb[0])[1]) >= 0.5: # a backchannel is not a turn: no act toward anyone
96
- return self._with_exchange(LineTags(
97
  emotion=emo, emotion_intensity="low" if emo == "neutral" else self.intensities[int(pei.argmax())],
98
  emotion_confidence=round(emotion_confidence(float(pe.max()), self.calibration), 4),
99
- acts={t: ActTag(act="none") for t in inp.targets}, backchannel=True), out)
100
  acts = {}
101
  for i, t in enumerate(inp.targets):
102
  ph = float(p_host[i]) if p_host is not None else summed_hostility(pa[i])
@@ -106,9 +113,9 @@ class LineClassifier:
106
  hostile = is_hostile(act, inten)
107
  conf = ph if hostile else float(pa[i][self.acts.index(act)])
108
  acts[t] = ActTag(act=act, intensity=inten, confidence=round(tag_confidence(conf, hostile, self.calibration), 4))
109
- return self._with_exchange(LineTags(
110
  emotion=emo, emotion_intensity="low" if emo == "neutral" else self.intensities[int(pei.argmax())],
111
- emotion_confidence=round(emotion_confidence(float(pe.max()), self.calibration), 4), acts=acts, backchannel=False), out)
112
 
113
  def _with_exchange(self, tags: LineTags, out) -> LineTags:
114
  """RC1 and any model without the exchange head: the tags unchanged. r8 on: the same tags plus the exchange move."""
@@ -124,6 +131,20 @@ class LineClassifier:
124
  return LineTags02(**{k: getattr(tags, k) for k in type(tags).model_fields},
125
  exchange=ExchangeTag(label=self.exchanges[i], confidence=conf))
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def timed(self, inp: LineInput) -> tuple[LineTags, float]:
128
  t = time.perf_counter()
129
  out = self.tag(inp)
 
12
  line's exchange move (asks, answers, hedges, doesnt_know, withholds, closes, other) with its confidence, as
13
  `LineTags02`. A model without it (RC1) returns `LineTags` exactly as before.
14
 
15
+ r10 (line-tags-0.3): a model whose graph also has a `sarcasm` output returns `sarcasm: {sarcastic, probability}` as
16
+ `LineTags03`: the calibrated chance the line is sarcastic, and the model's decision at its own threshold (the config's
17
+ `sarcasm.tau_raw` on the raw probability). Models without it return what they always did.
18
+
19
  Precision: full precision ("fp32") is the default and the reference. From release rc2.1 a folder may also hold
20
  `model_fp16.onnx`, the same graph with its weights stored in 16 bits (half the size, computed in 32 bits), picked with
21
  `precision="fp16"`.
 
26
  import pathlib
27
  import time
28
 
29
+ from contracts.schemas.line_tags import ActTag, ExchangeTag, LineInput, LineTags, LineTags02, LineTags03, SarcasmTag, is_hostile
30
+ from harness.expression.calibrate import emotion_confidence, exchange_confidence, sarcasm_probability, tag_confidence
31
  from harness.expression.decide import decide, summed_hostility
32
  from harness.expression.render import render, span_mask
33
 
 
72
  # r8: the exchange head, when the graph has it; RC1's graph does not
73
  self.exchanges = self.config.get("exchanges") if "exchange" in outputs else None
74
  self._exchange_at = outputs.index("exchange") if self.exchanges else None
75
+ # r10: the sarcasm head, when the graph has it; rc3 and earlier do not
76
+ self._sarcasm_at = outputs.index("sarcasm") if "sarcasm" in outputs else None
77
+ self.sarcasm_tau_raw = float((self.config.get("sarcasm") or {}).get("tau_raw", 0.5))
78
 
79
  def logits(self, inp: LineInput):
80
  """Raw outputs for one line: emotion, emotion intensity, act per person, act intensity per person, backchannel."""
 
100
  pe, pei, pa, pai = softmax(le[0]), softmax(lei[0]), softmax(la), softmax(lai)
101
  emo = self.emotions[int(pe.argmax())]
102
  if float(softmax(lb[0])[1]) >= 0.5: # a backchannel is not a turn: no act toward anyone
103
+ return self._with_sarcasm(self._with_exchange(LineTags(
104
  emotion=emo, emotion_intensity="low" if emo == "neutral" else self.intensities[int(pei.argmax())],
105
  emotion_confidence=round(emotion_confidence(float(pe.max()), self.calibration), 4),
106
+ acts={t: ActTag(act="none") for t in inp.targets}, backchannel=True), out), out)
107
  acts = {}
108
  for i, t in enumerate(inp.targets):
109
  ph = float(p_host[i]) if p_host is not None else summed_hostility(pa[i])
 
113
  hostile = is_hostile(act, inten)
114
  conf = ph if hostile else float(pa[i][self.acts.index(act)])
115
  acts[t] = ActTag(act=act, intensity=inten, confidence=round(tag_confidence(conf, hostile, self.calibration), 4))
116
+ return self._with_sarcasm(self._with_exchange(LineTags(
117
  emotion=emo, emotion_intensity="low" if emo == "neutral" else self.intensities[int(pei.argmax())],
118
+ emotion_confidence=round(emotion_confidence(float(pe.max()), self.calibration), 4), acts=acts, backchannel=False), out), out)
119
 
120
  def _with_exchange(self, tags: LineTags, out) -> LineTags:
121
  """RC1 and any model without the exchange head: the tags unchanged. r8 on: the same tags plus the exchange move."""
 
131
  return LineTags02(**{k: getattr(tags, k) for k in type(tags).model_fields},
132
  exchange=ExchangeTag(label=self.exchanges[i], confidence=conf))
133
 
134
+ def _with_sarcasm(self, tags: LineTags, out) -> LineTags:
135
+ """rc3 and earlier: the tags unchanged. r10 on: the same tags plus sarcasm, decided on the raw probability at the
136
+ model's threshold, reported as the calibrated probability."""
137
+ if self._sarcasm_at is None:
138
+ return tags
139
+ import numpy as np
140
+
141
+ z = np.asarray(out[self._sarcasm_at][0], dtype=np.float64)
142
+ p = np.exp(z - z.max())
143
+ raw = float(p[1] / p.sum())
144
+ return LineTags03(**{k: getattr(tags, k) for k in type(tags).model_fields},
145
+ sarcasm=SarcasmTag(sarcastic=raw >= self.sarcasm_tau_raw,
146
+ probability=round(sarcasm_probability(raw, self.calibration), 4)))
147
+
148
  def timed(self, inp: LineInput) -> tuple[LineTags, float]:
149
  t = time.perf_counter()
150
  out = self.tag(inp)