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:
@@ -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 条不该触发 —— 说明仍在用硬编码阈值")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user