Implement phase-memory foundation

This commit is contained in:
2026-05-18 01:55:34 +02:00
parent 751da54052
commit 87f104781a
22 changed files with 1705 additions and 12 deletions

56
tests/fixtures/memory-graph.json vendored Normal file
View File

@@ -0,0 +1,56 @@
{
"schema_version": "markitect.memory.graph.v1",
"id": "phase-memory-fixture-graph",
"nodes": [
{
"id": "decision.boundary",
"kind": "decision",
"text": "Markitect owns syntax contracts; phase-memory owns runtime phase planning.",
"phase": "stabilized",
"source_spans": [{"path": "docs/architecture.md", "line_start": 1}],
"metadata": {"title": "Boundary decision"}
},
{
"id": "event.restart",
"kind": "episode",
"text": "Restart package should include boundary decision and active graph neighborhood.",
"phase": "fluid",
"freshness": {"updated_at": "2026-05-01T00:00:00+00:00", "source_digest": "old"}
},
{
"id": "artifact.profile",
"kind": "artifact",
"text": "Memory profile declares budgets, stores, retention, activation, policy, and fallback behavior.",
"phase": "stabilized",
"freshness": {"updated_at": "2026-05-18T00:00:00+00:00", "source_digest": "fresh"}
},
{
"id": "risk.durable-write",
"kind": "risk",
"text": "Durable writes must stay review gated until the runtime plan is explicit.",
"phase": "fluid"
}
],
"edges": [
{
"id": "edge.boundary-profile",
"kind": "governs",
"source": "decision.boundary",
"target": "artifact.profile"
},
{
"id": "edge.risk-boundary",
"kind": "depends_on",
"source": "risk.durable-write",
"target": "decision.boundary"
}
],
"events": [
{
"id": "event.activation",
"kind": "activated",
"timestamp": "2026-05-18T00:00:00+00:00",
"activation_refs": ["activation.fixture"]
}
]
}

45
tests/fixtures/memory-profile.json vendored Normal file
View File

@@ -0,0 +1,45 @@
{
"schema_version": "markitect.memory.profile.v1",
"id": "phase-memory-fixture-profile",
"title": "Phase Memory Fixture Profile",
"intent": "Plan reasoning, conversation, knowledge, and activation memory behavior.",
"memory_kinds": ["reasoning", "conversation", "knowledge", "package"],
"stores": {
"reasoning": "local-graph-store",
"conversation": "local-event-log",
"knowledge": "local-graph-store",
"package": "markitect-context-package"
},
"limits": {
"reasoning": {"max_nodes": 40},
"conversation": {"max_nodes": 20},
"package": {"max_items": 3}
},
"retention": {
"conversation": {"stale_after_days": 7, "delete_after_days": 30}
},
"refresh": {
"trigger": "source-artifact-or-profile-digest-change"
},
"compaction": {
"strategy": "summarize-trace-after-review"
},
"activation": {
"max_items": 3,
"max_tokens": 30
},
"policy": {
"required_labels": ["project-local"],
"durable_writes": "review-gated",
"secrets_allowed": false
},
"observability": {
"emit_events": true
},
"failure": {
"missing_runtime_store": "degrade-to-dry-run"
},
"metadata": {
"derived_from": ["MKTT-WP-0016", "IB-WP-0017"]
}
}

36
tests/test_activation.py Normal file
View File

@@ -0,0 +1,36 @@
import json
from pathlib import Path
from phase_memory.activation import activation_action, plan_activation
from phase_memory.contracts import graph_from_markitect
from phase_memory.models import LifecycleActionKind
def test_activation_planner_emits_markitect_selection_under_budget() -> None:
graph_data = json.loads((Path(__file__).parent / "fixtures" / "memory-graph.json").read_text(encoding="utf-8"))
graph = graph_from_markitect(graph_data).value
plan = plan_activation(
graph,
max_items=2,
max_tokens=18,
profile_id="phase-memory-fixture-profile",
priority_node_ids=("decision.boundary",),
)
assert plan.selected_node_ids[0] == "decision.boundary"
assert len(plan.selected_node_ids) <= 2
assert plan.token_estimate <= 18
assert plan.selection["schema_version"] == "markitect.memory.selection.v1"
assert plan.selection["profile"] == "phase-memory-fixture-profile"
assert plan.omitted
def test_activation_action_wraps_selection_for_context_package_boundary() -> None:
graph = graph_from_markitect(json.loads((Path(__file__).parent / "fixtures" / "memory-graph.json").read_text(encoding="utf-8"))).value
plan = plan_activation(graph, max_items=1, max_tokens=20)
action = activation_action(plan)
assert action.action == LifecycleActionKind.ACTIVATE
assert action.metadata["selection"]["id"] == plan.plan_id

44
tests/test_adapters.py Normal file
View File

@@ -0,0 +1,44 @@
from phase_memory.adapters import (
AllowAllPolicyGateway,
InMemoryMemoryEventLog,
InMemoryMemoryGraphStore,
NoopContextPackageCompiler,
RecordingAuditSink,
)
from phase_memory.models import MemoryEdge, MemoryEvent, MemoryNode, ProfileIntent
from phase_memory.ports import graph_from_store
def test_in_memory_store_and_event_log_are_deterministic() -> None:
store = InMemoryMemoryGraphStore()
profile = ProfileIntent(profile_id="profile")
node = MemoryNode("node.a", "decision", "Boundary decision")
edge = MemoryEdge("edge.a", "governs", "node.a", "node.a")
store.save_profile(profile)
store.save_node(node)
store.save_edge(edge)
assert store.get_profile("profile") == profile
assert store.list_nodes() == [node]
assert store.list_edges(source="node.a") == [edge]
assert graph_from_store(store).nodes == (node,)
log = InMemoryMemoryEventLog()
event = MemoryEvent("event.a", "recorded")
assert log.append(event) == event
assert log.list_events(kind="recorded") == [event]
def test_local_policy_audit_and_compiler_adapters() -> None:
policy = AllowAllPolicyGateway()
audit = RecordingAuditSink()
compiler = NoopContextPackageCompiler()
decision = policy.authorize(action="read", resource="memory-node:node.a")
receipt = audit.record({"operation": "read", "allowed": decision.allowed})
package = compiler.compile_selection({"id": "selection.a", "nodes": ["node.a"], "events": []})
assert decision.allowed
assert receipt["recorded"] is True
assert package["package_id"] == "package:selection.a"

44
tests/test_contracts.py Normal file
View File

@@ -0,0 +1,44 @@
import json
from pathlib import Path
from phase_memory.contracts import graph_from_markitect, profile_from_markitect
FIXTURES = Path(__file__).parent / "fixtures"
def test_profile_ingress_preserves_markitect_profile_intent() -> None:
data = json.loads((FIXTURES / "memory-profile.json").read_text(encoding="utf-8"))
result = profile_from_markitect(data)
assert result.valid, [diagnostic.to_dict() for diagnostic in result.diagnostics]
assert result.value.profile_id == "phase-memory-fixture-profile"
assert result.value.memory_kinds == ("reasoning", "conversation", "knowledge", "package")
assert result.value.policy["durable_writes"] == "review-gated"
def test_graph_ingress_checks_edge_integrity_without_owning_vocabulary() -> None:
data = json.loads((FIXTURES / "memory-graph.json").read_text(encoding="utf-8"))
result = graph_from_markitect(data)
assert result.valid, [diagnostic.to_dict() for diagnostic in result.diagnostics]
assert result.value.graph_id == "phase-memory-fixture-graph"
assert len(result.value.nodes) == 4
assert result.value.node_ids == {
"decision.boundary",
"event.restart",
"artifact.profile",
"risk.durable-write",
}
def test_graph_ingress_rejects_missing_edge_endpoint() -> None:
data = json.loads((FIXTURES / "memory-graph.json").read_text(encoding="utf-8"))
data["edges"][0]["target"] = "missing"
result = graph_from_markitect(data)
assert not result.valid
assert any(diagnostic.code == "missing_edge_target" for diagnostic in result.diagnostics)

49
tests/test_lifecycle.py Normal file
View File

@@ -0,0 +1,49 @@
from datetime import datetime, timezone
from phase_memory.lifecycle import plan_compaction, plan_phase_transition, plan_refresh, plan_retention
from phase_memory.models import LifecycleActionKind, LifecycleState, MemoryNode, MemoryPhase
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"

32
tests/test_planner.py Normal file
View File

@@ -0,0 +1,32 @@
import json
from pathlib import Path
from phase_memory.contracts import profile_from_markitect
from phase_memory.planner import plan_profile_execution
def test_profile_execution_plan_reports_capabilities_and_gates() -> None:
profile_data = json.loads((Path(__file__).parent / "fixtures" / "memory-profile.json").read_text(encoding="utf-8"))
profile = profile_from_markitect(profile_data).value
plan = plan_profile_execution(profile)
assert plan.ready
assert "activation.plan" in plan.capabilities
assert "retention.plan" in plan.capabilities
assert "durable_writes:review-gated" in plan.policy_gates
assert "secrets:denied" in plan.policy_gates
assert "phase_memory.profile.planned" in plan.observability_events
def test_profile_execution_plan_degrades_when_adapter_is_missing() -> None:
profile_data = json.loads((Path(__file__).parent / "fixtures" / "memory-profile.json").read_text(encoding="utf-8"))
profile_data["stores"]["knowledge"] = "external-graph-store"
profile = profile_from_markitect(profile_data).value
plan = plan_profile_execution(profile, available_adapters={"local-event-log"})
assert not plan.ready
assert "external-graph-store" in plan.missing_adapters
assert plan.fallback_behavior["missing_runtime_store"] == "degrade-to-dry-run"
assert any(diagnostic.code == "missing_adapter" for diagnostic in plan.diagnostics)

6
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,6 @@
import phase_memory
def test_package_imports() -> None:
assert phase_memory.__version__ == "0.1.0"
assert phase_memory.MemoryPhase.FLUID.value == "fluid"