From fa799bc051f2b9af8e2d88140bea60a3a27c665c Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:05:59 +0400 Subject: [PATCH] mavend: run the persona checks over the clarify prose, document the proposal cooldown clarifyExpiredVariants and clarifyGaveUp are hand-written Russian that the phrasing eval never sees, because they never pass through the phraser. They carry feminine self-reference and a plain imperative, and they are the lines a later edit reaches for a synonym in. A table test now runs the eval's own feminine, his-gender, address and cringe checks over them and over clarifyQuestions. The apology clause of the cringe check is skipped with its reason written down: it exists so a greenlit nudge is not undercut, and a reply to a request she failed to parse is the opposite case. Also two notes and no behaviour change. announceProposal now says what its cooldown does and does not do: detectAndPropose returns non-nil only for a newly created row, so the first tick over a populated history announces one pattern and silences the rest permanently, and the cooldown only spaces genuinely new pairs found later. A queue would be needed for "one per day until each is mentioned". The duplicated Cooldown default is explained as cover for a tickLoop built in a test without going through Load. The -reembed flag help says the daemon does not answer until the backfill finishes. Found in review of #50, #54. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/clarify_test.go | 41 ++++++++++++++++++++++++++++++++++++++ cmd/mavend/main.go | 2 +- cmd/mavend/tick.go | 17 ++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index c895c35..e07298a 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -10,6 +10,7 @@ import ( "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser/eval" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tool" @@ -394,6 +395,46 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) { } } +// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that +// the phrasing eval never sees, because they never go through the phraser. They +// carry feminine self-reference ("ждала", "отпустила") and address him with a +// plain imperative, and they are exactly the kind of string someone later edits +// reaching for a synonym. Run the eval's own persona checks over them here. +func TestClarifyProseHoldsThePersona(t *testing.T) { + // Only the persona checks. Length and on-topic do not apply: these are not + // nudges, they have no rule to be on topic about, and the expiry lines are + // deliberately longer than a nudge ceiling. + want := map[string]bool{ + eval.CheckFeminine: true, + eval.CheckHisGender: true, + eval.CheckAddress: true, + eval.CheckCringe: true, + } + lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...) + for _, q := range clarifyQuestions { + lines = append(lines, q) + } + for _, line := range lines { + for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") { + // The apology clause of the cringe check is scoped to nudges: it + // exists because apologising for a greenlit nudge undermines it. + // These lines are the opposite case. She did not understand him, or + // she let his request go, and "прости" there is ordinary speech + // rather than grovelling. Every other cringe rule still applies: + // pet names, emoji, exclamations, fake concern, praise. + // checkCringe returns the first break it finds, so this skip also + // hides a later one in the same line. Kept narrow on purpose: it + // only fires on a leading "apology (…)" detail. + if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") { + continue + } + if want[r.Name] && !r.Pass { + t.Errorf("%q fails %s: %s", line, r.Name, r.Detail) + } + } + } +} + // TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the // question expires, he comes back and answers a confirm that is still parked. // The confirm turn used to return before the notice was even computed, so he diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index d6df63a..a384933 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -122,7 +122,7 @@ func main() { func run(args []string) error { cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config") wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)") - reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)") + reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)") flag.CommandLine.Parse(args) reembedOnStart = *reembed cfg, err := config.Load(*cfgPath) diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index b3b7c7d..d1c6eac 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -417,6 +417,8 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays) // One announcement per tick at most, whatever the scan turned up. The // rest are on /routines; they are not lost, they are just not shouted. + // Nor are they queued: the row now exists, so no later tick re-detects + // them and they are never announced. See announceProposal. if announced { continue } @@ -437,6 +439,21 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop // re-detect it and nothing queues up behind it. A missed announcement means // he reads it on /routines instead, which is the whole point of the page. // +// What the cooldown is and is not. detectAndPropose returns non-nil only for a +// newly created row, so a pair gets exactly one chance to be spoken: the tick +// that first proposes it. Combined with one announcement per tick, the first +// tick over a populated history announces one pattern and permanently silences +// every other pattern found in the same pass. That is the intent, not an +// oversight — an inferred routine is not worth a second attempt at his +// attention, and /routines lists all of them. So the cooldown does not drain a +// backlog. It only spaces announcements of genuinely new pairs discovered on +// later ticks. If it should ever become "one per day until each is mentioned", +// that needs a queue rather than this counter. +// +// Cooldown gets its default here as well as in applyDefaults. That is +// deliberate: a tickLoop assembled directly in a test never goes through Load, +// and an unspaced announcer is not what those tests mean to exercise. +// // The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine // — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so // an inferred routine cannot arrive worded as something Maven never observed.