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

View File

@@ -0,0 +1,54 @@
"""Profile-driven memory phase planning."""
from .activation import plan_activation
from .contracts import graph_from_markitect, profile_from_markitect
from .lifecycle import (
plan_compaction,
plan_phase_transition,
plan_refresh,
plan_retention,
)
from .models import (
ActivationPlan,
Diagnostic,
LifecycleAction,
LifecycleActionKind,
LifecycleState,
MemoryEdge,
MemoryEvent,
MemoryGraph,
MemoryKind,
MemoryNode,
MemoryPhase,
PolicyDecision,
ProfileExecutionPlan,
ProfileIntent,
)
from .planner import plan_profile_execution
__all__ = [
"ActivationPlan",
"Diagnostic",
"LifecycleAction",
"LifecycleActionKind",
"LifecycleState",
"MemoryEdge",
"MemoryEvent",
"MemoryGraph",
"MemoryKind",
"MemoryNode",
"MemoryPhase",
"PolicyDecision",
"ProfileExecutionPlan",
"ProfileIntent",
"graph_from_markitect",
"plan_activation",
"plan_compaction",
"plan_phase_transition",
"plan_profile_execution",
"plan_refresh",
"plan_retention",
"profile_from_markitect",
]
__version__ = "0.1.0"

View File

@@ -0,0 +1,94 @@
"""Activation planning and Markitect selection handoff."""
from __future__ import annotations
from .models import ActivationPlan, Diagnostic, LifecycleAction, LifecycleActionKind, MemoryGraph
from .utils import stable_digest
def plan_activation(
graph: MemoryGraph,
*,
max_items: int,
max_tokens: int,
profile_id: str | None = None,
priority_node_ids: tuple[str, ...] = (),
include_events: bool = True,
) -> ActivationPlan:
selected: list[str] = []
omitted: list[dict[str, object]] = []
token_estimate = 0
ordered_nodes = _ordered_nodes(graph, priority_node_ids)
for node in ordered_nodes:
node_tokens = _estimate_tokens(node.text or node.kind)
if len(selected) >= max_items:
omitted.append({"id": node.node_id, "reason": "max_items"})
continue
if token_estimate + node_tokens > max_tokens:
omitted.append({"id": node.node_id, "reason": "max_tokens", "tokens": node_tokens})
continue
selected.append(node.node_id)
token_estimate += node_tokens
selected_event_ids: tuple[str, ...] = ()
if include_events:
selected_event_ids = tuple(event.event_id for event in graph.events if event.package_refs or event.activation_refs)
plan_id = f"activation:{stable_digest([graph.graph_id, selected, selected_event_ids, max_items, max_tokens])}"
diagnostics = tuple(
Diagnostic("info", "activation_omitted_items", "Some nodes were omitted by activation budget.", metadata={"count": len(omitted)})
for _ in [None]
if omitted
)
selection = {
"schema_version": "markitect.memory.selection.v1",
"id": plan_id,
"graph": graph.graph_id,
"profile": profile_id,
"nodes": selected,
"events": list(selected_event_ids),
"metadata": {
"planned_by": "phase-memory",
"token_estimate": token_estimate,
"max_items": max_items,
"max_tokens": max_tokens,
"omitted": omitted,
},
}
return ActivationPlan(
plan_id=plan_id,
graph_id=graph.graph_id,
selected_node_ids=tuple(selected),
selected_event_ids=selected_event_ids,
omitted=tuple(omitted),
token_estimate=token_estimate,
max_items=max_items,
max_tokens=max_tokens,
selection=selection,
diagnostics=diagnostics,
)
def activation_action(plan: ActivationPlan) -> LifecycleAction:
return LifecycleAction(
LifecycleActionKind.ACTIVATE,
target_id=plan.plan_id,
reason="compile planned selection through Markitect context-package boundary",
metadata={"selection": plan.selection},
)
def _ordered_nodes(graph: MemoryGraph, priority_node_ids: tuple[str, ...]):
by_id = graph.node_by_id()
priority = [by_id[node_id] for node_id in priority_node_ids if node_id in by_id]
remaining = sorted(
(node for node in graph.nodes if node.node_id not in set(priority_node_ids)),
key=lambda node: node.node_id,
)
return priority + remaining
def _estimate_tokens(text: str) -> int:
return max(1, len(text.split()))

View File

@@ -0,0 +1,87 @@
"""Deterministic local adapters used by tests and first integrations."""
from __future__ import annotations
from typing import Any
from .models import MemoryEdge, MemoryEvent, MemoryNode, PolicyDecision, ProfileIntent
class InMemoryMemoryGraphStore:
def __init__(self) -> None:
self._profiles: dict[str, ProfileIntent] = {}
self._nodes: dict[str, MemoryNode] = {}
self._edges: dict[str, MemoryEdge] = {}
def save_profile(self, profile: ProfileIntent) -> ProfileIntent:
self._profiles[profile.profile_id] = profile
return profile
def get_profile(self, profile_id: str) -> ProfileIntent:
return self._profiles[profile_id]
def save_node(self, node: MemoryNode) -> MemoryNode:
self._nodes[node.node_id] = node
return node
def get_node(self, node_id: str) -> MemoryNode:
return self._nodes[node_id]
def list_nodes(self, *, kind: str | None = None) -> list[MemoryNode]:
nodes = list(self._nodes.values())
if kind:
nodes = [node for node in nodes if node.kind == kind]
return sorted(nodes, key=lambda node: node.node_id)
def save_edge(self, edge: MemoryEdge) -> MemoryEdge:
self._edges[edge.edge_id] = edge
return edge
def list_edges(self, *, source: str | None = None, target: str | None = None) -> list[MemoryEdge]:
edges = list(self._edges.values())
if source:
edges = [edge for edge in edges if edge.source == source]
if target:
edges = [edge for edge in edges if edge.target == target]
return sorted(edges, key=lambda edge: edge.edge_id)
class InMemoryMemoryEventLog:
def __init__(self) -> None:
self._events: list[MemoryEvent] = []
def append(self, event: MemoryEvent) -> MemoryEvent:
if any(existing.event_id == event.event_id for existing in self._events):
raise ValueError(f"Duplicate memory event id: {event.event_id}")
self._events.append(event)
return event
def list_events(self, *, kind: str | None = None) -> list[MemoryEvent]:
events = list(self._events)
if kind:
events = [event for event in events if event.kind == kind]
return events
class NoopContextPackageCompiler:
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
return {
"package_id": f"package:{selection.get('id', 'anonymous')}",
"selection": dict(selection),
"item_count": len(selection.get("nodes", ())) + len(selection.get("events", ())),
}
class AllowAllPolicyGateway:
def authorize(self, *, action: str, resource: str, context: dict[str, Any] | None = None) -> PolicyDecision:
return PolicyDecision(True, reason="local allow-all policy", metadata={"action": action, "resource": resource, "context": context or {}})
class RecordingAuditSink:
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
def record(self, event: dict[str, Any]) -> dict[str, Any]:
stored = dict(event)
self.events.append(stored)
return {"recorded": True, "index": len(self.events) - 1, "event": stored}

View File

@@ -0,0 +1,156 @@
"""Ingress adapters for Markitect-compatible memory contracts."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .models import Diagnostic, MemoryGraph, MemoryEdge, MemoryEvent, MemoryKind, MemoryNode, ProfileIntent
MARKITECT_PROFILE_SCHEMA = "markitect.memory.profile.v1"
MARKITECT_GRAPH_SCHEMA = "markitect.memory.graph.v1"
MARKITECT_SELECTION_SCHEMA = "markitect.memory.selection.v1"
KNOWN_MEMORY_KINDS = {kind.value for kind in MemoryKind}
@dataclass(frozen=True)
class ContractIngressResult:
subject_kind: str
subject_id: str
valid: bool
diagnostics: tuple[Diagnostic, ...]
value: Any
def to_dict(self) -> dict[str, Any]:
return {
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"valid": self.valid,
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
"value": self.value.to_dict() if hasattr(self.value, "to_dict") else self.value,
}
def profile_from_markitect(data: dict[str, Any]) -> ContractIngressResult:
diagnostics: list[Diagnostic] = []
profile = ProfileIntent.from_mapping(data)
if profile.schema_version != MARKITECT_PROFILE_SCHEMA:
diagnostics.append(
Diagnostic(
"error",
"unsupported_profile_schema",
f"Expected {MARKITECT_PROFILE_SCHEMA}.",
"schema_version",
{"actual": profile.schema_version},
)
)
if not profile.profile_id:
diagnostics.append(Diagnostic("error", "missing_profile_id", "Profile must declare id.", "id"))
for index, memory_kind in enumerate(profile.memory_kinds):
if memory_kind not in KNOWN_MEMORY_KINDS:
diagnostics.append(
Diagnostic(
"warn",
"unknown_memory_kind",
"Memory kind is not in phase-memory's first-slice vocabulary.",
f"memory_kinds[{index}]",
{"kind": memory_kind},
)
)
return ContractIngressResult(
subject_kind="memory_profile",
subject_id=profile.profile_id,
valid=not _has_errors(diagnostics),
diagnostics=tuple(diagnostics),
value=profile,
)
def graph_from_markitect(data: dict[str, Any]) -> ContractIngressResult:
diagnostics: list[Diagnostic] = []
schema_version = str(data.get("schema_version") or data.get("schema") or "")
graph_id = str(data.get("id") or data.get("graph_id") or "")
if schema_version != MARKITECT_GRAPH_SCHEMA:
diagnostics.append(
Diagnostic(
"error",
"unsupported_graph_schema",
f"Expected {MARKITECT_GRAPH_SCHEMA}.",
"schema_version",
{"actual": schema_version},
)
)
if not graph_id:
diagnostics.append(Diagnostic("error", "missing_graph_id", "Graph must declare id.", "id"))
nodes = tuple(MemoryNode.from_mapping(item) for item in data.get("nodes", ()))
edges = tuple(MemoryEdge.from_mapping(item) for item in data.get("edges", ()))
events = tuple(MemoryEvent.from_mapping(item) for item in data.get("events", ()))
node_ids = {node.node_id for node in nodes}
for index, node in enumerate(nodes):
if not node.node_id:
diagnostics.append(Diagnostic("error", "missing_node_id", "Graph node must declare id.", f"nodes[{index}].id"))
if not node.kind:
diagnostics.append(Diagnostic("error", "missing_node_kind", "Graph node must declare kind.", f"nodes[{index}].kind"))
for index, edge in enumerate(edges):
if edge.source not in node_ids:
diagnostics.append(
Diagnostic("error", "missing_edge_source", "Edge source does not reference a node.", f"edges[{index}].source", {"source": edge.source})
)
if edge.target not in node_ids:
diagnostics.append(
Diagnostic("error", "missing_edge_target", "Edge target does not reference a node.", f"edges[{index}].target", {"target": edge.target})
)
graph = MemoryGraph(
graph_id=graph_id,
schema_version=schema_version,
nodes=nodes,
edges=edges,
events=events,
metadata=dict(data.get("metadata") or {}),
)
return ContractIngressResult(
subject_kind="memory_graph",
subject_id=graph_id,
valid=not _has_errors(diagnostics),
diagnostics=tuple(diagnostics),
value=graph,
)
def selection_from_markitect(data: dict[str, Any]) -> ContractIngressResult:
diagnostics: list[Diagnostic] = []
schema_version = str(data.get("schema_version") or data.get("schema") or "")
selection_id = str(data.get("id") or data.get("selection_id") or "")
if schema_version != MARKITECT_SELECTION_SCHEMA:
diagnostics.append(
Diagnostic(
"error",
"unsupported_selection_schema",
f"Expected {MARKITECT_SELECTION_SCHEMA}.",
"schema_version",
{"actual": schema_version},
)
)
if not selection_id:
diagnostics.append(Diagnostic("error", "missing_selection_id", "Selection must declare id.", "id"))
return ContractIngressResult(
subject_kind="memory_selection",
subject_id=selection_id,
valid=not _has_errors(diagnostics),
diagnostics=tuple(diagnostics),
value=dict(data),
)
def _has_errors(diagnostics: list[Diagnostic]) -> bool:
return any(diagnostic.severity == "error" for diagnostic in diagnostics)

View File

@@ -0,0 +1,127 @@
"""Dry-run lifecycle planners."""
from __future__ import annotations
from datetime import datetime, timezone
from .models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryNode, MemoryPhase
from .utils import parse_iso_datetime, stable_digest
def plan_phase_transition(
node: MemoryNode,
target_phase: MemoryPhase,
*,
reason: str = "",
) -> LifecycleAction:
requires_review = target_phase in {MemoryPhase.STABILIZED, MemoryPhase.RIGID}
target_state = LifecycleState.REVIEW_NEEDED if requires_review else LifecycleState.ACTIVE
return LifecycleAction(
LifecycleActionKind.TRANSITION_PHASE,
target_id=node.node_id,
from_state=node.lifecycle,
to_state=target_state,
reason=reason or f"transition {node.phase.value} to {target_phase.value}",
requires_review=requires_review,
metadata={"from_phase": node.phase.value, "to_phase": target_phase.value},
)
def plan_retention(
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
*,
stale_after_days: int | None = None,
delete_after_days: int | None = None,
now: datetime | None = None,
) -> tuple[LifecycleAction, ...]:
now = now or datetime.now(timezone.utc)
actions: list[LifecycleAction] = []
for node in nodes:
age = _age_days(node, now)
if age is None:
continue
if delete_after_days is not None and age >= delete_after_days:
actions.append(
LifecycleAction(
LifecycleActionKind.REQUEST_DELETE,
node.node_id,
from_state=node.lifecycle,
to_state=LifecycleState.DELETE_REQUESTED,
reason=f"memory age {age}d exceeds delete_after_days={delete_after_days}",
requires_review=True,
metadata={"age_days": age, "physical_delete": False},
)
)
elif stale_after_days is not None and age >= stale_after_days:
actions.append(
LifecycleAction(
LifecycleActionKind.MARK_STALE,
node.node_id,
from_state=node.lifecycle,
to_state=LifecycleState.STALE,
reason=f"memory age {age}d exceeds stale_after_days={stale_after_days}",
metadata={"age_days": age},
)
)
return tuple(actions)
def plan_compaction(
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
*,
summary_node_id: str | None = None,
retire_source_nodes: bool = True,
reason: str = "",
) -> LifecycleAction:
source_ids = tuple(node.node_id for node in nodes)
summary_id = summary_node_id or f"summary:{stable_digest(source_ids)}"
return LifecycleAction(
LifecycleActionKind.COMPACT,
target_id=summary_id,
reason=reason or "compact selected memory window into reviewable summary",
requires_review=True,
metadata={
"source_node_ids": list(source_ids),
"retire_source_nodes": retire_source_nodes,
"summary_text": _summary_text(nodes),
},
)
def plan_refresh(
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
*,
source_digest_by_node_id: dict[str, str],
) -> tuple[LifecycleAction, ...]:
actions: list[LifecycleAction] = []
for node in nodes:
current = node.freshness.get("source_digest")
proposed = source_digest_by_node_id.get(node.node_id)
if proposed and proposed != current:
actions.append(
LifecycleAction(
LifecycleActionKind.REFRESH,
target_id=node.node_id,
from_state=node.lifecycle,
to_state=LifecycleState.REVIEW_NEEDED,
reason="source digest changed",
requires_review=True,
metadata={"current_digest": current, "proposed_digest": proposed},
)
)
return tuple(actions)
def _age_days(node: MemoryNode, now: datetime) -> int | None:
updated = parse_iso_datetime(str(node.freshness.get("updated_at") or node.updated_at))
if updated is None:
return None
return max((now - updated).days, 0)
def _summary_text(nodes: list[MemoryNode] | tuple[MemoryNode, ...]) -> str:
parts = [node.text.strip() for node in nodes if node.text.strip()]
if not parts:
return "Summary proposed for selected memory nodes."
return " ".join(parts)[:240]

437
src/phase_memory/models.py Normal file
View File

@@ -0,0 +1,437 @@
"""Core phase-memory records.
The model layer is deliberately small and serializable. Durable persistence,
schema ownership, and external adapter behavior live outside these records.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .utils import compact_dict, stable_digest, to_plain, utc_now_iso
class MemoryPhase(str, Enum):
EPHEMERAL = "ephemeral"
FLUID = "fluid"
STABILIZED = "stabilized"
RIGID = "rigid"
class MemoryKind(str, Enum):
REASONING = "reasoning"
CONVERSATION = "conversation"
KNOWLEDGE = "knowledge"
PACKAGE = "package"
IDENTITY = "identity"
PREFERENCE = "preference"
SOURCE = "source"
TASK = "task"
TOOL = "tool"
class LifecycleState(str, Enum):
ACTIVE = "active"
STALE = "stale"
REVIEW_NEEDED = "review_needed"
COMPACTED = "compacted"
SUPERSEDED = "superseded"
DELETE_REQUESTED = "delete_requested"
ARCHIVED = "archived"
class LifecycleActionKind(str, Enum):
TRANSITION_PHASE = "transition_phase"
MARK_STALE = "mark_stale"
REQUEST_DELETE = "request_delete"
COMPACT = "compact"
REFRESH = "refresh"
ACTIVATE = "activate"
DENY = "deny"
NO_OP = "no_op"
@dataclass(frozen=True)
class Diagnostic:
severity: str
code: str
message: str
path: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"severity": self.severity,
"code": self.code,
"message": self.message,
"path": self.path,
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class ProfileIntent:
profile_id: str
schema_version: str = "markitect.memory.profile.v1"
title: str = ""
intent: str = ""
memory_kinds: tuple[str, ...] = ()
stores: dict[str, str] = field(default_factory=dict)
limits: dict[str, Any] = field(default_factory=dict)
latency: dict[str, Any] = field(default_factory=dict)
retention: dict[str, Any] = field(default_factory=dict)
refresh: dict[str, Any] = field(default_factory=dict)
compaction: dict[str, Any] = field(default_factory=dict)
activation: dict[str, Any] = field(default_factory=dict)
policy: dict[str, Any] = field(default_factory=dict)
observability: dict[str, Any] = field(default_factory=dict)
failure: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "ProfileIntent":
return cls(
profile_id=str(data.get("id") or data.get("profile_id") or ""),
schema_version=str(data.get("schema_version") or data.get("schema") or ""),
title=str(data.get("title") or data.get("name") or ""),
intent=str(data.get("intent") or data.get("description") or ""),
memory_kinds=tuple(str(item) for item in data.get("memory_kinds", ())),
stores={str(key): str(value) for key, value in dict(data.get("stores") or {}).items()},
limits=dict(data.get("limits") or {}),
latency=dict(data.get("latency") or data.get("latency_targets") or {}),
retention=dict(data.get("retention") or data.get("retention_policy") or {}),
refresh=dict(data.get("refresh") or {}),
compaction=dict(data.get("compaction") or {}),
activation=dict(data.get("activation") or data.get("context_budget") or {}),
policy=dict(data.get("policy") or {}),
observability=dict(data.get("observability") or {}),
failure=dict(data.get("failure") or {}),
metadata=dict(data.get("metadata") or {}),
)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.profile_id,
"schema_version": self.schema_version,
"title": self.title,
"intent": self.intent,
"memory_kinds": list(self.memory_kinds),
"stores": dict(self.stores),
"limits": dict(self.limits),
"latency": dict(self.latency),
"retention": dict(self.retention),
"refresh": dict(self.refresh),
"compaction": dict(self.compaction),
"activation": dict(self.activation),
"policy": dict(self.policy),
"observability": dict(self.observability),
"failure": dict(self.failure),
"metadata": dict(self.metadata),
}
)
@dataclass(frozen=True)
class MemoryNode:
node_id: str
kind: str
text: str = ""
phase: MemoryPhase = MemoryPhase.FLUID
lifecycle: LifecycleState = LifecycleState.ACTIVE
namespace: dict[str, Any] = field(default_factory=dict)
source_spans: tuple[dict[str, Any], ...] = ()
provenance: tuple[dict[str, Any], ...] = ()
freshness: dict[str, Any] = field(default_factory=dict)
confidence: float | None = None
policy: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=utc_now_iso)
updated_at: str = field(default_factory=utc_now_iso)
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "MemoryNode":
return cls(
node_id=str(data.get("id") or data.get("node_id") or ""),
kind=str(data.get("kind") or data.get("type") or ""),
text=str(data.get("text") or data.get("content") or data.get("summary") or ""),
phase=MemoryPhase(str(data.get("phase") or MemoryPhase.FLUID.value)),
lifecycle=LifecycleState(str(data.get("lifecycle") or LifecycleState.ACTIVE.value)),
namespace=dict(data.get("namespace") or {}),
source_spans=tuple(dict(item) for item in _mapping_list(data.get("source_spans"))),
provenance=tuple(dict(item) for item in _mapping_list(data.get("provenance"))),
freshness=dict(data.get("freshness") or {}),
confidence=_optional_float(data.get("confidence")),
policy=dict(data.get("policy") or {}),
metadata=dict(data.get("metadata") or {}),
created_at=str(data.get("created_at") or utc_now_iso()),
updated_at=str(data.get("updated_at") or utc_now_iso()),
)
@property
def id(self) -> str:
return self.node_id
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.node_id,
"kind": self.kind,
"text": self.text,
"phase": self.phase,
"lifecycle": self.lifecycle,
"namespace": self.namespace,
"source_spans": list(self.source_spans),
"provenance": list(self.provenance),
"freshness": self.freshness,
"confidence": self.confidence,
"policy": self.policy,
"metadata": self.metadata,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
)
@dataclass(frozen=True)
class MemoryEdge:
edge_id: str
kind: str
source: str
target: str
provenance: tuple[dict[str, Any], ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "MemoryEdge":
kind = str(data.get("kind") or data.get("type") or "")
source = str(data.get("source") or data.get("from") or "")
target = str(data.get("target") or data.get("to") or "")
edge_id = str(data.get("id") or f"edge:{stable_digest([kind, source, target])}")
return cls(
edge_id=edge_id,
kind=kind,
source=source,
target=target,
provenance=tuple(dict(item) for item in _mapping_list(data.get("provenance"))),
metadata=dict(data.get("metadata") or {}),
)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.edge_id,
"kind": self.kind,
"source": self.source,
"target": self.target,
"provenance": list(self.provenance),
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class MemoryEvent:
event_id: str
kind: str
timestamp: str = field(default_factory=utc_now_iso)
node_updates: tuple[dict[str, Any], ...] = ()
edge_updates: tuple[dict[str, Any], ...] = ()
package_refs: tuple[str, ...] = ()
activation_refs: tuple[str, ...] = ()
policy: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "MemoryEvent":
kind = str(data.get("kind") or data.get("type") or "recorded")
timestamp = str(data.get("timestamp") or data.get("at") or utc_now_iso())
event_id = str(data.get("id") or data.get("event_id") or f"event:{stable_digest([kind, timestamp, data])}")
return cls(
event_id=event_id,
kind=kind,
timestamp=timestamp,
node_updates=tuple(dict(item) for item in _mapping_list(data.get("node_updates"))),
edge_updates=tuple(dict(item) for item in _mapping_list(data.get("edge_updates"))),
package_refs=tuple(str(item) for item in data.get("package_refs", ())),
activation_refs=tuple(str(item) for item in data.get("activation_refs", ())),
policy=dict(data.get("policy") or {}),
metadata=dict(data.get("metadata") or {}),
)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.event_id,
"kind": self.kind,
"timestamp": self.timestamp,
"node_updates": list(self.node_updates),
"edge_updates": list(self.edge_updates),
"package_refs": list(self.package_refs),
"activation_refs": list(self.activation_refs),
"policy": self.policy,
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class MemoryGraph:
graph_id: str
schema_version: str = "markitect.memory.graph.v1"
nodes: tuple[MemoryNode, ...] = ()
edges: tuple[MemoryEdge, ...] = ()
events: tuple[MemoryEvent, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
@property
def node_ids(self) -> set[str]:
return {node.node_id for node in self.nodes}
def node_by_id(self) -> dict[str, MemoryNode]:
return {node.node_id: node for node in self.nodes}
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"id": self.graph_id,
"schema_version": self.schema_version,
"nodes": [node.to_dict() for node in self.nodes],
"edges": [edge.to_dict() for edge in self.edges],
"events": [event.to_dict() for event in self.events],
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
reason: str = ""
obligations: tuple[str, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"allowed": self.allowed,
"reason": self.reason,
"obligations": list(self.obligations),
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class LifecycleAction:
action: LifecycleActionKind
target_id: str
from_state: LifecycleState | None = None
to_state: LifecycleState | None = None
reason: str = ""
requires_review: bool = False
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"action": self.action,
"target_id": self.target_id,
"from_state": self.from_state,
"to_state": self.to_state,
"reason": self.reason,
"requires_review": self.requires_review,
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class ProfileExecutionPlan:
profile_id: str
dry_run: bool = True
enabled_memory_kinds: tuple[str, ...] = ()
required_adapters: tuple[str, ...] = ()
missing_adapters: tuple[str, ...] = ()
capabilities: tuple[str, ...] = ()
activation_budget: dict[str, Any] = field(default_factory=dict)
policy_gates: tuple[str, ...] = ()
observability_events: tuple[str, ...] = ()
fallback_behavior: dict[str, Any] = field(default_factory=dict)
actions: tuple[LifecycleAction, ...] = ()
diagnostics: tuple[Diagnostic, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
@property
def ready(self) -> bool:
return not self.missing_adapters and not any(
diagnostic.severity == "error" for diagnostic in self.diagnostics
)
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"profile_id": self.profile_id,
"dry_run": self.dry_run,
"ready": self.ready,
"enabled_memory_kinds": list(self.enabled_memory_kinds),
"required_adapters": list(self.required_adapters),
"missing_adapters": list(self.missing_adapters),
"capabilities": list(self.capabilities),
"activation_budget": self.activation_budget,
"policy_gates": list(self.policy_gates),
"observability_events": list(self.observability_events),
"fallback_behavior": self.fallback_behavior,
"actions": [action.to_dict() for action in self.actions],
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
"metadata": self.metadata,
}
)
@dataclass(frozen=True)
class ActivationPlan:
plan_id: str
graph_id: str
selected_node_ids: tuple[str, ...] = ()
selected_event_ids: tuple[str, ...] = ()
omitted: tuple[dict[str, Any], ...] = ()
token_estimate: int = 0
max_items: int = 0
max_tokens: int = 0
selection: dict[str, Any] = field(default_factory=dict)
diagnostics: tuple[Diagnostic, ...] = ()
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"plan_id": self.plan_id,
"graph_id": self.graph_id,
"selected_node_ids": list(self.selected_node_ids),
"selected_event_ids": list(self.selected_event_ids),
"omitted": list(self.omitted),
"token_estimate": self.token_estimate,
"max_items": self.max_items,
"max_tokens": self.max_tokens,
"selection": to_plain(self.selection),
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
}
)
def _mapping_list(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if isinstance(value, dict):
return [value]
return [dict(item) for item in value]
def _optional_float(value: Any) -> float | None:
if value is None or value == "":
return None
return float(value)

129
src/phase_memory/planner.py Normal file
View File

@@ -0,0 +1,129 @@
"""Profile execution planning."""
from __future__ import annotations
from collections.abc import Iterable
from .models import (
Diagnostic,
LifecycleAction,
LifecycleActionKind,
ProfileExecutionPlan,
ProfileIntent,
)
DEFAULT_LOCAL_ADAPTERS = {
"local-event-log",
"local-graph-store",
"markitect-context-package",
"markitect-context-registry",
"markitect-memory-graph-fixture",
"infospace-workflow-trace-fixtures",
"infospace-artifact-neighborhood",
}
def plan_profile_execution(
profile: ProfileIntent,
*,
available_adapters: Iterable[str] | None = None,
) -> ProfileExecutionPlan:
available = set(DEFAULT_LOCAL_ADAPTERS if available_adapters is None else available_adapters)
required_adapters = tuple(sorted(set(profile.stores.values())))
missing_adapters = tuple(adapter for adapter in required_adapters if adapter not in available)
diagnostics: list[Diagnostic] = []
actions: list[LifecycleAction] = []
if not profile.profile_id:
diagnostics.append(Diagnostic("error", "missing_profile_id", "Profile execution requires a profile id."))
for adapter in missing_adapters:
diagnostics.append(
Diagnostic(
"warn",
"missing_adapter",
"Required adapter is not available; plan will use fallback behavior.",
"stores",
{"adapter": adapter},
)
)
actions.append(
LifecycleAction(
LifecycleActionKind.NO_OP,
target_id=adapter,
reason="adapter unavailable in dry-run planner",
metadata={"fallback": _missing_store_fallback(profile)},
)
)
capabilities = tuple(sorted(_capabilities_for(profile)))
policy_gates = tuple(_policy_gates(profile))
observability_events = tuple(_observability_events(profile, missing_adapters))
return ProfileExecutionPlan(
profile_id=profile.profile_id,
enabled_memory_kinds=tuple(profile.memory_kinds),
required_adapters=required_adapters,
missing_adapters=missing_adapters,
capabilities=capabilities,
activation_budget=dict(profile.activation),
policy_gates=policy_gates,
observability_events=observability_events,
fallback_behavior={
"missing_runtime_store": _missing_store_fallback(profile),
**dict(profile.failure),
},
actions=tuple(actions),
diagnostics=tuple(diagnostics),
metadata={
"stores": dict(profile.stores),
"limits": dict(profile.limits),
"retention": dict(profile.retention),
"refresh": dict(profile.refresh),
"compaction": dict(profile.compaction),
},
)
def _capabilities_for(profile: ProfileIntent) -> set[str]:
capabilities = {"profile.inspect", "profile.plan"}
if profile.retention:
capabilities.add("retention.plan")
if profile.refresh:
capabilities.add("refresh.plan")
if profile.compaction:
capabilities.add("compaction.plan")
if profile.activation:
capabilities.add("activation.plan")
if profile.policy:
capabilities.add("policy.gate")
return capabilities
def _policy_gates(profile: ProfileIntent) -> list[str]:
policy = profile.policy
gates: list[str] = []
for label in policy.get("required_labels", ()):
gates.append(f"label:{label}")
durable_writes = policy.get("durable_writes")
if durable_writes:
gates.append(f"durable_writes:{durable_writes}")
if policy.get("secrets_allowed") is False:
gates.append("secrets:denied")
if policy.get("reauthorization"):
gates.append(f"reauthorization:{policy['reauthorization']}")
return gates
def _observability_events(profile: ProfileIntent, missing_adapters: tuple[str, ...]) -> list[str]:
observability = profile.observability
events = list(observability.get("events") or observability.get("emit") or ())
if observability.get("emit_events") is True and not events:
events.append("phase_memory.profile.planned")
if missing_adapters:
events.append("phase_memory.adapter.missing")
return events
def _missing_store_fallback(profile: ProfileIntent) -> str:
return str(profile.failure.get("missing_runtime_store") or "degrade-to-dry-run")

50
src/phase_memory/ports.py Normal file
View File

@@ -0,0 +1,50 @@
"""Runtime port protocols for phase-memory adapters."""
from __future__ import annotations
from typing import Any, Protocol
from .models import MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, PolicyDecision, ProfileIntent
class MemoryGraphStore(Protocol):
def save_profile(self, profile: ProfileIntent) -> ProfileIntent: ...
def get_profile(self, profile_id: str) -> ProfileIntent: ...
def save_node(self, node: MemoryNode) -> MemoryNode: ...
def get_node(self, node_id: str) -> MemoryNode: ...
def list_nodes(self, *, kind: str | None = None) -> list[MemoryNode]: ...
def save_edge(self, edge: MemoryEdge) -> MemoryEdge: ...
def list_edges(self, *, source: str | None = None, target: str | None = None) -> list[MemoryEdge]: ...
class MemoryEventLog(Protocol):
def append(self, event: MemoryEvent) -> MemoryEvent: ...
def list_events(self, *, kind: str | None = None) -> list[MemoryEvent]: ...
class ContextPackageCompiler(Protocol):
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]: ...
class SemanticIndex(Protocol):
def upsert_nodes(self, nodes: list[MemoryNode]) -> dict[str, Any]: ...
def query(self, *, graph_id: str, query: str, limit: int = 10) -> list[dict[str, Any]]: ...
class PolicyGateway(Protocol):
def authorize(self, *, action: str, resource: str, context: dict[str, Any] | None = None) -> PolicyDecision: ...
class AuditSink(Protocol):
def record(self, event: dict[str, Any]) -> dict[str, Any]: ...
class RuntimeRegistry(Protocol):
def publish_runtime_envelope(self, envelope: dict[str, Any]) -> dict[str, Any]: ...
def fetch_runtime_envelope(self, reference: str) -> dict[str, Any]: ...
def graph_from_store(store: MemoryGraphStore, *, graph_id: str = "local") -> MemoryGraph:
return MemoryGraph(graph_id=graph_id, nodes=tuple(store.list_nodes()))

58
src/phase_memory/utils.py Normal file
View File

@@ -0,0 +1,58 @@
"""Small deterministic helpers."""
from __future__ import annotations
from dataclasses import is_dataclass, asdict
from datetime import datetime, timezone
from enum import Enum
import hashlib
import json
from typing import Any
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def stable_digest(value: Any, *, length: int = 12) -> str:
encoded = json.dumps(to_plain(value), sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(encoded).hexdigest()[:length]
def compact_dict(mapping: dict[str, Any]) -> dict[str, Any]:
return {
key: to_plain(value)
for key, value in mapping.items()
if value not in (None, "", [], {}, ())
}
def to_plain(value: Any) -> Any:
if isinstance(value, Enum):
return value.value
if is_dataclass(value):
return to_plain(asdict(value))
if isinstance(value, dict):
return {
str(key): to_plain(item)
for key, item in value.items()
if item not in (None, "", [], {}, ())
}
if isinstance(value, (list, tuple, set)):
return [to_plain(item) for item in value]
return value
def parse_iso_datetime(value: str | None) -> datetime | None:
if not value:
return None
raw = value.strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)