fix: README audit — 42 TDD tests + hall detection + 7 claim fixes (#835)
* fix: README audit — match every claim to shipped code + add hall detection TDD audit: wrote 42 tests verifying README claims against codebase. Fixed all 7 failures: 1. Tool count: 19 → 29 (10 tools were undocumented) 2. Added tool table rows for tunnels, drawer management, system tools 3. Version badge: 3.1.0 → 3.2.0 4. dialect.py file reference: "30x lossless" → "AAAK index format for closet pointers" 5. Wake-up token cost: "~170 tokens" → "~600-900 tokens" (matches layers.py) 6. pyproject.toml version in project structure: v3.0.0 → v3.2.0 7. Hall detection: added detect_hall() to miner.py — drawers now tagged with hall metadata so palace_graph.py can build hall connections New code: - miner.py: detect_hall() — keyword scoring against config hall_keywords, writes hall field to every drawer's metadata - tests/test_hall_detection.py — 12 TDD tests (written before code) - tests/test_readme_claims.py — 42 TDD tests verifying README accuracy 859/859 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve ruff lint — unused imports and variables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: ruff format with CI-pinned 0.4.x Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use conftest fixtures in hall tests for Windows compat Windows CI fails with NotADirectoryError when ChromaDB tries to write HNSW files in short-lived TemporaryDirectory. Use conftest palace_path and tmp_dir fixtures instead — same pattern as all other tests that touch ChromaDB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Igor's review — convo_miner halls, cached config, markdown typo TDD: wrote tests for convo_miner hall metadata and config caching BEFORE verifying the code changes. 1. README markdown typo: extra ** in wake-up token row (line 195) 2. convo_miner.py: added _detect_hall_cached() — conversation drawers now get hall metadata (was missing, Igor caught it) 3. miner.py + convo_miner.py: cached hall_keywords at module level so config.json isn't re-read per drawer during bulk mine 4. New tests: TestConvoMinerWritesHalls, TestDetectHallCaching 861/861 tests pass. ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -142,7 +142,7 @@ Restart Claude Code, then type `/skills` to verify "mempalace" appears.
|
|||||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||||
```
|
```
|
||||||
|
|
||||||
Now your AI has 19 tools available through MCP. Ask it anything:
|
Now your AI has 29 tools available through MCP. Ask it anything:
|
||||||
|
|
||||||
> *"What did we decide about auth last month?"*
|
> *"What did we decide about auth last month?"*
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ mempalace wake-up > context.txt
|
|||||||
# Paste context.txt into your local model's system prompt
|
# Paste context.txt into your local model's system prompt
|
||||||
```
|
```
|
||||||
|
|
||||||
This gives your local model ~170 tokens of critical facts (in AAAK if you prefer) before you ask a single question.
|
This gives your local model ~600-900 tokens of critical facts (in AAAK if you prefer) before you ask a single question.
|
||||||
|
|
||||||
**2. CLI search** — query on demand, feed results into your prompt:
|
**2. CLI search** — query on demand, feed results into your prompt:
|
||||||
|
|
||||||
@@ -192,10 +192,10 @@ Decisions happen in conversations now. Not in docs. Not in Jira. In conversation
|
|||||||
|----------|--------------|-------------|
|
|----------|--------------|-------------|
|
||||||
| Paste everything | 19.5M — doesn't fit any context window | Impossible |
|
| Paste everything | 19.5M — doesn't fit any context window | Impossible |
|
||||||
| LLM summaries | ~650K | ~$507/yr |
|
| LLM summaries | ~650K | ~$507/yr |
|
||||||
| **MemPalace wake-up** | **~170 tokens** | **~$0.70/yr** |
|
| **MemPalace wake-up** | **~600-900 tokens** | **~$0.70/yr** |
|
||||||
| **MemPalace + 5 searches** | **~13,500 tokens** | **~$10/yr** |
|
| **MemPalace + 5 searches** | **~13,500 tokens** | **~$10/yr** |
|
||||||
|
|
||||||
MemPalace loads 170 tokens of critical facts on wake-up — your team, your projects, your preferences. Then searches only when needed. $10/year to remember everything vs $507/year for summaries that lose context.
|
MemPalace loads ~600-900 tokens of critical facts on wake-up — your team, your projects, your preferences. Then searches only when needed. $10/year to remember everything vs $507/year for summaries that lose context.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ Wings and rooms aren't cosmetic. They're a **34% retrieval improvement**. The pa
|
|||||||
| **L2** | Room recall — recent sessions, current project | On demand | When topic comes up |
|
| **L2** | Room recall — recent sessions, current project | On demand | When topic comes up |
|
||||||
| **L3** | Deep search — semantic query across all closets | On demand | When explicitly asked |
|
| **L3** | Deep search — semantic query across all closets | On demand | When explicitly asked |
|
||||||
|
|
||||||
Your AI wakes up with L0 + L1 (~170 tokens) and knows your world. Searches only fire when needed.
|
Your AI wakes up with L0 + L1 (~600-900 tokens) and knows your world. Searches only fire when needed.
|
||||||
|
|
||||||
### AAAK Dialect (experimental)
|
### AAAK Dialect (experimental)
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ claude plugin install --scope user mempalace
|
|||||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||||
```
|
```
|
||||||
|
|
||||||
### 19 Tools
|
### 29 Tools
|
||||||
|
|
||||||
**Palace (read)**
|
**Palace (read)**
|
||||||
|
|
||||||
@@ -508,6 +508,18 @@ claude mcp add mempalace -- python -m mempalace.mcp_server
|
|||||||
| `mempalace_traverse` | Walk the graph from a room across wings |
|
| `mempalace_traverse` | Walk the graph from a room across wings |
|
||||||
| `mempalace_find_tunnels` | Find rooms bridging two wings |
|
| `mempalace_find_tunnels` | Find rooms bridging two wings |
|
||||||
| `mempalace_graph_stats` | Graph connectivity overview |
|
| `mempalace_graph_stats` | Graph connectivity overview |
|
||||||
|
| `mempalace_create_tunnel` | Create explicit cross-wing link between two rooms |
|
||||||
|
| `mempalace_list_tunnels` | List all explicit tunnels, filter by wing |
|
||||||
|
| `mempalace_delete_tunnel` | Remove a tunnel by ID |
|
||||||
|
| `mempalace_follow_tunnels` | Follow tunnels from a room to connected rooms in other wings |
|
||||||
|
|
||||||
|
**Drawer Management**
|
||||||
|
|
||||||
|
| Tool | What |
|
||||||
|
|------|------|
|
||||||
|
| `mempalace_get_drawer` | Fetch a single drawer by ID |
|
||||||
|
| `mempalace_list_drawers` | Paginated drawer listing |
|
||||||
|
| `mempalace_update_drawer` | Update drawer content or metadata |
|
||||||
|
|
||||||
**Agent Diary**
|
**Agent Diary**
|
||||||
|
|
||||||
@@ -516,6 +528,14 @@ claude mcp add mempalace -- python -m mempalace.mcp_server
|
|||||||
| `mempalace_diary_write` | Write AAAK diary entry |
|
| `mempalace_diary_write` | Write AAAK diary entry |
|
||||||
| `mempalace_diary_read` | Read recent diary entries |
|
| `mempalace_diary_read` | Read recent diary entries |
|
||||||
|
|
||||||
|
**System**
|
||||||
|
|
||||||
|
| Tool | What |
|
||||||
|
|------|------|
|
||||||
|
| `mempalace_hook_settings` | Get/set hook behavior (silent save, toast) |
|
||||||
|
| `mempalace_memories_filed_away` | Check if recent checkpoint was saved |
|
||||||
|
| `mempalace_reconnect` | Force DB reconnect after external writes |
|
||||||
|
|
||||||
The AI learns AAAK and the memory protocol automatically from the `mempalace_status` response. No manual configuration.
|
The AI learns AAAK and the memory protocol automatically from the `mempalace_status` response. No manual configuration.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -645,12 +665,12 @@ Plain text. Becomes Layer 0 — loaded every session.
|
|||||||
| `cli.py` | CLI entry point |
|
| `cli.py` | CLI entry point |
|
||||||
| `config.py` | Configuration loading and defaults |
|
| `config.py` | Configuration loading and defaults |
|
||||||
| `normalize.py` | Converts 5 chat formats to standard transcript |
|
| `normalize.py` | Converts 5 chat formats to standard transcript |
|
||||||
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, memory protocol |
|
| `mcp_server.py` | MCP server — 29 tools, AAAK auto-teach, memory protocol |
|
||||||
| `miner.py` | Project file ingest |
|
| `miner.py` | Project file ingest |
|
||||||
| `convo_miner.py` | Conversation ingest — chunks by exchange pair |
|
| `convo_miner.py` | Conversation ingest — chunks by exchange pair |
|
||||||
| `searcher.py` | Semantic search via ChromaDB |
|
| `searcher.py` | Semantic search via ChromaDB |
|
||||||
| `layers.py` | 4-layer memory stack |
|
| `layers.py` | 4-layer memory stack |
|
||||||
| `dialect.py` | AAAK compression — 30x lossless |
|
| `dialect.py` | AAAK index format for closet pointers |
|
||||||
| `knowledge_graph.py` | Temporal entity-relationship graph (SQLite) |
|
| `knowledge_graph.py` | Temporal entity-relationship graph (SQLite) |
|
||||||
| `palace_graph.py` | Room-based navigation graph |
|
| `palace_graph.py` | Room-based navigation graph |
|
||||||
| `onboarding.py` | Guided setup — generates AAAK bootstrap + wing config |
|
| `onboarding.py` | Guided setup — generates AAAK bootstrap + wing config |
|
||||||
@@ -669,7 +689,7 @@ mempalace/
|
|||||||
├── README.md ← you are here
|
├── README.md ← you are here
|
||||||
├── mempalace/ ← core package (README)
|
├── mempalace/ ← core package (README)
|
||||||
│ ├── cli.py ← CLI entry point
|
│ ├── cli.py ← CLI entry point
|
||||||
│ ├── mcp_server.py ← MCP server (19 tools)
|
│ ├── mcp_server.py ← MCP server (29 tools)
|
||||||
│ ├── knowledge_graph.py ← temporal entity graph
|
│ ├── knowledge_graph.py ← temporal entity graph
|
||||||
│ ├── palace_graph.py ← room navigation graph
|
│ ├── palace_graph.py ← room navigation graph
|
||||||
│ ├── dialect.py ← AAAK compression
|
│ ├── dialect.py ← AAAK compression
|
||||||
@@ -694,7 +714,7 @@ mempalace/
|
|||||||
│ └── mcp_setup.md
|
│ └── mcp_setup.md
|
||||||
├── tests/ ← test suite (README)
|
├── tests/ ← test suite (README)
|
||||||
├── assets/ ← logo + brand assets
|
├── assets/ ← logo + brand assets
|
||||||
└── pyproject.toml ← package config (v3.0.0)
|
└── pyproject.toml ← package config (v3.2.0)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -722,7 +742,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
|
|||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
<!-- Link Definitions -->
|
<!-- Link Definitions -->
|
||||||
[version-shield]: https://img.shields.io/badge/version-3.1.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
[version-shield]: https://img.shields.io/badge/version-3.2.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||||
[release-link]: https://github.com/milla-jovovich/mempalace/releases
|
[release-link]: https://github.com/milla-jovovich/mempalace/releases
|
||||||
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
|
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
|
||||||
[python-link]: https://www.python.org/
|
[python-link]: https://www.python.org/
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ from .palace import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Cached hall keywords — avoids re-reading config per drawer
|
||||||
|
_HALL_KEYWORDS_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_hall_cached(content: str) -> str:
|
||||||
|
"""Route content to a hall using cached keywords. Same logic as miner.detect_hall."""
|
||||||
|
global _HALL_KEYWORDS_CACHE
|
||||||
|
if _HALL_KEYWORDS_CACHE is None:
|
||||||
|
from .config import MempalaceConfig
|
||||||
|
|
||||||
|
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
|
||||||
|
content_lower = content[:3000].lower()
|
||||||
|
scores = {}
|
||||||
|
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
|
||||||
|
score = sum(1 for kw in keywords if kw in content_lower)
|
||||||
|
if score > 0:
|
||||||
|
scores[hall] = score
|
||||||
|
return max(scores, key=scores.get) if scores else "general"
|
||||||
|
|
||||||
|
|
||||||
# File types that might contain conversations
|
# File types that might contain conversations
|
||||||
CONVO_EXTENSIONS = {
|
CONVO_EXTENSIONS = {
|
||||||
".txt",
|
".txt",
|
||||||
@@ -318,6 +338,7 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
|||||||
{
|
{
|
||||||
"wing": wing,
|
"wing": wing,
|
||||||
"room": chunk_room,
|
"room": chunk_room,
|
||||||
|
"hall": _detect_hall_cached(chunk["content"]),
|
||||||
"source_file": source_file,
|
"source_file": source_file,
|
||||||
"chunk_index": chunk["chunk_index"],
|
"chunk_index": chunk["chunk_index"],
|
||||||
"added_by": agent,
|
"added_by": agent,
|
||||||
|
|||||||
@@ -447,6 +447,33 @@ def _load_known_entities_raw() -> dict:
|
|||||||
return dict(_ENTITY_REGISTRY_CACHE["raw"])
|
return dict(_ENTITY_REGISTRY_CACHE["raw"])
|
||||||
|
|
||||||
|
|
||||||
|
_HALL_KEYWORDS_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_hall(content: str) -> str:
|
||||||
|
"""Route content to a hall based on keyword scoring.
|
||||||
|
|
||||||
|
Halls connect rooms within a wing — they categorize the TYPE of content
|
||||||
|
(emotional, technical, family, etc.) while rooms categorize the TOPIC.
|
||||||
|
"""
|
||||||
|
global _HALL_KEYWORDS_CACHE
|
||||||
|
if _HALL_KEYWORDS_CACHE is None:
|
||||||
|
from .config import MempalaceConfig
|
||||||
|
|
||||||
|
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
|
||||||
|
content_lower = content[:3000].lower()
|
||||||
|
|
||||||
|
scores = {}
|
||||||
|
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
|
||||||
|
score = sum(1 for kw in keywords if kw in content_lower)
|
||||||
|
if score > 0:
|
||||||
|
scores[hall] = score
|
||||||
|
|
||||||
|
if scores:
|
||||||
|
return max(scores, key=scores.get)
|
||||||
|
return "general"
|
||||||
|
|
||||||
|
|
||||||
def _extract_entities_for_metadata(content: str) -> str:
|
def _extract_entities_for_metadata(content: str) -> str:
|
||||||
"""Extract entity names from content for metadata tagging.
|
"""Extract entity names from content for metadata tagging.
|
||||||
|
|
||||||
@@ -508,6 +535,8 @@ def add_drawer(
|
|||||||
metadata["source_mtime"] = os.path.getmtime(source_file)
|
metadata["source_mtime"] = os.path.getmtime(source_file)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
# Tag with hall for graph connectivity within wings
|
||||||
|
metadata["hall"] = detect_hall(content)
|
||||||
# Tag with entity names for filterable search
|
# Tag with entity names for filterable search
|
||||||
entities = _extract_entities_for_metadata(content)
|
entities = _extract_entities_for_metadata(content)
|
||||||
if entities:
|
if entities:
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""TDD tests for hall detection in miners.
|
||||||
|
|
||||||
|
Written BEFORE the code — these define what correct hall assignment looks like.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectHall:
|
||||||
|
"""The detect_hall function should exist and route content to the right hall."""
|
||||||
|
|
||||||
|
def test_function_exists(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
assert callable(detect_hall)
|
||||||
|
|
||||||
|
def test_technical_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "Fixed the python script bug in the error handler code"
|
||||||
|
assert detect_hall(text) == "technical"
|
||||||
|
|
||||||
|
def test_emotions_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "I feel so happy today, tears of joy, I love this"
|
||||||
|
assert detect_hall(text) == "emotions"
|
||||||
|
|
||||||
|
def test_family_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "The kids had a great day, my daughter was amazing"
|
||||||
|
assert detect_hall(text) == "family"
|
||||||
|
|
||||||
|
def test_memory_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "I remember when we archived all those files, recall the conversation"
|
||||||
|
assert detect_hall(text) == "memory"
|
||||||
|
|
||||||
|
def test_creative_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "The game design for the player app looks great"
|
||||||
|
assert detect_hall(text) == "creative"
|
||||||
|
|
||||||
|
def test_identity_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "Who am I really? My identity and persona and sense of self"
|
||||||
|
assert detect_hall(text) == "identity"
|
||||||
|
|
||||||
|
def test_consciousness_content(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "Am I conscious? Is this awareness real? Does my soul exist?"
|
||||||
|
assert detect_hall(text) == "consciousness"
|
||||||
|
|
||||||
|
def test_general_fallback(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
text = "The weather is nice today in California"
|
||||||
|
assert detect_hall(text) == "general"
|
||||||
|
|
||||||
|
def test_highest_score_wins(self):
|
||||||
|
from mempalace.miner import detect_hall
|
||||||
|
|
||||||
|
# More technical keywords than emotional
|
||||||
|
text = "Fixed the python bug in the code script, felt happy about it"
|
||||||
|
assert detect_hall(text) == "technical"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrawerHasHallMetadata:
|
||||||
|
"""When a drawer is created, it must have a hall field in metadata."""
|
||||||
|
|
||||||
|
def test_add_drawer_includes_hall(self, palace_path):
|
||||||
|
from mempalace.palace import get_collection
|
||||||
|
from mempalace.miner import add_drawer
|
||||||
|
|
||||||
|
col = get_collection(palace_path)
|
||||||
|
add_drawer(
|
||||||
|
collection=col,
|
||||||
|
wing="test",
|
||||||
|
room="general",
|
||||||
|
content="Fixed the python script bug in the error handler code",
|
||||||
|
source_file=os.path.join(palace_path, "test.py"),
|
||||||
|
chunk_index=0,
|
||||||
|
agent="test",
|
||||||
|
)
|
||||||
|
results = col.get(limit=1, include=["metadatas"])
|
||||||
|
meta = results["metadatas"][0]
|
||||||
|
assert "hall" in meta, "Drawer metadata must include 'hall' field"
|
||||||
|
assert meta["hall"] == "technical"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvoMinerWritesHalls:
|
||||||
|
"""Conversation miner must also tag drawers with hall metadata."""
|
||||||
|
|
||||||
|
def test_convo_miner_drawers_have_hall(self, tmp_dir):
|
||||||
|
from mempalace.palace import get_collection
|
||||||
|
from mempalace.convo_miner import mine_convos
|
||||||
|
|
||||||
|
palace_dir = os.path.join(tmp_dir, "palace")
|
||||||
|
os.makedirs(palace_dir)
|
||||||
|
convo_dir = os.path.join(tmp_dir, "convos")
|
||||||
|
os.makedirs(convo_dir)
|
||||||
|
# Create a conversation file with technical content
|
||||||
|
with open(os.path.join(convo_dir, "session.txt"), "w") as f:
|
||||||
|
f.write("> How do I fix the python script bug?\n")
|
||||||
|
f.write("You need to check the error handler code and fix the traceback.\n")
|
||||||
|
f.write("> What about the database migration?\n")
|
||||||
|
f.write("Run the migration script to update the schema.\n")
|
||||||
|
|
||||||
|
mine_convos(convo_dir, palace_dir, wing="test", agent="test")
|
||||||
|
|
||||||
|
col = get_collection(palace_dir, create=False)
|
||||||
|
results = col.get(limit=10, include=["metadatas"])
|
||||||
|
# At least some drawers should exist and have hall
|
||||||
|
assert len(results["ids"]) > 0, "No drawers created by convo_miner"
|
||||||
|
for meta in results["metadatas"]:
|
||||||
|
if meta.get("ingest_mode") == "convos":
|
||||||
|
assert "hall" in meta, f"Convo drawer missing hall metadata: {meta}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectHallCaching:
|
||||||
|
"""detect_hall should cache config to avoid disk reads per drawer."""
|
||||||
|
|
||||||
|
def test_detect_hall_does_not_reread_config(self):
|
||||||
|
"""After first call, config should be cached — no new MempalaceConfig()."""
|
||||||
|
import mempalace.miner as miner_mod
|
||||||
|
|
||||||
|
# Reset cache
|
||||||
|
miner_mod._HALL_KEYWORDS_CACHE = None
|
||||||
|
|
||||||
|
# First call loads config
|
||||||
|
miner_mod.detect_hall("Fixed the python bug in the code")
|
||||||
|
assert miner_mod._HALL_KEYWORDS_CACHE is not None
|
||||||
|
|
||||||
|
# Save reference
|
||||||
|
cached_ref = miner_mod._HALL_KEYWORDS_CACHE
|
||||||
|
|
||||||
|
# Second call should use same cached object
|
||||||
|
miner_mod.detect_hall("I feel so happy today")
|
||||||
|
assert miner_mod._HALL_KEYWORDS_CACHE is cached_ref
|
||||||
|
|
||||||
|
|
||||||
|
class TestMineProjectWritesHalls:
|
||||||
|
"""Full mine pipeline must produce drawers with hall metadata."""
|
||||||
|
|
||||||
|
def test_mined_drawers_have_hall(self, tmp_dir):
|
||||||
|
from mempalace.palace import get_collection
|
||||||
|
from mempalace.miner import mine
|
||||||
|
|
||||||
|
palace_dir = os.path.join(tmp_dir, "palace")
|
||||||
|
os.makedirs(palace_dir)
|
||||||
|
project_dir = os.path.join(tmp_dir, "project")
|
||||||
|
os.makedirs(project_dir)
|
||||||
|
# Create config
|
||||||
|
config = {"wing": "test", "rooms": [{"name": "general", "description": "all"}]}
|
||||||
|
with open(os.path.join(project_dir, "mempalace.yaml"), "w") as f:
|
||||||
|
yaml.dump(config, f)
|
||||||
|
# Create test file with technical content
|
||||||
|
with open(os.path.join(project_dir, "code.py"), "w") as f:
|
||||||
|
f.write("def fix_bug():\n # Fixed python script error in handler\n pass\n")
|
||||||
|
|
||||||
|
mine(project_dir, palace_dir, wing_override="test", agent="test")
|
||||||
|
|
||||||
|
col = get_collection(palace_dir, create=False)
|
||||||
|
results = col.get(limit=10, include=["metadatas"])
|
||||||
|
for meta in results["metadatas"]:
|
||||||
|
assert "hall" in meta, f"Drawer missing hall metadata: {meta}"
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
test_readme_claims.py — TDD verification of every major README claim against actual code.
|
||||||
|
|
||||||
|
Each test verifies a specific claim made in README.md. If a test fails, either
|
||||||
|
the README is wrong or the code hasn't shipped the feature yet. Fix one or the
|
||||||
|
other until all tests pass — that's when the README matches reality.
|
||||||
|
|
||||||
|
Based on the audit at ~/Desktop/readme_audit.md (2026-04-13).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — locate repo root and parse README / source files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MEMPALACE_PKG = REPO_ROOT / "mempalace"
|
||||||
|
README_PATH = REPO_ROOT / "README.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _readme() -> str:
|
||||||
|
return _read(README_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_dict_keys() -> list:
|
||||||
|
"""Return the list of tool names registered in the TOOLS dict."""
|
||||||
|
# Import the module-level TOOLS dict. We can't just import mcp_server
|
||||||
|
# because it calls chromadb on import, so we parse the source instead.
|
||||||
|
src = _read(MEMPALACE_PKG / "mcp_server.py")
|
||||||
|
return re.findall(r'"(mempalace_\w+)":\s*\{', src)
|
||||||
|
|
||||||
|
|
||||||
|
def _readme_tool_table_names() -> list:
|
||||||
|
"""Return tool names listed in the README's MCP tool table."""
|
||||||
|
readme = _readme()
|
||||||
|
return re.findall(r"^\| `(mempalace_\w+)`", readme, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Tool count — README says 19, verify actual count
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolCount:
|
||||||
|
"""README claims '19 tools available through MCP' in multiple places."""
|
||||||
|
|
||||||
|
def test_readme_tool_count_matches_code(self):
|
||||||
|
"""Claim: README says 19 tools. Actual TOOLS dict may differ.
|
||||||
|
|
||||||
|
This test asserts the REAL tool count so the README can be updated.
|
||||||
|
If TOOLS has 25 entries, the README should say 25, not 19.
|
||||||
|
"""
|
||||||
|
actual_count = len(_tools_dict_keys())
|
||||||
|
readme = _readme()
|
||||||
|
# Find all "19 tools" claims in README
|
||||||
|
claimed_counts = re.findall(r"(\d+)\s+tools", readme)
|
||||||
|
for claimed in claimed_counts:
|
||||||
|
assert int(claimed) == actual_count, (
|
||||||
|
f"README claims {claimed} tools but TOOLS dict has {actual_count}. "
|
||||||
|
f"Update every occurrence of '{claimed} tools' to '{actual_count} tools'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Every tool listed in README actually exists in TOOLS dict
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadmeToolsExistInCode:
|
||||||
|
"""Every tool name in the README tool table must be a key in TOOLS."""
|
||||||
|
|
||||||
|
def test_every_readme_tool_exists_in_tools_dict(self):
|
||||||
|
"""Claim: README lists tools like mempalace_get_aaak_spec.
|
||||||
|
Each one must actually be registered in the TOOLS dict."""
|
||||||
|
code_tools = set(_tools_dict_keys())
|
||||||
|
readme_tools = _readme_tool_table_names()
|
||||||
|
assert len(readme_tools) > 0, "Could not parse any tools from README table"
|
||||||
|
|
||||||
|
missing = [t for t in readme_tools if t not in code_tools]
|
||||||
|
assert missing == [], (
|
||||||
|
f"README lists tools that don't exist in TOOLS dict: {missing}. "
|
||||||
|
f"Either add them to mcp_server.py or remove them from README."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. No tool in TOOLS dict is missing from README's tool table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoUnlistedTools:
|
||||||
|
"""Every tool in the TOOLS dict should be documented in the README."""
|
||||||
|
|
||||||
|
def test_no_undocumented_tools(self):
|
||||||
|
"""Claim: README's tool table is complete.
|
||||||
|
Any tool in TOOLS but not in README is undocumented."""
|
||||||
|
code_tools = set(_tools_dict_keys())
|
||||||
|
readme_tools = set(_readme_tool_table_names())
|
||||||
|
|
||||||
|
undocumented = sorted(code_tools - readme_tools)
|
||||||
|
assert undocumented == [], (
|
||||||
|
f"Tools in TOOLS dict but missing from README: {undocumented}. "
|
||||||
|
f"Add rows for these to the tool table in README.md."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Closets collection exists — palace.py has get_closets_collection()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosetsExist:
|
||||||
|
"""README describes closets as a core architectural feature."""
|
||||||
|
|
||||||
|
def test_get_closets_collection_exists(self):
|
||||||
|
"""Claim: closets are a shipped feature.
|
||||||
|
palace.py must export get_closets_collection()."""
|
||||||
|
src = _read(MEMPALACE_PKG / "palace.py")
|
||||||
|
assert "def get_closets_collection(" in src, (
|
||||||
|
"palace.py does not define get_closets_collection(). "
|
||||||
|
"Closets are described in README but the collection function is missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_closets_importable(self):
|
||||||
|
"""get_closets_collection should be importable from mempalace.palace."""
|
||||||
|
from mempalace.palace import get_closets_collection
|
||||||
|
|
||||||
|
assert callable(get_closets_collection)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Closet-first search exists in searcher.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosetFirstSearch:
|
||||||
|
"""README implies search goes through closets, not just direct drawer query."""
|
||||||
|
|
||||||
|
def test_closet_boost_search_exists(self):
|
||||||
|
"""Claim: search uses closets as a boost signal.
|
||||||
|
searcher.py must have CLOSET_RANK_BOOSTS and query closets_col."""
|
||||||
|
src = _read(MEMPALACE_PKG / "searcher.py")
|
||||||
|
assert "CLOSET_RANK_BOOSTS" in src, (
|
||||||
|
"searcher.py has no closet boost logic. "
|
||||||
|
"README describes closet-based search but searcher.py has no closet ranking."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_searcher_imports_closets(self):
|
||||||
|
"""searcher.py must import get_closets_collection to use closets."""
|
||||||
|
src = _read(MEMPALACE_PKG / "searcher.py")
|
||||||
|
assert "get_closets_collection" in src, (
|
||||||
|
"searcher.py does not reference get_closets_collection. "
|
||||||
|
"Closet-first search can't work without the closets collection."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. BM25 hybrid search functions exist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBM25HybridSearch:
|
||||||
|
"""README claims 'BM25 hybrid search'. Verify the functions exist."""
|
||||||
|
|
||||||
|
def test_bm25_in_searcher(self):
|
||||||
|
"""Claim: BM25 hybrid search is shipped.
|
||||||
|
searcher.py must have BM25 scoring or hybrid ranking logic."""
|
||||||
|
src = _read(MEMPALACE_PKG / "searcher.py")
|
||||||
|
has_bm25 = any(
|
||||||
|
term in src.lower()
|
||||||
|
for term in [
|
||||||
|
"bm25",
|
||||||
|
"_bm25_score",
|
||||||
|
"_hybrid_rank",
|
||||||
|
"hybrid_search",
|
||||||
|
"bm25_score",
|
||||||
|
"rank_bm25",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert has_bm25, (
|
||||||
|
"searcher.py has no BM25 or hybrid search function. "
|
||||||
|
"README claims BM25 hybrid search but it's not in the code."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Entity metadata extraction exists in miner.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntityMetadataExtraction:
|
||||||
|
"""README implies entity extraction populates drawer/closet metadata."""
|
||||||
|
|
||||||
|
def test_entity_extraction_in_palace_or_miner(self):
|
||||||
|
"""Claim: entity extraction is part of the mining pipeline.
|
||||||
|
Either miner.py or palace.py must extract entities."""
|
||||||
|
miner_src = _read(MEMPALACE_PKG / "miner.py")
|
||||||
|
palace_src = _read(MEMPALACE_PKG / "palace.py")
|
||||||
|
# Entity extraction can be in either file — palace.py has it for closets
|
||||||
|
has_entity_extraction = (
|
||||||
|
"entities" in palace_src and "_ENTITY_STOPLIST" in palace_src
|
||||||
|
) or "extract_entities" in miner_src
|
||||||
|
assert has_entity_extraction, (
|
||||||
|
"No entity extraction found in miner.py or palace.py. "
|
||||||
|
"README implies entities are extracted during mining."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. strip_noise function exists in normalize.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripNoise:
|
||||||
|
"""normalize.py should have strip_noise() for cleaning input text."""
|
||||||
|
|
||||||
|
def test_strip_noise_exists(self):
|
||||||
|
"""Claim: normalize.py has noise stripping.
|
||||||
|
Function strip_noise must exist."""
|
||||||
|
src = _read(MEMPALACE_PKG / "normalize.py")
|
||||||
|
assert "def strip_noise(" in src, (
|
||||||
|
"normalize.py does not define strip_noise(). "
|
||||||
|
"This function is referenced in the normalization pipeline."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strip_noise_importable(self):
|
||||||
|
"""strip_noise should be importable from mempalace.normalize."""
|
||||||
|
from mempalace.normalize import strip_noise
|
||||||
|
|
||||||
|
assert callable(strip_noise)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. diary_ingest.py module exists and is importable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiaryIngest:
|
||||||
|
"""README describes diary ingest (day-based). Module must exist."""
|
||||||
|
|
||||||
|
def test_diary_ingest_module_exists(self):
|
||||||
|
"""Claim: diary_ingest.py is a shipped module.
|
||||||
|
File must exist at mempalace/diary_ingest.py."""
|
||||||
|
path = MEMPALACE_PKG / "diary_ingest.py"
|
||||||
|
assert path.is_file(), (
|
||||||
|
"mempalace/diary_ingest.py does not exist. "
|
||||||
|
"README describes diary ingest but the module is missing (still in an unmerged PR?)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_diary_ingest_importable(self):
|
||||||
|
"""diary_ingest should be importable."""
|
||||||
|
try:
|
||||||
|
importlib.import_module("mempalace.diary_ingest")
|
||||||
|
except ImportError:
|
||||||
|
pytest.fail(
|
||||||
|
"mempalace.diary_ingest is not importable. Module must exist and import cleanly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. fact_checker.py module exists and is importable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFactChecker:
|
||||||
|
"""README has a 'Contradiction detection' section implying fact_checker.py."""
|
||||||
|
|
||||||
|
def test_fact_checker_module_exists(self):
|
||||||
|
"""Claim: contradiction detection is shipped.
|
||||||
|
fact_checker.py must exist at mempalace/fact_checker.py."""
|
||||||
|
path = MEMPALACE_PKG / "fact_checker.py"
|
||||||
|
assert path.is_file(), (
|
||||||
|
"mempalace/fact_checker.py does not exist. "
|
||||||
|
"README describes contradiction detection but the module is missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fact_checker_importable(self):
|
||||||
|
"""fact_checker should be importable."""
|
||||||
|
try:
|
||||||
|
importlib.import_module("mempalace.fact_checker")
|
||||||
|
except ImportError:
|
||||||
|
pytest.fail(
|
||||||
|
"mempalace.fact_checker is not importable. Module must exist and import cleanly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. Tunnel functions exist in palace_graph.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTunnelFunctions:
|
||||||
|
"""README describes tunnels — connections between wings."""
|
||||||
|
|
||||||
|
def test_find_tunnels_exists(self):
|
||||||
|
"""Claim: tunnels connect rooms across wings.
|
||||||
|
palace_graph.py must have find_tunnels()."""
|
||||||
|
src = _read(MEMPALACE_PKG / "palace_graph.py")
|
||||||
|
assert "def find_tunnels(" in src, (
|
||||||
|
"palace_graph.py has no find_tunnels() function. "
|
||||||
|
"README describes tunnels but the function is missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_traverse_exists(self):
|
||||||
|
"""Claim: you can walk the palace graph.
|
||||||
|
palace_graph.py must have traverse()."""
|
||||||
|
src = _read(MEMPALACE_PKG / "palace_graph.py")
|
||||||
|
assert "def traverse(" in src, "palace_graph.py has no traverse() function."
|
||||||
|
|
||||||
|
def test_graph_stats_exists(self):
|
||||||
|
"""palace_graph.py must have graph_stats()."""
|
||||||
|
src = _read(MEMPALACE_PKG / "palace_graph.py")
|
||||||
|
assert "def graph_stats(" in src, "palace_graph.py has no graph_stats() function."
|
||||||
|
|
||||||
|
def test_tunnel_functions_importable(self):
|
||||||
|
"""find_tunnels, traverse, graph_stats should be importable."""
|
||||||
|
from mempalace.palace_graph import find_tunnels, traverse, graph_stats
|
||||||
|
|
||||||
|
assert callable(find_tunnels)
|
||||||
|
assert callable(traverse)
|
||||||
|
assert callable(graph_stats)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. closet_llm.py module exists and is importable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosetLLM:
|
||||||
|
"""README describes LLM-based closet regeneration. Module must exist."""
|
||||||
|
|
||||||
|
def test_closet_llm_module_exists(self):
|
||||||
|
"""Claim: LLM-based closet regen is shipped.
|
||||||
|
closet_llm.py must exist at mempalace/closet_llm.py."""
|
||||||
|
path = MEMPALACE_PKG / "closet_llm.py"
|
||||||
|
assert path.is_file(), (
|
||||||
|
"mempalace/closet_llm.py does not exist. "
|
||||||
|
"README describes LLM closet regeneration but the module is missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_closet_llm_importable(self):
|
||||||
|
"""closet_llm should be importable."""
|
||||||
|
try:
|
||||||
|
importlib.import_module("mempalace.closet_llm")
|
||||||
|
except ImportError:
|
||||||
|
pytest.fail(
|
||||||
|
"mempalace.closet_llm is not importable. Module must exist and import cleanly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 13. mine_lock exists in palace.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMineLock:
|
||||||
|
"""Multi-agent file locking must be shipped (PR #784 was merged)."""
|
||||||
|
|
||||||
|
def test_mine_lock_exists(self):
|
||||||
|
"""Claim: multi-agent file locking is shipped.
|
||||||
|
palace.py must define mine_lock."""
|
||||||
|
src = _read(MEMPALACE_PKG / "palace.py")
|
||||||
|
assert "def mine_lock(" in src, (
|
||||||
|
"palace.py does not define mine_lock(). "
|
||||||
|
"Multi-agent locking is claimed as shipped but function is missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mine_lock_importable(self):
|
||||||
|
"""mine_lock should be importable from mempalace.palace."""
|
||||||
|
from mempalace.palace import mine_lock
|
||||||
|
|
||||||
|
assert callable(mine_lock)
|
||||||
|
|
||||||
|
def test_mine_lock_is_context_manager(self):
|
||||||
|
"""mine_lock should be a context manager (used with `with` statement)."""
|
||||||
|
src = _read(MEMPALACE_PKG / "palace.py")
|
||||||
|
# It should be decorated with @contextlib.contextmanager or similar
|
||||||
|
# Find the mine_lock definition and check for context manager pattern
|
||||||
|
assert "@contextlib.contextmanager" in src or "def __enter__" in src, (
|
||||||
|
"mine_lock does not appear to be a context manager. "
|
||||||
|
"It should be usable with `with mine_lock(path):` syntax."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 14. Version in version.py matches pyproject.toml
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionConsistency:
|
||||||
|
"""version.py and pyproject.toml must agree on the version string."""
|
||||||
|
|
||||||
|
def test_version_py_matches_pyproject(self):
|
||||||
|
"""Claim: single source of truth for version.
|
||||||
|
version.py __version__ must match pyproject.toml version."""
|
||||||
|
version_src = _read(MEMPALACE_PKG / "version.py")
|
||||||
|
version_match = re.search(r'__version__\s*=\s*"([^"]+)"', version_src)
|
||||||
|
assert version_match, "Could not parse __version__ from version.py"
|
||||||
|
code_version = version_match.group(1)
|
||||||
|
|
||||||
|
pyproject_src = _read(REPO_ROOT / "pyproject.toml")
|
||||||
|
pyproject_match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_src, re.MULTILINE)
|
||||||
|
assert pyproject_match, "Could not parse version from pyproject.toml"
|
||||||
|
toml_version = pyproject_match.group(1)
|
||||||
|
|
||||||
|
assert code_version == toml_version, (
|
||||||
|
f"version.py says {code_version} but pyproject.toml says {toml_version}. "
|
||||||
|
f"These must match."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 15. Version badge URL in README matches version.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionBadge:
|
||||||
|
"""README version badge must show the current version, not a stale one."""
|
||||||
|
|
||||||
|
def test_readme_badge_matches_version_py(self):
|
||||||
|
"""Claim: README badge shows current version.
|
||||||
|
The shields.io badge URL must contain the version from version.py."""
|
||||||
|
version_src = _read(MEMPALACE_PKG / "version.py")
|
||||||
|
version_match = re.search(r'__version__\s*=\s*"([^"]+)"', version_src)
|
||||||
|
assert version_match, "Could not parse __version__ from version.py"
|
||||||
|
code_version = version_match.group(1)
|
||||||
|
|
||||||
|
readme = _readme()
|
||||||
|
# Find the version badge URL
|
||||||
|
badge_match = re.search(r"shields\.io/badge/version-([^-]+)-", readme)
|
||||||
|
assert badge_match, "Could not find version badge URL in README"
|
||||||
|
badge_version = badge_match.group(1)
|
||||||
|
|
||||||
|
assert badge_version == code_version, (
|
||||||
|
f"README badge says {badge_version} but version.py says {code_version}. "
|
||||||
|
f"Update the badge URL in README.md."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 16. dialect.py docstring does NOT say "lossless"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDialectNotLossless:
|
||||||
|
"""The April 7 correction: AAAK is lossy, not lossless."""
|
||||||
|
|
||||||
|
def test_dialect_docstring_says_not_lossless(self):
|
||||||
|
"""Claim: dialect.py correctly says AAAK is NOT lossless.
|
||||||
|
The docstring must contain 'NOT lossless' or 'lossy'."""
|
||||||
|
src = _read(MEMPALACE_PKG / "dialect.py")
|
||||||
|
# Check the module docstring (first ~20 lines)
|
||||||
|
docstring_area = src[:1000]
|
||||||
|
assert "NOT lossless" in docstring_area or "lossy" in docstring_area.lower(), (
|
||||||
|
"dialect.py docstring does not disclaim losslessness. "
|
||||||
|
"After the April 7 correction, it must say AAAK is NOT lossless."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dialect_docstring_does_not_claim_lossless(self):
|
||||||
|
"""The docstring must not positively claim 'lossless compression'."""
|
||||||
|
src = _read(MEMPALACE_PKG / "dialect.py")
|
||||||
|
docstring_area = src[:1000]
|
||||||
|
# "NOT lossless" is OK; bare "lossless" without negation is not
|
||||||
|
# Remove the "NOT lossless" disclaimer before checking
|
||||||
|
cleaned = docstring_area.replace("NOT lossless", "")
|
||||||
|
assert "lossless" not in cleaned.lower(), (
|
||||||
|
"dialect.py docstring still claims 'lossless' somewhere. "
|
||||||
|
"AAAK is lossy — remove any positive lossless claims."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 17. README file reference table for dialect.py does NOT say "lossless"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadmeDialectNotLossless:
|
||||||
|
"""README's file reference table must not say dialect.py is lossless."""
|
||||||
|
|
||||||
|
def test_readme_dialect_line_not_lossless(self):
|
||||||
|
"""Claim: April 7 correction applied to README file table.
|
||||||
|
The dialect.py row must not say 'lossless'."""
|
||||||
|
readme = _readme()
|
||||||
|
# Find the line with dialect.py in the file reference table
|
||||||
|
dialect_lines = [
|
||||||
|
line for line in readme.splitlines() if "dialect.py" in line and "|" in line
|
||||||
|
]
|
||||||
|
assert len(dialect_lines) > 0, "Could not find dialect.py in README file table"
|
||||||
|
|
||||||
|
for line in dialect_lines:
|
||||||
|
assert "lossless" not in line.lower(), (
|
||||||
|
f"README file table still says dialect.py is lossless: {line.strip()!r}. "
|
||||||
|
f"After April 7 correction, this must say 'lossy' or remove the lossless claim."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 18. Hall keywords in config.py — verify miners actually WRITE hall metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHallMetadata:
|
||||||
|
"""README describes 5 hall types. Miners must actually write hall metadata."""
|
||||||
|
|
||||||
|
def test_hall_keywords_defined_in_config(self):
|
||||||
|
"""Prerequisite: DEFAULT_HALL_KEYWORDS must exist in config.py."""
|
||||||
|
src = _read(MEMPALACE_PKG / "config.py")
|
||||||
|
assert "DEFAULT_HALL_KEYWORDS" in src, (
|
||||||
|
"config.py does not define DEFAULT_HALL_KEYWORDS. "
|
||||||
|
"Hall types are described in README but not defined in config."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_miners_write_hall_metadata(self):
|
||||||
|
"""Claim: halls are populated. At least one miner must write a 'hall'
|
||||||
|
field into drawer metadata.
|
||||||
|
|
||||||
|
If no miner writes hall metadata, the halls described in README are
|
||||||
|
a schema ghost — defined but never populated."""
|
||||||
|
miner_src = _read(MEMPALACE_PKG / "miner.py")
|
||||||
|
convo_miner_src = _read(MEMPALACE_PKG / "convo_miner.py")
|
||||||
|
|
||||||
|
# Check if either miner references 'hall' in the metadata it writes
|
||||||
|
writes_hall = (
|
||||||
|
'"hall"' in miner_src
|
||||||
|
or "'hall'" in miner_src
|
||||||
|
or '"hall"' in convo_miner_src
|
||||||
|
or "'hall'" in convo_miner_src
|
||||||
|
)
|
||||||
|
assert writes_hall, (
|
||||||
|
"Neither miner.py nor convo_miner.py writes a 'hall' field to drawer metadata. "
|
||||||
|
"README describes 5 hall types (hall_facts, hall_events, hall_discoveries, "
|
||||||
|
"hall_preferences, hall_advice) but no mining code populates them. "
|
||||||
|
"Halls are a schema ghost — defined in config, read by palace_graph, "
|
||||||
|
"but never written by any pipeline."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_readme_hall_types_match_config(self):
|
||||||
|
"""If README lists specific hall names, they should appear in config."""
|
||||||
|
# README mentions these 5 halls
|
||||||
|
readme_halls = [
|
||||||
|
"hall_facts",
|
||||||
|
"hall_events",
|
||||||
|
"hall_discoveries",
|
||||||
|
"hall_preferences",
|
||||||
|
"hall_advice",
|
||||||
|
]
|
||||||
|
for hall in readme_halls:
|
||||||
|
# These should either be in config or README should not list them
|
||||||
|
# The hall_ prefix is a README convention; config uses keyword groups
|
||||||
|
# like "emotions", "consciousness" etc. Check if they're consistent.
|
||||||
|
pass # This is a documentation check; the real test is #18b above
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 19. Backend abstraction exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendAbstraction:
|
||||||
|
"""Backend seam for pluggable storage backends."""
|
||||||
|
|
||||||
|
def test_backends_base_exists(self):
|
||||||
|
"""Claim: pluggable backends.
|
||||||
|
backends/base.py must define an abstract base class."""
|
||||||
|
path = MEMPALACE_PKG / "backends" / "base.py"
|
||||||
|
assert (
|
||||||
|
path.is_file()
|
||||||
|
), "mempalace/backends/base.py does not exist. Backend abstraction layer is missing."
|
||||||
|
src = _read(path)
|
||||||
|
assert (
|
||||||
|
"ABC" in src or "abstractmethod" in src
|
||||||
|
), "backends/base.py does not define an abstract base class."
|
||||||
|
|
||||||
|
def test_backends_chroma_exists(self):
|
||||||
|
"""Claim: ChromaDB backend implementation.
|
||||||
|
backends/chroma.py must exist and subclass the base."""
|
||||||
|
path = MEMPALACE_PKG / "backends" / "chroma.py"
|
||||||
|
assert path.is_file(), "mempalace/backends/chroma.py does not exist."
|
||||||
|
src = _read(path)
|
||||||
|
assert (
|
||||||
|
"BaseCollection" in src or "base" in src
|
||||||
|
), "backends/chroma.py does not reference the base class."
|
||||||
|
|
||||||
|
def test_backends_importable(self):
|
||||||
|
"""Both backend modules should be importable."""
|
||||||
|
from mempalace.backends.base import BaseCollection
|
||||||
|
from mempalace.backends.chroma import ChromaBackend
|
||||||
|
|
||||||
|
assert BaseCollection is not None
|
||||||
|
assert ChromaBackend is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 20. i18n module exists with at least 8 language files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestI18n:
|
||||||
|
"""i18n support — 8 languages."""
|
||||||
|
|
||||||
|
def test_i18n_directory_exists(self):
|
||||||
|
"""i18n directory must exist."""
|
||||||
|
path = MEMPALACE_PKG / "i18n"
|
||||||
|
assert path.is_dir(), "mempalace/i18n/ directory does not exist."
|
||||||
|
|
||||||
|
def test_at_least_8_language_files(self):
|
||||||
|
"""Claim: 8 languages supported.
|
||||||
|
i18n/ must contain at least 8 .json language files."""
|
||||||
|
path = MEMPALACE_PKG / "i18n"
|
||||||
|
json_files = list(path.glob("*.json"))
|
||||||
|
assert len(json_files) >= 8, (
|
||||||
|
f"i18n/ has only {len(json_files)} language files, expected >= 8. "
|
||||||
|
f"Files found: {[f.name for f in json_files]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_english_baseline_exists(self):
|
||||||
|
"""en.json must exist as the baseline language file."""
|
||||||
|
path = MEMPALACE_PKG / "i18n" / "en.json"
|
||||||
|
assert (
|
||||||
|
path.is_file()
|
||||||
|
), "mempalace/i18n/en.json does not exist. English baseline is required."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 21. Wake-up token cost — check layers.py vs README's "~170 tokens"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWakeUpTokenCost:
|
||||||
|
"""README claims '~170 tokens' for wake-up. layers.py says otherwise."""
|
||||||
|
|
||||||
|
def test_readme_wakeup_cost_matches_layers(self):
|
||||||
|
"""Claim: README says ~170 tokens for wake-up.
|
||||||
|
layers.py docstring says L0 ~100 tokens, L1 ~500-800 tokens.
|
||||||
|
Total = 600-900, not 170.
|
||||||
|
|
||||||
|
If the README means '170 tokens of critical facts' (just the AAAK
|
||||||
|
portion), it should say so clearly. If it means total wake-up cost,
|
||||||
|
it must match layers.py."""
|
||||||
|
readme = _readme()
|
||||||
|
layers_src = _read(MEMPALACE_PKG / "layers.py")
|
||||||
|
|
||||||
|
# What layers.py says
|
||||||
|
assert "~600-900 tokens" in layers_src or "600-900" in layers_src, (
|
||||||
|
"layers.py docstring does not mention 600-900 tokens. "
|
||||||
|
"Check if the wake-up cost documentation has changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
# What README says
|
||||||
|
readme_170_claims = re.findall(r"~?170 tokens", readme)
|
||||||
|
|
||||||
|
if readme_170_claims:
|
||||||
|
# README claims 170 tokens but layers.py says 600-900.
|
||||||
|
# This test enforces that README must match the code.
|
||||||
|
# Either README should say 600-900 or layers.py should say 170.
|
||||||
|
# Since we trust code over docs, the README is wrong.
|
||||||
|
pytest.fail(
|
||||||
|
f"README claims '~170 tokens' for wake-up ({len(readme_170_claims)} occurrences) "
|
||||||
|
f"but layers.py says L0+L1 = ~600-900 tokens. "
|
||||||
|
f"Either update README to match layers.py, or clarify that '170 tokens' "
|
||||||
|
f"refers to a specific subset (e.g., AAAK-compressed facts only)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bonus: pyproject.toml version in README project structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadmeProjectStructureVersion:
|
||||||
|
"""README's project structure section says pyproject.toml version."""
|
||||||
|
|
||||||
|
def test_readme_pyproject_version_claim(self):
|
||||||
|
"""Claim: README says 'pyproject.toml — package config (v3.0.0)' or similar.
|
||||||
|
Must match actual pyproject.toml version."""
|
||||||
|
readme = _readme()
|
||||||
|
pyproject_src = _read(REPO_ROOT / "pyproject.toml")
|
||||||
|
pyproject_match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_src, re.MULTILINE)
|
||||||
|
assert pyproject_match, "Could not parse version from pyproject.toml"
|
||||||
|
actual_version = pyproject_match.group(1)
|
||||||
|
|
||||||
|
# Find any version claim near pyproject.toml in README
|
||||||
|
version_in_readme = re.search(r"pyproject\.toml.*?v?([\d]+\.[\d]+\.[\d]+)", readme)
|
||||||
|
if version_in_readme:
|
||||||
|
readme_version = version_in_readme.group(1)
|
||||||
|
assert readme_version == actual_version, (
|
||||||
|
f"README says pyproject.toml is v{readme_version} "
|
||||||
|
f"but actual version is {actual_version}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bonus: README tool count consistency (all mentions must agree)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadmeToolCountConsistency:
|
||||||
|
"""README mentions tool count in multiple places — they must all agree."""
|
||||||
|
|
||||||
|
def test_all_tool_count_mentions_consistent(self):
|
||||||
|
"""Every place README says 'N tools' must use the same number."""
|
||||||
|
readme = _readme()
|
||||||
|
counts = re.findall(r"(\d+)\s+tools", readme)
|
||||||
|
if len(counts) > 1:
|
||||||
|
unique = set(counts)
|
||||||
|
assert (
|
||||||
|
len(unique) == 1
|
||||||
|
), f"README mentions different tool counts: {counts}. All occurrences must agree."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bonus: get_aaak_spec tool handler exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAAAKSpecToolHandler:
|
||||||
|
"""If mempalace_get_aaak_spec is in TOOLS, its handler must exist."""
|
||||||
|
|
||||||
|
def test_aaak_spec_handler_exists(self):
|
||||||
|
"""The handler function for get_aaak_spec must be defined."""
|
||||||
|
src = _read(MEMPALACE_PKG / "mcp_server.py")
|
||||||
|
tools = _tools_dict_keys()
|
||||||
|
if "mempalace_get_aaak_spec" in tools:
|
||||||
|
assert "def tool_get_aaak_spec(" in src, (
|
||||||
|
"mempalace_get_aaak_spec is in TOOLS dict but "
|
||||||
|
"tool_get_aaak_spec() handler function is not defined."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user