Compare commits

...

5 Commits

Author SHA1 Message Date
claude d773f1f72b docs: record the ecosystem reach measurement (V-405)
Praxis reach is zero on all twelve cases under both embedders, and it is
structurally impossible rather than merely weak: handlePraxisAct dispatches
on fn equality, and no praxis alias can ever enter the fn slot, because that
slot is filled from the deployment's tool allowlist.

Hexis reach is 9/10. All three services are up and answer; both praxis feeds
are empty, so the gap is entirely on Maven's side of the wire.
2026-08-04 06:22:02 +04:00
claude 80ac7579fb eval: score the reach fixture on both embedders (V-405)
TestReachDerivation pins the gate order the scorer depends on, so a change to
actions_act.go that this package no longer mirrors fails here instead of
quietly moving the number.

The hash baseline asserts overreach and nothing else. Accuracy on the hash
embedder measures the confidence gate, not reach. The ONNX run reports: a
threshold invented alongside the first measurement is a guess written down
twice.
2026-08-04 06:22:02 +04:00
claude bb6cb6d185 eval: derive and score which ecosystem service a turn reaches (V-405)
Reach mirrors actionAct and hexisBeforeClarify: praxis needs an act plus a
fn slot equal to a capability alias, hexis needs an act plus non-empty text,
and a clarified act with text reaches hexis before the question is asked.

The two miss directions are counted apart because they cost different
things. Missed means he asks again. Overreach means a turn arrived at a
mutating path nobody sent it to, and he never gets asked about that one.

PraxisAliases is a copy of the registry in cmd/mavend. The registry lives in
package main and cannot be imported, and lifting it out is a refactor this
measurement should not be carrying.
2026-08-04 06:22:02 +04:00
claude a9067a5754 make: add eval-reach (V-405)
Scores the ecosystem reach fixture. Same MAVEN_ONNX_LIB deal as eval-router:
without it only the deterministic hash ratchet runs.
2026-08-04 06:22:02 +04:00
claude 787cc56522 eval: add the held-out ecosystem reach fixture (V-405)
30 act-shaped Russian utterances, each with the service it must arrive at:
10 hexis, 12 praxis, 8 that must reach neither. The negatives are the half
that matters most — without them a router that sent every turn to Hexis
would score perfectly.

want_capability records which Praxis arm the fn should land on. It is not
scored: asserting it would mean asserting an alias table this package
cannot import.
2026-08-04 06:21:40 +04:00
5 changed files with 916 additions and 1 deletions
+9 -1
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -119,6 +119,14 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
eval-router:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
# eval-reach — score the held-out ecosystem reach fixture (internal/router/eval,
# ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive
# at Praxis or Hexis", which routing accuracy alone does not say. Same
# MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash
# ratchet runs. Vikunja #405.
eval-reach:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
# Answers "can she find the note again when it matters": recall@1, recall@3,
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
+100
View File
@@ -0,0 +1,100 @@
# Ecosystem reach, measured — 2026-08-04
Vikunja #405. Measured at `86dcd99` on the 30-case held-out fixture
`internal/router/eval/ru_ecosystem_v1.json`, scored by `make eval-reach`.
**Praxis reach is zero. Not low — zero, on all twelve cases, under both embedders.**
## What was measured
Reach is where an utterance *arrives*, not what it achieves. The derivation is in
`internal/router/eval/reach.go` and mirrors `actionAct` in `cmd/mavend/actions_act.go`:
- **Praxis** needs `IntentAct` and a fn slot whose value is one of the aliases in
`praxisCapabilities`.
- **Hexis** needs `IntentAct` and non-empty `Slots.Text`. It also fires from
`hexisBeforeClarify`, so a clarified act with text reaches Hexis before the clarify
question is ever asked.
- Everything else stays inside Maven.
The fixture holds 10 Hexis cases, 12 Praxis cases and 8 negatives. The negatives carry
the expensive direction: an utterance that reaches a mutating path it had no business
reaching is worse than one that never arrives, because he never gets asked about it.
## The numbers
| Path | Reached the right place | Missed | Overreach | Wrong service | p50 |
|---|---|---|---|---|---|
| classifier + hash embedder | 7/30 (23.3%) | 22 | 1 | 0 | 14.8µs |
| classifier + e5-small (deployed) | 16/30 (53.3%) | 11 | 1 | 2 | 23.8ms |
Split by target, on the deployed embedder:
| Want | Passed |
|---|---|
| hexis | 9/10 |
| none | 7/8 |
| **praxis** | **0/12** |
## The finding
Hexis reach is fine. Nine of ten act-shaped home and homelab utterances land on the
entity resolver, which is what the gate was built to do: it only needs the intent and
some text, and both the act grammar and the embedder produce those.
Praxis reach is structurally impossible from free Russian, and the fixture makes that
visible for the first time. `handlePraxisAct` dispatches on exact equality between
`Slots.Fn` and a capability alias. The fn slot is filled by `DefaultActMatcher`, whose
allowlist is the deployment's enabled tool names — `перезапусти`, `выключи`, and so on.
No Praxis alias is in that list, so no utterance can ever put one in the slot. The
Russian aliases in `praxisCapabilities` (`готово`, `принято`, `игнорировать`) read as if
they match speech and they do not: they are compared against a fn slot, never against
the utterance.
That means the whole lifecycle half of the Praxis contract — acknowledge, resolve,
ignore, pin — has no voice path at all. Attention and changes have none either.
Three of the twelve got as far as the wrong place, which is the same defect seen from the
other side: `"готово, закрывай"` and `"как дела у праксиса"` route to act with text, so
they fall past the Praxis check into the Hexis one and go to entity resolution instead.
## The live services
All three are up and answer. With `no_proxy` set for the loopback (the host's `http_proxy`
answers 503 for 127.0.0.1, which is the same trap `llmrouter_test.go` documents):
```
127.0.0.1:8989/health -> {"status":"ok"} praxis
127.0.0.1:9740/health -> {"status":"ok"} nexus
127.0.0.1:9741/health -> {"status":"ok"} hexis
127.0.0.1:8989/api/v1/tools/attention?limit=3 -> []
127.0.0.1:8989/api/v1/tools/changes?limit=3 -> []
```
So the boundary is not the problem, and an end-to-end run today would add nothing: both
Praxis feeds are empty, so even a perfect reach score would produce "ничего не требует
внимания". The gap is entirely on Maven's side of the wire.
## Not measured
**The resident model.** The LLM router is the deployed default, and these numbers are the
classifier only. `mavend` spawns its llama-server on a container-local port
(127.0.0.1:40063 inside `maven-mavend-1`), unreachable from the host, and the workstation
at 192.168.1.105:8080 was down. The classifier is the failure floor and it is what always
answers, so the floor is worth knowing on its own — but the LLM router could fill the fn
slot with a literal `list_attention`, since the grammar lets it emit any string. Whether
it does is the open question, and the fixture is ready for it.
## What this argues for
Not a new intent. The seven are frozen by prompt parity with the training workspace.
The cheap fix is stage 0: a grammar per Praxis capability that sets `Slots.Fn` to the
canonical arm name, the same trick `AgendaQueryGrammars` used to take agenda questions off
the model. It costs one regex per capability on every turn and it is deterministic, which
for a lifecycle verb is the right trade — "отметь это как сделанное" should never be a
similarity guess.
The second fix is smaller and separate: `entity_attention` aliases to grammar names only,
so scoped attention ("что там с нексусом") needs a grammar before it can be reached at
all.
+295
View File
@@ -0,0 +1,295 @@
package eval
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/kami/maven/internal/router"
)
//go:embed ru_ecosystem_v1.json
var reachFixtureJSON []byte
// Service — where an utterance arrives. Reach stops at the service boundary on
// purpose: whether Nexus knows the entity and whether Hexis holds a capability
// for it are those services' answers, and a fixture that asserted them would be
// measuring three systems and reporting one number.
type Service string
const (
// ServiceNone — the turn stays inside Maven.
ServiceNone Service = "none"
// ServicePraxis — handlePraxisAct dispatched to a capability.
ServicePraxis Service = "praxis"
// ServiceHexis — the text reached resolveEntityReference.
ServiceHexis Service = "hexis"
)
// ReachCase — one utterance and the service it must arrive at.
//
// WantCapability is informational and unscored: it says which Praxis arm the fn
// slot should land on, so a failure reads as "reached Praxis, wrong arm" rather
// than only "reached Praxis". Scoring it would assert an alias table this
// package cannot import.
type ReachCase struct {
ID string `json:"id"`
Utterance string `json:"utterance"`
Lang string `json:"lang"`
WantService Service `json:"want_service"`
WantCapability string `json:"want_capability"`
Tags []string `json:"tags"`
Note string `json:"note"`
}
// ReachFixture — the versioned envelope, same shape as Fixture.
type ReachFixture struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
ReferenceNow string `json:"reference_now"`
Notes []string `json:"notes"`
Cases []ReachCase `json:"cases"`
}
// LoadReach returns the embedded ecosystem fixture.
func LoadReach() (ReachFixture, error) {
var f ReachFixture
if err := json.Unmarshal(reachFixtureJSON, &f); err != nil {
return ReachFixture{}, fmt.Errorf("parse reach fixture: %w", err)
}
if f.SchemaVersion != SchemaVersion {
return ReachFixture{}, fmt.Errorf("reach fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
}
if len(f.Cases) == 0 {
return ReachFixture{}, fmt.Errorf("reach fixture has no cases")
}
return f, nil
}
// Now — the fixture's reference clock, same contract as Fixture.Now.
func (f ReachFixture) Now() (time.Time, error) {
t, err := time.Parse(time.RFC3339, f.ReferenceNow)
if err != nil {
return time.Time{}, fmt.Errorf("parse reference_now %q: %w", f.ReferenceNow, err)
}
return t, nil
}
// PraxisAliases — the fn slots handlePraxisAct dispatches on, copied from
// praxisCapabilities in cmd/mavend/ecosystem_acts.go.
//
// It is a copy because the registry lives in package main and cannot be
// imported. That is a drift risk and it is deliberate: the alternative is
// lifting the whole capability registry out of the daemon, which is a
// refactor this measurement should not be carrying. TestPraxisAliasesShape
// asserts the arms are all present; a new capability adds a line here.
var PraxisAliases = map[string]string{
"list_attention": "list_attention",
"attention": "list_attention",
"внимание": "list_attention",
"что требует внимания": "list_attention",
"что нового": "list_attention",
"acknowledge_item": "acknowledge_item",
"принято": "acknowledge_item",
"понял": "acknowledge_item",
"поняла": "acknowledge_item",
"resolve_item": "resolve_item",
"сделано": "resolve_item",
"готово": "resolve_item",
"решено": "resolve_item",
"ignore_item": "ignore_item",
"игнорировать": "ignore_item",
"неважно": "ignore_item",
"pin_item": "pin_item",
"закрепить": "pin_item",
"list_changes": "list_changes",
"changes": "list_changes",
"изменения": "list_changes",
"что изменилось": "list_changes",
"entity_attention": "entity_attention",
"entity_status": "entity_attention",
}
// Reach derives which ecosystem service a decision arrives at, assuming all
// three are configured. It mirrors actionAct in cmd/mavend/actions_act.go and
// hexisBeforeClarify in cmd/mavend/ecosystem_acts.go, in their order:
//
// 1. A clarified act with text and no fn reaches Hexis before the clarify
// question is ever asked. That path runs on the raw slots, so the matcher
// does not get to fill fn first.
// 2. Otherwise the act matcher may earn a fn from the text slot.
// 3. A fn that is a Praxis capability alias dispatches to Praxis.
// 4. Non-empty text reaches Hexis.
// 5. Anything else stays inside Maven.
//
// It returns the service and, for Praxis, the capability the fn landed on.
func Reach(d router.Decision, m router.ActMatcher) (Service, string) {
if d.Intent != router.IntentAct {
return ServiceNone, ""
}
if d.Clarify {
if !d.Slots.HasFn && d.Slots.Text != "" {
return ServiceHexis, ""
}
return ServiceNone, ""
}
fn, hasFn := d.Slots.Fn, d.Slots.HasFn
if !hasFn && d.Slots.Text != "" && m != nil {
if matched, _, ok := m.Match(d.Slots.Text); ok {
fn, hasFn = matched, true
}
}
if hasFn {
if capability, ok := PraxisAliases[strings.ToLower(strings.TrimSpace(fn))]; ok {
return ServicePraxis, capability
}
}
if d.Slots.Text != "" {
return ServiceHexis, ""
}
return ServiceNone, ""
}
// ReachOutcome — one scored case.
type ReachOutcome struct {
Case ReachCase
Decision router.Decision
Got Service
Capability string
Err error
Latency time.Duration
Pass bool
Reason string
}
// ReachReport — the aggregate.
//
// The two miss directions are kept apart because they cost different things.
// Missed is an utterance that should have reached a service and did not: he
// asks again, or does it himself. Overreach is an utterance that reached a
// service it had no business reaching, and on the Hexis side that is one
// resolution away from executing a capability nobody asked for.
type ReachReport struct {
Name string
Total int
Passed int
Missed int
Overreach int
// WrongService — reached a service, but the other one.
WrongService int
// WrongCapability — reached Praxis on the wrong arm. Reported, not failed.
WrongCapability int
Errors int
Outcomes []ReachOutcome
// ByService is keyed by the fixture's want_service.
ByService map[string]TagStat
ByTag map[string]TagStat
P50, P95 time.Duration
Max time.Duration
}
// Accuracy — fraction of cases that arrived where the fixture says they must.
func (r ReachReport) Accuracy() float64 {
if r.Total == 0 {
return 0
}
return float64(r.Passed) / float64(r.Total)
}
// ScoreReach runs every case through the router and derives where it lands. It
// never fails the run on a route error: an erroring case scores as a miss and
// is counted, because "the model was down" and "the router was wrong" are
// different numbers.
func ScoreReach(ctx context.Context, name string, r Router, m router.ActMatcher, f ReachFixture) (ReachReport, error) {
now, err := f.Now()
if err != nil {
return ReachReport{}, err
}
rep := ReachReport{
Name: name,
Total: len(f.Cases),
ByService: map[string]TagStat{},
ByTag: map[string]TagStat{},
}
lat := make([]time.Duration, 0, len(f.Cases))
for _, c := range f.Cases {
start := time.Now()
d, err := r.Route(ctx, c.Utterance, now)
o := ReachOutcome{Case: c, Decision: d, Err: err, Latency: time.Since(start)}
lat = append(lat, o.Latency)
switch {
case err != nil:
rep.Errors++
o.Reason = fmt.Sprintf("route error: %v", err)
default:
o.Got, o.Capability = Reach(d, m)
switch {
case o.Got == c.WantService:
o.Pass = true
if c.WantCapability != "" && o.Capability != c.WantCapability {
rep.WrongCapability++
o.Reason = fmt.Sprintf("reached praxis on %q, want %q", o.Capability, c.WantCapability)
}
case c.WantService == ServiceNone:
rep.Overreach++
o.Reason = fmt.Sprintf("reached %s, want none (intent %q, clarify %v, text %q)",
o.Got, d.Intent, d.Clarify, d.Slots.Text)
case o.Got == ServiceNone:
rep.Missed++
o.Reason = fmt.Sprintf("stayed local, want %s (intent %q, clarify %v, fn %q)",
c.WantService, d.Intent, d.Clarify, d.Slots.Fn)
default:
rep.WrongService++
o.Reason = fmt.Sprintf("reached %s, want %s (fn %q)", o.Got, c.WantService, d.Slots.Fn)
}
}
if o.Pass {
rep.Passed++
}
bump(rep.ByService, string(c.WantService), o.Pass)
for _, tag := range c.Tags {
bump(rep.ByTag, tag, o.Pass)
}
rep.Outcomes = append(rep.Outcomes, o)
}
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
if len(lat) > 0 {
rep.Max = lat[len(lat)-1]
}
return rep, nil
}
// String renders the report.
func (r ReachReport) String() string {
var b strings.Builder
fmt.Fprintf(&b, "%s: %d/%d reached the right place (%.1f%%)\n", r.Name, r.Passed, r.Total, 100*r.Accuracy())
fmt.Fprintf(&b, " missed: %d (should have reached, didn't) | overreach: %d (reached, shouldn't) | wrong service: %d | wrong praxis arm: %d | errors: %d\n",
r.Missed, r.Overreach, r.WrongService, r.WrongCapability, r.Errors)
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
fmt.Fprintf(&b, " by want: %s\n", renderStats(r.ByService))
fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag))
return b.String()
}
// Failures — the per-case detail, sorted by ID so two runs diff cleanly. A
// passing case with a wrong Praxis arm is listed too: it carries a reason.
func (r ReachReport) Failures() string {
var b strings.Builder
out := append([]ReachOutcome(nil), r.Outcomes...)
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
for _, o := range out {
if o.Reason == "" {
continue
}
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, o.Reason)
}
return b.String()
}
+228
View File
@@ -0,0 +1,228 @@
package eval
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/kami/maven/internal/router"
)
func TestLoadReachFixture(t *testing.T) {
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
if _, err := f.Now(); err != nil {
t.Fatalf("Now: %v", err)
}
valid := map[Service]bool{ServiceNone: true, ServicePraxis: true, ServiceHexis: true}
seen := map[string]bool{}
byService := map[Service]int{}
for _, c := range f.Cases {
if c.ID == "" || seen[c.ID] {
t.Errorf("case %q: empty or duplicate id", c.ID)
}
seen[c.ID] = true
if !valid[c.WantService] {
t.Errorf("%s: want_service %q, want none|praxis|hexis", c.ID, c.WantService)
}
if c.Lang != "ru" && c.Lang != "en" {
t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang)
}
// A capability only means something on the Praxis side, and it must
// name an arm that exists — otherwise the case asserts a target the
// dispatch can never hit.
if c.WantCapability != "" {
if c.WantService != ServicePraxis {
t.Errorf("%s: want_capability on %q", c.ID, c.WantService)
}
if PraxisAliases[c.WantCapability] != c.WantCapability {
t.Errorf("%s: capability %q is not a praxis arm", c.ID, c.WantCapability)
}
}
byService[c.WantService]++
}
// Coverage floor. The negative cases matter most: without them a router
// that sent everything to Hexis would score perfectly.
for s := range valid {
if byService[s] < 5 {
t.Errorf("want_service %q has %d cases, want >= 5", s, byService[s])
}
}
}
// TestReachFixtureIsHeldOut — same rule as TestFixtureIsHeldOut. The classifier
// routes by similarity to frozen seeds, so a case copied from models/seeds
// would measure memorisation rather than reach.
func TestReachFixtureIsHeldOut(t *testing.T) {
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
seeds := loadSeeds(t)
for _, c := range f.Cases {
if src, ok := seeds[normalize(c.Utterance)]; ok {
t.Errorf("%s: %q is verbatim in %s — not held out", c.ID, c.Utterance, src)
}
}
}
// TestReachDerivation pins the gate order that ScoreReach depends on. These are
// the four branches of actionAct plus the clarify pre-empt, asserted directly
// so a change to cmd/mavend/actions_act.go that this package no longer mirrors
// fails here rather than silently moving the score.
func TestReachDerivation(t *testing.T) {
m := router.DefaultActMatcher{Fns: actFns}
cases := []struct {
name string
dec router.Decision
want Service
capability string
}{
{
name: "act with a praxis alias in the fn slot",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "list_attention", HasFn: true}},
want: ServicePraxis, capability: "list_attention",
},
{
name: "act with a russian praxis alias",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "готово", HasFn: true}},
want: ServicePraxis, capability: "resolve_item",
},
{
name: "act with an entity but no fn earns one from the matcher",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: "выключи свет в спальне"}},
want: ServiceHexis,
},
{
name: "act with a non-praxis fn and text goes to hexis",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти гитею"}},
want: ServiceHexis,
},
{
name: "act with a fn and no text stays local",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true}},
want: ServiceNone,
},
{
name: "a clarified act with text still reaches hexis",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "выключи это"}},
want: ServiceHexis,
},
{
name: "a clarified act that already has a fn does not",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти"}},
want: ServiceNone,
},
{
name: "no intent but act reaches nothing",
dec: router.Decision{Intent: router.IntentQuery, Slots: router.Slots{Fn: "list_attention", HasFn: true, Text: "что требует внимания"}},
want: ServiceNone,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, capability := Reach(tc.dec, m)
if got != tc.want {
t.Errorf("Reach = %q, want %q", got, tc.want)
}
if capability != tc.capability {
t.Errorf("capability = %q, want %q", capability, tc.capability)
}
})
}
}
// TestPraxisAliasesShape — the copy of praxisCapabilities in reach.go is a
// drift risk (see its comment). This does not close it, but it does catch the
// cheap half: an arm losing all its aliases, or an alias pointing at an arm
// that no longer has a canonical name.
func TestPraxisAliasesShape(t *testing.T) {
arms := map[string]int{}
for alias, capability := range PraxisAliases {
if alias == "" || capability == "" {
t.Errorf("empty alias or capability: %q → %q", alias, capability)
}
arms[capability]++
}
for _, want := range []string{
"list_attention", "acknowledge_item", "resolve_item",
"ignore_item", "pin_item", "list_changes", "entity_attention",
} {
if arms[want] == 0 {
t.Errorf("praxis arm %q has no aliases", want)
}
// Every arm must be reachable by its own name, which is the alias the
// LLM router's fn slot actually emits.
if PraxisAliases[want] != want {
t.Errorf("arm %q does not alias to itself", want)
}
}
}
// TestReachBaselineHash — the deterministic ratchet. Same deal as
// TestClassifierBaseline: the hash embedder never clears the confidence gate on
// paraphrases, so almost everything lands on clarify, and the number this
// asserts is the overreach count rather than the accuracy.
//
// Overreach is the direction worth a hard assertion. An utterance that should
// stay inside Maven and instead resolves an entity through Nexus is one Hexis
// capability away from executing something nobody asked for, and unlike a miss
// he never gets asked about it.
func TestReachBaselineHash(t *testing.T) {
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
m := router.DefaultActMatcher{Fns: actFns}
rep, err := ScoreReach(context.Background(), "reach: classifier+hash",
newBaselineRouter(t, router.NewHashEmbedder(1024), nil), m, f)
if err != nil {
t.Fatalf("ScoreReach: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
if rep.Overreach > 4 {
t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s",
rep.Overreach, rep.Failures())
}
}
// TestReachBaselineONNX — the deployed configuration: the cascade with the
// multilingual e5 embedder. Opt-in via MAVEN_ONNX_LIB, same as TestONNXBaseline,
// because deps/ is gitignored. `make eval-reach` points it at the vendored copy.
//
// Reports rather than asserts. This is the number Vikunja #405 asked for, and a
// threshold invented alongside the first measurement is not a ratchet, it is a
// guess written down twice.
func TestReachBaselineONNX(t *testing.T) {
lib := os.Getenv("MAVEN_ONNX_LIB")
if lib == "" {
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
}
model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx")
tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json")
for _, p := range []string{lib, model, tok} {
if _, err := os.Stat(p); err != nil {
t.Skipf("missing %s: %v", p, err)
}
}
emb, err := router.NewONNXEmbedder(model, tok, lib)
if err != nil {
t.Skipf("onnx embedder unavailable: %v", err)
}
defer emb.Close()
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
rep, err := ScoreReach(context.Background(), "reach: classifier+onnx",
newBaselineRouter(t, emb, nil), router.DefaultActMatcher{Fns: actFns}, f)
if err != nil {
t.Fatalf("ScoreReach: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
}
+284
View File
@@ -0,0 +1,284 @@
{
"schema_version": 1,
"name": "ru_ecosystem_v1",
"reference_now": "2026-08-04T10:00:00+03:00",
"notes": [
"Held out from models/seeds/*.txt, same rule as ru_routing_v1: a case that is verbatim a seed measures memorisation.",
"want_service is where the utterance must ARRIVE, not what it must achieve. Praxis means handlePraxisAct dispatched to a capability; hexis means the text reached resolveEntityReference. Whether the entity exists in Nexus and the capability exists in Hexis is the services' answer, not the router's.",
"The reach model is derived in reach.go from cmd/mavend/actions_act.go. Praxis needs IntentAct AND a fn slot whose value is a praxis capability alias. Hexis needs IntentAct AND non-empty text, and it also fires from hexisBeforeClarify, so a clarified act still reaches it.",
"want_capability is informational and is not scored. It records which praxis arm the fn should land on, so a report can say WHERE a praxis reach went wrong rather than only that it did.",
"none means the utterance must NOT reach either service. Those cases are the expensive direction: a note or a query that lands on a mutating Hexis path is a wrong act, not a missed one."
],
"cases": [
{
"id": "eco-ru-001",
"utterance": "выключи свет в спальне",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": "The canonical act. Verb in the allowlist, entity text after it."
},
{
"id": "eco-ru-002",
"utterance": "включи свет на кухне, пожалуйста",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-003",
"utterance": "перезапусти вукунью",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": "Service name in Russian, declined. Nexus owns the alias, the router only has to hand it over."
},
{
"id": "eco-ru-004",
"utterance": "перезагрузи прокси",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": ""
},
{
"id": "eco-ru-005",
"utterance": "останови гитею на минуту",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": "Trailing qualifier the entity resolver has to survive."
},
{
"id": "eco-ru-006",
"utterance": "запусти бэкап на нексусе",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": ""
},
{
"id": "eco-ru-007",
"utterance": "закрой шторы в комнате",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-008",
"utterance": "открой гараж",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": "Two words. The single-token thinner spares it, the verb ending carries the subject."
},
{
"id": "eco-ru-009",
"utterance": "поставь чайник",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-010",
"utterance": "выключи телевизор в гостиной",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-011",
"utterance": "что требует внимания",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_attention",
"tags": ["attention", "reading"],
"note": "The task's own example. Query-shaped, and the reach model only dispatches to Praxis from IntentAct, so this is the case most likely to miss."
},
{
"id": "eco-ru-012",
"utterance": "что сейчас требует внимания",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_attention",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-013",
"utterance": "что нового по проектам",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_attention",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-014",
"utterance": "что изменилось за сегодня",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_changes",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-015",
"utterance": "покажи изменения",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_changes",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-016",
"utterance": "отметь это как сделанное",
"lang": "ru",
"want_service": "praxis",
"want_capability": "resolve_item",
"tags": ["lifecycle", "mutating"],
"note": "The task's second example. The item id is the value slot and is missing here, so the capability asks for it — reaching Praxis with no id is still reaching Praxis."
},
{
"id": "eco-ru-017",
"utterance": "готово, закрывай",
"lang": "ru",
"want_service": "praxis",
"want_capability": "resolve_item",
"tags": ["lifecycle", "mutating"],
"note": ""
},
{
"id": "eco-ru-018",
"utterance": "принято, я это видел",
"lang": "ru",
"want_service": "praxis",
"want_capability": "acknowledge_item",
"tags": ["lifecycle", "mutating"],
"note": "Acknowledged is not resolved. The alias must not collapse into the resolve arm."
},
{
"id": "eco-ru-019",
"utterance": "игнорировать это пока",
"lang": "ru",
"want_service": "praxis",
"want_capability": "ignore_item",
"tags": ["lifecycle", "mutating"],
"note": ""
},
{
"id": "eco-ru-020",
"utterance": "закрепи этот пункт",
"lang": "ru",
"want_service": "praxis",
"want_capability": "pin_item",
"tags": ["lifecycle", "mutating"],
"note": ""
},
{
"id": "eco-ru-021",
"utterance": "что там с нексусом",
"lang": "ru",
"want_service": "praxis",
"want_capability": "entity_attention",
"tags": ["attention", "reading"],
"note": "Scoped attention. The alias list for this arm is grammar names only, so free Russian reaching it depends on a grammar that does not exist yet."
},
{
"id": "eco-ru-022",
"utterance": "как дела у праксиса",
"lang": "ru",
"want_service": "praxis",
"want_capability": "entity_attention",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-023",
"utterance": "напомни завтра в девять позвонить маме",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "A reminder is Maven's own store. Nothing outside her hears it."
},
{
"id": "eco-ru-024",
"utterance": "запиши что мне понравился этот подход",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "A note carrying a verb-shaped word. It must not become an act."
},
{
"id": "eco-ru-025",
"utterance": "какая завтра погода",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": ""
},
{
"id": "eco-ru-026",
"utterance": "мой рост сто восемьдесят два",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "A fact about him. The personal boundary means this never leaves the box."
},
{
"id": "eco-ru-027",
"utterance": "как ты сегодня",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": ""
},
{
"id": "eco-ru-028",
"utterance": "выключи",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative", "ambiguous"],
"note": "A verb with no target. Reaching Hexis with an empty entity text is the failure this case guards: it must clarify, not resolve."
},
{
"id": "eco-ru-029",
"utterance": "сделай это",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative", "ambiguous"],
"note": "The known dangerous case. It carries text, so under the current gates it does reach Hexis — the fixture says it should not, and that disagreement is the finding."
},
{
"id": "eco-ru-030",
"utterance": "во сколько у меня встреча",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "Stage 0 routes agenda questions to query. The calendar is Maven's, not Praxis's."
}
]
}