Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb87fcbd4a | |||
| f6b75b25eb | |||
| 9f35669936 | |||
| 321810fa54 | |||
| fb17ba0657 | |||
| d40313063c |
@@ -50,6 +50,10 @@ The 3-entity cap is load-bearing: it makes the relationship graph fully enumerab
|
||||
- **Snapshots**: periodic every 100 events / 30 min; pre-rewind always. 5 periodic retained; pre-rewind retained 14 days.
|
||||
- **Streaming**: Stop button on streaming row; mid-stream disconnect commits partial with `truncated: true`; Send disabled mid-stream; multi-tab streaming via per-chat SSE channel.
|
||||
- **Display**: lightweight markdown; `*action*` italic; OOC `((parens))` shown dimmed/italic, never sent to bot.
|
||||
- **Multi-entity defaults (Phase 2)**: when `chat.guest_bot_id is None`, behavior matches Phase 1 single-bot 1:1. With a guest, all 3 entities are present in the prompt, witness writes, and state-update fan-out (6 directed pairs).
|
||||
- **Addressee detection**: simple substring match (whole-word, case-insensitive) over the user turn's body. If both bot names match or neither does, the host gets the floor.
|
||||
- **Interjection**: classifier-driven, conservative bias (default false on classifier failure / refusal / parse error). When the classifier returns true, the addressee speaks first, then the non-addressee may interject in a follow-up turn.
|
||||
- **Per-POV summaries (multi-entity)**: each present witness with a memory store gets their own per-POV summary on scene close. The summary differs per bot based on persona + their edge to "you". The group node summary is updated alongside.
|
||||
|
||||
## Core concepts (vocabulary)
|
||||
|
||||
@@ -177,3 +181,28 @@ Small follow-ups identified during Phase 1 reviews. Pick up at any time; none ar
|
||||
- **`bot_reset` purges orphaned "you" activity rows** (see limitation above). Either delete `activity` rows by chat-membership or accept the noise indefinitely; the projection-layer fix is one extra `DELETE FROM activity WHERE entity_id='you' AND container_id IN (SELECT id FROM containers WHERE chat_id IN (...))` clause inside `_apply_bot_reset`.
|
||||
- **Drawer edits for the deferred v1 fields**: edge_trust slider, edge_summary textarea, memory pov_summary textarea, knowledge_facts add/remove. The `manual_edit` projector already supports `edge_trust` / `edge_summary` / `memory_pov_summary` target_kinds — only the routes are missing. Knowledge_facts needs a new dispatch branch.
|
||||
- **NICE trim order in prompt assembly** drops previous-scene first instead of last (T18 review). Greedy-cuts heuristic vs spec listing order; revisit if v1 play surfaces a real regression.
|
||||
|
||||
## Phase 2 status
|
||||
|
||||
Phase 2 shipped end-to-end across **13 tasks** (T36–T48 wave). The multi-entity surface is functional: chats can host a guest bot, the prompt assembly is guest-aware, post-turn fans out across all directed pairs, and scene close writes a per-POV summary per present witness plus a group_node summary.
|
||||
|
||||
- **Multi-entity scene support**: chats can now have a guest bot (you + host + guest). The 3-entity cap holds. New event kinds: `guest_added`, `guest_removed`, `group_node_initialized`, `group_node_updated`. New table: `group_node` (members, summary, dynamic, threads).
|
||||
- **Drawer guest UX**: add/remove guest from the drawer side panel. The "have they met?" prose seed is parsed by the `relationship_seed` classifier into inter-bot directed edges (host↔guest).
|
||||
- **Multi-entity turn flow**: `post_turn` assembles narrative with the guest-aware prompt; writes memories for **all** present bot witnesses; runs state updates for **all** directed pairs (6 with 3 entities); detects interjections via classifier (default false; the addressee gets the floor first).
|
||||
- **Per-POV scene close summaries**: each present witness with a memory store gets their own per-POV summary on close; `group_node` summary updated alongside.
|
||||
- **Bot reset cascade**: resetting a bot now also clears `chats.guest_bot_id` references in other chats (root-cause fix for stale-guest references after T47).
|
||||
|
||||
### Phase 2.5 / 3 backlog
|
||||
|
||||
Carry-overs from Phase 2 reviews and implementer notes. None are blocking; pick up at any time.
|
||||
|
||||
- **Interjection regenerate**: regenerate currently only acts on the addressee turn. Phase 2.5 should extend regenerate to cover the interjection turn too.
|
||||
- **Classifier-based addressee detection**: substring match is brittle (e.g., names that are common English words, or names appearing inside a quoted aside). A small classifier call could disambiguate.
|
||||
- **LLM-merged group meta-summary**: current `group_node.summary` is a naive concat of host + guest per-POV summaries. Phase 2.5 should polish with an LLM-merged group view.
|
||||
- **First-meeting gate**: the drawer's "have they met?" textarea fires every time. Phase 2.5 should check whether the host→guest edge already exists and offer a "they already know each other" toggle to skip re-seeding.
|
||||
- **Witness flag editing**: drawer doesn't allow editing memory witness flags (read-only). Phase 2.5+ may expose this.
|
||||
- **Significance for interjection memories**: the interjection's `memory_written` event doesn't enqueue a `SignificanceJob` (per the T44 implementer note). Phase 2.5 should wire this in so interjection memories are scored alongside primary turns.
|
||||
- **Stale guest reference defensive degrade in `post_turn`**: T44 added a degrade-to-1:1 when `chat.guest_bot_id` points at a deleted bot. T47 fixes the root cause (resets clear the reference); the degrade can probably be removed but is harmless.
|
||||
- **Scene close on cancel**: scene close runs even when the primary turn is cancelled. Behavior may be intentional but could be argued either way; revisit if it surfaces a real UX regression.
|
||||
- **Dual `ACTIVITIES:` block**: T43's prompt assembly adds a second `ACTIVITIES:` block for guest activity. Cleaner would be a single block with three bullets and per-bullet trim.
|
||||
- **Witness role hardcoded in prompt assembly**: `chat/services/prompt.py:436` hardcodes `witness_role="host"` regardless of which bot is speaking. Phase 2.5 should derive the role from chat membership (e.g. `"host" if speaker_bot_id == chat.host_bot_id else "guest"`) so guest-as-speaker prompts retrieve the right memory slice. Test contract pinned in `tests/test_witness_filter_multi.py`.
|
||||
|
||||
@@ -66,6 +66,13 @@ def _apply_bot_reset(conn: Connection, e: Event) -> None:
|
||||
"DELETE FROM edges WHERE source_id = ? OR target_id = ?",
|
||||
(bot_id, bot_id),
|
||||
)
|
||||
|
||||
# Phase 2 cascade: clear guest references in other bots' chats so the host
|
||||
# doesn't see a stale guest_bot_id pointing at this (now-purged) bot.
|
||||
conn.execute(
|
||||
"UPDATE chats SET guest_bot_id = NULL WHERE guest_bot_id = ?",
|
||||
(bot_id,),
|
||||
)
|
||||
# NOTE: bots row itself is preserved (identity, kickoff_prose intact).
|
||||
# NOTE: "you" activity (entity_id="you") may linger from a deleted chat;
|
||||
# acceptable for v1 — Phase 1.5 cleanup if needed.
|
||||
|
||||
@@ -499,6 +499,8 @@ Written per witness when a scene closes. Different details, different interpreta
|
||||
|
||||
### Phase 2 — multi-entity
|
||||
|
||||
**Status: shipped 2026-04-26** — multi-entity scene support, guest add/remove drawer UX, guest-aware prompt assembly, multi-entity turn flow with interjection classifier, per-POV scene close summaries for every present witness, group_node initialization/update, and bot reset cascade clearing stale `chats.guest_bot_id` references all landed across the wave5 task series (see `CLAUDE.md` § "Phase 2 status" for the deliverable summary and follow-ups).
|
||||
|
||||
- Guest bot in chat (3-entity scene config).
|
||||
- Interjection classifier call.
|
||||
- Witness filtering across multiple owners.
|
||||
|
||||
@@ -183,3 +183,167 @@ def test_bot_list_renders_reset_form(client, tmp_path):
|
||||
assert response.status_code == 200
|
||||
assert "Reset" in response.text
|
||||
assert "confirm_name" in response.text
|
||||
|
||||
|
||||
def _seed_two_bots_with_guest_link(
|
||||
db: Path, *, extra_events: list[dict] | None = None
|
||||
) -> None:
|
||||
"""Seed bot_a + bot_b, each hosting their own chat, with bot_b a guest in chat_bot_a.
|
||||
|
||||
``extra_events`` is appended after the guest_added event and projected
|
||||
together with the rest of the seed (so handlers run only once per event).
|
||||
"""
|
||||
with open_db(db) as conn:
|
||||
# bot_a + its chat
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": "bot_a",
|
||||
"name": "BotA",
|
||||
"persona": "thoughtful",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"kickoff_prose": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1",
|
||||
"weather": "",
|
||||
},
|
||||
)
|
||||
# bot_b + its own chat
|
||||
append_event(
|
||||
conn,
|
||||
kind="bot_authored",
|
||||
payload={
|
||||
"id": "bot_b",
|
||||
"name": "BotB",
|
||||
"persona": "curious",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "friend",
|
||||
"kickoff_prose": "",
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="chat_created",
|
||||
payload={
|
||||
"id": "chat_bot_b",
|
||||
"host_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1",
|
||||
"weather": "",
|
||||
},
|
||||
)
|
||||
# bot_b joins chat_bot_a as a guest.
|
||||
append_event(
|
||||
conn,
|
||||
kind="guest_added",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
},
|
||||
)
|
||||
for ev in extra_events or []:
|
||||
append_event(conn, kind=ev["kind"], payload=ev["payload"])
|
||||
project(conn)
|
||||
|
||||
|
||||
def test_reset_clears_guest_reference_in_other_chats(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_seed_two_bots_with_guest_link(db)
|
||||
|
||||
# Sanity-check the seed: bot_b is the guest in bot_a's chat.
|
||||
from chat.state.world import get_chat
|
||||
with open_db(db) as conn:
|
||||
assert get_chat(conn, "chat_bot_a")["guest_bot_id"] == "bot_b"
|
||||
assert get_chat(conn, "chat_bot_b") is not None
|
||||
|
||||
response = client.post(
|
||||
"/bots/bot_b/reset",
|
||||
data={"confirm_name": "BotB"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with open_db(db) as conn:
|
||||
# The guest reference in bot_a's chat is cleared.
|
||||
chat_a = get_chat(conn, "chat_bot_a")
|
||||
assert chat_a is not None
|
||||
assert chat_a["guest_bot_id"] is None
|
||||
|
||||
# bot_b's own chat is gone (Phase 1 host purge behavior).
|
||||
assert get_chat(conn, "chat_bot_b") is None
|
||||
|
||||
# bot_a is untouched.
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM bots WHERE id = 'bot_a'"
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_reset_purges_guest_memories_from_other_chats(client, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
_seed_two_bots_with_guest_link(
|
||||
db,
|
||||
extra_events=[
|
||||
# bot_b is a guest in chat_bot_a and remembers things from there.
|
||||
{
|
||||
"kind": "memory_written",
|
||||
"payload": {
|
||||
"owner_id": "bot_b",
|
||||
"chat_id": "chat_bot_a",
|
||||
"pov_summary": "Met BotA; she was tense.",
|
||||
"witness_you": 1,
|
||||
"witness_host": 1,
|
||||
"witness_guest": 1,
|
||||
"significance": 3,
|
||||
},
|
||||
},
|
||||
# And a memory from bot_b's own chat for good measure.
|
||||
{
|
||||
"kind": "memory_written",
|
||||
"payload": {
|
||||
"owner_id": "bot_b",
|
||||
"chat_id": "chat_bot_b",
|
||||
"pov_summary": "A quiet evening at home.",
|
||||
"witness_you": 1,
|
||||
"witness_host": 1,
|
||||
"witness_guest": 0,
|
||||
"significance": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
with open_db(db) as conn:
|
||||
# Sanity: bot_b owns 2 memories pre-reset, one in each chat.
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_b'"
|
||||
).fetchone()[0] == 2
|
||||
|
||||
response = client.post(
|
||||
"/bots/bot_b/reset",
|
||||
data={"confirm_name": "BotB"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with open_db(db) as conn:
|
||||
# ALL of bot_b's memories are gone, including the cross-chat one in chat_bot_a.
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_b'"
|
||||
).fetchone()[0] == 0
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_b' AND chat_id = 'chat_bot_a'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
@@ -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