loosecanvas / tests /test_context_packer.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
10.2 kB
"""M13 β€” Context Packer + Deictic Resolver. Red/green tests from the M13 success criteria.
Success criteria under test:
- The produced packet is ≀1500 tokens via the ``len(json.dumps(packet)) / 4.5`` char proxy.
- The packet includes the selection plus visible nodes plus fog candidates with summaries,
and every fog candidate is drawn from ``scene.fogged_node_ids``.
- Deictic terms ("this", "it") resolve to the selection id, are recorded in
``resolved_deixis``, and the prepended ``(Selected: ...)`` anchor appears.
- An oversized graph triggers the truncation cascade (and the extreme case raises
``ContextBudgetExceededError``).
- Serialized edges use the property-graph alias ``type`` for ``Edge.kind``.
"""
from __future__ import annotations
import json
import pytest
from loosecanvas.context_packer import (
ContextBudgetExceededError,
ContextPacker,
ContextPacket,
)
from loosecanvas.contracts import Edge, MapEvent, Node, SceneState
from loosecanvas.graph_repository import LooseGraphRepository
# ── Fixtures ──────────────────────────────────────────────────────────────────
def _node(node_id: str, *, label: str | None = None, summary: str = "") -> Node:
return Node(id=node_id, kind="concept", label=label or node_id, summary=summary)
def _edge(edge_id: str, source: str, target: str) -> Edge:
return Edge(id=edge_id, source=source, target=target, kind="relates_to")
def _proxy_tokens(packet: ContextPacket) -> float:
return len(json.dumps(packet.model_dump())) / 4.5
def _small_repo() -> LooseGraphRepository:
repo = LooseGraphRepository()
repo.upsert_nodes(
[
_node("a", label="Alpha", summary="the alpha node summary"),
_node("b", label="Beta", summary="the beta node summary"),
_node("c", label="Gamma", summary="the gamma node summary"),
_node("d", label="Delta", summary="a fogged delta candidate summary"),
_node("e", label="Epsilon", summary="a fogged epsilon candidate summary"),
]
)
repo.upsert_edges([_edge("e_ab", "a", "b"), _edge("e_ad", "a", "d")])
return repo
def _scene() -> SceneState:
return SceneState(
scene_id="s1",
visible_node_ids=["a", "b", "c"],
visible_edge_ids=["e_ab"],
fogged_node_ids=["d", "e"],
)
# ── Budget & content ──────────────────────────────────────────────────────────
def test_simple_packet_within_budget_and_has_core_fields() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(), selection_id="a", message="tell me about Alpha", recent_events=[]
)
assert _proxy_tokens(packet) <= 1500
assert packet.selection["id"] == "a"
assert packet.selection["label"] == "Alpha"
assert {n["id"] for n in packet.visible_nodes} == {"a", "b", "c"}
assert packet.user_message # never empty when a message is supplied
def test_fog_candidates_only_from_fog_set_and_carry_summaries() -> None:
scene = _scene()
packer = ContextPacker(_small_repo())
packet = packer.pack(scene, selection_id="a", message="overview", recent_events=[])
fog_ids = {c["id"] for c in packet.fog_candidates}
assert fog_ids # fog candidates are produced
assert fog_ids <= set(scene.fogged_node_ids)
for cand in packet.fog_candidates:
assert "summary" in cand
assert len(cand["summary"]) <= 80
def test_fog_candidates_sorted_by_proximity_to_selection() -> None:
# ``d`` is a 1-hop neighbour of selection ``a`` (edge e_ad); ``e`` is not.
scene = _scene()
packer = ContextPacker(_small_repo())
packet = packer.pack(scene, selection_id="a", message="overview", recent_events=[])
assert packet.fog_candidates[0]["id"] == "d"
def test_visible_edges_use_type_alias_for_edge_kind() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(), selection_id="a", message="overview", recent_events=[]
)
assert packet.visible_edges
edge = packet.visible_edges[0]
assert edge["type"] == "relates_to"
assert "kind" not in edge
# ── Deictic resolution ────────────────────────────────────────────────────────
def test_deixis_this_it_resolve_to_selection_and_prepend_anchor() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(), selection_id="a", message="what is this?", recent_events=[]
)
assert packet.resolved_deixis == {"this": "a", "it": "a"}
assert packet.user_message.startswith("(Selected: Alpha, id=a.)")
assert "what is this?" in packet.user_message
def test_deixis_implicit_anchor_on_expansion_without_selection() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(), selection_id=None, message="show me more", recent_events=[]
)
assert "implicit_anchor" in packet.resolved_deixis
assert packet.resolved_deixis["implicit_anchor"] in {"a", "b", "c"}
def test_deixis_recent_highlight_reference() -> None:
packer = ContextPacker(_small_repo())
events = [
MapEvent(
actor="curator",
action="highlight",
target_id="c",
detail="highlighted gamma",
)
]
packet = packer.pack(
_scene(),
selection_id=None,
message="explain the thing you showed",
recent_events=events,
)
assert packet.resolved_deixis == {"that": "c"}
assert packet.user_message.startswith("(Selected: Gamma, id=c.)")
def test_message_never_truncated_and_no_deixis_when_irrelevant() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(), selection_id="a", message="overview please", recent_events=[]
)
assert packet.resolved_deixis == {}
assert packet.user_message == "overview please"
# ── Truncation cascade ────────────────────────────────────────────────────────
def _oversized_repo() -> LooseGraphRepository:
repo = LooseGraphRepository()
long_label = "L" * 400
long_summary = "S" * 250
nodes = [
_node("v" + str(i) * 50, label=long_label, summary=long_summary)
for i in range(10)
]
fog = [
_node("f" + str(i) * 50, label=long_label, summary=long_summary)
for i in range(5)
]
repo.upsert_nodes(nodes + fog)
# a couple of fat, non-truncatable visible edges
repo.upsert_edges([_edge("g" * 150, nodes[0].id, nodes[1].id)])
return repo
def _oversized_scene(repo: LooseGraphRepository) -> SceneState:
node_ids = [n.id for n in repo.nodes.values() if n.id.startswith("v")]
fog_ids = [n.id for n in repo.nodes.values() if n.id.startswith("f")]
edge_ids = list(repo.edges.keys())
return SceneState(
scene_id="big",
visible_node_ids=node_ids,
visible_edge_ids=edge_ids,
fogged_node_ids=fog_ids,
)
def test_oversized_graph_triggers_truncation_but_stays_within_budget() -> None:
repo = _oversized_repo()
scene = _oversized_scene(repo)
selection_id = scene.visible_node_ids[0]
packer = ContextPacker(repo)
packet = packer.pack(
scene, selection_id=selection_id, message="overview", recent_events=[]
)
assert _proxy_tokens(packet) <= 1500
# truncation actually fired: fewer than the full 10 visible nodes survive.
assert len(packet.visible_nodes) < 10
def test_extreme_message_raises_budget_exceeded() -> None:
repo = _small_repo()
packer = ContextPacker(repo)
huge_message = "x" * 10000 # never truncated β†’ no cascade step can recover
with pytest.raises(ContextBudgetExceededError):
packer.pack(_scene(), selection_id="a", message=huge_message, recent_events=[])
# ── Conversation memory (recent_dialogue / recent_actions) ─────────────────────
def test_recent_dialogue_populates_packet_field() -> None:
packer = ContextPacker(_small_repo())
dialogue = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
packet = packer.pack(
_scene(),
selection_id="a",
message="overview",
recent_events=[],
recent_dialogue=dialogue,
)
assert packet.recent_dialogue == dialogue
def test_recent_dialogue_defaults_to_empty_when_omitted() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(), selection_id="a", message="overview", recent_events=[]
)
assert packet.recent_dialogue == []
def test_recent_actions_override_packet_recent_events() -> None:
packer = ContextPacker(_small_repo())
packet = packer.pack(
_scene(),
selection_id="a",
message="overview",
recent_events=[],
recent_actions=["revealed X", "centered Y"],
)
assert packet.recent_events == ["revealed X", "centered Y"]
def test_recent_dialogue_trimmed_before_visible_nodes_reduced() -> None:
packer = ContextPacker(_small_repo())
# An oversized dialogue list pushes the packet over budget; the memory
# trimming steps must fire BEFORE the visible-node reduction steps.
oversized_dialogue = [{"role": "user", "content": "Q" * 200} for _ in range(40)]
packet = packer.pack(
_scene(),
selection_id="a",
message="overview",
recent_events=[],
recent_dialogue=oversized_dialogue,
)
assert _proxy_tokens(packet) <= 1500
# dialogue shrank...
assert len(packet.recent_dialogue) < len(oversized_dialogue)
# ...while all visible nodes were preserved (visible reduction never reached).
assert {n["id"] for n in packet.visible_nodes} == {"a", "b", "c"}