Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
608
tests/test_graph_memory.py
Normal file
608
tests/test_graph_memory.py
Normal file
@@ -0,0 +1,608 @@
|
||||
"""Tests for the node graph memory system (v2)."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src.jarvis.memory.graph import (
|
||||
GraphMemoryStore,
|
||||
MemoryNode,
|
||||
_estimate_tokens,
|
||||
SPLIT_THRESHOLD,
|
||||
MERGE_THRESHOLD,
|
||||
FIXED_BRANCHES,
|
||||
)
|
||||
|
||||
# Number of fixed top-level branches seeded under root on bootstrap
|
||||
# (User / Directives / World). See graph.py FIXED_BRANCHES.
|
||||
SEEDED = len(FIXED_BRANCHES)
|
||||
BOOTSTRAP_NODE_COUNT = SEEDED + 1 # seeded branches + root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
"""Create a fresh GraphMemoryStore with a temporary database."""
|
||||
db_path = str(tmp_path / "test_graph.db")
|
||||
s = GraphMemoryStore(db_path)
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEstimateTokens:
|
||||
"""Token estimation heuristic tests."""
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _estimate_tokens("") == 0
|
||||
|
||||
def test_short_text(self):
|
||||
assert _estimate_tokens("hello world") == 2 # 11 chars / 4
|
||||
|
||||
def test_longer_text(self):
|
||||
text = "a" * 400
|
||||
assert _estimate_tokens(text) == 100
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMemoryNodeModel:
|
||||
"""MemoryNode dataclass tests."""
|
||||
|
||||
def test_to_dict_roundtrip(self):
|
||||
node = MemoryNode(
|
||||
id="abc",
|
||||
name="Test",
|
||||
description="A test node",
|
||||
data="some data",
|
||||
)
|
||||
d = node.to_dict()
|
||||
assert d["id"] == "abc"
|
||||
assert d["name"] == "Test"
|
||||
assert d["description"] == "A test node"
|
||||
assert d["data"] == "some data"
|
||||
assert d["parent_id"] is None
|
||||
assert d["access_count"] == 0
|
||||
|
||||
def test_default_timestamps_populated(self):
|
||||
node = MemoryNode(id="x", name="X", description="x")
|
||||
assert node.created_at is not None
|
||||
assert node.last_accessed is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGraphMemoryStoreBootstrap:
|
||||
"""Schema initialisation and root node creation."""
|
||||
|
||||
def test_root_node_created_on_init(self, store):
|
||||
root = store.get_root()
|
||||
assert root is not None
|
||||
assert root.id == "root"
|
||||
assert root.parent_id is None
|
||||
|
||||
def test_root_not_duplicated(self, store):
|
||||
"""Re-initialising must not create a second root."""
|
||||
store._ensure_root()
|
||||
nodes = store.get_all_nodes()
|
||||
root_nodes = [n for n in nodes if n.parent_id is None]
|
||||
assert len(root_nodes) == 1
|
||||
|
||||
def test_node_count_starts_with_seeded_branches(self, store):
|
||||
# root + fixed branches (User / Directives / World)
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
|
||||
|
||||
def test_total_tokens_zero_for_empty_graph(self, store):
|
||||
assert store.get_total_tokens() == 0
|
||||
|
||||
def test_total_tokens_reflects_stored_data(self, store):
|
||||
store.create_node(
|
||||
name="Facts",
|
||||
description="Things I know",
|
||||
data="The sky is blue. Water boils at 100C.",
|
||||
parent_id="root",
|
||||
)
|
||||
assert store.get_total_tokens() > 0
|
||||
|
||||
def test_total_tokens_stays_zero_when_root_only_and_empty(self, store):
|
||||
# Even after touching/updating the root without data, tokens remain zero.
|
||||
root = store.get_root()
|
||||
store.update_node(root.id, description="updated description")
|
||||
assert store.get_total_tokens() == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMigrateLegacyShape:
|
||||
"""Startup wipe when the on-disk graph predates the User/Directives/World taxonomy."""
|
||||
|
||||
def test_no_wipe_on_fresh_graph(self, store):
|
||||
"""Freshly seeded graph (root + 3 branches, no data) is conforming."""
|
||||
assert store.migrate_legacy_shape() is False
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
|
||||
|
||||
def test_no_wipe_when_only_descendants_of_fixed_branches(self, store):
|
||||
"""Children grown under User/Directives/World are fine — the shape
|
||||
check only looks at direct root children."""
|
||||
store.create_node(
|
||||
name="Identity", description="who the user is",
|
||||
data="User's name is Baris.", parent_id="user",
|
||||
)
|
||||
assert store.migrate_legacy_shape() is False
|
||||
# Content preserved
|
||||
assert any(
|
||||
n.name == "Identity" for n in store.get_all_nodes()
|
||||
)
|
||||
|
||||
def test_wipes_when_root_has_rogue_child(self, store):
|
||||
"""Pre-taxonomy nodes sitting directly under root trigger a wipe."""
|
||||
store.create_node(
|
||||
name="People", description="pre-taxonomy category",
|
||||
data="Alice is a friend.", parent_id="root",
|
||||
)
|
||||
assert store.migrate_legacy_shape() is True
|
||||
# After wipe: only root + seeded branches, no rogue child
|
||||
names = {n.name for n in store.get_all_nodes()}
|
||||
assert "People" not in names
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
|
||||
|
||||
def test_wipes_when_root_itself_has_data(self, store):
|
||||
"""Cold-start facts appended to root before the taxonomy existed
|
||||
also count as non-conforming."""
|
||||
store.conn.execute(
|
||||
"UPDATE memory_nodes SET data = ? WHERE id = 'root'",
|
||||
("Some pre-taxonomy fact on root.",),
|
||||
)
|
||||
store.conn.commit()
|
||||
assert store.migrate_legacy_shape() is True
|
||||
root = store.get_root()
|
||||
assert root.data == ""
|
||||
|
||||
def test_reseeds_fixed_branches_after_wipe(self, store):
|
||||
"""After a wipe the three fixed branches are present again."""
|
||||
store.create_node(
|
||||
name="Rogue", description="x", data="y", parent_id="root",
|
||||
)
|
||||
assert store.migrate_legacy_shape() is True
|
||||
children = store.get_children("root")
|
||||
child_ids = {c.id for c in children}
|
||||
assert child_ids == {b[0] for b in FIXED_BRANCHES}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNodeCRUD:
|
||||
"""Create, read, update, delete operations."""
|
||||
|
||||
def test_create_and_get_node(self, store):
|
||||
node = store.create_node(
|
||||
name="People",
|
||||
description="People I know",
|
||||
data="Alice is a friend.",
|
||||
parent_id="root",
|
||||
)
|
||||
assert node.id is not None
|
||||
assert node.name == "People"
|
||||
assert node.parent_id == "root"
|
||||
assert node.data_token_count > 0
|
||||
|
||||
fetched = store.get_node(node.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "People"
|
||||
|
||||
def test_create_node_without_data(self, store):
|
||||
node = store.create_node(name="Empty", description="No data")
|
||||
assert node.data == ""
|
||||
assert node.data_token_count == 0
|
||||
|
||||
def test_get_nonexistent_node_returns_none(self, store):
|
||||
assert store.get_node("does-not-exist") is None
|
||||
|
||||
def test_update_node_name(self, store):
|
||||
node = store.create_node(name="Old", description="desc", parent_id="root")
|
||||
updated = store.update_node(node.id, name="New")
|
||||
assert updated is not None
|
||||
assert updated.name == "New"
|
||||
|
||||
refetched = store.get_node(node.id)
|
||||
assert refetched.name == "New"
|
||||
|
||||
def test_update_node_data_recalculates_tokens(self, store):
|
||||
node = store.create_node(name="N", description="d", data="short", parent_id="root")
|
||||
original_tokens = node.data_token_count
|
||||
|
||||
updated = store.update_node(node.id, data="a" * 200)
|
||||
assert updated.data_token_count == 50
|
||||
assert updated.data_token_count != original_tokens
|
||||
|
||||
def test_update_nonexistent_returns_none(self, store):
|
||||
assert store.update_node("nope", name="X") is None
|
||||
|
||||
def test_delete_node(self, store):
|
||||
node = store.create_node(name="Temp", description="d", parent_id="root")
|
||||
assert store.delete_node(node.id) is True
|
||||
assert store.get_node(node.id) is None
|
||||
|
||||
def test_delete_nonexistent_returns_false(self, store):
|
||||
assert store.delete_node("nope") is False
|
||||
|
||||
def test_cannot_delete_root(self, store):
|
||||
assert store.delete_node("root") is False
|
||||
assert store.get_root() is not None
|
||||
|
||||
def test_cannot_delete_fixed_branches(self, store):
|
||||
"""The seeded preset branches (user / directives / world) are
|
||||
non-deletable per graph.spec.md."""
|
||||
for branch_id, _name, _desc in FIXED_BRANCHES:
|
||||
assert store.delete_node(branch_id) is False, (
|
||||
f"Fixed branch {branch_id!r} must not be deletable"
|
||||
)
|
||||
assert store.get_node(branch_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNodeRelationships:
|
||||
"""Parent-child relationships and tree queries."""
|
||||
|
||||
def test_get_children(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id="root")
|
||||
c = store.create_node(name="C", description="c", parent_id=a.id)
|
||||
|
||||
root_children = store.get_children("root")
|
||||
# 2 test nodes + SEEDED fixed branches
|
||||
assert len(root_children) == 2 + SEEDED
|
||||
child_ids = {c.id for c in root_children}
|
||||
assert a.id in child_ids
|
||||
assert b.id in child_ids
|
||||
|
||||
a_children = store.get_children(a.id)
|
||||
assert len(a_children) == 1
|
||||
assert a_children[0].id == c.id
|
||||
|
||||
def test_get_children_empty(self, store):
|
||||
node = store.create_node(name="Leaf", description="d", parent_id="root")
|
||||
assert store.get_children(node.id) == []
|
||||
|
||||
def test_get_ancestors(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id=a.id)
|
||||
c = store.create_node(name="C", description="c", parent_id=b.id)
|
||||
|
||||
ancestors = store.get_ancestors(c.id)
|
||||
assert len(ancestors) == 4 # root -> A -> B -> C
|
||||
assert ancestors[0].id == "root"
|
||||
assert ancestors[1].id == a.id
|
||||
assert ancestors[2].id == b.id
|
||||
assert ancestors[3].id == c.id
|
||||
|
||||
def test_get_ancestors_of_root(self, store):
|
||||
ancestors = store.get_ancestors("root")
|
||||
assert len(ancestors) == 1
|
||||
assert ancestors[0].id == "root"
|
||||
|
||||
def test_get_subtree(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id=a.id)
|
||||
|
||||
tree = store.get_subtree("root", max_depth=3)
|
||||
assert tree["node"]["id"] == "root"
|
||||
assert len(tree["children"]) == 1 + SEEDED
|
||||
a_child = next(c for c in tree["children"] if c["node"]["id"] == a.id)
|
||||
assert len(a_child["children"]) == 1
|
||||
assert a_child["children"][0]["node"]["id"] == b.id
|
||||
|
||||
def test_get_subtree_depth_limit(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id=a.id)
|
||||
|
||||
tree = store.get_subtree("root", max_depth=1)
|
||||
# root (depth 0) -> A + seeded branches (depth 1), but B (depth 2) should not appear
|
||||
assert len(tree["children"]) == 1 + SEEDED
|
||||
for child in tree["children"]:
|
||||
assert child["children"] == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAccessTracking:
|
||||
"""Touch, recent nodes, and top nodes."""
|
||||
|
||||
def test_touch_increments_access_count(self, store):
|
||||
node = store.create_node(name="N", description="d", parent_id="root")
|
||||
assert node.access_count == 0
|
||||
|
||||
store.touch_node(node.id)
|
||||
store.touch_node(node.id)
|
||||
store.touch_node(node.id)
|
||||
|
||||
updated = store.get_node(node.id)
|
||||
assert updated.access_count == 3
|
||||
|
||||
def test_get_recent_nodes(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id="root")
|
||||
|
||||
store.touch_node(a.id)
|
||||
store.touch_node(b.id) # B touched last
|
||||
|
||||
recent = store.get_recent_nodes(limit=2)
|
||||
assert len(recent) == 2
|
||||
assert recent[0].id == b.id # most recent first
|
||||
|
||||
def test_get_recent_nodes_excludes_root(self, store):
|
||||
store.touch_node("root")
|
||||
recent = store.get_recent_nodes()
|
||||
root_ids = [n.id for n in recent]
|
||||
assert "root" not in root_ids
|
||||
|
||||
def test_get_top_nodes(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id="root")
|
||||
|
||||
# Touch A more than B
|
||||
for _ in range(5):
|
||||
store.touch_node(a.id)
|
||||
store.touch_node(b.id)
|
||||
|
||||
top = store.get_top_nodes(limit=2)
|
||||
assert len(top) == 2
|
||||
assert top[0].id == a.id # most accessed first
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGraphVisualisation:
|
||||
"""Graph data export for the canvas renderer."""
|
||||
|
||||
def test_get_graph_data_structure(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id=a.id)
|
||||
|
||||
data = store.get_graph_data("root", max_depth=5)
|
||||
assert "nodes" in data
|
||||
assert "edges" in data
|
||||
# root + seeded branches + A + B
|
||||
assert len(data["nodes"]) == BOOTSTRAP_NODE_COUNT + 2
|
||||
# seeded edges (root->each branch) + root->A + A->B
|
||||
assert len(data["edges"]) == SEEDED + 2
|
||||
|
||||
def test_graph_data_includes_depth(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
|
||||
data = store.get_graph_data("root", max_depth=5)
|
||||
root_data = next(n for n in data["nodes"] if n["id"] == "root")
|
||||
a_data = next(n for n in data["nodes"] if n["id"] == a.id)
|
||||
|
||||
assert root_data["depth"] == 0
|
||||
assert a_data["depth"] == 1
|
||||
|
||||
def test_graph_data_respects_max_depth(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id=a.id)
|
||||
c = store.create_node(name="C", description="c", parent_id=b.id)
|
||||
|
||||
data = store.get_graph_data("root", max_depth=1)
|
||||
node_ids = {n["id"] for n in data["nodes"]}
|
||||
assert "root" in node_ids
|
||||
assert a.id in node_ids
|
||||
# B is at depth 2, should not appear
|
||||
assert b.id not in node_ids
|
||||
|
||||
def test_get_all_nodes(self, store):
|
||||
store.create_node(name="A", description="a", parent_id="root")
|
||||
store.create_node(name="B", description="b", parent_id="root")
|
||||
|
||||
all_nodes = store.get_all_nodes()
|
||||
assert len(all_nodes) == BOOTSTRAP_NODE_COUNT + 2 # root + seeded + A + B
|
||||
|
||||
def test_node_count(self, store):
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
|
||||
store.create_node(name="A", description="a", parent_id="root")
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT + 1
|
||||
|
||||
def test_node_count_after_delete(self, store):
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id="root")
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT + 2
|
||||
store.delete_node(a.id)
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT + 1
|
||||
store.delete_node(b.id)
|
||||
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSafetyGuards:
|
||||
"""Cycle protection, FK enforcement, and input validation."""
|
||||
|
||||
def test_create_node_with_invalid_parent_raises(self, store):
|
||||
"""Creating a node with a non-existent parent_id must raise."""
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
store.create_node(name="Orphan", description="d", parent_id="nonexistent")
|
||||
|
||||
def test_get_ancestors_handles_cycle(self, store):
|
||||
"""get_ancestors must not infinite loop on a cyclic parent chain."""
|
||||
# Create two nodes then manually force a cycle via raw SQL
|
||||
a = store.create_node(name="A", description="a", parent_id="root")
|
||||
b = store.create_node(name="B", description="b", parent_id=a.id)
|
||||
|
||||
# Force a cycle: A -> B -> A (bypass normal validation)
|
||||
with store._lock:
|
||||
store.conn.execute(
|
||||
"UPDATE memory_nodes SET parent_id = ? WHERE id = ?",
|
||||
(b.id, a.id),
|
||||
)
|
||||
store.conn.commit()
|
||||
|
||||
# Should terminate without hanging, returning partial ancestors
|
||||
ancestors = store.get_ancestors(b.id)
|
||||
assert len(ancestors) <= 10 # bounded by MAX_TRAVERSAL_DEPTH + 1
|
||||
|
||||
def test_get_ancestors_deep_chain(self, store):
|
||||
"""Ancestors traversal works correctly for deep but acyclic chains."""
|
||||
parent_id = "root"
|
||||
for i in range(6):
|
||||
node = store.create_node(
|
||||
name=f"Level{i}", description=f"depth {i}", parent_id=parent_id
|
||||
)
|
||||
parent_id = node.id
|
||||
|
||||
ancestors = store.get_ancestors(parent_id)
|
||||
# root + 6 levels = 7 ancestors
|
||||
assert len(ancestors) == 7
|
||||
assert ancestors[0].id == "root"
|
||||
assert ancestors[-1].id == parent_id
|
||||
|
||||
def test_unicode_node_names(self, store):
|
||||
"""Nodes with unicode names, emoji, and CJK characters."""
|
||||
node = store.create_node(
|
||||
name="友達 🎉",
|
||||
description="Japanese friend with emoji",
|
||||
data="アリスは友達です。She loves 日本語。",
|
||||
parent_id="root",
|
||||
)
|
||||
fetched = store.get_node(node.id)
|
||||
assert fetched.name == "友達 🎉"
|
||||
assert "アリスは友達です" in fetched.data
|
||||
|
||||
def test_sql_special_chars_in_data(self, store):
|
||||
"""SQL metacharacters in data must round-trip safely."""
|
||||
dangerous = "Robert'); DROP TABLE memory_nodes;--"
|
||||
node = store.create_node(
|
||||
name="Bobby Tables",
|
||||
description="Test SQL injection",
|
||||
data=dangerous,
|
||||
parent_id="root",
|
||||
)
|
||||
fetched = store.get_node(node.id)
|
||||
assert fetched.data == dangerous
|
||||
# Table must still exist
|
||||
assert store.get_node_count() >= 2
|
||||
|
||||
def test_search_escapes_like_wildcards(self, store):
|
||||
"""Searching for literal % or _ must not behave as SQL LIKE wildcards."""
|
||||
store.create_node(
|
||||
name="100% Protein", description="Supplement", data="", parent_id="root"
|
||||
)
|
||||
store.create_node(
|
||||
name="Boring Node", description="Nothing special", data="plain", parent_id="root"
|
||||
)
|
||||
|
||||
# Searching for "100%" should only match the node with literal "100%"
|
||||
results = store.search_nodes("100%")
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "100% Protein"
|
||||
|
||||
def test_description_truncated_to_max_length(self, store):
|
||||
"""Descriptions exceeding SUMMARY_MAX_LENGTH are truncated on create and update."""
|
||||
from jarvis.memory.graph import SUMMARY_MAX_LENGTH
|
||||
|
||||
long_desc = "a" * (SUMMARY_MAX_LENGTH + 100)
|
||||
node = store.create_node(
|
||||
name="Long", description=long_desc, parent_id="root"
|
||||
)
|
||||
assert len(node.description) == SUMMARY_MAX_LENGTH
|
||||
|
||||
# Also truncated on update
|
||||
updated = store.update_node(node.id, description=long_desc)
|
||||
assert len(updated.description) == SUMMARY_MAX_LENGTH
|
||||
|
||||
def test_search_ranks_name_matches_above_data_only(self, store):
|
||||
"""Nodes matching keywords in name/description should rank above
|
||||
nodes that only match deep inside their data blob."""
|
||||
# Specific node: keyword in name
|
||||
store.create_node(
|
||||
name="Work Schedule",
|
||||
description="Office days and remote work pattern",
|
||||
data="Monday and Thursday are in-office days.",
|
||||
parent_id="root",
|
||||
)
|
||||
# Broad category node: keyword buried in large data
|
||||
store.create_node(
|
||||
name="Creative & Personal",
|
||||
description="Miscellaneous personal facts",
|
||||
data="The user enjoys painting on weekends. " * 50
|
||||
+ "They mentioned their office once. " + "More unrelated content. " * 50,
|
||||
parent_id="root",
|
||||
)
|
||||
|
||||
results = store.search_nodes("office schedule")
|
||||
assert len(results) >= 1
|
||||
assert results[0].name == "Work Schedule"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAccessDecay:
|
||||
"""Tests for time-decayed access scoring."""
|
||||
|
||||
def test_recently_accessed_node_ranks_higher(self, store):
|
||||
"""A node accessed today should rank above one accessed long ago,
|
||||
even if the stale node has a higher raw access_count.
|
||||
|
||||
With a 14-day half-life:
|
||||
- Stale: 20 accesses, 60 days ago → 20 / (1 + 60/14) ≈ 3.78
|
||||
- Fresh: 5 accesses, today → 5 / (1 + 0/14) = 5.0
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
stale = store.create_node(name="Stale", description="Old node", parent_id="root")
|
||||
fresh = store.create_node(name="Fresh", description="New node", parent_id="root")
|
||||
|
||||
# Give the stale node moderate accesses but set last_accessed to 60 days ago
|
||||
store.conn.execute(
|
||||
"UPDATE memory_nodes SET access_count = 20, last_accessed = ? WHERE id = ?",
|
||||
((datetime.now(timezone.utc) - timedelta(days=60)).isoformat(), stale.id),
|
||||
)
|
||||
store.conn.commit()
|
||||
|
||||
# Fresh node: fewer accesses but just now
|
||||
for _ in range(5):
|
||||
store.touch_node(fresh.id)
|
||||
|
||||
top = store.get_top_nodes(limit=2)
|
||||
assert len(top) >= 2
|
||||
assert top[0].id == fresh.id, (
|
||||
"Freshly accessed node should rank above stale node"
|
||||
)
|
||||
|
||||
def test_children_ordered_by_decayed_score(self, store):
|
||||
"""get_children should order by decayed score, not raw count.
|
||||
|
||||
With a 14-day half-life:
|
||||
- Old child: 10 accesses, 90 days ago → 10 / (1 + 90/14) ≈ 1.35
|
||||
- New child: 3 accesses, today → 3 / (1 + 0/14) = 3.0
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
old_child = store.create_node(name="Old Child", description="", parent_id="root")
|
||||
new_child = store.create_node(name="New Child", description="", parent_id="root")
|
||||
|
||||
# Old child: moderate count, very stale
|
||||
store.conn.execute(
|
||||
"UPDATE memory_nodes SET access_count = 10, last_accessed = ? WHERE id = ?",
|
||||
((datetime.now(timezone.utc) - timedelta(days=90)).isoformat(), old_child.id),
|
||||
)
|
||||
store.conn.commit()
|
||||
|
||||
# New child: low count, fresh
|
||||
for _ in range(3):
|
||||
store.touch_node(new_child.id)
|
||||
|
||||
children = store.get_children("root")
|
||||
child_ids = [c.id for c in children]
|
||||
assert child_ids[0] == new_child.id
|
||||
|
||||
def test_same_age_nodes_ordered_by_count(self, store):
|
||||
"""When two nodes were accessed at the same time, higher count wins."""
|
||||
a = store.create_node(name="A", description="", parent_id="root")
|
||||
b = store.create_node(name="B", description="", parent_id="root")
|
||||
|
||||
for _ in range(10):
|
||||
store.touch_node(a.id)
|
||||
for _ in range(3):
|
||||
store.touch_node(b.id)
|
||||
|
||||
top = store.get_top_nodes(limit=2)
|
||||
assert top[0].id == a.id
|
||||
|
||||
def test_zero_access_count_handled(self, store):
|
||||
"""Nodes with zero accesses should not cause division errors."""
|
||||
store.create_node(name="Untouched", description="", parent_id="root")
|
||||
top = store.get_top_nodes(limit=5)
|
||||
# Should not raise — zero access_count means score is 0
|
||||
assert all(n.access_count >= 0 for n in top)
|
||||
Reference in New Issue
Block a user