test: witness filter coverage for multi-entity scenarios
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user