"""Memory — the typed shape of a vault frontmatter. Single class covers every memory in the vault. The four orthogonal axes (`lifecycle / scope / source / role`) define the *behavioral shape* — services read off them to decide decay rules, retrieval ranking, merge eligibility, etc., instead of switching on a `category` string. Role-conditional fields (`confidence`, `status`, `origin_session_id`) are validated declaratively via `model_validator(mode='after')`. Legacy migration: a `model_validator(mode='before')` recognizes the old `category` field and back-fills the 4 axes via `LEGACY_AXES_FROM_CATEGORY`, so vault files written before this schema continue to parse without a migration script. Lives in `reme2/memory/schema/` (with the services that consume it), NOT in `reme2/component/` — the engine is domain-agnostic and never imports this module. """ from __future__ import annotations from enum import StrEnum from pydantic import Field, field_validator, model_validator from reme2.schema.file_node import FileNode # --------------------------------------------------------------------------- # The four behavioral axes # --------------------------------------------------------------------------- class Lifecycle(StrEnum): """Mutability + decay disposition of a memory. streaming — write-once events that decay/archive after a freshness window. evolving — long-lived memories that get continuously edited; never decay. frozen — immutable references / artifacts; never edited, never decay. """ STREAMING = "streaming" EVOLVING = "evolving" FROZEN = "frozen" class Scope(StrEnum): """Referential range of a memory. instance — refers to a specific moment / object / occurrence. class — abstracts over many instances; a concept / role / pattern. """ INSTANCE = "instance" CLASS = "class" class Source(StrEnum): """Provenance of the memory. auto — captured by the system from a session / tool output. curated — written or shaped by a human (or LLM curator) on purpose. derived — computed from other memories (Maintainer products, summaries). """ AUTO = "auto" CURATED = "curated" DERIVED = "derived" class Role(StrEnum): """Cognitive role the memory plays. Drives schema-aware ranking + role-specific field validation. Adding a new role is a one-line change here plus (optionally) a preset. """ OBSERVATION = "observation" # what happened (events live here) CLAIM = "claim" # an assertion needing confidence (thesis, model) QUESTION = "question" # an open inquiry needing an answer PROFILE = "profile" # entity description (person, org, system) CONCEPT = "concept" # abstract idea / definition (company, sector, concept) METHOD = "method" # procedure / how-to REFERENCE = "reference" # pointer to external thing (tool, paper, code) FUNDAMENTALS = "fundamentals" # foundational data / baseline facts class Status(StrEnum): """Lifecycle state — meaningful only for `lifecycle == streaming`. Topic-style memories (evolving / frozen) ignore this field. """ ACTIVE = "active" DISTILLED = "distilled" ARCHIVED = "archived" class Confidence(StrEnum): """Required for `role == claim`. Wire format stays emoji. PENDING — claim under investigation; outcome not yet decided. VERIFIED — claim supported by evidence and currently held. REJECTED — claim was investigated and disconfirmed; kept for history. """ PENDING = "⏳" VERIFIED = "✅" REJECTED = "❌" # --------------------------------------------------------------------------- # Legacy `category` → 4-axis mapping (auto-migration) # --------------------------------------------------------------------------- LEGACY_AXES_FROM_CATEGORY: dict[str, dict] = { # Events: streaming-instance-auto-observation "event": { "lifecycle": Lifecycle.STREAMING, "scope": Scope.INSTANCE, "source": Source.AUTO, "role": Role.OBSERVATION, }, # Topic categories — all evolving / class / curated, role varies "company": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.CONCEPT, }, "sector": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.CONCEPT, }, "concept": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.CONCEPT, }, "method": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.METHOD, }, "tool": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.REFERENCE, }, "profile": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.PROFILE, }, "thesis": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.CLAIM, }, "model": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.CLAIM, }, "questions": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.QUESTION, }, "fundamentals": { "lifecycle": Lifecycle.EVOLVING, "scope": Scope.CLASS, "source": Source.CURATED, "role": Role.FUNDAMENTALS, }, # Materials: frozen-instance-auto-reference (siblings of an event index) "material": { "lifecycle": Lifecycle.FROZEN, "scope": Scope.INSTANCE, "source": Source.AUTO, "role": Role.REFERENCE, }, } # --------------------------------------------------------------------------- # Memory — the single typed shape # --------------------------------------------------------------------------- class MemoryFileNode(FileNode): """The typed view of a vault frontmatter. `extra="allow"` keeps domain-specific fields (market, ticker, etc.) in the parsed object without polluting this base schema. `populate_by_name` lets `originSessionId` (legacy camelCase) populate `origin_session_id`. """ # -- Identity / common metadata ---------------------------------------- title: str = Field(default="") description: str = Field(default="") tags: list[str] = Field(default_factory=list) # -- The four behavioral axes ------------------------------------------ lifecycle: Lifecycle scope: Scope source: Source role: Role # -- Cross-cutting graph fields ---------------------------------------- # -- Role / lifecycle / source-conditional fields ---------------------- confidence: Confidence | None = Field( default=None, description="Required when role == claim. Use ⏳ / ✅ / ❌ as wire form.", ) status: Status | None = Field( default=None, description="Lifecycle state. Meaningful only when lifecycle == streaming.", ) # -- Migration: pre-validate hook -------------------------------------- @model_validator(mode="before") @classmethod def _migrate_legacy_category(cls, data): """Back-fill 4 axes from old `category` field if present. Idempotent: if axes are already populated, do nothing. The legacy `category` field is preserved (via `extra="allow"`) so downstream code that still reads it keeps working until we delete it. """ if not isinstance(data, dict): return data if "role" in data and "lifecycle" in data: return data cat = data.get("category") if cat in LEGACY_AXES_FROM_CATEGORY: data = {**LEGACY_AXES_FROM_CATEGORY[cat], **data} return data # -- Field coercion / cleanup ------------------------------------------ @field_validator("tags", mode="before") @classmethod def _strip_dedup(cls, v): """Strip whitespace + dedup string lists. Preserves order.""" if not isinstance(v, list): return v seen: dict[str, None] = {} for item in v: if isinstance(item, str): stripped = item.strip() if stripped: seen.setdefault(stripped, None) return list(seen.keys()) # -- Role-conditional checks ------------------------------------------- @model_validator(mode="after") def _enforce_role_conditionals(self): if self.role is Role.CLAIM and self.confidence is None: raise ValueError( "role='claim' requires explicit confidence " "(one of ⏳ / ✅ / ❌)", ) return self # -- Convenience properties -------------------------------------------- @property def is_active(self) -> bool: """True for streaming memories that are still mutable.""" return self.lifecycle is Lifecycle.STREAMING and self.status is Status.ACTIVE @property def is_terminal(self) -> bool: """True for memories no longer accepting writes (distilled / archived / frozen).""" if self.lifecycle is Lifecycle.FROZEN: return True return self.status in (Status.DISTILLED, Status.ARCHIVED)