File size: 10,184 Bytes
79c6440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# -*- coding: utf-8 -*-
"""Vibrato(颤音) PAD 契约 — 冻结的输出刻度与换算(单文件自包含)

Vibrato 是 Chordia(弦音) 生态的情绪前置网络: 输入消息+上轮PAD回声,
输出 P/A/D 三维 × 9档序数bin分布 + 全局置信度。压力值不进网络——
按 Chordia 标准由 ΔPAD 派生(本文件提供参照实现)。

刻度对齐(勿改, 改了就是与 chordia-engine 的契约破裂):
- chordia pad_calculator.py: 18词项李克特 1-9 分; P=mean(I1..I6), A=mean(I7..I12),
  D=mean(I13..I18); 标准化 = (均值-5)/4 → [-1,1]
- 本契约: 每维直接输出 9 档 bin 分布, 期望值 E[bin] ∈ [1,9] 等价于该维 6 词项均值,
  过同一标准化公式。chordia 历史 LLM 分析器的 18 项输出可用 items_to_bins()
  无损(核平滑)转成训练软标签。

echo 状态(v5 已废): 上轮 P/A/D 回声 3 浮点已溶解进 v6 消息范式——调用方把上轮
输出写回上一条 user 消息的 pad 字段即是回声(vib_messages.py)。
契约指纹 contract_digest() 训练前后/部署两侧比对, 不一致即停; v6 起指纹含
上下文窗口与 affect 消化能力(vib_messages.CONSUMED_FIELDS)。
"""

from __future__ import annotations

import hashlib
import json
import math
from typing import Dict, List, Sequence

import vib_messages

# ── 冻结刻度 ────────────────────────────────────────────────
DIMS: List[str] = ["pleasure", "arousal", "dominance"]
N_BINS = 9                          # 李克特 1..9, bin i ↔ 分值 i+1
BIN_CENTERS = list(range(1, 10))    # 1..9
ECHO_LEN = 3                        # v5 legacy: v6 起由消息范式状态伪token取代(仅桥接旧数据)

# chordia pad_calculator.py 的 18 词项分组(只取分组索引, 词项原文在
# chordia-engine/prompts/emotion_analysis.md, 不在此复制)
ITEM_GROUPING: Dict[str, List[str]] = {
    "pleasure": [f"I{i}" for i in range(1, 7)],
    "arousal": [f"I{i}" for i in range(7, 13)],
    "dominance": [f"I{i}" for i in range(13, 19)],
}
LIKERT_MIN, LIKERT_MID, LIKERT_HALF = 1.0, 5.0, 4.0


def expected_from_bins(dist: Sequence[float]) -> float:
    """bin 分布 → 李克特期望分值 ∈ [1,9]。"""
    if len(dist) != N_BINS:
        raise ValueError(f"bin 分布长度应为 {N_BINS}, 得到 {len(dist)}")
    total = float(sum(dist))
    if not (0.999 <= total <= 1.001):
        raise ValueError(f"bin 分布未归一化: sum={total}")
    return sum(p * c for p, c in zip(dist, BIN_CENTERS))


def normalize_likert(expected: float) -> float:
    """李克特期望值 → [-1,1], 与 chordia (均值-5)/4 同式。"""
    return max(-1.0, min(1.0, (expected - LIKERT_MID) / LIKERT_HALF))


def bins_to_pad(bins: Dict[str, Sequence[float]]) -> Dict[str, float]:
    """{dim: 9档分布} → {pleasure, arousal, dominance} 归一化 PAD。"""
    return {dim: round(normalize_likert(expected_from_bins(bins[dim])), 6) for dim in DIMS}


def likert_to_bins(value: float, half_width: float = 1.0) -> List[float]:
    """单个李克特分值 → 9档帐篷核分布(items_to_bins 的单值内核)。

    老数据桥接用: chordia 60k 的 PAD([-4,4]) 换算 L = pad + 5 即落 [1,9]。
    """
    if not (1.0 <= value <= 9.0):
        raise ValueError(f"李克特分越界: {value}")
    acc = [max(0.0, 1.0 - abs(bc - value) / half_width) for bc in BIN_CENTERS]
    total = sum(acc)
    return [v / total for v in acc] if total > 0 else [1.0 / N_BINS] * N_BINS


def items_to_bins(scores: Dict[str, float], half_width: float = 1.0) -> Dict[str, List[float]]:
    """chordia 18 词项评分 → 3×9 软分布。

    half_width=1.0(默认)是帐篷核: 整数分值落单 bin, 带小数分值在相邻两 bin
    按线性比例插值——**精确保留期望值**(两位小数分数的插值即其自然不确定性)。
    同维 6 个核平均后归一化: 词项一致时分布尖锐, 分歧时展宽, 不确定性留在
    分布形状里。half_width>1 会附加平滑但引入期望偏差(O((h-1)/4)), 谨慎用。
    """
    out: Dict[str, List[float]] = {}
    for dim, items in ITEM_GROUPING.items():
        acc = [0.0] * N_BINS
        n_used = 0
        for key in items:
            if key not in scores:
                continue
            center = float(scores[key])
            for i, bc in enumerate(BIN_CENTERS):
                d = abs(bc - center)
                if d < half_width:
                    acc[i] += 1.0 - d / half_width
            n_used += 1
        if n_used == 0:
            raise ValueError(f"{dim}: 18 词项评分中无任何 {items}")
        total = sum(acc)
        out[dim] = [v / total for v in acc] if total > 0 else [1.0 / N_BINS] * N_BINS
    return out


def pad_to_pressure_delta(d_p: float, d_a: float, d_d: float) -> float:
    """压力增量参照实现(Chordia 压力值标准 v1, 2 节):

        ΔPressure_raw = 1.0×(−ΔP) + 0.8×(ΔA) + 0.6×(−ΔD)

    累积/衰减/反馈调制归宿主(chordia)——防自激红线: 累积必须用调制前的
    原始 ΔPAD, 此函数输出即为原始值。
    """
    return 1.0 * (-d_p) + 0.8 * d_a + 0.6 * (-d_d)


# ── 训练行校验 ──────────────────────────────────────────────
def validate_gold(gold: Dict) -> List[str]:
    """校验一条训练软标签 {pad_bins: {dim: 9}, echo: [3]}。返回问题列表。"""
    problems: List[str] = []
    pad_bins = gold.get("pad_bins")
    if not isinstance(pad_bins, dict) or set(pad_bins.keys()) != set(DIMS):
        problems.append(f"pad_bins 键应为 {DIMS}")
        return problems
    for dim in DIMS:
        dist = pad_bins[dim]
        if len(dist) != N_BINS:
            problems.append(f"{dim}: bin 长度 {len(dist)} != {N_BINS}")
            continue
        s = sum(dist)
        if not (0.999 <= s <= 1.001) or any(p < 0 for p in dist):
            problems.append(f"{dim}: 分布非法 (sum={s:.4f})")
    echo = gold.get("echo")              # v5 legacy 行才带; v6 行状态在消息层, 此处不查
    if echo is not None and (len(echo) != ECHO_LEN
                             or any(not (-1.0 <= v <= 1.0) for v in echo)):
        problems.append(f"echo 应为 {ECHO_LEN} 个 [-1,1] 浮点")
    return problems


def contract_digest() -> str:
    canonical = json.dumps(
        {
            "dims": DIMS,
            "n_bins": N_BINS,
            "bin_centers": BIN_CENTERS,
            "item_grouping": ITEM_GROUPING,
            "likert": [LIKERT_MIN, LIKERT_MID, LIKERT_HALF],
            "pressure_weights": [1.0, 0.8, 0.6],
            # ── v6: 消息范式消化能力(换 checkpoint 代际即变, 部署侧必须同代) ──
            "window": vib_messages.MAX_WINDOW,
            "affect_consumed": list(vib_messages.CONSUMED_FIELDS),
            "state_divisors": list(vib_messages.STATE_DIVISORS),
        },
        ensure_ascii=False, sort_keys=True, separators=(",", ":"),
    )
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def verify_ckpt(ck: Dict, hard: bool = True) -> List[str]:
    """加载侧契约闸(README 声明的执行者): checkpoint 内指纹与当前模块不一致即拒绝。

    只比对 ckpt 里存在的键(v5 老 ckpt 无 affect_schema 键 → 跳过该键)。
    部署/导出路径应 hard=True; 跨代际对照评测(如 judge 加载 v5)可 hard=False 取警告。
    返回不一致键列表(hard 且不一致时先抛 RuntimeError)。
    """
    import battery
    import vib_messages
    expect = {"pad_digest": contract_digest(),
              "battery_digest": battery.battery_digest(),
              "affect_schema": vib_messages.schema_digest()}
    bad = [k for k, v in expect.items() if k in ck and ck[k] != v]
    if bad and hard:
        raise RuntimeError(f"契约指纹不一致: {bad} — 代码与 checkpoint 代际不匹配, 拒绝运行")
    return bad


# ── 自检 ────────────────────────────────────────────────────
if __name__ == "__main__":
    # 1) 全 5 分(中性) → PAD 归零
    neutral = {f"I{i}": 5.0 for i in range(1, 19)}
    pad = bins_to_pad(items_to_bins(neutral))
    assert all(abs(v) < 1e-6 for v in pad.values()), pad
    print("中性 18×5.0 → PAD(0,0,0) OK")

    # 2) 与 chordia PADCalculator 公式等价(词项一致时, 期望值=均值)
    rng = __import__("random").Random(7)
    worst = 0.0
    for _ in range(200):
        scores = {f"I{i}": rng.uniform(1, 9) for i in range(1, 19)}
        bins = items_to_bins(scores)
        ours = bins_to_pad(bins)
        for dim, items in ITEM_GROUPING.items():
            ref = (sum(scores[k] for k in items) / 6.0 - 5.0) / 4.0
            worst = max(worst, abs(ours[dim] - max(-1, min(1, ref))))
    assert worst < 1e-6, worst
    print(f"与 chordia 公式等价 OK (200 随机样本最大偏差 {worst:.4f})")

    # 3) 词项分歧 → 分布展宽(不确定性可见)
    agree = items_to_bins({f"I{i}": 7.0 for i in range(1, 19)})["pleasure"]
    ent = lambda d: -sum(p * math.log(max(p, 1e-12)) for p in d)
    assert ent(agree) < 0.1
    mixed = items_to_bins({f"I{i}": (7.0 if i % 2 else 3.0) for i in range(1, 19)})["pleasure"]
    assert ent(mixed) > ent(agree) + 0.5
    print(f"分歧展宽 OK (一致熵 {ent(agree):.3f} < 分歧熵 {ent(mixed):.3f})")

    # 4) 压力公式方向性
    assert pad_to_pressure_delta(-0.5, 0.3, -0.2) > 0    # 恶化方向 → 加压
    assert pad_to_pressure_delta(0.5, -0.3, 0.2) < 0     # 好转方向 → 泄压
    print("压力增量方向性 OK")

    # 5) 校验器 + 指纹
    good = {"pad_bins": items_to_bins(neutral), "echo": [0.0] * 3}
    assert validate_gold(good) == []
    bad = {"pad_bins": {"pleasure": [0.2] * 9, "arousal": [1 / 9] * 9, "dominance": [1 / 9] * 9},
           "echo": [0.0, 2.0, 0.0]}
    assert len(validate_gold(bad)) >= 2
    print("validate_gold OK;  contract_digest:", contract_digest())
    print("✓ pad_schema 自检全部通过")