phase-memory/src/phase_memory/planner.py

129 lines
4.3 KiB
Python

"""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")