MakeItMore commited on
Commit
c38bcf8
·
0 Parent(s):

SpellSpeak Audience rc1

Browse files
.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