fix(metrics): 计数器/率指标语义分清 + t8 阈值改由 Condition 承载(t_da03e910)

- 审计误读订正:deprecated_per_day 是计数器(无分母),不给 _samples/_valid 是对的
- 新增 deprecated_total:区分「今天 0 条」与「从未统计过」(0 的二义性)
- executor t8:原 Condition:0.1 从不生效(判据硬编码 >5)→ Condition:5.0 + 读 t.Condition,行为不变
- 单测钉住:RecordDeprecation 双计数、t8 阈值真的可配、计数器不带率键
- docs/metrics-semantics.md:counter/rate/mean 三种读法 + 0 的二义性铁律
This commit is contained in:
xiaoyi
2026-10-26 14:55:01 +08:00
committed by 小唯
parent 38d7ef147d
commit 9a4a734c19
4 changed files with 128 additions and 23 deletions
+30
View File
@@ -0,0 +1,30 @@
# 织忆指标语义:counter / rate / mean(`/api/v1/metrics`)
> 2026-10-26 建立。起因:有效性审计把 `deprecated_per_day`(计数器)当成率指标,
> 报出「0 样本却判 valid=None ⇒ 缺最小样本门」。计数器没有分母,「样本门」对它无定义
> —— 属**探针模型错配**,不是指标缺陷。本文件是给下游(审计脚本 / 看板 / 人)的唯一口径。
## 三类指标,三种读法
| 类型 | 命名特征 | 自带字段 | 怎么用 | 例子 |
|---|---|---|---|---|
| **counter 计数器** | `*_per_day` / `*_total` / `*_count` | **无** `_samples` / `_valid` | 直接读绝对值,跨时间比大小;不要算比率、不要报「样本不足」 | `deprecated_per_day`、`deprecated_total`、`distill_success_count` |
| **rate 比率** | `*_rate` | `_samples`(分母)+ `_valid`(1=可判 / 0=不可判 / -1=N/A) | `_valid != 1` 时**不得**当结论;`_samples` 小于门槛时只报「待积累」 | `auto_resolve_rate`、`recall_usefulness_rate` |
| **mean 均值** | `avg_*` | `_samples` + `_valid` | 同上:无分母就不下判断 | `avg_distill_loss` |
**铁律**:`0` 永远有两种含义 —— 「今天确实没有」和「根本没在统计」。
- 计数器必须配一个累计值来区分:`deprecated_per_day=0` + `deprecated_total=0` ⇒ 从未统计过;
`per_day=0` + `total=137` ⇒ 统计活着,只是今天没发生。
- 率指标必须配 `_valid`/`_samples`,分母退化时返回 `metricNA(-1)`,不给 1(给 1 会被读成满分)。
## 触发器阈值也用同一套语义
`internal/selfoptimize/executor.go` 的 `t8`(`TDecayCritical` → `archive_or_boost`):
- 判据读的是**计数器** `deprecated_per_day`(条/天),阈值由 `t8.Condition` 承载,**默认 5 条/天**。
- 2026-10-26 修正:原先 `Condition: 0.1` 但判据函数里硬编码 `> 5`,配置写了从不生效。
现改为 `Condition: 5.0` + 判据读 `t.Condition`(行为不变,但配置真的能被改)。
- 若要改成「率」语义,需要先有分母(当日活跃写入量),否则别动 —— 那会变成另一个探针错配。
## 已知遗留
- 审计脚本侧对计数器发「样本不足」告警的白名单修复,归 `t_44c07eca`(脚本侧统一处理,避免多处各改一遍)。
@@ -0,0 +1,53 @@
package selfoptimize
import "testing"
// 2026-10-26(t_da03e910 终审):把「计数器语义」和「t8 阈值可配」钉住。
//
// 背景:审计曾把 deprecated_per_day 当成率指标报「0 样本却 valid=None ⇒ 缺样本门」,
// 实为探针模型错配(计数器无分母)。真正的缺陷是:
// ① 0 无法区分「今天没淘汰」与「从来没统计过」→ 加 DeprecatedTotal;
// ② executor t8 硬编码 >5 且无视 Condition(配置写 0.1 从不生效)。
func TestRecordDeprecation_CountsBothTodayAndTotal(t *testing.T) {
oldToday, oldTotal := Dash.DeprecatedToday, Dash.DeprecatedTotal
defer func() { Dash.DeprecatedToday, Dash.DeprecatedTotal = oldToday, oldTotal }()
Dash.DeprecatedToday, Dash.DeprecatedTotal = 0, 100
Dash.RecordDeprecation()
Dash.RecordDeprecation()
if Dash.DeprecatedToday != 2 || Dash.DeprecatedTotal != 102 {
t.Fatalf("today=%d total=%d,期望 2/102:累计值必须跨天保留,日值才归零",
Dash.DeprecatedToday, Dash.DeprecatedTotal)
}
m := Dash.Metrics()
if v, ok := m["deprecated_total"]; !ok || v != 102 {
t.Fatalf("metrics 缺 deprecated_total 或值不对: %v (ok=%v)", v, ok)
}
// 计数器不该带 _samples/_valid(有分母才有样本门)
for _, k := range []string{"deprecated_samples", "deprecated_valid"} {
if _, bad := m[k]; bad {
t.Fatalf("计数器不应用率语义键 %s", k)
}
}
}
func TestTDecayCritical_ReadsConditionNotHardcode(t *testing.T) {
te := &TriggerExecutor{}
th := TriggerDef{ID: "t8", Type: TDecayCritical, Condition: 5.0, Enabled: true}
if te.evaluate(&th, map[string]float64{"deprecated_per_day": 4}) {
t.Fatal("4 条/天不应触发(阈值 5)")
}
if !te.evaluate(&th, map[string]float64{"deprecated_per_day": 6}) {
t.Fatal("6 条/天应触发(阈值 5)")
}
// 配置必须真的生效:把阈值调到 10,同样 6 条就不该触发。
th.Condition = 10.0
if te.evaluate(&th, map[string]float64{"deprecated_per_day": 6}) {
t.Fatal("Condition=10 时 6 条不该触发 —— 说明仍在用硬编码阈值")
}
}
+8 -2
View File
@@ -63,7 +63,10 @@ func makeTriggers() []*TriggerDef {
{ID: "t5", Type: TGapDetected, Condition: true, Action: "gap_fill", Cooldown: 5 * time.Minute, Enabled: true},
{ID: "t6", Type: TConflict, Condition: 3, Action: "conflict_muchen", Cooldown: 10 * time.Minute, Enabled: true},
{ID: "t7", Type: TVValueLow, Condition: 0.2, Action: "mem_review", Cooldown: 12 * time.Hour, Enabled: true},
{ID: "t8", Type: TDecayCritical, Condition: 0.1, Action: "archive_or_boost", Cooldown: 6 * time.Hour, Enabled: true},
// t8 的 Condition 是「当天废弃条数」阈值(计数器语义,不是率)。
// 2026-10-26:原先这里写 0.1 但判据函数硬编码 >5 —— 配置值从不被读取,
// 改配置也改不动行为。现改为 Condition: 5(与硬编码等价,行为不变)+ 判据读 t.Condition。
{ID: "t8", Type: TDecayCritical, Condition: 5.0, Action: "archive_or_boost", Cooldown: 6 * time.Hour, Enabled: true},
}
}
@@ -145,7 +148,10 @@ func (te *TriggerExecutor) evaluate(t *TriggerDef, metrics map[string]float64) b
v, ok := metrics["recall_usefulness_rate"]
return ok && !IsNA(v) && v < threshold
case TDecayCritical:
return metrics["deprecated_per_day"] > 5
// counter 语义:deprecated_per_day 是「当天废弃条数」,阈值同单位(条/天)。
threshold, _ := t.Condition.(float64)
v, ok := metrics["deprecated_per_day"]
return ok && v > threshold
}
return false
}
+37 -21
View File
@@ -45,15 +45,19 @@ func dashboardLocalPath() string {
// dashboardState 可序列化快照 —— Dashboard 内含 sync.RWMutex,不能直接 Marshal/整块复制
type dashboardState struct {
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
// DeprecatedTotal 累计淘汰条数(不跨天归零)。
// 2026-10-26:加它的唯一理由是把「今天 0 条」和「从来没统计过」分开 ——
// 与 pattern 召回那次的教训同型:0 永远有两种含义,指标必须能自证是哪一种。
DeprecatedTotal int `json:"deprecated_total"`
DeprecatedDate string `json:"deprecated_date"` // deprecated_today 的归属日期(跨天归零用)
DistillLossSum float64 `json:"distill_loss_sum"`
DistillLossCount int `json:"distill_loss_count"`
@@ -101,6 +105,7 @@ func loadDashboardLocal() error {
Dash.CascadeFixedTotal = st.CascadeFixedTotal
Dash.TotalFixes = st.TotalFixes
Dash.DeprecatedToday = st.DeprecatedToday
Dash.DeprecatedTotal = st.DeprecatedTotal
if st.DeprecatedDate != todayStr() { // "今日"计数跨天归零(进程跨零点不重置,下次加载时纠正)
Dash.DeprecatedToday = 0
}
@@ -132,6 +137,7 @@ func (d *Dashboard) persistLocalLocked() {
CascadeFixedTotal: d.CascadeFixedTotal,
TotalFixes: d.TotalFixes,
DeprecatedToday: d.DeprecatedToday,
DeprecatedTotal: d.DeprecatedTotal,
DeprecatedDate: todayStr(),
DistillLossSum: d.DistillLossSum,
DistillLossCount: d.DistillLossCount,
@@ -174,7 +180,7 @@ func (d *Dashboard) persist() {
_ = 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
_ = redisPersistence.Expire(dashboardRedisKey, 90*24*time.Hour) // 90 天 TTL
}
// loadDashboard 从 Redis 加载 Dashboard 数据
@@ -244,16 +250,19 @@ func loadDashboard() error {
// ─── 自优化仪表盘 ────────────────────────────────────────
type Dashboard struct {
mu sync.RWMutex
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
mu sync.RWMutex
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
// DeprecatedTotal 累计淘汰条数(不跨天归零)—— 与「今天 0 条」区分开,
// 因为 0 有两种含义:今天没淘汰 / 从来没统计。指标必须能自证是哪一种。
DeprecatedTotal int `json:"deprecated_total"`
DistillLossSum float64 `json:"distill_loss_sum"`
DistillLossCount int `json:"distill_loss_count"`
DistillSuccessCount int `json:"distill_success_count"`
@@ -316,7 +325,11 @@ func b2f(b bool) float64 {
// Metrics 返回核心指标。
//
// 比率项在分母退化时返回 metricNA(-1) 而非 1;每项比率都配 *_valid / *_samples。
// 计数项(deprecated_per_day)与均值项(avg_distill_loss)同规则。
//
// 计数项(deprecated_per_day / deprecated_total)**不配** *_samples / *_valid:
// 它们没有分母,「样本不足」对它们无定义(2026-10-26 更正:旧注释写作「与均值项
// avg_distill_loss 同规则」是错的 —— 均值项有分母,故有 samples/valid;审计脚本
// 曾据此对计数器报「0 样本」告警,属探针模型错配,不是指标缺陷)。
func (d *Dashboard) Metrics() map[string]float64 {
d.mu.RLock()
defer d.mu.RUnlock()
@@ -391,7 +404,9 @@ func (d *Dashboard) Metrics() map[string]float64 {
"auto_resolve_samples": float64(d.TotalConflicts),
"auto_resolve_valid": autoValid,
// counter 语义:无分母 ⇒ 不给 _samples/_valid(给了会诱导下游当率用)。
"deprecated_per_day": float64(d.DeprecatedToday),
"deprecated_total": float64(d.DeprecatedTotal),
"distill_success_count": float64(d.DistillSuccessCount),
"avg_distill_loss": lossVal,
@@ -922,6 +937,7 @@ func (d *Dashboard) RecordDeprecation() {
d.mu.Lock()
defer d.mu.Unlock()
d.DeprecatedToday++
d.DeprecatedTotal++
d.persist()
}