Memory backend¶
Built and verified
MemoryBackend (in coniti.memory, also exported from coniti) and
InMemoryBackend (in coniti.impl.memory) are built and pass the conformance suite, including the
portable memory file (dump / load). They are the Container’s default
memory piece. The ContinuityEngine that uses memory is built (see engine);
“What the engine does with it” describes its behaviour.
Every read and write of memory goes through one abstract interface,
MemoryBackend. The built-in implementation is InMemoryBackend, which lives in
process memory and is gone when the process ends. Later it can be a database or
a separate service, swapped in without touching the engine or any plug-in.
This follows the split LangGraph makes between its checkpointer interface and an in-memory saver meant for local work and testing. See research. coniti stores notes and vectors rather than graph checkpoints, so its operations are record-oriented, but the shape is the same: one ABC, request and result contracts, sync plus async, and an in-memory implementation.
The record¶
A backend stores MemoryRecord(note, vector, embedding_id=None, namespace=()).
record.id is the note’s id. The Note is described in layers. A
Namespace is a tuple of non-empty strings, () by default. It is addressing
(for example per user), not a session.
Operations¶
Each operation takes a request contract and returns a result contract.
Implementations may extend those models but never replace them. A wrong request
type raises ContractError.
| Operation | Request → result | Purpose |
|---|---|---|
put |
PutRequest(records) → PutResult(written) |
Write records |
get |
GetRequest(ids, namespace) → GetResult(records, missing) |
Read records by id |
delete |
DeleteRequest(ids, namespace) → DeleteResult(deleted, missing) |
Remove records by id |
scan |
ScanRequest(time_range, namespace, limit, cursor) → ScanResult(records, next_cursor) |
Read in timestamp order, optionally in a time range, paginated |
count |
CountRequest(time_range, namespace) → CountResult(count) |
Count records |
get_meta / set_meta |
MetaGetRequest(key, namespace) / MetaSetRequest(key, value, namespace) → MetaResult(value) |
Small key-value metadata (values are JSON-serialisable, finite, copied) |
clear |
ClearRequest(namespace) → ClearResult(deleted) |
Remove everything in a namespace |
dump / load |
file or stream, namespace → record count | Write or read the portable memory file, see below |
iter_all(time_range=None, namespace=(), page_size=500) is a helper that follows
cursors page by page.
Async counterparts (aput, aget, adelete, ascan, acount, aget_meta,
aset_meta, aclear) run the sync call in a worker thread by default; a backend
may implement them natively.
Errors all derive from MemoryBackendError (a RuntimeError): DuplicateRecord,
InvalidCursor and MemoryFileError (for the memory file).
Example¶
import numpy as np
from coniti import Note, TimeRange
from coniti.impl.memory import InMemoryBackend
from coniti.memory import MemoryRecord, PutRequest, ScanRequest
def rec(text: str, t: float) -> MemoryRecord:
return MemoryRecord(
note=Note.create(text, timestamp=t),
vector=np.array([1.0, 0.0, 0.0]),
)
memory = InMemoryBackend()
memory.put(PutRequest(records=[rec("first", 10.0), rec("second", 20.0), rec("third", 30.0)]))
# Half-open range [10, 30): "first" and "second".
window = TimeRange(start=10.0, end=30.0)
page = memory.scan(ScanRequest(time_range=window, limit=1))
print([r.note.text for r in page.records]) # ['first']
# Resume exactly where the page stopped.
page = memory.scan(ScanRequest(time_range=window, limit=1, cursor=page.next_cursor))
print([r.note.text for r in page.records]) # ['second']
print(page.next_cursor) # None: the end of the range
Rules every backend follows¶
These are checked by the conformance suite (see memory-backends).
putis all-or-nothing per call. An existing id, or an id repeated in the call, raisesDuplicateRecordand nothing is written. The same id in different namespaces is allowed.getanddeletereport unknown ids inmissingand never raise for them. Repeated ids count once, in first-occurrence order.getkeeps request order.scanorders by timestamp then write order, uses half-open time ranges, and pages exactly at every page size. A cursor resumes correctly even if records were deleted or inserted between pages.- Cursors are opaque. A malformed cursor, the empty string, a cursor from another backend instance, or a cursor from another namespace raises
InvalidCursor. - Namespaces are isolated for scan, get, delete, count, meta and clear.
- Records are copied in and out: mutating what you passed or received never changes memory.
- Async results match sync results.
InMemoryBackend¶
Kind in_memory. Per namespace it keeps records in a list sorted by
(timestamp, write order) and finds ranges by binary search. Cursors are opaque
and tied to the instance and the namespace. Nothing is persisted.
What the engine does with it¶
- The engine keeps no copy of notes or vectors. Structures are rebuilt from
scan. - Records carry the id of the embedding spec that produced them. The engine refuses to open a backend whose stored embedding id differs from its own.
snapshot()is produced fromscanand loaded throughput, so a snapshot moves memory between any two backends.
Serialisation and the memory file¶
Built and verified
Requirement 15, task 4D. dump and load are concrete methods on
MemoryBackend, written only with the backend’s public operations, so every
backend inherits them and a file written by one backend loads into any other.
The conformance suite checks them for every new backend.
The goal is that external applications and libraries can integrate with little
thought, and that a memory can be backed up, recovered and moved between
machines or backends using standard formats. Every contract model and spec also
has to_dict, from_dict, to_json, from_json and json_schema, see
contracts. For a how-to, see backup-restore.
dump and load¶
n = memory.dump("backup.jsonl") # returns the number of records written
n = memory.dump("backup.jsonl.gz") # gzip, chosen by the name
n = memory.dump("alice.jsonl", namespace=("alice",))
n = other.load("backup.jsonl.gz") # returns the number of records loaded
n = other.load("backup.jsonl", namespace=("bob",)) # re-home every record into ("bob",)
dump(target, namespace=())writes one namespace.targetis a path (strorPath) or an open text stream.load(source, namespace=None)reads a path or a text stream.namespace=Nonekeeps each record’s own namespace; a namespace re-homes every record into it.- The path form is the one that picks gzip, from the name ending in
.gz. With a stream you pass already-decoded text, so compress it yourself. dumpstreams the records page by page.loadreads and validates the whole file before it writes anything, so it holds the records in memory while it does.
The format¶
JSON Lines, UTF-8: one JSON object per line, \n line endings.
{"format": "coniti-memory", "version": "1.0.0", "embedding_id": "emb-74006f08273e", "namespace": [], "created_at": "2026-10-30T09:24:42Z", "count": 3}
{"note": {"id": "n1", "text": "...", "timestamp": 100.0, "metadata": {}}, "vector": [0.447, 0.0, ...], "embedding_id": "emb-74006f08273e", "namespace": []}
...
| Header field | Meaning |
|---|---|
format |
Always "coniti-memory". |
version |
The format version, semantic versioning (currently 1.0.0). A reader accepts files of the same major version. |
embedding_id |
The embedding spec id the stored vectors belong to, or null. Taken from the embedding_id entry in the backend’s metadata for the dumped namespace. |
namespace |
The namespace that was dumped (a list of strings). |
created_at |
When the file was written, RFC 3339 with a Z offset. |
count |
The number of record lines that follow. |
Each following line is one MemoryRecord as to_dict() gives it: note,
vector, embedding_id and namespace. Timestamps stay numeric so they
round-trip exactly; vectors are written as full-precision floats and load back
identical.
Where the header’s embedding_id comes from
dump reads the backend’s embedding_id metadata; it does not infer it from
the records. If nothing has set it, the header says null and load skips the
embedding check. The engine sets it on the first write; if you fill a backend by
hand and want the check, set it once:
memory.set_meta(memory.meta_set_request(key="embedding_id", value=spec_id))
with the spec id from Container.resolved()["embedding"].spec_id.
Guarantees¶
dump never leaves a half-written file. For a path, it writes
<name>.partial beside the target and renames it over the target only when
every record was written. On any failure the partial file is removed and the
previous file (if any) is untouched. If memory changes while dumping, so that the
number of records written differs from the header’s count, dump fails with
MemoryFileError instead of writing a file that disagrees with itself. Dumping
to a stream cannot be undone: write to a path if you need the guarantee.
load validates everything first and writes nothing on error. It reads the
whole file and checks, in this order of concern:
- the format name and a semantic version with the supported major version;
- the header fields,
count,namespace,embedding_idandcreated_at; - no blank lines, and every record valid (as if constructed, so no NaN or infinity);
- no duplicate id within a namespace in the file;
- the record count equals the header’s
count(this catches truncated or edited files); - the embedding id: if both the file and the target have one and they differ, it is refused;
- no record id already exists in the target’s namespace.
Any failure raises MemoryFileError with the line number where there is one, and
the target is unchanged. Corrupt gzip and I/O errors are also MemoryFileError.
Failure while writing. After validation, load writes in batches of 500. If a
write fails midway it deletes what it wrote and raises MemoryFileError (“load
failed and was rolled back”). The rollback is best-effort: it uses the
backend’s own delete, so a backend that is failing may not be able to undo it.
The embedding id travels with the memory. If the file has an embedding_id
and the target has none, load sets it in the target (in the header’s namespace,
or the one you re-homed into), so later loads and the engine see it.
try:
other.load("backup.jsonl") # records n1.. already exist
except MemoryFileError as exc:
print(exc) # record id 'n1' already exists in namespace (); nothing was loaded
Known limit
The file stores the base vector of each record. Extra fields of an extended
embedder output are not part of a memory record, so they are not in the file
(see embedding). There are no async dump / load yet.