Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d773f1f72b | |||
| 80ac7579fb | |||
| bb6cb6d185 | |||
| a9067a5754 | |||
| 787cc56522 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user