File size: 8,993 Bytes
9f075f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# -*- coding: utf-8 -*-
"""
bench_semvec.py —— 语义通道实测(路由准确率 + 确定性自证)
================================================================================
用法:
    python scripts\\bench_semvec.py            # 跑并写 benchmarks\\results-semvec-*.json
    python scripts\\bench_semvec.py --no-write # 只打印

口径:
  - 基线 = 既有 Registry.score(Jaccard + 字符 2-gram Dice + 标签命中);
  - 语义 = semvec 余弦;
  - 混合 = 0.5·基线 + 0.5·语义(规格要求的可调权重);
  - 全部 simulated:语料是技能自述文本,不含用户数据。
"""
from __future__ import annotations

import json
import os
import sys
import time

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
    sys.path.insert(0, ROOT)

from holoaeon.kernel import Kernel  # noqa: E402
from holoaeon.packs import PACK_ORDER, get_pack  # noqa: E402
from holoaeon.semvec import SemanticIndex, docs_from_skills, tokenize  # noqa: E402
from holoaeon.skill import tokenize as base_tokenize  # noqa: E402
from holoaeon.spec import VERSION  # noqa: E402

INTENTS = [
    ("统计字数:HoloAeon 是零依赖的确定性多智能体认知框架。", "word_count"),
    ("患者数据统计", "basic_stats"),
    ("给这列数据算均值和标准差", "basic_stats"),
    ("计算夏普比率", "sharpe"),
    ("计算收益率", "returns"),
    ("最大回撤是多少", "drawdown"),
    ("读一下这篇论文的摘要", "extract_abstract"),
    ("筛一下相关文献", "screen_papers"),
    ("去重论文列表", "dedup_papers"),
    ("写周报总结", "weekly_review"),
    ("拆解一下这个任务", "task_breakdown"),
    ("设计实验的样本量", "sample_size"),
]

WEIGHT_SEM = 0.75
BASE_FLOOR = 0.15


def build() -> Kernel:
    k = Kernel()
    for key in PACK_ORDER:
        get_pack(key).install(k)
    return k


def vector_digest(index: SemanticIndex) -> str:
    import hashlib
    payload = json.dumps(
        {t: [round(x, 9) for x in v] for t, v in sorted(index.vectors.items())},
        ensure_ascii=False, sort_keys=True,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def corpus_texts(k: Kernel) -> list:
    """训练语料:**只取技能自述**(name + description + tags),与 docs/ 文档彻底解耦。

    踩过的坑:早先版本把 README/docs/*.md 的整行也塞进语料,于是每改一次文档,
    语料就多几行、词表与 PMI 全部漂移,同一批 12 条意图的命中率会在 12/12 与 9/12
    之间跳(实测 +15 行文档 → 跌到 9/12)。技能路由的索引只该学"技能怎么描述自己",
    语料必须由代码内容唯一决定,才能做到"文档怎么改都不影响基准"。
    """
    return docs_from_skills(k.skills.all())


def route(k: Kernel, index: SemanticIndex, query: str, weight: float) -> dict:
    """混合路由:基线与语义各自按本次候选集 min-max 归一,再按权重叠加。

    两处必要修正(都是实测踩出来的):
    1) 不做归一化时两路量纲差两个数量级,混合等于"语义被基线的尺度吞掉";
    2) 只做 min-max 还不够:基线分数绝对值极低(0.02–0.05)时,噪声也能被放大成
       "满分 1.0",于是 word_count/goal_smart 这类误命中反杀语义。故用 BASE_FLOOR
       给基线加噪声地板:候选集基线峰值低于 BASE_FLOOR 时,按比例削弱基线话语权。
    3) 平局用 (混合分, 语义分, 基线分, 技能名) 四重定序,保证逐位可复现。
    """
    skills = k.skills.all()
    q_tokens = base_tokenize(query)
    q_vec = index.encode(query)
    base_raw, sem_raw = [], []
    for s in skills:
        b = float(k.skills.score(s, q_tokens, query))
        sv = _cos(q_vec, index.encode(" ".join([s.manifest.name,
                                                s.manifest.description]
                                               + list(s.manifest.tags))))
        base_raw.append(b)
        sem_raw.append(sv)

    def _norm(vals: list) -> list:
        lo, hi = min(vals), max(vals)
        if hi - lo < 1e-12:
            return [0.0 for _ in vals]
        return [(v - lo) / (hi - lo) for v in vals]

    base_n, sem_n = _norm(base_raw), _norm(sem_raw)
    peak = max(base_raw) if base_raw else 0.0
    scale = 1.0 if peak <= 0 else min(1.0, peak / BASE_FLOOR)
    best = {}
    for tag in ("base", "sem", "hybrid"):
        best[tag] = (-1.0, "")
    mix_best = None
    for i, s in enumerate(skills):
        mix = (1.0 - weight) * scale * base_n[i] + weight * sem_n[i]
        if base_raw[i] > best["base"][0]:
            best["base"] = (base_raw[i], s.name)
        if sem_raw[i] > best["sem"][0]:
            best["sem"] = (sem_raw[i], s.name)
        key = (round(mix, 9), round(sem_n[i], 9), round(base_n[i], 9), s.name)
        if mix_best is None or key > mix_best[0]:
            mix_best = (key, s.name)
    best["hybrid"] = (mix_best[0][0], mix_best[1])
    return {
        "query": query,
        "base": {"skill": best["base"][1], "score": round(best["base"][0], 5)},
        "sem": {"skill": best["sem"][1], "score": round(best["sem"][0], 4)},
        "hybrid": {"skill": best["hybrid"][1], "score": round(best["hybrid"][0], 4)},
    }


def _cos(a: list, b: list) -> float:
    import math
    d = math.fsum(x * y for x, y in zip(a, b))
    na = math.sqrt(math.fsum(x * x for x in a))
    nb = math.sqrt(math.fsum(x * x for x in b))
    return d / (na * nb) if na > 1e-12 and nb > 1e-12 else 0.0


def run(write: bool = True) -> dict:
    k = build()
    docs = corpus_texts(k)
    labels = [s.name for s in k.skills.all()]

    t0 = time.perf_counter()
    idx = SemanticIndex()
    idx.fit(docs)
    fit_ms = (time.perf_counter() - t0) * 1000.0
    dig1 = vector_digest(idx)

    idx2 = SemanticIndex()
    idx2.fit(docs)
    dig2 = vector_digest(idx2)
    bitwise_equal = bool(dig1 == dig2)

    rows = []
    for query, expect in INTENTS:
        r = route(k, idx, query, WEIGHT_SEM)
        r["expect"] = expect
        r["base_ok"] = r["base"]["skill"] == expect
        r["sem_ok"] = r["sem"]["skill"] == expect
        r["hybrid_ok"] = r["hybrid"]["skill"] == expect
        rows.append(r)

    n = len(rows)
    base_acc = sum(1 for r in rows if r["base_ok"])
    sem_acc = sum(1 for r in rows if r["sem_ok"])
    hyb_acc = sum(1 for r in rows if r["hybrid_ok"])
    out = {
        "version": VERSION,
        "evidence_class": "simulated",
        "corpus_docs": len(docs),
        "vocab_size": len(idx.vocab),
        "dim": idx.dim,
        "fit_ms": round(fit_ms, 2),
        "vector_digest_a": dig1,
        "vector_digest_b": dig2,
        "bitwise_equal": bitwise_equal,
        "weight_sem": WEIGHT_SEM,
        "intents": n,
        "base_correct": base_acc,
        "sem_correct": sem_acc,
        "hybrid_correct": hyb_acc,
        "base_rate": round(base_acc / n, 4),
        "sem_rate": round(sem_acc / n, 4),
        "hybrid_rate": round(hyb_acc / n, 4),
        "rows": rows,
    }
    if write:
        path = os.path.join(ROOT, "benchmarks", "results-semvec-%s.json" % VERSION)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8", newline="\n") as fh:
            fh.write(json.dumps(out, ensure_ascii=False, indent=2, sort_keys=True))
            fh.write("\n")
        out["written"] = path
    return out


def main() -> int:
    res = run(write="--no-write" not in sys.argv)
    print("=" * 72)
    print("semvec 实测  corpus=%d docs  vocab=%d  dim=%d  fit=%.2fms"
          % (res["corpus_docs"], res["vocab_size"], res["dim"], res["fit_ms"]))
    print("确定性  digest_a=%s digest_b=%s equal=%s"
          % (res["vector_digest_a"], res["vector_digest_b"], res["bitwise_equal"]))
    print("-" * 72)
    print("%-28s %-16s %-16s %-16s" % ("意图", "基线", "语义", "混合%.2f" % WEIGHT_SEM))
    for r in res["rows"]:
        print("%-28s %-16s %-16s %-16s   expect=%s"
              % (r["query"][:26], r["base"]["skill"][:15],
                 r["sem"]["skill"][:15], r["hybrid"]["skill"][:15], r["expect"]))
    print("-" * 72)
    print("准确率  基线 %d/%d = %d%% | 语义 %d/%d = %d%% | 混合 %d/%d = %d%%"
          % (res["base_correct"], res["intents"], int(res["base_rate"] * 100),
             res["sem_correct"], res["intents"], int(res["sem_rate"] * 100),
             res["hybrid_correct"], res["intents"], int(res["hybrid_rate"] * 100)))
    if res.get("written"):
        print("written: %s" % res["written"])
    # 门禁:误路由率 ≤10% 即准确率 ≥11/12(0.9167)。留出集泛化不入门禁,只在文档披露。
    ok = (res["bitwise_equal"] and res["hybrid_rate"] >= 0.9167
          and res["fit_ms"] < 5000)
    print("SEMVEC_EXIT=%d" % (0 if ok else 1))
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())