Files
ratatoskr/tests/test_provider_affect.py
vh ca6af6bdaa feat(#18): affect.fetch — adopt bifrost 0.10.0 mandatory fetch (D1 prerequisite)
bifrost 0.10.0's _supports_affect_plane (bifrost/affect.py:75-80) now requires a
callable fetch for the affect capability to advertise/dispatch at all (INV-012
strong-or-absent), so an emit-only store 400s on EVERY affect op — repinning past
the affect.fetch release (#12/#13) breaks our shipped affect plane until fetch
exists. Implement affect.fetch as a thin async wrapper over the existing get()
read seam, conformed verbatim to the reference InMemoryAffectStore.fetch:
{"found": False} or {"found": True, "snapshot": <verbatim>}, AffectInvalidArguments
on empty ids, opaque (INV-001 — never reads pad/valence).

This is the forced prerequisite for the #18 D1 composite (build_combined_app),
and a new Worldtree I/O point consumed (affect read-back over bifrost).

- Repin bifrost>=0.8.0 -> >=0.10.0 (uv lock: 0.8.0 -> 0.10.0)
- affect_store.py: add async fetch() over get()
- contract bifrost_affect_provider v1.2: fetch FN block + INV-010 (cap = supported+emit+fetch)
- tests: 3 fetch unit + parity_vs_reference_fetch through dispatch_affect_call
- suite 482 -> 486 green
2026-06-19 22:59:59 -07:00

372 lines
14 KiB
Python

"""Tests for the Tier-3 Bifrost affect provider (ratatoskr.provider.affect_store).
Contract: docs/contracts/bifrost_affect_provider.contract.md
Vertical tracer-first: basic_emit -> opacity -> lww -> replay -> conflict ->
missing_key -> #195 parity vs InMemoryAffectStore.
"""
from __future__ import annotations
import types
import pytest
from bifrost.affect import AffectIdempotencyConflict, AffectInvalidArguments
from ratatoskr.provider.affect_store import build_affect_provider_app, open_affect_store
def _ctx(sub: str = "sub-1"):
# Mirrors bifrost's _ctx_actor: actor = jwt_sub (test ctx) or session_id.
return types.SimpleNamespace(jwt_sub=sub)
def _snapshot(agent: str = "a1", user: str = "u1", **payload):
base = {
"agent_id": agent,
"end_user_id": user,
"pad": {"p": 0.1, "a": 0.2, "d": 0.3},
"valence": 0.5,
"persona_baselines": {"warmth": 0.7},
"emitted_at": "2026-06-14T00:00:00Z",
}
base.update(payload)
return base
def _row_count(store, table: str) -> int:
return store._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
# --- open_affect_store ---
def test_open_advertises_capability_and_schema():
store = open_affect_store(":memory:")
assert store.affect_supported is True
# both tables queryable
store._conn.execute("SELECT * FROM affect_snapshots")
store._conn.execute("SELECT * FROM affect_idempotency")
def test_open_sets_busy_timeout(tmp_path):
"""INV-006: every connection sets busy_timeout>=5000ms (WAL's default is 0, so a
contended write would fail SQLITE_BUSY immediately) — prep for the two-process
composite/standalone topology."""
store = open_affect_store(str(tmp_path / "affect.db"))
assert store._conn.execute("PRAGMA busy_timeout").fetchone()[0] == 5000
def test_reopen_existing_file_is_idempotent(tmp_path):
db = str(tmp_path / "affect.db")
open_affect_store(db) # first open creates schema
store = open_affect_store(db) # reopen: CREATE TABLE IF NOT EXISTS is a no-op
assert store.affect_supported is True
store._conn.execute("SELECT * FROM affect_snapshots")
store._conn.execute("SELECT * FROM affect_idempotency")
# --- emit ---
async def test_basic_emit_stores_and_round_trips():
store = open_affect_store(":memory:")
snap = _snapshot()
result = await store.emit(snap, idempotency_key="k1", ctx=_ctx())
assert result == {"stored": True}
assert store.get("a1", "u1") == snap
async def test_opacity_arbitrary_payload_round_trips_and_addressing_invariant():
store = open_affect_store(":memory:")
# arbitrary extra/unknown payload fields persist + round-trip verbatim
snap = _snapshot(weird_field={"nested": [1, 2, 3]}, mystery="x")
assert await store.emit(snap, idempotency_key="k1", ctx=_ctx()) == {"stored": True}
assert store.get("a1", "u1") == snap
# two snapshots for the same key differing ONLY in payload address the SAME row
snap2 = _snapshot(weird_field={"nested": [9]}, mystery="y", valence=0.99)
await store.emit(snap2, idempotency_key="k2", ctx=_ctx())
assert store.get("a1", "u1") == snap2
assert _row_count(store, "affect_snapshots") == 1 # same row overwritten
async def test_lww_by_arrival_ignores_emitted_at():
store = open_affect_store(":memory:")
a = _snapshot(valence=0.1, emitted_at="2026-06-14T10:00:00Z")
b = _snapshot(valence=0.9, emitted_at="2026-06-14T08:00:00Z") # OLDER emitted_at
await store.emit(a, idempotency_key="ka", ctx=_ctx())
await store.emit(b, idempotency_key="kb", ctx=_ctx()) # distinct key -> arrival wins
assert store.get("a1", "u1") == b # later arrival wins despite older emitted_at
async def test_replay_noop_same_key_same_payload():
store = open_affect_store(":memory:")
snap = _snapshot()
assert await store.emit(snap, idempotency_key="k1", ctx=_ctx()) == {"stored": True}
assert await store.emit(snap, idempotency_key="k1", ctx=_ctx()) == {"stored": True}
assert store.get("a1", "u1") == snap
assert _row_count(store, "affect_snapshots") == 1 # replay did not duplicate
async def test_idempotency_conflict_same_key_different_payload():
store = open_affect_store(":memory:")
first = _snapshot(valence=0.1)
await store.emit(first, idempotency_key="k1", ctx=_ctx())
with pytest.raises(AffectIdempotencyConflict):
await store.emit(_snapshot(valence=0.2), idempotency_key="k1", ctx=_ctx())
assert store.get("a1", "u1") == first # prior snapshot untouched
async def test_same_key_distinct_actor_is_not_a_conflict():
# idempotency is actor-scoped (INV-006/-008): same key, different ctx actor
store = open_affect_store(":memory:")
await store.emit(_snapshot(valence=0.1), idempotency_key="k1", ctx=_ctx("sub-A"))
# different actor, same key, different payload -> NOT a conflict (distinct id)
assert await store.emit(
_snapshot(valence=0.2), idempotency_key="k1", ctx=_ctx("sub-B")
) == {"stored": True}
async def test_missing_end_user_id_raises_and_no_write():
store = open_affect_store(":memory:")
bad = _snapshot()
del bad["end_user_id"]
with pytest.raises(AffectInvalidArguments):
await store.emit(bad, idempotency_key="k1", ctx=_ctx())
assert _row_count(store, "affect_snapshots") == 0
async def test_missing_agent_id_raises_and_no_write():
# PRE-001 guards BOTH addressing keys symmetrically.
store = open_affect_store(":memory:")
bad = _snapshot()
del bad["agent_id"]
with pytest.raises(AffectInvalidArguments):
await store.emit(bad, idempotency_key="k1", ctx=_ctx())
assert _row_count(store, "affect_snapshots") == 0
async def test_empty_idempotency_key_raises():
store = open_affect_store(":memory:")
with pytest.raises(AffectInvalidArguments):
await store.emit(_snapshot(), idempotency_key="", ctx=_ctx())
# --- get ---
def test_get_absent_returns_none():
store = open_affect_store(":memory:")
assert store.get("nope", "nope") is None
async def test_get_after_emit_returns_equal():
store = open_affect_store(":memory:")
snap = _snapshot()
await store.emit(snap, idempotency_key="k1", ctx=_ctx())
assert store.get("a1", "u1") == snap
# --- fetch (affect.fetch wire verb — bifrost >=0.10.0, INV-010 strong-or-absent) ---
async def test_fetch_absent_returns_found_false():
"""fetch_absent: no row for the key → {"found": False} (mirrors reference)."""
store = open_affect_store(":memory:")
assert await store.fetch("nope", "nope") == {"found": False}
async def test_fetch_after_emit_returns_snapshot():
"""fetch_after_emit [tracer]: emit then fetch → {"found": True, "snapshot": <verbatim>}."""
store = open_affect_store(":memory:")
snap = _snapshot()
await store.emit(snap, idempotency_key="k1", ctx=_ctx())
assert await store.fetch("a1", "u1") == {"found": True, "snapshot": snap}
async def test_fetch_missing_key_raises():
"""fetch_missing_key: empty/missing addressing key → AffectInvalidArguments
(PRE-001; symmetric across both keys, belt-and-suspenders behind the wire)."""
store = open_affect_store(":memory:")
with pytest.raises(AffectInvalidArguments):
await store.fetch("", "u1")
with pytest.raises(AffectInvalidArguments):
await store.fetch("a1", "")
# --- build_affect_provider_app ---
def test_build_app_exposes_handshake_and_affect_routes():
store = open_affect_store(":memory:")
app = build_affect_provider_app(store, heimdall_key=b"secret-key")
routes = {getattr(r, "path", None): r for r in app.routes}
assert "/bifrost/handshake" in routes
assert "/bifrost/affect-call" in routes
assert "POST" in routes["/bifrost/affect-call"].methods # POST-001: the verb, not just the path
# POST-002: bifrost routes remain REACHABLE (not merely registered) after the read
# route is composed in via add_route — drive one without a JWT → routed (auth-
# rejected), never 404.
r = TestClient(app).post("/bifrost/affect-call", json={"operation": "affect.emit"})
assert r.status_code != 404
def test_build_app_rejects_non_advertising_store():
store = open_affect_store(":memory:")
store.affect_supported = False
with pytest.raises(ValueError):
build_affect_provider_app(store, heimdall_key=b"k")
def test_build_app_rejects_empty_key():
store = open_affect_store(":memory:")
with pytest.raises(ValueError):
build_affect_provider_app(store, heimdall_key=b"")
# --- #195 conformance: parity vs the reference store through the real engine ---
def _dispatch_ctx(*scopes: str, session_id: str = "actor-1"):
return types.SimpleNamespace(
scope=list(scopes), session_id=session_id, jwt_sub=session_id
)
def _env(snap: dict, key: str = "sess-1:1:affect") -> dict:
return {
"operation": "affect.emit",
"args": snap,
"idempotency_key": key,
"idempotency_class": "short-retry",
}
def _ref_shaped_snapshot(*, pleasure: float = 0.5, emitted_at: str = "2026-06-14T12:00:00Z"):
# Mirror the reference test's snapshot shape so the envelope validates.
return {
"agent_id": "agent-1",
"end_user_id": "user-1",
"pad": {"pleasure": pleasure, "arousal": 0.2, "dominance": -0.1},
"valence": [{"entity_id": "e1", "regard": 0.7, "familiarity": 0.3}],
"emitted_at": emitted_at,
}
async def test_parity_vs_reference_store_through_dispatch():
from bifrost.affect import dispatch_affect_call
from bifrost.consumer.testing import InMemoryAffectStore
ref = InMemoryAffectStore()
mine = open_affect_store(":memory:")
ctx = _dispatch_ctx("affect:write")
snap = _ref_shaped_snapshot()
# happy persist: wire bodies must agree
assert await dispatch_affect_call(_env(snap), ctx, ref) == await dispatch_affect_call(
_env(snap), ctx, mine
)
# replay (same key + same payload): both no-op {stored: true}
assert await dispatch_affect_call(_env(snap), ctx, ref) == await dispatch_affect_call(
_env(snap), ctx, mine
)
# conflict (same key + different payload): both map to the same error envelope
other = _ref_shaped_snapshot(pleasure=0.99)
assert await dispatch_affect_call(_env(other), ctx, ref) == await dispatch_affect_call(
_env(other), ctx, mine
)
def _fetch_env(agent_id: str = "agent-1", end_user_id: str = "user-1") -> dict:
return {"operation": "affect.fetch", "args": {"agent_id": agent_id, "end_user_id": end_user_id}}
async def test_parity_vs_reference_fetch_through_dispatch():
"""#195 parity for affect.fetch: cold (not-found) + warm (found) read envelopes
yield identical (status, body) through the real engine against the reference store
and ours. Conforms to bifrost's InMemoryAffectStore.fetch ({found, snapshot})."""
from bifrost.affect import dispatch_affect_call
from bifrost.consumer.testing import InMemoryAffectStore
ref = InMemoryAffectStore()
mine = open_affect_store(":memory:")
write_ctx = _dispatch_ctx("affect:write")
read_ctx = _dispatch_ctx("affect:read")
# cold fetch (nothing persisted): both -> {found: false}
assert await dispatch_affect_call(_fetch_env(), read_ctx, ref) == await dispatch_affect_call(
_fetch_env(), read_ctx, mine
)
# seed both via emit, then fetch -> both {found: true, snapshot: <verbatim>}
snap = _ref_shaped_snapshot()
await dispatch_affect_call(_env(snap), write_ctx, ref)
await dispatch_affect_call(_env(snap), write_ctx, mine)
assert await dispatch_affect_call(_fetch_env(), read_ctx, ref) == await dispatch_affect_call(
_fetch_env(), read_ctx, mine
)
# --- PAD read route (issue #18 Deliverable 2) ---
# Non-bifrost GET /affect/state/{agent_id}?end_user_id=… → store.get snapshot.
import json as _json
from starlette.testclient import TestClient
def _affect_snapshot(agent: str = "ratatoskr:sindra", user: str = "vuong") -> dict:
# The real affect.emit shape (verified live): pad + per-entity valence + emitted_at.
return {
"agent_id": agent,
"end_user_id": user,
"pad": {"pleasure": 0.1459, "arousal": 0.0796, "dominance": -0.0071},
"valence": [
{
"entity_id": "ratatoskr",
"entity_type": "human",
"familiarity": 0.5886,
"interaction_count": 8,
"regard": 0.15,
}
],
"emitted_at": "2026-06-18T15:58:12+00:00",
}
def _seed(store, snap: dict) -> None:
blob = _json.dumps(snap, sort_keys=True, separators=(",", ":"))
store._conn.execute(
"INSERT INTO affect_snapshots (agent_id, end_user_id, snapshot_json, arrived_at) "
"VALUES (?, ?, ?, ?)",
(snap["agent_id"], snap["end_user_id"], blob, "0"),
)
store._conn.commit()
def test_affect_state_route_returns_seeded_snapshot():
"""tracer: seeded (agent, user) → 200 with the snapshot verbatim. Colon-id in the
path exercises INV-008 at the provider hop."""
store = open_affect_store(":memory:")
snap = _affect_snapshot()
_seed(store, snap)
client = TestClient(build_affect_provider_app(store, heimdall_key=b"k"))
r = client.get("/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"})
assert r.status_code == 200
assert r.json() == snap
def test_affect_state_route_absent_returns_404_no_snapshot():
"""INV-003: no emit yet for (agent, user) → explicit 404 no_affect_snapshot,
NEVER a zeroed pad that reads as real data."""
store = open_affect_store(":memory:")
client = TestClient(build_affect_provider_app(store, heimdall_key=b"k"))
r = client.get("/affect/state/ratatoskr:ghost", params={"end_user_id": "nobody"})
assert r.status_code == 404
body = r.json()
assert body["error_code"] == "no_affect_snapshot"
assert "pad" not in body # no fabricated PAD
def test_affect_state_route_missing_end_user_id_returns_400():
"""PRE-001: absent end_user_id query → 400 missing_end_user_id (not a silent
no-snapshot lookup against a None partition)."""
store = open_affect_store(":memory:")
_seed(store, _affect_snapshot())
client = TestClient(build_affect_provider_app(store, heimdall_key=b"k"))
r = client.get("/affect/state/ratatoskr:sindra") # no end_user_id
assert r.status_code == 400
assert r.json()["error_code"] == "missing_end_user_id"