# Phrasing evaluation — 31-07-2026

How Maven words a nudge, measured instead of argued. Counterpart to
`docs/evals/2026-07-31-routing.md`.

- Fixture + scorer: `internal/phraser/eval/` (`nudges_v1.json`, 15 cases; `eval.go`, `checks.go`)
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing`
- Model: Qwen3.5-0.8B Q4_K_M, the resident model. Not swapped.
- Commit: `a40bc55` (prompt fix)

Every check is a string or length test a human can read and disagree with. No model
grades another model here.

## Result

| | before | after |
|---|---|---|
| **cases passing every check** | **0/15** | **13/15** |
| mood in enum | 6/15 | 15/15 |
| Russian | 2/15 | 14/15 |
| length (≤120 chars, ≤16 words) | 13/15 | 15/15 |
| feminine self-reference | 15/15 | 15/15 |
| no cringe | 13/15 | 15/15 |
| on topic | 6/15 | 13/15 |
| p50 latency | 11.4s | 11.4s |

Latency did not move and is not good. 11s to word one nudge on this box.

## The bug reproduced

Yes, exactly as reported. 7 of 15 messages were the literal string `"..."`, and one was
`"full voice message"`. Both are text copied straight out of the prompt.

The system prompt said:

```
Respond ONLY with valid JSON: {"response": "full voice message", "mood": "neutral"}
```

and the user prompt said:

```
Respond as JSON: {"response": "...", "mood": "..."}
```

A 0.8B does not read `"..."` as "put your answer here". It reads it as the answer. The
prompt was a worked example whose worked part was blank, so the model filled the slot by
copying. This is the whole of finding 1.

## What else was wrong

Four separate faults, all prompt-side:

1. **Placeholder echo** (7 cases) — above.
2. **Wrong language** (13/15 failed the language check). The prompt was entirely English
   and said "in the user's language (Russian or English)". The model picked English. It is
   never English: the nudge is spoken by a Russian piper voice.
3. **Rule names are English identifiers.** `netdata_critical`, `service_down`, `break` went
   into the prompt raw. The model cannot nudge about a topic it has not been told in words,
   so 9/15 were off topic. The daemon knows what its own rules mean; now it says so.
4. **Mood invented** (`"warm"`, twice). The enum was listed in a parenthesis at the end of
   an English sentence. Now it is its own line: "ровно одно из: neutral, happy, thinking,
   tired, confused."

Plus two non-prompt faults the run exposed:

- **The no-parse fallback was English.** When the model returned nothing usable, the body
  became `fmt.Sprintf("%s — %s", rule, sev)` — `"water — care"` — and that string went to
  a Russian TTS. Now it falls back to plain Russian.
- **Durations were English.** `humanDur` returns "3 hours"; it was landing verbatim inside
  Russian sentences. Nudges now use a Russian formatter.

## Three iterations, and what each taught

| | score | change |
|---|---|---|
| baseline | 0/15 | — |
| iter 1 | 2/15 | Russian prompt, filled-in examples, Russian durations |
| iter 2 | 11/15 | required keyword per rule, one example instead of five, Russian fallback |
| iter 3 | **13/15** | examples moved to topics that are not rules |

The interesting step is 1 → 2. Fixing the placeholder did not fix the disease, it moved it:
the model stopped copying `"..."` and started copying my first example instead. Five nudges
in a row came back as `"Ты не пил воду три часа. Налей стакан."` regardless of the rule.

**A small model copies the nearest concrete text in its prompt.** That is one failure mode
with two symptoms. The fix that stuck was making the examples about laundry and a laptop
battery — topics no rule ever produces, so copying them is visible in the score rather than
invisibly passing the water cases.

## Do not oversell 13/15

Seven of the thirteen passes are the **deterministic fallback**, not the model:
`"Напоминаю: таблетки."`, `"Сервис не отвечает."`, `"Критический алярм: проверь диск."`,
`"Ты давно не пил воду."`. Those are strings this commit added to Go. The model returned
nothing parseable and the fallback scored.

So the honest reading is roughly **6/15 from the model, 7/15 from a fallback, 2/15 failing**.
The prompt fix is real — `"..."` is nearly gone and the language and mood checks are clean —
but a large part of the jump is that failure now degrades into Russian instead of into
`"water — care"`. That is a genuine improvement for the operator and a weak one for the model.

The two remaining failures: one `"..."` recurrence (`routine-stretch`) and one meal nudge
that never says food.

## Tried and reverted: an example-led nudge prompt (#393)

The idea was that a 0.8B copies examples better than it follows rules, so the nudge prompt
was rewritten to lead with five on-topic examples (water, break, pills, morning, service) and
the prose rules were compressed to pay for the tokens: 1190 chars down to 986.

It measured **worse**, three runs each side, same llama-server, same fixture:

| run | before | after |
|---|---|---|
| 1 | 12/15 (address 14) | 11/15 (address 13) |
| 2 | 13/15 (address 15) | 12/15 (address 15) |
| 3 | 14/15 (address 15) | 11/15 (address 12) |

`feminine` and `hisgender` were 15/15 on all six runs, so they measure nothing here. The
regression is all in `address`: 44/45 before, 40/45 after. Formal "вы"/"ваше" and plural
imperatives came back, and so did `"..."`.

Two likely causes, both about the same thing — **examples do not carry a prohibition**. The
old prompt spent a whole sentence on «говоришь на "ты", в единственном числе»; the new one
demoted that to one item in a long "никогда" list, and the model stopped obeying it. And
making the examples on-topic let their *wording* leak: a break case came back as
«Вы давно не пили воду. Выпей стакан.» — the water example, verbatim, in the wrong slot.
That is exactly the failure the laundry/laptop examples were chosen to avoid.

Change reverted. What survives is the measurement: a rule the model must obey needs its own
sentence, and examples must stay off-topic. Also note the before side alone spans 12–14 of
15 — this fixture cannot resolve anything smaller than about three cases.

## Broken, found, not fixed

1. ~~**`checkFeminine` only catches half the constraint.**~~ **Fixed** (#381). It scanned for
   masculine self-reference only, so three messages that addressed the *owner* in the feminine
   ("ты давно не отдыхал**а**") scored clean. There is now a second check, `hisgender`: a
   feminine past-tense verb (-ла/-лась) in a sentence addressed to him ("ты", "тебе", "твой")
   fails, unless the verb is hers ("я заметила", "напомнила тебе"). It is a suffix rule, not a
   parser — see the comment in `checks.go` for what it misses. A fresh 15-case run after adding
   it scored **12/15** with `hisgender` 15/15; the model did not repeat the feminine address in
   that sample, and the check is pinned by unit tests on the recorded bad strings instead.
2. **Grammar is not checked at all, and it is bad.** `"Он не ел 11 дней"` (it was 11 hours),
   `"Сонуждились 7 дней"` (not a word), `"Они забыли воду"` (wrong person entirely). Every
   one of these passes all six checks. The fixture measures properties, not fluency, and at
   0.8B fluency is the binding constraint.
3. **Unit confusion.** The model turns hours into days about a third of the time. The
   prompt now says "11 ч"; it reads it as days.
4. **11s p50.** Unchanged and untouched here. A nudge the model takes eleven seconds to
   word has missed its moment. Worth its own task.
5. **The keyword hint is close to teaching to the test.** `ruleKeywords` names the word the
   on-topic check looks for. It is defensible — the daemon genuinely knows its rule topics
   and the model genuinely cannot infer them from `netdata_critical` — but the on-topic
   number is softer than the others because of it.

## Next steps

1. ~~**Add a second-person gender check**~~ — done, `hisgender` in `checks.go` (#381).
2. **Decide whether the fallback should count as a pass.** Right now `Score` cannot tell a
   model answer from a fallback. Either mark fallback bodies in `PhrasedNudge` or count them
   in their own column. Without that, any future prompt change can score well by failing
   more.
3. **Attack the 11s.** Nudge phrasing is short and non-interactive; thinking off is the first
   thing to try, as it was for routing (#376).
4. **Re-measure when #122 lands.** The CPT'd Qwen3-1.7B is the target. 13/15 with seven
   fallbacks is the floor it has to beat, and the fluency problems above are the ones a
   bigger, Russian-trained checkpoint should actually fix.
