Files
CyberGril/tests/test_mood_runtime.py
T
zonggongandmultica-agent 31970c4dd3 HEL-870 assembly: memory/persona/mood/life/proactive-contact joined into one runnable candidate on an additive R4 migration (no existing table or row replaced; no visual changes beyond mechanical proactive flag passthrough) - src/cybergril/memory/semantic.py productizes the audited Mem0 path per plan-next-HEL-869: input-side pre-grouping sends only public rows outbound (user rows always; companion rows only judged+acked with all-told candidates), every batch anchor turn_ids/turn_roles/input_sha256 equals that exact input set, and the read-time gate independently recomputes the fingerprint against the live archive and re-checks every companion row's share state, fail-closed rejecting no_payload/unknown_source/archive_missing/source_mismatch/hit_text_mismatch/anchor_turn_mismatch/role_text_conflict/companion_unshared with honest row_verbatim/row_anchor/batch provenance; main archive chat_message remains the ONLY permanent quote layer; sem_batch/sem_covered carry a lease-recovered idempotent job loop; EmbedLedgerGuard continues the single authoritative embed ledger and with embed_cap=0 (no approved budget) refuses every real embedding so the frozen 498/500 and 39/60 accounts and the HEL-835 gate stay untouched; fixed Mem0 2c81af3 is lazily imported with zero upstream fork, a deterministic stub backend exists for engineering verification and is never claimed semantic; MoodRuntime makes core.mood live (event impulses scaled by bazi strength sensitivity, heartbeat decay, state_snapshot persistence, restart recovery, model sees one word only); DyadRuntime makes the relationship ledger live (completed exchanges are rituals, daily regression, behavior hints stay instructions); life_activity adds start/continue/interrupt/resume/finish continuity across slots and restarts with chat-completion interrupts; reach_out implements the approved R4 scope from character.yaml (>=6h interval, hours 8-23, salience>=0.6, arousal>=0.5, intimacy>=0.4) with traceable already-public bases only, UNIQUE(basis) so restarts and duplicate tasks can never multiply messages, night suppression, absence-rule fail-closed, phi projection, and messages persisted as companion rows (proactive=1) fully synced on reopen; API health reports R4-assembly with per-capability honesty (voice/vision/weather/materials/backup stay false, semantic_memory names its backend); config/semantic.example.yaml sanitized config and docs/runbook-hel870.md (startup, locked deps, health, independent data dir, disable/rollback, experience chain list, budget request, R1-R7 seams); verification: 250 python tests pass on 3.11 venv + requirements.lock (61 new deterministic/fake-provider cases covering every gate negative, restart recovery, dedupe, red line and the full entry chain incl. reopen epoch sync), ruff clean, web typecheck + 42 vitest + build pass with the original author frontend untouched except the one-line proactive passthrough; dual-target requirement checks 4/4 pass vs origin/main and origin/agent/agent/de8874a3803b with handoff-HEL-870.json; zero real model calls, zero embeddings, zero outbound network this card; local unified-instance smoke on port 8360 (stub semantic) serves the built web and reports the life loop waiting; read-only server check recorded in the runbook (existing 8300-8350 services and voice ports observed, end-to-end voice availability NOT verified); not merged, not deployed, no production or shared services touched
Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 21:03:42 +08:00

82 lines
2.7 KiB
Python

"""R4 mood wiring: events push state, heartbeat advances, restarts recover."""
import pytest
from cybergril.config import CharacterConfig
from cybergril.core.mood_runtime import MoodRuntime
from cybergril.memory.store import Store
@pytest.fixture
def store(tmp_path):
s = Store(tmp_path / "mood.db")
yield s
s.close()
class Clock:
def __init__(self, start=1700000000.0):
self.now = start
def __call__(self):
return self.now
def advance(self, seconds):
self.now += seconds
def test_first_start_persists_baseline(store):
clock = Clock()
mood = MoodRuntime(store, CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}), clock=clock)
mood.recover()
row = store.last_state()
assert row is not None and row["mood_word"] == "平静"
def test_events_push_state_and_it_persists(store):
clock = Clock()
mood = MoodRuntime(store, CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}), clock=clock)
mood.recover()
before = mood.state.v
mood.impulse(valence=0.8, salience=0.9)
assert mood.state.v > before
assert mood.state.a > 0.5 # enough to satisfy reach_out.min_arousal
# persisted snapshot matches live state
row = store.last_state()
assert abs(row["valence"] - mood.state.v) < 1e-6
def test_heartbeat_decays_toward_baseline(store):
clock = Clock()
mood = MoodRuntime(store, CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}), clock=clock)
mood.recover()
mood.impulse(valence=0.9, salience=1.0)
peak = mood.state.v
clock.advance(6 * 3600)
mood.advance()
assert mood.state.v < peak # decayed back toward the baseline
def test_restart_recovers_decayed_state_not_reset(store):
clock = Clock()
mood = MoodRuntime(store, CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}), clock=clock)
mood.recover()
mood.impulse(valence=-0.9, salience=0.8)
low = mood.state.v
# "restart": a fresh runtime over the same database, hours later — the decay
# continues from the persisted snapshot toward the baseline (0.05), never a reset
clock.advance(3 * 3600)
revived = MoodRuntime(store, CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}), clock=clock)
revived.recover()
assert revived.state.ts == int(clock.now * 1000)
assert low < revived.state.v < 0.05 # decayed toward baseline, still below it
def test_word_and_prompt_hint_never_expose_numbers(store):
clock = Clock()
mood = MoodRuntime(store, CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12}), clock=clock)
mood.recover()
hint = mood.prompt_hint()
assert "0." not in hint and mood.word() in hint
snap = mood.snapshot()
assert set(snap) == {"v", "a", "d", "e", "mood_word"}