fix(selfoptimize): t_e5997ca4 gap 计数器写侧统一 — +1 只在 open→closed 迁移点 + 计数器 v2 版本化重置

根因(小怡 2026-10-17 终审实测定位):写侧仍有未收口的事件式自增点,
ClosedGaps(9) 越过 TotalGaps(2),gap_closure_rate 被 -1 断言拦下但指标永久不可解读。
  ① routes/gaps.go:44  POST /close 在 Close() 之后无条件 Dash.RecordGapClosed()
     ⇒ 同一缺口每次调用都 +1(无限计数)。且实测现行注册为前缀路由
     /api/v1/gaps/close/(无 {topic} 通配)⇒ PathValue 恒为 ""、端点恒 400,
     该自增点当时不可达,但一旦注册改通配即复活。
  ② server.go:1950  gap_scan 自动修复循环 AutoRepair 成功只计数不关缺口
     ⇒ 同一缺口每轮再 +1(无限虚增,线上 9 vs 2 的主要来源)。

修复:
  - Close/RecordHit 返回 bool(是否真发生 open→closed 迁移);+1 唯一入口
    收敛到迁移点内部;RecordGapClosed 降为包内 recordGapClosed(编译期堵死
    跨包事件式自增)。
  - gaps.go 删无条件 +1;补前缀路由 topic 解析(端点自此真正可用),
    响应如实区分 closed / already_closed。
  - server.go 修复循环改「修复成功 ⇒ 关闭缺口」(RepairHandler 同语义),
    下一轮 !g.Closed 即跳过,计数只在迁移点。
  - 历史 9 vs 2 处置选 (b) 计数器版本化重置:dashboard_schema_version=
    dashboard_schema_v2 标记入 Redis HASH + 本地 JSON(既有 90 天 TTL 键,
    不新增键);首载无标记 ⇒ ClosedGaps/TotalGaps 归零并回写标记(一次性、
    可机械复核),标记在位 ⇒ 保真。代价:丢失不可信的 gap 历史累计。
  - 口径不变:ClosedGaps/TotalGaps = 缺口个数;Metrics() 越界断言保留作
    安全网(不当修复用)。

测试:
  - selfoptimize: Close×3 / RecordHit×3 / 修复循环 3 轮 ⇒ 各只 +1、rate<=1;
    SchemaV2 一次性重置 + 标记在位保真(v1 文件 9/2 载入归零、v2 值保真)。
  - routes: POST /close ×2(现行前缀注册 + {topic} 通配注册两形态)⇒ 只 +1、
    auto_* 计数器快照不变;新增 TestMain 把 ZHIYI_DASHBOARD_PATH 指到临时目录
    (先例:selfoptimize 包同款,防测试写生产落盘点)。
  - 判别力证明(/tmp 改前树 c015feb 旁证副本,不进仓库):逐字复刻改前两写
    路径 ⇒ ClosedGaps diff=6 > TotalGaps=2(rate 3.0 越界,线上 9 vs 2 同型)
    FAIL;改后同场景 diff=2、rate=1.0。

边界:只动 gap 计数器与调用点;部署不在本卡(t_ad887e7c,待牧尘放行)。
This commit is contained in:
小唯
2026-10-17 22:12:40 +08:00
parent c015febb49
commit 45726d26a7
6 changed files with 378 additions and 14 deletions
+19 -4
View File
@@ -4,6 +4,7 @@ package routes
import (
"encoding/json"
"net/http"
"strings"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
)
@@ -32,17 +33,31 @@ func (ga *GapAPI) List(w http.ResponseWriter, r *http.Request) {
})
}
// POST /api/v1/gaps/{topic}/close
// POST /api/v1/gaps/close/{topic}(现行前缀注册 /api/v1/gaps/close/;亦兼容 {topic} 通配注册)
func (ga *GapAPI) Close(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if topic == "" {
// 现行注册是前缀路由(模式无 {topic} 通配 ⇒ PathValue 恒为 "",端点此前恒 400):
// 回落剥前缀取 topic。若日后改注册为 /api/v1/gaps/{topic}/close,PathValue 直接命中。
const prefix = "/api/v1/gaps/close/"
if strings.HasPrefix(r.URL.Path, prefix) {
topic = r.URL.Path[len(prefix):]
}
}
if topic == "" {
respondError(w, 400, "topic required")
return
}
ga.Detector.Close(topic)
selfoptimize.Dash.RecordGapClosed()
respond(w, 200, map[string]string{"status": "closed", "topic": topic})
// t_e5997ca4:计数口径 = 「缺口个数」。+1 只发生在 GapDetector 的 open→closed 迁移点
// (Close 内部);旧的无条件 selfoptimize.Dash.RecordGapClosed() 已删除 —— 那是同一
// 缺口可被无限计数、ClosedGaps(9) 越过 TotalGaps(2) 的写侧根因 #1。
switch ga.Detector.Close(topic) {
case true:
respond(w, 200, map[string]string{"status": "closed", "topic": topic})
default:
respond(w, 200, map[string]string{"status": "already_closed", "topic": topic})
}
}
// POST /api/v1/gaps/detect — 手动触发缺口检测(miss 计数)
+112
View File
@@ -0,0 +1,112 @@
// 织忆 MemoryWeave — gap 关闭端点写侧回归(t_e5997ca4 卡 AC2)
//
// POST /close 打两回(httptest)⇒ gap 计数器只 +1;auto 等其余计数器不受影响。
package routes
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
)
// makeGapTopic 3 次 miss 命中默认阈值 3 ⇒ 生成缺口(TotalGaps +1)。
func makeGapTopic(t *testing.T, gd *selfoptimize.GapDetector, topic string) {
t.Helper()
var gap *selfoptimize.Gap
for i := 0; i < 3; i++ {
if gap = gd.RecordMiss(topic); gap != nil {
break
}
}
if gap == nil {
t.Fatalf("expected gap for %q after 3 misses", topic)
}
}
func postGapClose(t *testing.T, url string) (int, map[string]string) {
t.Helper()
resp, err := http.Post(url, "application/json", nil)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("POST %s: 非 JSON 响应 %q: %v", url, b, err)
}
return resp.StatusCode, body
}
// 卡 AC2:现行注册形态(前缀路由,topic 在 /api/v1/gaps/close/ 之后)打两回 ⇒ 只 +1。
func TestGapAPI_CloseTwice_CounterOnlyOnce(t *testing.T) {
gd := selfoptimize.NewGapDetector(nil, nil)
makeGapTopic(t, gd, "close-twice-topic")
baseClosed := selfoptimize.Dash.ClosedGapsCount()
baseTotal := selfoptimize.Dash.TotalGapsCount()
baseMetrics := selfoptimize.Dash.Metrics() // auto_* 快照(应不受影响)
api := NewGapAPI(gd)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/gaps/close/", api.Close) // 与 server.go:1205 现行注册同形
ts := httptest.NewServer(mux)
defer ts.Close()
code1, body1 := postGapClose(t, ts.URL+"/api/v1/gaps/close/close-twice-topic")
if code1 != 200 || body1["status"] != "closed" {
t.Fatalf("第 1 次 POST: got %d %v, want 200 closed", code1, body1)
}
code2, body2 := postGapClose(t, ts.URL+"/api/v1/gaps/close/close-twice-topic")
if code2 != 200 || body2["status"] != "already_closed" {
t.Fatalf("第 2 次 POST: got %d %v, want 200 already_closed(不得再动计数)", code2, body2)
}
if got := selfoptimize.Dash.ClosedGapsCount() - baseClosed; got != 1 {
t.Fatalf("2× POST close 后 ClosedGaps 增量: got %d, want 1(改前为 +3 ⇒ 虚增)", got)
}
if got := selfoptimize.Dash.TotalGapsCount(); got != baseTotal {
t.Errorf("TotalGaps 不应被 close 端点移动: got %d want %d", got, baseTotal)
}
after := selfoptimize.Dash.Metrics()
if after["gap_closure_rate"] > 1 {
t.Errorf("gap_closure_rate = %f 越界", after["gap_closure_rate"])
}
// auto(冲突)计数器与 gap 关闭事件无关 ⇒ 快照不变(「auto 不适用」)
for _, k := range []string{"auto_resolve_rate", "auto_resolve_samples", "auto_resolve_valid"} {
if after[k] != baseMetrics[k] {
t.Errorf("auto 计数器被 gap 关闭污染: %s got %f want %f", k, after[k], baseMetrics[k])
}
}
}
// {topic} 通配注册形态(若日后把 server.go 注册改成 /api/v1/gaps/{topic}/close):
// PathValue 直接命中,计数语义不变(+1 只在首次)。
func TestGapAPI_CloseWildcardRegistration(t *testing.T) {
gd := selfoptimize.NewGapDetector(nil, nil)
makeGapTopic(t, gd, "wildcard-topic")
baseClosed := selfoptimize.Dash.ClosedGapsCount()
api := NewGapAPI(gd)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/gaps/{topic}/close", api.Close)
ts := httptest.NewServer(mux)
defer ts.Close()
code, body := postGapClose(t, ts.URL+"/api/v1/gaps/wildcard-topic/close")
if code != 200 || body["status"] != "closed" {
t.Fatalf("通配注册第 1 次 POST: got %d %v, want 200 closed", code, body)
}
if _, body2 := postGapClose(t, ts.URL+"/api/v1/gaps/wildcard-topic/close"); body2["status"] != "already_closed" {
t.Errorf("通配注册第 2 次 POST: got %v, want already_closed", body2)
}
if got := selfoptimize.Dash.ClosedGapsCount() - baseClosed; got != 1 {
t.Fatalf("通配注册 2× POST 后 ClosedGaps 增量: got %d, want 1", got)
}
}
+24
View File
@@ -0,0 +1,24 @@
// 织忆 MemoryWeave — 路由层测试隔离(t_e5997ca4 起)
//
// 单元测试不得写到生产落盘点。selfoptimize 包已有同款 TestMain 先例
// (曾实测:整套测试跑完把 /var/lib/memoryweave/dashboard.json 写成测试数字)。
// gap close/repair 端点会触发 Dash.persist()(本地 JSON 兜底)⇒ 统一重定向到临时目录。
package routes
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "routes-test-dash-*")
if err != nil {
// 宁可测试直接失败,也不写生产落盘点
panic(err)
}
_ = os.Setenv("ZHIYI_DASHBOARD_PATH", filepath.Join(dir, "dashboard.json"))
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
+6 -1
View File
@@ -1947,7 +1947,12 @@ func NewServer() http.Handler {
for _, g := range gaps {
if !g.Closed && routes.GapRepair != nil {
if routes.GapRepair.AutoRepair(g) {
selfoptimize.Dash.RecordGapClosed()
// t_e5997ca4:AutoRepair 只做「修复」不关缺口。旧代码在此
// 无条件 Dash.RecordGapClosed() 却不置 g.Closed ⇒ 同一缺口每轮
// gap_scan 都再 +1(ClosedGaps 无限虚增,线上 9 vs 2 的写侧根因 #2)。
// 现按 RepairHandler 语义:修复成功 = 关闭该缺口;+1 只发生在
// Close 的 open→closed 迁移点(内部),下一轮 !g.Closed 即跳过。
gapDetector.Close(g.Topic)
}
}
}
@@ -0,0 +1,148 @@
// 织忆 MemoryWeave — gap 计数器写侧统一回归(t_e5997ca4)
//
// 口径铁律:ClosedGaps/TotalGaps = 「缺口个数」(同一缺口至多计 1 次)。
// +1 的唯一合法入口 = GapDetector 的 open→closed 迁移点(Close/RecordHit 内部)。
package selfoptimize
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// createGapForTest 3 次 miss 命中默认阈值 3 ⇒ 生成缺口(TotalGaps +1)。
func createGapForTest(t *testing.T, gd *GapDetector, topic string) *Gap {
t.Helper()
var gap *Gap
for i := 0; i < 3; i++ {
if gap = gd.RecordMiss(topic); gap != nil {
break
}
}
if gap == nil {
t.Fatalf("expected gap for %s after 3 misses (threshold=%d)", topic, GapThreshold())
}
return gap
}
// 卡 AC1:同一 topic 连续 Close() 3 次 ⇒ ClosedGaps 只 +1,gap_closure_rate <= 1。
func TestGapCounter_CloseThreeTimesCountsOnce(t *testing.T) {
zeroDash()
gd := NewGapDetector(nil, nil)
createGapForTest(t, gd, "idempotent-close")
baseClosed := Dash.ClosedGaps
if !gd.Close("idempotent-close") {
t.Fatal("第 1 次 Close 应返回 true(open→closed 真迁移)")
}
if gd.Close("idempotent-close") {
t.Fatal("第 2 次 Close 应返回 false(已关闭,no-op)")
}
if gd.Close("idempotent-close") {
t.Fatal("第 3 次 Close 应返回 false(已关闭,no-op)")
}
if got := Dash.ClosedGaps - baseClosed; got != 1 {
t.Fatalf("3× Close 后 ClosedGaps 增量: got %d, want 1(同一缺口只计 1 次)", got)
}
m := Dash.Metrics()
if m["gap_closure_rate"] > 1 {
t.Errorf("gap_closure_rate = %f 越界(>1),写侧仍有污染", m["gap_closure_rate"])
}
}
// 召回命中自动关闭入口同口径:RecordHit ×3 ⇒ +1。
func TestGapCounter_RecordHitCountsOnce(t *testing.T) {
zeroDash()
gd := NewGapDetector(nil, nil)
createGapForTest(t, gd, "record-hit-topic")
baseClosed := Dash.ClosedGaps
if !gd.RecordHit("record-hit-topic") {
t.Fatal("第 1 次 RecordHit 应返回 true")
}
if gd.RecordHit("record-hit-topic") {
t.Fatal("第 2 次 RecordHit 应返回 false")
}
if gd.RecordHit("record-hit-topic") {
t.Fatal("第 3 次 RecordHit 应返回 false")
}
if got := Dash.ClosedGaps - baseClosed; got != 1 {
t.Fatalf("3× RecordHit 后 ClosedGaps 增量: got %d, want 1", got)
}
}
// 卡根因 #2 的修复面:server.go gap_scan 自动修复循环。
// 修复后语义 = 「修复成功 ⇒ 关闭缺口」,+1 只在迁移点发生;
// 改前模式(事件式 +1 且不关缺口)同一场景为每轮 +1(3 轮 = +3 ⇒ 无限虚增)。
func TestGapCounter_AutoRepairLoopPatternCountsOnce(t *testing.T) {
zeroDash()
gd := NewGapDetector(nil, nil)
g := createGapForTest(t, gd, "autorepair-loop")
baseClosed := Dash.ClosedGaps
for cycle := 0; cycle < 3; cycle++ { // 模拟 3 轮 gap_scan
if !g.Closed {
repaired := true // AutoRepair 对 Type B/C(ldb 在场)恒 true 的替代
if repaired {
gd.Close(g.Topic) // 修复后口径:关闭缺口,+1 仅在 Close 迁移点
}
}
}
if got := Dash.ClosedGaps - baseClosed; got != 1 {
t.Fatalf("3 轮修复循环后 ClosedGaps 增量: got %d, want 1(改前模式为 +3 ⇒ 虚增)", got)
}
}
// 卡任务 3 (b):计数器版本化一次性重置。
// v1 落盘(无 dashboard_schema_version 标记,线上 9/2 值)加载 ⇒ gap 双计数器归 0 + 回写标记,
// 其余计数器保真;标记在位后加载 ⇒ 保真、不再重置(一次性,可机械复核)。
func TestDashboard_SchemaV2OneTimeReset(t *testing.T) {
p := filepath.Join(t.TempDir(), "dash.json")
t.Setenv("ZHIYI_DASHBOARD_PATH", p)
v1 := `{"useful_count":5,"not_useful_count":1,"total_recalls":10,"hit_count":8,` +
`"closed_gaps":9,"total_gaps":2,"cascade_fixed_total":0,"total_fixes":0,` +
`"deprecated_today":0,"deprecated_date":"","distill_loss_sum":0,"distill_loss_count":0,` +
`"distill_success_count":0,"auto_resolved_conflicts":0,"total_conflicts":0}`
if err := os.WriteFile(p, []byte(v1), 0o600); err != nil {
t.Fatal(err)
}
zeroDash()
if err := loadDashboardLocal(); err != nil {
t.Fatal(err)
}
if Dash.ClosedGaps != 0 || Dash.TotalGaps != 0 {
t.Fatalf("v1 加载应一次性重置 gap 计数器: got closed=%d total=%d, want 0/0", Dash.ClosedGaps, Dash.TotalGaps)
}
if Dash.UsefulCount != 5 || Dash.HitCount != 8 {
t.Errorf("其余计数器应保真: useful=%d hit=%d, want 5/8", Dash.UsefulCount, Dash.HitCount)
}
// 标记应已回写落盘
var st dashboardState
if b, err := os.ReadFile(p); err == nil {
_ = json.Unmarshal(b, &st)
if st.SchemaVersion != dashboardSchemaV2 {
t.Errorf("v2 标记未回写: got %q want %q", st.SchemaVersion, dashboardSchemaV2)
}
}
// 第二次加载(标记在位 + 新口径累计值)⇒ 保真、不再重置
Dash.mu.Lock()
Dash.ClosedGaps = 7
Dash.TotalGaps = 20
Dash.persist() // 测试环境 redisPersistence=nil ⇒ 写本地 JSON(ZHIYI_DASHBOARD_PATH 已指到临时目录)
Dash.mu.Unlock()
zeroDash()
if err := loadDashboardLocal(); err != nil {
t.Fatal(err)
}
if Dash.ClosedGaps != 7 || Dash.TotalGaps != 20 {
t.Fatalf("v2 标记在位应保真: got %d/%d, want 7/20", Dash.ClosedGaps, Dash.TotalGaps)
}
}
+69 -9
View File
@@ -16,6 +16,17 @@ import (
// Redis persistence key
const dashboardRedisKey = "zhiyi:dashboard"
// dashboardSchemaV2 缺口计数器版本标记(t_e5997ca4,三选一改 (b):计数器版本化重置)。
//
// 依据:历史 ClosedGaps/TotalGaps 被事件式自增污染(线上实测 closed_gaps=9 vs
// total_gaps=2,Redis HASH zhiyi:dashboard),且无法对账还原「哪 9 次关闭对应哪 2 个
// 缺口」——(a) min() 对账是伪造数据,(c) 等 TotalGaps 追上的时长不可估且期间指标恒 -1。
// 做法:首次加载见到 marker 缺失/非 v2 ⇒ 一次性把 ClosedGaps/TotalGaps 重置为 0 并回写
// marker;此后加载保真(v2 起按「缺口个数」口径从头累计)。
// 代价(写明):丢失 gap 双计数器的历史累计值——其本身就是不可信数据(虚增产物),
// 不掩盖。其他计数器(useful/cascade/auto/distill)口径无事件污染,一律不动。
const dashboardSchemaV2 = "dashboard_schema_v2"
// dashboardLocalPath 本地 JSON 落盘点 —— Redis 不可用时的兜底(2026-09-27 加)
//
// 背景:本机无 Redis(无 redis-cli/redis-server、6379 无监听、单元无 REDIS_ADDR/URL)
@@ -49,6 +60,8 @@ type dashboardState struct {
DistillSuccessCount int `json:"distill_success_count"`
AutoResolvedConflicts int `json:"auto_resolved_conflicts"`
TotalConflicts int `json:"total_conflicts"`
// t_e5997ca4:计数器版本标记(见 dashboardSchemaV2 注释)。
SchemaVersion string `json:"dashboard_schema_version"`
}
func todayStr() string { return time.Now().Format("2006-01-02") }
@@ -96,6 +109,13 @@ func loadDashboardLocal() error {
Dash.DistillSuccessCount = st.DistillSuccessCount
Dash.AutoResolvedConflicts = st.AutoResolvedConflicts
Dash.TotalConflicts = st.TotalConflicts
// t_e5997ca4 (b):版本化一次性重置。本地 JSON 无 v2 标记(旧版/缺失)⇒ 丢弃被
// 事件式自增污染的 gap 双计数器,回写标记;v2 标记在位 ⇒ 保真(新口径累计值)。
if st.SchemaVersion != dashboardSchemaV2 {
Dash.ClosedGaps = 0
Dash.TotalGaps = 0
Dash.persist() // 回写 v2 标记(调用方已持 Dash.mu ✓;Redis 不可用则落本地 JSON)
}
return nil
}
@@ -118,6 +138,7 @@ func (d *Dashboard) persistLocalLocked() {
DistillSuccessCount: d.DistillSuccessCount,
AutoResolvedConflicts: d.AutoResolvedConflicts,
TotalConflicts: d.TotalConflicts,
SchemaVersion: dashboardSchemaV2, // t_e5997ca4:v2 常驻回写(新安装/旧版升级都自动打标)
}
b, err := json.Marshal(st)
if err != nil {
@@ -152,6 +173,7 @@ func (d *Dashboard) persist() {
_ = redisPersistence.HSet(dashboardRedisKey, "distill_success_count", strconv.Itoa(d.DistillSuccessCount))
_ = redisPersistence.HSet(dashboardRedisKey, "auto_resolved_conflicts", strconv.Itoa(d.AutoResolvedConflicts))
_ = redisPersistence.HSet(dashboardRedisKey, "total_conflicts", strconv.Itoa(d.TotalConflicts))
_ = redisPersistence.HSet(dashboardRedisKey, "dashboard_schema_version", dashboardSchemaV2) // t_e5997ca4:v2 标记常驻回写
_ = redisPersistence.Expire(dashboardRedisKey, 90*24*time.Hour) // 90 天 TTL
}
@@ -209,6 +231,13 @@ func loadDashboard() error {
if v, err := strconv.Atoi(data["total_conflicts"]); err == nil {
Dash.TotalConflicts = v
}
// t_e5997ca4 (b):版本化一次性重置(Redis 路径)。HASH 无 v2 标记(旧版遗留/缺失)⇒
// 丢弃被事件式自增污染的 gap 双计数器(线上 9 vs 2 实例),回写标记;v2 在位 ⇒ 保真。
if data["dashboard_schema_version"] != dashboardSchemaV2 {
Dash.ClosedGaps = 0
Dash.TotalGaps = 0
Dash.persist() // 回写 v2 标记(调用方已持 Dash.mu ✓)
}
return nil
}
@@ -647,27 +676,38 @@ func (gd *GapDetector) List() []*Gap {
return result
}
func (gd *GapDetector) Close(topic string) {
// Close 关闭 topic 对应的缺口,返回本次调用是否真的发生了 open→closed 迁移。
//
// t_e5997ca4 写侧统一:ClosedGaps/TotalGaps 口径为「缺口个数」(同一缺口至多计 1 次)。
// +1 的唯一入口在本迁移点(g.Closed false→true 时),重复调用/多入口重复关闭永不虚增;
// 外部调用方(routes / server 循环)禁止再做无条件 RecordGapClosed 式事件自增
// ——历史 9 vs 2 不一致(closed_gaps=9 / total_gaps=2)正是那样污染的。
// 返回值语义:true = 首次关闭(仪表盘计数已随迁移 +1);
// false = 已关闭或未知 topic(no-op,调用方不得再动计数)。
func (gd *GapDetector) Close(topic string) bool {
gd.mu.Lock()
defer gd.mu.Unlock()
// 统一「缺口个数」口径:仅当缺口从未关闭时才算一次闭环(与 RecordHit 一致),
// 否则同一缺口被 Close 重复调用会让 ClosedGaps 虚增、gap_closure_rate 越界 > 1。
if g, ok := gd.gaps[topic]; ok && !g.Closed {
g.Closed = true
gd.persistGaps()
Dash.RecordGapClosed() // 同步仪表盘
Dash.recordGapClosed() // 迁移内 +1(唯一入口)
return true
}
return false
}
// RecordHit 记录一次召回命中,自动关闭该 topic 的 open gap
func (gd *GapDetector) RecordHit(topic string) {
// RecordHit 记录一次召回命中,自动关闭该 topic 的 open gap。
// 返回语义与 Close 一致:true = 本次命中真的发生了 open→closed 迁移。
func (gd *GapDetector) RecordHit(topic string) bool {
gd.mu.Lock()
defer gd.mu.Unlock()
if g, ok := gd.gaps[topic]; ok && !g.Closed {
g.Closed = true
gd.persistGaps()
Dash.RecordGapClosed()
Dash.recordGapClosed() // 迁移内 +1(唯一入口)
return true
}
return false
}
// ClearMisses 清除某个 topic 的 miss 计数(recall 命中后调用)
@@ -896,14 +936,34 @@ func (d *Dashboard) RecordCorrection(source string) {
d.persist()
}
// RecordGapClosed 记录缺口关闭
func (d *Dashboard) RecordGapClosed() {
// recordGapClosed 记录缺口关闭(+1 ClosedGaps 并持久化)。
//
// ⚠️ t_e5997ca4 起降为包内(小写):+1 的唯一合法入口是 GapDetector 的
// open→closed 迁移点(Close / RecordHit 内部)。跨包事件式无条件调用
// (旧 gaps.go:44 / server.go:1950 那种)会让同一缺口被无限计数、
// ClosedGaps 越过 TotalGaps(历史 9 vs 2 的根因)⇒ 用包边界从编译期堵死。
// 需要「本次是否真的关闭」语义的外部调用方,看 Close/RecordHit 的 bool 返回值。
func (d *Dashboard) recordGapClosed() {
d.mu.Lock()
defer d.mu.Unlock()
d.ClosedGaps++
d.persist()
}
// ClosedGapsCount 只读快照访问器(跨包审计/测试用;调用方自持锁语义不暴露)。
func (d *Dashboard) ClosedGapsCount() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.ClosedGaps
}
// TotalGapsCount 只读快照访问器(跨包审计/测试用)。
func (d *Dashboard) TotalGapsCount() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.TotalGaps
}
// RecordConflictResolved 记录冲突解决
func (d *Dashboard) RecordConflictResolved(auto bool) {
d.mu.Lock()