6 Commits

Author SHA1 Message Date
Joseph Doherty bb87fcbd4a merge: T48 Phase 2 documentation update 2026-04-26 16:28:46 -04:00
Joseph Doherty f6b75b25eb merge: T47 bot_reset cascades to guest references 2026-04-26 16:28:46 -04:00
Joseph Doherty 9f35669936 merge: T46 witness filter coverage for multi-entity scenarios 2026-04-26 16:28:46 -04:00
Joseph Doherty 321810fa54 docs: phase 2 status, behavioral defaults, deferred items 2026-04-26 16:28:14 -04:00
Joseph Doherty fb17ba0657 fix: bot_reset cascades to guest references in other chats 2026-04-26 16:25:37 -04:00
Joseph Doherty d40313063c test: witness filter coverage for multi-entity scenarios 2026-04-26 16:25:03 -04:00
5 changed files with 471 additions and 0 deletions
+29
View File
@@ -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** (T36T48 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`.
+7
View File
@@ -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.
+164
View File
@@ -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
+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"