Skip to content

Decision packet: Spoken Edit count words ("delete the last twenty words")

Date / audited revision: 2026-11-06, main @ 8a72bbc6 Status: Proposal, not a decision. Nothing here changes code. The owner decides. Found: Round 7 review, deliberately left unfixed so it could be decided first. Related: ADR-v2-003 Spoken Edit Mode · ADR-021 invest in error cost · Mandarin count precedent src/yazses/commands/grammars/zh.py

Evidence labels: [Code] read in this checkout at 8a72bbc6 (file:line) · [Assumption] reasoning with no measurement · [Unknown] not established. No external source was used. No audio or transcript was used.

Coordination note. The unmerged branch r1107-commands (commit 3f4f0ea8, also carried by release/v2.50.0-rc2) already changes src/yazses/commands/edit_ops.py: it adds a _WORD regex (shifting every line after 15 down by one) and rewrites delete_last_words to slice the original text instead of re-joining words. It does not touch _NUMBER_WORDS, _n or _RE_DEL_WORDS. Line numbers below are at 8a72bbc6. Any implementation must start after that branch lands, or rebase onto it.

1. The defect, in one line

parse_edit("delete the last twenty words") returns ("delete_words", {"n": 1}). It deletes one word and reports success. Any count word the parser does not know becomes 1.

2. Current behaviour

2.1 The parser (pure module)

What Where Behaviour
Number vocabulary src/yazses/commands/edit_ops.py:17-20 a, an, one … ten. Nothing above ten. No compounds.
Count lookup edit_ops.py:29-33 (_n) Digits go through int() with no upper bound. Any other token gets _NUMBER_WORDS.get(token, default) with default=1. This fallback is the defect.
Frame regex edit_ops.py:103-105 (_RE_DEL_WORDS) ^delete\s+(?:the\s+)?last\s+(?:(\w+)\s+)?words?$, case-insensitive. The count slot is any single \w+ token.
Parse edit_ops.py:129-130 ("delete_words", {"n": _n(m.group(1) or "one")})
Apply edit_ops.py:57-64 delete_last_words splits on whitespace and keeps words[:-n]. If n >= len(words) it clears the text. n <= 0 does nothing.
Destructive set edit_ops.py:151 delete_words is in DESTRUCTIVE.

What the parser does with some inputs, read from the code above [Code]:

Utterance (after outer-punctuation strip, edit_ops.py:122) Parsed today Correct?
delete the last three words n=3 yes (tests/test_edit_ops.py:67-70)
delete last 2 words n=2 yes (but see the note below the table)
delete the last twenty words n=1 no: silent under-count
delete the last eleven words n=1 no
delete the last few words / several / couple n=1 it guesses. The request is vague.
delete the last bad words n=1 it guesses. "bad" is not a count.
delete the last for words (ASR homophone of "four") n=1 no
delete the last twenty one words None (two tokens) not recognised. In command mode it is discarded, not typed.
delete the last twenty-one words None (\w excludes -) as above
delete the last 500 words n=500 no cap; clamped by the ledger (see 2.2) when it reaches this path at all
delete the last passwords None yes, regression-pinned by tests/test_spoken_edit_does_not_match_a_word_ending_in_words.py

Digits rarely reach this parser. classify uses the Tier 1 grammar of one command language only (src/yazses/commands/grammar.py:77-90). With the English grammar, any digit count is caught first by en.py:58 (section 2.3), so the digit rows above reach parse_edit only when the command language is not English (for example zh, where an English phrase falls through to DICTATE). Word counts ("twenty", "few") always reach it.

2.2 Who reaches it (daemon)

  • Command mode only. _try_spoken_edit is called at src/yazses/core/daemon.py:4799. That is inside the if command_mode: branch (daemon.py:4691). It runs only after classify(...) (daemon.py:4692-4695) returned DICTATE, meaning no grammar rule matched (daemon.py:4717). Ordinary prose dictation never reaches parse_edit [Code].
  • Two opt-ins, both off by default. [commands] spoken_edit = False (src/yazses/config.py:520) gates the call (daemon.py:4796). [commands] spoken_edit_destructive = False (config.py:524) gates deletes. With it off, a delete is recognised, logged as spoken_edit_skipped and not applied (daemon.py:8030-8035) [Code].
  • Bounded to the last dictation. The edit applies to self._ledger.last_text() (daemon.py:8036) through _replace_last_burst. The ledger stays updated, so "scratch that" undoes it (daemon.py:8010-8022). An over-count can at worst clear the last dictation. It cannot reach text from before that dictation [Code].

Who is affected today: users who turned on both flags and then say a count above ten or a non-number word. [Unknown] how many such users there are. Telemetry is forbidden, so this cannot be measured in the field.

2.3 The digit path is a different code path (adjacent finding)

The English grammar matches ^delete\s+(?:the\s+)?last\s+(\d+)\s+words?$ (src/yazses/commands/grammars/en.py:58) before Spoken Edit ever sees the phrase. The dispatcher then sends Ctrl+BackSpace n times (src/yazses/commands/dispatch.py:226-230). That path has no cap and no ledger bound. It is not gated by spoken_edit_destructive: "delete the last 500 words" sends 500 word-deletes into the focused app. The same holds for delete_lines (en.py:60) and undo_n (en.py:63, dispatch.py:241-244).

The Mandarin grammar already solved this. It uses a closed 0-99 count (zh.py:36-39, MAX_COUNT = 99) and a parser with "no fuzzy fallback" (zh.py:118-140, parse_command_number). Its destructive actions are reachable only through a literal prefix with a capped count (zh.py:285-290).

This packet is about the word path in edit_ops.py. The uncapped English digit path should be raised as its own issue so the two decisions stay separate. It is listed under "Owner decisions" below.

[Unknown] Which form Whisper or Parakeet produces for a spoken count ("20" or "twenty") has not been measured here. If the decoder mostly emits digits, most real traffic takes the grammar path in 2.3, not this one. That is why section 6 includes a measurement step.

3. Options

A. Extend the number vocabulary (closed set)

Recognise 0-99 as words: zero…nineteen, the tens twenty…ninety, and compounds twenty one / twenty-one. The set is closed and checked exactly. This is the same shape as zh.py.

  • Fixes: "twenty", "eleven", "twenty one", "twenty-one".
  • Does not fix on its own: "few", "bad" and "for". These still fall back to 1 unless B is also adopted.
  • Cost: a pure helper (about 25 lines) plus a regex built from the vocabulary. Do not reuse itn/normalize.py:12-18 (_NUM). Its "oh": 0 belongs to version strings, and an import would couple two features that are both off by default.

B. Refuse an unknown count, with a notice

Delete the default=1 fallback. If the token is not in the vocabulary, return a recognised-but-refused result, for example ("edit_refused", {"reason": "unknown_count", "heard": "few"}). The daemon then shows one notice through its existing notification path (notify(...), as used at daemon.py:8000-8006):

Nothing deleted: "few" is not a number. Say, for example, "delete the last 3 words".

The daemon returns True, so nothing is typed and the event is filed as spoken_edit_refused.

  • Fixes: every silent wrong count. A wrong action becomes no action plus one sentence.
  • Order relative to the destructive gate: check the destructive gate first. With spoken_edit_destructive off, keep today's quiet spoken_edit_skipped. A notice about an operation the user never enabled would be noise.
  • Rejected sub-option: mapping homophones ("for"→4, "to"/"too"→2, "won"→1). That is a heuristic guess, which is exactly what ADR-021 rules out (adr-021…md:85-87: "hold only on a specific, checkable signal, never on a heuristic"). Refusing and letting the user repeat costs one utterance. Deleting the wrong number of words costs a correction.

C. Cap the count

Accept counts only up to a ceiling, and refuse anything above it, by the same route as B.

  • Spoken Edit path: this matters little here, because the ledger already bounds the harm (2.2), and with the English grammar a digit count never reaches this path (2.1). Its value is consistency, so that "delete the last 5000 words" gets the same refusal everywhere.
  • Recommended ceiling: 99, the same as zh.py:39 (MAX_COUNT). One number across languages is easier to document and test.
  • Grammar digit path (2.3): a cap there would change real behaviour, because nothing else bounds it. That is a separate decision.

D. Do nothing, but document it

Keep the fallback to 1 and document "counts up to ten". This is the cheapest option, but the failure stays silent: the user sees one word disappear and has no reason to believe the command was misunderstood.

4. Which phrases newly become commands

The key point: the frame regex already accepts any single token as the count (edit_ops.py:103-105). Options A and C on single tokens therefore create no new command phrases. They change only how many words a phrase that already matches will delete.

Option Phrases that are newly recognised Phrases whose effect changes
A, single-token words none eleven…ninety: from 1 word to the stated count (clamped to the last dictation)
A, compounds (twenty one, twenty-one) yes: today these return None and are discarded in command mode they newly delete 21…99 words, clamped
B none newly applied few, several, bad, for …: from "delete 1 word" to "delete nothing, show one notice"
C (99) none digits ≥ 100 (only when the command language is not English, see 2.1): from "clear the last dictation" to a refusal notice

To keep A narrow, build the compound alternative from the vocabulary itself, as in (twenty|thirty|…|ninety)[\s-](https://github.com/MSKazemi/yazses/blob/main/design/research/one|…|nine). Do not widen the slot to \w+\s+\w+. A widened slot would also capture "delete the last bad french words" and send it down the refusal path, which adds notice traffic for no gain.

5. False-trigger risk (ADR-021: judged on how rarely it fires)

Two separate risks follow.

5.1 An unwanted destructive edit. Today's exposure is limited by three things already in the code [Code]:

  1. the command key is held (daemon.py:4691);
  2. no grammar rule matched (daemon.py:4717);
  3. the user opted into both flags (config.py:520,524).

The harm is also bounded to the last dictation and undoable by "scratch that". A adds destructive reach only for compound counts, and only up to 99. A larger deletion than today, when the user really asked for twenty words, is the requested behaviour, not a false trigger. [Assumption] The residual risk is that the decoder turns "two" into "twenty". That is an ASR error which no parser can detect, and "scratch that" recovers it.

5.2 The refusal notice from B and C. ADR-021 asks how often the notice interrupts. It fires only when all of these hold:

  • the command key is held;
  • the utterance matches the exact frame delete [the] last <token> word(s);
  • <token> is outside a closed, enumerable set (or above 99);
  • spoken_edit_destructive is on.

The signal is specific and checkable: membership in a finite set, with no scoring or threshold. The release is free, because there is nothing to confirm: the user simply says the command again with a number. It never fires during prose dictation. [Assumption] It fires less often than any guard already shipped. That should be measured by the synthetic replay in 6.3 before it is claimed.

The notice is not a confirmation prompt. It holds nothing and asks nothing, so the "users learn to confirm reflexively" failure in ADR-021 (adr-021…md:89-92) does not apply.

6. Test plan (for whoever implements; none of this exists yet)

All tests are deterministic and offline, with no audio, model or microphone. Planned file: tests/test_spoken_edit_count_words.py.

6.1 Pure parser (edit_ops.py)

  • Fails on 8a72bbc6: parse_edit("delete the last twenty words") == ("delete_words", {"n": 20}). Today it returns n=1.
  • Exhaustive 0-99 round trip. For every n in 0..99, render the English words with an independent oracle written in the test: a small table-driven speller, not the production table. Then assert parse_edit(f"delete the last {words} words") gives n, in both the spaced and hyphenated compound forms. This follows the pattern of tests/test_mandarin_command_numerals.py, which proves regex and parser agree.
  • Refusal (option B). few, several, couple, bad, for, hundred, twenty-ten and one hundred each give ("edit_refused", {"reason": "unknown_count", "heard": …}). None of them gives delete_words.
  • Cap (option C). 100, 500 and 005 (zero-padded, as in zh.py:131-134) are refused. 99 is accepted.
  • Unchanged. delete the last word gives n=1. delete the last passwords gives None. delete the last bad french words gives None (the slot was not widened). tests/test_edit_ops.py:67-70 still passes.
  • apply_edit. A refused phrase returns EditResult(text, False) and is never dispatched as a delete. twenty on a 5-word burst clears it, which pins the ledger clamp.
  • Optional property test. Use Hypothesis over delete the last <random token> words: the result is either n in 0..99 from the oracle, or a refusal. It is never n=1 for a token outside the vocabulary.

6.2 Daemon wiring (_try_spoken_edit, mocked ledger, injector and notify)

  • spoken_edit_destructive=True with "delete the last few words": no injection, no _replace_last_burst call, notify called once, the event's intent_type is spoken_edit_refused, and the function returns True.
  • spoken_edit_destructive=False with the same phrase: today's spoken_edit_skipped, and notify is not called.
  • With "delete the last twenty words" and a 30-word ledger entry, the retyped text is the first 10 words.

6.3 Firing-rate check (ADR-021)

Use a synthetic list of command-mode utterances: the existing grammar fixtures, plus generated delete the last <n> words phrases, plus non-count fillers. Count how often the refusal fires, and assert it fires only on the frame. The list must be synthetic or explicitly de-identified (AGENTS.md: no real transcripts).

Decoder-output measurement ([Unknown] in 2.3). Synthesise TTS clips of "delete the last twenty words" and similar phrases, then record whether the pinned decoder emits digits or words. This decides whether the word path or the uncapped digit path carries real traffic.

6.4 Gates once implemented

uv run python -m pytest tests/test_spoken_edit_count_words.py tests/test_edit_ops.py \
  tests/test_spoken_edit_does_not_match_a_word_ending_in_words.py -v
uv run python -m pytest tests/ -n auto
uv run ruff check src tests scripts
uv run mypy src

There is no config key, CLI flag or generated-doc change, so scripts/gen-docs.py is not needed. A CHANGELOG entry is.

7. Recommendation (proposal)

Adopt A + B + C together, on the Spoken Edit word path only:

  • a closed 0-99 vocabulary, with hyphenated or spaced compounds built from that vocabulary;
  • no fallback; an unknown or over-cap count gives one notice and deletes nothing;
  • the same ceiling as Mandarin (99).

This removes a silent wrong action. On single-token phrases it adds no new command phrases. Its only new interruption fires on a closed-set membership test, in command mode, behind two opt-ins.

Not recommended:

  • homophone mapping, because it is a heuristic (ADR-021);
  • widening the count slot to free text;
  • reusing itn/_NUM.

8. Owner decisions

  1. Accept A + B + C for edit_ops.py, or pick a subset. D, documenting "up to ten", is the minimum honest option.
  2. Wording of the refusal notice, and whether it also appears on the overlay or only as a desktop notification.
  3. Separate issue: cap the English grammar digit path (en.py:58,60,63 and dispatch.py:226-244) at 99, as Mandarin does. Today "delete the last 500 words" sends 500 Ctrl+BackSpace presses with no opt-in beyond the command key.
  4. Whether ADR-v2-003 needs a note. Its point 3 (adr-v2-003…md:24-25) promises a confirm step for multi-word deletes. The code instead uses an opt-in flag plus "scratch that" (daemon.py:8014-8018). That gap predates this packet and is out of scope, but whoever revisits the ADR should know about it.