Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """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"} | |