Files
mobai 1012d8bb79 chore: project foundation
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.
2026-10-19 20:08:52 +08:00

134 lines
3.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""情绪动力学测试。
守两件事:
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