feat(memory): FPLAN-0270 Phase 3 — changed_entries diff helper + write_memory_file enforcement (warn-only, rollover-safe)

changed_entries(before,after,limits): pure diff that flags only NEW/CHANGED
over-limit entries, ignoring unchanged legacy fat — so rollover (trims by count,
writes back recent fat entries) is never rejected. Wired into write_memory_file
via _validate_entry_limits (gates only .trinity/{local,observations}.json): warn
mode logs+writes, enforce mode rejects new/changed over-limit only. Validation
wrapped in try/except → a validator bug can never abort a write. +15 tests (917
total), seedgo 100%, enforce stays false. Verified by artifact incl. live proof
of rollover-safety + the defensive guarantee. @memory side (P1-3) complete. The
changed_entries() helper is what @hooks imports next. Part of DPLAN-0205.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-13 01:55:58 -07:00
co-authored by Claude Opus 4.8
parent 828cc1c8d8
commit 7064375589
4 changed files with 747 additions and 6 deletions
+15
View File
@@ -660,6 +660,21 @@
"file": "tests/test_entry_limits.py",
"standard": "meta",
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
},
{
"file": "tests/test_changed_entries.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by design, not in 3-layer apps/ structure."
},
{
"file": "tests/test_changed_entries.py",
"standard": "documentation",
"reason": "Test file — test functions don't require docstrings."
},
{
"file": "tests/test_changed_entries.py",
"standard": "meta",
"reason": "Test file — META block present at lines 1-7; hook false-positive on test file format."
}
],
"notes": {
@@ -1,13 +1,13 @@
# =================== AIPass ====================
# Name: entry_limits.py
# Description: Entry limits config reader and validator for memory files
# Version: 1.1.0
# Description: Entry limits config reader, validator, and diff helper for memory files
# Version: 1.2.0
# Created: 2026-06-13
# Modified: 2026-06-13
# =============================================
"""
Entry Limits Config Reader & Validator
Entry Limits Config Reader, Validator & Diff Helper
Reads the entry_limits section from memory.config.json and returns
the effective limits for a given branch, with per_branch overrides
@@ -16,12 +16,22 @@ deep-merged over the default entry_types.
Provides ``check_entry()`` — a pure validator that checks whether a
single entry text exceeds its character cap.
Provides ``changed_entries()`` — a pure diff helper that compares
before/after file dicts and returns only NEW or CHANGED entries that
exceed their character cap. Unchanged legacy over-limit entries pass
untouched (rollover-safe).
Usage:
from aipass.memory.apps.handlers.json.entry_limits import load_entry_limits, check_entry
from aipass.memory.apps.handlers.json.entry_limits import (
load_entry_limits, check_entry, changed_entries,
)
limits = load_entry_limits("devpulse")
verdict = check_entry("key_learnings", some_text, limits)
# => {"ok": True/False, "length": int, "cap": int, "over_by": int, "entry_type": str}
violations = changed_entries(before_dict, after_dict, limits)
# => [{"entry_type", "container", "key", "length", "cap", "over_by"}, ...]
"""
import copy
@@ -243,3 +253,177 @@ def check_entry(entry_type: str, text: str, limits: dict[str, Any]) -> dict[str,
"over_by": over_by,
"entry_type": entry_type,
}
# ---------------------------------------------------------------------------
# Phase 3: changed-entries diff helper (rollover-safe)
# ---------------------------------------------------------------------------
def _extract_text(value: Any, field: str) -> str:
"""Extract the text payload from a container entry.
For dict containers the value may be a plain string or a dict
with a *field* key (e.g. ``{"value": "some text", ...}``).
For list containers the entry is always a dict with a *field* key.
Args:
value: The entry value (string or dict).
field: The field name to extract from a dict value.
Returns:
The text string, or ``""`` if extraction fails.
"""
if isinstance(value, str):
return value
if isinstance(value, dict):
text = value.get(field, "")
return text if isinstance(text, str) else ""
return ""
def _check_dict_container(
type_name: str,
container: str,
field: str,
before_container: Any,
after_container: Any,
limits: dict[str, Any],
) -> list[dict[str, Any]]:
"""Check dict-shaped container for new/changed over-limit entries.
Args:
type_name: Entry type name (e.g. ``"key_learnings"``).
container: Container key in the file dict.
field: Field to extract text from dict-valued entries.
before_container: The container value from the on-disk file.
after_container: The container value from the proposed file.
limits: The dict returned by :func:`load_entry_limits`.
Returns:
List of violation dicts for new/changed entries that exceed cap.
"""
if not isinstance(after_container, dict):
return []
before_dict = before_container if isinstance(before_container, dict) else {}
hits: list[dict[str, Any]] = []
for key, after_value in after_container.items():
after_text = _extract_text(after_value, field)
if key in before_dict and after_text == _extract_text(before_dict[key], field):
continue # Unchanged — skip even if over-limit
verdict = check_entry(type_name, after_text, limits)
if not verdict["ok"]:
hits.append(
{
"entry_type": type_name,
"container": container,
"key": key,
"length": verdict["length"],
"cap": verdict["cap"],
"over_by": verdict["over_by"],
}
)
return hits
def _check_list_container(
type_name: str,
container: str,
field: str,
before_container: Any,
after_container: Any,
limits: dict[str, Any],
) -> list[dict[str, Any]]:
"""Check list-shaped container for new/changed over-limit entries.
Args:
type_name: Entry type name (e.g. ``"sessions"``).
container: Container key in the file dict.
field: Field to extract text from list-item dicts.
before_container: The container value from the on-disk file.
after_container: The container value from the proposed file.
limits: The dict returned by :func:`load_entry_limits`.
Returns:
List of violation dicts for new/changed entries that exceed cap.
"""
if not isinstance(after_container, list):
return []
before_list = before_container if isinstance(before_container, list) else []
hits: list[dict[str, Any]] = []
for idx, after_item in enumerate(after_container):
after_text = _extract_text(after_item, field)
if idx < len(before_list) and after_text == _extract_text(before_list[idx], field):
continue # Unchanged — skip even if over-limit
verdict = check_entry(type_name, after_text, limits)
if not verdict["ok"]:
hits.append(
{
"entry_type": type_name,
"container": container,
"key": str(idx),
"length": verdict["length"],
"cap": verdict["cap"],
"over_by": verdict["over_by"],
}
)
return hits
def changed_entries(
before: dict[str, Any],
after: dict[str, Any],
limits: dict[str, Any],
) -> list[dict[str, Any]]:
"""Return over-limit entries that are NEW or CHANGED between *before* and *after*.
This is a **pure function** — no I/O, no file reads, no side effects.
Unchanged entries (even if over-limit) are intentionally skipped so
that rollover and other maintenance writes are never blocked by
legacy fat entries.
Args:
before: Parsed .trinity file dict (current on-disk content).
after: Parsed .trinity file dict (proposed new content).
limits: The dict returned by :func:`load_entry_limits`.
Returns:
List of violation dicts, each containing::
{
"entry_type": str, # e.g. "key_learnings"
"container": str, # e.g. "key_learnings"
"key": str, # dict key or list index (as str)
"length": int, # len(text)
"cap": int, # max_chars
"over_by": int, # length - cap
}
Empty list when everything is within limits or unchanged.
"""
entry_types = limits.get("entry_types", {})
violations: list[dict[str, Any]] = []
for type_name, type_def in entry_types.items():
container = type_def.get("container", "")
kind = type_def.get("kind", "dict")
field = type_def.get("field", "value")
after_container = after.get(container)
if after_container is None:
continue
before_container = before.get(container)
if kind == "dict":
violations.extend(
_check_dict_container(type_name, container, field, before_container, after_container, limits)
)
elif kind == "list":
violations.extend(
_check_list_container(type_name, container, field, before_container, after_container, limits)
)
return violations
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: memory_files.py
# Description: Memory File Safe I/O Handler
# Version: 1.0.0
# Version: 1.1.0
# Created: 2026-03-17
# Modified: 2026-03-17
# Modified: 2026-06-13
# =============================================
"""
@@ -34,6 +34,7 @@ from typing import Dict, Any, Optional
from aipass.prax.apps.modules.logger import get_system_logger
from aipass.memory.apps.handlers.json import json_handler
from aipass.memory.apps.handlers.json.entry_limits import load_entry_limits, changed_entries
logger = get_system_logger()
@@ -45,6 +46,93 @@ _TEMPLATES_DIR = _MEMORY_ROOT / "apps" / "json_templates"
# No service imports - handlers are pure workers (3-tier architecture)
# No module imports (handler independence)
# Files tracked by entry-limits validation
_TRACKED_TRINITY_FILES = {"local.json", "observations.json"}
# =============================================================================
# ENTRY-LIMITS VALIDATION (rollover-safe)
# =============================================================================
def _validate_entry_limits(
file_path: Path,
data: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Check entry limits for a .trinity/ write and return a rejection or None.
Only validates files inside a ``.trinity/`` directory whose name is
in :data:`_TRACKED_TRINITY_FILES`. For all other paths this function
returns ``None`` immediately (no validation).
Unchanged entries (same text as on disk) are intentionally skipped so
that rollover and other maintenance writes are never blocked by
legacy over-limit entries.
Args:
file_path: Target path for the write.
data: The dict about to be written.
Returns:
``None`` when the write should proceed normally.
A ``{"success": False, "error": ...}`` dict when enforce mode is
on and new/changed entries exceed their caps.
"""
# --- Gate: only tracked .trinity files ------------------------------------
if file_path.parent.name != ".trinity":
return None
if file_path.name not in _TRACKED_TRINITY_FILES:
return None
branch = file_path.parent.parent.name
limits = load_entry_limits(branch)
if not limits.get("enabled", True):
return None
# Filter limits to entry_types that belong to THIS file
filtered_types = {
name: tdef for name, tdef in limits.get("entry_types", {}).items() if tdef.get("file") == file_path.name
}
if not filtered_types:
return None
filtered_limits = {
"enabled": limits["enabled"],
"enforce": limits["enforce"],
"entry_types": filtered_types,
}
# Read current on-disk content (before)
before: Dict[str, Any] = {}
if file_path.exists():
try:
before = json.loads(file_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning(f"[entry_limits] Could not parse {file_path.name} for diff: {exc}")
before = {} # Unparseable — treat as empty (all entries "new")
over = changed_entries(before, data, filtered_limits)
if not over:
return None
# --- Violations found -----------------------------------------------------
enforce = limits.get("enforce", False)
if not enforce:
for violation in over:
logger.warning(
f"[entry_limits] WARN {branch} {file_path.name} "
f"{violation['container']}[{violation['key']}] "
f"{violation['length']}/{violation['cap']} "
f"(+{violation['over_by']} over)"
)
return None # Write through in warn mode
# Enforce mode — block the write
details = "; ".join(f"{v['container']}[{v['key']}] {v['length']}/{v['cap']} (+{v['over_by']} over)" for v in over)
return {"success": False, "error": f"Entry limit exceeded: {details}"}
# =============================================================================
# CORE READ/WRITE OPERATIONS
@@ -120,6 +208,16 @@ def write_memory_file(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(data, dict):
return {"success": False, "error": f"Data must be dict, got {type(data).__name__}"}
# --- Entry-limits validation (rollover-safe) ------------------------------
# Only validate .trinity/ files that are tracked (local.json, observations.json).
# Unchanged legacy over-limit entries pass untouched so rollover is never blocked.
try:
rejection = _validate_entry_limits(file_path, data)
if rejection is not None:
return rejection
except Exception as exc:
logger.warning(f"[memory_files] Entry-limits validation error (writing anyway): {exc}")
try:
# Create temp file in same directory (for atomic rename)
temp_fd, temp_path = tempfile.mkstemp(dir=file_path.parent, prefix=f".{file_path.name}.", suffix=".tmp")
@@ -0,0 +1,444 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: tests/test_changed_entries.py
# Date: 2026-06-13
# Version: 1.0.0
# Category: memory/tests
# =============================================
"""
Tests for Phase 3 of FPLAN-0270: changed_entries diff helper and
write_memory_file entry-limits wiring.
Covers:
- changed_entries: new over-limit, changed over-limit, unchanged legacy
fat entries (rollover-safe), shrinking, dict/list containers, empty before.
- write_memory_file wiring: warn mode writes through + logs, enforce mode
rejects new fat entries, enforce mode allows unchanged legacy fat entries,
non-trinity files unaffected, passport.json unaffected.
"""
import importlib
import json
import sys
from pathlib import Path
from typing import Any
import pytest
# ---------------------------------------------------------------------------
# Per-test fixture: fresh-import modules with mocks in place
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _fresh_modules(monkeypatch):
"""Drop cached modules so each test gets fresh imports."""
sys.modules.pop("aipass.memory.apps.handlers.json", None)
sys.modules.pop("aipass.memory.apps.handlers.json.json_handler", None)
sys.modules.pop("aipass.memory.apps.handlers.json.entry_limits", None)
sys.modules.pop("aipass.memory.apps.handlers.json.memory_files", None)
sys.modules.pop("aipass.memory.apps.handlers.json.lint_handler", None)
yield
def _get_entry_limits():
"""Import and return the entry_limits module."""
return importlib.import_module("aipass.memory.apps.handlers.json.entry_limits")
def _get_memory_files():
"""Import and return the memory_files module."""
return importlib.import_module("aipass.memory.apps.handlers.json.memory_files")
# ---------------------------------------------------------------------------
# Helpers: build limits dicts for testing
# ---------------------------------------------------------------------------
_KEY_LEARNINGS_ONLY: dict[str, Any] = {
"enabled": True,
"enforce": False,
"entry_types": {
"key_learnings": {
"file": "local.json",
"container": "key_learnings",
"kind": "dict",
"field": "value",
"max_chars": 200,
},
},
}
_SESSIONS_ONLY: dict[str, Any] = {
"enabled": True,
"enforce": False,
"entry_types": {
"sessions": {
"file": "local.json",
"container": "sessions",
"kind": "list",
"field": "summary",
"max_chars": 300,
},
},
}
def _full_limits(**overrides: Any) -> dict[str, Any]:
"""Return a complete limits dict with all four default entry types."""
base: dict[str, Any] = {
"enabled": True,
"enforce": False,
"entry_types": {
"key_learnings": {
"file": "local.json",
"container": "key_learnings",
"kind": "dict",
"field": "value",
"max_chars": 200,
},
"sessions": {
"file": "local.json",
"container": "sessions",
"kind": "list",
"field": "summary",
"max_chars": 300,
},
"todos": {
"file": "local.json",
"container": "todos",
"kind": "list",
"field": "task",
"max_chars": 200,
},
"observations": {
"file": "observations.json",
"container": "observations",
"kind": "list",
"field": "note",
"max_chars": 600,
},
},
}
base.update(overrides)
return base
# ===========================================================================
# 1. changed_entries: new over-limit entry detected
# ===========================================================================
class TestNewOverLimitEntry:
"""A new dict entry that exceeds the cap is returned as a violation."""
def test_new_overlimit_key_learning(self) -> None:
mod = _get_entry_limits()
before = {"key_learnings": {"a": "short", "b": "also short"}}
fat_text = "x" * 250
after = {"key_learnings": {"a": "short", "b": "also short", "c": fat_text}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert len(result) == 1
assert result[0]["entry_type"] == "key_learnings"
assert result[0]["key"] == "c"
assert result[0]["length"] == 250
assert result[0]["cap"] == 200
assert result[0]["over_by"] == 50
# ===========================================================================
# 2. changed_entries: changed entry exceeds cap
# ===========================================================================
class TestChangedEntryOverCap:
"""An existing entry whose text grew past the cap is flagged."""
def test_changed_key_learning_over_cap(self) -> None:
mod = _get_entry_limits()
before = {"key_learnings": {"a": "short text"}}
after = {"key_learnings": {"a": "y" * 300}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert len(result) == 1
assert result[0]["key"] == "a"
assert result[0]["over_by"] == 100
# ===========================================================================
# 3. changed_entries: UNCHANGED legacy over-limit entry NOT returned
# ===========================================================================
class TestUnchangedLegacyFatEntry:
"""THE KEY TEST: unchanged fat entries must NOT be flagged (rollover-safe)."""
def test_unchanged_500char_key_learning_not_flagged(self) -> None:
mod = _get_entry_limits()
fat_text = "z" * 500
before = {"key_learnings": {"legacy": fat_text}}
after = {"key_learnings": {"legacy": fat_text}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert result == []
# ===========================================================================
# 4. changed_entries: shrinking an entry is not flagged
# ===========================================================================
class TestShrinkingEntry:
"""An entry that went from 500 chars to 100 is not flagged."""
def test_shrunk_entry_not_flagged(self) -> None:
mod = _get_entry_limits()
before = {"key_learnings": {"item": "z" * 500}}
after = {"key_learnings": {"item": "z" * 100}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert result == []
# ===========================================================================
# 5. changed_entries: dict container — value-as-string and value-as-dict
# ===========================================================================
class TestDictContainerShapes:
"""Both plain-string and dict-with-field value shapes are handled."""
def test_value_as_string(self) -> None:
mod = _get_entry_limits()
before: dict[str, Any] = {"key_learnings": {}}
after = {"key_learnings": {"new_key": "x" * 250}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert len(result) == 1
assert result[0]["length"] == 250
def test_value_as_dict_with_field(self) -> None:
mod = _get_entry_limits()
before: dict[str, Any] = {"key_learnings": {}}
after = {"key_learnings": {"new_key": {"value": "x" * 250, "source": "test"}}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert len(result) == 1
assert result[0]["length"] == 250
# ===========================================================================
# 6. changed_entries: list container — appended and unchanged
# ===========================================================================
class TestListContainer:
"""List containers detect new appended items and skip unchanged ones."""
def test_appended_item_over_cap_detected(self) -> None:
mod = _get_entry_limits()
existing = {"session_number": 1, "summary": "short"}
new_fat = {"session_number": 2, "summary": "s" * 400}
before = {"sessions": [existing]}
after = {"sessions": [existing, new_fat]}
result = mod.changed_entries(before, after, _SESSIONS_ONLY)
assert len(result) == 1
assert result[0]["key"] == "1"
assert result[0]["over_by"] == 100
def test_existing_unchanged_items_not_flagged(self) -> None:
mod = _get_entry_limits()
fat_item = {"session_number": 1, "summary": "s" * 400}
before = {"sessions": [fat_item]}
after = {"sessions": [fat_item]}
result = mod.changed_entries(before, after, _SESSIONS_ONLY)
assert result == []
# ===========================================================================
# 7. changed_entries: empty before (new file) — all entries treated as new
# ===========================================================================
class TestEmptyBefore:
"""When before is empty, all after entries are treated as new."""
def test_all_over_limit_entries_flagged(self) -> None:
mod = _get_entry_limits()
before: dict[str, Any] = {}
after = {"key_learnings": {"a": "x" * 250, "b": "ok"}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert len(result) == 1
assert result[0]["key"] == "a"
def test_within_limit_entries_not_flagged(self) -> None:
mod = _get_entry_limits()
before: dict[str, Any] = {}
after = {"key_learnings": {"a": "short", "b": "also short"}}
result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY)
assert result == []
# ===========================================================================
# 8. write_memory_file: warn mode writes through + logs warning
# ===========================================================================
class TestWarnModeWritesThrough:
"""In warn mode (enforce=False), over-limit entries log a warning but file is written."""
def test_warn_mode_writes_and_logs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mem_mod = _get_memory_files()
mock_logger = mem_mod.logger
# Build .trinity/local.json path
trinity = tmp_path / "test_branch" / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
before_data = {"key_learnings": {"existing": "short"}}
local_path.write_text(json.dumps(before_data, indent=2), encoding="utf-8")
fat_text = "x" * 300
after_data = {"key_learnings": {"existing": "short", "new_fat": fat_text}}
warn_limits = _full_limits(enforce=False)
monkeypatch.setattr(mem_mod, "load_entry_limits", lambda branch: warn_limits)
result = mem_mod.write_memory_file(local_path, after_data)
assert result["success"] is True
written = json.loads(local_path.read_text(encoding="utf-8"))
assert written["key_learnings"]["new_fat"] == fat_text
mock_logger.warning.assert_called()
warning_calls = [str(c) for c in mock_logger.warning.call_args_list]
assert any("entry_limits" in w for w in warning_calls)
# ===========================================================================
# 9. write_memory_file: enforce mode rejects new over-limit entry
# ===========================================================================
class TestEnforceModeRejects:
"""In enforce mode, a new over-limit entry is rejected and file is unchanged."""
def test_enforce_rejects_new_fat_entry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mem_mod = _get_memory_files()
trinity = tmp_path / "test_branch" / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
before_data = {"key_learnings": {"existing": "short"}}
local_path.write_text(json.dumps(before_data, indent=2), encoding="utf-8")
fat_text = "x" * 300
after_data = {"key_learnings": {"existing": "short", "new_fat": fat_text}}
enforce_limits = _full_limits(enforce=True)
monkeypatch.setattr(mem_mod, "load_entry_limits", lambda branch: enforce_limits)
result = mem_mod.write_memory_file(local_path, after_data)
assert result["success"] is False
assert "Entry limit exceeded" in result["error"]
# File on disk is UNCHANGED
on_disk = json.loads(local_path.read_text(encoding="utf-8"))
assert "new_fat" not in on_disk["key_learnings"]
# ===========================================================================
# 10. write_memory_file: enforce mode ALLOWS unchanged legacy fat entries
# ===========================================================================
class TestEnforceAllowsUnchangedLegacy:
"""THE CRITICAL ROLLOVER-SAFE TEST: enforce mode allows writing back same fat data."""
def test_enforce_allows_same_data_with_fat_entries(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mem_mod = _get_memory_files()
trinity = tmp_path / "test_branch" / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
fat_data = {"key_learnings": {"legacy": "z" * 500, "also_fat": "y" * 400}}
local_path.write_text(json.dumps(fat_data, indent=2), encoding="utf-8")
enforce_limits = _full_limits(enforce=True)
monkeypatch.setattr(mem_mod, "load_entry_limits", lambda branch: enforce_limits)
result = mem_mod.write_memory_file(local_path, fat_data)
assert result["success"] is True
on_disk = json.loads(local_path.read_text(encoding="utf-8"))
assert on_disk["key_learnings"]["legacy"] == "z" * 500
# ===========================================================================
# 11. write_memory_file: non-trinity file unaffected
# ===========================================================================
class TestNonTrinityFileUnaffected:
"""Files outside .trinity/ bypass validation entirely."""
def test_writes_normally_outside_trinity(self, tmp_path: Path) -> None:
mem_mod = _get_memory_files()
output_path = tmp_path / "some_output.json"
data = {"key": "value"}
result = mem_mod.write_memory_file(output_path, data)
assert result["success"] is True
assert output_path.exists()
written = json.loads(output_path.read_text(encoding="utf-8"))
assert written == data
# ===========================================================================
# 12. write_memory_file: passport.json unaffected
# ===========================================================================
class TestPassportUnaffected:
"""Writes to .trinity/passport.json bypass validation."""
def test_passport_writes_normally(self, tmp_path: Path) -> None:
mem_mod = _get_memory_files()
trinity = tmp_path / "test_branch" / ".trinity"
trinity.mkdir(parents=True)
passport_path = trinity / "passport.json"
data = {"branch_info": {"branch_name": "test_branch"}, "identity": {"role": "test"}}
result = mem_mod.write_memory_file(passport_path, data)
assert result["success"] is True
assert passport_path.exists()
written = json.loads(passport_path.read_text(encoding="utf-8"))
assert written == data