Commit ·
c38bcf8
0
Parent(s):
SpellSpeak Audience rc1
Browse files- .gitattributes +35 -0
- MANIFEST.json +86 -0
- README.md +130 -0
- config.json +16 -0
- encoder.onnx +3 -0
- example.py +56 -0
- head.onnx +3 -0
- requirements.txt +6 -0
- runtime/contracts/__init__.py +0 -0
- runtime/contracts/schemas/__init__.py +0 -0
- runtime/contracts/schemas/addressee.py +158 -0
- runtime/contracts/schemas/person_card.py +94 -0
- runtime/harness/__init__.py +0 -0
- runtime/harness/addressee/__init__.py +9 -0
- runtime/harness/addressee/bands.py +71 -0
- runtime/harness/addressee/classifier.py +145 -0
- runtime/harness/addressee/evidence.py +138 -0
- runtime/harness/addressee/features.py +85 -0
- runtime/harness/addressee/render.py +100 -0
- runtime/harness/addressee/rules.py +212 -0
- tokenizer.json +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
MANIFEST.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "SpellSpeak Audience",
|
| 3 |
+
"release": "rc1",
|
| 4 |
+
"package": "spellspeak-audience-rc1.2",
|
| 5 |
+
"created": "2026-10-08T21:35:48Z",
|
| 6 |
+
"model_run": "addr-r2b-ettin-32m-tt-s1",
|
| 7 |
+
"inputs": "addressee-tt-0.5",
|
| 8 |
+
"files": {
|
| 9 |
+
"README.md": {
|
| 10 |
+
"bytes": 9609,
|
| 11 |
+
"sha256": "e8f614b475a64b57b6bac573093386a98e1f2156c5ecf8f3bdd08f9956fdd35b"
|
| 12 |
+
},
|
| 13 |
+
"config.json": {
|
| 14 |
+
"bytes": 342,
|
| 15 |
+
"sha256": "4dcd9366bce0f930750d01b57f79503eb8b9d87b254a81c190be0085b7380832"
|
| 16 |
+
},
|
| 17 |
+
"encoder.onnx": {
|
| 18 |
+
"bytes": 127960962,
|
| 19 |
+
"sha256": "7960e1bc311030450d9e6648829c6f5a82e392c502b7853987e89dc2feb62de5"
|
| 20 |
+
},
|
| 21 |
+
"example.py": {
|
| 22 |
+
"bytes": 2799,
|
| 23 |
+
"sha256": "e4b5687cbfad34eb3802608848d34dc7a6505e43093827a4f53fc3193c92f39b"
|
| 24 |
+
},
|
| 25 |
+
"head.onnx": {
|
| 26 |
+
"bytes": 3684676,
|
| 27 |
+
"sha256": "cb21b22f047d798148da89922b352dd3247840afb3828de6e0aa1e964e798f83"
|
| 28 |
+
},
|
| 29 |
+
"requirements.txt": {
|
| 30 |
+
"bytes": 270,
|
| 31 |
+
"sha256": "7ca7dcd5aa19a996d459ef0ab9f8e43f61d814931525369e57664044e66d72db"
|
| 32 |
+
},
|
| 33 |
+
"runtime/contracts/__init__.py": {
|
| 34 |
+
"bytes": 0,
|
| 35 |
+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
| 36 |
+
},
|
| 37 |
+
"runtime/contracts/schemas/__init__.py": {
|
| 38 |
+
"bytes": 0,
|
| 39 |
+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
| 40 |
+
},
|
| 41 |
+
"runtime/contracts/schemas/addressee.py": {
|
| 42 |
+
"bytes": 6998,
|
| 43 |
+
"sha256": "fb8fda50516f446bcc368e362dcf799b70f09a8ca1a18c1ebb5c912999695b8c"
|
| 44 |
+
},
|
| 45 |
+
"runtime/contracts/schemas/person_card.py": {
|
| 46 |
+
"bytes": 4049,
|
| 47 |
+
"sha256": "7d40834f7a3dd088fce5a5489f421ed6b6d4bc8f51c50cd89363b81aad132b28"
|
| 48 |
+
},
|
| 49 |
+
"runtime/harness/__init__.py": {
|
| 50 |
+
"bytes": 0,
|
| 51 |
+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
| 52 |
+
},
|
| 53 |
+
"runtime/harness/addressee/__init__.py": {
|
| 54 |
+
"bytes": 613,
|
| 55 |
+
"sha256": "34bb4062a62803b2aa67fbc1b24e54efd0e198a2d978e419a9dcec99725846b1"
|
| 56 |
+
},
|
| 57 |
+
"runtime/harness/addressee/bands.py": {
|
| 58 |
+
"bytes": 3176,
|
| 59 |
+
"sha256": "ffbc520ee544ebc2fea6a25fb99b2d6edd3232a339a6d49b85a96a16de5db5d8"
|
| 60 |
+
},
|
| 61 |
+
"runtime/harness/addressee/classifier.py": {
|
| 62 |
+
"bytes": 7462,
|
| 63 |
+
"sha256": "36f65ff68b6b5e39d0650cf9001d9f46c10fee79a34df3c06aba30c3ba814941"
|
| 64 |
+
},
|
| 65 |
+
"runtime/harness/addressee/evidence.py": {
|
| 66 |
+
"bytes": 8048,
|
| 67 |
+
"sha256": "881c8f75aca1df4fe16bbcf2a5ce24621865937c697e12fe3cb3c4204df3a47b"
|
| 68 |
+
},
|
| 69 |
+
"runtime/harness/addressee/features.py": {
|
| 70 |
+
"bytes": 4513,
|
| 71 |
+
"sha256": "306388bfc4c0de5d2ce43f03846aede49ad205b016e9a9ca3b8153935fa55b86"
|
| 72 |
+
},
|
| 73 |
+
"runtime/harness/addressee/render.py": {
|
| 74 |
+
"bytes": 4314,
|
| 75 |
+
"sha256": "7474955c00e10abc53edfde258b6621d1e9cf1a64ad8900f54f6ed9f83337f24"
|
| 76 |
+
},
|
| 77 |
+
"runtime/harness/addressee/rules.py": {
|
| 78 |
+
"bytes": 11985,
|
| 79 |
+
"sha256": "4bb6bbf3557c928ae958737293e9dace118261e7b2e29fcac95367cbde03bac4"
|
| 80 |
+
},
|
| 81 |
+
"tokenizer.json": {
|
| 82 |
+
"bytes": 3583325,
|
| 83 |
+
"sha256": "82d1ca5ef2019321c91a01b78653345e6fe8ef25a38b7d83f7857c380649c7cf"
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
library_name: onnx
|
| 6 |
+
pipeline_tag: text-classification
|
| 7 |
+
base_model: jhu-clsp/ettin-encoder-32m
|
| 8 |
+
tags:
|
| 9 |
+
- onnx
|
| 10 |
+
- voice-agents
|
| 11 |
+
- conversational-ai
|
| 12 |
+
- multi-agent
|
| 13 |
+
- multi-party-conversation
|
| 14 |
+
- addressee-detection
|
| 15 |
+
- speech
|
| 16 |
+
- real-time
|
| 17 |
+
- pipecat
|
| 18 |
+
- npc
|
| 19 |
+
- dialogue
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# SpellSpeak Audience · RC1
|
| 23 |
+
|
| 24 |
+
When several AI characters listen to the same person, as in a game, an interactive story or any other multi-character voice experience, the application has to know who each line is for before any character answers or reacts. A name or a description often settles it, but much of what people say names nobody: "Hello.", "Who are you?", "You lied to me.". The language model voicing each character could guess, but only by reading every character's context for every line. That adds latency, the characters can disagree, and a wrong guess is heard at once.
|
| 25 |
+
|
| 26 |
+
SpellSpeak Audience treats the question as classification by a small model. One pass reads the line, a card for each character present (what the speaker can see of them) and, when the application knows it, where each one stands. It returns, for each character, how likely it is that the line is for them. It also returns whether the evidence leaves it unclear and whether the line is for the whole group. It scores. The application decides. Its constraints are millisecond inference on a CPU, typed or transcribed speech, and an honest "unclear", so that a character can ask "Who, me?" instead of taking offence at a line that may not have been meant for them.
|
| 27 |
+
|
| 28 |
+
[Try it in your browser](https://huggingface.co/spaces/spellspeak/audience-demo) · [Examples and source](https://github.com/spellspeak/models/tree/main/audience)
|
| 29 |
+
|
| 30 |
+
| | |
|
| 31 |
+
|---|---|
|
| 32 |
+
| **Released** | 2026-10-08 · rc1 (package `spellspeak-audience-rc1.2`, model run `addr-r2b-ettin-32m-tt-s1`) |
|
| 33 |
+
| **What it does** | Works out who a player's line is for: one person, several named on purpose, the whole group, or unclear |
|
| 34 |
+
| **Size** | About 33M parameters · 135 MB ONNX (fp32) in two graphs: a 128 MB encoder and a 3.7 MB head |
|
| 35 |
+
| **Speed** | p95 per line on 4 CPU threads (Apple M1 Max): 6.0 ms for 1 person, 6.4 for 2, 6.9 for 4, 7.1 for 8. On 1 thread: 11 to 13 ms. The first line of a scene also encodes everyone's card: 14 ms for 1 person to 62 ms for 8; cards are then cached |
|
| 36 |
+
| **Runtime** | ONNX Runtime and tokenizers, on CPU. A Python reference runtime ships with it, including the function that turns positions into the spatial facts and an instant rules baseline |
|
| 37 |
+
| **Files** | `encoder.onnx`, `head.onnx`, `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 |
|
| 38 |
+
| **Licence** | Apache-2.0 |
|
| 39 |
+
|
| 40 |
+
## Quick start
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
pip install -U huggingface_hub
|
| 44 |
+
hf download spellspeak/audience --revision rc1 --local-dir spellspeak-audience
|
| 45 |
+
cd spellspeak-audience
|
| 46 |
+
pip install -r requirements.txt
|
| 47 |
+
python example.py
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
The example puts a barkeep, an elf mercenary and an old sailor in a room, with the player facing the elf. It asks who three lines are for, with and without the spatial facts:
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
'You in the red hat, another round.' with facts tomas (0.99)
|
| 54 |
+
'You in the red hat, another round.' text only tomas (1.00)
|
| 55 |
+
'Hello.' with facts wren (0.99)
|
| 56 |
+
'Hello.' text only the whole group (0.99)
|
| 57 |
+
'You lied to me.' with facts wren (0.96)
|
| 58 |
+
'You lied to me.' text only unclear (0.99): a 'Who, me?' moment
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
In your own code, with the downloaded folder as the working directory:
|
| 62 |
+
|
| 63 |
+
```python
|
| 64 |
+
import sys
|
| 65 |
+
sys.path.insert(0, "runtime")
|
| 66 |
+
|
| 67 |
+
from contracts.schemas.addressee import AddresseeRequest
|
| 68 |
+
from contracts.schemas.person_card import PersonCard
|
| 69 |
+
from harness.addressee.classifier import load_classifier
|
| 70 |
+
|
| 71 |
+
model = load_classifier(".") # the folder with encoder.onnx, head.onnx, tokenizer.json and config.json
|
| 72 |
+
people = [
|
| 73 |
+
PersonCard(id="tomas", label="Tomas", aliases=["the barkeep"], features=[{"key": "headwear", "value": "red hat"}]),
|
| 74 |
+
PersonCard(id="wren", label="Wren", features=[{"key": "role", "value": "mercenary"}]),
|
| 75 |
+
]
|
| 76 |
+
answer = model.answer(AddresseeRequest(text="You in the red hat, another round.", present=people))
|
| 77 |
+
print(answer.top()) # tomas
|
| 78 |
+
print(answer.addressed) # one probability per person: tomas 0.996, wren 0.00003
|
| 79 |
+
print(answer.unclear, answer.to_group) # both near 0: the words pick out one person
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Input and output
|
| 83 |
+
|
| 84 |
+
The runtime calls the person speaking the player. Everyone who might be addressed gets a card.
|
| 85 |
+
|
| 86 |
+
- **Input:** the player's line, typed or from speech to text, with the line before it. Then everyone present (tested up to 8 people), one card each. A card holds a label ("Tomas", or "the barkeep" when the player would not know the name), aliases, and open `key: value` features for what the player can see. The game chooses its own keys: role, clothing, what someone holds or is doing, where they stand. A card is read up to 96 tokens, the line up to 64.
|
| 87 |
+
- **Optional facts per person:** distance (near, mid-range, far), in view, in the player's group, following the player, has asked the player something, spoke with the player last. `bands.py` turns positions and facings into these. Up to four earlier lines of the conversation say who spoke to whom.
|
| 88 |
+
- **Per person:** the probability that the line is for them.
|
| 89 |
+
- **Per line:** `unclear` (the evidence fits several people, so nobody should take it personally yet) and `to_group` (the line is for everyone the player is talking with).
|
| 90 |
+
|
| 91 |
+
The rules it learned:
|
| 92 |
+
- A name, an alias or a description that fits one person picks them out, whatever the player is looking at.
|
| 93 |
+
- A bare answer goes to whoever asked.
|
| 94 |
+
- A simple, harmless line ("Hello.", "Who are you?", "Thanks.") is never unclear. A line that carries the conversation on stays with the person the player was talking with. An opener goes to the person the player is looking at, else the conversation partner, else the one person nearby, else the whole group.
|
| 95 |
+
- A line with consequences that names nobody ("You lied to me.") is unclear unless the signals agree on one person.
|
| 96 |
+
|
| 97 |
+
## Performance (held-out test set, committee-checked labels)
|
| 98 |
+
|
| 99 |
+
| Measure | Result |
|
| 100 |
+
|---|---|
|
| 101 |
+
| Whole answer right (people, unclear and group), main test (3,294 lines) | 91.6% (95% interval 90.6 to 92.5). Rules baseline 86.4% |
|
| 102 |
+
| Simple lines such as "Hello." and "Who are you?" (1,193 lines) | 91.7% right; called unclear 0.7% |
|
| 103 |
+
| A wrong person scored 0.8 or more on a clear line | 1.3% |
|
| 104 |
+
| Top person, text only / with spatial facts | 98.6% / 91.1% |
|
| 105 |
+
| Lines that should be unclear, caught | 83.4% |
|
| 106 |
+
| Clear lines called unclear | 1.0% |
|
| 107 |
+
| Facts that contradict a unique description, words followed | 90.2% |
|
| 108 |
+
| Spatial facts drawn the way a game makes them (2,044 lines) | 96.5% right; top person 98.2% |
|
| 109 |
+
| Game settings never seen in training (919 lines) | 90.0% right |
|
| 110 |
+
| Lines worded unlike their construction, labelled by the committee (488 lines) | 64.3% right |
|
| 111 |
+
| Hand-written worked examples / hand-placed spatial scenes | 42 of 44 / 74 of 76 |
|
| 112 |
+
| Calibration error: person, unclear, group | 0.011, 0.005, 0.007 |
|
| 113 |
+
| For scale: a large language model asked the same questions | 63.8% right on 1,686 committee-labelled test lines (this model 89.0% on the same lines) |
|
| 114 |
+
|
| 115 |
+
## How it was made
|
| 116 |
+
|
| 117 |
+
- **Model:** Ettin-32M (`jhu-clsp/ettin-encoder-32m` at 1b8ba06, MIT) in two towers. Each person's card is encoded once, and the line once per utterance. A small head scores each person from the two, their spatial facts and conversation state, and the word-match signals of the rules baseline.
|
| 118 |
+
- **Scenes:** 41,989 scenes built by code in 18 game settings, each with a known answer from written rules of evidence. Lines and casts were written for this project by Qwen3.8-27B (Apache-2.0). The scenes include 12,730 of greetings, simple questions and lines with consequences, and openers said right after a conversation. Each line also has a text-only twin and some a speech-to-text twin.
|
| 119 |
+
- **Labels:** the construction's answer. On dev and test it is checked by the majority vote of a committee of three large language models, which agreed on 88% to 97% of lines. Lines whose words broke their brief take the committee's label.
|
| 120 |
+
- **Spatial facts:** drawn from positions through the bands function. A third of the training lines get fresh facts each epoch.
|
| 121 |
+
|
| 122 |
+
## Use and limits
|
| 123 |
+
|
| 124 |
+
- **Use it** to decide who answers a line and whose feelings it may change. Treat `unclear` as a cue for "Who, me?" and hold any change in feeling until the player makes the target clear. **Do not use it** to judge real people.
|
| 125 |
+
- **English only.** Up to 8 people and four lines of history.
|
| 126 |
+
- **It misses things.** About one line in six that should be unclear is given to a person. An opener right after a conversation ("And what's your name?") is ambiguous: the committee disagreed with the rule on 22% of those. It does not know that "check this wound" means the doctor.
|
| 127 |
+
- **Spatial facts depend on the game's band edges.** Tune them for each game (`BandConfig` in `bands.py`).
|
| 128 |
+
- **It has not met real players yet.** It was tested on generated and hand-written lines in game settings, not on logs from real players or from other multi-character settings.
|
| 129 |
+
|
| 130 |
+
The numbers come from the training repository's reports for this release (`2026-10-08-addr-r2-simple-lines.md` and `2026-10-08-addr-first-models.md`, at git tag `spellspeak-audience-rc1`). That repository is private.
|
config.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run_id": "addr-r2b-ettin-32m-tt-s1",
|
| 3 |
+
"arch": "twotower",
|
| 4 |
+
"encoder": "ettin-32m",
|
| 5 |
+
"repo": "jhu-clsp/ettin-encoder-32m",
|
| 6 |
+
"rev": "1b8ba06455dd44f80fc9c1ca9e22806157a57379",
|
| 7 |
+
"max_len": 512,
|
| 8 |
+
"card_len": 96,
|
| 9 |
+
"line_len": 64,
|
| 10 |
+
"temperatures": {
|
| 11 |
+
"person": 1.0,
|
| 12 |
+
"unclear": 1.9,
|
| 13 |
+
"group": 1.55
|
| 14 |
+
},
|
| 15 |
+
"render_version": "addressee-tt-0.5"
|
| 16 |
+
}
|
encoder.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7960e1bc311030450d9e6648829c6f5a82e392c502b7853987e89dc2feb62de5
|
| 3 |
+
size 127960962
|
example.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ask SpellSpeak Audience who a line is for.
|
| 2 |
+
|
| 3 |
+
pip install -r requirements.txt
|
| 4 |
+
python example.py
|
| 5 |
+
|
| 6 |
+
The model files (encoder.onnx, head.onnx, tokenizer.json, config.json) are read from this folder. Without them, as in
|
| 7 |
+
a git checkout, they are downloaded once from Hugging Face (spellspeak/audience, release rc1).
|
| 8 |
+
"""
|
| 9 |
+
import pathlib
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
HERE = pathlib.Path(__file__).resolve().parent
|
| 13 |
+
sys.path.insert(0, str(HERE / "runtime"))
|
| 14 |
+
|
| 15 |
+
from contracts.schemas.addressee import AddresseeRequest # noqa: E402
|
| 16 |
+
from contracts.schemas.person_card import PersonCard # noqa: E402
|
| 17 |
+
from harness.addressee.bands import Pose, bands # noqa: E402
|
| 18 |
+
from harness.addressee.classifier import load_classifier # noqa: E402
|
| 19 |
+
|
| 20 |
+
MODEL_FILES = ["encoder.onnx", "head.onnx", "tokenizer.json", "config.json"]
|
| 21 |
+
model_dir = HERE
|
| 22 |
+
if not all((HERE / f).exists() for f in MODEL_FILES):
|
| 23 |
+
from huggingface_hub import snapshot_download
|
| 24 |
+
|
| 25 |
+
model_dir = snapshot_download("spellspeak/audience", revision="rc1", allow_patterns=MODEL_FILES)
|
| 26 |
+
model = load_classifier(model_dir, threads=4)
|
| 27 |
+
|
| 28 |
+
# What the player can see of each person. Keys are open: a game uses its own.
|
| 29 |
+
people = [
|
| 30 |
+
PersonCard(id="tomas", label="Tomas", aliases=["barkeep"],
|
| 31 |
+
features=[{"key": "role", "value": "barkeep"}, {"key": "headwear", "value": "red hat"},
|
| 32 |
+
{"key": "holding", "value": "a tankard"}]),
|
| 33 |
+
PersonCard(id="wren", label="Wren", features=[{"key": "role", "value": "mercenary"}, {"key": "race", "value": "elf"},
|
| 34 |
+
{"key": "carrying", "value": "a longbow"}]),
|
| 35 |
+
PersonCard(id="oskar", label="the old sailor", features=[{"key": "build", "value": "stout"},
|
| 36 |
+
{"key": "holding", "value": "a pipe"}]),
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# Where everyone stands (metres) and faces (degrees), turned into the facts the model reads. The player faces Wren.
|
| 40 |
+
facts = bands(Pose(0.0, 0.0, 90.0), {"tomas": Pose(-6.0, 5.0, -40.0), "wren": Pose(0.5, 2.0, -100.0), "oskar": Pose(5.0, 4.0, 180.0)})
|
| 41 |
+
with_facts = [p.model_copy(update={"spatial": facts[p.id].spatial}) for p in people]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def reading(a) -> str:
|
| 45 |
+
"""One way a game might read the scores: unclear first, then the whole group, then the top person."""
|
| 46 |
+
if a.unclear >= 0.5:
|
| 47 |
+
return f"unclear ({a.unclear:.2f}): a 'Who, me?' moment"
|
| 48 |
+
if a.to_group >= 0.5:
|
| 49 |
+
return f"the whole group ({a.to_group:.2f})"
|
| 50 |
+
top = max(a.addressed, key=a.addressed.get)
|
| 51 |
+
return f"{top} ({a.addressed[top]:.2f})"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
for text in ("You in the red hat, another round.", "Hello.", "You lied to me."):
|
| 55 |
+
for mode, cards in (("with facts", with_facts), ("text only", people)):
|
| 56 |
+
print(f"{text!r:38} {mode:10} {reading(model.answer(AddresseeRequest(text=text, present=cards)))}")
|
head.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cb21b22f047d798148da89922b352dd3247840afb3828de6e0aa1e964e798f83
|
| 3 |
+
size 3684676
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SpellSpeak Audience runtime. Tested with onnxruntime 1.30.0, tokenizers 0.23.2, numpy 2.2.6, pydantic 2.13.5.
|
| 2 |
+
onnxruntime>=1.24
|
| 3 |
+
tokenizers>=0.23
|
| 4 |
+
numpy>=2.0
|
| 5 |
+
pydantic>=2.6
|
| 6 |
+
huggingface_hub>=0.30 # only for example.py to fetch the model files when they are not beside it
|
runtime/contracts/__init__.py
ADDED
|
File without changes
|
runtime/contracts/schemas/__init__.py
ADDED
|
File without changes
|
runtime/contracts/schemas/addressee.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Addressee 0.1: who a player line was said to.
|
| 2 |
+
|
| 3 |
+
The harness sends a request: the player's line, a card for each person present (contracts/schemas/person_card.py)
|
| 4 |
+
and the last few lines of the conversation. The model answers with one probability per person, a probability
|
| 5 |
+
that the words and facts do not pick anyone out (`unclear`), and a probability that the line is for the whole
|
| 6 |
+
group the player is talking with (`to_group`). It scores; it never decides: the harness decides who speaks.
|
| 7 |
+
|
| 8 |
+
Labels (`AddresseeLabel`) are what the rules of evidence (harness/addressee/evidence.py) give. A clear line lists
|
| 9 |
+
who it was said to. A vague line lists who it could have been said to, so a model can learn that "You lied to me."
|
| 10 |
+
with three people present is a third each, and the harness can play "Who, me?" instead of guessing.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import pathlib
|
| 15 |
+
from typing import Annotated, Literal
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
|
| 18 |
+
|
| 19 |
+
from contracts.schemas.person_card import CardId, PersonCard
|
| 20 |
+
|
| 21 |
+
SCHEMA_VERSION = "addressee-0.1"
|
| 22 |
+
PLAYER = "player"
|
| 23 |
+
|
| 24 |
+
MAX_PRESENT = 8 # the snapshot's `present` cap (contracts/schemas/vocab.py)
|
| 25 |
+
MAX_HISTORY = 4 # the last few lines of the conversation
|
| 26 |
+
|
| 27 |
+
LineText = Annotated[str, StringConstraints(min_length=1, max_length=400)]
|
| 28 |
+
Probability = Annotated[float, Field(ge=0.0, le=1.0)]
|
| 29 |
+
Who = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9_.:-]*$", max_length=64)] # a card id or "player"
|
| 30 |
+
|
| 31 |
+
# How the line picks out who it is for (the rules of evidence). The benchmark slices on these.
|
| 32 |
+
Kind = Literal["name", "alias", "role", "appearance", "item", "activity", "place", "neighbour", "several", "group",
|
| 33 |
+
"answer", "continuation", "correction", "switch", "vague", "nobody",
|
| 34 |
+
"greeting", "question"] # rule 5: simple harmless lines
|
| 35 |
+
KINDS: tuple[str, ...] = Kind.__args__
|
| 36 |
+
# What the spatial facts do in this example.
|
| 37 |
+
SpatialPattern = Literal["none", "agree", "contradict", "decide", "conflict", "neutral"]
|
| 38 |
+
SPATIAL_PATTERNS: tuple[str, ...] = SpatialPattern.__args__
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class Strict(BaseModel):
|
| 42 |
+
model_config = ConfigDict(extra="forbid")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class HistoryLine(Strict):
|
| 46 |
+
"""An earlier line in this conversation, oldest first. `to` empty: said to the room, or not known."""
|
| 47 |
+
|
| 48 |
+
speaker: Who
|
| 49 |
+
to: list[Who] = Field(default=[], max_length=MAX_PRESENT)
|
| 50 |
+
text: LineText
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class AddresseeRequest(Strict):
|
| 54 |
+
"""What the harness sends: the player's line, the people present, and the last few lines."""
|
| 55 |
+
|
| 56 |
+
speaker: Literal["player"] = PLAYER
|
| 57 |
+
text: LineText
|
| 58 |
+
present: list[PersonCard] = Field(min_length=1, max_length=MAX_PRESENT)
|
| 59 |
+
history: list[HistoryLine] = Field(default=[], max_length=MAX_HISTORY)
|
| 60 |
+
|
| 61 |
+
@model_validator(mode="after")
|
| 62 |
+
def _people(self) -> "AddresseeRequest":
|
| 63 |
+
ids = [c.id for c in self.present]
|
| 64 |
+
if len(set(ids)) != len(ids):
|
| 65 |
+
raise ValueError("present ids must be distinct")
|
| 66 |
+
known = set(ids) | {PLAYER}
|
| 67 |
+
for h in self.history:
|
| 68 |
+
if h.speaker not in known or any(t not in known for t in h.to):
|
| 69 |
+
raise ValueError("history lines name only the player and people present")
|
| 70 |
+
if h.speaker in h.to:
|
| 71 |
+
raise ValueError("a history line is not said to its own speaker")
|
| 72 |
+
return self
|
| 73 |
+
|
| 74 |
+
def card(self, cid: str) -> PersonCard:
|
| 75 |
+
return next(c for c in self.present if c.id == cid)
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def ids(self) -> list[str]:
|
| 79 |
+
return [c.id for c in self.present]
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def has_spatial(self) -> bool:
|
| 83 |
+
return any(c.spatial is not None and not c.spatial.empty for c in self.present)
|
| 84 |
+
|
| 85 |
+
def text_only(self) -> "AddresseeRequest":
|
| 86 |
+
"""The same request with every spatial fact removed (the text-only input mode)."""
|
| 87 |
+
return self.model_copy(update={"present": [c.without_spatial() for c in self.present]})
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class AddresseeAnswer(Strict):
|
| 91 |
+
"""What the model returns. `addressed` has exactly one entry per person present."""
|
| 92 |
+
|
| 93 |
+
addressed: dict[CardId, Probability]
|
| 94 |
+
unclear: Probability
|
| 95 |
+
to_group: Probability
|
| 96 |
+
|
| 97 |
+
def top(self) -> str:
|
| 98 |
+
return max(self.addressed, key=self.addressed.get)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class AddresseeLabel(Strict):
|
| 102 |
+
"""The answer the rules of evidence give. Clear: who it was said to. Vague: who it could have been said to."""
|
| 103 |
+
|
| 104 |
+
addressed: list[CardId] = Field(min_length=1, max_length=MAX_PRESENT)
|
| 105 |
+
vague: bool = False
|
| 106 |
+
group: bool = False
|
| 107 |
+
|
| 108 |
+
@model_validator(mode="after")
|
| 109 |
+
def _shape(self) -> "AddresseeLabel":
|
| 110 |
+
if len(set(self.addressed)) != len(self.addressed):
|
| 111 |
+
raise ValueError("addressed ids must be distinct")
|
| 112 |
+
if self.vague and self.group:
|
| 113 |
+
raise ValueError("a group line is clear: everyone in the group")
|
| 114 |
+
if self.vague and len(self.addressed) < 2:
|
| 115 |
+
raise ValueError("a vague line lists at least two people it could be for")
|
| 116 |
+
return self
|
| 117 |
+
|
| 118 |
+
def targets(self, ids: list[str]) -> dict[str, float]:
|
| 119 |
+
"""Per-person training targets: 1 for each addressed person; 1/k for each of k candidates when vague."""
|
| 120 |
+
share = 1.0 / len(self.addressed) if self.vague else 1.0
|
| 121 |
+
return {i: (share if i in self.addressed else 0.0) for i in ids}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class AddresseeRecord(Strict):
|
| 125 |
+
"""A labelled line: the request, its label, and how the line picks its people out. Worked examples,
|
| 126 |
+
constructed lines, committee labels and the spatial suite all use this record."""
|
| 127 |
+
|
| 128 |
+
schema_version: Literal["addressee-0.1"] = SCHEMA_VERSION
|
| 129 |
+
line_id: Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9_.:-]*$", max_length=120)]
|
| 130 |
+
request: AddresseeRequest
|
| 131 |
+
label: AddresseeLabel
|
| 132 |
+
kind: Kind
|
| 133 |
+
spatial: SpatialPattern = "none"
|
| 134 |
+
label_source: Literal["guide", "construction", "committee", "person", "model"] = "guide"
|
| 135 |
+
slices: list[Annotated[str, StringConstraints(pattern=r"^[a-z0-9_]+$")]] = []
|
| 136 |
+
notes: str | None = None
|
| 137 |
+
|
| 138 |
+
@model_validator(mode="after")
|
| 139 |
+
def _consistent(self) -> "AddresseeRecord":
|
| 140 |
+
ids = set(self.request.ids)
|
| 141 |
+
if not set(self.label.addressed) <= ids:
|
| 142 |
+
raise ValueError(f"label names people not present: {sorted(set(self.label.addressed) - ids)}")
|
| 143 |
+
if len(ids) == 1 and self.label.vague:
|
| 144 |
+
raise ValueError("with one person present a line is never vague (rule 1)")
|
| 145 |
+
if (self.spatial == "none") == self.request.has_spatial:
|
| 146 |
+
raise ValueError("spatial is `none` exactly when no card carries a spatial fact")
|
| 147 |
+
return self
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
EXAMPLES_PATH = pathlib.Path(__file__).resolve().parent / "examples" / "addressee-0.1.examples.yaml"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def load_examples() -> list[AddresseeRecord]:
|
| 154 |
+
"""The worked examples (examples/ next to this file, not part of a release). The file defines its cards once
|
| 155 |
+
under `cards` and reuses them with YAML anchors."""
|
| 156 |
+
import yaml
|
| 157 |
+
|
| 158 |
+
return [AddresseeRecord.model_validate(r) for r in yaml.safe_load(EXAMPLES_PATH.read_text())["examples"]]
|
runtime/contracts/schemas/person_card.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Person card 0.1: what the player can see of one person in the scene.
|
| 2 |
+
|
| 3 |
+
A card is how the addressee model knows who is who. It holds only what the player could see or would call
|
| 4 |
+
someone, never what is true but hidden: a disguised elf is `race: human`, and a hood up makes `hair: hidden`.
|
| 5 |
+
|
| 6 |
+
- `label` is what the player would call them. The player may not know the name, so "the barkeep" is a valid
|
| 7 |
+
label. `aliases` hold the other names they answer to (nicknames, titles, what the locals say). `id` is the
|
| 8 |
+
harness's handle and is never shown to the model.
|
| 9 |
+
- `features` is an open list of `key: value` pairs; the set of keys is not fixed. The model reads them as text.
|
| 10 |
+
`CORE_KEYS` are recommended, and the ones a harness can later fill from the engine. A game adds any key it
|
| 11 |
+
likes. A value may be `hidden` (the player cannot see it) or `unknown`.
|
| 12 |
+
- `spatial` is optional, and every fact in it is optional. It is a closed set of plain bands, never raw engine
|
| 13 |
+
numbers. `harness/addressee/bands.py` turns positions into these bands.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Annotated, Literal
|
| 18 |
+
|
| 19 |
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
|
| 20 |
+
|
| 21 |
+
SCHEMA_VERSION = "person-card-0.1"
|
| 22 |
+
|
| 23 |
+
CardId = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9_.:-]*$", max_length=64)]
|
| 24 |
+
Label = Annotated[str, StringConstraints(min_length=1, max_length=40, strip_whitespace=True)]
|
| 25 |
+
FeatureKey = Annotated[str, StringConstraints(min_length=1, max_length=32, strip_whitespace=True)]
|
| 26 |
+
FeatureValue = Annotated[str, StringConstraints(min_length=1, max_length=80, strip_whitespace=True)]
|
| 27 |
+
|
| 28 |
+
# Recommended keys. Open: a card may use none of them and any others.
|
| 29 |
+
CORE_KEYS: tuple[str, ...] = (
|
| 30 |
+
"role", "race", "presents as", "age", "build", "marks", "hair", "facial hair", "headwear", "clothing", "insignia",
|
| 31 |
+
"holding", "carrying", "doing", "posture", "condition", "near", "with",
|
| 32 |
+
)
|
| 33 |
+
HIDDEN, UNKNOWN = "hidden", "unknown"
|
| 34 |
+
|
| 35 |
+
MAX_FEATURES = 16
|
| 36 |
+
MAX_ALIASES = 4
|
| 37 |
+
|
| 38 |
+
Distance = Literal["near", "mid", "far"]
|
| 39 |
+
DISTANCES: tuple[str, ...] = Distance.__args__
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Strict(BaseModel):
|
| 43 |
+
model_config = ConfigDict(extra="forbid")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class Feature(Strict):
|
| 47 |
+
key: FeatureKey
|
| 48 |
+
value: FeatureValue
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def visible(self) -> bool:
|
| 52 |
+
return self.value.lower() not in (HIDDEN, UNKNOWN)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class SpatialFacts(Strict):
|
| 56 |
+
"""What the game knows about where this person is relative to the player, in bands. None: not known."""
|
| 57 |
+
|
| 58 |
+
distance: Distance | None = None
|
| 59 |
+
in_view: bool | None = None # inside the player's gaze cone when they started speaking
|
| 60 |
+
in_group: bool | None = None # in the player's current conversation group
|
| 61 |
+
following: bool | None = None # following the player
|
| 62 |
+
asked_you: bool | None = None # has a question waiting for the player's answer
|
| 63 |
+
last_spoke_with_you: bool | None = None # the last person the player was talking with
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def empty(self) -> bool:
|
| 67 |
+
return all(v is None for v in self.model_dump().values())
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class PersonCard(Strict):
|
| 71 |
+
id: CardId
|
| 72 |
+
label: Label
|
| 73 |
+
aliases: list[Label] = Field(default=[], max_length=MAX_ALIASES)
|
| 74 |
+
features: list[Feature] = Field(default=[], max_length=MAX_FEATURES)
|
| 75 |
+
spatial: SpatialFacts | None = None
|
| 76 |
+
|
| 77 |
+
@model_validator(mode="after")
|
| 78 |
+
def _names(self) -> "PersonCard":
|
| 79 |
+
names = [self.label.lower()] + [a.lower() for a in self.aliases]
|
| 80 |
+
if len(set(names)) != len(names):
|
| 81 |
+
raise ValueError("aliases must differ from the label and from each other")
|
| 82 |
+
if self.id == "player":
|
| 83 |
+
raise ValueError("`player` is reserved for the player, who has no card")
|
| 84 |
+
return self
|
| 85 |
+
|
| 86 |
+
def feature(self, key: str) -> list[str]:
|
| 87 |
+
return [f.value for f in self.features if f.key.lower() == key.lower()]
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def names(self) -> list[str]:
|
| 91 |
+
return [self.label, *self.aliases]
|
| 92 |
+
|
| 93 |
+
def without_spatial(self) -> "PersonCard":
|
| 94 |
+
return self.model_copy(update={"spatial": None})
|
runtime/harness/__init__.py
ADDED
|
File without changes
|
runtime/harness/addressee/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The addressee model (released as SpellSpeak Audience): who a player's line was said to.
|
| 2 |
+
|
| 3 |
+
- `render`: how the single-pass model reads a request (addressee-input-0.1), shared by training and the runtime.
|
| 4 |
+
- `features`: the cached-cards model's inputs (addressee-tt-0.5), shared by training and the runtime.
|
| 5 |
+
- `classifier`: the model at run time, on ONNX Runtime.
|
| 6 |
+
- `evidence`: the rules of evidence as code, shared by the data generator and the rules baseline.
|
| 7 |
+
- `rules`: the rules baseline, the floor and the harness's instant fallback.
|
| 8 |
+
- `bands`: positions and facings to the spatial facts the model reads.
|
| 9 |
+
"""
|
runtime/harness/addressee/bands.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Positions to spatial facts: the one piece of harness logic a scene demo needs.
|
| 2 |
+
|
| 3 |
+
A game knows where everyone stands and which way they face. The addressee model reads plain bands instead
|
| 4 |
+
(contracts/schemas/person_card.py `SpatialFacts`), so it is never tied to one game's units. This turns poses
|
| 5 |
+
into those bands. Band edges are per game (`BandConfig`); a heatmap of the answers over the floor helps tune them.
|
| 6 |
+
|
| 7 |
+
Coordinates are any flat 2D frame in metres. Facing is in degrees in the same frame (`atan2(dy, dx)`), so the
|
| 8 |
+
handedness of the frame does not matter. The player's facing is read when they start speaking, not when they stop.
|
| 9 |
+
|
| 10 |
+
The group rule here is a placeholder: an explicit group from the game wins; otherwise a person is in the group
|
| 11 |
+
when they stand within `group_m` of the player, in front of them, and face them. Anyone following the player is in
|
| 12 |
+
the group. A real game should use its own conversation state.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import math
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from contracts.schemas.person_card import SpatialFacts
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class Pose:
|
| 24 |
+
x: float
|
| 25 |
+
y: float
|
| 26 |
+
facing: float = 0.0 # degrees
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class BandConfig:
|
| 31 |
+
near_m: float = 3.0 # conversation distance
|
| 32 |
+
mid_m: float = 8.0 # across a room
|
| 33 |
+
earshot_m: float = 15.0 # beyond this a person cannot hear the line and is left out of the request
|
| 34 |
+
gaze_half_angle_deg: float = 20.0
|
| 35 |
+
group_m: float = 2.5
|
| 36 |
+
group_facing_deg: float = 60.0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class Band:
|
| 41 |
+
spatial: SpatialFacts
|
| 42 |
+
can_hear: bool
|
| 43 |
+
distance_m: float
|
| 44 |
+
angle_deg: float # from the player's facing to the person, 0 to 180
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _angle(a: float, b: float) -> float:
|
| 48 |
+
d = (a - b + 180.0) % 360.0 - 180.0
|
| 49 |
+
return abs(d)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def bands(player: Pose, people: dict[str, Pose], cfg: BandConfig = BandConfig(), *, group: set[str] | None = None,
|
| 53 |
+
following: set[str] = frozenset(), asked: set[str] = frozenset(), last_spoke: str | None = None) -> dict[str, Band]:
|
| 54 |
+
out = {}
|
| 55 |
+
for pid, p in people.items():
|
| 56 |
+
dx, dy = p.x - player.x, p.y - player.y
|
| 57 |
+
dist = math.hypot(dx, dy)
|
| 58 |
+
toward = math.degrees(math.atan2(dy, dx)) if dist > 1e-9 else player.facing
|
| 59 |
+
angle = _angle(toward, player.facing)
|
| 60 |
+
distance = "near" if dist <= cfg.near_m else "mid" if dist <= cfg.mid_m else "far"
|
| 61 |
+
if group is not None:
|
| 62 |
+
in_group = pid in group
|
| 63 |
+
else:
|
| 64 |
+
back = math.degrees(math.atan2(-dy, -dx)) # from the person toward the player
|
| 65 |
+
in_group = dist <= cfg.group_m and angle <= 90.0 and _angle(p.facing, back) <= cfg.group_facing_deg
|
| 66 |
+
in_group = in_group or pid in following
|
| 67 |
+
out[pid] = Band(spatial=SpatialFacts(distance=distance, in_view=angle <= cfg.gaze_half_angle_deg, in_group=in_group,
|
| 68 |
+
following=pid in following, asked_you=pid in asked,
|
| 69 |
+
last_spoke_with_you=pid == last_spoke),
|
| 70 |
+
can_hear=dist <= cfg.earshot_m, distance_m=round(dist, 3), angle_deg=round(angle, 2))
|
| 71 |
+
return out
|
runtime/harness/addressee/classifier.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The addressee model at run time: ONNX Runtime on the CPU, no torch.
|
| 2 |
+
|
| 3 |
+
A model folder holds `model.onnx` (encoder and heads in one graph; inputs input_ids, attention_mask and the
|
| 4 |
+
per-person marker masks; outputs one logit per person, the unclear logit and the group logit), `tokenizer.json` and
|
| 5 |
+
`config.json` (temperatures fitted on dev, max length, render version). One encoder pass per line, whatever the number
|
| 6 |
+
of people. The input is rendered by `harness/addressee/render.py`, the same code training used.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import math
|
| 12 |
+
import pathlib
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
from contracts.schemas.addressee import AddresseeAnswer, AddresseeRequest
|
| 16 |
+
from harness.addressee.render import RENDER_VERSION, render, span_mask
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _sigmoid(x: float) -> float:
|
| 20 |
+
return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AddresseeClassifier:
|
| 24 |
+
def __init__(self, model_dir: str | pathlib.Path, onnx_file: str = "model.onnx", threads: int = 4):
|
| 25 |
+
import numpy as np
|
| 26 |
+
import onnxruntime as ort
|
| 27 |
+
from tokenizers import Tokenizer
|
| 28 |
+
|
| 29 |
+
self.np = np
|
| 30 |
+
d = pathlib.Path(model_dir)
|
| 31 |
+
self.config = json.loads((d / "config.json").read_text())
|
| 32 |
+
if self.config.get("render_version", RENDER_VERSION) != RENDER_VERSION:
|
| 33 |
+
raise ValueError(f"model expects {self.config['render_version']}, the runtime renders {RENDER_VERSION}")
|
| 34 |
+
self.tok = Tokenizer.from_file(str(d / "tokenizer.json"))
|
| 35 |
+
self.tok.enable_truncation(self.config["max_len"])
|
| 36 |
+
self.tok.no_padding()
|
| 37 |
+
so = ort.SessionOptions()
|
| 38 |
+
so.intra_op_num_threads = threads
|
| 39 |
+
so.inter_op_num_threads = 1
|
| 40 |
+
self.sess = ort.InferenceSession(str(d / onnx_file), so, providers=["CPUExecutionProvider"])
|
| 41 |
+
self.temps = self.config["temperatures"]
|
| 42 |
+
|
| 43 |
+
def logits(self, req: AddresseeRequest) -> tuple[list[float], float, float]:
|
| 44 |
+
np = self.np
|
| 45 |
+
text, spans = render(req)
|
| 46 |
+
enc = self.tok.encode(text)
|
| 47 |
+
sm = np.asarray(span_mask(enc.offsets, spans), dtype=np.float32)
|
| 48 |
+
lp, lu, lg = self.sess.run(None, {"input_ids": np.asarray([enc.ids], dtype=np.int64),
|
| 49 |
+
"attention_mask": np.asarray([enc.attention_mask], dtype=np.int64),
|
| 50 |
+
"span_mask": sm})
|
| 51 |
+
return [float(x) for x in lp.reshape(-1)], float(lu.reshape(-1)[0]), float(lg.reshape(-1)[0])
|
| 52 |
+
|
| 53 |
+
def answer(self, req: AddresseeRequest) -> AddresseeAnswer:
|
| 54 |
+
lp, lu, lg = self.logits(req)
|
| 55 |
+
t = self.temps
|
| 56 |
+
return AddresseeAnswer(addressed={pid: _sigmoid(x / t["person"]) for pid, x in zip(req.ids, lp)},
|
| 57 |
+
unclear=_sigmoid(lu / t["unclear"]), to_group=_sigmoid(lg / t["group"]))
|
| 58 |
+
|
| 59 |
+
def timed(self, req: AddresseeRequest) -> tuple[AddresseeAnswer, float]:
|
| 60 |
+
t0 = time.perf_counter()
|
| 61 |
+
a = self.answer(req)
|
| 62 |
+
return a, (time.perf_counter() - t0) * 1000
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class CachedCardsClassifier:
|
| 66 |
+
"""The cached-cards model (addressee-tt-0.1) at run time. Two ONNX graphs: `encoder.onnx` encodes a card's text
|
| 67 |
+
(without facts; the result is kept per card text) and, per utterance, the player's line; `head.onnx` scores each
|
| 68 |
+
person from the line, their cached card and their facts and conversation vector."""
|
| 69 |
+
|
| 70 |
+
def __init__(self, model_dir: str | pathlib.Path, threads: int = 4):
|
| 71 |
+
import numpy as np
|
| 72 |
+
import onnxruntime as ort
|
| 73 |
+
from tokenizers import Tokenizer
|
| 74 |
+
|
| 75 |
+
from harness.addressee.features import TT_VERSION
|
| 76 |
+
|
| 77 |
+
self.np = np
|
| 78 |
+
d = pathlib.Path(model_dir)
|
| 79 |
+
self.config = json.loads((d / "config.json").read_text())
|
| 80 |
+
if self.config.get("render_version") != TT_VERSION:
|
| 81 |
+
raise ValueError(f"model expects {self.config.get('render_version')}, the runtime renders {TT_VERSION}")
|
| 82 |
+
self.card_tok = Tokenizer.from_file(str(d / "tokenizer.json"))
|
| 83 |
+
self.card_tok.enable_truncation(self.config["card_len"])
|
| 84 |
+
self.card_tok.no_padding()
|
| 85 |
+
self.line_tok = Tokenizer.from_file(str(d / "tokenizer.json"))
|
| 86 |
+
self.line_tok.enable_truncation(self.config["line_len"])
|
| 87 |
+
self.line_tok.no_padding()
|
| 88 |
+
so = ort.SessionOptions()
|
| 89 |
+
so.intra_op_num_threads = threads
|
| 90 |
+
so.inter_op_num_threads = 1
|
| 91 |
+
self.enc = ort.InferenceSession(str(d / "encoder.onnx"), so, providers=["CPUExecutionProvider"])
|
| 92 |
+
self.head = ort.InferenceSession(str(d / "head.onnx"), so, providers=["CPUExecutionProvider"])
|
| 93 |
+
self.temps = self.config["temperatures"]
|
| 94 |
+
self.cache: dict[str, tuple] = {}
|
| 95 |
+
|
| 96 |
+
def card(self, text: str) -> tuple:
|
| 97 |
+
"""(token states, projected token states) for one card text, from the cache or encoded now."""
|
| 98 |
+
hit = self.cache.get(text)
|
| 99 |
+
if hit is None:
|
| 100 |
+
np = self.np
|
| 101 |
+
enc = self.card_tok.encode(text)
|
| 102 |
+
g, gp, _ = self.enc.run(None, {"ids": np.asarray([enc.ids], dtype=np.int64),
|
| 103 |
+
"mask": np.asarray([enc.attention_mask], dtype=np.int64)})
|
| 104 |
+
hit = (g[0], gp[0])
|
| 105 |
+
self.cache[text] = hit
|
| 106 |
+
return hit
|
| 107 |
+
|
| 108 |
+
def logits(self, req: AddresseeRequest) -> tuple[list[float], float, float]:
|
| 109 |
+
from harness.addressee.features import card_only, line_features, line_input, person_features
|
| 110 |
+
|
| 111 |
+
np = self.np
|
| 112 |
+
cached = [self.card(card_only(c)) for c in req.present]
|
| 113 |
+
L = max(g.shape[0] for g, _ in cached)
|
| 114 |
+
d, k = cached[0][0].shape[1], cached[0][1].shape[1]
|
| 115 |
+
g = np.zeros((len(cached), L, d), dtype=np.float32)
|
| 116 |
+
gp = np.zeros((len(cached), L, k), dtype=np.float32)
|
| 117 |
+
cm = np.zeros((len(cached), L), dtype=np.int64)
|
| 118 |
+
for i, (a, b) in enumerate(cached):
|
| 119 |
+
g[i, :a.shape[0]], gp[i, :b.shape[0]], cm[i, :a.shape[0]] = a, b, 1
|
| 120 |
+
text, cur_len = line_input(req)
|
| 121 |
+
enc = self.line_tok.encode(text)
|
| 122 |
+
cur = np.asarray([[1.0 if (b > a and b <= cur_len) else 0.0 for a, b in enc.offsets]], dtype=np.float32)
|
| 123 |
+
h, hp, q = self.enc.run(None, {"ids": np.asarray([enc.ids], dtype=np.int64),
|
| 124 |
+
"mask": np.asarray([enc.attention_mask], dtype=np.int64)})
|
| 125 |
+
lp, lu, lg = self.head.run(None, {
|
| 126 |
+
"q": q, "h": h, "hp": hp, "cur": cur, "g": g, "gp": gp, "card_mask": cm,
|
| 127 |
+
"pfeat": np.asarray(person_features(req), dtype=np.float32), "lfeat": np.asarray([line_features(req)], dtype=np.float32)})
|
| 128 |
+
return [float(x) for x in lp.reshape(-1)], float(lu.reshape(-1)[0]), float(lg.reshape(-1)[0])
|
| 129 |
+
|
| 130 |
+
def answer(self, req: AddresseeRequest) -> AddresseeAnswer:
|
| 131 |
+
lp, lu, lg = self.logits(req)
|
| 132 |
+
t = self.temps
|
| 133 |
+
return AddresseeAnswer(addressed={pid: _sigmoid(x / t["person"]) for pid, x in zip(req.ids, lp)},
|
| 134 |
+
unclear=_sigmoid(lu / t["unclear"]), to_group=_sigmoid(lg / t["group"]))
|
| 135 |
+
|
| 136 |
+
def timed(self, req: AddresseeRequest) -> tuple[AddresseeAnswer, float]:
|
| 137 |
+
t0 = time.perf_counter()
|
| 138 |
+
a = self.answer(req)
|
| 139 |
+
return a, (time.perf_counter() - t0) * 1000
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def load_classifier(model_dir: str | pathlib.Path, threads: int = 4):
|
| 143 |
+
"""The right runtime for an exported model folder: cached cards when it has a card encoder, else single pass."""
|
| 144 |
+
d = pathlib.Path(model_dir)
|
| 145 |
+
return CachedCardsClassifier(d, threads) if (d / "head.onnx").exists() else AddresseeClassifier(d, threads=threads)
|
runtime/harness/addressee/evidence.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The rules of evidence as code: who a line is for, given what its words pick out and what the game knows.
|
| 2 |
+
|
| 3 |
+
Two callers: the data generator, which knows by construction what the words of a line pick out and asks for the
|
| 4 |
+
label, and the rules baseline (harness/addressee/rules.py), which guesses what the words pick out from the text.
|
| 5 |
+
Both then go through `resolve`, so a constructed label and the baseline's answer follow the same rules.
|
| 6 |
+
|
| 7 |
+
The rules, strongest first:
|
| 8 |
+
|
| 9 |
+
1. One person present: the line is for them.
|
| 10 |
+
2. Words for the whole group ("right, listen to me"): everyone in the player's group when the facts say who that
|
| 11 |
+
is, otherwise everyone present.
|
| 12 |
+
3. Words that pick out people: one person, or a set named on purpose ("you two", "Tomas and Wren"), is clear. A
|
| 13 |
+
description that fits several people is broken only by the other evidence agreeing on one of them; else vague
|
| 14 |
+
among those it fits.
|
| 15 |
+
4. Words that pick out no one ("You lied to me.", "Yes."): a bare answer goes to whoever asked. Otherwise the other signals
|
| 16 |
+
must agree: who the player was talking with, the one person near and in view, the one person in the group, the
|
| 17 |
+
one person near (or, with nobody near, the one person at mid range). Agreement on one person is clear.
|
| 18 |
+
Disagreement is vague among them. No signal is vague among everyone not known to be far away.
|
| 19 |
+
5. A simple, harmless line with no name or description (a greeting, small talk, a simple question, a thank-you).
|
| 20 |
+
After a bare answer: a line that carries on the conversation (a reply, a thank-you, a goodbye, a follow-up) goes to
|
| 21 |
+
the one the player was talking with. Otherwise (an opener: "Hello.", "Who are you?"): the person the player is
|
| 22 |
+
looking at within mid range (the nearest of them), else the one the player was talking with, else the one person
|
| 23 |
+
in the group, else the one person near (or at mid range), else the whole group. Never vague: nobody says
|
| 24 |
+
"Who, me?" to a hello. Someone far away in view is not "looked at".
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from dataclasses import dataclass
|
| 29 |
+
|
| 30 |
+
from contracts.schemas.addressee import PLAYER, AddresseeLabel, AddresseeRequest
|
| 31 |
+
from contracts.schemas.person_card import PersonCard
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class Evidence:
|
| 36 |
+
"""What the words and the conversation say, before the spatial facts are read."""
|
| 37 |
+
|
| 38 |
+
words: frozenset[str] = frozenset() # ids the words pick out; empty: the words pick out no one
|
| 39 |
+
words_set: bool = False # the words name all of `words` on purpose ("you two", "Tomas and Wren")
|
| 40 |
+
words_group: bool = False # the words are for the whole group ("listen to me", "evening, all")
|
| 41 |
+
asker: str | None = None # who has a question waiting for the player's answer
|
| 42 |
+
partner: str | None = None # who the player was talking with
|
| 43 |
+
benign: bool = False # a simple, harmless line: greeting, small talk, simple question (rule 5)
|
| 44 |
+
follows: bool = False # it carries on the conversation (a reply, thanks, a follow-up), not an opener
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _f(c: PersonCard, name: str):
|
| 48 |
+
return getattr(c.spatial, name) if c.spatial is not None else None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def history_signals(req: AddresseeRequest) -> tuple[str | None, str | None]:
|
| 52 |
+
"""(asker, partner) from the history text and the facts. The last line decides: an NPC's question to the player
|
| 53 |
+
makes them the asker; any line between the player and exactly one person makes that person the partner. A fact
|
| 54 |
+
on exactly one card (asked_you, last_spoke_with_you) counts the same when the history says nothing."""
|
| 55 |
+
asker = partner = None
|
| 56 |
+
if req.history:
|
| 57 |
+
last = req.history[-1]
|
| 58 |
+
if last.speaker != PLAYER and (last.to == [PLAYER] or (not last.to and len(req.present) == 1)):
|
| 59 |
+
partner = last.speaker
|
| 60 |
+
if last.text.rstrip().rstrip('"\'').endswith("?"):
|
| 61 |
+
asker = last.speaker
|
| 62 |
+
elif last.speaker == PLAYER and len(last.to) == 1:
|
| 63 |
+
partner = last.to[0]
|
| 64 |
+
if asker is None:
|
| 65 |
+
asked = [c.id for c in req.present if _f(c, "asked_you") is True]
|
| 66 |
+
asker = asked[0] if len(asked) == 1 else None
|
| 67 |
+
if partner is None:
|
| 68 |
+
spoke = [c.id for c in req.present if _f(c, "last_spoke_with_you") is True]
|
| 69 |
+
partner = spoke[0] if len(spoke) == 1 else None
|
| 70 |
+
return asker, partner
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _one(ids: list[str]) -> str | None:
|
| 74 |
+
return ids[0] if len(ids) == 1 else None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def spatial_signals(req: AddresseeRequest, among: list[str] | None = None) -> dict[str, str | None]:
|
| 78 |
+
"""The one person each spatial signal points to, or None. `among` limits the people considered."""
|
| 79 |
+
cards = [c for c in req.present if among is None or c.id in among]
|
| 80 |
+
near = [c.id for c in cards if _f(c, "distance") == "near"]
|
| 81 |
+
mid = [c.id for c in cards if _f(c, "distance") == "mid"]
|
| 82 |
+
return {
|
| 83 |
+
"gaze": _one([c.id for c in cards if _f(c, "in_view") is True and (among is not None or _f(c, "distance") == "near")]),
|
| 84 |
+
"group": _one([c.id for c in cards if _f(c, "in_group") is True]),
|
| 85 |
+
# A line said to no one in particular could be for anyone, unless only one person is within a moderate radius.
|
| 86 |
+
"nearby": _one(near) if near else _one(mid),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _ordered(req: AddresseeRequest, ids) -> list[str]:
|
| 91 |
+
ids = set(ids)
|
| 92 |
+
return [c.id for c in req.present if c.id in ids]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _agree(req: AddresseeRequest, signals: list[str | None], fallback: list[str]) -> AddresseeLabel:
|
| 96 |
+
pointed = _ordered(req, {s for s in signals if s is not None})
|
| 97 |
+
if len(pointed) == 1:
|
| 98 |
+
return AddresseeLabel(addressed=pointed)
|
| 99 |
+
if len(pointed) >= 2:
|
| 100 |
+
return AddresseeLabel(addressed=pointed, vague=True)
|
| 101 |
+
return AddresseeLabel(addressed=fallback, vague=True) if len(fallback) >= 2 else AddresseeLabel(addressed=fallback)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def resolve(req: AddresseeRequest, ev: Evidence) -> AddresseeLabel:
|
| 105 |
+
ids = req.ids
|
| 106 |
+
if len(ids) == 1: # rule 1
|
| 107 |
+
return AddresseeLabel(addressed=ids, group=ev.words_group)
|
| 108 |
+
if ev.words_group: # rule 2
|
| 109 |
+
grouped = [c.id for c in req.present if _f(c, "in_group") is True]
|
| 110 |
+
return AddresseeLabel(addressed=grouped or ids, group=True)
|
| 111 |
+
words = _ordered(req, ev.words)
|
| 112 |
+
if len(words) == 1 or (words and ev.words_set): # rule 3, clear
|
| 113 |
+
return AddresseeLabel(addressed=words)
|
| 114 |
+
asker, partner = history_signals(req)
|
| 115 |
+
if words: # rule 3, a description that fits several
|
| 116 |
+
sp = spatial_signals(req, among=words)
|
| 117 |
+
inside = [s if s in words else None for s in (asker, partner, sp["gaze"], sp["group"])]
|
| 118 |
+
return _agree(req, inside, words)
|
| 119 |
+
if asker is not None: # rule 4, a bare answer
|
| 120 |
+
return AddresseeLabel(addressed=[asker])
|
| 121 |
+
sp = spatial_signals(req)
|
| 122 |
+
if ev.benign: # rule 5, a simple harmless line
|
| 123 |
+
if ev.follows and partner is not None:
|
| 124 |
+
return AddresseeLabel(addressed=[partner])
|
| 125 |
+
rank = {"near": 0, "mid": 1, None: 2}
|
| 126 |
+
looked = [c for c in req.present if _f(c, "in_view") is True and _f(c, "distance") != "far"]
|
| 127 |
+
if looked:
|
| 128 |
+
best = min(rank[_f(c, "distance")] for c in looked)
|
| 129 |
+
nearest = [c.id for c in looked if rank[_f(c, "distance")] == best]
|
| 130 |
+
if len(nearest) == 1:
|
| 131 |
+
return AddresseeLabel(addressed=nearest)
|
| 132 |
+
for who in (partner, sp["group"], sp["nearby"]):
|
| 133 |
+
if who is not None:
|
| 134 |
+
return AddresseeLabel(addressed=[who])
|
| 135 |
+
grouped = [c.id for c in req.present if _f(c, "in_group") is True]
|
| 136 |
+
return AddresseeLabel(addressed=grouped or ids, group=True)
|
| 137 |
+
not_far = [c.id for c in req.present if _f(c, "distance") != "far"]
|
| 138 |
+
return _agree(req, [partner, sp["gaze"], sp["group"], sp["nearby"]], not_far if len(not_far) >= 2 else ids)
|
runtime/harness/addressee/features.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inputs for the cached-cards addressee model (`TT_VERSION`). Shared by training and the runtime.
|
| 2 |
+
|
| 3 |
+
The single-pass model reads every card with every line (harness/addressee/render.py), which cost 18 to 65 ms on our
|
| 4 |
+
test Mac for one to eight people. This model splits the work:
|
| 5 |
+
|
| 6 |
+
- A card's text, without its spatial facts, is encoded once and cached. It changes only when what the player can see
|
| 7 |
+
of that person changes (an item picked up, a hood raised, walking over to the fire).
|
| 8 |
+
- The player's line is encoded on every utterance, with the line before it as context: a few dozen tokens.
|
| 9 |
+
- The spatial facts and the conversation state become a small vector per person, so moving people around never
|
| 10 |
+
re-encodes anything.
|
| 11 |
+
|
| 12 |
+
Per-person vector (PERSON_DIM): distance near, mid, far, unknown; in view yes, no, unknown; in group yes, no, unknown;
|
| 13 |
+
following; asked you (fact); spoke with you last (fact); the last history line was theirs to the player; it was a
|
| 14 |
+
question; the player's last line was to them; they spoke at all in the history; the player spoke to them at all;
|
| 15 |
+
one over the number of people present; then the rules baseline's word matcher and answer: their name or alias used
|
| 16 |
+
to call them, only mentioned, their best feature match (share of three words), they have the top match, pointed at by
|
| 17 |
+
who they are with, the rules' probability for them.
|
| 18 |
+
Line vector (LINE_DIM): number of people present (one-hot, 1 to 8), any spatial fact known, any history; group words,
|
| 19 |
+
pair words ("you two"), the rules call the line unclear, the rules call it a group line; a simple harmless line and an
|
| 20 |
+
opener by the word lists (rule 5 in harness/addressee/evidence.py).
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from contracts.schemas.addressee import PLAYER, AddresseeRequest
|
| 25 |
+
from contracts.schemas.person_card import PersonCard
|
| 26 |
+
from harness.addressee.render import card_text
|
| 27 |
+
|
| 28 |
+
TT_VERSION = "addressee-tt-0.5"
|
| 29 |
+
PERSON_DIM = 25
|
| 30 |
+
LINE_DIM = 16
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def card_only(c: PersonCard) -> str:
|
| 34 |
+
"""The cached part of a card: what the player can see, without where the person is."""
|
| 35 |
+
return card_text(c.without_spatial())
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def line_input(req: AddresseeRequest) -> tuple[str, int]:
|
| 39 |
+
"""The text the line encoder reads and the character length of the current line within it. Names of earlier
|
| 40 |
+
speakers are left out: who said what is in the per-person vector, so a name in the history is never mistaken for
|
| 41 |
+
a name in the line."""
|
| 42 |
+
cur = f"\"{req.text}\""
|
| 43 |
+
if req.history:
|
| 44 |
+
return cur + f" Before: \"{req.history[-1].text}\"", len(cur)
|
| 45 |
+
return cur, len(cur)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _three(v: bool | None) -> list[float]:
|
| 49 |
+
return [1.0 if v is True else 0.0, 1.0 if v is False else 0.0, 1.0 if v is None else 0.0]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def person_features(req: AddresseeRequest) -> list[list[float]]:
|
| 53 |
+
from harness.addressee.rules import match_signals, rules_answer
|
| 54 |
+
|
| 55 |
+
per, _ = match_signals(req)
|
| 56 |
+
rules = rules_answer(req).addressed
|
| 57 |
+
last = req.history[-1] if req.history else None
|
| 58 |
+
player_last = next((h for h in reversed(req.history) if h.speaker == PLAYER), None)
|
| 59 |
+
n = len(req.present)
|
| 60 |
+
out = []
|
| 61 |
+
for c in req.present:
|
| 62 |
+
s = c.spatial
|
| 63 |
+
d = s.distance if s else None
|
| 64 |
+
v = [1.0 if d == "near" else 0.0, 1.0 if d == "mid" else 0.0, 1.0 if d == "far" else 0.0, 1.0 if d is None else 0.0]
|
| 65 |
+
v += _three(s.in_view if s else None) + _three(s.in_group if s else None)
|
| 66 |
+
v += [float(bool(s and s.following)), float(bool(s and s.asked_you)), float(bool(s and s.last_spoke_with_you))]
|
| 67 |
+
theirs = last is not None and last.speaker == c.id and (PLAYER in last.to or not last.to)
|
| 68 |
+
v += [float(theirs), float(theirs and last.text.rstrip().rstrip('"\'').endswith("?")),
|
| 69 |
+
float(player_last is not None and player_last.to == [c.id]),
|
| 70 |
+
float(any(h.speaker == c.id for h in req.history)),
|
| 71 |
+
float(any(h.speaker == PLAYER and c.id in h.to for h in req.history)),
|
| 72 |
+
1.0 / n]
|
| 73 |
+
v += [*per[len(out)], rules[c.id]]
|
| 74 |
+
out.append(v)
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def line_features(req: AddresseeRequest) -> list[float]:
|
| 79 |
+
from harness.addressee.rules import match_signals, rules_answer
|
| 80 |
+
|
| 81 |
+
n = min(len(req.present), 8)
|
| 82 |
+
_, (group_words, pair_words, simple, opener) = match_signals(req)
|
| 83 |
+
ra = rules_answer(req)
|
| 84 |
+
return [1.0 if i == n else 0.0 for i in range(1, 9)] + [float(req.has_spatial), float(bool(req.history)),
|
| 85 |
+
group_words, pair_words, ra.unclear, ra.to_group, simple, opener]
|
runtime/harness/addressee/render.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""How the addressee model reads a request (render addressee-input-0.1). Shared by training and the runtime.
|
| 2 |
+
|
| 3 |
+
Everyone present comes first, one bracketed marker each holding what the player can see of them and, when the game
|
| 4 |
+
knows, where they are. Then the player's line. Then the history, newest first, so truncation cuts the oldest line
|
| 5 |
+
and never a person or the line itself:
|
| 6 |
+
|
| 7 |
+
People: [Tomas (the barkeep). role: barkeep; headwear: red hat; holding: a tankard. near you, in view, spoke with
|
| 8 |
+
you last] [Wren. role: mercenary; hair: hidden. mid-range, out of view]. The player: "You in the red hat, a word."
|
| 9 |
+
Before: Tomas to the player: "Another round?" | The player to Tomas: "Go on then."
|
| 10 |
+
|
| 11 |
+
A person's head reads the tokens of their marker, brackets included. Features keep the caller's order; a card over
|
| 12 |
+
`MAX_CARD_CHARS` loses features from the end, so put the most telling features first.
|
| 13 |
+
Spatial facts are never cut. Unknown facts are left out; known negatives that matter are said ("out of view").
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from contracts.schemas.addressee import PLAYER, AddresseeRequest, HistoryLine
|
| 18 |
+
from contracts.schemas.person_card import PersonCard, SpatialFacts
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def span_mask(offsets: list[tuple[int, int]], spans: list[tuple[int, int]]) -> list[list[float]]:
|
| 22 |
+
"""Per target, which tokens belong to its marker (special tokens have empty offsets and never do). The same mask as
|
| 23 |
+
the line classifier's (harness/expression/render.py), kept here so this runtime stands alone."""
|
| 24 |
+
rows = []
|
| 25 |
+
for a, b in spans:
|
| 26 |
+
row = [1.0 if (t > s and s >= a and t <= b) else 0.0 for s, t in offsets]
|
| 27 |
+
if not any(row):
|
| 28 |
+
row[0] = 1.0
|
| 29 |
+
rows.append(row)
|
| 30 |
+
return rows
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
RENDER_VERSION = "addressee-input-0.1"
|
| 34 |
+
MAX_CARD_CHARS = 240
|
| 35 |
+
|
| 36 |
+
DISTANCE_WORDS = {"near": "near you", "mid": "mid-range", "far": "far away"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def spatial_words(s: SpatialFacts | None) -> list[str]:
|
| 40 |
+
if s is None:
|
| 41 |
+
return []
|
| 42 |
+
out = []
|
| 43 |
+
if s.distance is not None:
|
| 44 |
+
out.append(DISTANCE_WORDS[s.distance])
|
| 45 |
+
if s.in_view is not None:
|
| 46 |
+
out.append("in view" if s.in_view else "out of view")
|
| 47 |
+
if s.in_group is not None:
|
| 48 |
+
out.append("in your group" if s.in_group else "not in your group")
|
| 49 |
+
if s.following:
|
| 50 |
+
out.append("following you")
|
| 51 |
+
if s.asked_you:
|
| 52 |
+
out.append("asked you something")
|
| 53 |
+
if s.last_spoke_with_you:
|
| 54 |
+
out.append("spoke with you last")
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def card_text(c: PersonCard, max_chars: int = MAX_CARD_CHARS) -> str:
|
| 59 |
+
"""The inside of one person's marker."""
|
| 60 |
+
head = c.label + (f" ({', '.join(c.aliases)})" if c.aliases else "")
|
| 61 |
+
tail = ", ".join(spatial_words(c.spatial))
|
| 62 |
+
budget = max_chars - len(head) - (len(tail) + 2 if tail else 0)
|
| 63 |
+
feats: list[str] = []
|
| 64 |
+
used = 0
|
| 65 |
+
for f in c.features:
|
| 66 |
+
piece = f"{f.key}: {f.value}"
|
| 67 |
+
if used + len(piece) + 2 > budget:
|
| 68 |
+
break
|
| 69 |
+
feats.append(piece)
|
| 70 |
+
used += len(piece) + 2
|
| 71 |
+
text = head + (". " + "; ".join(feats) if feats else "")
|
| 72 |
+
return text + (". " + tail if tail else "")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def who(req: AddresseeRequest, pid: str) -> str:
|
| 76 |
+
return "the player" if pid == PLAYER else req.card(pid).label
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def history_text(req: AddresseeRequest, h: HistoryLine) -> str:
|
| 80 |
+
to = " and ".join(who(req, t) for t in h.to)
|
| 81 |
+
speaker = who(req, h.speaker)
|
| 82 |
+
speaker = speaker[0].upper() + speaker[1:]
|
| 83 |
+
return f"{speaker}{' to ' + to if to else ''}: \"{h.text}\""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def render(req: AddresseeRequest, order: list[str] | None = None) -> tuple[str, list[tuple[int, int]]]:
|
| 87 |
+
"""The input text and, per person in `req.present` order, the character span of their marker (brackets
|
| 88 |
+
included). `order` is the order the people are written in (training shuffles it); default: as given."""
|
| 89 |
+
order = order or req.ids
|
| 90 |
+
if sorted(order) != sorted(req.ids):
|
| 91 |
+
raise ValueError("order must be a permutation of the people present")
|
| 92 |
+
text, at = "People:", {}
|
| 93 |
+
for pid in order:
|
| 94 |
+
start = len(text) + 1
|
| 95 |
+
text += f" [{card_text(req.card(pid))}]"
|
| 96 |
+
at[pid] = (start, len(text))
|
| 97 |
+
text += f". The player: \"{req.text}\""
|
| 98 |
+
if req.history:
|
| 99 |
+
text += " Before: " + " | ".join(history_text(req, h) for h in reversed(req.history))
|
| 100 |
+
return text, [at[pid] for pid in req.ids]
|
runtime/harness/addressee/rules.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The rules baseline: word matching plus the rules of evidence (harness/addressee/evidence.py). No model.
|
| 2 |
+
|
| 3 |
+
It is the floor every model is compared against, the scene demo's first engine, and the harness's instant fallback.
|
| 4 |
+
What the words pick out is guessed from the text:
|
| 5 |
+
|
| 6 |
+
1. Names: a person's label or alias used to call them: at the start, after a greeting or "hey", or set off by commas
|
| 7 |
+
("Tomas, another round", "Oi, barkeep!"). A name the line only talks about ("tell Wren", "talking to the mechanic")
|
| 8 |
+
is not a call. Two or more calls are a set named on purpose ("Tomas, Wren, a word").
|
| 9 |
+
1b. Neighbours: "you, talking to the barkeep" points at whoever's card says they are with the barkeep.
|
| 10 |
+
2. Group words with no name: "everyone", "anyone", "someone", "all of you", "you guys", "you lot", "listen up". "You
|
| 11 |
+
two" and "both of you" are the two people present when there are two, otherwise the group (rule 2).
|
| 12 |
+
3. Descriptions: content words of each visible feature value found in the line ("red hat" in "you in the red hat").
|
| 13 |
+
The person or people with the most matching words are picked out. A word matches a longer one it starts
|
| 14 |
+
("fire" and "fireplace") when it has four letters or more.
|
| 15 |
+
|
| 16 |
+
Then `evidence.resolve` applies the rules of evidence with the history and the spatial facts. Known limits: no fuzzy
|
| 17 |
+
match for misheard names, and any shared content word counts as a description, even an incidental one.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
|
| 23 |
+
from contracts.schemas.addressee import AddresseeAnswer, AddresseeLabel, AddresseeRequest
|
| 24 |
+
from harness.addressee.evidence import Evidence, resolve
|
| 25 |
+
|
| 26 |
+
STOP = frozenset("""a an the of and or to in on at by with from for as is are was were be been it its this that these those
|
| 27 |
+
there here you your yours you're ya ye u i me my mine we us our he him his she her hers they them their one ones who what
|
| 28 |
+
which where when how why do does did not no yes so but if then than just very too some any all also over up down out
|
| 29 |
+
into onto off about around near next behind front back side left right old new big small little bit lot""".split())
|
| 30 |
+
# Words that, with no name in the line, address the whole group.
|
| 31 |
+
GROUP = re.compile(r"\b(everyone|everybody|anyone|anybody|someone|somebody|all of you|you all|y'?all|you lot|you people|"
|
| 32 |
+
r"you guys|guys|folks|lads|gents|listen up|listen to me|gather round|(morning|evening|afternoon|hello|hi|hey|night),? all)\b", re.I)
|
| 33 |
+
PAIR = re.compile(r"\b(you two|both of you|you both|the pair of you|the two of you)\b", re.I)
|
| 34 |
+
# Simple, harmless lines (rule 5): greetings, small talk, simple questions, thanks, polite requests.
|
| 35 |
+
SIMPLE = re.compile(r"\b(hello|hi|hiya|hey there|hey|howdy|greetings|good (morning|afternoon|evening|day|night)|morning|evening|"
|
| 36 |
+
r"afternoon|well met|ahoy|yo|g'?day|what'?s up|who are you|what'?s your name|what is your name|who'?s this|"
|
| 37 |
+
r"what is this place|what'?s this place|where am i|where'?s|where is|what do you (do|sell|have|want|need)|how much|"
|
| 38 |
+
r"can you help|could you help|can i ask|excuse me|pardon|how are you|how'?s it going|how do you do|"
|
| 39 |
+
r"(nice|pleased|good) to meet|what'?s going on|what happened|any news|what'?s new|got a (minute|moment|sec)|"
|
| 40 |
+
r"do you know|have you seen|can i (buy|get|have)|i'?d like|thanks|thank you|cheers|much obliged|goodbye|bye|"
|
| 41 |
+
r"see you|farewell|take care|nice (weather|day|place)|(is|are) \w+ (around|here|about|in))\b", re.I)
|
| 42 |
+
# Words that give a line consequences (rule 4): insults, threats, accusations, orders to go away.
|
| 43 |
+
HOSTILE = re.compile(r"\b(idiot|moron|fool|stupid|dumb|useless|worthless|pathetic|scum|bastard|damn|shut up|get out|go away|"
|
| 44 |
+
r"piss|crap|ugly|loser|coward|liar|lied|lying|thief|stole|steal|kill|gut|hurt|smash|break your|fuck|shit|"
|
| 45 |
+
r"wanker|git|prat|twit|clown|filthy|disgusting|stink|lazy|move it|out of my way|hate|rat|pig|wretch)\b", re.I)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Lines that open an exchange rather than carry one on (rule 5): greetings and introductions.
|
| 49 |
+
OPENER = re.compile(r"\b(hello|hi|hiya|hey there|hey|howdy|greetings|good (morning|afternoon|evening|day)|well met|ahoy|g'?day|"
|
| 50 |
+
r"who are you|who'?s this|what'?s your name|what is your name|(nice|pleased|good) to meet|excuse me|pardon me|"
|
| 51 |
+
r"sorry to bother|got a (minute|moment|sec)|have you got a (minute|moment))\b", re.I)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def is_opener(text: str) -> bool:
|
| 55 |
+
"""A line that opens an exchange (a greeting, "who are you?"), not one that carries the conversation on."""
|
| 56 |
+
return bool(OPENER.search(text))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def is_simple(text: str) -> bool:
|
| 60 |
+
"""A simple, harmless line (rule 5): simple words or a short question, and nothing hostile."""
|
| 61 |
+
if HOSTILE.search(text):
|
| 62 |
+
return False
|
| 63 |
+
short_question = text.rstrip().endswith("?") and len(_words(text)) <= 8
|
| 64 |
+
return bool(SIMPLE.search(text)) or short_question
|
| 65 |
+
WORD = re.compile(r"[a-z0-9']+")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _words(text: str) -> list[str]:
|
| 69 |
+
return [w.strip("'") for w in WORD.findall(text.lower()) if w.strip("'")]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _content(text: str) -> set[str]:
|
| 73 |
+
return {w for w in _words(text) if w not in STOP and len(w) > 1}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _match(a: str, b: str) -> bool:
|
| 77 |
+
if a == b:
|
| 78 |
+
return True
|
| 79 |
+
short, long_ = sorted((a, b), key=len)
|
| 80 |
+
return len(short) >= 4 and long_.startswith(short)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _name_hit(name: str, line_words: list[str]) -> bool:
|
| 84 |
+
parts = _words(name)
|
| 85 |
+
if parts and parts[0] == "the" and len(parts) > 1:
|
| 86 |
+
parts = parts[1:]
|
| 87 |
+
n = len(parts)
|
| 88 |
+
return n > 0 and any(line_words[i:i + n] == parts for i in range(len(line_words) - n + 1))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# Words that may come before a name that calls someone ("Hey, Tomas", "Excuse me Tomas", "Good morning, Tomas").
|
| 92 |
+
LEAD = frozenset("""hey oi hi hello excuse me sorry right ok okay well listen look yo ah um uh so and now please thanks thank you
|
| 93 |
+
dear good morning evening afternoon night sir madam ma'am miss mister oh""".split())
|
| 94 |
+
# Phrases that point at someone by who they are with ("you, talking to the mechanic").
|
| 95 |
+
NEIGHBOUR = re.compile(r"\b(talking to|talking with|chatting with|chatting to|speaking to|speaking with|sitting with|standing with|"
|
| 96 |
+
r"next to|beside|with)\s+(the\s+)?$", re.I)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def name_use(name: str, text: str) -> str | None:
|
| 100 |
+
"""How the line uses a name: "call" when it calls that person (at the start, after a greeting or "hey", or set
|
| 101 |
+
off by commas or at the end after a comma), "mention" when it only talks about them ("tell Wren", "talking to the
|
| 102 |
+
mechanic"), None when the name is absent (rule 3)."""
|
| 103 |
+
parts = _words(name)
|
| 104 |
+
if parts and parts[0] == "the" and len(parts) > 1:
|
| 105 |
+
parts = parts[1:]
|
| 106 |
+
if not parts:
|
| 107 |
+
return None
|
| 108 |
+
pattern = re.compile(r"\b" + r"\W+".join(re.escape(x) for x in parts) + r"\b", re.I)
|
| 109 |
+
use = None
|
| 110 |
+
for m in pattern.finditer(text):
|
| 111 |
+
before, after = text[:m.start()], text[m.end():]
|
| 112 |
+
b = before.rstrip()
|
| 113 |
+
b = re.sub(r"\bthe$", "", b, flags=re.I).rstrip() if re.search(r"\bthe$", b, re.I) else b
|
| 114 |
+
clause = re.split(r"[,.!?;:]", b)[-1] # only the words since the last comma or full stop count as a lead-in
|
| 115 |
+
lead = all(w in LEAD for w in _words(clause))
|
| 116 |
+
opens = (not b) or b[-1] in ",!.?:;" or lead
|
| 117 |
+
closes = (not after.strip()) or after.lstrip()[:1] in ",!.?:;"
|
| 118 |
+
if NEIGHBOUR.search(before):
|
| 119 |
+
use = use or "mention"
|
| 120 |
+
elif opens and (closes or lead or not b):
|
| 121 |
+
return "call"
|
| 122 |
+
else:
|
| 123 |
+
use = "mention"
|
| 124 |
+
return use
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _neighbour_of(c, req: AddresseeRequest, text: str) -> bool:
|
| 128 |
+
"""The line points at `c` by who they are with: "talking to the barkeep" and c's card says they are talking to
|
| 129 |
+
Tomas, whose alias is the barkeep."""
|
| 130 |
+
withs = [f.value for f in c.features if f.key.lower() == "with" and f.visible]
|
| 131 |
+
if not withs:
|
| 132 |
+
return False
|
| 133 |
+
for other in req.present:
|
| 134 |
+
if other.id == c.id:
|
| 135 |
+
continue
|
| 136 |
+
if any(any(_name_hit(nm, _words(w)) for nm in other.names) for w in withs):
|
| 137 |
+
for nm in other.names:
|
| 138 |
+
parts = _words(nm)
|
| 139 |
+
if parts and parts[0] == "the" and len(parts) > 1:
|
| 140 |
+
parts = parts[1:]
|
| 141 |
+
m = re.search(r"\b" + r"\W+".join(re.escape(x) for x in parts) + r"\b", text, re.I) if parts else None
|
| 142 |
+
if m and NEIGHBOUR.search(text[:m.start()]):
|
| 143 |
+
return True
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def words_evidence(req: AddresseeRequest) -> Evidence:
|
| 148 |
+
line = _words(req.text)
|
| 149 |
+
benign = is_simple(req.text)
|
| 150 |
+
follows = benign and not is_opener(req.text)
|
| 151 |
+
named = [c.id for c in req.present if any(name_use(n, req.text) == "call" for n in c.names)]
|
| 152 |
+
if named:
|
| 153 |
+
return Evidence(words=frozenset(named), words_set=len(named) > 1, benign=benign, follows=follows)
|
| 154 |
+
neighbours = [c.id for c in req.present if _neighbour_of(c, req, req.text)]
|
| 155 |
+
if len(neighbours) == 1:
|
| 156 |
+
return Evidence(words=frozenset(neighbours), benign=benign, follows=follows)
|
| 157 |
+
if PAIR.search(req.text):
|
| 158 |
+
if len(req.present) == 2:
|
| 159 |
+
return Evidence(words=frozenset(req.ids), words_set=True, benign=benign, follows=follows)
|
| 160 |
+
return Evidence(words_group=True, benign=benign, follows=follows)
|
| 161 |
+
if GROUP.search(req.text):
|
| 162 |
+
return Evidence(words_group=True, benign=benign, follows=follows)
|
| 163 |
+
line_content = {w for w in line if w not in STOP}
|
| 164 |
+
scores = {}
|
| 165 |
+
for c in req.present:
|
| 166 |
+
best = 0
|
| 167 |
+
for f in c.features:
|
| 168 |
+
if not f.visible:
|
| 169 |
+
continue
|
| 170 |
+
best = max(best, sum(1 for w in _content(f.value) if any(_match(w, lw) for lw in line_content)))
|
| 171 |
+
scores[c.id] = best
|
| 172 |
+
top = max(scores.values())
|
| 173 |
+
if top == 0:
|
| 174 |
+
return Evidence(benign=benign, follows=follows)
|
| 175 |
+
return Evidence(words=frozenset(i for i, s in scores.items() if s == top), benign=benign, follows=follows)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def rules_label(req: AddresseeRequest) -> AddresseeLabel:
|
| 179 |
+
return resolve(req, words_evidence(req))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def answer_from_label(req: AddresseeRequest, label: AddresseeLabel) -> AddresseeAnswer:
|
| 183 |
+
"""A label as an answer: certainty for a clear line, an even split among the candidates for a vague one."""
|
| 184 |
+
return AddresseeAnswer(addressed=label.targets(req.ids), unclear=1.0 if label.vague else 0.0,
|
| 185 |
+
to_group=1.0 if label.group else 0.0)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def rules_answer(req: AddresseeRequest) -> AddresseeAnswer:
|
| 189 |
+
return answer_from_label(req, rules_label(req))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def match_signals(req: AddresseeRequest) -> tuple[list[tuple[float, ...]], tuple[float, float, float, float]]:
|
| 193 |
+
"""The word matcher's raw signals, for the model to weigh: per person (name or alias used to call
|
| 194 |
+
them, only mentioned, best feature match as a share of three words, has the top feature match, pointed at by who
|
| 195 |
+
they are with), and for the line (group words, pair words, a simple harmless line, an opener)."""
|
| 196 |
+
line = _words(req.text)
|
| 197 |
+
line_content = {w for w in line if w not in STOP}
|
| 198 |
+
names, mentions, desc = [], [], []
|
| 199 |
+
for c in req.present:
|
| 200 |
+
uses = {name_use(n, req.text) for n in c.names}
|
| 201 |
+
names.append(1.0 if "call" in uses else 0.0)
|
| 202 |
+
mentions.append(1.0 if "mention" in uses and "call" not in uses else 0.0)
|
| 203 |
+
best = 0
|
| 204 |
+
for f in c.features:
|
| 205 |
+
if f.visible:
|
| 206 |
+
best = max(best, sum(1 for w in _content(f.value) if any(_match(w, lw) for lw in line_content)))
|
| 207 |
+
desc.append(best)
|
| 208 |
+
top = max(desc) if desc else 0
|
| 209 |
+
per = [(n, m, min(d, 3) / 3.0, 1.0 if d == top and d > 0 else 0.0, 1.0 if _neighbour_of(c, req, req.text) else 0.0)
|
| 210 |
+
for n, m, d, c in zip(names, mentions, desc, req.present)]
|
| 211 |
+
return per, (1.0 if GROUP.search(req.text) else 0.0, 1.0 if PAIR.search(req.text) else 0.0, 1.0 if is_simple(req.text) else 0.0,
|
| 212 |
+
1.0 if is_opener(req.text) else 0.0)
|
tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|