Skip to content

Phase 1: the storage contract, offline - #5

Merged
rohan-hotdata merged 12 commits into
mainfrom
phase-1-storage-contract
Oct 6, 2026
Merged

rohan-hotdata merged 12 commits into
mainfrom
phase-1-storage-contract

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Collaborator

This pull request adds the storage contract as typed Python, with MemoryStore as its first driver. It also adds the conformance suite, the frozen-surface tests, the ledger test, and CI. Nothing in this phase uses the network. Closes #4.

How

  • src/hotmemory/record.py: Record, a frozen dataclass for schema version 1. It derives id and refuses values that the contract does not allow. normalize() gives the form that deduplication compares.
  • src/hotmemory/store.py: Store and Writer as typing.Protocol, and Hit (a record with its distance).
  • src/hotmemory/filter.py: Filter, a frozen dataclass with one optional field per allowed key, and TimeRange.
  • src/hotmemory/_rules.py: plain functions that both drivers call. They cover prefix matching, revision numbering, deduplication, visibility, filter matching, list order, and cosine distance.
  • src/hotmemory/memory.py: MemoryStore, which takes an embedder and a clock, and MemoryWriter.
  • tests/test_conformance.py: one test per answered storage-contract guarantee. Each test runs against every driver in DRIVERS in tests/conftest.py. tests/test_frozen.py freezes the four surfaces. tests/test_ledger.py reads docs/guarantees.md.
  • make verify runs ruff, ruff format, strict mypy, pytest with sockets disabled, and the link check, in that order. .github/workflows/verify.yml runs it on Python 3.11.

Decisions taken during the phase

The owner answered three stop conditions:

  • The ledger row "Two processes write to the same table" needs the Hotdata load lock. Phase 2 proves it.
  • cues, tags, and sources are tuple[str, ...], so a record cannot change in place.
  • A namespace label cannot be empty or contain . or /. A key cannot be empty or contain / or @. With these rules, the stored path and the id stay unambiguous.

Where the contract was silent, this pull request fixes the following rules in docs/contracts.md:

  • A tags filter matches records that hold every tag that it names.
  • A time range includes its start and excludes its end. A null timestamp never matches a range.
  • since includes a record created at exactly that time.
  • A record is hidden once forget_after is at or before the clock's current time.
  • search with no query text returns the order of list, with a distance of None.
  • MemoryStore ranks by cosine distance over content only.
  • If the with block of a writer raises an error, the writer drops its buffer.

Verification

  • AC1: make verify passed in 0.98 s after all caches were deleted. All five steps ran in order, and 70 tests passed.
  • AC2: I removed one element from each surface by hand (normalize from __all__, Store.delete, Record.forget_reason, Filter.actor). Each frozen test failed with this message: Frozen surface changed: the filter keys of list and search. This is a public contract change. Add an entry to CHANGELOG.md under Unreleased, then update the literal in this test.
  • AC3: I renamed test_last_writer_wins_on_one_key. test_every_named_test_exists failed with docs/guarantees.md names tests that tests/test_conformance.py does not define.
  • AC4 and AC5: every storage-contract row in the ledger names a conformance test, and every such test passes under --disable-socket.
  • AC6: the CI run on this pull request.
  • AC7: git grep -i -w -E for the known private names over the whole tracked tree found nothing.
  • Also: mypy and the suite passed on Python 3.11. I ran the README example as written, and its printed output matches its comments.

Not verified: nothing in this pull request runs against Hotdata or the local RuntimeDB stack. That is phase 2.

Notes

  • The ledger rows of the memory contract (remember, as-of, scope, sources, turns) name phase 3. The row on two processes names phase 2.
  • plan.md has one open question that the issue does not have. supersede must set valid_until and expired_at on the old record, but no Store operation can change those fields. Phase 3 decides how Memory sets them.
  • The filter is AND only, and it has no OR. A richer predicate is possible later, as a change to a frozen surface.

src/hotmemory holds an empty package and py.typed. pyproject.toml builds
it with hatchling at version 0.0.0, with no runtime dependencies. mypy,
pytest, and pytest-socket join the dev group.

Refs #4
make verify now runs five steps in order: ruff, ruff format in check
mode, strict mypy over src/ and tests/, pytest with sockets disabled,
and the link check. CONTRIBUTING.md lists the five steps.

Refs #4
Record is a frozen dataclass with the fields in docs/contracts.md. It
derives id as namespace/key@revision and refuses a value the contract
does not allow. A namespace label cannot be empty or contain . or /,
and a key cannot be empty or contain / or @, so the stored path and the
id stay unambiguous. cues, tags, and sources are tuples. normalize()
gives the form that deduplication compares.

contracts.md states the new refusals and the tuple types.

Refs #4
Store is a typing.Protocol with put, get, history, list, search,
delete, list_namespaces, and writer. Writer is the protocol of the
buffer that writer() returns. Filter is a frozen dataclass with one
optional field per allowed key, and TimeRange is a half-open range in
which a null timestamp never matches. Hit pairs a record with its
distance.

contracts.md states how the filter matches tags and ranges, the order
of list and its since argument, the distance when search has no query
text, and the arguments and flush rules of writer.

Refs #4
MemoryStore implements Store: revisions and supersession, exact
deduplication on normalized content, whole-label namespace matching,
forget_after hiding, cosine distance on content through the embedder,
and a buffered writer that records the ids it flushed. It takes an
embedder and a clock. The rules that the Hotdata driver will share
(prefix matching, revision numbering, deduplication, visibility,
filter matching, list order, cosine distance) are plain functions in
hotmemory._rules.

tests/conftest.py adds a fixed clock, a deterministic fake embedder,
and a store fixture parametrized over drivers.

Refs #4
tests/test_conformance.py holds one test per answered storage-contract
guarantee in docs/guarantees.md, run against every driver through the
store fixture. Today the only driver is MemoryStore.

Refs #4
tests/test_frozen.py compares __all__, the Store method set, the Record
fields and types with the Kind values, and the Filter keys against
literal sets. Each failure message says the change needs a CHANGELOG.md
entry. CHANGELOG.md starts with an Unreleased section.

Refs #4
tests/test_ledger.py reads docs/guarantees.md. It fails when the Test
column names a test that tests/test_conformance.py does not define, or
when a row names neither a test nor a phase. The Test column now names
the conformance test for each storage-contract row. The row on two
processes waits for phase 2, because only the Hotdata driver has a load
lock, and the memory-contract rows wait for phase 3.

Refs #4
A GitHub Actions workflow installs uv with Python 3.11, the lowest
version that pyproject.toml allows, and runs make verify on each pull
request and on each push to main.

Refs #4
README and contracts.md no longer say that no code exists. README gets
an example of MemoryStore, run as written. CONTRIBUTING.md names the
test files, the fake clock and embedder, and the ledger rule for rows
that a later phase proves, and marks the Hotdata tests and the oracle
test as phase 2. AGENTS.md points to CHANGELOG.md and the library, and
adds the changelog rule. local.md says the Hotdata driver comes in
phase 2. The brief and the ledger carry the record rules and the writer
wording from contracts.md. plan.md records the supersede question for
phase 3.

Refs #4
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner October 6, 2026 07:37
Comment thread src/hotmemory/memory.py Outdated
slot = (draft.namespace, draft.key)
revisions = self._revisions.get(slot, [])
current = revisions[-1] if revisions else None
if current is not None and is_duplicate(current, draft.content):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Skip deduplication when the current revision is past forget_after, or state the rule in docs/contracts.md (not blocking).

Today a put with the same normalized content returns the id of a hidden record. Example: put(content="X", forget_after=t), the clock passes t, then put(content="X"). The second put returns …@1 and writes nothing. list and search never return …@1, so the caller cannot bring the fact back. The same rule also drops a change to tags, sources, kind, or forget_after when the content is the same. docs/contracts.md:89 makes the length of sources the corroboration count, and this rule blocks adding a source to an existing fact.

Comment thread src/hotmemory/record.py
subject: str = ""
content: str
cues: tuple[str, ...] = ()
payload: dict[str, JSONValue] = field(default_factory=dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Freeze payload too, for example with a deep copy on the way in and out, or types.MappingProxyType (not blocking).

MemoryStore.get returns the stored Record object. A caller that runs store.get(ns, k).payload["x"] = 1 changes the stored revision in place. docs/contracts.md:101-102 says a record cannot change after the store writes it. The tuple change for cues, tags, and sources does not cover payload.

Comment thread src/hotmemory/memory.py
query_vector = self._embed_query(query)
self._embed_missing(records)
hits = [
Hit(record, cosine_distance(query_vector, self._vectors[record.id]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Give a record with a zero vector a distance of None, or put it last, and do not raise (not blocking).

cosine_distance raises ValueError for a zero vector. One record with empty content (the record allows it), or with text that the embedder maps to zero, makes every search with query text under that prefix fail. The conformance fake_embedder returns a zero vector for content="".

claude[bot]
claude Bot previously approved these changes Oct 6, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full diff was omitted from the review prompt. Reviewed from the checkout: src/hotmemory/memory.py, _rules.py, record.py, filter.py, store.py, tests/test_conformance.py, tests/conftest.py, docs/contracts.md. Not read: the other test files, CI config, and the remaining docs. CI was still pending when this review started. Only non-blocking nits are left inline.

A put whose content equals a current revision past its forget_after now
writes a new revision, so a forgotten fact can come back. The record
deep-copies payload, and MemoryStore returns copies, so a caller cannot
change a stored record. A record refuses content that is empty after
normalization, so no record gets a zero vector from its own text.

Each rule has a conformance test that the ledger names, and each test
was checked to fail with its fix undone. contracts.md, the brief, and
the ledger state the rules. plan.md records for phase 3 that
deduplication on content alone blocks adding a source.

Refs #4

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full diff was omitted from the review prompt. This review read src/hotmemory/memory.py, src/hotmemory/_rules.py, src/hotmemory/record.py, tests/conftest.py, CHANGELOG.md, and the diff since the last review.

@rohan-hotdata
rohan-hotdata merged commit 8a1d43b into main Oct 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1: the storage contract, offline

1 participant