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 自检全部通过")
|