diff --git a/README.md b/README.md index ba73a42..acffce3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/lifecycle-rules.md b/docs/lifecycle-rules.md new file mode 100644 index 0000000..2655d31 --- /dev/null +++ b/docs/lifecycle-rules.md @@ -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 diff --git a/src/phase_memory/__init__.py b/src/phase_memory/__init__.py index 860afa5..dbdadf1 100644 --- a/src/phase_memory/__init__.py +++ b/src/phase_memory/__init__.py @@ -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", diff --git a/src/phase_memory/cli.py b/src/phase_memory/cli.py index feb0704..6420729 100644 --- a/src/phase_memory/cli.py +++ b/src/phase_memory/cli.py @@ -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), diff --git a/src/phase_memory/lifecycle.py b/src/phase_memory/lifecycle.py index 948dec0..51b363f 100644 --- a/src/phase_memory/lifecycle.py +++ b/src/phase_memory/lifecycle.py @@ -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) diff --git a/src/phase_memory/runtime.py b/src/phase_memory/runtime.py index 919ae8e..e2e1805 100644 --- a/src/phase_memory/runtime.py +++ b/src/phase_memory/runtime.py @@ -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], diff --git a/src/phase_memory/service.py b/src/phase_memory/service.py index bb2b494..76fe564 100644 --- a/src/phase_memory/service.py +++ b/src/phase_memory/service.py @@ -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( diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b43499..f85be68 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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( [ diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 0cc266d..aca526d 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -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" diff --git a/tests/test_service_readiness.py b/tests/test_service_readiness.py index 25f040d..8072e16 100644 --- a/tests/test_service_readiness.py +++ b/tests/test_service_readiness.py @@ -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( { diff --git a/workplans/PMEM-MATURITY-SCORECARD.md b/workplans/PMEM-MATURITY-SCORECARD.md index f730ea6..a3378fd 100644 --- a/workplans/PMEM-MATURITY-SCORECARD.md +++ b/workplans/PMEM-MATURITY-SCORECARD.md @@ -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 diff --git a/workplans/PMEM-WP-0009-profile-driven-lifecycle-rules.md b/workplans/PMEM-WP-0009-profile-driven-lifecycle-rules.md new file mode 100644 index 0000000..3aec2f0 --- /dev/null +++ b/workplans/PMEM-WP-0009-profile-driven-lifecycle-rules.md @@ -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.