File size: 13,931 Bytes
4b1014f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3956325
4b1014f
257f5bd
 
 
4b1014f
1873a1b
 
 
 
 
257f5bd
4b1014f
257f5bd
 
 
6710b04
257f5bd
 
6710b04
257f5bd
4b1014f
257f5bd
 
 
 
4b1014f
1dd7c3e
 
 
 
 
 
 
b6b9678
 
 
4b1014f
 
257f5bd
 
 
4b1014f
 
50e4585
 
b6b9678
1dd7c3e
 
 
 
 
 
 
 
 
 
 
 
 
1b2a2fb
 
 
1dd7c3e
 
 
 
b6b9678
1dd7c3e
 
 
50e4585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b2a2fb
 
50e4585
 
 
 
 
 
 
1873a1b
50e4585
 
 
 
 
 
 
 
 
 
 
 
1dd7c3e
 
 
 
 
 
50e4585
 
 
 
 
 
1dd7c3e
 
 
 
 
4b1014f
257f5bd
4b1014f
1dd7c3e
 
 
257f5bd
1dd7c3e
 
257f5bd
1dd7c3e
 
 
257f5bd
 
1dd7c3e
 
 
 
 
 
 
 
1b2a2fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257f5bd
 
1873a1b
 
1dd7c3e
1873a1b
 
 
 
 
 
 
1dd7c3e
 
 
 
 
 
1b2a2fb
 
 
 
 
 
257f5bd
 
 
 
 
 
 
 
 
1873a1b
 
 
 
 
 
 
 
 
 
257f5bd
 
4b1014f
 
 
257f5bd
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
---
license: apache-2.0
language:
- en
base_model: distilbert-base-uncased
pipeline_tag: text-classification
tags:
- topos
- scope-routing
- multi-label
- on-device
- privacy
datasets:
- AmazonScience/massive
- clinc/oos-eval
---

# Horos

**The boundary stone for personal data.** A 268 MB on-device router for
[Topos](https://github.com/dialoguesai/topos): free text in β†’ the data scopes it touches,
an explicit `none`, or a hand-off to an LLM when it isn't sure.

**v2** (2026-08-16) β€” macro-F1 **0.613**, up from v1's 0.512. Trained against a gap v1's
own failures exposed: it handled *band* phrasings ("how has my sleep been") and fell
silent on *artifact* ones ("what's my resting heart rate trend"). Still not promoted β€”
see Limitations.

![How Horos routes a question](routing.png)

*ὅρος β€” the stones Athenians planted at property lines: "whose land begins here." Same
job, for your data. Horos runs before the permission gate, so the raw question never
leaves the device.*

**Router, not authorizer.** Every scope it emits is still permission-checked downstream.
A node without Horos escalates more often; it does not become less safe.

## Use

The `scope` role in a Topos model pack. Load through `topos.query.scope_head.load_head`
β€” it validates the label set against the live scope registry and the training manifest
against the licence policy before touching a weight. Not intended for any task besides
Topos scope routing, and not intended to run without the escalation path.

**Pin a revision.** The thresholds in `head.json` are selected per-artifact and the label
set can change; `main` is not a stable contract. Topos pins the SHA it was gated against:

```python
snapshot_download("Dialogues/horos", revision="1873a1b667dc2d1bd630835f1f4f47025f3864d9")
```

**The ladder.** Four branches, because "no scopes" is two different states β€” the model
deciding there is no personal data here, and the model having no idea:

```
labels β‰₯ tau_high, none low, nothing in band β†’ ACT on the scope set (1..N scopes)
high none, nothing else                      β†’ abstain: no personal data
any label in [tau_low, tau_high)             β†’ escalate to the pack's LLM (ambiguity)
nothing anywhere, none included              β†’ escalate to the pack's LLM (ignorance)
```

## Report card

Every release publishes all five blocks below, in full, **including the numbers that got
worse** β€” this is a standing contract, not a summary of a good run. Every row is versioned
and every cross-arm claim carries an interval. Each block earns its slot by having caught
something the others hid:

* **1 β€” the gate.** `disjoint` sat at 0.206–0.265 across three training rounds while
  macro-F1 climbed 20%. An average would have called that pure progress.
* **2 β€” the split.** Artifact-concrete swallowing was 26% while the overall dead rate
  looked healthy at 0.171. The total hid a defect the split names.
* **3 β€” unseen data.** `wrong` rose 11% β†’ 17% in the same round routing improved 14
  points. Nothing else here can see a model converting silence into error.
* **4 β€” composed.** Horos never runs alone; a head-only number is not a product number.
  It is also where an unqualified 0.500-vs-0.495 claim went out on this card and turned
  out to be inside the noise (see block 4).
* **5 β€” per-scope.** Every other block is an average or a count, and both let a gain on
  one scope pay for a regression on another without saying so. Only this view names the
  scope that got worse.

`swallowed` and `escalated` stay separate throughout: they have opposite fixes, and
collapsing them into "not answered" hides which one you have.

Blocks 1–3 and 5 regenerate from one command in `topos-eval`
(`scripts/scope_head_report_card.py --head A --label v1 --head B --label v2`). That
repo is private, so the numbers here are auditable by us and take-it-or-leave-it for
you β€” the benchmark itself is what we'd have to open to change that.

### 1. `role_classify_8` β€” the promotion gate

1,071 cases, template-disjoint from training, multi-label gold, 24% adversarial
negatives. Built to be hard: don't anchor against CLINC-style intent scores. The right
column is the same benchmark's LLM baseline.

| metric | v2 | v1 | mistral:7b (4.4 GB) | gate |
|---|---|---|---|---|
| macro-F1 | **0.613** | 0.512 | 0.495 | β‰₯ incumbent βœ… |
| exact set match | **0.558** | 0.496 | 0.243 | β€” |
| negatives abstained | 0.941 | 0.984 | 0.126 | β‰₯0.85 βœ… |
| single / multi-gold recall | 0.502 / 0.596 | 0.388 / 0.425 | β€” | gap ≀0.05 βœ… |
| dead rate | **0.149** | 0.171 | β€” | <0.20 βœ… |
| **disjoint rate** | 0.206 | 0.217 | β€” | ≀0.03 ❌ |
| per-scope recall β‰₯ 0.60 | 6 / 14 | 2 / 14 | 9 / 14 | 14/14 ❌ |

Which six, and which scope went backwards: block 5.

### 2. Real language β€” a gap finder, not a score

53 hand-annotated natural phrasings neither version trained on. The labels are one
annotator's judgement, so the totals measure agreement-with-the-annotator; the **split**
is the signal worth acting on.

| metric | v2 | v1 | gate |
|---|---|---|---|
| correct | **0.774** | 0.585 | β€” |
| swallowed | **0.132** | 0.189 | β€” |
| escalated | 0.094 | 0.189 | β€” |
| **swallowed β€” artifact-concrete** | 0.176 | 0.265 | ≀0.10 ❌ |
| swallowed β€” abstract / band | 0.053 | 0.053 | β€” |

### 3. Unseen phrasings β€” did the authoring generalise?

2,745 rows from the schema-grounded generator's heldout half, which the training corpus
never draws from. Provably unseen by both versions and absent from the benchmark. This
block separates *learning a register* from *memorising the rows we wrote*.

| metric | v2 | v1 | ratchet |
|---|---|---|---|
| routed | **0.565** | 0.421 | β‰₯ incumbent βœ… |
| swallowed | **0.172** | 0.214 | ≀ incumbent βœ… |
| **wrong scope** | 0.169 | 0.113 | ≀ incumbent ❌ |
| escalated | 0.094 | 0.252 | β€” |

**Read the `wrong` row, not just the first one.** v2 routes 14 points more of this
traffic and is confidently wrong 6 points more often. It got better at recognising that
a question concerns personal data, and no better at discriminating *which* data β€” the
same finding as the flat `disjoint` rate. Those are separate abilities; one improved.

### 4. Composed with its LLM escalation

Horos is not deployed alone, so this is the number that describes the product. Each case
is routed by the ladder above: Horos answers when it acts or abstains, the LLM sees only
what it escalates.

| arm | macro-F1 | exact | neg-abstain | wrong-scope | LLM sees |
|---|---|---|---|---|---|
| mistral:7b (4.4 GB) alone | 0.495 | 0.243 | 0.126 | 0.237 | 100% |
| Horos **v1** + mistral:7b | 0.550 | 0.524 | 0.972 | 0.143 | 16.4% |
| **Horos v2 + mistral:7b** | **0.620** | **0.578** | 0.909 | 0.163 | **15.3%** |
| llama3.2 (2 GB) alone | 0.376 | 0.261 | 0.510 | 0.204 | 100% |
| Horos **v1** + llama3.2 | 0.500 | 0.508 | 0.976 | 0.146 | 16.4% |
| **Horos v2 + llama3.2** | **0.583** | **0.565** | 0.917 | 0.146 | **15.3%** |

**A 2 GB machine running the hybrid beats a 4.4 GB machine running LLM-only** β€” 0.583 vs
0.495, **+0.088, 95% CI [+0.052, +0.123]** (paired bootstrap, 2,000 resamples of the same
1,071 cases). Horos is identical on every hardware tier, so only the escalated ~1/6th of
traffic degrades with weaker hardware.

That claim did not survive this test at v1, where the same comparison was +0.005, CI
[βˆ’0.036, +0.042] β€” indistinguishable from zero. It was stated on this card as fact for
one release. Cross-arm claims here now carry an interval or they don't get made.

Note the direction of the v1 β†’ v2 trade in this table: composed accuracy up ~7 points,
composed negatives-abstained down ~6. The escalation path was covering for the head's
false-positives, and v2 hands it less to cover.

### 5. Per-scope β€” where it moved, and where it didn't

Blocks 1 and 3 report counts ("6 / 14 above the floor", "routed 0.565"). A count cannot
be audited: it says how many scopes cleared the bar and never which, so a large gain on
one scope silently pays for a regression on another. Both slices, sorted by movement.

**Unseen phrasings** β€” did the gains reach language nobody wrote down?

| scope | n | v1 | v2 | Ξ” |
|---|---|---|---|---|
| `places` | 217 | 30% | 68% | **+37** |
| `schedule` | 65 | 31% | 65% | **+34** |
| `work_context` | 420 | 22% | 51% | **+29** |
| `relationship_context` | 174 | 16% | 42% | **+26** |
| `public_bio` | 229 | 67% | 85% | +17 |
| `messages` | 253 | 49% | 66% | +17 |
| `attention` | 145 | 35% | 46% | +10 |
| `complexity` | 178 | 39% | 47% | +8 |
| `activity` | 308 | 61% | 68% | +7 |
| `resources` | 151 | 28% | 34% | +6 |
| `availability` | 116 | 21% | 25% | +4 |
| `contacts` | 119 | 76% | 73% | βˆ’3 |
| `ai_conversations` | 111 | 42% | 39% | βˆ’4 |
| `health` | 259 | 61% | 56% | **βˆ’5** |

**Gate benchmark** β€” this is what "6 / 14 above the floor" expands to.

| scope | n | v1 | v2 | Ξ” | β‰₯0.60 |
|---|---|---|---|---|---|
| `contacts` | 58 | 69% | 83% | +14 | βœ… |
| `public_bio` | 54 | 67% | 81% | +15 | βœ… |
| `attention` | 73 | 25% | 71% | **+47** | βœ… |
| `schedule` | 59 | 44% | 69% | +25 | βœ… |
| `activity` | 78 | 58% | 64% | +6 | βœ… |
| `messages` | 61 | 51% | 64% | +13 | βœ… |
| `health` | 116 | 47% | 54% | +8 | ❌ |
| `places` | 61 | 26% | 49% | +23 | ❌ |
| `availability` | 62 | 35% | 45% | +10 | ❌ |
| `resources` | 70 | 27% | 37% | +10 | ❌ |
| `complexity` | 82 | 35% | 37% | +1 | ❌ |
| `ai_conversations` | 54 | 43% | 35% | **βˆ’7** | ❌ |
| `relationship_context` | 77 | 17% | 34% | +17 | ❌ |
| `work_context` | 73 | 25% | 30% | +5 | ❌ |

Three things only this view shows:

* **`ai_conversations` is down on both slices** (βˆ’7, βˆ’4) β€” the one unambiguous regression,
  not a slice artifact. v2's training targeted artifact-concrete phrasings, and questions
  about your own past AI conversations are the scope least like an artifact.
* **`health` and `contacts` flip sign between slices.** `health` gains 8 on the benchmark
  and loses 5 on unseen phrasings; `contacts` gains 14 and loses 3. The two instruments
  measure genuinely different things, and a card publishing only one of them would report
  either as a clean win.
* **The four biggest unseen gains are exactly the four scopes v2's authoring targeted**
  (`places`, `schedule`, `work_context`, `relationship_context`, +26 to +37). That is the
  evidence the authoring generalised rather than being memorised β€” the gains land on
  phrasings of those scopes that nobody wrote down.

Floors are still unmet: on the gate slice, 8 of 14 scopes sit under the 0.60 recall bar.
`work_context` (30%), `complexity` (37%) and `resources` (37%) are the weakest, and
`relationship_context` at 34% remains the hardest scope in the taxonomy.

## Limitations

* **Confident-none swallowing, now concentrated rather than general.** Overall dead rate
  is 0.149, but on *artifact-concrete* phrasings ("what's my bank balance", "what's in
  my review queue") it is **17.6%** against 5.3% on abstract ones. v2 closed most of v1's
  26% gap here and gave part of it back for the safety property below β€” one threshold
  currently trades them against each other, which is the next fix. Treat a `none` on a
  plausibly-personal question with suspicion.
* **Disjoint rate 0.206 β€” the blocking defect, and unsolved.** One in five acted-on
  turns names a scope set sharing nothing with the truth. It is **threshold-invariant**
  (0.256–0.270 across Ο„ 0.4–0.9 on a sibling run), so it lives in the weights, not the
  operating point. Three training rounds have not moved it.
* **v2 gained recognition, not discrimination.** It routes far more personal-data
  questions than v1 and is confidently wrong on a larger share of them (block 3: `wrong`
  0.113 β†’ 0.169, against a flat `disjoint`). Knowing a question is about your data and
  knowing *which* of your data are separate abilities; this round only advanced the
  first. The escalation path absorbs less of that than it used to β€” composed
  negatives-abstained fell 0.972 β†’ 0.909.
* **8 of 14 scopes are under the 0.60 recall floor** (v1: 12), weakest `work_context`
  30%, `relationship_context` 34%, `complexity` and `resources` 37%. This artifact has
  not cleared its promotion gate; it fronts an LLM in shadow/advisory postures only.
* **`ai_conversations` regressed on both slices** (βˆ’7 gate, βˆ’4 unseen) β€” the one scope
  v2 made unambiguously worse. Questions about your own past AI conversations are the
  scope least like the artifact-concrete register v2 was trained to fix.
* All numbers are synthetic-benchmark. Real-traffic behaviour is being measured in
  shadow mode.
* English only. **No user data, ever** β€” the loader refuses artifacts whose manifest
  says otherwise.

## Training data

| source | licence | rows |
|---|---|---|
| schema-grounded synthetic + compounds + boundary negatives (Topos) | internal | 7,369 |
| [AmazonScience/massive](https://huggingface.co/datasets/AmazonScience/massive) | CC BY 4.0 | 5,200 |
| [CLINC150 OOS](https://github.com/clinc/oos-eval) | CC BY 3.0 | 1,200 |

v2 added ~95 artifact-concrete realizations **and** restored the concept-negative ratio
(0.054 β†’ 0.070). The second half is the interesting one: without a "what *is* a
curriculum vitae?" negative beside "what's on *my* CV?", an earlier v2 candidate learned
the artifact noun as sufficient evidence of ownership and fired on 43 near-miss
definition questions, dropping negatives-abstained to 0.739. Teaching a positive without
its matching negative was the whole regression.

This card is the CC BY attribution notice. No share-alike or non-commercial data.

## Architecture

DistilBERT-base-uncased, 15 sigmoid outputs (14 scopes + `none`), `BCEWithLogitsLoss`
with per-label `pos_weight`, max_length 64. Artifact = `head.json` (labels, thresholds,
manifest, metrics) + `model/` (HF-standard).