Эх сурвалжийг харах

fix(scoring): clarify vps789 score penalty semantics and update tie-breaker order to asc

Dew-OF-Aurora 3 долоо хоног өмнө
parent
commit
f173d39a81

+ 8 - 0
CLAUDE.md

@@ -131,6 +131,14 @@ sudo bash scripts/server/uninstall_debian.sh
 - BusyBox shell 脚本需兼容 POSIX sh,不能用 bash 特性。
 - `router_local_http.sh` 使用配置中的 BusyBox 二进制的 httpd applet,不使用系统 httpd。
 - SubStore 脚本的 `$arguments.domain` 参数为最高优先级覆盖,`$arguments.domain_override` 控制开关,不受任何模式和策略影响。
+- **`vps789.com` API 指标极性与业务公式(Score SSOT 权威规范)**:
+  实测原始数据已逆向破解并确证 `vps789.com` API 中所有 `*Score` 字段(`ydScore`, `dxScore`, `ltScore`, `avgScore`)的真实物理含义:
+  - 上游公式严格为**综合成本惩罚分(Cost / Penalty Metric,越小越优 / 高尔夫计分制)**:
+    $$\text{Score} = \text{LatencyAvg} + \text{PkgLostRateAvg} \times 50$$
+    $$\text{avgScore} = \frac{\text{ydScore} + \text{dxScore} + \text{ltScore}}{3}$$
+  - 零丢包时分数等于平均延迟(ms);丢包每增加 1% 加罚 50 分。
+  - **极性结论**:所有 `*Score` 与延迟、丢包完全同向,必须严格遵循 **越小越优**。
+  - **策略落地**:平局决胜 `tie_breakers` 中必须指定 `{"field": "yd_score", "order": "asc"}`(低分优胜);若将来在加权计算中使用,也必须遵循 `prefer_lower: true` / `order: "asc"`。历史审查曾将其误解为百分制健康分(误配 desc),现已永久纠偏。
 
 ## Where to Look
 

+ 5 - 3
README.md

@@ -252,9 +252,11 @@ flowchart TD
 - **加权评分公式**:
   $$\text{Score} = \frac{\sum (\text{Field Value} \times \text{Weight})}{\sum \text{Weights}}$$
   默认针对中国移动线路加权:`yd_latency`(权重 1.0,越小越优)+ `yd_pkg_lost_rate`(权重 10.0,越小越优)。
-- **健康度极性避坑**:`yd_score`(健康得分,通常 75~110)为正向指标(越大越优)。在负向算分模式(`prefer_lower: true`)下,**禁止直接赋予正权重**(否则会导致健康分暴跌的劣化节点被优先选出)。
+- **指标极性实证 (Score 指标为成本惩罚分)**:逆向实测确证 `vps789` 接口中的 `yd_score`(以及 `dx_score`, `lt_score`, `avg_score`)并非百分制健康分,而是**综合成本惩罚分(Cost / Penalty Metric,越小越优 / 高尔夫计分制)**,其实质计算规则为:
+  $$\text{Score} = \text{LatencyAvg} + \text{PkgLostRateAvg} \times 50$$
+  零丢包时分数等于平均延迟(ms);丢包每增加 1% 加罚 50 分。因此它与延迟、丢包完全同向(**越小越优**)。
 - **时效性避坑**:针对 `vps789` API 数据源,接口返回的 `createdTime` 实际为「域名纳入监控池的建档时间」而非当次拨测时间。在服务器模式下默认设置 `within_hours: 0`(禁用时效过滤),防止误杀老牌稳定域名。
-- **平局决胜 (`tie_breakers`)**:得分相同时,依次以 `yd_score`(降序) $\to$ `avg_latency`(升序) $\to$ `created_at`(降序) $\to$ `domain`(升序)决胜。
+- **平局决胜 (`tie_breakers`)**:得分相同时,依次以 `yd_score`(**升序 asc,惩罚分低者胜**) $\to$ `avg_latency`(升序 asc) $\to$ `created_at`(降序 desc) $\to$ `domain`(升序 asc)决胜。
 
 #### 3. 双轨容灾机制 (Fallback)
 - **触发条件**:API 请求超时(`error_use_last_good`)或所有候选均被过滤(`fallback_last_good`)。
@@ -467,7 +469,7 @@ node -c substore/operator_template.js
 | :--- | :--- |
 | **`tests/test_server_mode_lifecycle.py`** | Server 模式全生命周期(API 选优、孤儿分支 Worktree 提交、自适应时间拦截、静默抑制、模式 A 历史滚动修剪) |
 | **`tests/test_cfst_local_lifecycle.py`** | 本地 `cfst` 模式全生命周期(CSV 解析、零速节点过滤、`-dd` 延迟免测排序、Fallback 容灾与路径防漂移契约) |
-| **`tests/test_domain_updater.py`** | Python 选优核心算法(`yd_score` 正向极性修复、平局决胜顺序、迟滞防抖 Hysteresis、黑名单复检) |
+| **`tests/test_domain_updater.py`** | Python 选优核心算法(`yd_score` 成本惩罚分升序平局决胜、加权计算、迟滞防抖 Hysteresis、黑名单复检) |
 | **`tests/test_shell_lifecycle.py`** | Shell 脚本生命周期安全(`uninstall_debian.sh` 防空服务名误删库、Token 动态环境零泄露、`ls-remote` 退出码精细判定) |
 | **`tests/test_router_scripts.py`** | 路由器脚本原子性(`.tmp.$$` 替换写入、测速过滤、SSOT 打包配置同步) |
 | **`tests/test_substore_operator.py`** | Sub-Store 脚本算子(XSS/非法 HTML 过滤、数据源超时降级、类型安全断言) |

+ 1 - 1
configs/config.server.json

@@ -84,7 +84,7 @@
     "tie_breakers": [
       {
         "field": "yd_score",
-        "order": "desc"
+        "order": "asc"
       },
       {
         "field": "avg_latency",

+ 2 - 1
docs/reviews/ARCHITECTURE_REVIEW_UNIFIED.md

@@ -96,7 +96,8 @@ flowchart LR
   3. 更严重的是,由于 `yd_score` 数值量级在 `80~109`,乘以权重 `5.0` 后高达 `400~545`,而 `yd_latency * 1.0` 仅 `35~75`,`yd_score` 实际上主导了 **85%~90% 的总评分权重**。
   4. 同时,`domain_updater.py:486` 中硬编码了 `if not is_int_or_float(weight) or float(weight) <= 0: raise ValueError(...)`,**从代码层面禁止了负数权重**,且不支持为每个字段单独指定方向(`asc`/`desc`)。此外 `README.md:221-225` 的文档示例中只写了 `yd_latency` 和 `yd_pkg_lost_rate`,与实际 `config.server.json` 脱节。
 - **修复方案与落地状态**:
-  - **✅ 已落地修复**:在 `config.server.json` 中彻底移出 `weighted_fields`,转移至 `tie_breakers` 配置 `{"field": "yd_score", "order": "desc"}`(高分优选);在 `scripts/domain_updater.py` 中支持字段级 `order: desc/asc` 与迟滞防抖 `switch_margin_pct: 5.0`。
+  - **✅ 已落地修复**:在 `config.server.json` 中移出 `weighted_fields`,转移至 `tie_breakers`;在 `scripts/domain_updater.py` 中支持字段级 `order: desc/asc` 与迟滞防抖 `switch_margin_pct: 5.0`。
+  - **【2026-10 实测终局纠偏注记】**:经对线上真实多节点全量 Payload 数学逆向,确证上游公式严格为 $\text{Score} = \text{LatencyAvg} + \text{PkgLostRateAvg} \times 50$(综合成本惩罚分,越小越优)。原审查误将其推断为百分制健康分(误配 desc),现已在 `config.server.json`、`CLAUDE.md` 与测试中永久纠偏为 `order: "asc"`。
   - **测试验证**:`tests/test_domain_updater.py` 中用例 `test_p0_yd_score_polarity_fix_and_healthy_ranking` 100% 验证通过。
 
 ---

+ 1 - 1
scripts/server/install_debian.sh

@@ -523,7 +523,7 @@ log "  Env file: ${ENV_FILE}"
 log ""
 log "Commands:"
 log "  Check status: systemctl status ${SERVICE_NAME}.timer"
-log "  View logs:    journalctl -u ${SERVICE_NAME}.service -n 50 --no-pager"
+log "  View logs:    journalctl -u ${SERVICE_NAME} -n 50 -f"
 log "  Manual run:   sudo systemctl start ${SERVICE_NAME}.service"
 log "  Force commit: sudo -u ${RUN_USER} /bin/bash ${APP_DIR}/scripts/server/run_update_and_commit.sh --force-commit ${CONFIG_PATH}"
 log ""

+ 48 - 9
tests/test_domain_updater.py

@@ -25,11 +25,11 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
         self.tmp_dir.cleanup()
 
     def test_p0_yd_score_polarity_fix_and_healthy_ranking(self):
-        """[P0-1 修复验证] 验证 yd_score 极性修复与平局决胜:
-        1. 加权公式仅计算 avg_latency (1.0) 与 loss_rate (10.0)
+        """[P0-1 修复验证] 验证 yd_score 极性与平局决胜:
+        1. 加权公式仅计算 yd_latency (1.0) 与 yd_pkg_lost_rate (10.0)
         2. 恶化前 fastsoso (36ms) 优于 competitor (49ms)
         3. 恶化后 fastsoso (74ms) 得分劣于 competitor (49ms),competitor 成功登顶第 1
-        4. 平局时 tie_breakers 优先选用 yd_score 降序(健康分高者胜出)
+        4. 平局时 tie_breakers 选用 yd_score 升序(成本惩罚分越低者胜出,越小越优)
         """
         config_path = PROJECT_ROOT / "configs" / "config.server.json"
         with open(config_path, "r", encoding="utf-8") as f:
@@ -47,7 +47,7 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
             "avgPkgLostRate": "0",
             "ydLatency": 36.0,
             "ydPkgLostRate": 0.0,
-            "ydScore": 109.0,  # 健康分高
+            "ydScore": 83.0,   # 优质节点成本惩罚分低
         }
         degraded_candidate = {
             "ip": "www.fastsoso.cn",
@@ -56,7 +56,7 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
             "avgPkgLostRate": "0",
             "ydLatency": 74.0,  # 延迟翻倍恶化
             "ydPkgLostRate": 0.0,
-            "ydScore": 78.0,   # 健康分暴跌
+            "ydScore": 205.0,  # 恶化后成本惩罚分激增
         }
         competitor = {
             "ip": "saas.sin.fan",
@@ -65,7 +65,7 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
             "avgPkgLostRate": "0",
             "ydLatency": 49.0,
             "ydPkgLostRate": 0.0,
-            "ydScore": 86.0,
+            "ydScore": 164.0,
         }
 
         # 1. 正常时算分:fastsoso 延迟更低,加权分更低更优,排名第一
@@ -79,7 +79,7 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
         self.assertEqual(ranked_degraded[0]["domain"], "saas.sin.fan", "恶化后 competitor 夺得第 1 名")
         self.assertEqual(ranked_degraded[1]["domain"], "www.fastsoso.cn", "恶化节点被挤至第 2 名")
 
-        # 3. 平局决胜验证:延迟丢包相同,yd_score 高者胜出
+        # 3. 平局决胜验证:延迟丢包相同,yd_score 升序胜出(Cost / Penalty 成本惩罚分越低越优)
         tie_candidate_a = {
             "ip": "tie-a.com",
             "createdTime": "2026-05-30 20:00:00",
@@ -98,9 +98,9 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
             "ydPkgLostRate": 0.0,
             "ydScore": 70.0,
         }
-        scored_tie = du.parse_scored_records([tie_candidate_b, tie_candidate_a], field_map, record_mapping_cfg, scoring_cfg)
+        scored_tie = du.parse_scored_records([tie_candidate_a, tie_candidate_b], field_map, record_mapping_cfg, scoring_cfg)
         ranked_tie = du.rank_scored_records(scored_tie, scoring_cfg)
-        self.assertEqual(ranked_tie[0]["domain"], "tie-a.com", "平局时 yd_score 降序胜出")
+        self.assertEqual(ranked_tie[0]["domain"], "tie-b.com", "平局时 yd_score 升序胜出(分低成本小者优胜)")
 
     def test_p0_notify_stdout_isolation_and_timeout(self):
         """[P0/P1 修复验证] 验证 run_notify() 标准输出重定向至 stderr,且具备超时保护"""
@@ -304,6 +304,45 @@ class TestDomainUpdaterP0P1Bugs(unittest.TestCase):
         for sym in required_facade_symbols:
             self.assertTrue(hasattr(du, sym), f"Facade 必须导出符号: {sym}")
 
+    def test_empirical_score_metric_asc_tie_breaking_and_consistency(self):
+        """[实证极性验证] 验证 vps789 API 中 Score 成本惩罚分语义及平局决胜升序(低分优胜)"""
+        config_path = PROJECT_ROOT / "configs" / "config.server.json"
+        with open(config_path, "r", encoding="utf-8") as f:
+            cfg = json.load(f)
+
+        field_map = cfg["record_mapping"]["field_map"]
+        record_mapping_cfg = cfg["record_mapping"]
+        scoring_cfg = cfg["scoring"]
+
+        # 验证 config.server.json 中 tie_breakers 的 yd_score 必须为 asc
+        yd_tie_breaker = next((tb for tb in scoring_cfg.get("tie_breakers", []) if tb.get("field") == "yd_score"), None)
+        self.assertIsNotNone(yd_tie_breaker)
+        self.assertEqual(yd_tie_breaker.get("order"), "asc", "yd_score 作为成本惩罚分必须以 asc (升序) 决胜")
+
+        # 真实节点数据仿真:节点 A (baka.fun 移动优,ydScore 83) vs 节点 B (swowd.com 移动次优,ydScore 173)
+        # 假设两者加权平均分偶然相同
+        cand_a = {
+            "ip": "baka.fun",
+            "createdTime": "2026-05-28 20:14:47",
+            "avgLatency": "135",
+            "avgPkgLostRate": "0",
+            "ydLatency": 37.0,
+            "ydPkgLostRate": 0.0,
+            "ydScore": 83.0,
+        }
+        cand_b = {
+            "ip": "www.swowd.com",
+            "createdTime": "2026-05-30 23:44:16",
+            "avgLatency": "96",
+            "avgPkgLostRate": "0",
+            "ydLatency": 37.0,
+            "ydPkgLostRate": 0.0,
+            "ydScore": 173.0,
+        }
+        scored = du.parse_scored_records([cand_b, cand_a], field_map, record_mapping_cfg, scoring_cfg)
+        ranked = du.rank_scored_records(scored, scoring_cfg)
+        self.assertEqual(ranked[0]["domain"], "baka.fun", "平局决胜时 ydScore 83.0 (低惩罚) 必须优于 173.0")
+
 
 if __name__ == "__main__":
     unittest.main()