Files
Cyrene_v2/internal/services/backthink/quiet_interval_test.go
T
AskaEth 236b202fda feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600
主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
2026-10-05 22:38:17 +08:00

288 lines
11 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.
package backthink
import (
"bytes"
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件钉住 design/12 §N「静默时段降频」:心跳在安静窗口内降频、窗外回落,
// 且降频**只作用于周期性心跳**——事件驱动通路照旧立刻触发(§N A2)。
//
// 三条边界必须同时成立,缺一条就是「暗坑」:
// 1. 每拍**重算**间隔(不是启动时定死)⇒ 跨窗口边界真的切换;
// 2. `QuietInterval ≤ 0` = 关闭降频(回落普通间隔),**不是**「半夜不思考」;
// 3. 安静判定与既有三处 `inQuietHours` 同源(不另写一份越走越偏的口径)。
// fakeClock 是可注入的墙钟:测试在运行中挪动它,以驱动「跨窗口边界」。
type fakeClock struct {
mu sync.Mutex
at time.Time
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *fakeClock) set(at time.Time) {
c.mu.Lock()
c.at = at
c.mu.Unlock()
}
// atHour 造一个本地钟点为 h:m 的时刻(UTC 承载,Hour() 即 h)。
func atHour(h, m int) time.Time {
return time.Date(2026, 10, 5, h, m, 0, 0, time.UTC)
}
// intervalFor 在给定配置上取 at 时刻的心跳间隔(经 normalize 后的真实路径)。
func intervalFor(cfg Config, at time.Time) time.Duration {
b := &Backthink{cfg: normalize(cfg)}
return b.heartbeatInterval(at)
}
// TestHeartbeatInterval_SelectsByWindow 覆盖 design/12 §N A6 的 ①~④:
// 窗口内用 quiet 间隔、窗口外用普通间隔、`≤0` 回落普通、窗口关闭不降频。
func TestHeartbeatInterval_SelectsByWindow(t *testing.T) {
t.Parallel()
const normal = 15 * time.Minute
const quiet = time.Hour
cases := []struct {
name string
cfg Config
at time.Time
want time.Duration
}{
{
name: "①窗口内降频(跨午夜 23:00-07:00 的 23:30)",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(23, 30),
want: quiet,
},
{
name: "②窗口外回落普通间隔(14:00)",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(14, 0),
want: normal,
},
{
name: "窗口起点(23:00)算在窗口内",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(23, 0),
want: quiet,
},
{
name: "窗口终点(07:00)算在窗口外",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(7, 0),
want: normal,
},
{
name: "白天窗口(01:00-05:00)内的 02:00 降频",
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 1, QuietEndHour: 5},
at: atHour(2, 0),
want: quiet,
},
{
name: "③QuietInterval=0 ⇒ 关闭降频(窗口内也回落)",
cfg: Config{Interval: normal, QuietInterval: 0},
at: atHour(23, 30),
want: normal,
},
{
name: "③QuietInterval 为负 ⇒ 同样关闭降频",
cfg: Config{Interval: normal, QuietInterval: -time.Minute},
at: atHour(23, 30),
want: normal,
},
{
name: "④窗口关闭(23/23)不降频",
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 23, QuietEndHour: 23},
at: atHour(23, 30),
want: normal,
},
{
name: "④钟点非法(25/07)不降频(否则可能全时段降频)",
cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 25, QuietEndHour: 7},
at: atHour(0, 30),
want: normal,
},
{
name: "0/0 视为未填 ⇒ 回落默认窗口 23:00-07:00(窗口内降频)",
cfg: Config{Interval: normal, QuietInterval: quiet},
at: atHour(23, 30),
want: quiet,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := intervalFor(tc.cfg, tc.at); got != tc.want {
t.Fatalf("heartbeatInterval(%s) = %v, want %v", tc.at.Format("15:04"), got, tc.want)
}
})
}
}
// TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours 钉住降频用的是**同一套**
// 安静窗口口径(design/12 §N A3:与既有三处逐字一致):静默间隔开启且窗口合法时,
// quiet 档位**当且仅当** inQuietHours。另写一份判定(如写成 `h > start && h <= end`)
// 会在跨午夜窗口的边界小时上立刻暴露。
func TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours(t *testing.T) {
t.Parallel()
const normal = 900 * time.Second
const quiet = 3600 * time.Second
windows := []struct{ start, end int }{{23, 7}, {1, 5}, {0, 7}, {22, 3}}
for _, w := range windows {
b := &Backthink{cfg: normalize(Config{
Interval: normal, QuietInterval: quiet, QuietStartHour: w.start, QuietEndHour: w.end,
})}
for hour := 0; hour < 24; hour++ {
at := atHour(hour, 30)
inWindow := b.inQuietHours(at)
want := normal
if inWindow {
want = quiet
}
if got := b.heartbeatInterval(at); got != want {
t.Fatalf("窗口 %02d:00-%02d:00 的 %02d:30:inQuietHours=%v 但间隔=%v(want %v)",
w.start, w.end, hour, inWindow, got, want)
}
}
}
}
// TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary 钉住「每拍重算」
// (design/12 §N A3):注入时钟从窗口内走到窗口外,心跳必须**加速**到普通间隔。
// 若间隔只在启动时算一次,第一拍之后仍是 quiet 间隔 ⇒ 拍数远达不到阈值 ⇒ 红。
func TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary(t *testing.T) {
t.Parallel()
const normal = 50 * time.Millisecond
const quiet = 500 * time.Millisecond
clock := &fakeClock{at: atHour(23, 30)} // 起点在窗口内
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{
Thinker: thinker,
Now: clock.now,
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮
clock.set(atHour(12, 0)) // 走出窗口
// 阈值取 8 拍:只在启动时算一次(quiet=500ms)的话,3 秒内最多 6 拍;
// 每拍重算则 500ms 首拍 + 之后每 50ms 一拍 ⇒ 约 900ms 即达标。
eventually(t, 3*time.Second, func() bool { return thinker.count()-1 >= 8 })
}
// TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow 是上面那条的反向:时钟从窗口外
// 走进窗口内,心跳应放慢到 quiet 间隔(20ms ⇒ 600ms)。
func TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow(t *testing.T) {
t.Parallel()
const normal = 20 * time.Millisecond
const quiet = 600 * time.Millisecond
clock := &fakeClock{at: atHour(12, 0)} // 起点在窗口外
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{
Thinker: thinker,
Now: clock.now,
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 3 }) // 快节奏已建立
clock.set(atHour(23, 30)) // 走进窗口
base := thinker.count()
time.Sleep(300 * time.Millisecond)
// 已武装的那一拍最多再多跑 1-2 次,之后应被 quiet 间隔挡住;若降频没生效,
// 20ms 的节奏会在 300ms 里再跑约 15 拍。
if got := thinker.count() - base; got > 3 {
t.Fatalf("进入安静窗口后 300ms 内又跑了 %d 拍,quiet 间隔未生效", got)
}
}
// TestBackthink_EventTrigger_NotSlowedByQuietInterval 是 design/12 §N A2 / A6-⑤:
// 安静窗口只降低**周期性心跳**的频率;事件驱动通路照旧**立刻**触发,不必等下一拍。
// 心跳间隔被刻意设成小时级,事件仍须在秒级内跑完一轮思考。
func TestBackthink_EventTrigger_NotSlowedByQuietInterval(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
bs := bus.New()
b, _ := newBackthink(t, Config{Interval: time.Hour, QuietInterval: 4 * time.Hour}, Deps{
Bus: bs,
Thinker: thinker,
Now: func() time.Time { return nightAt() }, // 当前在安静窗口内
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮
base := thinker.count()
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "reminder due"); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, time.Second, func() bool { return thinker.count() > base })
}
// TestBackthink_StartupLog_ReportsQuietInterval 是 design/12 §N A6-⑥:启动日志必须
// **同时**打印普通间隔与安静间隔(并标明降频是否启用),否则主人无法从日志确认
// 降频到底有没有生效。
func TestBackthink_StartupLog_ReportsQuietInterval(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: 15 * time.Minute, QuietInterval: time.Hour}, Deps{
Thinker: thinker,
Log: slog.New(slog.NewTextHandler(&buf, nil)),
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
out := buf.String()
for _, want := range []string{"interval=15m0s", "quiet_interval=1h0m0s", "quiet_interval_enabled=true"} {
if !strings.Contains(out, want) {
t.Fatalf("启动日志缺少 %q:\n%s", want, out)
}
}
}
// TestConfigFromBackground_QuietInterval 钉住 env → backthink 的映射与 ≤0 语义
// (design/12 §N A1/A3):3600 秒 ⇒ 1 小时的降频间隔;`≤0` 必须原样保留成
// 「关闭降频」——normalize **不得**把它改写成默认值,否则主人设的 0 会静默失效
// (那正是 §N A3 要禁的暗坑)。
func TestConfigFromBackground_QuietInterval(t *testing.T) {
t.Parallel()
on := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: 3600}))}
if on.cfg.QuietInterval != time.Hour {
t.Fatalf("QuietIntervalSec=3600 ⇒ QuietInterval=%v, want 1h", on.cfg.QuietInterval)
}
if !on.quietIntervalEnabled() {
t.Fatal("QuietIntervalSec=3600 必须视为降频已启用")
}
for _, sec := range []int{0, -1} {
off := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: sec}))}
if off.quietIntervalEnabled() {
t.Fatalf("QuietIntervalSec=%d 必须被视为「关闭降频」", sec)
}
if got := off.heartbeatInterval(atHour(23, 30)); got != off.cfg.Interval {
t.Fatalf("QuietIntervalSec=%d 时窗口内心跳应为普通间隔 %v,得到 %v", sec, off.cfg.Interval, got)
}
}
}