Query Patterns
This guide covers the two read operations in mempill: query_memory (current belief and
time travel) and query_audit (ledger forensics). For the write path see
Quickstart (Python) and the language guides.
query_memory
Section titled “query_memory”query_memory computes the canonical belief for a (subject, predicate) pair from
the full claim history at read time. The fold is deterministic (I8) — given the same
claim history, it always returns the same result.
Pattern 1 — current belief
Section titled “Pattern 1 — current belief”The most common query: what does the engine believe right now?
result = engine.query_memory({ "agent_id": "my-agent", "subject": "user", "predicate": "city", # as_of_tx_time omitted = now})
belief = result["belief"]print(belief["status"]) # "Resolved" | "TimingUncertain" | "Contested" | "NoBelief"use mempill_core::application::QueryMemoryRequest;
let result = engine.query_memory(QueryMemoryRequest { agent_id: AgentId("my-agent".into()), subject: "user".into(), predicate: "city".into(), as_of_tx_time: None, valid_at: None,}).await?;
println!("status={:?}", result.belief.status);Pattern 2 — point-in-time (time travel)
Section titled “Pattern 2 — point-in-time (time travel)”Read the engine’s belief as it was at a past transaction time. Useful for auditing, debugging “what did the agent think yesterday?”, or reproducible report generation:
result = engine.query_memory({ "agent_id": "my-agent", "subject": "acme:ceo", "predicate": "held_by", "as_of_tx_time": "2024-01-01T00:00:00Z", # ISO-8601 UTC})use chrono::{Duration, Utc};
let one_week_ago = Utc::now() - Duration::weeks(1);let result = engine.query_memory(QueryMemoryRequest { agent_id: agent.clone(), subject: "acme:ceo".into(), predicate: "held_by".into(), as_of_tx_time: Some(one_week_ago), valid_at: None,}).await?;as_of_tx_time rewinds the transaction-time axis — “what did the engine know as of this
timestamp?” To independently select by the real-world validity window (“what was true at
this point in time?”), use the separate valid_at parameter, available as of mempill
0.3.0. The two parameters can be supplied together for a full bi-temporal point-in-time
query — see Point-in-time valid-time query.
Pattern 3 — everything known about a subject (query_subject)
Section titled “Pattern 3 — everything known about a subject (query_subject)”query_memory requires knowing the predicate upfront. When the goal is instead “fetch
everything known about this subject” without an existing predicate list, use
query_subject — it enumerates every predicate that has ever been asserted for the
subject and returns the resolved belief for each, in one call:
result = engine.query_subject({ "agent_id": "my-agent", "subject": "acme:ceo", # Optional, same as query_memory: # "valid_at": "2021-06-01T00:00:00Z", # "as_of_tx_time": "2024-01-01T00:00:00Z",})
for entry in result: print(entry["predicate"], entry["value"], entry["status"])use mempill_core::application::QuerySubjectRequest;
let result = engine.query_subject(QuerySubjectRequest { agent_id: AgentId("my-agent".into()), subject: "acme:ceo".into(), valid_at: None, as_of_tx_time: None,}).await?;
for entry in &result.entries { println!("{} {:?} {}", entry.predicate, entry.value, entry.status);}Entries are sorted by predicate (lexicographic) for stable, deterministic output. Each
entry mirrors a query_memory primary shape: predicate, value, status,
valid_from_display, valid_until_display, provenance, claim_ref, conf. A
Contested predicate has value: None in its entry — the caller should surface that the
same way it would surface a Contested result from query_memory.
Pattern 4 — extracting the belief value
Section titled “Pattern 4 — extracting the belief value”The belief is a nested structure. The deep path is:
belief → primary → fact → value
belief = result["belief"]status = belief["status"]
if status in ("Resolved", "TimingUncertain"): primary = belief["primary"] value = primary["fact"]["value"] # the authoritative value conf = primary["confidence"]["value_confidence"] vt = primary.get("valid_time") or {} vt_start = vt.get("start") # ISO-8601 string or None vt_end = vt.get("end") or "open" claim_ref = primary["claim_ref"] # UUID string of the winning claim print(f"{value!r} conf={conf} [{vt_start} .. {vt_end}]")
elif status == "Contested": # No primary — all candidates are in alternatives. for alt in belief.get("alternatives") or []: print(alt["fact"]["value"])
elif status == "NoBelief": print("No claim found for this (subject, predicate)")Belief status reference
Section titled “Belief status reference”| Status | primary |
alternatives |
Meaning |
|---|---|---|---|
"Resolved" |
present | empty | Single winner after valid-time fold |
"TimingUncertain" |
present | empty | Only one claim; no valid_time supplied — ordered by tx-time |
"Contested" |
None |
2+ entries | Multiple claims; engine cannot pick a winner |
"NoBelief" |
None |
empty | No claim exists for this (subject, predicate) |
Pattern 5 — handling Contested beliefs
Section titled “Pattern 5 — handling Contested beliefs”When status == "Contested", primary is None. Both (all) values are preserved in
alternatives. Do not pick one silently — surface the uncertainty to the user or trigger
oracle resolution.
if belief["status"] == "Contested": alts = [a["fact"]["value"] for a in belief.get("alternatives") or []] # Option A: surface to user print(f"Uncertain: {alts}") # Option B: trigger reconciliation engine.reconcile({ "agent_id": "my-agent", "subject_lines": [("user", "city")], })After reconcile, query_memory again. If an oracle is wired and resolved the conflict,
status will be "Resolved". See Writing an Oracle.
Pattern 6 — valid-time queries
Section titled “Pattern 6 — valid-time queries”When you ingested claims with explicit valid_time, the fold selects the claim whose
window contains the query instant. Use this for temporal succession queries:
# "Who is CEO today?"q_now = engine.query_memory({"agent_id": "a", "subject": "acme:ceo", "predicate": "held_by"})print(q_now["belief"]["primary"]["fact"]["value"]) # Bob (current)
# "Who was CEO in February 2022?"q_past = engine.query_memory({ "agent_id": "a", "subject": "acme:ceo", "predicate": "held_by", "valid_at": "2022-02-01T00:00:00Z",})print(q_past["belief"]["primary"]["fact"]["value"]) # Alice (past)See Concepts: Valid-Time Succession.
Updating a fact — closing open-ended claims
Section titled “Updating a fact — closing open-ended claims”When you record an open-ended fact (no end date) and later learn when it ended, use end_fact to explicitly close it and enable succession:
❌ Anti-pattern: writing a bounded copy
Section titled “❌ Anti-pattern: writing a bounded copy”# DON'T DO THIS:# Original claim (open-ended):r1 = engine.ingest_claim({ "agent_id": "my-agent", "subject": "acme:ceo", "predicate": "held_by", "value": "Alice", "valid_time": { "start": "2020-01-01T00:00:00Z", # no "end" — open-ended ... }, ...})
# WRONG: writing a bounded copy of the same valuer_copy = engine.ingest_claim({ "agent_id": "my-agent", "subject": "acme:ceo", "predicate": "held_by", "value": "Alice", # same value as r1! "valid_time": { "start": "2020-01-01T00:00:00Z", "end": "2023-03-14T23:59:59Z", # now with an end ... }, ...})
# Result: TWO overlapping claims for Alice with the same value.# This creates a line conflict (Contested) until resolved by oracle.# Note (0.4.0): A retroactive write into an ended window is also contested —# the engine checks against both live and explicitly ended claims.✅ Correct pattern: use end_fact
Section titled “✅ Correct pattern: use end_fact”end_fact is a module-level function (from mempill import end_fact), not a method on engine:
from mempill import end_fact
# Step 1: Record open-endedr1 = engine.ingest_claim({ "agent_id": "my-agent", "subject": "acme:ceo", "predicate": "held_by", "value": "Alice", "valid_time": { "start": "2020-01-01T00:00:00Z", # no "end" — open-ended ... }, ...})
# Step 2: Later, explicitly end itend_fact( engine, agent_id="my-agent", subject="acme:ceo", predicate="held_by", at="2023-03-14T23:59:59Z", provenance=mempill.ProvenanceLabel.external_first_hand(),)
# Step 3: Ingest successor (non-overlapping)r2 = engine.ingest_claim({ "agent_id": "my-agent", "subject": "acme:ceo", "predicate": "held_by", "value": "Bob", "valid_time": { "start": "2023-03-15T00:00:00Z", # no "end" — open-ended ... }, ...})
# Result: Clean succession. No conflict. History shows:# Alice [2020, 2023-03-14] — status: Superseded# Bob [2023-03-15, ∞) — status: CurrentWhy this works:
end_factis not a write to the original claim; it’s a validity assertion — a ledger entry applied at read time.- Once bounded via
end_fact, the incumbent is no longer live, so it reportsSuperseded— the same status as a claim closed by a non-overlapping successor.Endedis reserved for a claim whose own window expires naturally with no explicit bound and no live successor. - The original claim’s storage is unchanged; a new bounded window is computed on the fly.
- When Bob’s claim arrives non-overlapping, it folds as
Succession(no oracle needed). - The line stays clean: never two claims with the same value on the same line.
history() — full claim timeline
Section titled “history() — full claim timeline”history() returns every claim ever ingested for a (subject, predicate) subject-line,
ordered oldest→newest. Use it to inspect how a belief evolved over time — who held the role
before, when each value was valid, and which entry is current.
Basic usage
Section titled “Basic usage”from mempill import open_in_memory, remember, history, recall, RememberOptions
engine = open_in_memory()agent = "my-agent"
# Ingest a succession: Alice → John → Bob (non-overlapping windows)remember(engine, agent, "acme:ceo", "held_by", "Alice", RememberOptions(valid_from="2018-01-01", valid_until="2021-01-01"))remember(engine, agent, "acme:ceo", "held_by", "John", RememberOptions(valid_from="2021-01-01", valid_until="2024-01-01"))remember(engine, agent, "acme:ceo", "held_by", "Bob", RememberOptions(valid_from="2024-01-01"))
# Retrieve the full timelineh = history(engine, agent, "acme:ceo", "held_by")
for e in h: print(e.value, e.status, e.valid_from, "→", e.valid_until or "open")# Alice Superseded 2018-01-01T00:00:00Z → 2021-01-01T00:00:00Z# John Superseded 2021-01-01T00:00:00Z → 2024-01-01T00:00:00Z# Bob Current 2024-01-01T00:00:00Z → open
# For honest, pre-rendered display strings (no fabricated precision), use# .valid_from_display / .valid_until_display instead of the raw RFC3339 fields:for e in h: print(e.value, e.status, e.valid_from_display, "→", e.valid_until_display or "open")# Alice Superseded 2018-01-01 → 2021-01-01# John Superseded 2021-01-01 → 2024-01-01# Bob Current 2024-01-01 → openuse mempill::{open_default_in_memory, remember, history, recall, RememberOptions};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let engine = open_default_in_memory()?; let agent = "my-agent";
// Ingest a succession: Alice → John → Bob (non-overlapping windows) remember(&engine, agent, "acme:ceo", "held_by", "Alice", RememberOptions::new().valid_from("2018-01-01").valid_until("2021-01-01")).await?; remember(&engine, agent, "acme:ceo", "held_by", "John", RememberOptions::new().valid_from("2021-01-01").valid_until("2024-01-01")).await?; remember(&engine, agent, "acme:ceo", "held_by", "Bob", RememberOptions::new().valid_from("2024-01-01")).await?;
// Retrieve the full timeline let h = history(&engine, agent, "acme:ceo", "held_by").await?;
for e in &h.entries { println!("{:?} {:?} {:?} → {:?}", e.value.as_str(), e.status, e.valid_from, e.valid_until); } // Some("Alice") Superseded Some(2018-01-01T00:00:00Z) → Some(2021-01-01T00:00:00Z) // Some("John") Superseded Some(2021-01-01T00:00:00Z) → Some(2024-01-01T00:00:00Z) // Some("Bob") Current Some(2024-01-01T00:00:00Z) → None (open)
// For honest, pre-rendered display strings, use format_valid_time_endpoint with // the entry's own granularity fields (valid_until_granularity is DERIVED — see // HistoryEntry fields below): use mempill_types::time::format_valid_time_endpoint; for e in &h.entries { let from_display = format_valid_time_endpoint(e.valid_from, e.valid_from_granularity); let until_display = format_valid_time_endpoint(e.valid_until, e.valid_until_granularity); println!("{:?} {:?} {:?} → {:?}", e.value.as_str(), e.status, from_display, until_display); } // Some("Alice") Superseded Some("2018-01-01") → Some("2021-01-01") // Some("John") Superseded Some("2021-01-01") → Some("2024-01-01") // Some("Bob") Current Some("2024-01-01") → None (open)
Ok(())}history().current() agrees with recall()
Section titled “history().current() agrees with recall()”The current() helper returns the single Current entry when one exists, and is guaranteed
to agree with recall() — both use the same canonical fold. current() returns None when
the subject-line is empty, all claims have been superseded, the line is Contested (see
HistoryEntryStatus::Contested), or the only
live claim’s own window has closed without a live successor (see HistoryEntryStatus::Ended).
h = history(engine, agent, "acme:ceo", "held_by")
current_entry = h.current() # HistoryEntry or Nonerecall_result = recall(engine, agent, "acme:ceo", "held_by")
if current_entry is not None: # When Current exists: both agree assert current_entry.value == recall_result.as_str() # "Bob" == "Bob"else: # When None (contested, ended, or empty): recall also returns None assert recall_result is Nonelet h = history(&engine, agent, "acme:ceo", "held_by").await?;let r = recall(&engine, agent, "acme:ceo", "held_by").await?;
// When Current exists: both agree// When None (contested, ended, or empty): both return Noneassert_eq!( h.current().and_then(|e| e.value.as_str()), r.as_str(),);HistoryEntry fields
Section titled “HistoryEntry fields”| Field | Type | Description |
|---|---|---|
value |
any | The asserted value for this claim |
status |
"Current" | "Superseded" | "Contested" | "Ended" |
Entry status; see status vocabulary below. Consumers should handle unknown values gracefully. |
valid_from |
string | None | RFC 3339 start of the valid-time window, or None |
valid_until |
string | None | Effective end of the slot, or None (open-ended) |
valid_from_granularity (0.4.0) |
"year" | "month" | "day" | "instant" | None |
Precision of valid_from, taken verbatim from this claim |
valid_until_granularity (0.4.0) |
"year" | "month" | "day" | "instant" | None |
Precision of valid_until — from whichever endpoint supplied it: the claim’s own end_granularity if using its explicit end, the successor’s start_granularity if narrowed by non-overlapping succession, or the bounding instant’s granularity if ended by an adjudication or end_fact (the winning challenger’s start_granularity for adjudication, or the date precision as given for end_fact); None when open-ended, when the successor’s ordering key fell back to transaction-time, for a Deny adjudication bound, or when the claim has no explicit end and no successor |
valid_from_display (0.4.0, Python only) |
string | None | Pre-rendered valid_from at its recorded precision |
valid_until_display (0.4.0, Python only) |
string | None | Pre-rendered valid_until at its (derived) recorded precision |
provenance |
string | Human-readable label, e.g. "External/UserAsserted" |
value_confidence |
float | Confidence in this claim’s value (0.0–1.0) |
claim_ref |
string (UUID) | Identifies the underlying claim |
HistoryStatus vocabulary
Section titled “HistoryStatus vocabulary”Current— live (in the fold’s live set), unconflicted (no fold-wide conflict flag), and its valid-time window contains the query instant.Superseded— not live; closed by a non-overlapping successor claim.Contested— live and part of an unresolved conflict: either the fold-wide conflict flag, or a pairwise valid-time overlap detected against an adjacent live entry in the timeline (never silently narrowed or picked).Ended— still live per the ledger (never explicitly bounded), but its own valid-time window has naturally expired with no live successor covering the query instant. Distinguishable fromSupersededbecause the ledger contains no explicit successor.
The HistoryEntryStatus enum is non-exhaustive — consumers should handle unknown values gracefully.
history() — honest date precision (0.4.0)
Section titled “history() — honest date precision (0.4.0)”Since 0.4.0, every HistoryEntry also carries its recorded date-precision, rendered
identically to query_memory / query_subject — no fabricated precision is ever inserted.
valid_until_granularity is derived from whichever endpoint supplies valid_until:
- If using the claim’s own explicit
valid_time.end: the claim’s ownend_granularity. - If narrowed by a non-overlapping successor: the successor’s
start_granularity. - Otherwise (open-ended or no successor):
None.
See Changelog: Date granularity on history() for the full rule and the Python/Rust code samples above.
Note: history() has no valid_at parameter
Section titled “Note: history() has no valid_at parameter”history() always returns the full timeline as it exists now — it has no valid_at
parameter. For point-in-time valid-time queries (“who was CEO as of 2022-06-01 according
to what we knew then?”), use query_memory with both valid_at and as_of_tx_time,
available as of mempill 0.3.0: valid_at independently selects by the real-world validity
window, and as_of_tx_time independently rewinds the transaction-time axis.
query_audit
Section titled “query_audit”The audit ledger is an append-only record of every disposition event. Use query_audit
for provenance forensics, compliance, and debugging.
AuditQueryRequest shape
Section titled “AuditQueryRequest shape”audit = engine.query_audit({ "agent_id": "my-agent", # required "claim_ref": None, # str | None — filter to a specific claim UUID "from_tx_time": None, # str | None — ISO-8601 UTC lower bound "limit": 100, # int — max entries to return})use mempill_core::application::AuditQueryRequest;
let audit = engine.query_audit(AuditQueryRequest { agent_id: AgentId("my-agent".into()), claim_ref: None, // or Some(claim_ref) to filter from_tx_time: None, limit: 100,}).await?;Pattern — full agent history
Section titled “Pattern — full agent history”audit = engine.query_audit({ "agent_id": "my-agent", "claim_ref": None, "from_tx_time": None, "limit": 500,})
for entry in audit["entries"]: print(entry["event_kind"], entry["recorded_at"], entry["claim_ref"][:8])Pattern — history for a specific claim
Section titled “Pattern — history for a specific claim”# First get the claim_ref from an ingest or query response:resp = engine.ingest_claim({...})claim_ref = resp["claim_ref"]
audit = engine.query_audit({ "agent_id": "my-agent", "claim_ref": claim_ref, "from_tx_time": None, "limit": 50,})Pattern — recent events (time-bounded)
Section titled “Pattern — recent events (time-bounded)”from datetime import datetime, timezone, timedelta
one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
audit = engine.query_audit({ "agent_id": "my-agent", "claim_ref": None, "from_tx_time": one_hour_ago, "limit": 200,})from_tx_time is an inclusive lower bound, so a paged walk (using each page’s last
recorded_at as the next from_tx_time) must de-duplicate by entry_id, and pages should
be large enough to hold all entries sharing one timestamp.
LedgerEventKind values
Section titled “LedgerEventKind values”| Event | Meaning |
|---|---|
ClaimCommitted |
Claim written and committed |
ValidityAsserted |
Valid-time bounds recorded or updated |
AdjudicationRequested |
Oracle was called; pending row created |
AdjudicationResolved |
submit_adjudication resolved the conflict |
RecallReEntryDetected |
Amplification Guard caught a RecallReEntry echo |
Quarantined |
Claim quarantined (provenance or amplification guard) |
DependentFlaggedPendingReview |
Claim whose source was invalidated |
AdjudicationExpired |
Pending adjudication TTL expired; returned to Contested |
Understanding the belief shape from audit
Section titled “Understanding the belief shape from audit”Audit entries carry claim_ref but not subject, predicate, or value — the
ledger is a disposition event log, not a content store. Correlate back to ingestion
records in your application using the claim_ref returned from ingest_claim.
The mempill-demo adapter (memory_mempill.py) keeps a session-local registry dict
mapping claim_ref → ClaimMeta for exactly this purpose.
How do I update a fact that’s already been recorded?
Section titled “How do I update a fact that’s already been recorded?”Use end_fact(engine, agent_id, subject, predicate, at, ...) (a module-level function, from mempill import end_fact) to explicitly close the incumbent open-ended claim, then ingest a new claim starting after that instant. See Updating a fact — closing open-ended claims above.
What if I don’t know the exact end date of an open-ended fact?
Section titled “What if I don’t know the exact end date of an open-ended fact?”- Option 1: Set
valid_time_confidencelower (< 0.7) on the successor; it will beContesteduntil resolved by oracle. - Option 2: Use
end_factwith your best-guess date; if you later learn a different date, callengine.assert_validity({"target": claim_ref, "assertion": {"type": "Reopen"}, ...})directly to undo the bound (end_factitself has no reopen parameter — it only binds), then callend_factagain with the correct date. - Option 3: Route via oracle — escalate the conflict and let the oracle decide.
Can I change a fact’s value?
Section titled “Can I change a fact’s value?”No — mempill facts are immutable once written. To represent a belief change, ingest a new claim with a non-overlapping valid_time window (temporal succession) or let the new claim conflict and resolve via oracle. Re-writing the same claim with a different value creates two claims on the line, which is Contested until resolved. Note (0.4.0): a claim written retroactively into a window that was explicitly ended (via end_fact or affirmed adjudication) is now contested against the ended incumbent instead of being committed silently — the engine checks all live claims AND explicitly ended claims whose narrowed window overlaps the new claim’s window.
What if I have multiple agents writing to the same subject-line?
Section titled “What if I have multiple agents writing to the same subject-line?”Each agent has a separate write lock: per-agent SQLite files (agent_<id>.db) or per-agent Postgres advisory locks. Agent isolation is guaranteed by the engine’s single-writer protocol. If you need cross-agent shared beliefs, use a shared Postgres database and call query_memory from any agent to see the union of all beliefs.
Can I query as-of a past point in time?
Section titled “Can I query as-of a past point in time?”Yes — use the as_of_tx_time parameter to rewind the transaction-time axis (when the engine recorded the claim) and valid_at to filter by the real-world validity window. Both are independent:
# "What did the engine believe on 2023-01-01 (tx-time) about the CEO in 2021-06-01 (valid-time)?"result = engine.query_memory({ "agent_id": "my-agent", "subject": "acme:ceo", "predicate": "held_by", "as_of_tx_time": "2023-01-01T00:00:00Z", # what the engine knew on this date "valid_at": "2021-06-01T00:00:00Z", # filtered to this real-world date})What does “Ended” status mean in history?
Section titled “What does “Ended” status mean in history?”Ended means the claim’s own valid-time window expired with no live successor covering the query instant. It differs from Superseded (which means an explicit successor closed the line). Both are closed, but Ended indicates natural expiration while Superseded indicates a deliberate succession.
Related guides
Section titled “Related guides”- Python (Advanced) — full dict shapes, TypedDicts, async
- Rust (Advanced) — typed request structs, valid_time, cardinality
- Writing an Oracle — resolving Contested beliefs
- Concepts: Contested and Dispositions — 12-state model
- Concepts: Temporal Validity Problem — why valid_time matters
- Concepts: Valid-Time Succession — temporal succession mechanics