Files
Maven/internal/router/eval/reach.go
T
claude a97764c5f7 Add seven stage 0 frames and tighten three more (V-720)
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>
2026-08-15 17:18:48 +04:00

296 lines
10 KiB
Go

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 a named entity target 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. An act with a named entity target 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 && router.ActHasEntityTarget(d) {
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 router.ActHasEntityTarget(d) {
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()
}