Files
Auto-Trader/agent/tests/memory/test_hierarchy.py
T
shadowinlifeandQoder b9507ee12b feat(memory): implement Tier 2 structural organization (#815)
* feat(memory): implement Tier 2 structural organization

Add four independent modules for memory structural organization:
- H-MEM hierarchical directory routing (hierarchy.py)
- A-MEM semantic linking via BM25 (semantic_links.py)
- Auto-compression pipeline via TF-IDF (compression.py)
- FTS5 full-text search index (search_index.py)

Integration:
- 4 feature flags in MemoryConfig (default: disabled)
- MemoryEntry extended with category + compression_level fields
- persistent.py wired with hierarchy routing, FTS5 search, link expansion
- lifecycle.py triggers compression in GC cycle
- remember_tool.py attaches related links in recall results
- FTS5 CJK bigram+unigram indexing for better Chinese recall
- Lazy auto-rebuild on first FTS5 search

Testing:
- 163 unit tests + 9 integration tests + 23 benchmarks (all passing)
- Benchmark evaluated on 200 real production memories + 50 labeled queries
- Compression retention: daily 91.4%, digest 97.1%
- Hierarchy routing: 93.3% P@5 parity with full scan

Closes #732 (Tier 2 scope)

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

Co-Authored-By: Qoder <noreply@qoder.com>
AI-Contributed/Feature: 1582/1871
AI-Contributed/UT: 2123/2159

* feat(memory): add VT_MEMORY=off|on|full business-friendly preset

Simplify memory configuration from 7 individual boolean flags to a
single business-friendly environment variable:
  - off:  all features disabled (default, backward compatible)
  - on:   Tier 1 quality + decay + garbage collection
  - full: Tier 1 + Tier 2 hierarchy + links + compression + FTS

Individual VT_MEMORY_* flags still work as explicit overrides with
higher priority than the preset baseline.

Also adds a dependency-conflict warning in lifecycle.py when
compression is enabled without GC.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/138
AI-Contributed/UT: 0/0

* docs: add Memory Tier 2 and VT_MEMORY preset to CHANGELOG

Document the new structural organization features (hierarchy, semantic
links, compression, FTS5) and the business-friendly VT_MEMORY=off|on|full
configuration preset.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/15
AI-Contributed/UT: 0/0

---------

Co-authored-by: Qoder <noreply@qoder.com>
2026-07-24 22:13:16 +08:00

184 lines
6.9 KiB
Python

"""Tests for MemoryHierarchy: hierarchical directory routing (Tier 2)."""
from __future__ import annotations
from pathlib import Path
import pytest
from src.memory.hierarchy import CATEGORIES, MemoryHierarchy
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_md(path: Path, title: str = "test", memory_type: str = "project") -> Path:
"""Write a dummy .md file with basic frontmatter."""
path.parent.mkdir(parents=True, exist_ok=True)
content = (
f"---\nname: {title}\ntype: {memory_type}\n"
f"keywords: [{title}]\n---\n\nBody of {title}\n"
)
path.write_text(content, encoding="utf-8")
return path
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestRouteEntry:
def test_route_entry_creates_subdir(self, tmp_path: Path) -> None:
"""Routing to known categories creates subdirectory."""
h = MemoryHierarchy(tmp_path)
for cat in CATEGORIES:
result = h.route_entry(cat, f"{cat}_note.md")
assert result.parent.name == cat
assert result.parent.is_dir()
assert result.name == f"{cat}_note.md"
def test_route_entry_unknown_type_falls_back(self, tmp_path: Path) -> None:
"""Unknown memory_type routes to base directory."""
h = MemoryHierarchy(tmp_path)
result = h.route_entry("unknown_type", "note.md")
assert result.parent == tmp_path
assert result.name == "note.md"
class TestScan:
def test_scan_all_includes_root_and_subdirs(self, tmp_path: Path) -> None:
"""scan_all finds .md files in both base dir and category subdirs."""
h = MemoryHierarchy(tmp_path)
# Root-level entry
_write_md(tmp_path / "legacy.md", title="legacy")
# Category entry
_write_md(tmp_path / "user" / "user_note.md", title="user_note")
_write_md(tmp_path / "project" / "proj_note.md", title="proj_note")
results = h.scan_all()
names = [p.name for p in results]
assert "legacy.md" in names
assert "user_note.md" in names
assert "proj_note.md" in names
def test_scan_all_skips_special_files(self, tmp_path: Path) -> None:
"""scan_all skips MEMORY.md and .hierarchy.yaml."""
h = MemoryHierarchy(tmp_path)
(tmp_path / "MEMORY.md").write_text("index", encoding="utf-8")
(tmp_path / ".hierarchy.yaml").write_text("meta", encoding="utf-8")
_write_md(tmp_path / "real.md", title="real")
results = h.scan_all()
names = [p.name for p in results]
assert "MEMORY.md" not in names
assert ".hierarchy.yaml" not in names
assert "real.md" in names
def test_scan_category_only_target(self, tmp_path: Path) -> None:
"""scan_category only returns files in specified category."""
h = MemoryHierarchy(tmp_path)
_write_md(tmp_path / "user" / "u1.md", title="u1")
_write_md(tmp_path / "project" / "p1.md", title="p1")
_write_md(tmp_path / "root.md", title="root")
user_files = h.scan_category("user")
assert len(user_files) == 1
assert user_files[0].name == "u1.md"
def test_scan_category_empty(self, tmp_path: Path) -> None:
"""scan_category for non-existent dir returns empty list."""
h = MemoryHierarchy(tmp_path)
assert h.scan_category("feedback") == []
class TestRebuildIndex:
def test_rebuild_index_writes_yaml(self, tmp_path: Path) -> None:
"""rebuild_index generates .hierarchy.yaml with correct structure."""
h = MemoryHierarchy(tmp_path)
entries = [
{"memory_type": "user", "keywords": ["trading", "strategy"]},
{"memory_type": "user", "keywords": ["portfolio"]},
{"memory_type": "project", "keywords": ["backtest"]},
]
h.rebuild_index(entries)
index_path = tmp_path / ".hierarchy.yaml"
assert index_path.is_file()
content = index_path.read_text(encoding="utf-8")
assert "categories:" in content
assert "user:" in content
assert "count: 2" in content
assert "trading" in content
assert "project:" in content
assert "count: 1" in content
assert "rebuilt_at:" in content
class TestPruneSearchScope:
def test_prune_search_scope_with_category_filter(self, tmp_path: Path) -> None:
"""With category_filter, only scans that category."""
h = MemoryHierarchy(tmp_path)
_write_md(tmp_path / "user" / "u1.md", title="u1")
_write_md(tmp_path / "project" / "p1.md", title="p1")
results = h.prune_search_scope(set(), category_filter="user")
names = [p.name for p in results]
assert "u1.md" in names
assert "p1.md" not in names
def test_prune_search_scope_keyword_overlap(self, tmp_path: Path) -> None:
"""Categories with keyword overlap are prioritized."""
h = MemoryHierarchy(tmp_path)
# Create entries in subdirs
_write_md(tmp_path / "user" / "u1.md", title="u1")
_write_md(tmp_path / "project" / "p1.md", title="p1")
# Build index with known keywords
entries = [
{"memory_type": "user", "keywords": ["alpha", "beta"]},
{"memory_type": "project", "keywords": ["gamma", "delta"]},
]
h.rebuild_index(entries)
# Query with tokens overlapping "project" keywords
results = h.prune_search_scope({"gamma", "delta"})
names = [p.name for p in results]
# project files should appear first (higher overlap)
assert "p1.md" in names
assert names.index("p1.md") < names.index("u1.md")
class TestMigrateFlatEntry:
def test_migrate_flat_entry(self, tmp_path: Path) -> None:
"""Moves file from root to category subdir."""
h = MemoryHierarchy(tmp_path)
src = _write_md(tmp_path / "old_note.md", title="old_note")
assert src.exists()
new_path = h.migrate_flat_entry(src, "user")
assert new_path is not None
assert new_path.parent.name == "user"
assert new_path.name == "old_note.md"
assert new_path.exists()
assert not src.exists()
def test_migrate_skips_already_in_subdir(self, tmp_path: Path) -> None:
"""Does not migrate files already in a subdirectory."""
h = MemoryHierarchy(tmp_path)
src = _write_md(tmp_path / "user" / "already.md", title="already")
result = h.migrate_flat_entry(src, "project")
assert result is None
def test_migrate_unknown_category(self, tmp_path: Path) -> None:
"""Cannot migrate to unknown category."""
h = MemoryHierarchy(tmp_path)
src = _write_md(tmp_path / "note.md", title="note")
result = h.migrate_flat_entry(src, "nonexistent")
assert result is None
assert src.exists()