Files
Maven/cmd/semantic-router-experiment/slice21/guard.go
T
claude fa98e4722e router/semantic: slice 21 deterministic execution-frame guard engine, fixtures, runner and emit step
The guard answers one question — may this utterance become an executable
action — as a three-way policy gate (permissive / blocked / ambiguous) and
never decides what the utterance is. Rules are the encoding of the measured
slice-20 dev-pool discriminators: 126 capability-question rows are 42/42/42
addressed / bare-ability / bare-future; 127 bare можешь+пожалуйста rows are
100% action; can-you-please is 100% action. Reuses the shipped prohibition
parser, morph finiteness and lexicon fillers; reason vocabulary is closed.

Slice 21 (task/725, brief after the accepted slice 20).
2026-09-07 23:43:47 +04:00

660 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Guard is the slice-21 deterministic execution-frame engine (experiment-only).
//
// It answers one question: given an utterance, what is its execution-frame
// eligibility as a three-way gate — permissive, blocked, ambiguous — and why.
// It never decides what an utterance IS (that stays with the route classifier);
// it only decides whether an utterance may become an executable action at all.
// The policy is asymmetric on purpose: blocked and ambiguous must never
// execute, and permissive only means "no blocking speech-act evidence exists",
// not "execute this".
//
// It reuses the shipped deterministic routers rather than inventing new ones:
//
// router.ParseCommandProhibition / IsCommandProhibition direct negative commands
// morph.IsVerbForm / morph.Lemma verb mood and finiteness
// lexicon.IsFillerParticle / FirstPerson() politeness and first-person frames
//
// Everything else is closed-class evidence measured on the frozen slice-20 dev
// pool (§"measured discriminators" in the brief): 126 capability-question rows
// split 42/42/42 across ты-addressed, bare ability (умеешь), and bare future
// (сможешь) modality; 127 bare "можешь, пожалуйста" rows are 100% action;
// "can you … , please" (English frame + Russian imperative) is 100% action.
// The rules below are the encoding of precisely those numbers.
//
// The reason vocabulary is a closed set. Additions are design decisions that
// must land in the report, not silent new branches.
package main
import (
"strings"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/router"
)
// Eligibility is the three-way execution-frame verdict.
type Eligibility int
const (
Permissive Eligibility = iota // no blocking speech-act evidence; downstream route decides
Blocked // a speech act forbids execution (negation, question, report, …)
Ambiguous // not enough evidence either way; must not execute
)
func (e Eligibility) String() string {
switch e {
case Permissive:
return "permissive"
case Blocked:
return "blocked"
default:
return "ambiguous"
}
}
// Reason is a closed set of structural explanations for a verdict.
type Reason string
const (
ReasonCommandProhibition Reason = "command_prohibition"
ReasonCapabilityQuestion Reason = "capability_question"
ReasonReportedSpeech Reason = "reported_speech"
ReasonQuotation Reason = "quotation"
ReasonHypothetical Reason = "hypothetical"
ReasonNegatedCommand Reason = "negated_command"
ReasonExplicitRequest Reason = "explicit_request"
ReasonAmbiguousModal Reason = "ambiguous_modal"
ReasonNoRequestEvidence Reason = "no_request_evidence"
)
func (r Reason) String() string { return string(r) }
// Frame is the verdict for one utterance. Eligibility decides; Reasons explain.
// A frame may carry more than one reason (e.g. a quoted reported command).
type Frame struct {
Eligibility Eligibility
Reasons []Reason
}
// maybeWord is a single-token or multi-token closed expression, e.g. the
// token "не мог бы" covers the three tokens не мog бы when matched as a
// contiguous run ("бы" is itself a bound marker). Multi-token members are
// matched over the reconstructed token text, never over raw text, so
// punctuation boundaries do not defeat them.
type maybeWord struct {
single []string
multi []string // matched as contiguous lowercased token runs
}
func (w maybeWord) in(toks []string, joined string) bool {
if hasAny(toks, w.single) {
return true
}
for _, m := range w.multi {
tm := strings.Join(tokens(m), " ")
if tm != "" && strings.Contains(joined, tm) {
return true
}
}
return false
}
// ── closed evidence sets (all measured on the slice-20 dev pool) ──────────
// wakeAddr is stripped from the left of an utterance before command-form
// detection: "мавен, выключи свет" and "выключи свет" must ride the same
// frame. Closed: the names Maven answers to in the dev pool.
var wakeAddr = []string{"мавен", "maven", "мавэн", "алекса", "алиса", "окей", "эй", "hey"}
// ruAddress are the second-person Russian address tokens. "ты можешь …"
// (with or without politeness) is 42/42 capability-question in the dev pool,
// so any addressed Russian can-form is a capability question, never a request.
var ruAddress = []string{"ты", "тебе", "тебя", "тобой", "тобою", "вы", "вас", "вам", "вами"}
// enAddress is the English second-person address. Unlike Russian, "can you …
// , please" is 96/96 action in the dev pool (English modal frame around a
// Russian imperative), so English address alone never blocks: it routes to the
// politeness arm.
var enAddress = []string{"you", "u", "your"}
// ruCanForms are the present-can verb forms. Bare (no address) "можешь …,
// пожалуйста" is 127/127 action; bare "можешь …" with no politeness is the
// ambiguous bucket (no such rows exist in dev — conservative default).
var ruCanForms = []string{"можешь", "можете", "могу", "можем"}
// ruAbilityForms are future/ability modal forms that read as a question of
// capability regardless of politeness: "сможешь открыть окно, пожалуйста" and
// "умеешь ли ты …" are 0/84 action in the dev pool, so even a polite bare
// form never grants execution. "мог(ла) бы …" and "смог(ла) бы …" are the
// conditional-politeness mask over the same boundary — except the leading
// politeness construction "не мог бы ты …", which the prohibition parser
// already classifies as ordinary modal politeness and must stay permissive.
var ruAbilityForms = maybeWord{
single: []string{
"сможешь", "сможете", "смогу", "сможем", "сумеешь", "сумеете",
"умеешь", "умеете", "способна", "способен", "способно", "способны",
"смог", "смогла", "смогли", "мог", "могла", "могли",
},
multi: []string{
"смог бы", "смогла бы", "смогли бы", "мог бы", "могла бы", "могли бы",
"смочь бы", "мочь бы",
},
}
// politeNegativeModal is the leading "не мог бы ты/вы …" politeness framing the
// prohibition parser exempts as ordinary modal politeness. When it leads the
// utterance the capability stage declines and the frame reads as a request.
var politeNegativeModal = []string{
"не мог бы", "не могла бы", "не могли бы", "не смог бы", "не смогла бы", "не смогли бы",
}
// enCanForms are the English modal can/could tokens.
var enCanForms = []string{"can", "could"}
// politeness is the closed set of politeness fillers. пожалуйста/плиз/please
// are already closed-class filler particles in the lexicon; the добр-forms
// are the only additions the dev pool exercises.
var politeness = maybeWord{
single: []string{"пожалуйста", "плиз", "please"},
multi: []string{"будь добр", "будьте добры", "был бы добр", "были бы добры"},
}
// reportVerbs are the past/third-person report verbs — the frame that reports
// a command rather than issuing it. Second-person imperatives ("скажи",
// "расскажи", "напомни") are deliberately absent: those are requests to
// report, and their clause forms part of the current utterance, not a
// replayed order. Matched as closed list (a report verb outside it is a data
// gap, noted in the report).
var reportVerbs = []string{
"сказал", "сказала", "сказали", "говорил", "говорила", "говорили",
"говорит", "говорят", "попросил", "попросила", "попросили",
"просил", "просила", "просили", "написал", "написала", "написали",
"пишет", "приказал", "приказала", "приказали", "велел", "велела",
"велели", "скомандовал", "скомандовала", "рекомендовал", "рекомендовала",
"посоветовал", "посоветовала", "сообщил", "сообщила", "сообщили",
"объявил", "объявила", "велено", "сказано", "написано", "записано",
}
// reportNouns name a quoted or reported text: "фраза «выключи свет»" is a
// quotation, not a command.
var reportNouns = []string{
"фраза", "фразы", "фразе", "фразу", "слово", "слова", "слове", "словом",
"выражение", "выражения", "цитата", "цитату", "цитате",
"название", "текст", "сообщение", "письмо", "заметка", "заметку",
}
// hypothesisMarkers open a conditional scope.
var hypothesisMarkers = []string{"если", "ежели", "коли", "кабы", "if"}
// illocutionVerbs make a first-person or impersonal clause a request even
// without an imperative form ("я хочу …", "мне нужно …", "надо …").
var illocutionVerbs = maybeWord{
single: []string{
"хочу", "хотел", "хотела", "хотелось", "желаю", "прошу", "просим",
"просил", "просила", "просили", "попросить",
"надо", "нужно", "следует", "пора", "требуется", "придётся", "придется",
"могу", "давай", "давайте",
},
multi: []string{
"хотел бы", "хотела бы", "хочу чтобы", "хотел чтобы", "хотела чтобы",
"могу ли",
},
}
// ── token helpers ─────────────────────────────────────────────────────────
// tokens lowercases and splits on anything that is not a letter or digit,
// matching the router's planTokens discipline ("что-дальше" tokenises like
// "что дальше").
func tokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
func hasTok(toks []string, w string) bool {
for _, t := range toks {
if t == w {
return true
}
}
return false
}
func hasAny(toks, ws []string) bool {
for _, w := range ws {
if hasTok(toks, w) {
return true
}
}
return false
}
func indexTok(toks []string, w string) int {
for i, t := range toks {
if t == w {
return i
}
}
return -1
}
// isFiniteVerb reports a verb form that is not the dictionary (infinitive)
// form: "выключи" is finite, "выключить" is not. A finite verb at command
// position is positive request evidence; an infinitive is not.
func isFiniteVerb(tok string) bool {
if !morph.IsVerbForm(tok) {
return false
}
return morph.Lemma(tok) != tok
}
// isInfinitive reports a token that morph resolves to its own dictionary form
// (the lemma ends in the infinitive ending by construction).
func isInfinitive(tok string) bool {
if !morph.IsVerbForm(tok) {
return false
}
return morph.Lemma(tok) == tok
}
func anyInfinitive(toks []string) bool {
for _, t := range toks {
if isInfinitive(t) {
return true
}
}
return false
}
func anyFiniteVerb(toks []string) bool {
for _, t := range toks {
if isFiniteVerb(t) {
return true
}
}
return false
}
func hasAnyVerb(toks []string) bool {
for _, t := range toks {
if morph.IsVerbForm(t) {
return true
}
}
return false
}
func blocked(rs ...Reason) Frame { return Frame{Eligibility: Blocked, Reasons: rs} }
func ambiguous(rs ...Reason) Frame {
return Frame{Eligibility: Ambiguous, Reasons: rs}
}
func permissive(rs ...Reason) Frame {
return Frame{Eligibility: Permissive, Reasons: rs}
}
// ── quoted spans ─────────────────────────────────────────────────────────
// quotedSpan is a maximal quoted interval in the normalized text.
type quotedSpan struct{ content string }
// quotePairs covers the quoting styles the dev pool and brief fixtures use:
// Russian guillemets, curly double/single quotes, and straight quotes.
var quotePairs = []struct{ open, close string }{
{"«", "»"}, {"„", "\""}, {"“", "”"}, {"", ""}, {"", ""}, {"'", "'"}, {"\"", "\""},
}
// extractQuotedSpans returns the contents of quoted spans in order, in rune
// index space (the text is normalized, so glyphs are single runes). An
// unbalanced delimiter yields no span (best-effort; the conservative
// fallback then applies).
func extractQuotedSpans(t string) []quotedSpan {
runes := []rune(t)
var out []quotedSpan
i := 0
for i < len(runes) {
matched := false
for _, p := range quotePairs {
po := []rune(p.open)
pc := []rune(p.close)
if i+len(po) > len(runes) || string(runes[i:i+len(po)]) != p.open {
continue
}
j := i + len(po)
for j+len(pc) <= len(runes) && string(runes[j:j+len(pc)]) != p.close {
j++
}
out = append(out, quotedSpan{content: string(runes[i+len(po) : j])})
i = j + len(pc)
matched = true
break
}
if !matched {
i++
}
}
return out
}
// ── rule stages (evaluated in this order; a decision is final) ────────────
// Evaluate derives the execution-frame verdict for one utterance.
func Evaluate(text string) Frame {
t := router.NormalizeMatchText(text)
if strings.TrimSpace(t) == "" {
return ambiguous(ReasonNoRequestEvidence)
}
toks := tokens(t)
joined := strings.Join(toks, " ")
// 1. Quotation: a command inside a quoted span is not a command being
// issued now. With a reporting frame outside it is a quotation; a bare
// quote is at best ambiguous.
if f, ok := stageQuotation(t, toks, joined); ok {
return f
}
// 2. Reported speech: a past/third-person report verb governing a command
// clause reports an order to someone else, it does not issue one.
if f, ok := stageReport(t, toks, joined); ok {
return f
}
// 3. Hypothetical: a command scope opened by "если/if" that does not
// continue as a real condition→command is not an execution request.
if f, ok := stageHypothesis(toks, joined); ok {
return f
}
// 4. Direct negative commands: the shipped prohibition parser.
if router.IsCommandProhibition(t) {
return blocked(ReasonCommandProhibition)
}
// 5. Advisory negatives: "не надо/не стоит/не нужно …".
if f, ok := stageAdvisoryNegation(toks); ok {
return f
}
// 6. Capability and permission modality (the measured core).
if f, ok := stageCapability(toks, joined); ok {
return f
}
// 7. Trailing question mark with no modal at play: an uncertain posture,
// never a confirmed executable request.
if strings.HasSuffix(t, "?") {
return ambiguous(ReasonAmbiguousModal)
}
// 8. Positive request evidence.
if hasRequestEvidence(toks, joined) {
return permissive(ReasonExplicitRequest)
}
// 9. No execution pressure at all.
return ambiguous(ReasonNoRequestEvidence)
}
// stageQuotation blocks a quoted command when a reporting frame surrounds it.
func stageQuotation(t string, toks []string, joined string) (Frame, bool) {
spans := extractQuotedSpans(t)
if len(spans) == 0 {
return Frame{}, false
}
commandSpan := false
for _, sp := range spans {
if isCommandishWithin(tokens(sp.content), strings.Join(tokens(sp.content), " ")) {
commandSpan = true
break
}
}
if !commandSpan {
return Frame{}, false
}
reasons := []Reason{ReasonQuotation}
if hasReportFrame(toks, joined) {
reasons = append(reasons, ReasonReportedSpeech)
return Frame{Eligibility: Blocked, Reasons: reasons}, true
}
// a bare quoted command has no reporting frame: refusable but not a
// definite prohibition either (it is at least ambiguous)
return Frame{Eligibility: Ambiguous, Reasons: reasons}, true
}
// isCommandishWithin reports the span content carrying command or capability
// polarity itself — imperative, prohibition, or a can-form.
func isCommandishWithin(toks []string, joined string) bool {
if len(toks) == 0 {
return false
}
if router.IsCommandProhibition(strings.Join(toks, " ")) {
return true
}
if hasAny(toks, ruCanForms) || ruAbilityForms.in(toks, joined) || hasAny(toks, enCanForms) {
return true
}
return anyFiniteVerb(toks)
}
func hasReportFrame(toks []string, joined string) bool {
if hasAny(toks, reportVerbs) {
return true
}
return hasAny(toks, reportNouns)
}
// stageReport blocks when a report frame governs a command clause: an
// infinitive after the report verb, or a quoted imperative. Second-person
// imperatives like "скажи/расскажи" are not in reportVerbs, so a request to
// report ("расскажи мне, что сказал папа") passes through.
func stageReport(t string, toks []string, joined string) (Frame, bool) {
if !hasReportFrame(toks, joined) {
return Frame{}, false
}
last := -1
for i, w := range toks {
if hasTok(reportVerbs, w) || hasTok(reportNouns, w) {
last = i
}
}
if last < 0 {
return Frame{}, false
}
after := toks[last+1:]
if len(after) == 0 {
return Frame{}, false
}
// a quoted command after the frame counts as the governed clause
for _, sp := range extractQuotedSpans(t) {
if isCommandishWithin(tokens(sp.content), strings.Join(tokens(sp.content), " ")) {
return blocked(ReasonReportedSpeech, ReasonQuotation), true
}
}
if anyInfinitive(after) || hasAny(after, []string{"что", "чтобы", "чтоб"}) {
return blocked(ReasonReportedSpeech), true
}
return Frame{}, false
}
// stageHypothesis blocks a conditional scope whose clauses are hypothetical
// (infinitive or subjunctive "бы") rather than a real condition→command.
// "если будет дождь, выключи полив" keeps its imperative continuation and
// passes through; it is a real conditional request, not a hypothetical.
func stageHypothesis(toks []string, joined string) (Frame, bool) {
idx := -1
for _, m := range hypothesisMarkers {
if i := indexTok(toks, m); i >= 0 && (idx < 0 || i < idx) {
idx = i
}
}
if idx < 0 {
return Frame{}, false
}
post := toks[idx+1:]
if len(post) == 0 || hasTok(post, "бы") || anyInfinitive(post) {
return blocked(ReasonHypothetical), true
}
// a real condition clause is not hypothetical: «если будет дождь,
// выключи полив» is a request. The dev dict does not cover «будет», so
// the imperative is looked for anywhere, not just after the marker
// («выключи свет, если будет дождь»).
if !anyFiniteVerb(toks) {
return blocked(ReasonHypothetical), true
}
return permissive(ReasonExplicitRequest), true
}
// stageAdvisoryNegation blocks "не надо/не нужно/не стоит/не следует …".
// (absent from the dev pool; covered by brief fixtures)
func stageAdvisoryNegation(toks []string) (Frame, bool) {
if len(toks) < 3 || toks[0] != "не" {
return Frame{}, false
}
if !hasTok(toks[1:2], "надо") && !hasTok(toks[1:2], "нужно") &&
!hasTok(toks[1:2], "стоит") && !hasTok(toks[1:2], "следует") &&
!hasTok(toks[1:2], "требуется") {
return Frame{}, false
}
rest := toks[2:]
if anyInfinitive(rest) || anyFiniteVerb(rest) || hasAnyVerb(rest) {
return blocked(ReasonNegatedCommand), true
}
return Frame{}, false
}
// stageCapability encodes the measured modal matrix. Returns a decision when
// modality alone settles the frame.
func stageCapability(toks []string, joined string) (Frame, bool) {
// "не мог бы ты …, пожалуйста" style conditional politeness is ordinary
// modal politeness (the prohibition parser exempts it as such): a request.
for _, pref := range politeNegativeModal {
if strings.HasPrefix(joined, pref) {
return permissive(ReasonExplicitRequest), true
}
}
// "… ли" directly after a can-form is a polar capability question:
// "могу ли я …", "можешь ли ты …", "умеешь ли ты …", "можно ли …".
// Checked before the modality arms so the polar reading wins.
if hasTok(toks, "ли") {
for i := 1; i < len(toks); i++ {
if toks[i] != "ли" {
continue
}
prev := toks[i-1]
if hasTok(ruCanForms, prev) || prev == "можно" || hasTok(ruAbilityForms.single, prev) {
return blocked(ReasonCapabilityQuestion), true
}
}
}
ruAddr := hasAny(toks, ruAddress)
ruCan := hasAny(toks, ruCanForms)
ruAbil := ruAbilityForms.in(toks, joined)
enCan := hasAny(toks, enCanForms)
polite := politeness.in(toks, joined)
// addressed Russian can-form: capability question, always blocked.
// ("ты можешь выключить свет, пожалуйста" included — 42/42 non-action.)
if ruAddr && (ruCan || ruAbil) {
return blocked(ReasonCapabilityQuestion), true
}
// ability forms (future/conditional/умеешь) are capability even bare and
// even polite: "сможешь открыть окно, пожалуйста" is 7/7 non-action.
if ruAbil && !ruCan {
return blocked(ReasonCapabilityQuestion), true
}
// bare Russian present can-form: politeness is the request marker.
if ruCan && !ruAddr {
if polite {
return Frame{}, false // modal-request positive evidence is found later
}
return ambiguous(ReasonAmbiguousModal), true
}
// English can/could: "can you …, please" is a request (96/96 action in the
// dev pool; the frame wraps a Russian imperative). Without politeness it
// reads as a capability question and stays ambiguous.
if enCan && !ruAddr {
if polite {
return Frame{}, false // positive modal-request evidence later
}
return ambiguous(ReasonAmbiguousModal), true
}
// "можно" (permission): "можно ли …" is a permission question; a bare
// "можно …" is a politeness-implicature request.
if hasTok(toks, "можно") {
if hasTok(toks, "ли") {
return blocked(ReasonCapabilityQuestion), true
}
return Frame{}, false
}
// bare "могу": a self-capability statement, not a request.
if hasTok(toks, "могу") && !hasTok(toks, "ли") {
return ambiguous(ReasonAmbiguousModal), true
}
return Frame{}, false
}
// hasRequestEvidence is the positive permissive trigger, reached only after
// every block/ambiguity stage above has declined.
func hasRequestEvidence(toks []string, joined string) bool {
polite := politeness.in(toks, joined)
enCan := hasAny(toks, enCanForms)
// 1. politeness + a verb (or an English modal) is explicit request
// evidence: "можешь выключить свет, пожалуйста", "can you останови …,
// please", "выключи свет, пожалуйста".
if polite && (hasAnyVerb(toks) || enCan) {
return true
}
// 2. first-person illocution frame: "я хочу …", "мне нужно …".
if hasAny(toks, lexicon.FirstPerson()) && illocutionVerbs.in(toks, joined) {
return true
}
// 3. impersonal need: "надо …", "нужно …", "пора …".
if hasAny(toks, []string{"надо", "нужно", "следует", "пора", "требуется", "придётся", "придется"}) {
return true
}
// 4. permission-implicature request: "можно выключить свет".
if hasTok(toks, "можно") && !hasTok(toks, "ли") {
return true
}
// 5. reminder request in the parser's own exemption scope: the
// prohibition parser declines «не забудь напомнить про свет» as a
// reminder, not a prohibition — carry that into a request.
if strings.HasPrefix(joined, "не забудь") && hasAny(toks, lexicon.ReminderVerbs()) {
return true
}
// 6. leading finite verb (imperative or otherwise tensed verb at command
// position): "выключи свет", "покажи что запущено". Address and filler
// particles are stripped first, so "мавен, выключи свет" rides the same
// frame.
lead := toks
for len(lead) > 0 {
first := lead[0]
if !lexicon.IsFillerParticle(first) && !hasTok(wakeAddr, first) && !hasTok(ruAddress, first) {
break
}
lead = lead[1:]
}
if len(lead) > 0 && isFiniteVerb(lead[0]) {
return true
}
return false
}