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:
@@ -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 计数)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user