ShadowHarness records turns where both the legacy router and the
experiment model produce a decision. Reports agreement/disagreement
split by fast-path vs residual. No action, clarification, capability
selection, or reply depends on the shadow result.
EvalReport with macro F1, per-route precision/recall/F1, confusion
matrix, false-action rate, and fast-path vs residual breakdown.
ScoreEval runs a SemanticRouter against a frozen eval set and produces
all metrics needed for promotion decisions.
Six deterministic transforms (negation, question, reported speech,
quotation, hypothetical, capability question) applied to action-route
seeds. Each transform determines the expected class explicitly — no
model guessing labels. SplitByFamily uses hash-based bucketing to keep
paraphrases in the same split.
136 examples with provenance from ru_routing_v1.json, tagged
fast_path_resolved vs residual. RouteExample carries source, source_id,
split_group for traceability. Split-by-family prevents paraphrase
leakage across train/eval.
Defines the six-class SemanticRoute type (conversation, knowledge,
action, memory_write, system, uncertain), the SemanticRouteDecision
output, the SemanticRouter interface, and the deterministic
Intent→SemanticRoute mapping from the current seven-intent cascade.
Migrate 54 test call sites to construct NormalizedInput{Text: ...}.
Add invariant tests:
- TestNormalizedInputReachesRouteIntact: ingress NormalizedInput reaches Route
- TestTryFastPathReceivesMatchText: TryFastPath gets the same input
- TestMatchTextDoesNotChangeRouting: same Text + different MatchText → same Decision
- TestDecisionUtteranceEqualsInputText: Decision.Utterance == input.Text
Change Route from (ctx, utterance string, now) to (ctx, input NormalizedInput, now).
The ingress-constructed NormalizedInput now reaches the cascade intact — no
reconstruction downstream. TryFastPath receives the same input, not a rebuilt one.
All callers (production, eval framework, tests) updated to construct NormalizedInput.
Add MatchText field to NormalizedInput — a lossy lexical matching view
derived from ingress text: TrimSpace → NFKC → lowercase → collapse
Unicode whitespace. Does NOT fold ё→е, strip punctuation, strip wake
words, rewrite numbers, or invoke morphology.
Both ingress sites (voice + text) construct MatchText at entry. No
existing consumer reads MatchText yet — it is dark data for future
opt-in migration.
Vikunja: #725
Migrate the two remaining action-routing consumers from compatibility
Decision.Slings fields to authoritative Decision.CapabilitySelection:
- refusesCommand: reads CapabilitySelection.Fn instead of Slots.Fn
- ActHasEntityTarget: reads CapabilitySelection.Resolved and
CapabilitySelection.Args instead of Slots.HasFn and Slots.Args
Slots.Text remains the source for entity text when positional args do
not contain the target (unchanged).
Regression tests prove:
- prohibited sentinel preserved byte-for-byte through SelectCapability
- blanked Slots.Fn/Args/HasFn do not affect migrated consumers
- Praxis/Hexis entity-target routing unchanged
- stage-0 deterministic act unchanged
- classifier/extractor act unchanged
do not remove the compatibility mirrors yet.
Set CapabilitySelection on decisions that have Slots.HasFn=true, so
ResolveActionCandidate reads from the authoritative record. Backward
compatibility tests verify that decisions without CapabilitySelection
still resolve via Slots.HasFn.
The candidate now receives Fn/Args from CapabilitySelection (the
authoritative record) rather than from Decision.Slots.HasFn. Backward
compatibility: decisions with Slots.HasFn but no CapabilitySelection
(tests, rebuilt decisions) still resolve via the compatibility path.
Authoritative record of which executable capability matched, separate
from Decision.Intent (what kind of turn) and ActionCandidate (downstream
action artifact). Decision.Slots.Fn/Args/HasFn remain as compatibility
representations populated from this selection.
Call SelectCapability after each cascade path (grammar, heads, LLM,
classifier) and propagate the result via applyCapabilityToSlots.
Remove LLM text capability backfill from fillSlots — SelectCapability
now owns that path. fillMatchedSlots retains raw extractor capability
extraction for backward compatibility with stage-0 grammars.
gateLLMDecision now reads CapabilitySelection.Resolved instead of
Slots.HasFn for the act-intent confidence thinning check.
Add the explicit capability-selection boundary between route resolution
and action candidate production. SelectCapability is the single entry
point for selecting which executable capability matched an IntentAct turn.
Three input kinds: raw, llm_text, deterministic. Decision.CapabilitySelection
is the authoritative record; Decision.Slots.Fn/Args/HasFn remain as
compatibility representations populated from the selection.
Run routing and ecosystem fixtures through the baseline router and
report which component selected the exact function for every IntentAct
case. Shadow matcher comparison confirms zero disagreements.
Five disjoint values tracking which component selected the exact function:
grammar_fixed, grammar_matcher, extractor_raw, extractor_llm_text,
fallback_matcher. Slots.ResolvedBy carries provenance at the selection
point.
Introduce ActionValidationStatus enum (valid, unresolved, missing_argument,
invalid_argument, ambiguous_target) as the typed classification of validation
outcomes. ActionValidationResult now carries Status instead of boolean flags.
Backward-compatible: Unresolved() and Valid() methods preserved on the result.
Existing validation behavior unchanged: only blank Fn produces invalid_argument.
All downstream behavior (proposeGap, confirmation, task_status, praxis, hexis)
unchanged.
Tests added for all five status values, backward compatibility, and the full
validation → execution boundary.
Introduce the typed boundary between routing and action resolution:
- ActionCandidate: Fn, Args, Source (route|matcher), Producer, Confidence
- ResolveActionCandidate(dec, m): standalone function usable by both
the daemon and the eval harness
- Update eval harness Reach() to use ResolveActionCandidate instead of
duplicating the matcher fallback logic
This is the routing-side half of the action-resolution boundary.
The daemon integration follows in the next commit.
12 focused tests proving the first slice properties:
- text and voice enter equivalent typed turn input after stt
- stage-0 outputs remain identical with grammar producer
- classifier floor sets its producer
- clarification carries the classifier producer
- pre-route claims produce no route producer
- route producer appears on the decision record
- input source is preserved on the decision record
First behavior-preserving slice of the Maven redesign. Establishes
explicit ingress/routing boundaries and enough observability to refactor
later without changing current routing, action, clarification, or
execution semantics.
Types introduced:
- NormalizedInput (internal/router/source.go): Text + InputSource,
the typed ingress boundary replacing raw string at the turn entry.
- InputSource (internal/router/source.go): channel provenance enum
(tap:voice, tap:text). Reuses the existing turnSource distinction.
- RouteProducer (internal/router/intent.go): which cascade stage
produced the decision (grammar, heads, llm, classifier).
Changes:
- Decision carries a Producer RouteProducer field, set at each cascade
stage (grammar, heads, LLM, classifier).
- turnRoute carries NormalizedInput instead of bare text string.
- runTurn takes NormalizedInput instead of (text, src).
- decision.Record carries InputSource and RouteProducer for
observability; RoutingTrace persists route_producer (migration #27).
- turnSource is now a type alias for router.InputSource.
Behavior preserved:
- Stage-0 grammars unchanged: same order, same matching, same confidence.
- Cascade fallthrough order unchanged (grammar → heads → llm → classifier).
- Clarification behavior unchanged.
- Action dispatch unchanged.
- No new linguistic normalization.
MavenHelpGrammar keeps "как отменить напоминание" on SourceSelf, where the
answer names the command Maven accepts, instead of leaking to search.
PublicCurrentVersionGrammar anchors an explicitly current release on
SourceWorld and declines first-person ownership.
AmbiguousFragmentGrammar refuses filler plus an unresolved demonstrative
rather than letting a statistical head invent context.
ImplicitElapsedQueryGrammar reads Russian question word order in "давно я
не тренировался" as recall; the declarative order stays a statement.
ReminderCancellationReportGrammar keeps "я отменил напоминание" in the
non-mutating chat lane.
CommandProhibitionGrammar routes a direct negative command to a sentinel
fn that can never collide with an enabled tool. ActHasEntityTarget stops a
bare verb or a demonstrative-only tail from crossing into Nexus.
Praxis attention now accepts "что там с X" for the four service names only.
taskstatus separates command mood from result words so a first-person
report cannot mutate the board. question.go exports the open-question and
locative shapes the recall gate reads.
--no-verify: master is the working branch this session by the owner's call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reference-count the process-global ONNX Runtime across embedder and routing-head sessions, make close idempotent, and require named proof that both aggregate routing gates executed rather than self-skipped (V-716). Owner explicitly requested direct commits to master.
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
buildRouter held the real set and baselineGrammars in eval_test.go restated it
by hand, in the daemon's order, with its own comment saying so. Three test files
score against the fixture and nothing compared the two lists. They had already
drifted: BareCaptureGrammar went into the daemon with V-557 and never into the
fixture, so every routing measurement since has scored a set nobody runs. That
is the failure CLAUDE.md warns about by name, and a diff test would have caught
it one grammar late.
The list moves to router.StageZeroGrammars in internal/router/stagezero.go, with
the ordering comments, which are the load-bearing part. buildRouter and the
fixture both call it. One list cannot drift from itself.
Measured before and after on the 96-case fixture: classifier+onnx 72/96, 75.0%
intent, 33.3% destination, identical either way, and the deterministic claim and
reach hash ratchets do not move. So the missing grammar cost no measurable
accuracy. That is the point rather than a reprieve: the fixture had been scoring
the wrong set for four days and nothing could say so.
The invariants caveat is deleted, both entries, since V-692 landed the other
guard in the previous commit. The reasoning for both now sits in docs/routing.md
beside the subsystem, which is where a fix's durable record belongs.
Unrelated and pre-existing: TestONNXPersonalBoundary fails on "я рассказывал
тебе про байкал?" (personal 0.9068, world 0.9413) at the merge base too.
Naming a destination takes the guessing query sources off a turn, and the
personal boundary is one of them. Every other guesser costs an answer when it
is wrongly dropped. This one costs the rule that a question about him never
reaches an upstream engine.
Three deciders name a destination now and two of them infer it: the routing
heads and the resident model. Decision.SourceAnchored says a stage 0 grammar
read the words instead. queryWalk honours it for the source marked
boundary: true and for no other, so the rest of the table is unchanged.
Owner's call of 2026-08-09.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
embedder.heads_path is empty by default and deploy/mavend.json sets
it. A missing or broken weights file logs and leaves the heads nil,
because refusing to start over a routing accelerator would trade a
working box for a better one.
TestONNXRoutingHeads is the same cascade TestONNXBaseline scores with
one arm added, so the two are directly comparable. It also checks the
Go tokenizer against the Python one, since the heads were trained
through transformers and are read through a hand-written tokenizer: a
mismatch shows up here as a score below what Python measured on the
same weights, and nowhere else. That is how the reversed word pieces
were found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
They run before the model because they are two orders of magnitude
faster and score better on both halves of the route. They decline
rather than clarify, so a declined turn carries on to the model and
then the classifier, which is what a box with no weights file does on
every turn. Nil heads are byte-for-byte the cascade that shipped
before this.
Measured on the 96-case fixture, classifier+ONNX either way:
intent 76.0% -> 96.9%
destination 36.4% -> 75.8%
false clarify 0 -> 1
missed clarify 8 -> 1
p50 24.5ms -> 27.9ms
That beats the gemma-4-12b cascade on both halves, 84.4% and 72.7%, at
a twelfth of its 329ms. The four remaining destination misses are all
calendar, which is the stage 0 trade V-660 flagged and the owner has
not called yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
The heads trained in V-661 ran nowhere. This loads the exported graph
and reads intent, destination and clarify off one forward pass. It
declines below 0.6 max softmax rather than clarifying, so a declined
turn reaches whatever is behind it.
The slot head is exported and deliberately not read: slots already
come from the stage-2 extractor, and mapping BIO tags back to text
needs character offsets the tokenizer does not keep.
The clarify head decides on its own and decides first. It answers a
different question from the intent head, so a low intent confidence is
no reason to discard it. Reading it only above the intent threshold
cost 6 of the 8 ambiguous cases on the fixture: the word for water
reads as intent act at 0.23 and clarify at 0.98.
0.6 is the knee measured on the intent fixture: every higher value up
to 0.9 drops right answers and keeps the same two wrong ones.
The body is a fine-tuned COPY of the resident embedder and must never
replace it, because memory recall depends on that file scoring what it
scored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
encodeWord backtracks the Viterbi path from the end of the word and
prepends each piece, which puts them back in reading order. A second
reverse after that loop undid it. So "query: вода" tokenized to
[0 12 1294 41 12489 2] where the reference tokenizer gives
[0 41 1294 12 12489 2], and every multi-piece Russian word reached the
model with its pieces in the wrong order.
Measured on the recall fixture, same 27 cases either way:
recall@1 70.4% -> 77.8%
recall@3 85.2% -> 96.3%
answered after gate 63.0% -> 66.7%
false recall 0/5 -> 1/5
The classifier barely moves, 76.0% to 75.0% on the routing fixture,
because seeds and queries were mangled the same way and cosine survived
it. Recall is where it cost, because a stored passage and a live query
are different lengths and break differently.
The embedder id now names a tokenizer revision. Stored vectors were
written under rev 1 and no longer sit in the same space as a query
embedded now, and the model file's name never moved, so nothing would
have triggered ReembedAll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
The training workspace labels with routeSystem and routeGrammar, and it held
its own copies. V-660 changed both. A retyped prompt drifts silently, which is
the problem llm/check_prompt_parity.py exists for on the other side.
Inert unless MAVEN_DUMP_PROMPT names a directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
V-659 measured the destination at 12/33 on the classifier cascade and named
the gap: recall 0/15, because nothing anywhere names it. The model could not
help, for a structural reason rather than a capability one. Nothing in
routeSystem mentioned a Source and routeGrammar could not emit one, so there
was no string for it to write. Same shape as the Praxis reach V-517
measured at 0/12.
routeGrammar grows a source rule, closed over router.Sources plus the empty
floor. A grammar cannot emit a destination that does not exist, which is the
guarantee V-546 wants from a softmax and gets here for free. The prompt
lists the twelve in Russian, one line each, and says plainly that "" is a
normal answer to give often: two sources that can both answer means the
chain walks, and guessing is the failure mode this whole field exists to
stop.
The read-back goes through ValidSource and runs on IntentQuery alone. The
grammar already bounds the enum, but it is a request to a server that may be
running another build, and only a query reaches queryWalk.
Measured against gemma-4-12b on the workstation, same fixture, cascade with
a hash fallback: destination 24/33 (72.7%) against the classifier's 12/33,
and intent 81/96 (84.4%) which is where it already was. Recall is the whole
move, 0/15 to 14/15. The model alone scores 26/33.
Four cases the cascade loses and llm-only wins are calendar. The possessive
agenda rules claim them at stage 0 and deliberately name nothing, because
"что у меня в списке покупок" matches the same rule and naming the calendar
would take the list source off the turn. So stage 0's caution now costs four
destination points it did not cost before. That is a real trade and it wants
its own argument, not a quiet edit here.
The resident Qwen3-1.7B is unmeasured: it binds --port 0 inside the
container and no host process can reach it.
llm/check_prompt_parity.py in the training workspace compares its copy of
routeSystem to this one and will fail until that copy gets the same edit.
V-362 covers the catch-up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
Twenty-eight existing query cases get a want_source and five new ones
arrive with theirs. Every label is the destination that SHOULD claim the
turn, which on the five new cases is not the one that did: they were
observed failing on the box on 2026-08-07, so the fixture fails on the day
it is written.
Seven cases assert the SourceUnknown floor, and six of those are homelab
operations. They cluster because SourceRecall, SourceNetwork and
SourceAttention overlap on every question about the box: mavpoll writes its
netdata and uptime-kuma observations into the fact store recall reads.
Naming one destination there takes the other two off a turn that needs
them. That is a finding about the enum, not a gap in the labelling.
The fixture's grammar mirror had drifted. WorldQueryGrammars went into
buildRouter with V-655 and never into baselineGrammars, so the fixture was
scoring a grammar set the daemon does not run — the exact thing the comment
above that function forbids. Adding it moved the destination number 9/33 to
12/33 and moved nothing else.
Measured classifier+onnx: intent 73/96 (76.0%), was 69/91 (75.8%). Four of
the five new cases pass and no existing case moved. Destination 12/33
(36.4%), and the split is the point. World is 5/5, because a stage 0 rule
names it. Calendar is 2/6, because the possessive agenda rules deliberately
do not. Recall is 0/15, because nothing anywhere names it yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
The fixture measured the first half of a route and stopped. V-655 split a
routing decision in two, and the second half arrived with no fixture, so
Decision.Source had no accuracy number at all.
want_source is a pointer because the destination has three states and a
bare string has two. Absent is every intent but query, which never reaches
queryWalk. Present and empty is the SourceUnknown contract: name nothing
and let the daemon walk the chain, which is right whenever two destinations
can both answer and the utterance does not choose. Present and named is a
destination the route must produce.
A destination miss does not fail the case. It goes in SourceReason, never
in Reasons, so Accuracy and IntentAccuracy stay the numbers they were and
69/91 still means what it meant. SourceAccuracy is the second number, over
the labelled cases only, because a percentage of the whole fixture would be
a percentage of turns that never ask a query source.
A clarified or mis-routed case still counts in the denominator. It named no
destination and that is a miss, not a case to skip, or the denominator drops
every turn the route already lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
A question was sorted twice. The cascade picked one of seven intents with
stage 0 rules, the resident model and the classifier behind it, a 91-case
fixture measuring it and the decision trace recording it. Then IntentQuery
handed the turn to a second dispatch in the daemon, twenty-two branches
deciding by seed similarity in a fixed order, with none of that. The careful
sorter did the easy half.
Decision grows a Source: twelve destinations, not twenty-two, because the
recall passes are one destination from the outside and so are the three world
sources. Empty is a real value and it is the floor — nothing names one, the
daemon walks its whole chain, and that is exactly what shipped before.
Stage 0 fills it where a deterministic rule already knows. Two new world rules
for the shapes measured failing on the box on 2026-08-07: "что такое TCP?" and
"кто такой Линус Торвальдс?" were answered by weather and by the personal
boundary, and "сколько будет 17 на 23?" was answered "для какого города?".
The calendar noun rule and the closed event-noun rule name the calendar. The
possessive agenda rules deliberately do not: "что у меня в списке покупок"
matches agenda-query, and naming the calendar there would take the list off
the turn.
Fixture unchanged at 69/91, which is the point — it scores intent, and none of
these cases changes intent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tangled defects, fixed together because each one hid the others.
DefaultActMatcher matched an exact English prefix and internal/tool.Matcher
delegated straight to it, so no Russian utterance could reach a tool: 55 of the
69 lines in models/seeds/act.txt routed to IntentAct and fell to proposeGap.
Tools now carry spoken aliases from deploy/mavend.json, matched as exact leading
tokens, longest phrase first. Config data, not a stem pattern in code. The
comment claiming "the production matcher is fuzzy" was false and is gone.
Seven lines were exact duplicates inside models/seeds/query.txt, each one a
second identical vector double-weighting its region.
"как дела у сервера" carried both a query and a system label. It leaves
system.txt, because replySystem's stats arm answers "системная статистика пока
не подключена." and always did. The mode inventory records that shape as
act.tool.hoststats rather than a system mode.
Fixture unchanged at 69/91, and it cannot see any of this: no host-stat case and
no Russian act in it. TestActMatcherAliases is the coverage.
docs/evals/2026-08-06-russian-acts-reach-tools.md has the numbers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
reminder_verbs held five words and none named an alarm, and ReminderGrammar
did not read the set anyway — it carried the literal напомни|remind me. So no
part of the cascade recognised разбуди, and the three alarm cases in the
fixture went to fact and act at over 0.89.
The lexicon addition alone moved nothing, measured at 66/91. Every consumer
reads the set after a reminder route already exists. Building the grammar's
alternation from the set is what scored: 66/91 to 69/91, three cases gained,
none lost, and each alarm now carries its time slot.
Longest-first ordering in the alternation is load-bearing. Go's regexp
alternation is leftmost-first, so напомнить after напомни would never match.
Found while training the V-546 intent head, where the same three cases went
to system.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
V-586 reported 64/91 on the RU routing fixture, unchanged. That number does not
bear on the change: the fixture holds three fact cases and all three miss on
intent, so DefaultFactParser is never reached and any parser edit scores as
"unchanged".
So the parser gets its own corpus, 91 cases, scored against BOTH
implementations — the closed classes that ship and legacyFactParse, a verbatim
copy of the substring parser at 0445693, frozen in the test file so the
comparison reruns. True positives 35/40 to 39/40, misfires rejected 8/15 to
14/15. The rewrite wins every case anyone argued about.
The third case class is the point: 36 sentences a person would plainly say
whose word is in no lexicon set. The old parser caught 3 by accident, the new
one catches 0. "ем суп", "вздремнул", "помылся", "перекур", "i napped". A
silent miss is this parser's worst failure mode and the corpus sizes it.
Two defects recorded rather than fixed, since this branch measures: "допил
воду" misses because the dictionary lemmatises допил to допилить, the same saw
collision drink_verbs carries пил for; and the oblique cases of душ go with the
exact match that keeps the soul out.
The LLM arm the original commit skipped is run here against gemma-4-12b on the
workstation at 192.168.1.105:8080 — it was reachable all along, the failure was
the shell's HTTP_PROXY. cascade+llm 85.7% to 86.8%, one case, same failing set,
variance. Full write-up in docs/evals/2026-08-06-fact-parser.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
DefaultFactParser matched Russian by hand-written stem substring: "вод", "пил",
"душ", "еда" and eleven more, with a helper whose own comment said it would use
a morphology lib "until misfires actually bite". That is the fourth mechanism
CLAUDE.md says does not exist, and it ran on every fact turn through both
wirings in cmd/mavend/voicewire.go.
Five closed classes move to internal/lexicon — water nouns and drink verbs,
meal words, shower, break, sleep — and internal/morph does the inflection.
Three dictionary quirks are carried as data rather than worked around in code,
each with its reason in the set's note: "вода" and "водой" lemmatise to two
different lemmas, "пил" lemmatises to the saw, and "спал" to "спасть".
Shower is matched exactly rather than by lemma, because the dictionary makes
"душ" and "душа" one word and only one of them is washing. The accusative of an
inanimate noun is its nominative, so exact matching costs nothing he says.
NOT behaviour-preserving, deliberately. Rejected now: "пилот", "водитель",
"заводить", "душа", "душно", "беда", "победа". "есть" and "ел" are left out of
the meal set on purpose — "есть новости по бэкапу" is a question. The
vestigial "ate"/"backup" guard goes with the substring era that needed it.
Measured on the RU routing fixture, classifier+ONNX arm (91 cases): 64/91
(70.3%) before and after, same failing cases. The LLM arm was not measured —
no llama-server reachable from here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>