generated from coulomb/repo-seed
Add profile-driven lifecycle rules
This commit is contained in:
@@ -40,6 +40,7 @@ From a checkout, run the CLI through the local source tree:
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m phase_memory.cli profile plan tests/fixtures/memory-profile.json
|
||||
PYTHONPATH=src python3 -m phase_memory.cli graph lifecycle tests/fixtures/memory-graph.json --stale-after-days 7 --delete-after-days 30
|
||||
PYTHONPATH=src python3 -m phase_memory.cli graph lifecycle tests/fixtures/memory-graph.json --profile tests/fixtures/memory-profile.json
|
||||
PYTHONPATH=src python3 -m phase_memory.cli graph activate tests/fixtures/memory-graph.json --max-items 3 --max-tokens 60
|
||||
PYTHONPATH=src python3 -m phase_memory.cli store import --store .phase-memory-local --profile tests/fixtures/memory-profile.json --graph tests/fixtures/memory-graph.json
|
||||
```
|
||||
@@ -78,7 +79,7 @@ and a package compilation request for the `ContextPackageCompiler` boundary.
|
||||
and plan envelopes.
|
||||
- `phase_memory.contracts`: Markitect-compatible profile/graph ingress.
|
||||
- `phase_memory.planner`: profile execution planning.
|
||||
- `phase_memory.lifecycle`: dry-run lifecycle planning.
|
||||
- `phase_memory.lifecycle`: dry-run and profile-driven lifecycle planning.
|
||||
- `phase_memory.activation`: Markitect-compatible activation selection planning.
|
||||
- `phase_memory.ports`: runtime port protocols.
|
||||
- `phase_memory.adapters`: deterministic in-memory test adapters.
|
||||
@@ -91,5 +92,6 @@ file-backed adapter, [docs/policy-audit.md](docs/policy-audit.md) for local
|
||||
policy and review gates, [docs/markitect-interop.md](docs/markitect-interop.md)
|
||||
for package bridge boundaries, [docs/activation-quality.md](docs/activation-quality.md)
|
||||
for retrieval and evaluation behavior, [docs/service-readiness.md](docs/service-readiness.md)
|
||||
for service and adapter contracts, and [SCOPE.md](SCOPE.md) for repository
|
||||
for service and adapter contracts, [docs/lifecycle-rules.md](docs/lifecycle-rules.md)
|
||||
for profile-driven lifecycle rules, and [SCOPE.md](SCOPE.md) for repository
|
||||
boundaries.
|
||||
|
||||
78
docs/lifecycle-rules.md
Normal file
78
docs/lifecycle-rules.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Profile-Driven Lifecycle Rules
|
||||
|
||||
Lifecycle planning can now derive its first local rule set from a
|
||||
Markitect-compatible memory profile.
|
||||
|
||||
## Retention Rules
|
||||
|
||||
`LifecycleRuleConfig.from_profile(...)` reads retention rules from:
|
||||
|
||||
```json
|
||||
{
|
||||
"retention": {
|
||||
"default": {"stale_after_days": 7, "delete_after_days": 30},
|
||||
"episode": {"stale_after_days": 3}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The fixture style is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"retention": {
|
||||
"conversation": {"stale_after_days": 7, "delete_after_days": 30}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a profile has one nested retention rule and no explicit `default`, that
|
||||
rule becomes the default for local planning. This keeps older profile fixtures
|
||||
useful while more specific node-kind rules evolve.
|
||||
|
||||
## Phase Transition Rules
|
||||
|
||||
The first transition rule format lives in profile metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"phase_transitions": [
|
||||
{
|
||||
"kind": "episode",
|
||||
"from_phase": "fluid",
|
||||
"to_phase": "stabilized",
|
||||
"min_age_days": 7,
|
||||
"reason": "episode old enough to stabilize"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Transitions into `stabilized` or `rigid` still require review. The rule only
|
||||
creates a dry-run `transition_phase` action.
|
||||
|
||||
## Refresh And Compaction
|
||||
|
||||
Refresh actions are only produced when the profile enables refresh and the
|
||||
caller supplies source digests:
|
||||
|
||||
```bash
|
||||
phase-memory graph lifecycle tests/fixtures/memory-graph.json \
|
||||
--profile tests/fixtures/memory-profile.json \
|
||||
--refresh-digest event.restart=new
|
||||
```
|
||||
|
||||
Compaction can be requested through `--compact-node` or profile compaction
|
||||
metadata with `node_ids` / `compact_node_ids`.
|
||||
|
||||
## Runtime API
|
||||
|
||||
Use `PhaseMemoryRuntime.plan_lifecycle_with_profile(...)` when callers have a
|
||||
profile and graph envelope. The returned runtime envelope includes:
|
||||
|
||||
- `dry_run_actions`
|
||||
- `profile_id`
|
||||
- `rule_config`
|
||||
- source digest and compaction parameters
|
||||
@@ -11,10 +11,16 @@ from .bridge import (
|
||||
)
|
||||
from .contracts import graph_from_markitect, profile_from_markitect
|
||||
from .lifecycle import (
|
||||
LifecycleRuleConfig,
|
||||
PhaseTransitionRule,
|
||||
RetentionRule,
|
||||
plan_compaction,
|
||||
plan_lifecycle_from_profile,
|
||||
plan_phase_transition,
|
||||
plan_phase_transitions_from_rules,
|
||||
plan_refresh,
|
||||
plan_retention,
|
||||
plan_retention_from_rules,
|
||||
)
|
||||
from .models import (
|
||||
ActivationPlan,
|
||||
@@ -69,6 +75,9 @@ __all__ = [
|
||||
"ReviewDecision",
|
||||
"ReviewRecord",
|
||||
"PhaseMemoryRuntime",
|
||||
"LifecycleRuleConfig",
|
||||
"PhaseTransitionRule",
|
||||
"RetentionRule",
|
||||
"POLICY_OPERATION_POINTS",
|
||||
"MemoryOperation",
|
||||
"MARKITECT_PACKAGE_REQUEST_SCHEMA",
|
||||
@@ -84,10 +93,13 @@ __all__ = [
|
||||
"make_review_record",
|
||||
"plan_activation",
|
||||
"plan_compaction",
|
||||
"plan_lifecycle_from_profile",
|
||||
"plan_phase_transition",
|
||||
"plan_phase_transitions_from_rules",
|
||||
"plan_profile_execution",
|
||||
"plan_refresh",
|
||||
"plan_retention",
|
||||
"plan_retention_from_rules",
|
||||
"profile_from_markitect",
|
||||
"path_event",
|
||||
"package_request_from_selection",
|
||||
|
||||
@@ -41,6 +41,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
lifecycle = graph_subparsers.add_parser("lifecycle", help="Plan graph lifecycle actions")
|
||||
lifecycle.add_argument("graph", type=Path)
|
||||
lifecycle.add_argument("--profile", type=Path, help="Derive lifecycle thresholds and rules from a memory profile")
|
||||
lifecycle.add_argument("--stale-after-days", type=int)
|
||||
lifecycle.add_argument("--delete-after-days", type=int)
|
||||
lifecycle.add_argument("--refresh-digest", action="append", default=[], metavar="NODE_ID=DIGEST")
|
||||
@@ -91,6 +92,15 @@ def _profile_plan(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict
|
||||
|
||||
|
||||
def _graph_lifecycle(args: argparse.Namespace, runtime: PhaseMemoryRuntime) -> dict[str, Any]:
|
||||
if args.profile:
|
||||
return runtime.plan_lifecycle_with_profile(
|
||||
_read_json(args.profile),
|
||||
_read_json(args.graph),
|
||||
source_ref=str(args.graph),
|
||||
profile_source_ref=str(args.profile),
|
||||
refresh_digests=_parse_digest_args(args.refresh_digest),
|
||||
compact_node_ids=tuple(args.compact_node),
|
||||
)
|
||||
return runtime.plan_lifecycle(
|
||||
_read_json(args.graph),
|
||||
source_ref=str(args.graph),
|
||||
|
||||
@@ -2,12 +2,104 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryNode, MemoryPhase
|
||||
from .models import LifecycleAction, LifecycleActionKind, LifecycleState, MemoryGraph, MemoryNode, MemoryPhase, ProfileIntent
|
||||
from .utils import parse_iso_datetime, stable_digest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetentionRule:
|
||||
node_kind: str = ""
|
||||
stale_after_days: int | None = None
|
||||
delete_after_days: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {}
|
||||
if self.node_kind:
|
||||
data["node_kind"] = self.node_kind
|
||||
if self.stale_after_days is not None:
|
||||
data["stale_after_days"] = self.stale_after_days
|
||||
if self.delete_after_days is not None:
|
||||
data["delete_after_days"] = self.delete_after_days
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseTransitionRule:
|
||||
target_phase: MemoryPhase
|
||||
node_kind: str = ""
|
||||
from_phase: MemoryPhase | None = None
|
||||
min_age_days: int | None = None
|
||||
reason: str = ""
|
||||
|
||||
def matches(self, node: MemoryNode, *, now: datetime) -> bool:
|
||||
if self.node_kind and node.kind != self.node_kind:
|
||||
return False
|
||||
if self.from_phase is not None and node.phase != self.from_phase:
|
||||
return False
|
||||
if self.min_age_days is not None:
|
||||
age = _age_days(node, now)
|
||||
if age is None or age < self.min_age_days:
|
||||
return False
|
||||
return node.phase != self.target_phase
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {"target_phase": self.target_phase.value}
|
||||
if self.node_kind:
|
||||
data["node_kind"] = self.node_kind
|
||||
if self.from_phase is not None:
|
||||
data["from_phase"] = self.from_phase.value
|
||||
if self.min_age_days is not None:
|
||||
data["min_age_days"] = self.min_age_days
|
||||
if self.reason:
|
||||
data["reason"] = self.reason
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleRuleConfig:
|
||||
retention_default: RetentionRule | None = None
|
||||
retention_by_kind: dict[str, RetentionRule] | None = None
|
||||
transition_rules: tuple[PhaseTransitionRule, ...] = ()
|
||||
refresh_enabled: bool = False
|
||||
compact_node_ids: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_profile(cls, profile: ProfileIntent | dict[str, Any]) -> "LifecycleRuleConfig":
|
||||
profile_intent = profile if isinstance(profile, ProfileIntent) else ProfileIntent.from_mapping(profile)
|
||||
retention_default, retention_by_kind = _retention_rules(profile_intent.retention)
|
||||
return cls(
|
||||
retention_default=retention_default,
|
||||
retention_by_kind=retention_by_kind,
|
||||
transition_rules=_transition_rules(profile_intent),
|
||||
refresh_enabled=_refresh_enabled(profile_intent.refresh),
|
||||
compact_node_ids=_string_tuple(
|
||||
profile_intent.compaction.get("node_ids")
|
||||
or profile_intent.compaction.get("compact_node_ids")
|
||||
or ()
|
||||
),
|
||||
)
|
||||
|
||||
def retention_for(self, node: MemoryNode) -> RetentionRule | None:
|
||||
by_kind = self.retention_by_kind or {}
|
||||
return by_kind.get(node.kind) or self.retention_default
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"retention_default": self.retention_default.to_dict() if self.retention_default else None,
|
||||
"retention_by_kind": {
|
||||
key: value.to_dict()
|
||||
for key, value in sorted((self.retention_by_kind or {}).items())
|
||||
},
|
||||
"transition_rules": [rule.to_dict() for rule in self.transition_rules],
|
||||
"refresh_enabled": self.refresh_enabled,
|
||||
"compact_node_ids": list(self.compact_node_ids),
|
||||
}
|
||||
|
||||
|
||||
def plan_phase_transition(
|
||||
node: MemoryNode,
|
||||
target_phase: MemoryPhase,
|
||||
@@ -67,6 +159,47 @@ def plan_retention(
|
||||
return tuple(actions)
|
||||
|
||||
|
||||
def plan_retention_from_rules(
|
||||
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
|
||||
config: LifecycleRuleConfig,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[LifecycleAction, ...]:
|
||||
actions: list[LifecycleAction] = []
|
||||
for node in nodes:
|
||||
rule = config.retention_for(node)
|
||||
if rule is None:
|
||||
continue
|
||||
actions.extend(
|
||||
plan_retention(
|
||||
[node],
|
||||
stale_after_days=rule.stale_after_days,
|
||||
delete_after_days=rule.delete_after_days,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
|
||||
def plan_phase_transitions_from_rules(
|
||||
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
|
||||
config: LifecycleRuleConfig,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[LifecycleAction, ...]:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
actions: list[LifecycleAction] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for rule in config.transition_rules:
|
||||
for node in nodes:
|
||||
key = (node.node_id, rule.target_phase.value)
|
||||
if key in seen or not rule.matches(node, now=now):
|
||||
continue
|
||||
actions.append(plan_phase_transition(node, rule.target_phase, reason=rule.reason))
|
||||
seen.add(key)
|
||||
return tuple(actions)
|
||||
|
||||
|
||||
def plan_compaction(
|
||||
nodes: list[MemoryNode] | tuple[MemoryNode, ...],
|
||||
*,
|
||||
@@ -113,6 +246,29 @@ def plan_refresh(
|
||||
return tuple(actions)
|
||||
|
||||
|
||||
def plan_lifecycle_from_profile(
|
||||
graph: MemoryGraph,
|
||||
profile: ProfileIntent | dict[str, Any],
|
||||
*,
|
||||
refresh_digests: dict[str, str] | None = None,
|
||||
compact_node_ids: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
) -> tuple[LifecycleAction, ...]:
|
||||
config = LifecycleRuleConfig.from_profile(profile)
|
||||
actions: list[LifecycleAction] = []
|
||||
actions.extend(plan_retention_from_rules(graph.nodes, config, now=now))
|
||||
actions.extend(plan_phase_transitions_from_rules(graph.nodes, config, now=now))
|
||||
if config.refresh_enabled and refresh_digests:
|
||||
actions.extend(plan_refresh(graph.nodes, source_digest_by_node_id=refresh_digests))
|
||||
|
||||
by_id = graph.node_by_id()
|
||||
compact_ids = tuple(dict.fromkeys((*config.compact_node_ids, *compact_node_ids)))
|
||||
compact_nodes = [by_id[node_id] for node_id in compact_ids if node_id in by_id]
|
||||
if compact_nodes:
|
||||
actions.append(plan_compaction(compact_nodes))
|
||||
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:
|
||||
@@ -125,3 +281,102 @@ def _summary_text(nodes: list[MemoryNode] | tuple[MemoryNode, ...]) -> str:
|
||||
if not parts:
|
||||
return "Summary proposed for selected memory nodes."
|
||||
return " ".join(parts)[:240]
|
||||
|
||||
|
||||
def _retention_rules(data: dict[str, Any]) -> tuple[RetentionRule | None, dict[str, RetentionRule]]:
|
||||
if not data:
|
||||
return None, {}
|
||||
default = _retention_rule("", data) if _is_retention_rule(data) else None
|
||||
by_kind: dict[str, RetentionRule] = {}
|
||||
for key, value in data.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
rule = _retention_rule("" if key == "default" else str(key), value)
|
||||
if key == "default":
|
||||
default = rule
|
||||
else:
|
||||
by_kind[str(key)] = rule
|
||||
if default is None and len(by_kind) == 1:
|
||||
only = next(iter(by_kind.values()))
|
||||
default = RetentionRule(stale_after_days=only.stale_after_days, delete_after_days=only.delete_after_days)
|
||||
return default, by_kind
|
||||
|
||||
|
||||
def _retention_rule(node_kind: str, data: dict[str, Any]) -> RetentionRule:
|
||||
return RetentionRule(
|
||||
node_kind=node_kind,
|
||||
stale_after_days=_optional_int(data.get("stale_after_days")),
|
||||
delete_after_days=_optional_int(data.get("delete_after_days")),
|
||||
)
|
||||
|
||||
|
||||
def _is_retention_rule(data: dict[str, Any]) -> bool:
|
||||
return "stale_after_days" in data or "delete_after_days" in data
|
||||
|
||||
|
||||
def _transition_rules(profile: ProfileIntent) -> tuple[PhaseTransitionRule, ...]:
|
||||
raw_rules = (
|
||||
profile.metadata.get("phase_transitions")
|
||||
or profile.metadata.get("transition_rules")
|
||||
or _metadata_lifecycle(profile).get("phase_transitions")
|
||||
or ()
|
||||
)
|
||||
rules: list[PhaseTransitionRule] = []
|
||||
for item in (raw_rules if isinstance(raw_rules, (list, tuple)) else ()):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target_phase = _phase(item.get("to_phase") or item.get("target_phase"))
|
||||
if target_phase is None:
|
||||
continue
|
||||
when = item.get("when") if isinstance(item.get("when"), dict) else {}
|
||||
min_age_days = item.get("min_age_days")
|
||||
if min_age_days is None:
|
||||
min_age_days = when.get("min_age_days")
|
||||
rules.append(
|
||||
PhaseTransitionRule(
|
||||
target_phase=target_phase,
|
||||
node_kind=str(item.get("kind") or item.get("node_kind") or ""),
|
||||
from_phase=_phase(item.get("from_phase")),
|
||||
min_age_days=_optional_int(min_age_days),
|
||||
reason=str(item.get("reason") or ""),
|
||||
)
|
||||
)
|
||||
return tuple(rules)
|
||||
|
||||
|
||||
def _metadata_lifecycle(profile: ProfileIntent) -> dict[str, Any]:
|
||||
lifecycle = profile.metadata.get("lifecycle")
|
||||
return dict(lifecycle) if isinstance(lifecycle, dict) else {}
|
||||
|
||||
|
||||
def _refresh_enabled(data: dict[str, Any]) -> bool:
|
||||
if not data:
|
||||
return False
|
||||
trigger = str(data.get("trigger") or data.get("mode") or "enabled").lower()
|
||||
return trigger not in {"disabled", "off", "none", "false"}
|
||||
|
||||
|
||||
def _phase(value: Any) -> MemoryPhase | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return MemoryPhase(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _string_tuple(value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
return tuple(str(item) for item in value)
|
||||
|
||||
@@ -17,7 +17,7 @@ from .adapters import (
|
||||
)
|
||||
from .bridge import MARKITECT_PACKAGE_REQUEST_SCHEMA, package_request_from_selection, package_response_envelope
|
||||
from .contracts import ContractIngressResult, graph_from_markitect, profile_from_markitect
|
||||
from .lifecycle import plan_compaction, plan_refresh, plan_retention
|
||||
from .lifecycle import LifecycleRuleConfig, plan_compaction, plan_lifecycle_from_profile, plan_refresh, plan_retention
|
||||
from .models import (
|
||||
Diagnostic,
|
||||
LifecycleAction,
|
||||
@@ -150,6 +150,57 @@ class PhaseMemoryRuntime:
|
||||
},
|
||||
)
|
||||
|
||||
def plan_lifecycle_with_profile(
|
||||
self,
|
||||
profile_data: dict[str, Any],
|
||||
graph_data: dict[str, Any],
|
||||
*,
|
||||
source_ref: str = "mapping",
|
||||
profile_source_ref: str = "profile",
|
||||
refresh_digests: dict[str, str] | None = None,
|
||||
compact_node_ids: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile_result = profile_from_markitect(profile_data)
|
||||
graph_result = graph_from_markitect(graph_data)
|
||||
diagnostics = list(profile_result.diagnostics) + list(graph_result.diagnostics)
|
||||
actions: tuple[LifecycleAction, ...] = ()
|
||||
rule_config = None
|
||||
|
||||
if profile_result.valid and graph_result.valid:
|
||||
profile: ProfileIntent = profile_result.value
|
||||
graph: MemoryGraph = graph_result.value
|
||||
rule_config = LifecycleRuleConfig.from_profile(profile)
|
||||
actions = plan_lifecycle_from_profile(
|
||||
graph,
|
||||
profile,
|
||||
refresh_digests=refresh_digests or {},
|
||||
compact_node_ids=compact_node_ids,
|
||||
now=now,
|
||||
)
|
||||
|
||||
return self._envelope(
|
||||
"graph.lifecycle.plan",
|
||||
subject_kind="memory_graph",
|
||||
subject_id=graph_result.subject_id,
|
||||
valid=profile_result.valid and graph_result.valid,
|
||||
diagnostics=diagnostics,
|
||||
source_ref=source_ref,
|
||||
data={
|
||||
"graph_id": graph_result.subject_id,
|
||||
"profile_id": profile_result.subject_id,
|
||||
"dry_run_actions": [action.to_dict() for action in actions],
|
||||
"rule_config": rule_config.to_dict() if rule_config else None,
|
||||
"parameters": compact_dict(
|
||||
{
|
||||
"profile_source_ref": profile_source_ref,
|
||||
"refresh_digests": refresh_digests or {},
|
||||
"compact_node_ids": list(compact_node_ids),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def plan_activation(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
|
||||
@@ -29,7 +29,7 @@ KONTEXTUAL_DELEGATION_SCHEMA = "phase_memory.kontextual.delegation.v1"
|
||||
SERVICE_OPERATIONS = {
|
||||
"profile.plan": {"request": ["profile"], "response": "runtime_envelope"},
|
||||
"graph.import": {"request": ["graph"], "response": "runtime_envelope"},
|
||||
"graph.lifecycle.plan": {"request": ["graph", "parameters"], "response": "runtime_envelope"},
|
||||
"graph.lifecycle.plan": {"request": ["graph", "parameters", "profile?"], "response": "runtime_envelope"},
|
||||
"lifecycle.apply": {"request": ["actions", "review_record"], "response": "runtime_envelope"},
|
||||
"graph.activation.plan": {"request": ["graph", "budget"], "response": "runtime_envelope"},
|
||||
"package.compile": {"request": ["selection"], "response": "runtime_envelope"},
|
||||
@@ -353,6 +353,25 @@ class LocalServiceRunner:
|
||||
return self.runtime.plan_profile(payload["profile"], source_ref=payload.get("source_ref", "service"))
|
||||
if operation == "graph.import":
|
||||
return self.runtime.import_graph(payload["graph"], source_ref=payload.get("source_ref", "service"))
|
||||
if operation == "graph.lifecycle.plan":
|
||||
parameters = payload.get("parameters", {})
|
||||
if payload.get("profile"):
|
||||
return self.runtime.plan_lifecycle_with_profile(
|
||||
payload["profile"],
|
||||
payload["graph"],
|
||||
source_ref=payload.get("source_ref", "service"),
|
||||
profile_source_ref=payload.get("profile_source_ref", "service-profile"),
|
||||
refresh_digests=dict(parameters.get("refresh_digests") or {}),
|
||||
compact_node_ids=tuple(parameters.get("compact_node_ids") or ()),
|
||||
)
|
||||
return self.runtime.plan_lifecycle(
|
||||
payload["graph"],
|
||||
source_ref=payload.get("source_ref", "service"),
|
||||
stale_after_days=parameters.get("stale_after_days"),
|
||||
delete_after_days=parameters.get("delete_after_days"),
|
||||
refresh_digests=dict(parameters.get("refresh_digests") or {}),
|
||||
compact_node_ids=tuple(parameters.get("compact_node_ids") or ()),
|
||||
)
|
||||
if operation == "graph.activation.plan":
|
||||
budget = payload.get("budget", {})
|
||||
return self.runtime.plan_activation(
|
||||
|
||||
@@ -47,6 +47,29 @@ def test_cli_graph_lifecycle_emits_dry_run_actions(capsys) -> None:
|
||||
assert [action["action"] for action in output["data"]["dry_run_actions"]][:2] == ["mark_stale", "refresh"]
|
||||
|
||||
|
||||
def test_cli_graph_lifecycle_can_use_profile_rules(capsys) -> None:
|
||||
code = main(
|
||||
[
|
||||
"graph",
|
||||
"lifecycle",
|
||||
str(FIXTURES / "memory-graph.json"),
|
||||
"--profile",
|
||||
str(FIXTURES / "memory-profile.json"),
|
||||
"--refresh-digest",
|
||||
"event.restart=new",
|
||||
]
|
||||
)
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
actions = {(action["target_id"], action["action"]) for action in output["data"]["dry_run_actions"]}
|
||||
assert code == 0
|
||||
assert output["operation"] == "graph.lifecycle.plan"
|
||||
assert output["data"]["profile_id"] == "phase-memory-fixture-profile"
|
||||
assert output["data"]["rule_config"]["retention_default"]["stale_after_days"] == 7
|
||||
assert ("event.restart", "mark_stale") in actions
|
||||
assert ("event.restart", "refresh") in actions
|
||||
|
||||
|
||||
def test_cli_graph_activate_emits_selection(capsys) -> None:
|
||||
code = main(
|
||||
[
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from phase_memory.lifecycle import plan_compaction, plan_phase_transition, plan_refresh, plan_retention
|
||||
from phase_memory.models import LifecycleActionKind, LifecycleState, MemoryNode, MemoryPhase
|
||||
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:
|
||||
@@ -47,3 +64,61 @@ def test_compaction_and_refresh_are_reviewable_plans() -> None:
|
||||
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"
|
||||
|
||||
@@ -58,6 +58,24 @@ def test_service_runner_handles_health() -> None:
|
||||
assert response["ok"] is True
|
||||
|
||||
|
||||
def test_service_runner_handles_profile_driven_lifecycle_plan() -> None:
|
||||
runner = LocalServiceRunner()
|
||||
|
||||
response = runner.handle(
|
||||
"graph.lifecycle.plan",
|
||||
{
|
||||
"profile": _load("memory-profile.json"),
|
||||
"graph": _load("memory-graph.json"),
|
||||
"parameters": {"refresh_digests": {"event.restart": "new"}},
|
||||
},
|
||||
)
|
||||
|
||||
actions = {(action["target_id"], action["action"]) for action in response["data"]["dry_run_actions"]}
|
||||
assert response["operation"] == "graph.lifecycle.plan"
|
||||
assert response["data"]["profile_id"] == "phase-memory-fixture-profile"
|
||||
assert ("event.restart", "refresh") in actions
|
||||
|
||||
|
||||
def test_profile_driven_runtime_config_resolves_file_backed_adapters(tmp_path) -> None:
|
||||
config = RuntimeConfig.from_profile(
|
||||
{
|
||||
|
||||
@@ -44,13 +44,14 @@ not what adjacent repositories may already provide.
|
||||
|
||||
## Current Baseline - 2026-05-18
|
||||
|
||||
Overall maturity: **4.3 / 5**
|
||||
Overall maturity: **4.4 / 5**
|
||||
|
||||
The repo has crossed from intent-only into a working deterministic library
|
||||
foundation, a usable local runtime facade, a CLI, a file-backed local
|
||||
workspace, first-slice policy/review/audit gates, a concrete Markitect package
|
||||
bridge, deterministic activation quality helpers, and first-slice service
|
||||
readiness contracts with profile-driven runtime adapter resolution.
|
||||
readiness contracts with profile-driven runtime adapter resolution and
|
||||
profile-derived lifecycle rules.
|
||||
|
||||
| Dimension | Current | Target | Evidence | Needed Next |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
@@ -58,18 +59,18 @@ readiness contracts with profile-driven runtime adapter resolution.
|
||||
| Package foundation | 4.0 | 4.0 | Python package, exports, runtime facade, CLI entrypoint, config, service contracts, dependency-light tests | Maintain public API compatibility as adapters expand. |
|
||||
| Profile contract ingress | 3.2 | 4.0 | Markitect-compatible profile loading, diagnostics, runtime envelopes, profile-derived runtime config, local adapter alias normalization | Add richer compatibility coverage. |
|
||||
| Graph/event contract ingress | 3.5 | 4.0 | Graph loading, edge endpoint diagnostics, event model, JSONL event log, export, repair diagnostics, service import/export contracts | Add broader external adapter fixtures. |
|
||||
| Phase domain model | 3.0 | 4.0 | Phases, memory kinds, lifecycle states, actions, explicit path records | Add profile-driven transition rule evaluation and migration semantics. |
|
||||
| Phase domain model | 3.4 | 4.0 | Phases, memory kinds, lifecycle states, actions, explicit path records, profile-derived transition rules | Add migration semantics. |
|
||||
| Profile execution planning | 3.8 | 4.0 | Adapter plan, capabilities, policy gates, fallback behavior, CLI output, snapshot fixture, service contract, config-driven local adapter resolution | Add external adapter injection coverage. |
|
||||
| Lifecycle planning | 3.0 | 4.0 | Transition, retention, refresh, compaction dry-run plans, review-gated local apply | Add profile-driven rule evaluation and service apply contracts. |
|
||||
| Lifecycle planning | 3.6 | 4.0 | Transition, retention, refresh, compaction dry-run plans, profile-driven lifecycle rules, review-gated local apply | Add service apply contracts and migration semantics. |
|
||||
| Activation planning | 3.8 | 5.0 | Budgeted selection, Markitect-compatible selection output, package request envelope, graph neighborhoods, event paths, ranking, metadata preservation, metrics | Add semantic-index adapters and broader evaluation corpora. |
|
||||
| Local persistence | 3.0 | 4.0 | Versioned local workspace, file-backed graph store, JSONL event log, JSONL audit sink | Add migration/repair utilities and stronger durability semantics. |
|
||||
| Policy and audit | 3.2 | 5.0 | Operation points, policy gateway checks, audit schema, review records, redaction, activation denials | Add external policy adapters and richer audit retention behavior. |
|
||||
| Observability and diagnostics | 3.5 | 4.0 | Planner diagnostics, runtime diagnostics, event log corruption checks, repair diagnostics, policy denial diagnostics, health envelopes, adapter status | Add production telemetry adapters. |
|
||||
| Markitect interop | 3.5 | 4.0 | Compatible contract ingress, optional validation boundary, enriched selection metadata, package request/response envelopes | Add live optional Markitect compiler adapter when available. |
|
||||
| Kontextual/Infospace interop | 2.5 | 4.0 | Boundaries documented, small derived fixtures, activation quality report fixture, Kontextual delegation envelope | Add live fake/real delegation adapters and broader Infospace reports. |
|
||||
| Testing and evaluation | 4.0 | 4.0 | 54 deterministic tests over planners, adapters, runtime envelopes, CLI, snapshots, file-store round trips, apply denial, review records, audit schema, policy redaction, Markitect bridge fixtures, retrieval, activation metrics, service contracts, config, health, conformance, and adapter resolution | Add broader evaluation corpora. |
|
||||
| Service readiness | 3.8 | 4.0 | Runtime ports, service contracts, config model, profile-derived adapter resolution, health checks, local service runner, adapter conformance helpers | Add framework-specific bindings and production adapter packs. |
|
||||
| Developer experience | 3.5 | 4.0 | README quick start, package map, runtime facade docs, CLI examples, local persistence guide, service adapter resolution docs | Add troubleshooting and richer examples. |
|
||||
| Testing and evaluation | 4.0 | 4.0 | 58 deterministic tests over planners, adapters, runtime envelopes, CLI, snapshots, file-store round trips, lifecycle rules, apply denial, review records, audit schema, policy redaction, Markitect bridge fixtures, retrieval, activation metrics, service contracts, config, health, conformance, and adapter resolution | Add broader evaluation corpora. |
|
||||
| Service readiness | 3.9 | 4.0 | Runtime ports, service contracts, config model, profile-derived adapter resolution, health checks, local service runner lifecycle support, adapter conformance helpers | Add framework-specific bindings and production adapter packs. |
|
||||
| Developer experience | 3.6 | 4.0 | README quick start, package map, runtime facade docs, CLI examples, local persistence guide, service adapter resolution docs, lifecycle rule docs | Add troubleshooting and richer examples. |
|
||||
|
||||
## Progress Update - PMEM-WP-0002
|
||||
|
||||
@@ -173,6 +174,26 @@ Remaining maturity blockers:
|
||||
- Optional framework-specific service bindings.
|
||||
- Production telemetry and audit retention integrations.
|
||||
|
||||
## Progress Update - PMEM-WP-0009
|
||||
|
||||
Closed on 2026-05-18:
|
||||
|
||||
- Added `LifecycleRuleConfig` for profile-derived retention, transition,
|
||||
refresh, and compaction settings.
|
||||
- Added profile-derived retention planning.
|
||||
- Added review-gated phase transition rules from profile metadata.
|
||||
- Added `plan_lifecycle_from_profile(...)`.
|
||||
- Added `PhaseMemoryRuntime.plan_lifecycle_with_profile(...)`.
|
||||
- Added CLI `graph lifecycle --profile`.
|
||||
- Added lifecycle rule docs, README examples, and regression tests.
|
||||
|
||||
Remaining maturity blockers:
|
||||
|
||||
- Migration semantics for profile/rule changes over durable stores.
|
||||
- Service apply contracts.
|
||||
- Live external adapter implementations.
|
||||
- Broader evaluation corpora.
|
||||
|
||||
## Progress Update - PMEM-WP-0004
|
||||
|
||||
Closed on 2026-05-18:
|
||||
@@ -237,6 +258,7 @@ flowchart TD
|
||||
WP6["PMEM-WP-0006\nRetrieval and activation quality"]
|
||||
WP7["PMEM-WP-0007\nService readiness and adapters"]
|
||||
WP8["PMEM-WP-0008\nProfile-driven runtime config"]
|
||||
WP9["PMEM-WP-0009\nProfile-driven lifecycle rules"]
|
||||
|
||||
WP1 --> WP2
|
||||
WP2 --> WP3
|
||||
@@ -252,6 +274,9 @@ flowchart TD
|
||||
WP3 --> WP8
|
||||
WP5 --> WP8
|
||||
WP7 --> WP8
|
||||
WP2 --> WP9
|
||||
WP4 --> WP9
|
||||
WP8 --> WP9
|
||||
```
|
||||
|
||||
## Next Tracking Cadence
|
||||
|
||||
118
workplans/PMEM-WP-0009-profile-driven-lifecycle-rules.md
Normal file
118
workplans/PMEM-WP-0009-profile-driven-lifecycle-rules.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
id: PMEM-WP-0009
|
||||
type: workplan
|
||||
title: "Profile-Driven Lifecycle Rules"
|
||||
domain: markitect
|
||||
repo: phase-memory
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: phase-memory
|
||||
created: "2026-05-18"
|
||||
updated: "2026-05-18"
|
||||
---
|
||||
|
||||
# PMEM-WP-0009: Profile-Driven Lifecycle Rules
|
||||
|
||||
## Goal
|
||||
|
||||
Let Markitect-compatible memory profiles drive lifecycle planning directly,
|
||||
instead of requiring every retention, refresh, compaction, and transition
|
||||
parameter to be passed by hand.
|
||||
|
||||
This moves the phase domain model and lifecycle planning rows toward their
|
||||
target maturity by turning profile policy into executable dry-run behavior.
|
||||
|
||||
## Current Evidence
|
||||
|
||||
PMEM-WP-0008 added profile-derived runtime configuration. Lifecycle planning
|
||||
still depended on explicit CLI/runtime parameters for stale/delete thresholds,
|
||||
refresh digests, and compaction targets.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Mutate durable lifecycle state automatically.
|
||||
- Add physical delete behavior.
|
||||
- Replace review gates for stabilization, rigid promotion, refresh, or
|
||||
compaction.
|
||||
- Build a profile migration engine.
|
||||
|
||||
## Implementation Update - 2026-05-18
|
||||
|
||||
Implemented the first profile-driven lifecycle rules slice:
|
||||
|
||||
- Added `LifecycleRuleConfig` with retention, transition, refresh, and
|
||||
compaction rule extraction from `ProfileIntent` or mappings.
|
||||
- Added profile-derived retention planning.
|
||||
- Added profile metadata phase transition rules.
|
||||
- Added `plan_lifecycle_from_profile(...)`.
|
||||
- Added `PhaseMemoryRuntime.plan_lifecycle_with_profile(...)`.
|
||||
- Added CLI `graph lifecycle --profile`.
|
||||
- Added local service runner support for profile-driven lifecycle planning.
|
||||
- Added docs, README examples, and regression tests.
|
||||
|
||||
## T01 - Add lifecycle rule config
|
||||
|
||||
```task
|
||||
id: PMEM-WP-0009-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Derive retention, transition, refresh, and compaction rule settings from a
|
||||
profile.
|
||||
|
||||
## T02 - Plan retention from profile rules
|
||||
|
||||
```task
|
||||
id: PMEM-WP-0009-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Support default retention rules and node-kind-specific retention rules.
|
||||
|
||||
## T03 - Plan phase transitions from profile metadata
|
||||
|
||||
```task
|
||||
id: PMEM-WP-0009-T03
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Support first-slice metadata transition rules with node kind, source phase,
|
||||
target phase, minimum age, and review-gated transition actions.
|
||||
|
||||
## T04 - Wire runtime and CLI profile lifecycle planning
|
||||
|
||||
```task
|
||||
id: PMEM-WP-0009-T04
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Expose profile-driven lifecycle planning through the runtime facade and CLI.
|
||||
Include local service runner handling for the same contract.
|
||||
|
||||
## T05 - Document and test the rule path
|
||||
|
||||
```task
|
||||
id: PMEM-WP-0009-T05
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Add lifecycle rule docs, README examples, fixture coverage, transition rule
|
||||
coverage, and CLI regression coverage.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Profile retention rules can produce lifecycle actions.
|
||||
- Profile transition metadata can produce review-gated phase transitions.
|
||||
- Refresh and compaction remain deliberate caller/profile inputs.
|
||||
- Runtime and CLI envelopes expose the derived rule config.
|
||||
- Tests cover fixture-derived rules and explicit transition rules.
|
||||
|
||||
## Closure Review - 2026-05-18
|
||||
|
||||
Closed after adding executable profile lifecycle rules, runtime/CLI support,
|
||||
documentation, and deterministic regression tests.
|
||||
Reference in New Issue
Block a user