PRME Integration Reference
Audience: AI coding assistants and developers integrating PRME into applications. Original reference: 2026-03-02, with subsequent sections updated incrementally. API coverage is partial. See the README for newer capabilities and focused guides.
Table of Contents
- Quick Start
- Architecture Overview
- Core API Reference
- Data Models
- Type Reference
- Configuration
- Retrieval Pipeline Deep Dive
- Multi-User / Multi-Tenant
- Working Examples
- PostgreSQL Backend
- RFC Summary Table
1. Quick Start
Minimal working example — store memories and retrieve them with hybrid search.
import asyncio
import tempfile
from pathlib import Path
from prme import MemoryEngine, PRMEConfig, NodeType, Scope
async def main():
# 1. Configure — paths default to cwd; use a temp dir for demos
tmp = tempfile.mkdtemp(prefix="prme_")
config = PRMEConfig(
db_path=str(Path(tmp) / "memory.duckdb"),
vector_path=str(Path(tmp) / "vectors.usearch"),
lexical_path=str(Path(tmp) / "lexical_index"),
)
# 2. Create engine (async factory — initializes all 4 backends)
engine = await MemoryEngine.create(config)
try:
# 3. Store memories
await engine.store(
"Alice prefers dark mode in all her editors.",
user_id="alice",
node_type=NodeType.PREFERENCE,
scope=Scope.PERSONAL,
)
await engine.store(
"The team decided to use PostgreSQL for the backend.",
user_id="alice",
node_type=NodeType.DECISION,
scope=Scope.PROJECT,
)
# 4. Retrieve with hybrid search
response = await engine.retrieve(
"What does Alice prefer?",
user_id="alice",
)
# 5. Use the results
for r in response.results:
print(f"[{r.composite_score:.3f}] {r.node.content}")
finally:
await engine.close()
asyncio.run(main())Requirements: Python 3.11+, pip install prme. No LLM API key needed for store() + retrieve(). The ingest() method requires an OpenAI/Anthropic/Ollama API key for LLM extraction.
PostgreSQL backend: Pass
database_urlinstead of file paths to use PostgreSQL for all storage. See Section 10 for details and a quick start example.
2. Architecture Overview
PRME coordinates four storage backends behind a single MemoryEngine API:
MemoryEngine
│
┌──────────┬───────┴───────┬──────────┐
▼ ▼ ▼ ▼
EventStore GraphStore VectorIndex LexicalIndexTwo backend modes (selected automatically via config.backend):
| Layer | DuckDB mode (default) | PostgreSQL mode (database_url set) |
|---|---|---|
| EventStore | DuckDB table | PostgreSQL table (asyncpg) |
| GraphStore | DuckDB + recursive CTEs | PostgreSQL + recursive CTEs |
| VectorIndex | USearch HNSW + DuckDB metadata | pgvector HNSW on nodes.embedding |
| LexicalIndex | Tantivy (BM25) | tsvector / tsquery (GIN index) |
| Write Queue | Serialized WriteQueue (single-writer) | NoOpWriteQueue (passthrough) |
Data Flow
Write path (store() / ingest()):
Content → Event (DuckDB) → MemoryNode (Graph) → Vector embedding → Lexical indexRead path (retrieve()):
Query → Analysis → Candidate Generation → Epistemic Filtering → Scoring → Context Packing → MemoryBundle
(stage 1) (stages 2-3) (stage 4) (stage 5) (stage 6)Memory Pack (portable artifact — DuckDB mode only)
In DuckDB mode, a pack is a directory by default, and all state lives in these files, copyable as a unit:
| File | Backend | Purpose |
|---|---|---|
memory.duckdb | EventStore + GraphStore | Event log, graph nodes/edges, operations |
vectors.usearch | VectorIndex | HNSW vector index |
lexical_index/ | LexicalIndex | Tantivy full-text index directory |
manifest.json | Encryption | List of encrypted files and key derivation settings, written only when encryption is on |
A single-file pack (opt-in) is one DuckDB file, conventionally ending in .prme. It holds the same durable tables as memory.duckdb; the derived indexes and the manifest move inside it:
In the .prme file | Backend | Purpose |
|---|---|---|
Every table of memory.duckdb | EventStore + GraphStore + VectorIndex | Event log, graph nodes/edges, operations, and per-vector metadata and float payloads (vector_metadata, vector_payloads) |
vector_snapshot | VectorIndex | One row with the USearch snapshot (data), ndim, vector_count and saved_at, in place of vectors.usearch |
prme_manifest | MemoryEngine | One row describing the pack: format_version (currently 1), prme_version, embedding_model, embedding_version, embedding_dim, scoring_version, created_at and updated_at |
| (none) | LexicalIndex | Tantivy index held in memory and rebuilt from active nodes on open, in place of lexical_index/ |
A single-file pack has no manifest.json; DuckDB encrypts the file itself when encryption is on.
In PostgreSQL mode, all data lives in a single database. The memory pack portability story (RFC-0014) applies to DuckDB mode.
3. Core API Reference
MemoryEngine
The single entry point for all operations. Always use the async create() factory.
MemoryEngine.create()
@classmethod
async def create(cls, config: PRMEConfig | None = None) -> MemoryEngineFactory method. Dispatches to DuckDB or PostgreSQL based on config.backend:
-
DuckDB (default): Opens DuckDB file, initializes schema, USearch index, Tantivy index, serialized write queue.
-
PostgreSQL (
database_urlset): Creates asyncpg pool, initializes PostgreSQL schema (pgvector + tsvector), usesNoOpWriteQueuepassthrough. -
config— OptionalPRMEConfig. Defaults toPRMEConfig()(loads from env vars /.env). -
Returns: Initialized
MemoryEngine.
engine.store()
async def store(
self,
content: str,
*,
user_id: str,
retrieval_content: str | None = None,
value_bindings: list[MemoryValueBinding | dict] | None = None,
session_id: str | None = None,
role: str = "user",
speaker: str | None = None,
node_type: NodeType = NodeType.NOTE,
scope: Scope = Scope.PERSONAL,
metadata: dict | None = None,
confidence: float | None = None,
epistemic_type: EpistemicType | None = None,
source_type: SourceType | None = None,
event_time: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
ttl_days: int | None = ...,
) -> strStore content across all four backends in one call. No LLM needed. By default,
the exact source text is also the searchable and model-facing memory. For large
agent traces, logs, or structured documents, pass compact
retrieval_content; get_event() still returns the exact source while the graph,
vector index, lexical index and context packer use the compact representation.
Use value_bindings when an exact source-backed presentation value differs from
the complete string accepted by a tool. The value-binding guide
defines visibility, resolution and audit behavior.
The event, complete initial node snapshot and repair job are saved atomically,
then the graph node and both indexes are written. Index failures leave the job
pending; processing_status() and process_pending() expose scoped inspection
and repair after restart. A graph creation failure raises MaterializationError
with the accepted event_id. Recovery preserves the original node values without
an LLM. Optional reinforcement, supersedence and QA pairing run afterward and
are outside this job’s completion boundary.
Question/answer pairing is disabled by default. enable_qa_pairing=True
enables an experimental in-process heuristic that creates an extra merged node
for consecutive turns from different roles or speakers in one exact session and scope. It is
not recovered after restart, its graph/index writes are not one atomic durable
publication, and registered quality evaluations have not enabled it. Prefer
normal session-aware retrieval unless you are explicitly evaluating this
hypothesis.
When enable_store_supersedence=True, a newly formed flip-flop chain can apply
the existing bounded oscillation confidence penalty. The engine revalidates the
exact owner, scope, node snapshots, and SUPERSEDES edges under the backend
transaction, then commits the confidence update with one deterministic,
checksummed PENALTY record. Concurrent and restarted attempts reuse that
identity. This is a lexical heuristic behind the supersedence opt-in, not a
truth judgment or calibrated confidence model.
| Parameter | Type | Default | Description |
|---|---|---|---|
content | str | required | Exact source text retained in the immutable event log |
retrieval_content | str | None | None | Optional compact text to index, rank and place in model context |
value_bindings | list[MemoryValueBinding | dict] | None | None | Exact presentation values paired with caller-supplied complete lookup forms |
user_id | str | required | Owner user ID (all queries scoped to this) |
session_id | str | None | None | Optional session identifier |
role | str | "user" | "user" (the memory’s owner), "participant" (another human in the conversation), "assistant", "tool", or "system" |
speaker | str | None | None | Optional name of who said it; kept with the source and node and shown by the reader context format |
node_type | NodeType | NodeType.NOTE | Type of memory node |
scope | Scope | Scope.PERSONAL | Memory scope |
metadata | dict | None | None | Optional structured metadata |
confidence | float | None | None | Confidence 0.0-1.0. If None, derived from confidence matrix |
epistemic_type | EpistemicType | None | None | If None, assistant/system roles infer INFERRED; other roles infer from node_type |
source_type | SourceType | None | None | If None, inferred from node_type + role: "user" and "participant" select USER_STATED, "assistant" and "system" select SYSTEM_INFERRED, "tool" selects TOOL_OUTPUT |
event_time | datetime | None | None | Timezone-aware source event time; remains separate from admission and validity |
valid_from | datetime | None | None | Timezone-aware inclusive validity start; omission uses admission time |
valid_to | datetime | None | None | Exclusive validity end; requires an explicit earlier valid_from |
ttl_days | int | None | ... | Omit for configured default, pass None to disable, or set a nonnegative override |
Returns: str — UUID of the created event (source of truth ID).
A conversation between two or more people has more than one first-party
speaker. Store the memory owner’s turns with role="user" and every other
person’s with role="participant", each with their name as speaker. Every
human’s statements are then USER_STATED with the same confidence, while
assistant and system output stays SYSTEM_INFERRED. When the owner is not in
the conversation, as with an imported chat between two other people, every
person is a participant:
when = datetime(2023, 6, 9, 19, 55, tzinfo=timezone.utc)
for speaker, text in [("Caroline", "I went to the support group yesterday."),
("Melanie", "That sounds wonderful. How did it go?")]:
await engine.store(text, user_id="chat-7", role="participant",
speaker=speaker, session_id="s1", event_time=when)The name is kept in the reserved metadata.prme_speaker_v1 key of the source
event and its node. Passing that key in metadata raises ValueError, so a
caller that copies metadata from a stored memory into a new write must remove
the key and pass speaker instead. Leading and trailing whitespace is removed;
the name must then be non-empty, at most 200 characters, and free of control
characters, line breaks and bidirectional controls. The speaker is an unverified
caller assertion, not an identity: PRME does not resolve it to an entity. It does
not select the role or source type, and it is not indexed for lexical search.
The reader context format prints it as
- [2023-06-09 19:55] "Caroline": "I went to the support group yesterday.",
and leaves it out when the text already begins with the name and a colon, after
an optional leading parenthesized or bracketed group such as a date. The
auditable format adds a "speaker" key to such records; the compact format does
not show it. With enable_qa_pairing=True, a change of speaker within one role
also creates a merged question/answer node, as a change of role does, and each
half of its text starts with its speaker’s name.
ingest(), ingest_batch() (a "speaker" key per message), ingest_fast() and
FastIngestItem accept the same speaker. It stays with the source event and
its raw note. Claims that ingest() extracts do not show it yet: a
participant’s first-person claim such as “I moved to Boston” reads like any
other first-party claim, so follow a claim’s evidence_refs to its source event
to see who said it. With enable_speaker_references (on by default since
2026-10-06), the claim’s I, me and my bind to the speaker’s entity, and its
speaker_reference metadata names the speaker (see
entity identity). The extractor applies its generic admission policy to
participants, and the user-only literal recoveries do not run for them.
Use store_with_receipt() when the next operation needs the created node ID:
receipt = await engine.store_with_receipt(
"The project uses PostgreSQL.",
user_id="alice",
node_type=NodeType.FACT,
scope=Scope.PROJECT,
)
await engine.promote(str(receipt.node_id), user_id="alice")The immutable source ID is receipt.event_id; lifecycle methods accept
receipt.node_id. receipt.node is the exact created node and
receipt.processing_status reports its durable materialization state. Resolution
follows the source event, so a concurrent write cannot be mistaken for this
node. Existing store() callers retain the event-ID return for compatibility.
For example, retain a complete tool trajectory without spending the retrieval budget on raw SDK payloads:
receipt = await engine.store_with_receipt(
raw_trace_json,
retrieval_content="Traveler: Alice\nFinal plan:\n...",
user_id="alice",
metadata={"record_kind": "agent_trace"},
)
source = await engine.get_event(str(receipt.event_id), user_id="alice")
assert source.content == raw_trace_json
assert receipt.node.content.startswith("Traveler: Alice")The projection is caller-supplied data, not a generated summary or a claim that the source supports it. It is durably journaled with the initial node and reused exactly during restart recovery.
For raw imports that do not need typed-node overrides or model extraction, use
ingest_fast_many(items, user_id=...). Each FastIngestItem carries content,
role, speaker, session_id, scope, metadata, and an optional timezone-aware
event_time. PRME validates and snapshots the complete list before I/O, then
admits every immutable event and materialization job in one transaction. It
returns event IDs in input order; an empty Python batch is a no-op. Run
process_pending() until pending == 0 to build the deterministic raw NOTE and
both indexes. Pass a persisted UUID as request_id to make a lost-response retry
return the original event IDs. The identity is owner scoped and survives
restart; reusing it with different items raises an input conflict. HTTP POST /v1/ingest/fast uses the UUID Idempotency-Key header, while MCP
memory_ingest_fast_many accepts request_id. Both expose the same
owner-scoped admission contract; their
empty request lists are rejected. MCP memory_process_materializations mirrors
the HTTP processing endpoint.
An explicit EpistemicType.CONDITIONAL requires
metadata={"condition": "..."}. New conditional memories always begin with
condition_state="unknown"; setting a resolved state during creation is
rejected so an evaluation cannot bypass its audit record.
Validity intervals use [valid_from, valid_to). Python, HTTP /v1/store, and
MCP memory_store preserve the same fields. Timezone-free values, an end without
an explicit start, and empty or inverted intervals fail before the immutable
source is admitted. New extracted facts use their resolved source-effective time
as valid_from; source-grounded replacements close the previous interval in the
same derivation transaction when the stored interval can be closed safely.
engine.ingest()
async def ingest(
self,
content: str,
*,
user_id: str,
role: str = "user",
speaker: str | None = None,
first_person_owner: bool = False,
session_id: str | None = None,
metadata: dict | None = None,
wait_for_extraction: bool = False,
scope: Scope = Scope.PERSONAL,
) -> strIngest with LLM-powered extraction. Two-phase pipeline:
- Phase 1 (immediate): Atomically persist the source event and its raw indexing job.
- Phase 2 (background): Extract and ground model output, save it in the operation log, then materialize the graph and indexes. Indexing retries reuse the saved output.
| Parameter | Type | Default | Description |
|---|---|---|---|
content | str | required | Message text to ingest |
user_id | str | required | Owner user ID |
role | str | "user" | Message role; see store() |
speaker | str | None | None | Optional name of who said it; see store() |
first_person_owner | bool | False | Declare singular first person in this user/human message to be its owner; no named speaker; omit for pasted text |
session_id | str | None | None | Optional session ID |
metadata | dict | None | None | Optional metadata |
wait_for_extraction | bool | False | If True, block until extraction completes |
scope | Scope | Scope.PERSONAL | Memory scope |
See automatic correction rules and limits.
Returns: str — UUID of the persisted event. Falls back to store() if no pipeline configured.
engine.ingest_batch()
async def ingest_batch(
self,
messages: list[dict],
*,
user_id: str,
session_id: str | None = None,
wait_for_extraction: bool = False,
scope: Scope = Scope.PERSONAL,
) -> list[str]Ingest multiple messages sequentially (preserves conversation order). Each dict must have "content" and "role" keys, with optional "speaker", "first_person_owner", "metadata" and "event_time". Every attribution is checked before the first message is admitted.
Returns: list[str] — Event IDs, one per message.
Consumer node and source filters
retrieve() on the async engine and synchronous client accepts
exclude_node_types and source_types. Filters apply within generated
candidates before scoring and limiting, and to session expansion, supplementary
evidence, packed context and cross-scope hints. They apply in DEFAULT and
EXPLICIT modes. source_types=None allows all provenance types; an empty
collection allows none. Invalid enum values fail before pending work is drained.
Receipts record active filters in execution.parameters, and responses expose
them in filter_metadata.
from prme import NodeType, SourceType
response = await engine.retrieve(
"What should I know about my coverage?",
user_id="member",
exclude_node_types={NodeType.NOTE, NodeType.ENTITY},
source_types={SourceType.USER_STATED},
)This retrieves source-supported derived claims without serving raw messages or
bare entities. Raw NOTE messages still use node-based modality defaults: a
question or hypothetical may be labelled ASSERTED. These controls do not perform
sentence classification or semantic entailment, repair older extracted claims,
or prove that pasted text describes its owner. USER_STATED includes user,
human and participant roles, so it is not an exact owner-authorship filter.
New direct stores and raw notes from assistant/system roles default to INFERRED and SYSTEM_INFERRED; caller-supplied epistemic types win. INFERRED remains eligible in DEFAULT mode, so use the source allowlist to exclude generated content entirely. Existing nodes and saved direct-store snapshots retain their original labels; no historical records are rewritten.
engine.retrieve()
async def retrieve(
self,
query: str,
*,
user_id: str,
scope: Scope | list[Scope] | None = None,
time_from: datetime | None = None,
time_to: datetime | None = None,
token_budget: int | None = None,
exclude_node_types: Collection[NodeType] | None = None,
source_types: Collection[SourceType] | None = None,
weights: ScoringWeights | None = None,
min_fidelity: RepresentationLevel | None = None,
include_cross_scope: bool = True,
) -> RetrievalResponseHybrid retrieval through the 6-stage pipeline.
| Parameter | Type | Default | Description |
|---|---|---|---|
query | str | required | Natural language query |
user_id | str | required | User ID for scoping |
scope | Scope | list[Scope] | None | None | Scope filter. None = all scopes |
time_from | datetime | None | None | Start of temporal window |
time_to | datetime | None | None | End of temporal window |
token_budget | int | None | None | Override default token budget (default: 4096) |
exclude_node_types | Collection[NodeType] | None | None | Omit selected node types before result limiting and packing |
source_types | Collection[SourceType] | None | None | Provenance allowlist; empty means no results |
weights | ScoringWeights | None | None | Override scoring weights |
min_fidelity | RepresentationLevel | None | None | Override minimum representation level. STRUCTURED, PROSE or FULL keeps text-free KEY_VALUE and REFERENCE fallbacks, and blank records, out of the context |
include_cross_scope | bool | True | Include cross-scope hints when scope is filtered |
Returns: RetrievalResponse with bundle, scored results, metadata, score traces.
retrieval_mode accepts DEFAULT or EXPLICIT across the async engine, sync
client, HTTP API, and MCP tool. DEFAULT excludes hypothetical and deprecated
claims; EXPLICIT includes them within the generated candidate pool.
engine.record_answer_citations()
async def record_answer_citations(
self,
submission: AnswerCitationSubmission,
*,
user_id: str,
) -> AnswerCitationRecordAppend the memory citations for an answer created from a saved retrieval. The
submission includes the retrieval request_id, a caller answer reference,
zero or more cited node IDs, an optional answer SHA-256 digest, a collection
method, and a caller-generated citation UUID used for safe retries. Every cited
node must be content-bearing and included in the receipt’s rendered context.
An empty citation tuple explicitly records that the answer reported no memory
citations. The record remains valid after graph changes or archival. It is
telemetry and does not modify memory state or ranking. Use
get_answer_citations() and list_answer_citations() to read owned records.
engine.get_node()
async def get_node(
self,
node_id: str,
*,
include_superseded: bool = False,
) -> MemoryNode | NoneRetrieve a node by ID. Returns None if not found or not visible (superseded/archived, unless include_superseded=True).
engine.query_nodes()
async def query_nodes(self, **kwargs) -> list[MemoryNode]Query nodes with flexible filters. Defaults to active lifecycle states
(tentative, stable and contested). Filters include user_id, node_type, scope
or scopes, session_ids, lifecycle_states, valid_at, min_confidence,
min_salience, content_contains_any, created_before, oldest_first and
limit. Use the plural lifecycle_states; there is no offset argument.
Pass the owner explicitly in application queries.
engine.get_event() / engine.get_events()
async def get_event(self, event_id: str, *, user_id: str | None = None) -> Event | None
async def get_events(self, user_id: str, **kwargs) -> list[Event]
async def get_extraction(self, event_id: str, *, user_id: str) -> ExtractionRecord | NoneRetrieve events by ID or by user. get_events() accepts session_id, limit, offset.
get_extraction() reads saved grounded model output without inference or pending
processing. None means no owned record exists. A record includes the source
hash, provider/model, schema and grounding versions, and structured output; it
does not acknowledge graph completion or prove its claims. The synchronous
MemoryClient has the same method. HTTP and MCP expose the scoped source record
through /v1/events/{event_id}/extraction and memory_get_extraction.
engine.consolidate_knowledge()
created = await engine.consolidate_knowledge(
user_id="alice", scope=Scope.PROJECT,
entity_names=["Aurora"], max_profile_tokens=1000,
)Build inferred entity profiles from complete same-owner, same-scope source
excerpts. Each replacement commits atomically after index preparation. Source
changes or competing rebuilds raise StaleProfileError; embedding/storage
errors also propagate. MemoryClient provides the equivalent synchronous API.
See the entity profile guide for requirements and recovery.
Lifecycle Transitions
async def promote(
self, node_id: str, *, user_id: str | None = None,
request_id: str | UUID | None = None,
) -> None # TENTATIVE → STABLE
async def supersede(
self,
old_node_id: str,
new_node_id: str,
*,
evidence_id: str | None = None,
user_id: str | None = None,
actor_id: str | None = None,
) -> None # → SUPERSEDED
async def archive(
self, node_id: str, *, user_id: str | None = None,
request_id: str | UUID | None = None,
) -> None # → ARCHIVED (terminal)All raise ValueError if the transition is invalid per the lifecycle state
machine. Supersedence commits a deterministic edge and checksummed before/after
record with the state change. Repeating the same ordered nodes, evidence, and
actor is safe across restarts; changing an input after publication is rejected.
HTTP exposes POST /v1/supersedences, and MCP exposes memory_supersede.
Promotion and archival accept an optional UUID request_id; reuse it with the
same node, action, and actor after an ambiguous response. HTTP accepts that UUID
as Idempotency-Key, and the MCP lifecycle tools accept request_id. Reusing a
key with different inputs raises a conflict. Calls without a request ID retain
strict lifecycle errors and reject a repeated transition.
engine.evaluate_condition()
updated = await engine.evaluate_condition(
node_id,
ConditionState.TRUE,
user_id="alice",
evidence_id=approval_event_id,
request_id="9ee0440b-4ea4-48c5-87ed-c1f43546475b",
evaluation_method=ConditionEvaluationMethod.TOOL,
reason="Approval service confirmed",
)This is an explicit recorded evaluation, not an automatic truth inference.
Mutation and a checksummed EPISTEMIC_TRANSITION record with the complete
before/after claim commit atomically. Evidence must belong to the same owner and
scope. Reusing request_id safely retries the same evaluation; changing its
inputs raises a conflict. The conditional epistemic type remains intact so a
changing condition can be evaluated again. MemoryClient.evaluate_condition()
provides the synchronous equivalent. HTTP uses
PUT /v1/nodes/{node_id}/condition with an optional UUID Idempotency-Key
header; MCP exposes memory_evaluate_condition.
engine.get_provenance()
history = await engine.get_provenance(
node_id, user_id="alice", operation_limit=100,
)
next_page = await engine.get_provenance(
node_id, user_id="alice",
operation_cursor=history.next_operation_cursor,
)Returns the current node, owned source events, references whose source event is
missing or outside the node scope, same-owner/same-scope contradiction edges,
and a chronological page of raw operation records. The page limit is 1–1000;
the opaque cursor is stable for append-only operation history. Missing and
foreign nodes return None. MemoryClient.get_provenance() is the synchronous
equivalent. HTTP exposes GET /v1/nodes/{node_id}/provenance; MCP exposes
memory_get_provenance.
engine.contradict() and engine.resolve_contradiction()
first, second = await engine.contradict(
first_claim_id,
second_claim_id,
user_id="alice",
actor_id="reviewer",
evidence_id=review_event_id,
)
winner, loser = await engine.resolve_contradiction(
second_claim_id,
first_claim_id,
user_id="alice",
resolver_actor_id="reviewer",
evidence_id=review_event_id,
)Each operation commits claim states, the contradiction edge, and its audit
record atomically. Claims and optional evidence must share one owner and scope.
An exact retry with the same ordered claims, actor, and evidence is a no-op;
changing those inputs after the operation raises ValueError instead of
silently accepting a different decision. Resolution returns the stable winner
followed by the deprecated loser and removes the loser from derived search
indexes after the durable transaction. MemoryClient provides synchronous
methods with the same names. HTTP uses POST /v1/contradictions and
POST /v1/contradictions/resolve; MCP uses memory_mark_contradiction and
memory_resolve_contradiction.
engine.close()
async def close(self) -> NoneShuts down ingestion pipeline, drains write queue, saves vector index, closes lexical index, and closes the DuckDB connection or PostgreSQL pool. Always call this (use try/finally).
4. Data Models
All models are Pydantic BaseModel subclasses. Import from prme.models.
MemoryObject (base class)
from prme.models import MemoryObject
class MemoryObject(BaseModel):
id: UUID # default: uuid4()
user_id: str # required — owner user ID
session_id: str | None # default: None
scope: Scope # default: Scope.PERSONAL
created_at: datetime # default: now(UTC)
updated_at: datetime # default: now(UTC)Event
Immutable event in the append-only log. Frozen (all fields read-only after creation).
from prme.models import Event
class Event(MemoryObject):
# Frozen (immutable)
timestamp: datetime # default: now(UTC)
role: str # required — "user", "participant", "assistant", "tool", or "system"
content: str # required — event content text
content_hash: str # auto-computed SHA-256 of content
metadata: dict | None # default: NoneMemoryNode
A typed node in the memory graph.
from prme.models import MemoryNode
class MemoryNode(MemoryObject):
node_type: NodeType # required
content: str # required
metadata: dict | None # default: None
confidence: float # default: 0.5, range [0.0, 1.0]
salience: float # default: 0.5, range [0.0, 1.0]
epistemic_type: EpistemicType # default: EpistemicType.ASSERTED
source_type: SourceType # default: SourceType.USER_STATED
lifecycle_state: LifecycleState # default: LifecycleState.TENTATIVE
valid_from: datetime # default: now(UTC)
valid_to: datetime | None # default: None (still valid)
superseded_by: UUID | None # default: None
evidence_refs: list[UUID] # default: [] — event IDs as evidenceMemoryEdge
A typed edge connecting two nodes.
from prme.models import MemoryEdge
class MemoryEdge(BaseModel):
id: UUID # default: uuid4()
source_id: UUID # required
target_id: UUID # required
edge_type: EdgeType # required
user_id: str # required
confidence: float # default: 0.5, range [0.0, 1.0]
valid_from: datetime # default: now(UTC)
valid_to: datetime | None # default: None
provenance_event_id: UUID | None # default: None
metadata: dict | None # default: None
created_at: datetime # default: now(UTC)5. Type Reference
All enums are str, Enum subclasses. Import from prme.types.
NodeType
Eight node types for the memory graph:
| Value | Description |
|---|---|
"entity" | Named entity (person, org, tool) |
"event" | Conversational event |
"fact" | Factual assertion |
"decision" | Decision record |
"preference" | User preference |
"task" | Active task |
"summary" | Summarization output |
"note" | Generic catch-all (default for store()) |
EdgeType
Nine relationship types:
| Value | Description |
|---|---|
"relates_to" | General relationship |
"supersedes" | New fact replaces old |
"derived_from" | Derivation provenance |
"mentions" | Entity mention |
"part_of" | Part-whole relationship |
"caused_by" | Causal link |
"supports" | Supporting evidence |
"contradicts" | Contradiction (triggers CONTESTED state) |
"has_fact" | Entity → Fact link |
Scope
Six namespace isolation levels:
| Value | Description |
|---|---|
"personal" | Single user. Highest trust. |
"project" | Shared across actors on a common goal |
"organisation" | Cross-project org-wide facts |
"agent" | Private to a specific AI agent |
"system" | System-generated (summaries, organizer) |
"sandbox" | Temporary isolated scope for testing |
LifecycleState
Six states with forward-only transitions:
| Value | Description |
|---|---|
"tentative" | Initial state for all new nodes |
"stable" | Promoted after verification |
"contested" | Unresolved contradiction detected |
"superseded" | Replaced by a newer node |
"deprecated" | Confirmed incorrect |
"archived" | Terminal state |
State machine transitions:
TENTATIVE ──► STABLE ──► SUPERSEDED ──► ARCHIVED
│ │ ▲
│ └──► CONTESTED │
│ │ └──► STABLE │
│ └──► DEPRECATED ───┘
└──► SUPERSEDED / CONTESTED / ARCHIVEDValid transitions:
- TENTATIVE → STABLE, SUPERSEDED, CONTESTED, ARCHIVED
- STABLE → SUPERSEDED, CONTESTED, ARCHIVED
- CONTESTED → STABLE, DEPRECATED, ARCHIVED
- SUPERSEDED → ARCHIVED
- DEPRECATED → ARCHIVED
- ARCHIVED → (terminal, no transitions)
EpistemicType
Seven epistemic classifications:
| Value | Weight | Description |
|---|---|---|
"observed" | 1.0 | Directly observed |
"asserted" | 0.9 | User stated |
"inferred" | 0.7 | System inferred |
"hypothetical" | 0.3 | Speculative (excluded in DEFAULT retrieval) |
"conditional" | 0.5 | Conditionally true |
"deprecated" | 0.1 | Confirmed wrong (excluded in DEFAULT retrieval). Not assignable at creation — lifecycle transition only. |
"unverified" | 0.5 | Not yet verified |
SourceType
Five source provenance types:
| Value | Description |
|---|---|
"user_stated" | User explicitly stated |
"user_demonstrated" | Inferred from user behavior |
"system_inferred" | System/LLM inferred |
"external_document" | From external documents |
"tool_output" | From tool/API output |
QueryIntent
| Value | Description |
|---|---|
"semantic" | Meaning-based similarity search |
"factual" | Looking for specific facts |
"entity_lookup" | Looking up a named entity |
"temporal" | Time-scoped query |
"relational" | Relationship-based query |
RetrievalMode
| Value | Description |
|---|---|
"default" | Excludes HYPOTHETICAL and DEPRECATED |
"explicit" | Includes everything |
RepresentationLevel
Ordered by fidelity (lowest → highest):
| Value | Description |
|---|---|
"reference" | Type and ID only, no memory text |
"key_value" | ID, type and confidence, no memory text |
"structured" | Type label plus the complete text |
"prose" | The complete text (no separate prose rendering exists) |
"full" | Complete original content |
6. Configuration
All configuration uses pydantic-settings. Env vars use PRME_ prefix with __ delimiter for nesting.
PRMEConfig
from prme.config import PRMEConfig
config = PRMEConfig(
# PostgreSQL backend (when set, all storage uses PostgreSQL)
database_url=None, # PRME_DATABASE_URL
# Storage paths (DuckDB mode only, ignored when database_url is set)
db_path="./memory.duckdb", # PRME_DB_PATH
vector_path="./vectors.usearch", # PRME_VECTOR_PATH
lexical_path="./lexical_index", # PRME_LEXICAL_PATH
# Single-file pack (opt-in; when set, the three paths above are ignored,
# and it cannot be combined with database_url). See "Single-file packs".
pack_path=None, # PRME_PACK_PATH
# Nested configs (see below)
embedding=EmbeddingConfig(...),
extraction=ExtractionConfig(...),
# Write queue
write_queue_size=1000, # PRME_WRITE_QUEUE_SIZE
# Retrieval scoring and packing
scoring=ScoringWeights(...),
packing=PackingConfig(...),
# Epistemic parameters
epistemic_weights={ # PRME_EPISTEMIC_WEIGHTS
"observed": 1.0,
"asserted": 0.9,
"inferred": 0.7,
"hypothetical": 0.3,
"conditional": 0.5,
"deprecated": 0.1,
"unverified": 0.5,
},
unverified_confidence_threshold=0.30, # PRME_UNVERIFIED_CONFIDENCE_THRESHOLD
# Confidence matrix overrides
confidence_overrides={}, # Keys: "epistemic:source" e.g. "observed:user_stated"
)Environment variable prefix: PRME_
Nested delimiter: __ (e.g., PRME_EMBEDDING__DIMENSION=384)
Single-file packs
A local pack can also be one file. This layout is opt-in: directory packs remain
the default, and their layout and behavior are unchanged. Give MemoryClient a
path that ends in .prme, or set pack_path (PRME_PACK_PATH):
from prme import MemoryClient, MemoryEngine, config_from_pack
# A path ending in .prme opens a single-file pack, creating it if needed.
with MemoryClient("./memories.prme") as client:
client.store("Alice prefers dark mode", user_id="alice")
# The same pack through the async engine.
config = config_from_pack("./memories.prme") # PRMEConfig(pack_path=<absolute path>)
assert config.single_file_pack
engine = await MemoryEngine.create(config)A .prme path that names an existing directory still opens as a directory pack,
and config_from_directory() always selects one, even with PRME_PACK_PATH
set. config_from_pack() creates the parent directory and ignores a
PRME_DATABASE_URL from the environment; the file itself is created when an
engine first opens it. While pack_path is set, db_path, vector_path and
lexical_path are ignored, and combining it with database_url fails
validation. An empty value, such as PRME_PACK_PATH=, means unset. The tables
inside the file are listed under Memory Pack in
Section 2.
On open, before it serves any read, the engine:
- Writes the
prme_manifestrow of a pack that has none, naming the embedding model most of its stored vectors were built with, or the configured model when it has no vectors. A reopen updatesprme_versionandupdated_atonly when another PRME version first opens the pack, such as after an upgrade. It logs a warning, without refusing to open, when the configured embedding model’s name or dimension differs from the recorded one. - Loads the USearch index from the
vector_snapshotrow and reconciles it againstvector_payloadsandvector_metadataexactly as it reconciles avectors.usearchfile. When the row is missing, unreadable or inconsistent with its recorded dimension or count, every vector is restored from its payload. - Builds the in-memory Tantivy index from active
nodeswith the same documents, fields andidorder asprme rebuild, which gives lexical documents to the same nodes as the write path (see Retrieval below). Engines in one process that open the same pack share this index: the first one builds it, and it is dropped when the last one closes.
The snapshot row is rewritten on close when the vector index changed, and after
every vector_save_interval changes to the vector index, where an addition and
a removal each count as one. Each save rewrites the whole snapshot (about
168 MB per 100,000 384-dimension vectors), so a large index may want a rarer
interval. A directory pack’s vectors.usearch is also saved after every
removal; the row is not, so archiving many nodes does not rewrite the snapshot
once per node. A removal the row does not show yet survives a crash, because
the next open drops snapshot keys whose vector metadata is gone. An open that
only reads therefore leaves the file byte-identical, except that the first open
after a session that added vectors moves the vector key sequence past them
once; retrieve() saves its receipt, which is a write. prme rebuild rebuilds
both indexes and records the rebuilding embedding model in the manifest. While
a pack is open, DuckDB keeps memories.prme.wal beside it, and if DuckDB spills
to disk, its temporary files go to a memories.prme.tmp directory beside it,
for a plain and an encrypted pack alike. The pack is one file at rest, so copy
it after its engine closes.
Encryption. With encryption_enabled=True and an encryption_key, a
single-file pack uses DuckDB’s native encryption instead of Fernet file
encryption. DuckDB encrypts the file and its write-ahead log with AES-256-GCM.
It does not encrypt its temporary files by default, even for an encrypted
database, so PRME turns on DuckDB’s temp_file_encryption for an encrypted
pack and sets its temp_directory to memories.prme.tmp. Stored text
therefore does not appear in clear on disk while the engine runs.
encryption_key takes the same forms as for a directory pack. A raw key, which
is a 44-character url-safe base64 Fernet key or any value with the raw_key:
prefix, reaches DuckDB as its 32 bytes in hex and does not depend on a salt.
Any other value, or one with the passphrase: prefix, is a passphrase: PRME
derives a 32-byte key from it with PBKDF2-HMAC-SHA256 (600,000 iterations) and
the fixed, versioned salt prme-single-file-pack-v1. A per-pack salt cannot be
stored inside a file that needs the key to open, so identical passphrases give
identical keys across packs; each guess still costs the full iteration count.
Use a strong passphrase, and do not reuse it across packs.
An encrypted pack cannot be opened without its key. A missing key, a wrong
key, or encryption configured for an unencrypted pack raises EncryptionError:
the pack is neither opened nor encrypted, the file and any WAL beside it stay
byte-identical, and the message never contains the key. An encrypted pack can
be open in only one engine per process; a second open raises PackInUseError,
whatever spelling of the path it uses. Engines that open the same plain pack in
one process share one DuckDB instance and one in-memory lexical index, so a
node stored through one engine is found by lexical search through the others
at once, as engines on one directory pack share its on-disk index. This holds
for MemoryClient objects too, although each runs its engine on its own event
loop. The first engine’s lexical_commit_interval and
lexical_commit_max_delay_s apply to the shared index. Each engine still keeps
its own vector index, so vector search through one engine finds a vector added
through another only after it reopens the pack, as on a directory pack.
engine.lock() and engine.unlock()
belong to Fernet directory packs and raise RuntimeError for a single-file
pack.
Compaction. DuckDB never shrinks a database file: deleted rows and replaced
snapshot rows leave free blocks behind. prme compact ./memories.prme copies
the pack with COPY FROM DATABASE into a fresh hidden file beside it, keeping
its storage version and, for an encrypted pack, its key and cipher, then
replaces the original with one rename and reports the size before and after. A
failure leaves the original untouched. Through a symbolic link, the file the
link names is compacted and the link stays, and the new file keeps the pack’s
permission bits. Close the pack first: compaction refuses
a pack with memories.prme.wal beside it, DuckDB’s file lock refuses one that
another process has open, and in the same process every engine that uses it
must be closed. Rows, node IDs and vector keys do not change, so
prme compact ./my_memories/memory.duckdb compacts a directory pack’s database
too. On a copy of a LoCoMo benchmark pack measured on 2026-10-14, compaction
took a 743.5 MiB memory.duckdb to 510.5 MiB in 1.0 s, with identical
retrieval results. The live data is about 510 MiB, of which 492.7 MiB is 404
stored retrieval receipts (RETRIEVAL_REQUEST rows in operations.payload, up
to 2.4 MiB each). How large stored receipts should be is a separate matter.
Migration. prme migrate ./my_memories ./memories.prme copies a directory
pack into a new single-file pack and leaves the directory in place. The new
name must end in .prme, and migration never overwrites an existing file. The
directory pack is opened once with an engine and copied through that engine’s
connection, so a legacy Fernet-encrypted directory pack migrates with its key
and is encrypted again when that engine closes. The copy is built in a hidden
temporary file, opened once as a single-file pack to write its vector snapshot
and manifest, checked for both, and only then moved into place, so a failure
never leaves a partial pack. With a key, the new pack is encrypted natively
under that key, even when the directory pack was not encrypted; without one,
it is not encrypted. The LoCoMo benchmark pack above, with 1,413 nodes, 556
edges, 419 events and 1,413 vectors, migrated into a 521.5 MiB .prme file in
1.4 s. Directory packs keep opening as before, so migration is never required.
Retrieval. A single-file pack is searched with the same indexes, scoring
and packing as a directory pack, and vector hits are identical. The write
path, prme rebuild and the open of a single-file pack give lexical documents
to the same nodes (has_lexical_document in prme.storage.lexical_index):
every active node with content gets one, except an entity node that
extraction created, which gets only a vector. An entity stored directly with
store() gets both. A reopen therefore keeps the lexical documents of a
single-file pack’s active nodes, and a pack migrated from a directory pack
built with ingest() holds the same documents as its source:
the LoCoMo source above held 827 of its 1,413 active nodes in its Tantivy
index, and so does the migrated pack. Because prme rebuild follows the same
rule, rebuilding a pack built with ingest(), a directory pack included, no
longer adds lexical documents for its extracted entities, which keep their
vectors. A node a session retires leaves both indexes when it is retired,
whether the engine’s supersede(), archive() or resolve_contradiction()
retired it or the derivation commit of ingest() replaced it, as with an
earlier copy of a repeated claim; the eviction is best effort, and the
index_compaction organizer job retries what it left. A pack whose claims
ingest() retired before this change keeps their entries until
index_compaction or prme rebuild removes them (a single-file pack’s open
drops the lexical ones). Tantivy keeps a deleted document in its segment’s
BM25 statistics until a merge, so a single-file pack’s in-memory index
rebuilds itself from its live documents before the next search after such a
deletion (one pass over the live documents: 12 ms for 1,413 documents and
0.88 s for 100,000, measured on 2026-10-15), and a session, a reopen and a
rebuild give the same documents and scores. BM25 results can still differ in
two cases:
- A directory pack keeps Tantivy’s deleted-document markers on disk, so it
scores the same before and after a restart but can differ slightly from a
prme rebuilduntil Tantivy merges the segment. - Tied BM25 scores come back in Tantivy’s segment order, and
lexical_kcuts candidates in that order, so a tie can reach the final ranks. A single-file pack’s open and its in-memory rebuild add documents inidorder on one writer thread into one segment (measured up to 100,000 documents), so two opens of the same.prmefile give the same order, and so doesprme rebuildon a single-file pack, which ends by settling the index into that one segment. A session that only added documents holds them in write order across several segments, as a directory index does (prme rebuildon a directory pack commits in batches and so makes several segments), so their tie order can differ from the next open’s.
Directory-pack retrieval is unchanged, except after prme rebuild on a pack
built with ingest(): on the LoCoMo half of the offline evidence gate (1,540
questions), contexts from directory packs were byte-identical with and without
single-file pack support. That gate ran before the lexical policy above was
shared. The LongMemEval-S half could not be run, because its saved control
captures were not present on the machine that ran the gate. See the
RFC-0014 amendment
for details.
Tools. Every prme command that takes a db_path also opens a .prme
file, among them prme rebuild, prme export and prme info.
prme init ./memories.prme creates exactly that one file, encrypted when
encryption is configured, and writes no .env.example beside it; it prints a
hint instead. prme doctor ./memories.prme checks the file, its required
tables and its manifest, and warns about a WAL beside it and about a missing or
stale vector snapshot. prme doctor, prme compact and prme migrate take
--encryption-key KEY, where an empty value means no key. Without it, for a
.prme pack, they use PRME_ENCRYPTION_KEY when PRME_ENCRYPTION_ENABLED is
true, which is where prme info and the other commands always read the key;
they read only those two settings, from the environment or a .env file. A
directory pack’s memory.duckdb never takes them, because for a directory pack
they configure Fernet file encryption. See the
CLI reference.
prme-mcp --db-path ./memories.prme serves a single-file pack by setting
PRME_PACK_PATH, and sets PRME_DATABASE_URL to an empty value so a database
URL from the environment cannot redirect it; given a directory, it sets
PRME_PACK_PATH to an empty value, so a pack path from the environment or a
.env file cannot take the directory’s place. MemoryWorkspace(..., single_file_packs=True), which
MemoryWorkspace.open() also accepts, keeps each namespace’s pack in one
memory.prme file. The registry does not record the layout, so pass the same
setting every time the workspace opens; an initialized namespace whose pack is
missing in the requested layout is refused rather than recreated. In the
directory layout, a pack_path in the workspace configuration is cleared for
every namespace. prme.client.is_pack_file(path) reports whether a path
selects a single-file pack. Native encryption needs DuckDB 1.4.0 or later, and
PRME requires duckdb>=1.4.0.
EmbeddingConfig
from prme.config import EmbeddingConfig
EmbeddingConfig(
provider="fastembed", # PRME_EMBEDDING_PROVIDER
model_name="BAAI/bge-small-en-v1.5", # PRME_EMBEDDING_MODEL_NAME
dimension=384, # PRME_EMBEDDING_DIMENSION; optional for registered models
api_key=None, # PRME_EMBEDDING_API_KEY
)Supported providers: "fastembed" (local, default), "openai" (requires API key).
When dimension is omitted, PRME reads registered FastEmbed model metadata or
uses the known OpenAI model dimension without downloading weights. Selecting
provider="openai" alone chooses text-embedding-3-small and 1,536 dimensions.
Unknown or newly released model names require an explicit positive dimension,
and fail during configuration instead of after an index has been opened.
ExtractionConfig
from prme.config import ExtractionConfig
ExtractionConfig(
provider="openai", # PRME_EXTRACTION_PROVIDER
model="gpt-4o-mini", # PRME_EXTRACTION_MODEL
max_retries=3, # PRME_EXTRACTION_MAX_RETRIES
timeout=30.0, # PRME_EXTRACTION_TIMEOUT
temperature=0.0, # PRME_EXTRACTION_TEMPERATURE
reasoning_effort=None, # PRME_EXTRACTION_REASONING_EFFORT
api_key=None, # PRME_EXTRACTION_API_KEY
base_url=None, # PRME_EXTRACTION_BASE_URL
)Without api_key or base_url, OpenAI and Anthropic use their own
OPENAI_API_KEY/OPENAI_BASE_URL or ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL,
from the process environment first and then .env; Ollama uses its local
server. Opt-in query reformulation (enable_query_reformulation=True) calls the
same provider, model, endpoint and credential, and gives up after the same
timeout. It keeps its own sampling: the provider’s default temperature, and
for Ollama a reasoning effort of "none".
Supported providers: "openai", "anthropic", "ollama". Temperature zero
favors repeatable schema-constrained extraction. Ollama resolves an omitted
reasoning effort to "none", preventing thinking traces from exhausting the
structured response window. Ollama also uses its constrained JSON output mode
instead of requiring a tool-call envelope. Other providers retain their native default.
Benchmark before increasing either setting.
ScoringWeights
Scoring fields are frozen. The six additive weights must sum to 1.0. Epistemic weight is multiplicative; paths weight is a tiebreaker.
PRMEConfig() scores with reciprocal rank fusion (fusion="rrf", rrf_k=60,
RFC-0005 Section 7.2), which does not use the additive weights. A
ScoringWeights object built in code, like the one below, defaults to
fusion="weighted" and scores with them. Set PRME_SCORING__FUSION=weighted
to keep the weighted formula with environment settings; other
PRME_SCORING__* variables keep rank fusion unless it is set.
Scoring and packing configuration reject NaN and positive/negative infinity
at construction or environment loading, including nested node-type boosts and
scoped scoring overrides. Validation errors identify the offending field before
retrieval starts. Finite defaults and their version identifiers are unchanged.
from prme.retrieval.config import ScoringWeights
ScoringWeights(
w_semantic=0.30, # Semantic similarity
w_lexical=0.15, # Lexical relevance
w_graph=0.20, # Graph proximity
w_recency=0.10, # Recency factor
w_salience=0.10, # Salience
w_confidence=0.15, # Confidence
w_epistemic=0.05, # Epistemic (multiplicative)
w_paths=0.00, # Multi-path corroboration (tiebreaker)
recency_lambda=0.02, # Decay rate: exp(-lambda * days)
)
# .version_id → deterministic SHA-256 hash (12 chars) for traceabilityPackingConfig
multipath_ordering="balanced", the PRMEConfig default and PackingConfig’s
own field default, reserves the highest-scored ordinary multi-path candidate,
then uses a quarter-length penalty. "score" selects composite-score ordering
within the multi-path priority tier.
Pins, instructions, active tasks, other tiers and measured whole-output budgets
keep their existing rules. Balanced was chosen for JSON records after a complete
119-question answer trial at 4K: balanced scored 83 versus density at 67, with 26
wins and 10 losses. A separately registered 381-question answer confirmation
scored 250 versus 185, with 88 wins and 23 losses. Both source partitions had
already been inspected, so these results do not establish superior behavior for
every workload. Score order became the default with the one-line reader format
and rank fusion, which passed a paired answer test twice. Balanced order with
those settings then passed the same test twice against score order and is the
default again (see
Default retrieval settings).
from prme import MemoryClient, config_from_directory
from prme.retrieval.config import PackingConfig
config = config_from_directory("./my_memories")
config.packing = config.packing.model_copy(update={"multipath_ordering": "density"})
with MemoryClient(config=config) as client:
client.store("Aurora requires deployment approval.", user_id="alice")
result = client.retrieve("Aurora deployment policy", user_id="alice")
print(result.bundle.render())config_from_directory() creates the directory and resolves the database, vector
and lexical paths together. Set typed options before opening the client. Passing
a config to MemoryClient uses its paths as-is; its separate directory argument
is ignored. The equivalent environment setting is
PRME_PACKING__MULTIPATH_ORDERING=density.
Temporal context guidance is enabled by default; disable it with
PRME_PACKING__CONTEXT_GUIDANCE_MODE=off. all additionally enables
experimental current-state and personalization prompts. Retrieval receipts
retain ordering, guidance, context format, episode and evidence-projection
settings in receipt.packing, and the current-update multiplier in
receipt.scoring. The default context format is reader: one plain line per
record (date, state tags, text) with the audit envelope kept in the bundle and
receipt. Set PRME_PACKING__CONTEXT_CITATIONS=true to add [m3] references,
which answerability checks and verify_bundle() need. Set
PRME_PACKING__CONTEXT_FORMAT=auditable for self-describing JSON records, or
compact for schema-declared JSON arrays and bundle-local references. Scoring
defaults to reciprocal rank fusion of semantic and lexical ranks
(PRME_SCORING__FUSION=rrf, RFC-0005 Section 7.2); set
PRME_SCORING__FUSION=weighted for the weighted sum. Rank-fused scores are
rank-based, so min_score then compares against each result’s
semantic_relevance, its semantic cosine similarity, and a floor tuned on weighted
scores does not carry over. When vector search fails or detects an embedding
model mismatch and no result has a cosine, the floor is skipped instead of
returning nothing, metadata.min_score_skipped is set, and the receipt uses
version 18. The LangChain and LlamaIndex retrievers accept an optional
min_score, and add min_score_skipped: True to every result’s metadata when
the floor was skipped.
Fused scores are compressed, so session neighbors
at the weighted decay of 0.85 could crowd primary evidence out of the context;
PRME_PACKING__SESSION_CONTEXT_RANK_FUSION_SCORE_DECAY (default 0.6) gives
rank-fused triggers their own decay. Rank fusion alone ignores recency, so on a
current-state question the older of two conflicting memories could rank above
the newer one; PRME_SCORING__RRF_RECENCY_BOOST (default 0.25) applies the
weighted formula’s current-state recency to the fused score, and
PRME_SCORING__RRF_TIE_BREAK=event_time (the default) orders equal fused
scores newest first instead of by node ID. Default retrievals write schema
version 22 receipts, which record those settings, the decay and folded repeated
text (on by default since 2026-10-06), or version 19 with
PRME_PACKING__FOLD_REPEATED_TEXT=false. Rank fusion
without the recency settings writes version 16, 17 with the decay or 18 with a
skipped min_score; the reader format with weighted scoring writes version 14,
and weighted retrievals in the auditable or compact format write version 12. Weighted
retrievals with PRME_SCORING__RECENCY_TIME=event_time write version 20 in any format. Retrievals with PRME_PACKING__SESSION_CONTEXT_PACKING set write version 21 under either formula. Retrievals with PRME_PACKING__FOLD_REPEATED_TEXT=true write version 22 under either formula. Versions 1–7 mean episode routing was disabled. Versions 1–6 retain
their original canonical JSON and feedback checksums and always mean auditable
rendering. Versions 1–5 also mean context guidance was off. For source blocks or
bounded dialogue episodes stored under meaningful session IDs, set
PRME_PACKING__EPISODE_CONTEXT_TOP_K=2 to trial deterministic episode routing;
the default 0 disables it.
Set PRME_PACKING__EVIDENCE_PROJECTION_TOP_K=50 to trial source projection for
the top exact evidence groups; its default 0 also disables it.
Set PRME_PACKING__EVIDENCE_AUGMENTATION_TOP_K=10 to retain the derived claims
and add bounded direct sources beside them. Projection and augmentation are
mutually exclusive and disabled by default.
PRME_PACKING__EVIDENCE_AUGMENTATION_ANCHOR_POLICY=non_entity prevents an
entity-name match from routing its whole source passage; the default all
preserves version 11 behavior.
Current-state retrieval gives the newest record that explicitly presents itself
as an update a bounded, relevance-capped multiplier. Configure
PRME_SCORING__CURRENT_UPDATE_MULTIPLIER between 1.0 and 2.0; the default is
the provisional 1.30, and 1.0 disables it. Receipts record the exact applied
coefficient. This ranking signal does not supersede or validate either claim.
This example chooses smaller candidate limits explicitly; it is not a list of defaults.
A PackingConfig built from scratch starts from its field defaults (auditable,
balanced, no rank fusion session decay), not from PRMEConfig().packing; copy
config.packing with model_copy(update=...) to keep the product defaults.
from prme.retrieval.config import PackingConfig
from prme.types import RepresentationLevel
PackingConfig(
token_budget=4096, # Context budget in tokens
min_fidelity=RepresentationLevel.REFERENCE, # Minimum fidelity
overhead_tokens=100, # Additional caller reserve beyond measured context
context_format="auditable", # PRMEConfig default "reader" (plain lines); or "compact" for arrays
context_citations=False, # Reader only: add [m3] references and context_references
episode_context_top_k=0, # Opt-in session-scoped episode routing
episode_context_local_k=8, # Records reserved per selected episode
episode_context_score_decay=0.95,# Inherited episode-evidence score
graph_max_candidates=50, # Max from graph traversal
vector_k=50, # Max from vector search
lexical_k=50, # Max from lexical search
graph_max_hops=3, # Max graph hops (1-3)
cross_scope_top_n=5, # Top-N cross-scope hints
)Context packing counts the complete rendered output with the configured
tokenizer. The legacy chars_per_token and cross_scope_token_budget names
remain accepted for configuration and receipt compatibility, but non-default
values are ignored with a warning. Cross-scope hints live outside the packed
context and are bounded by cross_scope_top_n.
7. Retrieval Pipeline Deep Dive
The 6-stage pipeline transforms a natural language query into a token-budgeted MemoryBundle.
Stage 1: Query Analysis
Input: Raw query string, optional temporal overrides.
Output: QueryAnalysis with intent, entities, temporal signals, retrieval mode.
class QueryAnalysis(BaseModel):
query: str
intent: QueryIntent # semantic, factual, entity_lookup, temporal, relational
entities: list[str] # extracted entity names
temporal_signals: list[dict] # [{type, value, resolved}, ...]
time_from: datetime | None
time_to: datetime | None
retrieval_mode: RetrievalMode # default or explicit
request_id: UUID # unique per retrievalIntent is the first pattern that matches: entity lookup, temporal, relational,
factual, then semantic. Only the temporal intent is read downstream, by
temporal affinity scoring, the current-state path and the context guidance.
PRMEConfig.query_intent_order="temporal_first" (PRME_QUERY_INTENT_ORDER)
checks temporal wording and dates before the entity patterns, so a temporal
question that names someone gets temporal affinity (RFC-0005 Section 3, issue
#85). The default, entity_first, is unchanged.
Stages 2-3: Candidate Generation + Merging
Input: QueryAnalysis, user_id, scope, temporal window, packing config.
Output: Deduplicated list[RetrievalCandidate], per-backend candidate counts.
Runs three backends in parallel:
- Graph neighborhood — entities from query analysis → graph traversal up to
graph_max_hopshops - Vector similarity — ANN search over embeddings, top-
vector_k - Lexical search — BM25-style full-text search, top-
lexical_k
Candidates are deduplicated by node_id. Each candidate tracks which backends produced it (paths field) and multi-path count.
For extracted stores, set max_per_source=1 on retrieve() when the consuming
surface presents node.content as a list of passages. This limits only results
with the same exact nonempty evidence set and byte-identical content, so sibling
claims cannot consume the result budget with repeated source text. The default
is disabled while answer-quality trials establish when to promote it.
Use max_per_evidence=1 when that surface needs one ranked representative per
exact cited evidence set even if extracted sibling nodes have different text.
This broader option fills the requested result limit from later evidence groups.
Nodes without evidence remain independent, and identical text citing distinct
events remains distinct. The result metadata and retrieval receipt record the
applied value; exclusions use evidence_limit.
Stage 4: Epistemic Filtering
Input: Merged candidates, retrieval mode. Output: Filtered candidates, excluded candidates list.
In DEFAULT mode:
- Excludes nodes with
epistemic_typeofHYPOTHETICALorDEPRECATED - Filters
UNVERIFIEDnodes belowunverified_confidence_threshold(default: 0.30)
In EXPLICIT mode: no filtering.
Stage 5: Scoring + Ranking
Input: Filtered candidates, scoring weights, epistemic weights. Output: Scored + sorted candidates, score traces.
Composite score formula:
additive = (
w_semantic * semantic_score +
w_lexical * lexical_score +
w_graph * graph_proximity +
w_recency * recency_factor +
w_salience * salience +
w_confidence * confidence
)
composite = additive * epistemic_weightThe epistemic weight is a direct multiplier on the additive sum (not 1 + w_epistemic * ...). The w_paths / path_score value is used only as a sort tiebreaker, not in the composite score itself.
Where:
recency_factor = exp(-recency_lambda * days_since_update)— usesupdated_at(falls back tocreated_at); withrecency_time="event_time"(PRME_SCORING__RECENCY_TIME, weighted fusion only) it usesevent_time, elsecreated_at, so history imported with a past event time is not scored as brand newgraph_proximity: 1-hop = 1.0, 2-hop = 0.7, 3-hop = 0.4epistemic_weight: looked up fromepistemic_weightsdict byEpistemicTypepath_score: number of backends that independently found this candidate (tiebreaker only)
Candidates sorted by (-composite_score, -path_score) for deterministic ordering.
Every candidate gets a ScoreTrace for full explainability:
class ScoreTrace(BaseModel):
semantic_similarity: float
lexical_relevance: float
graph_proximity: float
recency_factor: float
salience: float
confidence: float
epistemic_weight: float
path_score: float
composite_score: floatStage 5.5: Conflict Metadata
CONTESTED candidates are annotated with conflict_flag=True and contradicts_id pointing to the counterpart node. Counterparts are not auto-injected — only included if independently relevant.
Cross-Scope Hints
When scope is filtered and include_cross_scope=True, a secondary vector+lexical pass runs without scope restriction. Results from outside the primary scope are scored and the top-N (default: 5) appear as cross_scope_hints in the response — never mixed into primary results.
Stage 6: Context Packing
Input: Scored candidates, packing config.
Output: MemoryBundle.
Greedy bin-packing within the token budget:
- Count each serialized candidate with the configured tokenizer
- Reserve
overhead_tokensfor JSON envelope - Pack candidates in score order, assigning representation levels:
- High-budget:
FULLorPROSE - Mid-budget:
STRUCTUREDorKEY_VALUE - Low-budget:
REFERENCE
- High-budget:
- Group packed candidates into sections:
entity_snapshots,stable_facts,recent_decisions,active_tasks,provenance_refs,contested_claims
class MemoryBundle(BaseModel):
sections: dict[str, list[RetrievalCandidate]]
included_count: int
excluded_ids: list[UUID]
tokens_used: int
token_budget: int
budget_remaining: int
min_fidelity: RepresentationLevel
rendered_context: str
coverage_notice: str | None # Counted system boundary when applicableRetrievalResponse (full return type)
class RetrievalResponse(BaseModel):
bundle: MemoryBundle # Context-packed output
results: list[RetrievalCandidate] # Scored results (pre-packing)
metadata: RetrievalMetadata # Timing, counts, config version
score_traces: list[ScoreTrace] # Always-on, one per result
filter_metadata: FilterMetadata | None # Active filters for debugging
cross_scope_hints: list[RetrievalCandidate] # Results from outside scopeclass RetrievalMetadata(BaseModel):
request_id: UUID
candidates_generated: dict[str, int] # Per-backend counts
candidates_filtered: int
candidates_included: int
scoring_config_version: str # ScoringWeights.version_id
timing_ms: float
backends_used: list[str]
embedding_mismatch: bool
backend_failures: dict[str, str]
min_score_skipped: bool # Rank fusion floor skipped: no cosine
aggregation_coverage: AggregationCoverage | NoneFor detected natural-language counts and lists, aggregation_coverage reports
exhaustive=False, candidate/selection/context counts, stable limitation codes,
and candidate paths observed at their configured caps. The rendered bundle also
contains a token-counted non-exhaustive warning. Use scan_nodes() or
iter_nodes() for complete stored-record traversal; semantic retrieval cannot
prove that every real-world item matching a natural-language criterion was
found or deduplicated.
The same stored-record page is available to remote clients through
GET /v1/nodes/scan and the MCP tool memory_scan_nodes. Both require an
explicit or credential-bound owner, accept scope, node type, lifecycle, UUID
cursor, and page-size filters, and return has_more plus next_cursor. Follow
the cursor until has_more is false. The response reports order="id" and
consistency="page": traversal is complete for an unchanged store but is not a
transaction snapshot across requests. GET /v1/nodes remains a bounded query
convenience and must not be used as an export or counting contract.
Structured assertion counts use the same complete scan without exposing page bookkeeping to the caller:
from datetime import datetime, timezone
from prme import AssertionQuery
result = memory.aggregate_assertions(
AssertionQuery(
subjects=["I"],
predicates=["tried"],
group_by=["object"],
event_time_from=datetime(2025, 1, 1, tzinfo=timezone.utc),
),
user_id="alice",
)matched_records counts assertion occurrences and distinct_count counts the
normalized group_by keys. Each group carries occurrence/evidence counts,
bounded provenance samples, and its earliest/latest event time. Selectors use
exact NFKC/case/whitespace-normalized matching, with predicate spaces and
hyphens normalized to underscores. Use retrieval_mode="explicit" to include
all epistemic and lifecycle states, or pass explicit lifecycle states to narrow
the exact state set independently.
HTTP exposes this at POST /v1/assertions/aggregate with
{"user_id": "alice", "query": {...}}; MCP exposes
memory_aggregate_assertions. Both bind the result to the authenticated owner.
stored_set_exhaustive=true covers matching structured records for an unchanged
store. Separate fields report unknown source-extraction and real-world coverage,
with semantic equivalence limited to normalized exact values. Finish pending
ingestion and prevent concurrent mutation for audited counts. This API
counts and groups text values; it does not parse or sum numeric quantities.
For facts carrying grounding="object_decimal_v1", use the dedicated exact
quantity operation:
from prme import QuantityAggregationQuery
totals = memory.aggregate_quantities(
QuantityAggregationQuery(
predicate_prefixes=["raised"],
units=["$"],
group_by=["unit"],
),
user_id="alice",
)Each group returns an exact Decimal total, minimum, maximum, value/evidence
counts, temporal bounds, and bounded per-node source samples. JSON serializes
decimals as strings. group_by must contain unit; normalization is limited to
Unicode, case, and whitespace, and no conversion or currency inference occurs.
predicate_prefixes is opt-in and token-bounded after predicate normalization:
raised includes raised and raised_*, but not fundraised. A response using
it reports semantic_equivalence="normalized_exact_and_predicate_prefix" rather
than normalized_exact_only; no synonym or embedding inference is performed.
The read path revalidates the stored decimal against its claim object and source
evidence. HTTP exposes POST /v1/quantities/aggregate; MCP exposes
memory_aggregate_quantities. These operations use the same unchanged-store and
extraction/real-world coverage boundaries as assertion aggregation.
For supported simple questions, the convenience path returns its exact plan and execution together:
planned = memory.aggregate_quantities_from_text(
"How many kilometers did I run?",
user_id="alice",
)The planner recognizes only complete, qualifier-free amount/count shapes in a
fixed action and unit table. It preserves the exact I or we subject from the
question and selects positive default epistemic state, explicit predicate-prefix
families, and unit-separated groups. Unsupported qualifiers, negation, future
wording, named subjects,
actions, or units return plan.status="unsupported" with no scan. Inspect
plan.query and plan.assumptions; the convenience result does not hide a
semantic model call. HTTP exposes POST /v1/quantities/aggregate-text; MCP
exposes memory_aggregate_quantities_from_text.
Use the exact temporal state operation when the application already knows an assertion’s subject and predicate:
from datetime import datetime, timezone
from prme import AssertionStateQuery, Scope
state = memory.get_assertion_state(
AssertionStateQuery(
subject="Alice",
predicate="lives_in",
scope=Scope.PERSONAL,
valid_at=datetime.now(timezone.utc),
),
user_id="alice",
)This scans all matching FACT, DECISION, and PREFERENCE records for an unchanged
store. It returns eligible current candidates separately from the bounded
timeline, along with event time, ingestion time, validity windows, lifecycle,
supersedence pointers, contradiction edges, and evidence IDs. Status is
unknown, single, consistent, multiple, or contested. multiple does
not imply a contradiction, and the operation never treats the latest record as
truth. Scope and valid_at are required to prevent cross-scope state mixing and
implicit wall-clock results. An optional knowledge_at remains an ingestion
cutoff over current lifecycle state and returns exact_snapshot=false.
HTTP exposes POST /v1/assertions/state; MCP exposes
memory_get_assertion_state. Subject and predicate matching use the same exact
normalization contract as assertion aggregation. Source extraction and
real-world coverage remain explicitly unknown.
8. Multi-User / Multi-Tenant
user_id Isolation
Every store(), ingest(), and retrieve() call requires a user_id. All backend queries are scoped to this user — a user’s data is never returned for another user’s queries.
# User A stores memories
await engine.store("I like Python", user_id="user-a")
# User B cannot see User A's memories
response = await engine.retrieve("What languages?", user_id="user-b")
# response.results → emptyScope Layering
Scopes provide cross-cutting isolation within a user’s data:
# Store at different scopes
await engine.store("My preference", user_id="alice", scope=Scope.PERSONAL)
await engine.store("Team decision", user_id="alice", scope=Scope.PROJECT)
# Retrieve only personal memories
response = await engine.retrieve("...", user_id="alice", scope=Scope.PERSONAL)
# Retrieve from multiple scopes
response = await engine.retrieve("...", user_id="alice", scope=[Scope.PERSONAL, Scope.PROJECT])
# Retrieve all scopes (default)
response = await engine.retrieve("...", user_id="alice") # scope=NoneCross-scope hints automatically surface relevant results from other scopes when filtering by scope, appearing in response.cross_scope_hints.
9. Working Examples
Basic Store and Retrieve
engine = await MemoryEngine.create()
# Store different types
await engine.store("Alice is a senior engineer", user_id="u1", node_type=NodeType.FACT)
await engine.store("Use Python for backends", user_id="u1", node_type=NodeType.DECISION)
await engine.store("Prefers dark mode", user_id="u1", node_type=NodeType.PREFERENCE)
# Retrieve
response = await engine.retrieve("What do we know about Alice?", user_id="u1")
for r in response.results:
print(f"[{r.node.node_type.value}] {r.node.content} (score: {r.composite_score:.3f})")
await engine.close()Chat App Integration Pattern
Core pattern: store every message, retrieve before each LLM call, inject memories into system prompt.
from openai import AsyncOpenAI
from prme import MemoryEngine, PRMEConfig, NodeType, Scope
client = AsyncOpenAI()
engine = await MemoryEngine.create(PRMEConfig(
db_path="./chat_memory.duckdb",
vector_path="./chat_vectors.usearch",
lexical_path="./chat_lexical",
))
async def handle_message(user_input: str, user_id: str, session_id: str) -> str:
# 1. Store the user message
await engine.store(
user_input,
user_id=user_id,
session_id=session_id,
role="user",
node_type=NodeType.EVENT,
)
# 2. Retrieve relevant memories
response = await engine.retrieve(user_input, user_id=user_id)
# 3. Format memories for the system prompt
memory_lines = []
for r in response.results[:10]:
memory_lines.append(f"- [{r.node.node_type.value}] {r.node.content}")
memory_block = "\n".join(memory_lines)
# 4. Call LLM with memories injected
system_prompt = f"""You are a helpful assistant with persistent memory.
## Relevant memories
{memory_block}
"""
completion = await client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input},
],
)
reply = completion.choices[0].message.content
# 5. Store the assistant response
await engine.store(
reply,
user_id=user_id,
session_id=session_id,
role="assistant",
node_type=NodeType.EVENT,
)
return replyLLM Extraction with ingest()
# Requires OPENAI_API_KEY (or another extraction provider)
engine = await MemoryEngine.create(PRMEConfig(
extraction=ExtractionConfig(provider="openai", model="gpt-4o-mini"),
))
# Ingest a conversation — LLM extracts entities, facts, relationships
event_ids = await engine.ingest_batch(
[
{"role": "user", "content": "I just started using Neovim and I love it."},
{"role": "assistant", "content": "Neovim has excellent plugin support!"},
{"role": "user", "content": "Sarah recommended it. She has used it for years."},
],
user_id="alice",
session_id="session-1",
wait_for_extraction=True, # block until extraction done
scope=Scope.PROJECT,
)
# Now retrieve — extraction created entities (Alice, Sarah, Neovim) and facts
response = await engine.retrieve("What tools does Alice use?", user_id="alice")When a fact object contains one exact numeric amount, built-in extraction may
also populate node.metadata["quantity"] with decimal-string value, verbatim
unit, verbatim source_text, and grounding="object_decimal_v1". Grounding
requires the quantified phrase in both the object and source evidence and checks
the parsed decimal. When JSON transport emits a float, built-in extraction can
recover the decimal only by reparsing one exact supported token from the grounded
source phrase; it never converts the float. Approximation or range cues in the
surrounding evidence reject clipped exact-looking output. If model-authored
quantity fields are absent or invalid, built-in extraction can recognize one
verbatim currency or unit from its bounded physical, data and count-unit
lexicon in an otherwise grounded fact object. It does not normalize that unit
or accept an unlisted noun as a measure. Invalid optional quantity output is removed while the
otherwise grounded fact remains. Ranges, approximations, scientific notation,
locale decimal commas, and phrases with multiple numbers are not typed. No
currency inference or unit conversion occurs.
V11 and later extraction can also recover one leading exact measure
from a user-authored first-person completed action in a bounded verb lexicon,
such as I just ran 5 kilometers. It retains the source phrase as the object and
uses the same validators; modals, negations, examples, questions, conditions,
approximations and ranges do not use this recovery path.
Custom Scoring Weights
from prme.retrieval.config import ScoringWeights
# Emphasize semantic similarity and recency over graph proximity
custom_weights = ScoringWeights(
w_semantic=0.40,
w_lexical=0.10,
w_graph=0.10,
w_recency=0.20,
w_salience=0.05,
w_confidence=0.15,
)
response = await engine.retrieve(
"What happened recently?",
user_id="alice",
weights=custom_weights,
token_budget=2048, # smaller context window
)
# Check the scoring config version used
print(response.metadata.scoring_config_version)Lifecycle Management
# Store a fact (starts TENTATIVE)
eid = await engine.store("Project uses MySQL", user_id="u1", node_type=NodeType.FACT)
# Find the node
nodes = await engine.query_nodes(user_id="u1", node_type=NodeType.FACT)
old = next(n for n in nodes if "MySQL" in n.content)
# Promote to STABLE after verification
await engine.promote(str(old.id))
# Store corrected fact and supersede
await engine.store("Project uses PostgreSQL, not MySQL", user_id="u1", node_type=NodeType.FACT)
nodes = await engine.query_nodes(user_id="u1", node_type=NodeType.FACT)
new = next(n for n in nodes if "PostgreSQL" in n.content)
await engine.supersede(str(old.id), str(new.id))
# Old node is now SUPERSEDED — excluded from default retrieval
# New node is TENTATIVE — included in retrieval, can be promoted10. PostgreSQL Backend
PRME supports PostgreSQL as an alternative storage backend. When database_url is set, all four storage layers (events, graph, vector, lexical) use a single PostgreSQL instance instead of file-based DuckDB/USearch/Tantivy.
Quick Start (PostgreSQL)
import asyncio
from prme import MemoryEngine, PRMEConfig, NodeType, Scope
async def main():
config = PRMEConfig(
database_url="postgresql://user:pass@localhost:5432/myapp",
)
engine = await MemoryEngine.create(config)
try:
await engine.store(
"Alice prefers dark mode",
user_id="alice",
node_type=NodeType.PREFERENCE,
scope=Scope.PERSONAL,
)
response = await engine.retrieve("What does Alice prefer?", user_id="alice")
for r in response.results:
print(f"[{r.composite_score:.3f}] {r.node.content}")
finally:
await engine.close()
asyncio.run(main())Installation
PostgreSQL support requires optional dependencies:
pip install prme[postgres]This installs asyncpg (async PostgreSQL driver) and pgvector (vector similarity extension support).
PostgreSQL requirements:
- PostgreSQL 14+ with the
pgvectorextension installed - The
pgcryptoextension (ships with PostgreSQL by default)
Configuration
Set database_url via config or environment variable:
# Via config
config = PRMEConfig(database_url="postgresql://user:pass@host:5432/dbname")
# Via environment variable
# export PRME_DATABASE_URL=postgresql://user:pass@host:5432/dbname
config = PRMEConfig() # auto-reads PRME_DATABASE_URLWhen database_url is set, config.backend returns "postgres" and all file-path settings (db_path, vector_path, lexical_path) are ignored. The single-file pack_path setting is the exception: combining it with database_url fails validation.
Schema
MemoryEngine.create() automatically initializes the PostgreSQL schema on first use (all DDL is idempotent with IF NOT EXISTS). Tables created:
| Table | Purpose |
|---|---|
events | Append-only event log (mirrors DuckDB events table) |
nodes | Memory graph nodes with embedding vector(N) column and content_tsv tsvector GENERATED column |
edges | Graph edges with foreign key constraints and ON DELETE CASCADE |
operations | Operation log for retrieval/ingestion tracking |
lexical_documents | Non-node content indexed for full-text search (events, summaries) |
Indexes:
- HNSW index on
nodes.embeddingusingvector_cosine_ops(pgvector) - GIN indexes on
nodes.content_tsvandlexical_documents.content_tsv - B-tree indexes on
user_id,node_type,scope,lifecycle_state, timestamps
How It Differs from DuckDB Mode
| Aspect | DuckDB mode | PostgreSQL mode |
|---|---|---|
| Concurrency | Single-writer via WriteQueue | Multi-writer via connection pool (NoOpWriteQueue passthrough) |
| Vector search | USearch HNSW + separate metadata table | pgvector HNSW on nodes.embedding column directly |
| Lexical search | Tantivy (BM25) | tsvector / tsquery with ts_rank_cd() scoring |
| Text indexing | Explicit index() call for all content | GENERATED ALWAYS AS column auto-maintains tsvector for nodes; explicit for non-node content |
| Graph traversal | DuckDB recursive CTEs | PostgreSQL recursive CTEs with native CYCLE detection |
| Persistence | Manual save() calls for vector index | Automatic (PostgreSQL WAL) |
| Portability | Memory pack (copyable files) | Single database (use pg_dump for export) |
| Async model | asyncio.to_thread() wrappers around sync DuckDB | Natively async end-to-end (asyncpg) |
API Compatibility
The MemoryEngine API is identical in both modes. All methods — store(), ingest(), retrieve(), promote(), supersede(), archive(), close(), etc. — work the same way. No code changes needed when switching backends.
SST / Cloud Integration
For SST (or similar IaC) apps that provision a PostgreSQL database:
import os
from prme import MemoryEngine, PRMEConfig
# SST injects the database URL at runtime
config = PRMEConfig(
database_url=os.environ["DATABASE_URL"],
)
engine = await MemoryEngine.create(config)Connection Pool
PostgreSQL mode uses an asyncpg connection pool (default: min_size=2, max_size=10). The pool is created by MemoryEngine.create() and closed by engine.close().
Testing Against PostgreSQL
PostgreSQL tests require the PRME_TEST_DATABASE_URL environment variable. The repository’s docker-compose.yml starts a loopback-only test database for this; see CONTRIBUTING.md :
export PRME_TEST_DATABASE_URL=postgresql://prme_test:prme_test@127.0.0.1:5432/prme_test
pytest tests/test_pg_*.py -vTests are automatically skipped when PRME_TEST_DATABASE_URL is not set. All existing DuckDB tests continue to pass without any PostgreSQL instance.
11. RFC Summary Table
| RFC | Title | Tier | One-line Summary |
|---|---|---|---|
| RFC-0000 | Suite Overview and Design Philosophy | 0 | Design principles, tier system, [HYPOTHESIS] tagging convention |
| RFC-0001 | Core Data Model and Terminology | 0 | Node types, edge types, memory objects, temporal validity, evidence refs |
| RFC-0002 | Event Store and Append-Only Log | 1 | Immutable event log (DuckDB), content hashing, replay capability |
| RFC-0003 | Epistemic State Model | 1 | Epistemic types, confidence matrix, source provenance, filtering rules |
| RFC-0004 | Namespace and Scope Isolation | 1 | Six scope levels, cross-scope hints, sandbox hard-delete |
| RFC-0005 | Hybrid Retrieval Pipeline | 2 | 6-stage pipeline, 8-input composite scoring, deterministic ranking |
| RFC-0006 | Retrieval Cost and Context Efficiency | 2 | Token budgeting, representation levels, greedy bin-packing |
| RFC-0007 | Decay and Forgetting Model | 3 | Salience decay, TTL enforcement, policy-based archival |
| RFC-0008 | Confidence Evolution and Reinforcement | 3 | Confidence updates from retrieval feedback and corroboration |
| RFC-0009 | Memory Usage Feedback Loop | 3 | Track memory usage in retrieval to influence future ranking |
| RFC-0010 | Temporal Pattern Awareness | 4 | Detect recurring patterns and temporal clusters in memory |
| RFC-0011 | Multi-Agent Memory Semantics | 4 | Domain-scoped agent trust, shared memory coordination |
| RFC-0012 | Memory Branching and Simulation | 4 | Branch memory state for hypothetical exploration, merge back |
| RFC-0013 | Intent and Goal Memory | 4 | Track user goals and active intents (replaced emotional tracking) |
| RFC-0014 | Portability, Sync, and Federation | 4 | Memory pack export, sync protocol, multi-device federation |
Conformance Tiers
| Tier | Required RFCs | Description |
|---|---|---|
| Tier 0 | 0000, 0001 | Core data model only |
| Tier 1 | + 0002, 0003, 0004 | Persistent, epistemic, namespace-isolated storage |
| Tier 2 | + 0005, 0006 | Hybrid retrieval with context-efficient bundling |
| Tier 3 | + 0007, 0008, 0009 | Adaptive lifecycle: decay, confidence, feedback |
| Tier 4 | + any of 0010-0014 | Advanced capabilities (each independently optional) |
PRME’s current implementation covers Tiers 0-2 fully, with partial Tier 1 epistemic support (confidence matrix, epistemic filtering, supersedence detection).
Experimental retrieval composition policies
Two explicit policies are available for controlled trials through PRMEConfig:
reranker_policy="anchored_score_envelope" with enable_reranker=True, and
query_reformulation_merge_policy="max_signals" with
enable_query_reformulation=True. The unanchored "score_envelope" reranker is
also available, and reranker_prior_weight=0.0 with an envelope policy orders
the reranked prefix by the cross-encoder alone while it keeps its fused scores
(a weight other than 0.3 is refused with "legacy"). Default flags, the existing "legacy" / "new_only"
policies and the 0.3 prior weight are unchanged. Both local and PostgreSQL
engines use these settings.
See experimental retrieval policies for failure semantics, receipt versions, quality evidence and configuration examples. These options have not passed an untouched answer confirmation and are not recommended production defaults. Product alignment/Jev remains a separate explicit pair-selection and review workflow.
Source: docs/INTEGRATION.md in PRME at 58caddb1.