Contracts (the shared language): - percept / state / context / utterance / render / audio / capabilities - Capability model instead of vendor schemas (see below) Providers (capability-based abstraction): - LLM + Embedding: OpenAI-compatible shape IS the standard, adopted directly - ASR: internal contract is an event stream (partial -> final), because /v1/audio/transcriptions has no partial and dialogue needs it for endpointing. OpenAI adapter degrades to supports_partial=False. - TTS: internal contract carries tags/emotion/style, because /v1/audio/speech has no paralinguistics and [laughter]/[breath] are the core of feeling alive. Unsupported providers are stripped AND reported via Degradation -- silent degradation is forbidden. - Avatar: no de-facto standard, custom RenderCmd protocol driven by State. Core: - mood: dynamical system, slow variables, negativity bias (grudge), numbing after prolonged negativity - bazi: four pillars -> numeric vector; 'ming' is data, not prose - projection: phi, system event -> in-world statement; uncovered events are counted as projection gaps Storage: - SQLite schema, three memory layers, dyad ledger - accessibility floor 0.03 (not 0): forgettable but still there Config split: - models.yaml is the ONLY place model versions live - character.yaml holds world rules / emotion params, no version numbers 58 tests pass; ruff clean.
134 lines
3.7 KiB
Python
134 lines
3.7 KiB
Python
"""情绪动力学测试。
|
||
|
||
守两件事:
|
||
1. 情绪是慢变量(有惯性),不是每轮重置
|
||
2. 负向偏置真实存在(难过比开心久)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
from cybergril.contracts import State
|
||
from cybergril.core.mood import (
|
||
Baseline,
|
||
Impulse,
|
||
MoodParams,
|
||
NumbingTracker,
|
||
decay,
|
||
make_impulse,
|
||
mood_word,
|
||
step,
|
||
)
|
||
|
||
P = MoodParams.default()
|
||
B = Baseline()
|
||
|
||
|
||
def test_decay_moves_toward_baseline() -> None:
|
||
s = State(ts=0, v=-0.8, a=0.9, d=0.9, e=0.1)
|
||
out = decay(s, B, dt_seconds=3600, params=P)
|
||
# 一小时后应该更接近基线
|
||
assert out.v > s.v
|
||
assert out.a < s.a
|
||
assert out.e > s.e
|
||
|
||
|
||
def test_negative_bias_slower_recovery() -> None:
|
||
"""负向恢复必须慢于正向。这是"记仇"的技术来源。
|
||
|
||
检验方式:比较"剩余距离占初始距离的比例",再解出时间常数之比。
|
||
纯正向时 tau_v = 3h;负向时 tau_v ×2 = 6h。
|
||
"""
|
||
start_v = 0.5
|
||
pos = State(ts=0, v=start_v)
|
||
neg = State(ts=0, v=-start_v)
|
||
|
||
gap0_pos = abs(start_v - B.v)
|
||
gap0_neg = abs(-start_v - B.v)
|
||
|
||
after_pos = decay(pos, B, 3600, P)
|
||
after_neg = decay(neg, B, 3600, P)
|
||
|
||
pos_gap = abs(after_pos.v - B.v)
|
||
neg_gap = abs(after_neg.v - B.v)
|
||
|
||
# 同样的时间,负向剩得更多
|
||
assert neg_gap > pos_gap
|
||
|
||
# 剩余比例:exp(-dt/tau)。两比例取对数后相除 = tau_neg / tau_pos
|
||
pos_frac = pos_gap / gap0_pos
|
||
neg_frac = neg_gap / gap0_neg
|
||
assert neg_frac > pos_frac
|
||
|
||
tau_ratio = math.log(pos_frac) / math.log(neg_frac)
|
||
# 配置里 neg_bias["v"] = 2.0
|
||
assert 1.95 <= tau_ratio <= 2.05
|
||
|
||
|
||
def test_decay_is_monotonic_toward_baseline() -> None:
|
||
s = State(ts=0, v=-0.9)
|
||
prev = s.v
|
||
for _ in range(10):
|
||
s = decay(s, B, 1800, P)
|
||
assert s.v > prev
|
||
prev = s.v
|
||
|
||
|
||
def test_impulse_scales_with_sensitivity() -> None:
|
||
strong = make_impulse(valence=-0.8, salience=0.9, sensitivity=0.65)
|
||
weak = make_impulse(valence=-0.8, salience=0.9, sensitivity=1.35)
|
||
assert abs(weak.v) > abs(strong.v)
|
||
|
||
|
||
def test_impulse_direction() -> None:
|
||
bad = make_impulse(valence=-0.7, salience=0.8, sensitivity=1.0)
|
||
good = make_impulse(valence=0.7, salience=0.8, sensitivity=1.0)
|
||
assert bad.v < 0 < good.v
|
||
# 好事也会消耗精力
|
||
assert bad.e <= 0 and good.e <= 0
|
||
|
||
|
||
def test_step_clamps_and_advances_time() -> None:
|
||
s = State(ts=1000, v=0.0)
|
||
huge = Impulse(v=99.0, a=99.0)
|
||
out = step(s, B, dt_seconds=60, params=P, impulses=(huge,))
|
||
assert -1.0 <= out.v <= 1.0
|
||
assert 0.0 <= out.a <= 1.0
|
||
assert out.ts == 1000 + 60_000
|
||
|
||
|
||
def test_step_no_impulse_returns_valid_state() -> None:
|
||
s = State(ts=0, v=0.2, a=0.4, d=0.5, e=0.6)
|
||
out = step(s, B, 60, P)
|
||
assert isinstance(out, State)
|
||
out.clamp()
|
||
|
||
|
||
def test_mood_word_bands() -> None:
|
||
assert mood_word(v=0.6, a=0.7, e=0.8) == "雀跃"
|
||
assert mood_word(v=-0.8, a=0.4, e=0.6) == "低落"
|
||
assert mood_word(v=0.0, a=0.3, e=0.5) == "平静"
|
||
assert mood_word(v=0.0, a=0.3, e=0.1) == "疲惫"
|
||
|
||
|
||
def test_numbing_kicks_in_after_threshold() -> None:
|
||
tracker = NumbingTracker(after_hours=6.0, drop=0.15)
|
||
s = State(ts=0, v=-0.5)
|
||
|
||
# 刚开始:无影响
|
||
assert tracker.update(s) == 0.0
|
||
|
||
# 推进 8 小时
|
||
s2 = State(ts=8 * 3600 * 1000, v=-0.5)
|
||
offset = tracker.update(s2)
|
||
assert offset < 0
|
||
|
||
|
||
def test_numbing_resets_on_positive() -> None:
|
||
tracker = NumbingTracker(after_hours=1.0, drop=0.15)
|
||
tracker.update(State(ts=0, v=-0.5))
|
||
tracker.update(State(ts=3 * 3600 * 1000, v=-0.5)) # 已经麻木
|
||
tracker.update(State(ts=4 * 3600 * 1000, v=0.5)) # 转正
|
||
assert tracker.update(State(ts=5 * 3600 * 1000, v=0.5)) == 0.0
|