import json from datetime import datetime, timezone from pathlib import Path from phase_memory.contracts import graph_from_markitect from phase_memory.lifecycle import ( LifecycleRuleConfig, plan_compaction, plan_lifecycle_from_profile, plan_phase_transition, plan_refresh, plan_retention, ) from phase_memory.models import LifecycleActionKind, LifecycleState, MemoryGraph, MemoryNode, MemoryPhase FIXTURES = Path(__file__).parent / "fixtures" def _load(name: str): return json.loads((FIXTURES / name).read_text(encoding="utf-8")) def test_phase_transition_to_stabilized_requires_review() -> None: node = MemoryNode("event.restart", "episode", "Useful restart trace", phase=MemoryPhase.FLUID) action = plan_phase_transition(node, MemoryPhase.STABILIZED) assert action.action == LifecycleActionKind.TRANSITION_PHASE assert action.requires_review assert action.to_state == LifecycleState.REVIEW_NEEDED assert action.metadata["to_phase"] == "stabilized" def test_retention_plans_stale_and_delete_requested_without_physical_delete() -> None: now = datetime(2026, 5, 18, tzinfo=timezone.utc) stale = MemoryNode( "stale", "episode", freshness={"updated_at": "2026-05-01T00:00:00+00:00"}, ) old = MemoryNode( "old", "episode", freshness={"updated_at": "2026-04-01T00:00:00+00:00"}, ) actions = plan_retention([stale, old], stale_after_days=7, delete_after_days=30, now=now) by_target = {action.target_id: action for action in actions} assert by_target["stale"].action == LifecycleActionKind.MARK_STALE assert by_target["old"].action == LifecycleActionKind.REQUEST_DELETE assert by_target["old"].metadata["physical_delete"] is False def test_compaction_and_refresh_are_reviewable_plans() -> None: node = MemoryNode("artifact.profile", "artifact", "Profile text", freshness={"source_digest": "old"}) compact = plan_compaction([node]) refresh = plan_refresh([node], source_digest_by_node_id={"artifact.profile": "new"})[0] assert compact.action == LifecycleActionKind.COMPACT assert compact.requires_review assert refresh.action == LifecycleActionKind.REFRESH assert refresh.requires_review assert refresh.metadata["proposed_digest"] == "new" def test_profile_retention_rules_drive_lifecycle_plan_from_fixture() -> None: now = datetime(2026, 5, 18, tzinfo=timezone.utc) profile = _load("memory-profile.json") graph = graph_from_markitect(_load("memory-graph.json")).value config = LifecycleRuleConfig.from_profile(profile) actions = plan_lifecycle_from_profile( graph, profile, refresh_digests={"event.restart": "new"}, now=now, ) by_target_and_action = {(action.target_id, action.action): action for action in actions} assert config.retention_default.stale_after_days == 7 assert by_target_and_action[("event.restart", LifecycleActionKind.MARK_STALE)].to_state == LifecycleState.STALE assert by_target_and_action[("event.restart", LifecycleActionKind.REFRESH)].requires_review def test_profile_transition_rules_promote_matching_nodes() -> None: now = datetime(2026, 5, 18, tzinfo=timezone.utc) graph = MemoryGraph( "graph.rules", nodes=( MemoryNode( "episode.old", "episode", "Useful trace", phase=MemoryPhase.FLUID, freshness={"updated_at": "2026-05-01T00:00:00+00:00"}, ), ), ) profile = { "schema_version": "markitect.memory.profile.v1", "id": "profile.rules", "metadata": { "phase_transitions": [ { "kind": "episode", "from_phase": "fluid", "to_phase": "stabilized", "min_age_days": 7, "reason": "episode old enough to stabilize", } ] }, } actions = plan_lifecycle_from_profile(graph, profile, now=now) assert len(actions) == 1 assert actions[0].action == LifecycleActionKind.TRANSITION_PHASE assert actions[0].target_id == "episode.old" assert actions[0].requires_review is True assert actions[0].metadata["to_phase"] == "stabilized"