bots-garden/mini-mepublic Fork
⑂ main
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP
🛟 Updated. 5266466 · on main · k33g · 18h ago
measure-skill-routing.md · 112 lines · 6.8 KBmarkdown
Blame HistoryOpen raw

Measure whether the model picks the right skill

Run the routing eval, read its table, and use it to decide whether a change to the catalogue or to the prompt helped.

mm shows a skill in the catalogue and hopes the model reaches for it. Whether it does is a property of the model, not of the code, so no ordinary test can answer it. This one asks a live model, twenty times.

Run it

MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 25m

Both variables are required in practice: without MM_EVAL the test skips, and without AGENT_CONFIG it evaluates the built-in defaults rather than the configuration you care about. go test ./... skips it, so nothing in CI depends on a server being up.

Expect a few minutes: one model turn per case, and small models are not fast. Rows are printed as they complete.

Read the table

[ 6/20] ✓ hit    want=what-time-is-it  got=what-time-is-it  1.204s  "quelle heure est-il ?"  ← French
[14/20] ≠ wrong  want=greetings        got=vulcan-salute    0.981s  "Salue Bob de ma part"   ← two skills claim salut/saluer
[16/20] · miss   want=bg-jobs          got=(none)           2.017s  "lance le serveur en tâche de fond"
[17/20] ! extra  want=(none)           got=greetings        0.774s  "write a haiku about pointers"
Mark Meaning What it points at
✓ hit the expected skill, or correctly no skill —
≠ wrong a skill was loaded, but not that one the catalogue: two entries the model cannot tell apart
· miss a skill applied and none was loaded the prompt: the model did not think to look
! extra no skill applied and one was loaded over-eager instruction, or a catalogue entry that claims too much
✗ error the turn failed never a routing score. Either the instrument (a stub that starved the model, caseTimeout too short, a server that went away) or the model being unable to compose a valid tool call at all (unexpected end of JSON input, or the runaway guard cutting off repeated arguments). The message says which. Fails the test, because the numbers are then incomplete.

wrong and miss are kept apart on purpose. Merging them hides which of the two problems you have, and they have different fixes.

Use it

The number on its own means nothing. There is no threshold and the test does not fail on a low score — a number that fails a build becomes a target, and the first thing anyone does with a red build is edit the cases.

The eval is a before and after:

  1. Run it, keep the output.
  2. Change one thing — a skill's keywords, the SKILLS block of the system prompt, a description.
  3. Run it again against the same model, with the same configuration.
  4. Compare.

One change at a time. Two at once and you have measured their sum.

Small models at temperature: 0.0 are reproducible enough for this to mean something; run the eval twice before trusting a difference of one case.

What it measures, and what it does not

It measures the catalogue — the tool description the model reads — under one system prompt. One model turn per case, with tools that record what was asked for and run nothing: a skill that says "run this echo" must not echo twenty times, and letting the agent loop would measure recovery as much as routing.

It does not measure whether the skill, once loaded, was followed correctly. That is a different question and needs a different instrument.

It also does not measure whether the model can emit a valid tool call. When it cannot — and small models often cannot — you get ✗ error, not a low routing score. Those two failures have nothing to do with each other and no amount of catalogue work fixes the second one. Measured on qwen3.5-4b: three of twenty cases failed this way, reproducibly, on the prompts that invite a more structured call.

Add a case

internal/eval/routing_test.go, the cases table:

{"lance le serveur en tâche de fond", "bg-jobs", "French"},
{"what is 17 times 23?", none, ""},

Two rules that keep the numbers honest:

  • Do not phrase the prompt in the words of the catalogue. A case that says "use the greetings skill" measures nothing.
  • Keep the none cases. A catalogue that fires on everything is as broken as one that never fires, and abstaining correctly is a hit.

When you find a real routing failure, add it as a case before fixing it. That is how the eval stops the same failure coming back.

Write cases that can separate explanations

A single case tells you something failed. A set of cases tells you why, and that is what makes the eval worth running.

Worked example. bg-jobs was missing on English requests and hitting on French ones, and the obvious reading was "the catalogue's French coverage is thin" — except bg-jobs has no French keyword at all. Three cases settled it:

Case Result
start the dev server in the background — English, names the concept ✓ hit
démarre le serveur de dev et vérifie qu'il répond — French, only a situation · miss
lance le serveur en tâche de fond — French, names the concept ✓ hit
start the dev server and check it answers — English, only a situation · miss

A clean 2×2: the language is irrelevant. What decides it is whether the request names the concept the skill is about. That reframed the problem from a catalogue one into a prompt one — the model has no reason to know a server blocks the turn — and no amount of keyword editing would have found it.

Build cases in pairs that differ by one thing. One case is an anecdote.

Measure a decider

With skill routing, a decision model picks the skill before the chat model sees the question. Two runs tell you whether that helps.

The decider alone, on the same cases — no chat model, a fraction of a second per case:

MM_EVAL=1 MM_DECIDER=demos/decision-skills/agent.decider.yaml go test ./internal/eval -run Decider -v

Each row shows what the decider chose and its confidence (chose=bg-jobs conf=0.84); the summary scores the run at several thresholds, which is how to choose minConfidence:

minConfidence 0.60: hit 15 · wrong 0 · miss 8 · extra 0  →  65% right

The skills are those of AGENT_CONFIG's skillsDir when it is set, skills/ otherwise.

The decider and the chat model together: point AGENT_CONFIG at an agent file with a skillRouting block, and run TestSkillRouting as usual. Each prompt goes through the decider first, as in the front ends, and the log's first line ends with routed by <model> ≥ <minConfidence>. Compare it with a run of the same file without the block.

Read wrong and extra first: they are the lines a decider would append wrongly. A miss only means the chat model decides alone, as without a decider.

  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
# Measure whether the model picks the right skill

> Run the routing eval, read its table, and use it to decide whether a change to the catalogue or to the prompt helped.

`mm` shows a skill in the catalogue and hopes the model reaches for it. Whether it does is a property of the **model**, not of the code, so no ordinary test can answer it. This one asks a live model, twenty times.

## Run it

```bash
MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 25m
```

Both variables are required in practice: without `MM_EVAL` the test skips, and without `AGENT_CONFIG` it evaluates the built-in defaults rather than the configuration you care about. `go test ./...` skips it, so nothing in CI depends on a server being up.

Expect a few minutes: one model turn per case, and small models are not fast. Rows are printed as they complete.

## Read the table

```
[ 6/20] ✓ hit    want=what-time-is-it  got=what-time-is-it  1.204s  "quelle heure est-il ?"  ← French
[14/20] ≠ wrong  want=greetings        got=vulcan-salute    0.981s  "Salue Bob de ma part"   ← two skills claim salut/saluer
[16/20] · miss   want=bg-jobs          got=(none)           2.017s  "lance le serveur en tâche de fond"
[17/20] ! extra  want=(none)           got=greetings        0.774s  "write a haiku about pointers"
```

| Mark | Meaning | What it points at |
|---|---|---|
| `✓ hit` | the expected skill, or correctly no skill | — |
| `≠ wrong` | a skill was loaded, but not that one | the **catalogue**: two entries the model cannot tell apart |
| `· miss` | a skill applied and none was loaded | the **prompt**: the model did not think to look |
| `! extra` | no skill applied and one was loaded | over-eager instruction, or a catalogue entry that claims too much |
| `✗ error` | the turn failed | **never a routing score.** Either the instrument (a stub that starved the model, `caseTimeout` too short, a server that went away) or the model being unable to compose a valid tool call at all (`unexpected end of JSON input`, or the runaway guard cutting off repeated arguments). The message says which. Fails the test, because the numbers are then incomplete. |

`wrong` and `miss` are kept apart on purpose. Merging them hides which of the two problems you have, and they have different fixes.

## Use it

**The number on its own means nothing.** There is no threshold and the test does not fail on a low score — a number that fails a build becomes a target, and the first thing anyone does with a red build is edit the cases.

The eval is a **before and after**:

1. Run it, keep the output.
2. Change one thing — a skill's `keywords`, the `SKILLS` block of the system prompt, a `description`.
3. Run it again against the same model, with the same configuration.
4. Compare.

One change at a time. Two at once and you have measured their sum.

Small models at `temperature: 0.0` are reproducible enough for this to mean something; run the eval twice before trusting a difference of one case.

## What it measures, and what it does not

It measures the **catalogue** — the tool description the model reads — under one system prompt. One model turn per case, with tools that record what was asked for and **run nothing**: a skill that says "run this echo" must not echo twenty times, and letting the agent loop would measure recovery as much as routing.

It does not measure whether the skill, once loaded, was followed correctly. That is a different question and needs a different instrument.

It also does not measure whether the model can emit a valid tool call. When it cannot — and small models often cannot — you get `✗ error`, not a low routing score. Those two failures have nothing to do with each other and no amount of catalogue work fixes the second one. Measured on `qwen3.5-4b`: three of twenty cases failed this way, reproducibly, on the prompts that invite a more structured call.

## Add a case

`internal/eval/routing_test.go`, the `cases` table:

```go
{"lance le serveur en tâche de fond", "bg-jobs", "French"},
{"what is 17 times 23?", none, ""},
```

Two rules that keep the numbers honest:

- **Do not phrase the prompt in the words of the catalogue.** A case that says "use the greetings skill" measures nothing.
- **Keep the `none` cases.** A catalogue that fires on everything is as broken as one that never fires, and abstaining correctly is a hit.

When you find a real routing failure, add it as a case before fixing it. That is how the eval stops the same failure coming back.

## Write cases that can separate explanations

A single case tells you something failed. A **set** of cases tells you why, and that is what makes the eval worth running.

Worked example. `bg-jobs` was missing on English requests and hitting on French ones, and the obvious reading was "the catalogue's French coverage is thin" — except `bg-jobs` has no French keyword at all. Three cases settled it:

| Case | Result |
|---|---|
| `start the dev server in the background` — English, **names** the concept | ✓ hit |
| `démarre le serveur de dev et vérifie qu'il répond` — French, only a situation | · miss |
| `lance le serveur en tâche de fond` — French, **names** the concept | ✓ hit |
| `start the dev server and check it answers` — English, only a situation | · miss |

A clean 2×2: **the language is irrelevant.** What decides it is whether the request names the concept the skill is about. That reframed the problem from a catalogue one into a prompt one — the model has no reason to know a server blocks the turn — and no amount of keyword editing would have found it.

Build cases in pairs that differ by **one** thing. One case is an anecdote.

## Measure a decider

With [skill routing](route-skills-with-a-decision-model.md), a decision model picks the skill before the chat model sees the question. Two runs tell you whether that helps.

The decider alone, on the same cases — no chat model, a fraction of a second per case:

```bash
MM_EVAL=1 MM_DECIDER=demos/decision-skills/agent.decider.yaml go test ./internal/eval -run Decider -v
```

Each row shows what the decider chose and its confidence (`chose=bg-jobs conf=0.84`); the summary scores the run at several thresholds, which is how to choose `minConfidence`:

```
minConfidence 0.60: hit 15 · wrong 0 · miss 8 · extra 0  →  65% right
```

The skills are those of `AGENT_CONFIG`'s `skillsDir` when it is set, `skills/` otherwise.

The decider and the chat model together: point `AGENT_CONFIG` at an agent file with a `skillRouting` block, and run `TestSkillRouting` as usual. Each prompt goes through the decider first, as in the front ends, and the log's first line ends with `routed by <model> ≥ <minConfidence>`. Compare it with a run of the same file without the block.

Read `wrong` and `extra` first: they are the lines a decider would append wrongly. A `miss` only means the chat model decides alone, as without a decider.