# v2.39.1 — the guard that ate your dictation

**Released 2026-09-24.** Upgrade:

```sh
sudo snap refresh yazses     # snap
pipx upgrade yazses          # or: uv tool upgrade yazses
scoop update yazses          # Windows
```

A bug-fix release. Both fixes are about the same failure shape: a check that fires on a
machine where nothing is wrong. [ADR-021](https://github.com/MSKazemi/yazses/blob/main/design/adr/adr-021-invest-in-error-cost.md)
puts it as rule 9 — *a guard is judged on how rarely it fires* — and two of ours were
failing that test in opposite directions. One was loud and permanent; the other was
completely silent, which is worse.

## The hallucination guard deleted real speech

With `[hallucination] enabled = true`, these were all discarded before they reached your
editor:

| You said | What happened |
|---|---|
| "No no no." | deleted |
| "Okay okay okay." | deleted |
| "Wait, wait, wait." | deleted |
| "Yeah yeah yeah." | deleted |
| "What what what?" | deleted |

The loop rule flagged any unit repeated three times — including a **single word**. At
three, a one-word repeat stops describing a decoder fault and starts describing English.

Measured on a real machine, five of seven consecutive dictations ended in

```
INFO yazses.core.daemon: Hallucination guard -- discarding fabricated transcript.
```

at input levels up to **0.1786**, roughly twenty times that machine's `vad_threshold` of
0.0036. That is loud, deliberate speech — not silence, not breath. Nothing was typed, no
sound played, and nothing said why.

Two things make that the wrong default:

- **[ADR-v2-025](https://github.com/MSKazemi/yazses/blob/main/design/adr/adr-v2-025-hallucination-guard.md) promised it would not
  happen.** Its Consequences say the guard "avoids dropping legitimate short utterances".
  The ghost-phrase rule earned that by matching whole transcripts only, never substrings.
  The loop rule never got the same conservatism.
- **It hit hardest the people the ADR says are worst affected.** The same ADR cites
  atypical and aphasic speech as suffering the most hallucination — and repeating a word
  *is* the dysfluency. The guard also runs *before* `filters.disfluency`, so on a
  `dysfluency_friendly` install it deleted the stutter before the filter built to tidy it
  ever saw it.

**A one-word unit now needs four repeats.** Repeated *phrases* still go at three, and
every degenerate loop the test suite pins is still caught, including the thirteen-word
partial-tail loop taken from a live corpus.

The trade is deliberate and asymmetric, which is what ADR-021 asks for: a ghost phrase
that reaches your editor is visible and one keystroke to delete. A silently deleted
sentence is unrecoverable, and you cannot even tell it happened.

## A discard now says why, and you can hear it

This is the reason the above went unnoticed for so long. The daemon logged one bland line
naming neither the rule nor the text, so **a guard eating real dictation and a guard
working correctly produced identical logs and identical silence.**

Now:

- The rule is named at `INFO` — `Hallucination guard (repetition_loop)` or
  `(ghost_phrase)`.
- The text is logged at `DEBUG`, behind the same gate as `Injecting text`
  ([ADR-011](https://github.com/MSKazemi/yazses/blob/main/design/adr/adr-011.md) — it is content, so it stays off
  by default).
- The **error earcon plays**, exactly as the empty-transcription branch beside it always
  did. You held the key and spoke; you are owed the one bit of feedback that says *heard,
  dropped*.

## Every Wayland start demanded a daemon that was already running

`yazses start` printed this on a fully provisioned Wayland desktop:

```
⚠  ACTION NEEDED
   Missing prerequisites: ydotoold (Wayland injection).
 Fix everything in one step:  yazses setup
```

It was wrong, and **nothing could ever clear it** — running the `yazses setup` it
recommended changed no input the check looked at.

`build_plan().setup_ydotoold` answers *"will setup write the ydotoold unit?"*, and on
Wayland that is unconditionally true, because writing it is idempotent and the planner
never needs to probe. The preflight read that flag as *"ydotoold is missing"*.

On the machine this was found on, ydotoold was installed at `/usr/bin/ydotoold`, its user
unit was `enabled` and `active` and logging `accepted client`, the udev rule was
byte-identical to the one we ship, and `input`-group membership was in effect. The daemon
correctly chose `YdotoolInjector` — while the warning insisted the "Remote Desktop" portal
would be used instead.

So it fired on **100% of Wayland starts**, forever. That is precisely how a warning trains
people to skip the whole block, including the days it is telling the truth.

The preflight now consults `ydotool_ready()` — the single readiness function `doctor` and
the injector already share, so the warning can no longer name a backend different from the
one the daemon picks. It still fires, unchanged, when ydotoold is genuinely absent or its
socket is dead.

## `yazses doctor` names the socket that actually exists

Ubuntu ships ydotool **0.1.8**, which predates `--socket-path` and ignores it silently,
binding `/tmp/.ydotool_socket` instead. Readiness already checked both candidates, but
doctor printed the path we *requested* — so on exactly the machines the fallback exists
for, it reported `running (/run/user/1000/.ydotool_socket)`, naming a file that is not
there.

## Two tests were reading the developer's own desktop

`test_portal_consent_is_explained.py` asked the host whether ydotoold was live, so it
passed or failed on an accident of whoever ran it — and it is the *provisioned* host that
produces no hint at all, which is how this defect survived a green suite. Both call sites
now pin the probe.

## Verification

Gates at release: **ruff clean**, **mypy clean across 526 source files**, **15,531 tests
passed / 300 skipped**.

Both fixes were proven in both directions — the warning clears on a ready machine *and*
still fires on an unready one; the guard spares emphatic repetition *and* still catches
every degenerate loop.

One frozen test vector was changed deliberately rather than worked around.
`test_hallucination_loop_with_partial_tail.py` pinned `"the the the"` as a loop; it now
pins `"the the the the"`. At three repeats the rule genuinely cannot tell `"the the the"`
from `"no no no"` — and that same file already listed `"very very very good"` as speech
that must survive. The only thing separating those two vectors was whether the speaker
trailed off into a fourth word, which is an arbitrary line to delete a sentence on.
