test: witness filter coverage for multi-entity scenarios

This commit is contained in:
Joseph Doherty
2026-04-26 16:25:03 -04:00
parent 60ac33a787
commit d40313063c
+269
View File
@@ -0,0 +1,269 @@
"""Task 46 — Witness filter coverage for multi-entity scenarios.
The witness filter is enforced at the SQL layer in
``chat.state.memory.search_memories``. Each memory row carries three witness
flags ``(witness_you, witness_host, witness_guest)``. A retrieval is scoped
to a *bot's own memory store* via ``owner_id`` and a *POV role*
(``"you"``/``"host"``/``"guest"``); the SQL filter is
``WHERE owner_id = ? AND witness_<role> = 1``.
This module exercises the cross-witness scenarios called out in §"Witnessed-By
Tracking" (rp-engine-design.md L108-L116) — multi-witness masks, secondhand
provenance, and the per-owner separation that prevents bleed between bots'
private memory stores.
These are tests-only. ``search_memories`` already accepts ``witness_role``,
so the cases land green without any production-code change. The host-only
hardcode in ``chat/services/prompt.py`` is a separate concern (the v1 prompt
builder always queries from the host POV); these tests pin the underlying
retrieval contract so a future viewer-aware caller has something to lean on.
"""
from __future__ import annotations
from pathlib import Path
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.state.memory import search_memories
import chat.state.memory # noqa: F401 (registers memory_written handler)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_memories(db: Path, specs: list[dict]) -> None:
"""Apply migrations and project a list of ``memory_written`` events.
Each spec dict supplies the witness mask + provenance fields explicitly so
the test can name the exact mask under test (``[you, host, guest]``).
"""
apply_migrations(db)
with open_db(db) as conn:
for spec in specs:
payload = {
"owner_id": spec["owner_id"],
"chat_id": spec.get("chat_id", "chat_ab"),
"pov_summary": spec["pov_summary"],
"witness_you": spec["witness_you"],
"witness_host": spec["witness_host"],
"witness_guest": spec["witness_guest"],
"source": spec.get("source", "direct"),
"reliability": spec.get("reliability", 1.0),
"significance": spec.get("significance", 1),
"pinned": 0,
"auto_pinned": 0,
}
append_event(conn, kind="memory_written", payload=payload)
project(conn)
# ---------------------------------------------------------------------------
# Scenario 1 — mask [1, 1, 0]: visible to host, NOT to guest.
# ---------------------------------------------------------------------------
def test_witness_1_1_0_visible_to_host_not_guest(tmp_path):
"""A private host moment ([you=1, host=1, guest=0]) must surface for the
host's own POV query and stay hidden when the guest queries the same
memory store."""
db = tmp_path / "t.db"
_seed_memories(
db,
[
{
"owner_id": "bot_a",
"pov_summary": "BotA quietly noticed the broken vase",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
},
],
)
with open_db(db) as conn:
host_hits = search_memories(conn, "bot_a", "host", "vase", k=4)
assert len(host_hits) == 1
assert host_hits[0]["pov_summary"] == "BotA quietly noticed the broken vase"
# Same store, guest POV: filtered out (witness_guest = 0).
guest_hits = search_memories(conn, "bot_a", "guest", "vase", k=4)
assert guest_hits == []
# ---------------------------------------------------------------------------
# Scenario 2 — mask [0, 1, 1]: visible to BOTH host and guest queries.
# ---------------------------------------------------------------------------
def test_witness_0_1_1_visible_to_both_host_and_guest(tmp_path):
"""A bot-only side scene ([you=0, host=1, guest=1]) must surface from
*both* POV queries against bot stores that recorded it."""
db = tmp_path / "t.db"
_seed_memories(
db,
[
# bot_a recorded the moment from its own (host) POV.
{
"owner_id": "bot_a",
"pov_summary": "the bots whispered about the secret meeting",
"witness_you": 0,
"witness_host": 1,
"witness_guest": 1,
},
# bot_b recorded the same moment from its own (guest) POV.
{
"owner_id": "bot_b",
"pov_summary": "the bots whispered about the secret meeting",
"witness_you": 0,
"witness_host": 1,
"witness_guest": 1,
},
],
)
with open_db(db) as conn:
host_hits = search_memories(conn, "bot_a", "host", "secret", k=4)
assert len(host_hits) == 1
assert host_hits[0]["owner_id"] == "bot_a"
guest_hits = search_memories(conn, "bot_b", "guest", "secret", k=4)
assert len(guest_hits) == 1
assert guest_hits[0]["owner_id"] == "bot_b"
# Cross-check the "you" POV doesn't pick it up — witness_you = 0.
you_hits_a = search_memories(conn, "bot_a", "you", "secret", k=4)
you_hits_b = search_memories(conn, "bot_b", "you", "secret", k=4)
assert you_hits_a == []
assert you_hits_b == []
# ---------------------------------------------------------------------------
# Scenario 3 — mask [1, 0, 0]: degenerate "you-only" memory; filtered out for
# both bot queries because neither host nor guest witness flag is set.
# ---------------------------------------------------------------------------
def test_witness_1_0_0_filtered_out_for_bot_queries(tmp_path):
"""`you` doesn't have a memory store in v1, so a row with only
``witness_you = 1`` is degenerate. From either bot POV the filter must
drop it (it would only ever surface via a ``"you"`` role query, which
isn't a path the v1 prompt builder uses)."""
db = tmp_path / "t.db"
_seed_memories(
db,
[
{
"owner_id": "bot_a",
"pov_summary": "you alone caught the slip of the tongue",
"witness_you": 1,
"witness_host": 0,
"witness_guest": 0,
},
],
)
with open_db(db) as conn:
host_hits = search_memories(conn, "bot_a", "host", "tongue", k=4)
guest_hits = search_memories(conn, "bot_a", "guest", "tongue", k=4)
assert host_hits == []
assert guest_hits == []
# And a ``you`` POV query still finds it — the row exists, just isn't
# reachable from either of the v1 bot retrieval paths.
you_hits = search_memories(conn, "bot_a", "you", "tongue", k=4)
assert len(you_hits) == 1
# ---------------------------------------------------------------------------
# Scenario 4 — secondhand source carries reduced reliability and is still
# witness-filtered. Per design.md L114: "BotA tells BotB about it secondhand:
# creates a new memory in BotB's store flagged [0,0,1] with source: botA".
# We park the mask at [0, 0, 1] (you=0, host=0, guest=1) so that bot_b's
# guest-POV query reaches it, and assert reliability < 1.0 surfaces.
# ---------------------------------------------------------------------------
def test_secondhand_memory_visible_with_reduced_reliability(tmp_path):
"""A secondhand memory ([0, 0, 1] in bot_b's store, ``source = "told_by:bot_a"``)
must surface for bot_b's guest-POV query and carry ``reliability < 1.0``
so downstream callers can tag it as hearsay."""
db = tmp_path / "t.db"
_seed_memories(
db,
[
{
"owner_id": "bot_b",
"pov_summary": "BotA mentioned a fight at the dockyard",
"witness_you": 0,
"witness_host": 0,
"witness_guest": 1,
"source": "told_by:bot_a",
"reliability": 0.6,
},
],
)
with open_db(db) as conn:
hits = search_memories(conn, "bot_b", "guest", "dockyard", k=4)
assert len(hits) == 1
m = hits[0]
assert m["source"] == "told_by:bot_a"
assert m["reliability"] < 1.0
assert m["reliability"] == 0.6
# And it's *not* visible from bot_b's host-POV query — bot_b is the
# guest in this chat, not the host. The mask enforces that.
host_hits = search_memories(conn, "bot_b", "host", "dockyard", k=4)
assert host_hits == []
# ---------------------------------------------------------------------------
# Scenario 5 — owner separation. Two bots both have [1, 1, 1] memories about
# the same event, but the queries are scoped per owner store and must not
# bleed across owners.
# ---------------------------------------------------------------------------
def test_owner_separation_no_cross_owner_bleed(tmp_path):
"""Each bot only sees memories it OWNS, regardless of witness flags. A
fully-witnessed memory in ``bot_a``'s store must not leak into a query
against ``bot_b``'s store and vice versa."""
db = tmp_path / "t.db"
_seed_memories(
db,
[
{
"owner_id": "bot_a",
"pov_summary": "the lighthouse beam swept across all three of them",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 2,
},
{
"owner_id": "bot_b",
"pov_summary": "the lighthouse beam swept across all three of them",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 2,
},
],
)
with open_db(db) as conn:
# bot_a's host-POV query: only bot_a's row.
a_hits = search_memories(conn, "bot_a", "host", "lighthouse", k=4)
assert len(a_hits) == 1
assert a_hits[0]["owner_id"] == "bot_a"
# bot_b's guest-POV query: only bot_b's row.
b_hits = search_memories(conn, "bot_b", "guest", "lighthouse", k=4)
assert len(b_hits) == 1
assert b_hits[0]["owner_id"] == "bot_b"
# Even though bot_a's memory is fully witnessed, switching to bot_b's
# store with bot_a's POV role still confines us to bot_b's rows.
cross_hits = search_memories(conn, "bot_b", "host", "lighthouse", k=4)
assert len(cross_hits) == 1
assert cross_hits[0]["owner_id"] == "bot_b"