diff --git a/docs/memory-graph-runtime.md b/docs/memory-graph-runtime.md new file mode 100644 index 0000000..6e8b15e --- /dev/null +++ b/docs/memory-graph-runtime.md @@ -0,0 +1,42 @@ +# Memory Graph Runtime + +`kontextual-engine` owns the operational side of the Markitect memory graph +contracts. The engine imports Markitect-compatible graph/profile envelopes, +maps source contract ids to stable runtime ids, and stores graph nodes, edges, +and append-only events for later governed retrieval and lifecycle operations. + +This repository does not redefine the Markitect syntax vocabulary. The first +runtime slice checks only schema compatibility, required ids, edge endpoint +integrity, and append-only event identity. Node and edge kind validation remains +with `markitect-tool`. + +## Implemented Slice + +- `MemoryGraphImportResult` maps `markitect.memory.graph.v1` contracts into + runtime records. +- `MemoryProfileRecord` maps `markitect.memory.profile.v1` profiles without + enforcing runtime policy, latency, retention, or compaction declarations yet. +- `MemoryGraphRepository` defines the storage port for profile, node, edge, and + event records. +- `InMemoryMemoryGraphRepository` provides deterministic local storage for + tests and future service wiring. +- `MemoryRuntimeService.import_markitect_graph()` persists an imported graph and + can attach an audit event when an `OperationContext` is supplied. + +## Boundary + +`markitect-tool` remains responsible for: + +- profile and graph syntax validation +- graph selection compilation into context packages +- deterministic package summaries and source-span preservation + +`kontextual-engine` is responsible for: + +- runtime ids and persistence +- append-only event storage +- permission-aware retrieval and context assembly +- retention, refresh, compaction, review gates, and audit behavior + +`infospace-bench` should consume these records and Markitect fixtures to measure +retrieval quality, latency, budget pressure, and regression behavior. diff --git a/src/kontextual_engine/__init__.py b/src/kontextual_engine/__init__.py index 620bc22..1b37e96 100644 --- a/src/kontextual_engine/__init__.py +++ b/src/kontextual_engine/__init__.py @@ -13,6 +13,7 @@ from .artifacts import ( ) from .adapters.memory import InMemoryAssetRegistryRepository from .adapters.memory import InMemoryBlobStorage +from .adapters.memory import InMemoryMemoryGraphRepository from .adapters.local_files import LocalBlobStorage from .adapters.s3 import S3BlobStorage from .adapters.sqlite import SQLiteAssetRegistryRepository @@ -49,12 +50,20 @@ from .core import ( IngestionJobStatus, KnowledgeAsset, LifecycleState, + MARKITECT_MEMORY_GRAPH_SCHEMA_VERSION, + MARKITECT_MEMORY_PROFILE_SCHEMA_VERSION, MetadataFieldDefinition, MetadataRecord, MetadataSchema, MetadataSchemaAssignment, MetadataValidationIssue, MetadataValueType, + MemoryEdgeRecord, + MemoryEventRecord, + MemoryGraphImportResult, + MemoryNodeRecord, + MemoryProfileRecord, + MemorySourceSpan, NormalizedDocument, OperationContext, PolicyDecision, @@ -107,6 +116,7 @@ from .ports import ( BlobWriteResult, DirectorySourceConnector, FormatExtractor, + MemoryGraphRepository, PolicyGateway, SourceConnector, ) @@ -125,6 +135,8 @@ from .services import ( ContextEntityQueryRequest, ContextEntityQueryResult, LexicalIndexRefreshResult, + MemoryGraphImportSummary, + MemoryRuntimeService, RelationshipChangeResult, RelationshipQueryItem, RelationshipQueryRequest, @@ -217,6 +229,7 @@ __all__ = [ "FormatExtractor", "InMemoryAssetRegistryRepository", "InMemoryBlobStorage", + "InMemoryMemoryGraphRepository", "InMemoryKnowledgeRepository", "IngestionRequest", "IngestionResult", @@ -233,12 +246,23 @@ __all__ = [ "LexicalIndexRefreshResult", "LifecycleState", "LocalBlobStorage", + "MARKITECT_MEMORY_GRAPH_SCHEMA_VERSION", + "MARKITECT_MEMORY_PROFILE_SCHEMA_VERSION", "MetadataFieldDefinition", "MetadataRecord", "MetadataSchema", "MetadataSchemaAssignment", "MetadataValidationIssue", "MetadataValueType", + "MemoryEdgeRecord", + "MemoryEventRecord", + "MemoryGraphImportResult", + "MemoryGraphImportSummary", + "MemoryGraphRepository", + "MemoryNodeRecord", + "MemoryProfileRecord", + "MemoryRuntimeService", + "MemorySourceSpan", "NormalizedDocument", "NotFoundError", "OperationFailure", diff --git a/src/kontextual_engine/adapters/memory/__init__.py b/src/kontextual_engine/adapters/memory/__init__.py index 6f7a629..519ba8d 100644 --- a/src/kontextual_engine/adapters/memory/__init__.py +++ b/src/kontextual_engine/adapters/memory/__init__.py @@ -2,5 +2,10 @@ from .asset_registry import InMemoryAssetRegistryRepository from .blob_storage import InMemoryBlobStorage +from .graph_store import InMemoryMemoryGraphRepository -__all__ = ["InMemoryAssetRegistryRepository", "InMemoryBlobStorage"] +__all__ = [ + "InMemoryAssetRegistryRepository", + "InMemoryBlobStorage", + "InMemoryMemoryGraphRepository", +] diff --git a/src/kontextual_engine/adapters/memory/graph_store.py b/src/kontextual_engine/adapters/memory/graph_store.py new file mode 100644 index 0000000..ac5cdbf --- /dev/null +++ b/src/kontextual_engine/adapters/memory/graph_store.py @@ -0,0 +1,126 @@ +"""In-memory operational memory graph repository.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable + +from kontextual_engine.core import ( + LifecycleState, + MemoryEdgeRecord, + MemoryEventRecord, + MemoryNodeRecord, + MemoryProfileRecord, +) +from kontextual_engine.errors import DuplicateResourceError, NotFoundError, ValidationError + + +@dataclass +class InMemoryMemoryGraphRepository: + profiles: dict[str, MemoryProfileRecord] = field(default_factory=dict) + nodes: dict[str, MemoryNodeRecord] = field(default_factory=dict) + edges: dict[str, MemoryEdgeRecord] = field(default_factory=dict) + events: dict[str, MemoryEventRecord] = field(default_factory=dict) + + def save_memory_profile(self, profile: MemoryProfileRecord) -> MemoryProfileRecord: + self.profiles[str(profile.profile_id)] = profile + return profile + + def get_memory_profile(self, profile_id: str) -> MemoryProfileRecord: + try: + return self.profiles[profile_id] + except KeyError as exc: + raise NotFoundError("Memory profile not found", details={"profile_id": profile_id}) from exc + + def save_memory_node(self, node: MemoryNodeRecord) -> MemoryNodeRecord: + self.nodes[str(node.node_id)] = node + return node + + def get_memory_node(self, node_id: str) -> MemoryNodeRecord: + try: + return self.nodes[node_id] + except KeyError as exc: + raise NotFoundError("Memory node not found", details={"node_id": node_id}) from exc + + def list_memory_nodes( + self, + *, + graph_id: str | None = None, + kind: str | None = None, + lifecycle: LifecycleState | str | None = None, + ) -> list[MemoryNodeRecord]: + nodes: Iterable[MemoryNodeRecord] = self.nodes.values() + if graph_id is not None: + nodes = [node for node in nodes if node.graph_id == graph_id] + if kind is not None: + nodes = [node for node in nodes if node.kind == kind] + if lifecycle is not None: + lifecycle = LifecycleState(lifecycle) + nodes = [node for node in nodes if node.lifecycle == lifecycle] + return sorted(nodes, key=lambda node: (node.graph_id, node.kind, node.contract_node_id)) + + def save_memory_edge(self, edge: MemoryEdgeRecord) -> MemoryEdgeRecord: + source = self.get_memory_node(edge.source_node_id) + target = self.get_memory_node(edge.target_node_id) + if source.graph_id != edge.graph_id or target.graph_id != edge.graph_id: + raise ValidationError( + "Memory edge endpoints must belong to the edge graph.", + details={ + "edge_id": edge.edge_id, + "graph_id": edge.graph_id, + "source_graph_id": source.graph_id, + "target_graph_id": target.graph_id, + }, + ) + self.edges[str(edge.edge_id)] = edge + return edge + + def get_memory_edge(self, edge_id: str) -> MemoryEdgeRecord: + try: + return self.edges[edge_id] + except KeyError as exc: + raise NotFoundError("Memory edge not found", details={"edge_id": edge_id}) from exc + + def list_memory_edges( + self, + *, + graph_id: str | None = None, + source_node_id: str | None = None, + target_node_id: str | None = None, + ) -> list[MemoryEdgeRecord]: + edges: Iterable[MemoryEdgeRecord] = self.edges.values() + if graph_id is not None: + edges = [edge for edge in edges if edge.graph_id == graph_id] + if source_node_id is not None: + edges = [edge for edge in edges if edge.source_node_id == source_node_id] + if target_node_id is not None: + edges = [edge for edge in edges if edge.target_node_id == target_node_id] + return sorted(edges, key=lambda edge: (edge.graph_id, edge.kind, edge.contract_edge_id)) + + def append_memory_event(self, event: MemoryEventRecord) -> MemoryEventRecord: + if event.event_id in self.events: + raise DuplicateResourceError( + "Memory event already exists; event storage is append-only.", + details={"event_id": event.event_id, "contract_event_id": event.contract_event_id}, + ) + self.events[str(event.event_id)] = event + return event + + def get_memory_event(self, event_id: str) -> MemoryEventRecord: + try: + return self.events[event_id] + except KeyError as exc: + raise NotFoundError("Memory event not found", details={"event_id": event_id}) from exc + + def list_memory_events( + self, + *, + graph_id: str | None = None, + kind: str | None = None, + ) -> list[MemoryEventRecord]: + events: Iterable[MemoryEventRecord] = self.events.values() + if graph_id is not None: + events = [event for event in events if event.graph_id == graph_id] + if kind is not None: + events = [event for event in events if event.kind == kind] + return sorted(events, key=lambda event: (event.graph_id, event.timestamp, event.contract_event_id)) diff --git a/src/kontextual_engine/core/__init__.py b/src/kontextual_engine/core/__init__.py index 6139e1d..01d8b95 100644 --- a/src/kontextual_engine/core/__init__.py +++ b/src/kontextual_engine/core/__init__.py @@ -36,6 +36,17 @@ from .metadata import ( MetadataValueType, Sensitivity, ) +from .memory import ( + MARKITECT_MEMORY_GRAPH_SCHEMA_VERSION, + MARKITECT_MEMORY_PROFILE_SCHEMA_VERSION, + MemoryEdgeRecord, + MemoryEventRecord, + MemoryGraphImportResult, + MemoryNodeRecord, + MemoryProfileRecord, + MemorySourceSpan, + runtime_id, +) from .policy import PolicyDecision, PolicyEffect from .primitives import content_digest, mapping_digest, new_id, stable_json_dumps, utc_now from .provenance import ( @@ -100,12 +111,20 @@ __all__ = [ "IngestionJobStatus", "KnowledgeAsset", "LifecycleState", + "MARKITECT_MEMORY_GRAPH_SCHEMA_VERSION", + "MARKITECT_MEMORY_PROFILE_SCHEMA_VERSION", "MetadataFieldDefinition", "MetadataRecord", "MetadataSchema", "MetadataSchemaAssignment", "MetadataValidationIssue", "MetadataValueType", + "MemoryEdgeRecord", + "MemoryEventRecord", + "MemoryGraphImportResult", + "MemoryNodeRecord", + "MemoryProfileRecord", + "MemorySourceSpan", "NormalizedDocument", "OperationContext", "PolicyDecision", @@ -114,6 +133,7 @@ __all__ = [ "RepresentationKind", "RetrievalFeedbackLabel", "RetrievalFeedbackRecord", + "runtime_id", "Sensitivity", "SourceReference", "SourcePayload", diff --git a/src/kontextual_engine/core/memory.py b/src/kontextual_engine/core/memory.py new file mode 100644 index 0000000..de21a09 --- /dev/null +++ b/src/kontextual_engine/core/memory.py @@ -0,0 +1,556 @@ +"""Runtime memory graph records mapped from Markitect contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .metadata import LifecycleState +from .primitives import compact_dict, mapping_digest, utc_now + + +MARKITECT_MEMORY_GRAPH_SCHEMA_VERSION = "markitect.memory.graph.v1" +MARKITECT_MEMORY_PROFILE_SCHEMA_VERSION = "markitect.memory.profile.v1" + + +@dataclass(frozen=True) +class MemorySourceSpan: + path: str + snapshot_id: str | None = None + unit_kind: str | None = None + unit_index: int | None = None + line_start: int | None = None + line_end: int | None = None + selector: str | None = None + engine: str | None = None + + @classmethod + def from_contract(cls, data: dict[str, Any]) -> "MemorySourceSpan": + return cls( + path=str(data["path"]), + snapshot_id=_optional_str(data.get("snapshot_id")), + unit_kind=_optional_str(data.get("unit_kind")), + unit_index=_optional_int(data.get("unit_index")), + line_start=_optional_int(data.get("line_start")), + line_end=_optional_int(data.get("line_end")), + selector=_optional_str(data.get("selector")), + engine=_optional_str(data.get("engine")), + ) + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "path": self.path, + "snapshot_id": self.snapshot_id, + "unit_kind": self.unit_kind, + "unit_index": self.unit_index, + "line_start": self.line_start, + "line_end": self.line_end, + "selector": self.selector, + "engine": self.engine, + } + ) + + +@dataclass(frozen=True) +class MemoryProfileRecord: + contract_profile_id: str + schema_version: str + title: str = "" + intent: str = "" + memory_kinds: tuple[str, ...] = () + stores: dict[str, Any] = 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) + profile_id: str | None = None + imported_at: str = field(default_factory=lambda: utc_now().isoformat()) + + def __post_init__(self) -> None: + if self.profile_id is None: + object.__setattr__( + self, + "profile_id", + runtime_id("memprofile", self.schema_version, self.contract_profile_id), + ) + object.__setattr__(self, "memory_kinds", tuple(self.memory_kinds)) + + @classmethod + def from_markitect_contract(cls, data: dict[str, Any]) -> "MemoryProfileRecord": + schema_version = str(data.get("schema_version") or data.get("schema") or "") + if schema_version != MARKITECT_MEMORY_PROFILE_SCHEMA_VERSION: + raise ValueError(f"Unsupported memory profile schema `{schema_version}`.") + contract_id = str(data.get("id") or "") + if not contract_id: + raise ValueError("Memory profile contract must declare an id.") + return cls( + contract_profile_id=contract_id, + schema_version=schema_version, + 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=dict(data.get("stores") or {}), + 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( + { + "profile_id": self.profile_id, + "contract_profile_id": self.contract_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), + "imported_at": self.imported_at, + } + ) + + +@dataclass(frozen=True) +class MemoryNodeRecord: + graph_id: str + contract_graph_id: str + contract_node_id: str + kind: str + text: str = "" + namespace: dict[str, Any] = field(default_factory=dict) + source_spans: tuple[MemorySourceSpan, ...] = () + 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) + lifecycle: LifecycleState = LifecycleState.ACTIVE + metadata: dict[str, Any] = field(default_factory=dict) + node_id: str | None = None + created_at: str = field(default_factory=lambda: utc_now().isoformat()) + updated_at: str = field(default_factory=lambda: utc_now().isoformat()) + + def __post_init__(self) -> None: + if self.node_id is None: + object.__setattr__( + self, + "node_id", + runtime_id("memnode", self.contract_graph_id, self.contract_node_id), + ) + object.__setattr__(self, "source_spans", tuple(self.source_spans)) + object.__setattr__(self, "provenance", tuple(dict(item) for item in self.provenance)) + object.__setattr__(self, "lifecycle", LifecycleState(self.lifecycle)) + + @classmethod + def from_markitect_contract( + cls, + *, + graph_id: str, + contract_graph_id: str, + data: dict[str, Any], + graph_namespace: dict[str, Any] | None = None, + ) -> "MemoryNodeRecord": + contract_node_id = str(data.get("id") or "") + if not contract_node_id: + raise ValueError("Memory graph node must declare an id.") + spans = data.get("source_spans") or data.get("spans") or [] + return cls( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + contract_node_id=contract_node_id, + kind=str(data.get("kind") or data.get("type") or ""), + text=str(data.get("text") or data.get("content") or data.get("summary") or ""), + namespace=dict(data.get("namespace") or graph_namespace or {}), + source_spans=tuple(MemorySourceSpan.from_contract(span) for span in _mapping_list(spans)), + provenance=tuple(_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 {}), + ) + + @property + def resource_id(self) -> str: + return f"memory-node:{self.node_id}" + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "node_id": self.node_id, + "graph_id": self.graph_id, + "contract_graph_id": self.contract_graph_id, + "contract_node_id": self.contract_node_id, + "kind": self.kind, + "text": self.text, + "namespace": dict(self.namespace), + "source_spans": [span.to_dict() for span in self.source_spans], + "provenance": [dict(item) for item in self.provenance], + "freshness": dict(self.freshness), + "confidence": self.confidence, + "policy": dict(self.policy), + "lifecycle": self.lifecycle.value, + "metadata": dict(self.metadata), + "created_at": self.created_at, + "updated_at": self.updated_at, + } + ) + + +@dataclass(frozen=True) +class MemoryEdgeRecord: + graph_id: str + contract_graph_id: str + contract_edge_id: str + kind: str + source_node_id: str + target_node_id: str + source_contract_node_id: str + target_contract_node_id: str + 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) + lifecycle: LifecycleState = LifecycleState.ACTIVE + metadata: dict[str, Any] = field(default_factory=dict) + edge_id: str | None = None + created_at: str = field(default_factory=lambda: utc_now().isoformat()) + + def __post_init__(self) -> None: + if self.edge_id is None: + object.__setattr__( + self, + "edge_id", + runtime_id("memedge", self.contract_graph_id, self.contract_edge_id), + ) + object.__setattr__(self, "provenance", tuple(dict(item) for item in self.provenance)) + object.__setattr__(self, "lifecycle", LifecycleState(self.lifecycle)) + + @classmethod + def from_markitect_contract( + cls, + *, + graph_id: str, + contract_graph_id: str, + data: dict[str, Any], + node_id_by_contract_id: dict[str, str], + ) -> "MemoryEdgeRecord": + kind = str(data.get("kind") or data.get("type") or "") + source_contract_id = str(data.get("source") or data.get("from") or "") + target_contract_id = str(data.get("target") or data.get("to") or "") + contract_edge_id = str( + data.get("id") + or f"edge:{stable_suffix(kind, source_contract_id, target_contract_id)}" + ) + try: + source_node_id = node_id_by_contract_id[source_contract_id] + target_node_id = node_id_by_contract_id[target_contract_id] + except KeyError as exc: + missing = source_contract_id if source_contract_id not in node_id_by_contract_id else target_contract_id + raise ValueError(f"Memory edge `{contract_edge_id}` references unknown node `{missing}`.") from exc + return cls( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + contract_edge_id=contract_edge_id, + kind=kind, + source_node_id=source_node_id, + target_node_id=target_node_id, + source_contract_node_id=source_contract_id, + target_contract_node_id=target_contract_id, + provenance=tuple(_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 {}), + ) + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "edge_id": self.edge_id, + "graph_id": self.graph_id, + "contract_graph_id": self.contract_graph_id, + "contract_edge_id": self.contract_edge_id, + "kind": self.kind, + "source_node_id": self.source_node_id, + "target_node_id": self.target_node_id, + "source_contract_node_id": self.source_contract_node_id, + "target_contract_node_id": self.target_contract_node_id, + "provenance": [dict(item) for item in self.provenance], + "freshness": dict(self.freshness), + "confidence": self.confidence, + "policy": dict(self.policy), + "lifecycle": self.lifecycle.value, + "metadata": dict(self.metadata), + "created_at": self.created_at, + } + ) + + +@dataclass(frozen=True) +class MemoryEventRecord: + graph_id: str + contract_graph_id: str + contract_event_id: str + kind: str + timestamp: str + namespace: dict[str, Any] = field(default_factory=dict) + actor_id: str | None = None + thread: str | None = None + task: str | None = None + node_updates: tuple[dict[str, Any], ...] = () + edge_updates: tuple[dict[str, Any], ...] = () + package_refs: tuple[str, ...] = () + activation_refs: tuple[str, ...] = () + branch: dict[str, Any] = field(default_factory=dict) + policy: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + event_id: str | None = None + appended_at: str = field(default_factory=lambda: utc_now().isoformat()) + + def __post_init__(self) -> None: + if self.event_id is None: + object.__setattr__( + self, + "event_id", + runtime_id("memevent", self.contract_graph_id, self.contract_event_id), + ) + object.__setattr__(self, "node_updates", tuple(dict(item) for item in self.node_updates)) + object.__setattr__(self, "edge_updates", tuple(dict(item) for item in self.edge_updates)) + object.__setattr__(self, "package_refs", tuple(self.package_refs)) + object.__setattr__(self, "activation_refs", tuple(self.activation_refs)) + + @classmethod + def from_markitect_contract( + cls, + *, + graph_id: str, + contract_graph_id: str, + data: dict[str, Any], + graph_namespace: dict[str, Any] | None = None, + ) -> "MemoryEventRecord": + kind = str(data.get("kind") or data.get("type") or "recorded") + timestamp = str(data.get("timestamp") or data.get("at") or utc_now().isoformat()) + contract_event_id = str(data.get("id") or f"event:{stable_suffix(kind, timestamp, data)}") + return cls( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + contract_event_id=contract_event_id, + kind=kind, + timestamp=timestamp, + namespace=dict(data.get("namespace") or graph_namespace or {}), + actor_id=_optional_str(data.get("actor") or data.get("actor_id")), + thread=_optional_str(data.get("thread")), + task=_optional_str(data.get("task")), + node_updates=tuple(_mapping_list(data.get("node_updates") or data.get("nodes"))), + edge_updates=tuple(_mapping_list(data.get("edge_updates") or data.get("edges"))), + package_refs=tuple(_string_list(data.get("package_refs") or data.get("packages"))), + activation_refs=tuple(_string_list(data.get("activation_refs") or data.get("activations"))), + branch=dict(data.get("branch") or {}), + policy=dict(data.get("policy") or {}), + metadata=dict(data.get("metadata") or {}), + ) + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "event_id": self.event_id, + "graph_id": self.graph_id, + "contract_graph_id": self.contract_graph_id, + "contract_event_id": self.contract_event_id, + "kind": self.kind, + "timestamp": self.timestamp, + "namespace": dict(self.namespace), + "actor_id": self.actor_id, + "thread": self.thread, + "task": self.task, + "node_updates": [dict(item) for item in self.node_updates], + "edge_updates": [dict(item) for item in self.edge_updates], + "package_refs": list(self.package_refs), + "activation_refs": list(self.activation_refs), + "branch": dict(self.branch), + "policy": dict(self.policy), + "metadata": dict(self.metadata), + "appended_at": self.appended_at, + } + ) + + +@dataclass(frozen=True) +class MemoryGraphImportResult: + graph_id: str + contract_graph_id: str + schema_version: str + title: str = "" + intent: str = "" + namespace: dict[str, Any] = field(default_factory=dict) + profile: MemoryProfileRecord | None = None + nodes: tuple[MemoryNodeRecord, ...] = () + edges: tuple[MemoryEdgeRecord, ...] = () + events: tuple[MemoryEventRecord, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "nodes", tuple(self.nodes)) + object.__setattr__(self, "edges", tuple(self.edges)) + object.__setattr__(self, "events", tuple(self.events)) + + @classmethod + def from_markitect_contract( + cls, + graph_contract: dict[str, Any], + *, + profile_contract: dict[str, Any] | None = None, + ) -> "MemoryGraphImportResult": + schema_version = str(graph_contract.get("schema_version") or graph_contract.get("schema") or "") + if schema_version != MARKITECT_MEMORY_GRAPH_SCHEMA_VERSION: + raise ValueError(f"Unsupported memory graph schema `{schema_version}`.") + contract_graph_id = str(graph_contract.get("id") or "") + if not contract_graph_id: + raise ValueError("Memory graph contract must declare an id.") + graph_id = runtime_id("memgraph", schema_version, contract_graph_id) + namespace = dict(graph_contract.get("namespace") or {}) + nodes = tuple( + MemoryNodeRecord.from_markitect_contract( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + data=node, + graph_namespace=namespace, + ) + for node in _mapping_values(graph_contract.get("nodes")) + ) + node_id_by_contract_id = {node.contract_node_id: str(node.node_id) for node in nodes} + edges = tuple( + MemoryEdgeRecord.from_markitect_contract( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + data=edge, + node_id_by_contract_id=node_id_by_contract_id, + ) + for edge in _mapping_values(graph_contract.get("edges")) + ) + events = tuple( + MemoryEventRecord.from_markitect_contract( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + data=event, + graph_namespace=namespace, + ) + for event in _mapping_values(graph_contract.get("events")) + ) + profile = MemoryProfileRecord.from_markitect_contract(profile_contract) if profile_contract else None + return cls( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + schema_version=schema_version, + title=str(graph_contract.get("title") or graph_contract.get("name") or ""), + intent=str(graph_contract.get("intent") or graph_contract.get("description") or ""), + namespace=namespace, + profile=profile, + nodes=nodes, + edges=edges, + events=events, + metadata=dict(graph_contract.get("metadata") or {}), + ) + + def to_dict(self) -> dict[str, Any]: + return compact_dict( + { + "graph_id": self.graph_id, + "contract_graph_id": self.contract_graph_id, + "schema_version": self.schema_version, + "title": self.title, + "intent": self.intent, + "namespace": dict(self.namespace), + "profile": self.profile.to_dict() if self.profile else None, + "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": dict(self.metadata), + } + ) + + +def runtime_id(prefix: str, *parts: Any) -> str: + return f"{prefix}_{stable_suffix(*parts)}" + + +def stable_suffix(*parts: Any) -> str: + digest = mapping_digest(parts) + return digest.removeprefix("sha256:")[:24] + + +def _mapping_values(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, dict): + items: list[dict[str, Any]] = [] + for key, raw in value.items(): + if not isinstance(raw, dict): + continue + item = dict(raw) + item.setdefault("id", str(key)) + items.append(item) + return items + return _mapping_list(value) + + +def _mapping_list(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if isinstance(value, dict): + return [dict(value)] + if isinstance(value, list): + return [dict(item) for item in value if isinstance(item, dict)] + if isinstance(value, tuple): + return [dict(item) for item in value if isinstance(item, dict)] + return [] + + +def _string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, (list, tuple)): + return [str(item) for item in value if item is not None] + return [str(value)] + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text else None + + +def _optional_int(value: Any) -> int | None: + if value is None or value == "": + return None + return int(value) + + +def _optional_float(value: Any) -> float | None: + if value is None or value == "": + return None + return float(value) diff --git a/src/kontextual_engine/ports/__init__.py b/src/kontextual_engine/ports/__init__.py index 98ec100..0013e66 100644 --- a/src/kontextual_engine/ports/__init__.py +++ b/src/kontextual_engine/ports/__init__.py @@ -9,6 +9,7 @@ from .blob_storage import ( digest_storage_key, ) from .ingestion import DirectorySourceConnector, FormatExtractor, SourceConnector +from .memory import MemoryGraphRepository from .policy import AllowAllPolicyGateway, PolicyGateway from .repositories import AssetRegistryRepository @@ -23,6 +24,7 @@ __all__ = [ "digest_storage_key", "DirectorySourceConnector", "FormatExtractor", + "MemoryGraphRepository", "PolicyGateway", "SourceConnector", ] diff --git a/src/kontextual_engine/ports/memory.py b/src/kontextual_engine/ports/memory.py new file mode 100644 index 0000000..b1f5614 --- /dev/null +++ b/src/kontextual_engine/ports/memory.py @@ -0,0 +1,47 @@ +"""Ports for operational memory graph state.""" + +from __future__ import annotations + +from typing import Protocol + +from kontextual_engine.core import ( + LifecycleState, + MemoryEdgeRecord, + MemoryEventRecord, + MemoryNodeRecord, + MemoryProfileRecord, +) + + +class MemoryGraphRepository(Protocol): + def save_memory_profile(self, profile: MemoryProfileRecord) -> MemoryProfileRecord: ... + def get_memory_profile(self, profile_id: str) -> MemoryProfileRecord: ... + + def save_memory_node(self, node: MemoryNodeRecord) -> MemoryNodeRecord: ... + def get_memory_node(self, node_id: str) -> MemoryNodeRecord: ... + def list_memory_nodes( + self, + *, + graph_id: str | None = None, + kind: str | None = None, + lifecycle: LifecycleState | str | None = None, + ) -> list[MemoryNodeRecord]: ... + + def save_memory_edge(self, edge: MemoryEdgeRecord) -> MemoryEdgeRecord: ... + def get_memory_edge(self, edge_id: str) -> MemoryEdgeRecord: ... + def list_memory_edges( + self, + *, + graph_id: str | None = None, + source_node_id: str | None = None, + target_node_id: str | None = None, + ) -> list[MemoryEdgeRecord]: ... + + def append_memory_event(self, event: MemoryEventRecord) -> MemoryEventRecord: ... + def get_memory_event(self, event_id: str) -> MemoryEventRecord: ... + def list_memory_events( + self, + *, + graph_id: str | None = None, + kind: str | None = None, + ) -> list[MemoryEventRecord]: ... diff --git a/src/kontextual_engine/services/__init__.py b/src/kontextual_engine/services/__init__.py index 0f26c55..2a837d4 100644 --- a/src/kontextual_engine/services/__init__.py +++ b/src/kontextual_engine/services/__init__.py @@ -7,6 +7,7 @@ from .asset_service import ( ) from .content_service import RepresentationContentResult, RepresentationContentStream, RepresentationContentService from .ingestion_service import AssetIngestionResult, AssetIngestionService +from .memory_service import MemoryGraphImportSummary, MemoryRuntimeService from .retrieval_service import ( AssetQueryItem, AssetQueryRequest, @@ -53,6 +54,8 @@ __all__ = [ "ContextEntityQueryRequest", "ContextEntityQueryResult", "LexicalIndexRefreshResult", + "MemoryGraphImportSummary", + "MemoryRuntimeService", "RelationshipChangeResult", "RepresentationContentResult", "RepresentationContentStream", diff --git a/src/kontextual_engine/services/memory_service.py b/src/kontextual_engine/services/memory_service.py new file mode 100644 index 0000000..9371a2d --- /dev/null +++ b/src/kontextual_engine/services/memory_service.py @@ -0,0 +1,100 @@ +"""Application service for operational memory graph imports.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from kontextual_engine.core import ( + AuditEvent, + AuditOutcome, + MemoryGraphImportResult, + OperationContext, +) +from kontextual_engine.errors import ValidationError +from kontextual_engine.ports import MemoryGraphRepository + + +@dataclass(frozen=True) +class MemoryGraphImportSummary: + graph_id: str + contract_graph_id: str + imported_nodes: int + imported_edges: int + appended_events: int + profile_id: str | None = None + audit_event: AuditEvent | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = { + "graph_id": self.graph_id, + "contract_graph_id": self.contract_graph_id, + "profile_id": self.profile_id, + "imported_nodes": self.imported_nodes, + "imported_edges": self.imported_edges, + "appended_events": self.appended_events, + "metadata": dict(self.metadata), + } + if self.audit_event: + data["audit_event"] = self.audit_event.to_dict() + return data + + +class MemoryRuntimeService: + def __init__(self, repository: MemoryGraphRepository) -> None: + self.repository = repository + + def import_markitect_graph( + self, + graph_contract: dict[str, Any], + *, + profile_contract: dict[str, Any] | None = None, + context: OperationContext | None = None, + ) -> MemoryGraphImportSummary: + try: + imported = MemoryGraphImportResult.from_markitect_contract( + graph_contract, + profile_contract=profile_contract, + ) + except ValueError as exc: + raise ValidationError(str(exc), details={"operation": "memory.import_markitect_graph"}) from exc + + if imported.profile: + self.repository.save_memory_profile(imported.profile) + for node in imported.nodes: + self.repository.save_memory_node(node) + for edge in imported.edges: + self.repository.save_memory_edge(edge) + for event in imported.events: + self.repository.append_memory_event(event) + + audit_event = None + if context: + audit_event = AuditEvent.from_context( + "memory.import_markitect_graph", + f"memory-graph:{imported.graph_id}", + AuditOutcome.SUCCESS, + context, + details={ + "contract_graph_id": imported.contract_graph_id, + "profile_id": imported.profile.profile_id if imported.profile else None, + "nodes": len(imported.nodes), + "edges": len(imported.edges), + "events": len(imported.events), + }, + ) + return MemoryGraphImportSummary( + graph_id=imported.graph_id, + contract_graph_id=imported.contract_graph_id, + profile_id=imported.profile.profile_id if imported.profile else None, + imported_nodes=len(imported.nodes), + imported_edges=len(imported.edges), + appended_events=len(imported.events), + audit_event=audit_event, + metadata={ + "schema_version": imported.schema_version, + "title": imported.title, + "intent": imported.intent, + }, + ) diff --git a/tests/test_memory_graph_runtime.py b/tests/test_memory_graph_runtime.py new file mode 100644 index 0000000..cd16ad3 --- /dev/null +++ b/tests/test_memory_graph_runtime.py @@ -0,0 +1,153 @@ +import pytest + +from kontextual_engine import ( + Actor, + ActorType, + DuplicateResourceError, + InMemoryMemoryGraphRepository, + MemoryGraphImportResult, + MemoryRuntimeService, + OperationContext, + ValidationError, +) + + +def test_markitect_memory_graph_import_preserves_contract_identity_and_spans() -> None: + imported = MemoryGraphImportResult.from_markitect_contract( + _graph_contract(), + profile_contract=_profile_contract(), + ) + second_import = MemoryGraphImportResult.from_markitect_contract(_graph_contract()) + + assert imported.graph_id == second_import.graph_id + assert imported.contract_graph_id == "markitect-memory-decisions" + assert imported.profile is not None + assert imported.profile.contract_profile_id == "local-agent-memory" + assert imported.nodes[0].contract_node_id == "decision.contract-boundary" + assert imported.nodes[0].node_id == second_import.nodes[0].node_id + assert imported.nodes[0].source_spans[0].path == "workplans/MKTT-WP-0016.md" + assert imported.edges[0].source_node_id == imported.nodes[1].node_id + assert imported.events[0].package_refs == ("memory:package:example",) + + +def test_in_memory_memory_repository_persists_graph_and_append_only_events() -> None: + repo = InMemoryMemoryGraphRepository() + imported = MemoryGraphImportResult.from_markitect_contract(_graph_contract()) + + for node in imported.nodes: + repo.save_memory_node(node) + repo.save_memory_edge(imported.edges[0]) + repo.append_memory_event(imported.events[0]) + + assert repo.list_memory_nodes(graph_id=imported.graph_id, kind="decision") == [imported.nodes[0]] + assert repo.list_memory_edges(source_node_id=imported.nodes[1].node_id) == [imported.edges[0]] + assert repo.list_memory_events(graph_id=imported.graph_id, kind="recorded") == [imported.events[0]] + with pytest.raises(DuplicateResourceError): + repo.append_memory_event(imported.events[0]) + + +def test_memory_runtime_service_imports_contracts_and_reports_audit_context() -> None: + repo = InMemoryMemoryGraphRepository() + service = MemoryRuntimeService(repo) + actor = Actor.create(ActorType.AI_AGENT, actor_id="agent-codex", display_name="Codex") + context = OperationContext.create(actor, correlation_id="corr-memory") + + summary = service.import_markitect_graph( + _graph_contract(), + profile_contract=_profile_contract(), + context=context, + ) + + assert summary.imported_nodes == 2 + assert summary.imported_edges == 1 + assert summary.appended_events == 1 + assert summary.audit_event is not None + assert summary.audit_event.actor_id == "agent-codex" + assert summary.audit_event.correlation_id == "corr-memory" + assert repo.get_memory_profile(summary.profile_id).memory_kinds == ( + "reasoning", + "knowledge", + "package", + ) + + +def test_memory_runtime_service_rejects_invalid_edge_contracts() -> None: + repo = InMemoryMemoryGraphRepository() + service = MemoryRuntimeService(repo) + graph = _graph_contract() + graph["edges"][0]["target"] = "missing-node" + + with pytest.raises(ValidationError) as exc: + service.import_markitect_graph(graph) + + assert "unknown node" in str(exc.value) + + +def _profile_contract() -> dict: + return { + "schema_version": "markitect.memory.profile.v1", + "id": "local-agent-memory", + "title": "Local Agent Memory", + "intent": "Runtime import fixture.", + "memory_kinds": ["reasoning", "knowledge", "package"], + "stores": { + "reasoning": "local-event-log", + "knowledge": "local-graph-store", + "package": "markitect-context-registry", + }, + "activation": {"max_items": 6, "max_tokens": 1800}, + "policy": {"required_labels": ["public"]}, + } + + +def _graph_contract() -> dict: + return { + "schema_version": "markitect.memory.graph.v1", + "id": "markitect-memory-decisions", + "title": "Markitect Memory Decisions", + "intent": "Preserve memory boundary decisions for runtime import.", + "namespace": {"project": "markitect-tool", "task": "MKTT-WP-0016"}, + "nodes": [ + { + "id": "decision.contract-boundary", + "kind": "decision", + "text": "Markitect compiles memory graph selections into context packages.", + "source_spans": [ + { + "path": "workplans/MKTT-WP-0016.md", + "unit_kind": "section", + "selector": "tasks[id=P16.5]", + "engine": "selector", + } + ], + "metadata": {"title": "Contract boundary decision"}, + }, + { + "id": "constraint.no-runtime-services", + "kind": "constraint", + "text": "Runtime persistence belongs in kontextual-engine.", + "policy": {"labels": ["public"]}, + }, + ], + "edges": [ + { + "id": "edge.boundary-support", + "kind": "supports", + "source": "constraint.no-runtime-services", + "target": "decision.contract-boundary", + } + ], + "events": [ + { + "id": "event.initial-contract", + "kind": "recorded", + "timestamp": "2026-05-15T00:00:00Z", + "actor": "agent-codex", + "task": "KONT-WP-0017", + "package_refs": ["memory:package:example"], + "node_updates": [ + {"node_id": "decision.contract-boundary", "operation": "import"} + ], + } + ], + } diff --git a/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md b/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md index 8a68751..680bc5d 100644 --- a/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md +++ b/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md @@ -4,7 +4,7 @@ type: workplan title: "Agentic Memory Graph Runtime And Operational State" domain: markitect repo: kontextual-engine -status: todo +status: active owner: codex topic_slug: markitect planning_priority: medium @@ -59,11 +59,32 @@ Disallowed responsibilities: - Do not require any one graph database, vector store, LLM provider, or enterprise policy system in the core implementation. +## Implementation Update - 2026-05-15 + +The first runtime-contract slice is implemented. `kontextual-engine` can now +map `markitect.memory.graph.v1` and `markitect.memory.profile.v1` envelopes +into stable runtime records, persist them through a `MemoryGraphRepository` +port, and use `InMemoryMemoryGraphRepository` as the deterministic local +implementation for tests. + +The implementation deliberately does not validate Markitect's node/edge +vocabulary. It checks runtime compatibility concerns only: schema version, +required ids, edge endpoint integrity, stable runtime id mapping, source-span +preservation, and append-only event identity. + +Primary files: + +- `src/kontextual_engine/core/memory.py` +- `src/kontextual_engine/ports/memory.py` +- `src/kontextual_engine/adapters/memory/graph_store.py` +- `src/kontextual_engine/services/memory_service.py` +- `docs/memory-graph-runtime.md` + ## P17.1 - Import and map Markitect memory contracts ```task id: KONT-WP-0017-T001 -status: todo +status: done priority: high state_hub_task_id: "1b1f8904-3a3b-4d23-9d00-176633578801" ``` @@ -86,7 +107,7 @@ Output: contract import design, mapping tests, and compatibility fixtures. ```task id: KONT-WP-0017-T002 -status: todo +status: done priority: high state_hub_task_id: "dc3557e3-d425-46b0-8acc-9ac0230a385c" ```