From d40313063ccfa3e8d463c52e9f6c906ee9faf26c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:25:03 -0400 Subject: [PATCH] test: witness filter coverage for multi-entity scenarios --- tests/test_witness_filter_multi.py | 269 +++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/test_witness_filter_multi.py diff --git a/tests/test_witness_filter_multi.py b/tests/test_witness_filter_multi.py new file mode 100644 index 0000000..e8e2f1a --- /dev/null +++ b/tests/test_witness_filter_multi.py @@ -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_ = 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"