Comparing main...speedkick · MemMachine/MemMachine · GitHub
Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: MemMachine/MemMachine
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: main
Choose a base ref
...
head repository: MemMachine/MemMachine
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: speedkick
Choose a head ref
Checking mergeability… Don’t worry, you can still create the pull request.
  • 15 commits
  • 60 files changed
  • 6 contributors

Commits on Aug 26, 2026

  1. fix(metrics): make Prometheus metrics correct with multiple workers, …

    …and wire the trackers up (#1523)
    
    * fix(metrics): aggregate Prometheus metrics across uvicorn workers
    
    With MEMMACHINE_WORKERS > 1 uvicorn forks a worker per process and each
    keeps its own registry, so /metrics returned whichever worker happened to
    answer the scrape - about 1/N of the traffic, a different 1/N each time.
    Consecutive scrapes then look like counter resets, and every rate,
    histogram quantile and calls-per-request figure derived from them is
    wrong in an unbounded direction.
    
    Build a fresh registry per scrape and attach MultiProcessCollector when
    PROMETHEUS_MULTIPROC_DIR is set, leaving the single-worker path on the
    default registry so nothing changes for the default deployment.
    
    Measured on an 8-tenant capacity run: counter resets over one ladder went
    from 70 (4 workers) and 87 (8 workers) to 0, and five consecutive scrapes
    of a loaded server returned identical values where they previously
    diverged.
    
    Requires PROMETHEUS_MULTIPROC_DIR to be set and a writable directory
    mounted at it; the chart change that does so is separate.
    
    * fix(metrics): hand every OperationTracker a metrics factory
    
    OperationTracker accepts metrics_factory=None and then silently discards
    every timing it takes - no error, no warning, no series. A component that
    is fully instrumented but never given a factory is therefore
    indistinguishable from one that was never instrumented at all, and the
    only way to notice is to go looking for a metric that should exist.
    
    The Neo4j store, the episode store and the session store each shipped
    instrumented and unwired, which is why database latency appeared to be
    unmeasurable: every call was timed and thrown away. Wire the factory
    through the resource manager, the database manager and the event
    backend's params so those trackers emit.
    
    The tests assert at the call sites rather than on the components. An
    earlier version tested that each store honours a factory it is given,
    which passes whether or not anything passes one - it still passed with
    the fix reverted. These fail when the wiring is removed.
    
    * fix(metrics): choose a multiprocess directory when workers > 1
    
    PROMETHEUS_MULTIPROC_DIR had to be set by whoever deployed the server. Set
    MEMMACHINE_WORKERS=4 without it and nothing complains: each worker keeps its
    own registry, a scrape is answered by whichever worker the load balancer
    picked, and the numbers that come back look plausible. There is no error to
    notice, so the only way to find out is to compare a counter against a request
    count and see it come up short by a factor of the worker count.
    
    The worker count is the thing that decides whether aggregation is needed, so
    read it here and default the directory when it is above 1. An explicit setting
    still wins, which is how two servers on one host keep their metrics apart. If
    the chosen directory cannot be created the variable is removed again -
    prometheus_client raises in every worker if it cannot open the directory it is
    pointed at, and taking the server down over metrics would be the wrong trade.
    
    Worker-count parsing moves into _worker_count() so start_http() and the
    directory setup cannot disagree about it.
    
    Verified: uvicorn spawns workers via multiprocessing.get_context("spawn"), so
    children re-import in a fresh interpreter and inherit the variable set here.
    13 new tests cover the default, the explicit override, nested creation, stale
    file clearing, and both failure paths; 1228 server/common tests pass; ruff
    check, ruff format --check and ty are clean (17 ty diagnostics, all
    pre-existing and none in these files).
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    ---------
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Aug 26, 2026
    Configuration menu
    Copy the full SHA
    ace7752 View commit details
    Browse the repository at this point in the history
  2. Configuration menu
    Copy the full SHA
    4d90895 View commit details
    Browse the repository at this point in the history
  3. Fix unit test failure

    malatewang committed Aug 26, 2026
    Configuration menu
    Copy the full SHA
    8bbddea View commit details
    Browse the repository at this point in the history

Commits on Aug 28, 2026

  1. fix(event-backend): make expand_context return timeline-neighbor epis…

    …odes (speedkick) (#1547)
    
    * fix(event-backend): make expand_context return timeline-neighbor episodes
    
    Fixes #1540.
    
    On the event backend, expand_context was silently inert: EventMemory
    fetched and materialized the expanded segment windows, but
    LongTermMemory._search_scored_event read only the seed segment's
    _episode_uid and score from each window, and the response schema has no
    context field - so responses were byte-identical for expand_context 0
    and 5 while every request paid the LATERAL fetch.
    
    The declarative backend, by contrast, folds neighbor episodes into the
    returned list (_unify_scored_anchored_episode_contexts). This brings
    the event backend to parity:
    
    - Each scored window now contributes the episodes its segments belong
      to (chronological within the window, the seed's episode as nucleus).
    - Windows are unified best-score-first with the same fill algorithm as
      the declarative backend: taken whole while they fit within
      num_episodes_limit, then filled by weighted index-proximity to the
      nucleus (forward recall preferred) until the limit is met; an episode
      keeps the score of the first window that contributed it.
    - The unified context is returned chronologically, matching the
      declarative backend's ordering contract for expanded results.
    - expand_context is clamped to num_episodes_limit - 1 (declarative
      parity).
    
    expand_context == 0 behavior is unchanged (score-ordered seeds, exactly
    as before). Reranked configurations gain the same folding on top of
    reranker-scored windows.
    
    Tests: end-to-end via the in-memory event-backend wiring (neighbors
    returned, chronological order, limit respected) and unit tests for the
    window-to-episode-uid extraction and the unification algorithm
    (whole-context fit, overflow proximity with forward preference,
    first-window score retention).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * style: ruff format
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix(event-backend): clamp expand_context above zero, and make the
    expansion tests actually discriminate
    
    Self-review of the two commits above turned up one defect and one hole.
    
    Defect: the quota clamp `min(max(0, expand_context), num_episodes_limit - 1)`
    goes negative when `num_episodes_limit == 0` -- reachable, since
    `SearchMemoriesSpec.top_k` carries no lower bound. `EventMemory._query`
    then derives `max_backward_segments = -1 // 3 = -1` and hands the segment
    store a negative window, which the SegmentStorePartition contract does not
    define: the SQLAlchemy store happens to short-circuit on `<= 0`, the
    in-memory store computes an empty slice and drops the seed. Apply the floor
    last so the clamp can only ever produce a non-negative window.
    
    Hole: neither end-to-end test could tell the fix from its absence -- both
    pass unmodified against the pre-fix `long_term_memory.py`. `FakeEmbedder`
    maps text to `[len(text), -len(text)]`, so under cosine every document
    scores exactly 1.0 against every query; all seven timeline episodes become
    seeds of equal rank, ties keep insertion order (which is chronological),
    and `num_episodes_limit=7` returns all seven with or without expansion. The
    "expansion adds episodes" assertion compared a limit-2 search against a
    limit-7 one, so the limit alone explained the difference.
    
    Embed on a keyword instead: only `tl-3` matches the query, so `tl-4` and
    `tl-5` -- which score zero -- can reach the result only through the
    expansion. The tests now pin the exact window (`[tl-3, tl-4, tl-5]`,
    chronological, each keeping the window's score), the clamp against an
    oversized `expand_context`, and the non-negative window above. All three
    fail against the code they cover.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01PVtg6Zea292Pb9L7GXnTJp
    
    * test(event-backend): assert the expansion contract, not a ranking
    
    The tests added in the previous commit discriminate, but they pin an
    outcome: an exact episode list (`[tl-3, tl-4, tl-5]`) and exact score
    values. Both are properties of the fixture's ranking and of the
    backward/forward split `expand_context // 3`, neither of which the fix
    claims -- change the split or the scoring and the tests fail while the
    behaviour under test is still correct.
    
    Restate them as the contract. Each episode now gets its own similarity from
    an explicit search rank, with the match's four timeline neighbours ranked
    last, so:
    
    - no correct top-k can return those neighbours, and any nonzero window
      around the match reaches at least one of them whatever the split. The
      assertion is "expansion returned a neighbour the search itself would not",
      plus chronological order and the episode limit.
    - the clamp is asserted on the call made to the segment store
      (0 <= backward + forward <= limit - 1, over several limit/expand_context
      pairs) rather than on which episodes come back.
    - `expand_context == 0` is asserted as "matches only, best score first",
      without naming them.
    
    Exact lists and score values stay in the unit tests, which own the fill
    algorithm and the score-retention rule and are meant to track them. All
    three expansion tests still fail against the code they cover.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01PVtg6Zea292Pb9L7GXnTJp
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    edwinyyyu and claude authored Aug 28, 2026
    Configuration menu
    Copy the full SHA
    7a66113 View commit details
    Browse the repository at this point in the history

Commits on Aug 30, 2026

  1. Add a deployment sizing calculator under tools/sizing (#1553)

    The calculator turns a design peak in operations per second, plus a traffic
    mix of adds, plain searches and agent-mode searches, into a hardware order:
    API servers, vector-store machines and their RAM size, a PostgreSQL server,
    embedding and agent-model GPU cards, hot vector RAM and disk for a chosen
    retention period, the max_connections PostgreSQL will need, north-south and
    east-west network peaks, and how many callers that capacity holds. It also
    works backwards, turning a caller population into the capacity it demands.
    
    Every number it prints is labelled measured, derived, estimate or assumption,
    and every input is a command-line flag and a box on the web form, so a reader
    changes any of them without editing code.
    
    Two things that sound alike are kept apart throughout. Agent-mode search is a
    property of one REQUEST - the agent_mode flag, which fans one search out into
    about 22 - and it is a cost multiplier. An automated client is a property of a
    CALLER - a program sending requests in a loop - and it is a rate multiplier.
    Each caller population carries its own traffic mix and the two are blended,
    because a person can send agent-mode searches and a program need not.
    
    The unit everywhere is a session sending requests at the same moment, whoever
    is behind it: one developer driving a ten-user load test is ten sessions. A
    user count is not a session count, so converting one to the other takes two
    figures - the share of users active at the busiest moment, and the sessions
    one active user holds. Both ship as example defaults, labelled as such, with
    a warning naming which were defaulted and what to replace them with. The
    share is 10 per 100 people: a convention, not a measurement, plausibly 5 to
    20, and every machine count moves with it.
    
    The two per-caller rates carry published sources. A human chat session at
    0.011 to 0.028 operations per second is the median to about the 90th
    percentile of a session's busiest five minutes, measured across 55,295
    sessions in the BurstGPT dataset (arXiv:2401.17644). An automated client at
    0.4 is TraceLab's measured 5.0-second median step across roughly 4,300
    production agent sessions (arXiv:2606.30560). Both reports also carry the
    figures those sources revealed and no count uses: a heavy session at 0.06,
    and an automated client at 0.07 sustained, six times below its burst because
    an agent is idle most of the wall-clock time.
    
    Sizing for the worst sustained five minutes follows ITU-T E.500, which
    requires read-out periods greater than five minutes so that resources are not
    dimensioned for infrequent small-interval peaks.
    
    It deliberately does not price high-availability additions: a second copy of
    every vector, a PostgreSQL standby or a second gateway are a separate
    decision.
    
    476 tests, standard-library unittest, covering the arithmetic at exact
    machine boundaries, every subcommand, the web form driven end to end, and
    rejection of bad input on every path. ruff check is clean.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01DiqwYpXVGDet5NiHjdU7Ng
    leomem and claude committed Aug 30, 2026
    Configuration menu
    Copy the full SHA
    4aeb020 View commit details
    Browse the repository at this point in the history

Commits on Aug 31, 2026

  1. Configuration menu
    Copy the full SHA
    99c808b View commit details
    Browse the repository at this point in the history
  2. fix(metrics): wire the Qdrant vector store, and let a build include it (

    #1532)
    
    QdrantVectorStore was the fourth component built without a metrics factory.
    OperationTracker accepts metrics_factory=None and then discards every timing
    without an error, so the store looked instrumented and emitted nothing - the
    same defect as the Neo4j store, the episode store and the session store, which
    is why no Qdrant latency was observable.
    
    QdrantConf gains MetricsFactoryIdMixin so it can resolve one, and
    database_manager passes it through.
    
    The Dockerfile gains an EXTRAS build arg on all four uv sync lines. Without
    --extra qdrant the qdrant-client package is never installed and the provider
    raises ModuleNotFoundError on every request, so an image intended to exercise
    this path could not run at all.
    
    test_qdrant_creates_vector_store pinned the exact params and had to change.
    It now asserts metrics_factory is not None rather than pinning it: passing the
    keyword is not the property worth guarding, since None is accepted and silently
    discards everything. Removing the wiring fails it; 1228 common/server tests
    pass, ruff check and format are clean.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Aug 31, 2026
    Configuration menu
    Copy the full SHA
    b6c90ab View commit details
    Browse the repository at this point in the history
  3. fix(server): warm the search path at startup, not on the first request (

    #1551)
    
    Every resource a search needs is lazy-loaded behind a lock, so the first
    search into a fresh worker imports its module tree and builds pydantic
    schemas for it. That work is synchronous, so it blocks the event loop, and
    it is paid once per uvicorn worker because each worker is a separate
    process. Kubernetes has already routed traffic by then: /api/v2/health
    touches none of it and answers in about a millisecond while a search on the
    same worker takes seconds.
    
    start() already resolves the session data manager. This extends that to the
    rest of the search path - episode storage, the episodic memory manager, and
    the embedder, reranker, vector store and segment store named by the
    long-term-memory config - so the cost lands in front of the readiness probe
    where it belongs.
    
    Failures are logged and swallowed. Each of these is retried lazily on the
    request path anyway, so a store that happens to be unreachable at boot
    should cost a slow first request rather than a process that will not start.
    The long-term-memory config is polymorphic over the backend, so the resource
    names are read with getattr rather than assuming a concrete class.
    
    Verified on a deployed platform (k3s, four uvicorn workers, 12k episodes,
    Qdrant and PostgreSQL on separate hosts) by mounting this file over the
    installed package - byte-identical to upstream/main beforehand, so the only
    variable was this change. First search after a restart, three restarts each:
    
      stock       2.22 / 2.69 / 3.15 / 2.74 s     (mean 2.70)
      with warm   1.40 / 0.92 / 1.00 s            (mean 1.11)
    
    No "Could not warm" warnings, so every resource resolved cleanly. Rollout to
    Ready was unchanged - 41.8 / 43.8 s with the fix against 43.1 / 42.8 s
    without - because the startup probe's 10 s period absorbs the shift.
    
    The remaining ~1.1 s is not addressed here: partition creation is
    session-scoped, so a project's first search still pays its own first touch.
    
    ruff check and ruff format --check both pass.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Aug 31, 2026
    Configuration menu
    Copy the full SHA
    4be5a88 View commit details
    Browse the repository at this point in the history
  4. fix(db): bound asyncpg statement and connect time so a dead socket re…

    …covers (#1552)
    
    A pooled connection whose peer stops responding is not an error, it is a
    wait. The kernel retransmits with exponential backoff - observed at
    tcp_retries2 attempt 13, Send-Q stuck at 34 bytes and unchanged over
    20 seconds - and asyncpg has no deadline of its own, so the request blocks
    for the whole of it. pool_pre_ping does not help: its liveness SELECT is
    written into the same dead socket and waits with everything else.
    
    The failure this produces is worse than a slow request. The pool never
    discards the dead connections, so the process stays wedged after the network
    recovers and only a restart clears it. Seen twice on a benchmark deployment:
    searches timing out indefinitely while /api/v2/health answered in about a
    millisecond, four uvicorn workers idle in select(), no lock contention in
    Postgres, and a fresh connection from the same pod completing in 18 ms.
    
    create_async_engine was passed no connect_args, so nothing bounded either
    the statement or the connect. This adds command_timeout and connect_timeout,
    defaulted to 60 s and 10 s and settable to null for the old behaviour. Both
    are gated on driver == "asyncpg": they reach asyncpg.connect() and mean
    nothing to aiosqlite or aiomysql. The keyword assembly moves into a helper,
    because adding a branch to async_get_sql_engine pushed it past ruff's
    complexity limit and the repeated "if not None" lines were asking for it.
    
    Verified by fault injection on a deployed platform - iptables DROP on the
    pod's traffic to Postgres:5432, so pooled connections wedge exactly as they
    did in the incident:
    
                            under the fault          after the fault is removed
      stock        no response at 150 s (cap)    still hung, no response at 90 s
      with fix     500 after 194 s               200 in 1.5 s
    
    The recovery is the point: the stock build stays wedged once the network is
    healthy again, which is what forced the restarts.
    
    Under a total outage the request still takes ~194 s to fail rather than
    ~60 s. A search touches three separate stores, so it pays a timeout per
    store; the bound is roughly command_timeout times the number of stores
    touched, not command_timeout. Lowering the default would shorten it
    proportionally - 60 s is chosen as generous against real query times here,
    which are milliseconds, and is left tunable rather than tuned.
    
    ruff check and ruff format both pass.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Aug 31, 2026
    Configuration menu
    Copy the full SHA
    10b3706 View commit details
    Browse the repository at this point in the history
  5. ci(docker): publish images that can actually use the Qdrant backend (#…

    …1556)
    
    qdrant-client is an optional extra (packages/server/pyproject.toml:67).
    #1532 gave the Dockerfile an EXTRAS build arg so a build can include it,
    but the publish workflow never passed one, so every image on Docker Hub
    lacks the package: the core starts cleanly and then raises
    ModuleNotFoundError on the first request that uses the event backend.
    The platform chart can now select that backend, so there is no tag it
    can point at.
    
    Both the CPU and GPU builds pass EXTRAS=--extra qdrant unconditionally.
    The GPU sync line becomes `--extra gpu --extra qdrant`.
    
    Verified: uv.lock already carries qdrant-client under
    `marker = "extra == 'qdrant'"`, so `uv sync --frozen --extra qdrant`
    resolves without relocking. Parsed the workflow with PyYAML and confirmed
    build-args is exactly ['GPU=...', 'SCM_VERSION=...', 'EXTRAS=--extra qdrant']
    for both jobs -- the rationale sits in a YAML comment above the key, not
    inside the block scalar, where it would have reached Docker as a literal
    build arg.
    
    Not verified: no image was published from this branch. The workflow is
    tag-triggered and was not dispatched, so the built image has not been
    run against a Qdrant deployment.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Aug 31, 2026
    Configuration menu
    Copy the full SHA
    899ace1 View commit details
    Browse the repository at this point in the history
  6. ci(docker): stop the GPU build silently overwriting the CPU image (#1560

    )
    
    Dispatching this workflow with a pre-release tag publishes one image, not two.
    metadata-action's semver type drops its pattern when the parsed version has a
    pre-release component, so `v0.3.9-post1` produced the bare tag `0.3.9-post1`
    for both jobs instead of `v0.3.9-post1-cpu` and `-gpu`. The CPU build pushed
    first, the GPU build overwrote it twenty minutes later, and nothing reported an
    error: the tag simply held a 2.9 GB GPU image under a name meant for CPU.
    
    Observed on run 33439754567, where both jobs logged
    `tags: memmachine/memmachine:0.3.9-post1` and pushed distinct manifests to it.
    
    This has never been hit before because every release so far used a plain
    vX.Y.Z, for which the pattern applies normally. It is not specific to this
    branch - the rules are character-identical on main.
    
    Two changes:
    
    - The dispatch version tag is now type=raw with the variant appended, which
      cannot lose the suffix. For a release input it produces exactly what semver
      did (v0.3.9 -> v0.3.9-cpu), so nothing changes for the existing flow; for a
      pre-release it produces v0.3.9-post1-cpu. Simulating both rule sets over both
      input shapes gives no overlapping tag between the jobs.
    - A `variant` input (both | cpu | gpu, default both) so a caller can skip the
      26-minute GPU build they do not want. build-gpu needs always() for this,
      because a deliberately skipped CPU job would otherwise skip it too; the
      result check still stops a *failed* CPU build from letting it through.
    
    The `latest` rules are untouched and stay guarded by !contains(inputs.tag,'-'),
    so a pre-release still cannot move latest/latest-cpu/latest-gpu.
    
    Note for anyone pinning: after this, rebuilding the tag v0.3.9-post1 publishes
    v0.3.9-post1-cpu rather than 0.3.9-post1. The existing 0.3.9-post1 image is
    left as it is.
    
    Not verified: no build was dispatched from this branch. The tag sets above were
    derived by evaluating the rules, not by running the workflow.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Aug 31, 2026
    Configuration menu
    Copy the full SHA
    d0901ac View commit details
    Browse the repository at this point in the history

Commits on Sep 2, 2026

  1. Configuration menu
    Copy the full SHA
    3b0d9ec View commit details
    Browse the repository at this point in the history
  2. Overhaul segment store: shared tables with incarnation-scoped tenant …

    …keys (fixes #1544, #1546, #1549) (speedkick) (#1548)
    
    * Fix: Detach segment store partitions before dropping them, and lock partition metadata on delete
    
    Deleting a partition on PostgreSQL dropped its child tables with CASCADE.
    The foreign key from segment_store_dv_ln to segment_store_sg is declared on
    the partitioned parents, so the CASCADE dropped the parent-level constraint
    rather than only the part belonging to the deleted partition. After the
    first partition deletion the store stopped enforcing the link for every
    remaining partition, and ON DELETE CASCADE stopped removing derivative
    links with it, so delete_segments left orphaned rows that
    get_derivative_uuids_by_segment_uuids still returned. Detaching each child
    before dropping it keeps the constraint and the cascade intact.
    
    delete_partition also took only a row lock on the partition row. ROW SHARE
    does not conflict with the SHARE ROW EXCLUSIVE table lock the create paths
    take, so a concurrent create and delete could reach segment_store_sg and
    segment_store_dv_ln in opposite order and deadlock; that reproduced in 4 of
    7 sampled interleavings against PostgreSQL 16, and in 0 of 7 once delete
    takes the same table lock first. The row lock stays, because it is what
    makes delete wait for in-flight writers holding FOR SHARE on that row.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
    
    * fix: address partition child tables directly for segment DML
    
    Fixes #1546. Parent-table queries carry the partition key as a bind
    parameter; once asyncpg's prepared statement flips to a cached generic
    plan (after five executions) PostgreSQL locks every child partition on
    every execution before runtime pruning. With hundreds of partitions and
    concurrent sessions this exhausts the lock table (searches fail 500
    'out of shared memory') and saturates the database CPU with lock churn
    and generic-plan startup.
    
    The partition handle now maps the ORM entities onto its own child
    tables (orm.aliased with adapt_on_names) and targets them for
    insert/delete, so every plan references exactly one partition. SQLite
    keeps the parent tables (it has no children).
    
    Measured on a store with 316 partitions: max relation locks held by a
    backend during a read loop drops 1276 -> 4; the 239 HTTP 500s in a
    4-worker load test disappear; throughput at 128 concurrent requests
    rises ~20-30% with PostgreSQL no longer pinned at its CPU cap.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * Test: Pin the partition-delete lock ordering
    
    The foreign-key half of this branch has a regression test; the lock
    ordering did not. A deadlock test would be timing-dependent, so assert the
    invariant the deadlock analysis rests on instead: delete_partition issues
    LOCK TABLE segment_store_pt IN SHARE ROW EXCLUSIVE MODE, and issues it
    before any DETACH or DROP of a child table. Without the lock the test fails
    and reports the statement sequence it saw.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
    
    * Style: Apply ruff format to the lock-ordering test
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
    
    * test: segment DML must address only the partition's child tables
    
    Regression test for #1546. Captures the SQL the partition handle emits
    across add / seed read / windowed read / filtered read / uuid maps /
    delete and asserts no statement references the partitioned parents --
    the deterministic observable of the generic-plan lock explosion (lock
    counts would need timing-dependent pg_locks sampling). Fails against
    the parent-table implementation, passes with per-partition DML.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * style: drop annotation-only AliasedClass and Table imports
    
    This SQLAlchemy version exports no public AliasedClass name (only the
    aliased() factory), so the attribute annotations forced an import from
    sqlalchemy.orm.util. The annotations were documentation only; the
    branch comment already records that the attributes hold either the ORM
    class or its child-table alias.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: derive partition table names from one helper and the models
    
    The parent names come from the models' __tablename__ and the child
    naming pattern lives in _pg_child_table_name, used by child-table
    creation, teardown, and the per-partition DML targets, so the three
    sites cannot drift apart. Physical names are unchanged; the regression
    tests keep literal names to pin the on-disk naming contract.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * Fix: Drop detached children, and stop holding the store-wide lock while waiting for writers
    
    Three follow-ups from review of this branch.
    
    The child-table probe asked whether the table exists, but the statement it
    guards is DETACH PARTITION, which requires that the table be attached. A
    child left detached by manual maintenance or an interrupted DETACH
    PARTITION CONCURRENTLY (that form is not transactional) passed the probe and
    made DETACH raise "is not a partition of", rolling back the transaction, so
    the partition became neither deletable nor recreatable -- a state the
    CASCADE drop this branch replaced used to clean up. Probe pg_inherits for
    attachment instead, in one round trip for both children, and drop an
    unattached child directly.
    
    delete_partition took the partitions-table lock before the row lock that
    waits for in-flight writers, so a slow writer on one partition stalled
    open_or_create_partition for every partition, which runs on the request
    path. Take the row lock first; the table lock only has to be held across the
    child DDL for the deadlock argument to hold. Re-measured: 4/7 sampled
    interleavings deadlock with no table lock, 0/7 with either ordering.
    
    Tests: the foreign key is now asserted to be enforced after a partition
    delete, not only that the cascade fires -- the PR's measurements list those
    as separate things the CASCADE drop broke. A second test leaves a child
    detached and requires deletion to succeed and the key to be reusable.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
    
    * fix: memoize partition entities per key; validate keys in the naming helper
    
    Review follow-ups on the child-table change:
    
    - The child Table objects and aliases are now built once per partition
      key (functools.cache) instead of per handle. SQLAlchemy's compiled-
      statement cache keys on the Table objects a statement references, so
      per-handle tables made every handle's statements recompile and
      polluted the cache for everything else (verified: cache keys differed
      across handles for the same partition; now identical).
    - The tables carry columns only. The to_metadata copies dragged along
      foreign keys with unresolvable targets and duplicate index names --
      latent hazards for anything walking that MetaData.
    - _pg_child_table_name validates the partition key itself, so every SQL
      string built from a child table name (including the DDL literals) is
      safe by construction rather than by call-site convention; the store's
      validator moved to module level beside it.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: make partition key validation part of the segment store contract
    
    Partition keys are embedded in native storage identifiers by any
    implementation, so the alphabet/length rule is interface-level, not a
    SQLAlchemy detail: validate_partition_key now lives in the package's
    data_types (exported from the package), the SegmentStore.create_partition
    docstring states the contract, and the SQLAlchemy store imports it.
    Deliberately NOT unified with the vector store's identical identifier
    rule: the repo-wide naming contract is not wired through yet, so the
    convergence is treated as incidental for now.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: move partition key validation to segment_store/utils.py
    
    Mirrors the vector store's layout (validate_identifier in
    vector_store/utils.py); the interface docstring states the key rule
    plainly instead of referencing a code path.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * style: state partition key naming constraints in the VectorStore format
    
    Same ABC-level 'Naming constraints:' block the vector store uses, no
    method-level restatement, and the length limit is enforced and
    documented in bytes, matching validate_identifier.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * style: state the key rule as the regex, not a prose fragment
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * style: restore the ABC's original naming-constraints docstring
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: make validate_partition_key boolean, call sites raise
    
    Mirrors the vector store's validate_identifier: the predicate returns
    bool so callers can compose it, and each entry point raises its own
    error in the vector store's message style.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * Revert "refactor: make validate_partition_key boolean, call sites raise"
    
    This reverts commit 88be86603c36e4b759da0f876968e6dd6db8d913.
    
    * fix: bound the partition-entity cache with lru_cache(4096)
    
    functools.cache grew ~29 KiB per distinct partition key (measured) for
    the life of the process, including deleted partitions. The LRU cap
    bounds it at ~115 MiB per worker; eviction is harmless since a rebuilt
    entry is identical and only costs recompiling that partition's
    statements once.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * style: drop point-in-time memory figures from the cache comment
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: address second review round
    
    - Type the partition entities: _pg_partition_entities returns a
      NamedTuple with real field types instead of a positional tuple of
      object, which was adding 55 ty diagnostics (the CI static check would
      have failed) and forcing blind unpacking; callers and the memoization
      test use named fields, and the private _generate_cache_key assertion
      (redundant given object identity) is dropped.
    - DROP TABLE gains IF EXISTS back, so an out-of-band drop landing
      between the state probe and the drop cannot leave a partition
      half-deleted.
    - open_or_create_partition opens existing partitions without the
      store-wide management lock (double-checked: unlocked read, then lock
      and re-check only when creating), so request-path opens no longer
      serialize behind a concurrent deletion's DDL window; pinned by
      test_open_existing_partition_takes_no_management_lock.
    - The engine's compiled-statement cache is raised from the default 500
      (per-partition statements would thrash it once enough partitions are
      live concurrently).
    - Comments and the DML test docstring scope the lock claim honestly:
      PostgreSQL's FK integrity triggers still address the parents
      internally, costing a one-shot per-backend lock spike on
      writes/deletes when a trigger plan first goes generic (verified:
      ~10 locks steady, one spike at execution six, then back) -- tracked
      on #1546.
    - The detach test cleans up its detached child unconditionally so a
      failure cannot poison the session-scoped container for later tests;
      the statement recorder is a shared fixture instead of copy-paste;
      the byte-length check encodes once.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: drop the typing casts from the partition entities
    
    inspect(Model).columns yields real Column objects (the stubs type
    __table__ as FromClause, which forced the cast), and SQLAlchemy's
    typing convention represents an aliased entity as the mapped class
    type, so the NamedTuple fields are type[SegmentRow] /
    type[DerivativeLinkRow] and aliased() assigns without coercion.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * design: shared segment store tables with incarnation-scoped keys
    
    Design record for replacing the per-tenant partitioned layout with
    shared tables on every dialect: the tenant registry carries an
    incarnation, data rows are keyed by <logical_key>@<incarnation>,
    deletion is an O(1) registry write plus a purge queue, and fencing
    fails stale handles loudly. Records the measured comparison against
    PARTITION OF and standalone-table layouts and the scaling requirements
    (cheap tenant creation at 1e5-1e6 tenants, 1e4-1e7 rows per tenant)
    that decided it.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor!: shared segment store tables, incarnation fencing, O(1) delete
    
    Implements design/segment_store_shared_tables.md. Fixes #1544, #1546,
    and #1549 by construction:
    
    - The ORM models are the physical schema on every dialect; PostgreSQL
      partitioning, per-tenant DDL, the detach machinery, the store-wide
      management lock, and the per-partition entity cache are all removed.
      No partitions means no generic-plan lock fan-out (client or
      RI-trigger) and no DDL for lifecycle deadlocks to live in -- the
      churn smoke that measured 41-83 deadlocks per 20s on every
      partitioned build measures zero, with 60x more write throughput.
    - segment_store_pt becomes the tenant registry: partition_key +
      incarnation. Data rows are keyed by <logical_key>@<incarnation>, so
      a deleted-and-recreated tenant never sees its predecessor's rows.
    - create_partition is a row insert (no DDL); delete_partition is O(1):
      FOR UPDATE on the registry row (drains writer pins), enqueue the
      physical key on segment_store_gc, delete the row.
      purge_deleted_partitions reclaims rows in chunked background batches.
    - Writes pin the registry row FOR SHARE with an incarnation predicate;
      reads check it too: a stale handle raises
      SegmentStorePartitionStaleError on every dialect, SQLite included.
      Measured cost: one extra registry round trip per read operation.
    - The segment table's FK to the registry is removed (registry and data
      rows are deliberately decoupled for O(1) deletion); the link-table FK
      and cascade remain.
    
    Same-moment ABAB vs the partitioned build: ingest and windowed reads at
    parity, lifecycle cycles 4-6x faster, tenant creation ~1000x cheaper
    (row insert vs DDL).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: identify tenant data rows by incarnation UUID alone
    
    Data rows drop the composite <logical_key>@<incarnation> string for a
    bare incarnation UUID column: a data query cannot be constructed
    without resolving the registry, so referencing the wrong tenant is
    structurally impossible; index entries narrow from a 41-byte varchar
    to a native 16-byte uuid; random UUIDs are globally unique across
    nodes without coordination, so tenant moves between databases carry
    rows verbatim; and collisions among incarnations with live traces are
    rejected by constraints (unique on the registry, primary key on the
    purge queue) instead of left to probability. The purge queue keeps the
    logical key for forensics.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: finish the physical-key -> incarnation wording in the design doc
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: fence by incarnation alone
    
    The incarnation is unique-constrained, so it resolves the registry row
    by itself; the logical-key predicate was a leftover from the composite
    string design and contradicted the rule that the incarnation is the
    handle's sole authority.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: rename the stale error to name the handle, not the partition
    
    The handle is what is stale -- the partition is deleted -- and
    SegmentStorePartitionHandleStaleError follows the existing noun+state
    convention (ConfigMismatch, AlreadyExists).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: inline uuid4 for incarnation generation
    
    new_incarnation() was a one-line wrapper adding indirection for no
    behavior; the multi-node rationale lives in the design doc.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: normalize segment timestamps to UTC before persisting
    
    Segment-store slice of #1462: SQLite's DateTime(timezone=True) discards
    tzinfo and stores wall-clock fields verbatim, so a non-UTC timezone-aware
    timestamp written without UTC normalization read back shifted by its
    offset (13:30:45-08:00 came back as 05:30:45-08:00). The read path
    already assumed UTC and reapplies the separately stored offset; only the
    write was missing the conversion. PostgreSQL timestamptz stores a true
    instant, so this is a no-op there.
    
    Regression test parametrized over UTC/-08:00/+05:30 runs on both
    backends; verified the non-UTC params fail without the fix and pass
    with it (sqlite 54, pg 58).
    
    The companion filter-bound normalization lives in shared
    sql_filter_util.py (used by episode and cluster stores too) and stays
    in #1462.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: normalize datetime filter bounds to UTC in SQL filter compilation
    
    Second half of the #1462 segment-store slice: timestamp columns now hold
    the UTC instant, so comparison bounds must be named in the same frame.
    On SQLite an aware datetime bind is rendered as wall-clock digits with
    tzinfo dropped and compared lexically, so `timestamp <=
    2024-01-01T08:00+08:00` excluded a row stored at 00:00Z -- the same
    instant. _normalize_column_value converts datetime values (Comparison
    and In leaves) to UTC before binding; PostgreSQL compares timestamptz
    by instant either way, so the two backends now agree.
    
    The helper lives in the shared sql_filter_util because that is where
    column leaves are compiled; other stores' write paths (episode, cluster)
    are intentionally not touched here.
    
    Regression test parametrized over the same instant named in +00:00,
    +08:00, and -08:00, on both backends; verified the non-UTC bounds fail
    without the fix and pass with it (sqlite 57, pg 61; full server suite
    1880 passed, 3 skipped).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * feat: promote purge_deleted_partitions to the SegmentStore ABC
    
    Physical reclamation of deleted partitions is now an ABC capability:
    callers schedule it however they want; the store never schedules it
    itself. delete_partition's contract notes that reclamation may be
    deferred. Implementations whose deletes reclaim physically implement
    it as a no-op returning False.
    
    Signature review against the prior purge iterations (#1199/#1205):
    - The old three-step orphan-derivative API (get_orphaned / mark /
      purge) existed only because derivative purging interleaved with
      vector-collection deletes between steps; incarnation purge is fully
      internal to the store, so a single method suffices.
    - The old scheduling knob (purge_interval loop in ExtraMemory) lived
      in the consumer -- preserved: no scheduling in the store.
    - The bound is max_segments (domain unit; derivative links ride along
      uncounted) rather than max_batches, which presumed chunked-transaction
      implementations. batch_size stays as a keyword on the SQLAlchemy
      implementation only, as a transaction-size tuning knob.
    - Returns bool ("reclaimable work may remain") instead of rows deleted:
      a row count cannot distinguish "drained" from "stopped at the bound"
      when a dead incarnation has zero data rows, and the scheduling caller
      needs exactly the more-work signal.
    
    New test pins the bound and the completion signal on both dialects.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: drop batch_size from purge_deleted_partitions
    
    With max_segments as the caller's bound, a per-call batch_size is
    redundant: bounded calls already cap every delete transaction at the
    remaining budget, so the knob only governed the unbounded case --
    where transaction sizing is engine policy, not caller policy. The
    chunk is now an internal constant (_PURGE_CHUNK_SIZE); if a deployment
    ever needs to tune it, it belongs in SQLAlchemySegmentStoreParams,
    not per call. Tests exercise multi-chunk draining by patching the
    constant.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: purge is one atomic slice per call; fix open_or_create race
    
    Purge contract resolved to atomic-slice-per-call: each
    purge_deleted_partitions call is a single transaction that reclaims up
    to max_segments rows and either commits that progress or nothing.
    Draining a backlog is the caller's loop (call until False), so
    reclamation never holds a long transaction, committed slices survive
    interruption, and there is no internal chunking competing with the
    caller's bound. max_segments=None means the store-chosen slice size
    (_PURGE_SLICE_SEGMENTS), keeping engine-appropriate transaction sizing
    out of callers' hands. Rationale over the alternatives: cross-call
    atomicity is anti-useful for gc (a huge atomic purge is exactly the
    long-transaction hazard, and an error would forfeit all progress),
    while batch_size+max_batches exposes the store's transaction quantum
    and bounds a call only as a product of two knobs.
    
    Also fixes a TOCTOU in _open_or_create_partition caught by the new
    lifecycle churn test: losing the insert race and then finding no row
    (a concurrent delete removed the winner) raised RuntimeError; the
    read-then-insert sequence now retries, since every retry implies
    another actor changed the state. New deterministic fencing tests:
    test_write_landing_during_delete_is_never_orphaned (the write pin means
    rows can never land under an incarnation the purge queue no longer
    tracks) and test_concurrent_remote_delete_yields_single_queue_entry
    (the delete pin means racing deletions enqueue exactly once).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * test: lock-necessity suite verified by per-lock ablation in both generations
    
    New test_segment_store_locking.py (PostgreSQL integration lane) pins
    each locking property through the public API, using only surface shared
    with the pre-overhaul partitioned store so the module runs against both
    generations. All interleavings are staged event-driven: blocked-ness is
    decided by observing pg_stat_activity lock waits, not elapsed time;
    there are no grace sleeps, and paused writers are released in finally
    blocks so a failing assertion cannot wedge fixture teardown. The lane
    adds under a second of CI time.
    
    Ablation matrix (each lock removed one at a time via source-patched
    variant trees, PYTHONPATH-shadowed; old = pre-overhaul partitioned
    store at 14b8f0a2~1):
    
    - write pin ablated (either generation): write-pin test fails, plus
      the no-orphaned-writes fencing test on the new store.
    - delete pin ablated (new store): churn, concurrent-delete, and
      single-queue-entry tests fail (double-enqueue IntegrityError).
    - delete row pin ablated (old store): write-pin test fails (the delete
      no longer waits out the in-flight writer).
    - ordered delete_segments row locks ablated (either generation): no
      test fails -- identical DELETE shapes lock rows in identical orders
      on PostgreSQL (sorted scalar-array probes, TID-ordered bitmap scans),
      so the AB/BA cycle needs plan divergence the store never produces.
      The overlap test is kept as a regression canary and documented as
      such; whether to keep the pre-lock itself is a separate decision.
    - old store with ALL locks intact: churn and concurrent-delete tests
      fail with DeadlockDetectedError in the two cycle shapes documented
      on #1546 (delete-vs-delete lock upgrade over the table mutex;
      create-vs-delete DDL cycles through the shared parents). Those
      deadlocks are inherent to the partitioned layout -- the property the
      shared-table overhaul removes, and these tests now pin.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: purge claims queue entries with SKIP LOCKED; correct lock-order rationale
    
    Concurrent purgers were a real deadlock surface: two processes draining
    the same dead incarnation delete overlapping row sets through unordered
    scans. Claiming queue entries with FOR UPDATE SKIP LOCKED removes the
    contention instead of ordering it -- racing purgers partition the queue,
    and only the claiming call touches a dead incarnation's rows (writers
    cannot; the fence pins live incarnations only), so reclamation is
    deadlock-free by construction. This is the claiming half of the purger
    scale-out design in the design doc; the ABC now states the contract
    (concurrent calls, including cross-process, must neither error nor
    deadlock).
    
    Tests: test_purge_skips_entries_claimed_by_concurrent_purger stages a
    purger from another process holding its claim uncommitted -- a
    concurrent purge must skip the entry and complete without blocking;
    verified to fail (blocks on the held queue row) with the claim ablated
    and pass with it. test_concurrent_purges_reclaim_everything pins the
    correctness property on both dialects: racing drain loops terminate
    cleanly with full reclamation.
    
    Also rewords the ordered-row-lock rationale in the locking suite: the
    consistent acquisition order that makes the ablation unobservable is
    current PostgreSQL executor behavior, not a guarantee any engine
    documents -- the pre-lock imposes the order deliberately, and the canary
    catches divergence if an engine or plan change ever produces it.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * test: accept any task result type in _wait_until_blocked_or_done
    
    The helper only observes done-ness; Task[None] rejected the purge
    task (Task[bool]).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: reject minting an incarnation whose garbage is still awaiting purge
    
    Data rows are keyed by incarnation alone, so a fresh mint colliding with
    a dead-but-unpurged incarnation would adopt its garbage and then be
    erased by the purger. The registry's unique constraint only guarded
    collisions with live incarnations; the purge-queue case was guarded by
    uuid randomness alone.
    
    The mint (shared by create_partition and open_or_create_partition) now
    re-checks the purge queue inside the insert transaction and re-mints on
    collision. The check is race-free with the existing tables -- no ledger
    table needed: it runs after the registry insert, so a concurrent
    deletion moving a colliding row to the queue (the insert waited on its
    uncommitted registry delete) is already visible, and no new queue entry
    for the minted value can appear before commit because the only registry
    row carrying it is uncommitted. The locking read sees latest-committed
    state on dialects whose plain reads serve transaction-start snapshots;
    SQLite serializes whole transactions. An incarnation value can therefore
    never be reused while any trace of it remains within a database; across
    databases, uniqueness still rests on random-uuid collision resistance.
    
    test_incarnation_with_garbage_left_is_never_reused forces the collision
    by stubbing the mint (both creation paths, both dialects); verified to
    fail with the re-check ablated and pass with it.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: one collision error for live and garbage incarnation mints
    
    Both collision causes look the same to the mint's callers and share the
    same remedy -- mint a fresh incarnation and retry -- so they now share
    one error, with the cause classification (key taken vs incarnation
    collision) resolved inside _insert_partition_row: an IntegrityError with
    a committed row under the key means the key is taken
    (SegmentStorePartitionAlreadyExistsError: open or delete it instead);
    without one, the incarnation collided with a live row. Errors are typed
    by the decision the caller makes, not by the failing constraint, and
    both call sites shrink to one remedy branch per error.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * test: fold locking tests into the segment store test file; drop "slice"
    
    All SQLAlchemy segment store tests live in one file. The separate
    locking module existed so the same tests could import against the
    pre-overhaul partitioned store for the lock-ablation matrix; that
    verification is done and recorded, so the split's constraint is spent.
    The per-lock coverage map moves to a section comment.
    
    Also replaces the "one slice per call" purge wording, which was
    circular (a slice being defined as whatever one call does), with the
    actual contract: each call reclaims at most max_segments segments --
    in this store, one transaction that commits that progress or nothing --
    and None means the store's default bound (_DEFAULT_PURGE_MAX_SEGMENTS,
    renamed from _PURGE_SLICE_SEGMENTS).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: correct design-doc drift; drop dead _is_postgresql flag
    
    Accuracy review of the design doc against the code:
    - The create bullet said "one row insert"; the mint transaction also
      re-checks the purge queue.
    - The locking model omitted the purger's SKIP LOCKED queue claims and
      the mint's collision-case queue pin; it now lists every row lock and
      why reclamation cannot contend with anything.
    - The consequences section claimed the only remaining dialect split is
      the LATERAL-vs-loop read strategy; the PostgreSQL-only ordered row
      locks in delete_segments and SQLite's foreign-key pragma are splits
      too.
    
    _is_postgresql was assigned and never read -- dead since the overhaul
    removed the DDL branches.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * feat: make the default purge bound a store construction parameter
    
    SQLAlchemySegmentStoreParams.default_purge_max_segments (default 10000)
    replaces the module constant: each purge call is one transaction, so
    the right default bound is dialect- and deployment-dependent, and the
    construction parameter lets an application set it once instead of
    every purge caller reading configuration to pass max_segments.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * test: cover live-incarnation collision and purge-bound params wiring
    
    Coverage audit of the recent additions found two unpinned paths:
    
    - The live half of the mint's collision handling (registry unique
      violation classified by the key re-read, then re-mint) had no test --
      only the garbage half did. test_incarnation_colliding_with_live_
      partition_is_never_reused forces the collision on both creation paths
      and both dialects; verified to fail with the classification ablated
      (create_partition misreports AlreadyExists) and pass with it.
    - The purge tests patched the store's default-bound attribute directly,
      leaving the SQLAlchemySegmentStoreParams.default_purge_max_segments
      wiring itself untested. test_default_purge_bound_comes_from_params
      constructs a store with a small configured bound and observes it
      govern an unbounded purge call.
    
    Also converts the two override-method docstrings (delete_partition,
    purge_deleted_partitions) to body comments: the contract lives on the
    ABC; overrides keep only implementation mechanics.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: rename _logical_partition_key to _partition_key
    
    The "logical" qualifier contrasted with the physical partition key of
    the composite-key era; data rows now carry no key at all, so there is
    nothing physical to distinguish from.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: rename PurgeRow to PurgeQueueRow
    
    The model classes are named for what a row represents (PartitionRow,
    SegmentRow, DerivativeLinkRow); a segment_store_gc row is not a purge
    but an entry of the purge queue.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: restore @staticmethod on _resolve_segment_field
    
    It became an instance method when field resolution went through the
    handle's per-partition aliased entities; the shared-table overhaul
    resolves against the module-level SegmentRow again, leaving self
    unused. Call sites return to the original class-qualified form.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * test: pin the mint's insert-then-check statement ordering
    
    The collision guard relies on checking the purge queue AFTER the
    registry insert: under READ COMMITTED, the insert's unique-index wait
    on a concurrent deletion's uncommitted registry delete is what forces
    that deletion's queue entry to be committed -- and therefore visible to
    the later check. Checked before the insert, the queue is read too
    early and the mint commits a live partition whose incarnation is on
    the purge queue, handing its rows to the purger.
    
    Only a concurrent interleave distinguishes the orderings, so the
    sequential collision tests cannot pin it: verified by swapping the two
    statements -- the sequential tests all still pass (the opposite order
    is correct for non-concurrent use), while the new
    test_mint_detects_collision_with_concurrent_deletion fails (it is
    incorrect for concurrent use). The test stages the interleave
    deterministically: a raw-session deletion held uncommitted, the
    colliding mint observed blocking on it via pg_stat_activity, then the
    deletion committed.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: plain "maximum number of segment rows purged per call" wording
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: match params docstring to the pydantic field description
    
    Convention in the class: the Attributes entry carries the field
    description plus the default; the field expresses the default via its
    default attribute only.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: purge claims queue entries one at a time
    
    The claim SELECT had no limit: it materialized and row-locked every
    unclaimed queue entry even when max_segments exhausted on the first
    incarnation -- a mass-deletion backlog was fetched wholesale per call,
    and the first purger claimed the entire queue, so concurrent purgers
    skipped everything and exited instead of sharing the backlog.
    
    Claims are now LIMIT 1 FOR UPDATE SKIP LOCKED, issued as the call
    processes entries: a bounded call locks exactly what it works on.
    Within the transaction each claimed entry is retired before the next
    claim, so the call's own claims (which SKIP LOCKED does not skip)
    cannot recur and the loop terminates.
    
    test_purge_claims_queue_entries_incrementally pins the property via
    recorded SQL: every queue claim carries LIMIT, and a call whose bound
    exhausts on its first incarnation issues exactly one claim; verified
    to fail against the previous claim-all form.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: batch, not chunk, for the purge deletion unit
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: purge runs on an engine connection for typed rowcount
    
    AsyncSession.execute has no DML overload -- it is typed Result[Any] for
    every non-typed statement, so reading rowcount needed an isinstance
    narrowing to CursorResult (whose unreachable else-branch would have
    fabricated a zero count). AsyncConnection.execute is typed CursorResult
    in every overload, and the purge transaction is pure Core DML with no
    session features, so it now runs on self._engine.begin(): the library's
    own annotations carry the type and the narrowing disappears.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: manual formatting
    
    Signed-off-by: Edwin Yu <edwinyyyu@gmail.com>
    
    * fix: address code-review findings (round 3)
    
    Six confirmed-or-verified defects from a second review session, each
    fixed with a test verified to fail on the pre-fix code:
    
    - SQLite write fence was a no-op: the driver defers BEGIN to the first
      data-modifying statement, so the fence SELECT ran outside the write
      transaction and a write racing a delete-plus-purge committed rows no
      queue entry tracked. The write fence (and deletion's row check) now
      issue a no-op registry UPDATE first, opening the write transaction so
      the check is transactional and racing deletions serialize.
    - The shared column-leaf UTC normalization silently changed OTHER
      stores' datetime filters: their write paths still store wall clock,
      so on SQLite their filters stopped matching rows they had just
      written. Normalization is now an explicit compile_sql_filter opt-in
      (column_datetimes_are_utc) that only the segment store sets; other
      stores regain their previous behavior, and #1462 flips the opt-in
      for the stores whose write paths it fixes.
    - Mint collision retries were unbounded: any persistent IntegrityError
      with the key absent became an infinite hot loop. Both creation paths
      cap consecutive collision retries (_MAX_MINT_ATTEMPTS) and re-raise
      the underlying error -- consecutive failures at that depth mean a
      permanent cause, not a race.
    - purge_deleted_partitions accepted non-positive bounds and returned
      True unconditionally, spinning the documented drain loop; it now
      raises ValueError. Empty incarnations charge one segment of budget,
      so a backlog of empty tenants is bounded per call instead of drained
      in one unbounded transaction.
    - open_or_create committed the registry row before materializing the
      payload codec, leaving an unopenable partition behind on codec
      failure; the codec is loaded before the insert again.
    - validate_partition_key used re.match with $, accepting keys with a
      trailing newline; now re.fullmatch.
    
    Also from the review: the ABC documents the stale-handle contract on
    SegmentStorePartition and corrects purge's False semantics (work owned
    by a concurrent purger is not counted); the blocked-or-done test helper
    scopes pg_stat_activity to the current database.
    
    Rejected findings, with grounds recorded in the PR discussion: the
    read-fence round trip is the deliberate loud-fencing contract (#1549);
    fence/live-check unification, the forensic enqueued_at column, and
    FIFO claiming are declined as taste; the partition-key rule's overlap
    with service_locator stays per the incidental-convergence ruling.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: LongTermMemory erasure drains the purge queue inline
    
    delete_partition's physical reclamation is deferred by design, but the
    review found its one production caller now leaked: session deletion
    previously removed data physically (DROP on PostgreSQL, cascade on
    SQLite) and nothing anywhere called purge_deleted_partitions. The
    erasure path drains the queue inline before returning, restoring
    physical removal semantics; background scheduling remains available to
    other callers.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: UTC-normalize every SQL store; self-checking SQLite fence; FIFO purge
    
    Four follow-ups to the review round, per direction:
    
    - The datetime-normalization opt-in is gone: instead of scoping the
      shared compiler's UTC bound normalization to the segment store, every
      SQL store's write path is fixed honestly in this PR. #1462's episode
      and cluster fixes (created_at + start/end bounds; last_ts + pending
      created_at) are ported with their regression tests, and the compiler
      normalizes column datetime bounds unconditionally -- correct for all
      consumers, since the semantic-storage columns it also serves are
      server-generated UTC (func.now()). Fixes #1558 and #1559 here.
    
    - The SQLite fence is one self-checking statement instead of a no-op
      UPDATE plus a SELECT: the proper primitive, BEGIN IMMEDIATE, is only
      expressible engine-wide in SQLAlchemy (it would put every read
      transaction behind the write lock), so the registry-row UPDATE
      acquires the same write lock scoped to the transaction, and its match
      count is the staleness check. Deletion opens its transaction the same
      way, with zero matches as the idempotent no-op case.
    
    - The purge queue is FIFO: claims order by enqueued_at (indexed), so
      the oldest garbage is reclaimed first and the name is honest. Queue
      entries carry their own per-call bound
      (SQLAlchemySegmentStoreParams.purge_max_partitions, default 1000)
      instead of charging a fake segment of budget: their cost is round
      trips rather than row deletions, and empty partitions are cheap to
      mass-create-and-delete, normally or adversarially. Empty entries no
      longer consume max_segments.
    
    - PostgreSQL-only concurrency coverage gains SQLite counterparts
      wherever the property exists on both dialects: lifecycle churn,
      racing deletions (plus a single-enqueue assertion), overlapping
      segment deletes now run on both; new SQLite tests pin the
      mint-vs-deletion collision race and O(1) deletion via recorded SQL.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: precise rationale for the SQLite fence primitive
    
    BEGIN IMMEDIATE is expressible per-transaction in principle, but only
    atop engine-wide rewiring (isolation_level=None plus a begin-event
    hook) that the store cannot apply to a caller-owned, possibly shared
    engine; say that instead of "only expressible engine-wide".
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: two-character index name tokens, matching the prior convention
    
    pk_ev / pk_ts_ev_bk_ix / pk_su used two characters per indexed column;
    in (incarnation) and ea (enqueued_at) follow, replacing inc and enq.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor!: purge takes no arguments; cap derivatives at ingestion
    
    purge_deleted_partitions() -> bool. Callers cannot know
    engine-appropriate transaction sizing -- the same argument that made
    the default a construction parameter removes the per-call override:
    the caller's whole protocol is "call until False", and every bound
    (purge_max_segments, renamed from default_purge_max_segments;
    purge_max_partitions) is implementation policy set once at
    construction. Non-positive bounds are now impossible by pydantic
    validation, superseding the runtime ValueError.
    
    The derivative side is bounded where it is created, not where it is
    reclaimed: purge keeps relying on the link-table ON DELETE CASCADE --
    benchmarked against manual link deletion on the real schema and 50-68%
    faster (1 link/segment: ~312k vs ~209k segs/s; 4 links: ~266k vs
    ~158k; the manual pattern's extra round trips and array shipping cost
    more than the per-row indexed trigger probes) -- and ingestion rejects
    more than max_derivatives_per_segment links per segment (default 100),
    so one purge call's work is at most purge_max_segments segment rows
    plus that many times the cap in link rows.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * revert: drop the store-level derivatives-per-segment cap
    
    The cap rejected at add_segments time, when the caller has already
    segmented and derived and can do nothing to obey it -- the bound on
    link fan-out is ingestion-pipeline policy (deriver/segmenter design),
    not a store contract. Performance also gives the cap no case: measured
    across densities, cascade deletion saturates around 3M link rows/s
    (380k segments/s at one link per segment, 302k at 4, 175k at 16, 46k
    at 64 -- per-row cost FALLS with density, 1.3us/row at 1 link to
    0.34us at 64), so a purge_max_segments=10000 call finishes in ~0.45s
    even at 64 links per segment. The design doc records where the bound
    lives and the measured sensitivity.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: state the purge contract's promise to callers
    
    The bounds are implementation policy; what the caller is promised is
    that a purge call does not noticeably degrade concurrent request
    serving. The design doc also records why a store-level link cap would
    be unactionable (only the deployment's segmenter/deriver choice can
    change the ingested shape, so a dedicated error type would have no
    useful handler).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: rebalance the purge entry bound to measured cost
    
    Retiring an empty queue entry measures ~0.95 ms through the store
    (four round trips), roughly 200x a segment row at the measured purge
    rate -- not the ~10x the old default implied. purge_max_partitions
    drops from 1000 (a ~0.95 s transaction when saturated, 20x the row
    bound's ~46 ms) to 50, putting a full-entry call and a full-row call
    at comparable transaction duration. Backlog drain throughput is
    unchanged (~1k entries/s regardless of slicing); only per-call
    transaction length shrinks.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: public SegmentStorePermanentError; power-of-ten entry bound
    
    Mint-retry exhaustion raised "last_collision.__cause__ or
    last_collision" -- expedient plumbing that leaked either the wrapped
    SQLAlchemy IntegrityError or the private collision type to callers.
    Per the error-design principle (type by the caller's decision), the
    decision here is "retrying will not fix this; diagnose", so both
    creation paths now raise the ABC-declared SegmentStorePermanentError
    with the underlying error chained as the cause. The ABC documents it
    on create_partition and open_or_create_partition.
    
    purge_max_partitions defaults to 100 instead of 50: sibling fields of
    one config keep to the same numeric family (powers of ten, alongside
    purge_max_segments=10000); a saturated entry call (~95 ms measured)
    and a saturated row call (~46 ms) stay within the same order of
    transaction duration.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: rename SegmentStorePermanentError to SegmentStoreRetriesExhaustedError
    
    "Permanent" asserted a diagnosis the store cannot make -- sustained
    adversarial churn could in principle clear on a later attempt. The
    name now states only what happened (internal retries exhausted), with
    the guidance phrased as likelihood: an immediate retry is unlikely to
    succeed; diagnose the chained cause.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: drop illustrative examples from contract docstrings
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: bound open_or_create's lost-race arm with the same retry cap
    
    The collision arm was capped but the AlreadyExists arm looped
    unboundedly -- the reviewer's livelock finding. Both non-terminating
    outcomes now count toward one retry budget, and exhausting it raises
    SegmentStoreRetriesExhaustedError with the last error chained. With
    this, every retry construct in the store is bounded: purge makes
    guaranteed progress per call, deletion is a single idempotent
    transaction, fences raise stale, and reads are single-pass -- the
    creation paths were the only sites with retries to exhaust.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * refactor: attempts, not retries
    
    SegmentStoreAttemptsExhaustedError, with the counter and docstrings
    using the same word: "retry" is ambiguous between a re-attempt and the
    whole attempt sequence, and _MAX_MINT_ATTEMPTS already counted
    attempts.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: attempts vocabulary in the mint-exhaustion message
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * nit: increase max mint attempts from 8 to 10
    
    Signed-off-by: Edwin Yu <edwinyyyu@gmail.com>
    
    * nit: manual formatting
    
    Signed-off-by: Edwin Yu <edwinyyyu@gmail.com>
    
    * review: fold the read fence into the data statement; harden creation, drain, purge
    
    Third review round (15 findings; 12 acted on, 3 declined with grounds
    in the PR body).
    
    - Reads no longer issue a separate registry round trip: the liveness
      predicate rides in each data statement as an EXISTS conjunct (one
      statement, one snapshot -- a stale handle reads nothing), and the
      registry check is issued on its own only when a read returns no rows,
      to tell an empty partition from a stale handle. The write fence and
      the read check share one query builder (`_registry_row_query`) and
      one checker (`_ensure_partition_live(pin=...)`).
    - `create_partition` materializes the payload codec before inserting,
      like `open_or_create_partition`; its mint loop uses the same
      attempt-counter idiom and message as the other path, which also
      removes the possibly-unbound `last_collision`.
    - `drop_session_partition` nulls its handles before the inline drain,
      so a drain failure cannot leave them pointing at deleted resources;
      the drain's comment states exactly what it guarantees (the queue is
      global, the drain uncapped, and an entry a concurrent drain claimed
      is finished by that drain).
    - The purge queue's enqueue stamp is the database clock (`now()`), so
      every server's entries order on one clock; the unreachable
      `remaining <= 0` guard is gone; the purge comment and design doc
      state SQLite's actual claiming behavior (plain read, serialized on
      the database write lock at the DELETE; duplicated round trips only).
    - `startup()` refuses the old partitioned layout (registry without the
      incarnation column) with a directive to recreate the schema, instead
      of letting create_all leave the old tables in place for an opaque
      missing-column error later.
    - Cluster store reads use the shared `ensure_tz_aware`; the private
      clone is deleted. Contract wording: "every data operation" raises
      the stale-handle error (the config property never did).
    
    Tests: unloadable codec guard parametrized over both creation paths,
    FIFO pinned with explicit stamps set against insertion order, the
    database-clock stamp and the folded liveness check pinned via recorded
    SQL, the startup probe on both dialects, and the LTM nulling order
    under a failing drain. The codec and nulling tests were each verified
    to fail with their fix ablated.
    
    Read-path ABAB against the previous HEAD (interleaved rounds, medians):
    seed context reads 1.17 vs 1.42 ms, event lookups 1.04 vs 1.61 ms,
    derivative lookups 1.05 vs 1.33 ms (5 rounds), windowed context
    expansion 8.74 vs 9.67 ms (8 rounds x 600 reps, paired median
    -0.91 ms); reads that find nothing unchanged (two statements either
    way). Server-side EXPLAIN ANALYZE: the EXISTS conjunct plans as a
    one-time InitPlan (~3 us per statement); a windowed read's 3
    statements execute in 0.069 ms vs the previous 4 statements' 0.067 ms.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: purge bound scales with link fan-out; queue stamp is transaction time
    
    Follow-up notes from the review session: the purge_max_segments
    description says its derivative links cascade uncounted, so a call's
    transaction also scales with the deployment's links per segment (the
    promise in the ABC is kept by sizing this bound with that fan-out in
    mind, which is the deployment's knob, not the caller's); the enqueue
    stamp comment records that PostgreSQL's now() is transaction-start
    time and that one deletion per transaction makes it one stamp per
    entry.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: a deleted partition's handle is permanently invalid
    
    "Obtain a fresh handle to continue" read as if deletion-and-recreation
    were a routine flow; the contract is simply that deletion permanently
    invalidates the handle, including against a later same-key creation.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * revert: drop the old-layout startup probe
    
    Handling pre-existing partitioned-layout deployments is out of scope
    for the opt-in, pre-GA event backend; existing databases recreate
    their schema, and startup stays a plain create_all.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * nit: slim the purge claiming comment
    
    The code comment keeps only the invariants the loop relies on; the
    full rationale stays in design/segment_store_shared_tables.md, which
    the comment now points at.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * nit: params docstring matches field descriptions, defaults at the end
    
    The purge bounds' field descriptions carry the full text and the
    docstring repeats them verbatim, with (default: N) moved to the end
    of each description per the params convention.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * review: second-round fixes across locator, stores, and race tests
    
    Second review round from the local review session (9 findings; 7
    acted on here, the background-purger suggestion lands separately, and
    the unbounded link-retire guard is kept with its tradeoff stated in a
    comment -- bounding it would add a budget for a case that indicates a
    broken schema).
    
    - partition_key_for_session validated with a drifted private copy of
      the store's key contract: its re.match passed a trailing-newline
      session id through unhashed, and the store's re.fullmatch then
      hard-rejected it, failing session creation where hashing would have
      succeeded. The copy is deleted; the locator (and its tests) now call
      the store's own validate_partition_key, and the hash slice length
      comes from the now-public PARTITION_KEY_MAX_BYTES, so the two can
      never disagree again. Regression test verified to fail pre-fix.
    - The SegmentStorePartition contract states that a call with empty
      input does no work and returns without checking the handle -- the
      empty-set guards return before any fence, which the docstring's
      "from then on" overstated.
    - delete_partition on SQLite resolves the incarnation in the pin
      UPDATE itself via RETURNING; the locking select is PostgreSQL's path
      only, removing SQLite's extra round trip and its unreachable
      row-is-None branch.
    - _open_or_create_partition loads the payload codec only on the create
      path (still before any registry write); opening an existing
      partition no longer materializes a codec it discards.
    - Episode-store reads use ensure_tz_aware instead of an inline clone
      in the same file that imports it for writes.
    - The purge's link-retire guard comment states it is normally a
      zero-row delete and unbounded only if referential integrity was
      actually broken.
    - The two SQLite race tests gained started-events proving the racing
      task ran before the sample, so a loaded box cannot pass them
      vacuously by never scheduling it; the remaining grace periods are
      annotated (SQLite exposes no lock-wait state to observe).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * feat: background purge tick in the resource manager
    
    Nothing but the inline drain in drop_session_partition ever called
    purge_deleted_partitions, so a drain interrupted by a crash or a
    dropped connection left its queue entry (and the partition's rows)
    waiting for the next session deletion anywhere in the deployment.
    
    The resource manager -- the component that owns each segment store --
    now runs one background task per store: one bounded purge call per
    fixed tick, exceptions logged and retried next tick, cancelled in
    close() before the stores shut down. One call per tick keeps the
    background work bounded by construction (a backlog drains over
    successive ticks), and no purger coordination is needed at any
    instance count because the store's claiming already makes racing
    purgers safe. The store itself still never schedules reclamation;
    this loop is the caller-side scheduler the ABC contract calls for.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: purge loop reads the backlog signal
    
    purge_deleted_partitions() returning True is the API's statement that
    more work remains; discarding it drained a backlog at one bounded
    call per tick (~167 rows/s at the defaults). The loop now runs
    bounded calls back-to-back while the store reports more and sleeps
    one tick only when it reports done or a call fails -- full-rate
    recovery, still bounded per call, still one idle call per tick.
    Pinned by a test that drains a three-call backlog under a
    deliberately huge tick interval.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: empty-input calls MAY skip the handle check
    
    The contract permits the shortcut rather than mandating it; an
    implementation that checks anyway still conforms.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * test: pin the config-mismatch guard directly
    
    The guard, its error type, and the ABC declaration predate this
    branch, but no test staged a mismatch -- only the lifecycle-churn
    test tolerated it as a domain outcome. Plaintext is the only concrete
    codec config, so the test stands in a subclass for a future variant
    (pydantic instances survive validation unrevalidated and compare
    unequal by class). Verified to fail with the guard ablated.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * purge: bound the integrity-escape link delete; warn on it and on collisions
    
    The retire-path guard delete was the one unbounded statement in the
    purge, unbounded precisely when it was not a no-op. It is now batched
    under the same per-call budget as the segment rows: a full batch
    leaves the queue entry for the next call (the existing call-until-
    False contract absorbs it, callers unchanged), the normal case still
    costs one zero-row statement, and reclaiming rows there logs a
    warning naming the incarnation, since it means referential integrity
    failed somewhere. Pinned by a test that stages orphan link rows
    through a second SQLite engine without the foreign-key pragma and
    drains them in warned batches; verified to fail against the unbounded
    form.
    
    The module's logger also gains the only other events worth an
    operator's attention: a minted incarnation colliding (with garbage or
    in the registry) is warning-logged at the detection site -- a genuine
    collision is astronomically unlikely, so the log marks either broken
    randomness or a misclassified persistent database error, visible even
    when retries eventually succeed. Everything else either raises to the
    caller or is normal operation, and stays unlogged.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: shared purge budget is measured, not a guessed ratio
    
    Measured with the purge's batched-delete shape on 100k rows each
    (3 interleaved rounds): a segment row deletes at ~3.3 us and a
    derivative-link row at ~1.0 us, so link rows are about 3x cheaper --
    they are narrower, carry fewer indexes, and fire no cascade. That is
    why integrity-escaped links draw count-for-count on the segment
    budget instead of getting their own limit: one budget calibrated on
    the most expensive row type upper-bounds the call, whereas a separate
    link limit would be safe only under an assumed cost ratio.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: the row-cost direction is the shared budget's precondition
    
    The shared purge budget stays conservative only while a link row
    deletes cheaper than a segment row; widening the link table or adding
    indexes to it revisits the choice.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * review: pace the purger, state the drain's real guarantee, close cleanly
    
    Round-3 findings 1, 2, 5 and 6 -- the first three introduced by the
    background purger itself.
    
    - The purge loop now pauses briefly after every productive call
      instead of running delete transactions back-to-back: the pause
      yields the database (and SQLite's single write lock) to request
      serving, while a backlog still drains at one bounded call per pause
      and an idle store costs one call per tick. This also removes the
      in-process busy-timeout window between the background task and the
      inline drain on SQLite.
    - The inline drain's comment claimed "the server schedules no other
      purger", which the purger commit falsified, and "reclaimed before
      returning", which SKIP LOCKED claiming never strictly guaranteed
      under any concurrent purger. Comment, design doc, and PR body now
      state the actual promise: rows are reclaimed promptly -- normally
      before the drain returns, and otherwise within the bounded call of
      whichever purger claimed the entry, moments later.
    - close() clears the purge-task list and the store registry, so a
      second close is a no-op and a post-close get_segment_store can no
      longer hand back a shut-down store that silently never purges.
    - The design doc no longer implies deployments can already tune the
      purge bounds through server configuration: the server constructs
      its stores with the defaults, and config plumbing is future work.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * review: one datetime-normalization rule per filter path
    
    Round-3 findings 3 and 11.
    
    - The properties_json In branch bound raw values while its Comparison
      sibling normalized through _cast_properties_json_value; a datetime
      In list would bind datetime objects against the stored ISO-string
      form (an InterfaceError on Python 3.14's sqlite3, a never-matching
      comparison on PostgreSQL). Both leaf shapes now cast and normalize
      through the one function, which also aligns the float and bool
      casts the old branch fell through to as_string/as_integer. Same
      defensive-reachability status as the column-leaf In normalization
      kept deliberately: unreachable by In's declared value types,
      reachable at runtime.
    - The episode store's start_time/end_time bounds re-implemented the
      UTC normalization inline; sql_filter_util's normalize_column_value
      is now public and both bounds use it, so the storage convention has
      one definition across compiled filters and dedicated bounds.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * review: StaticPool guard raises; empty add_segments short-circuits
    
    Round-3 findings 9 and 10.
    
    - The params validator's StaticPool guard was a bare assert, stripped
      under python -O -- and the design depends on multiple connections
      (the registry fence, deletion waiting out writers, SKIP LOCKED
      claiming all degrade on one shared connection). It now raises
      ValueError like the ephemeral-SQLite check beside it; pinned by a
      test, and the check stays a ValueError because pydantic converts
      only ValueError/AssertionError into a ValidationError.
    - add_segments returns early on empty input, matching delete_segments
      and the ABC's empty-input permission; previously it opened a
      transaction and, on SQLite, took the write lock to insert nothing.
      The stale-handle test pins the shortcut.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * filter: datetime values normalize to UTC at node construction
    
    The filter language now owns datetime semantics: a value denotes an
    instant, and a naive value means UTC. Comparison.__post_init__
    normalizes datetime values to UTC-aware instants (In gets the same
    defensively -- its declared types exclude datetimes, but runtime lists
    are unchecked), so every consumer -- parsed trees and programmatically
    built ones, SQL compilers and vector stores alike -- receives
    normalized instants by construction, and compilers only choose a
    representation.
    
    This is where the rule the recent fixes kept restating per leaf
    actually belongs: the same aware-to-UTC-or-naive-means-UTC conversion
    appeared in the SQL column leaf, the properties_json leaf, the episode
    bounds, and twice in the Milvus store, and two of the drifted copies
    were bugs fixed this round. With the invariant at the node, the SQL
    column leaf's re-normalization became redundant and is reverted (it
    binds tree values as-is); the properties_json leaf keeps its routing
    because datetime-to-ISO-string is representation, not normalization;
    the episode start/end bounds keep the shared helper because they are
    raw API values outside any tree; other backends' now-idempotent
    defenses are left for separate cleanup.
    
    Pinned by tests that a programmatically built Comparison and a parsed
    date() literal with a non-UTC offset both carry the UTC instant.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * filter: drop normalize_column_value; contract stated on the protocol
    
    With datetime normalization at node construction, the compiler-side
    helper had no filter role left, and its one remaining consumer -- the
    episode store's start/end bounds, which arrive outside any filter
    tree -- now spells the convention inline as the two explicit steps,
    ensure_tz_aware(...).astimezone(UTC). A composed to_utc() helper was
    considered and rejected: the name does not pin the naive-means-UTC
    tagging decision (an alternative design under the same name could
    reject naive datetimes entirely), so the explicit steps are clearer
    at each site.
    
    The FilterExpr protocol docstring now states the construction-time
    contract where the next value-carrying node's author will read it:
    such a node normalizes datetime values to UTC-aware instants, and
    compilers bind instants without re-normalizing.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: the two-step datetime spelling is deliberate
    
    Record the datetime convention in the design doc so a future cleanup
    does not consolidate the repeated ensure_tz_aware(...).astimezone(UTC)
    sequences back into the composed helper b636d62a deliberately removed:
    a name that pins only the conversion, not the naive-means-UTC tagging,
    hides a real design decision, so the repetition is load-bearing.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * docs: second ground for rejecting the composed datetime helper
    
    A shared helper earns its place only when the name honestly pins the
    unit AND the composition structurally prevents half-applied
    normalization. The second condition fails here regardless of naming:
    read paths legitimately need the tagging step alone (segment reads
    reapply the stored original offset; cluster and episode reads only tag
    naive database values), so ensure_tz_aware stays independently
    available and the helper could not have removed the partial-use error
    class.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
    * fix: SQLite foreign keys enforced from engine creation
    
    Round-4 findings 1, 4 and 5. The store registered its foreign_keys
    pragma as a per-store connect listener, which has two structural
    faults: connections the caller's shared engine pooled before the store
    existed never receive the pragma, so cascade deletes silently leave
    orphaned link rows for LIV…
    edwinyyyu and claude authored Sep 2, 2026
    Configuration menu
    Copy the full SHA
    7752e4c View commit details
    Browse the repository at this point in the history

Commits on Sep 3, 2026

  1. fix(vector-store): create payload indexes even when the collection ex…

    …ists (#1578)
    
    _create_native_collection wrapped create_collection and every
    create_payload_index in one try and swallowed "already exists" for the whole
    block. A collection that already existed therefore raised on the first call,
    took the already-exists path, and was left with no payload indexes at all -
    despite the docstring promising both were created idempotently.
    
    Two creators are easy to arrive at. The guarding lock is keyed on the
    AsyncQdrantClient object, so it serialises callers inside one process and
    nothing across them; with MEMMACHINE_WORKERS above 1 each worker has its own
    client and its own lock. A crash between the two calls leaves the same state.
    
    The collection and the indexes now sit under separate guards, and each index
    is created individually and tolerant of already-exists.
    
    Verified against Qdrant 1.19 in testcontainers. Before the change,
    test_indexes_are_created_when_the_collection_already_exists fails with an
    empty payload_schema - not a missing index, none of the twelve. After it, both
    new tests pass, along with the rest of the vector store suite: 293 unit and
    142 integration.
    
    Scope, checked rather than assumed: a filtered query on an unindexed
    collection returns only the matching tenant's points, so what a missing
    sys-partition_key index costs is the multitenant storage layout and query
    speed, not isolation. Note also that only the already-exists path reproduces;
    two clients creating simultaneously both succeed, which the second test pins.
    
    The tests need a real server and are marked integration - local-mode Qdrant
    ignores payload indexes, so the defect is invisible there and CI, which runs
    with `-m "not integration"`, will not exercise them.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Sep 3, 2026
    Configuration menu
    Copy the full SHA
    a2a1754 View commit details
    Browse the repository at this point in the history
  2. feat(config): default to no short-term or semantic memory, and infer …

    …the backend (#1580)
    
    A deployment that runs the server on its own, with a hand-written config and no
    chart, gets whatever the code defaults are. Those defaults assumed a full
    installation: short-term memory on, semantic memory on, and long-term memory on
    Neo4j. A lab running MemMachine for retrieval alone had to turn two things off
    and switch a third before it matched what it wanted.
    
    Short-term memory is now off unless asked for. Two changes were needed, not one:
    the field default, and the merge, which read True whenever a short_term_memory
    section existed at all - so the field default never applied to any real config.
    Configuring the block and enabling it are now separate statements.
    
    Semantic memory is off by default. Note this only decides the fully-configured
    case: _auto_disable_when_incomplete already forced it off whenever llm_model,
    embedding_model or the storage fields were missing, so the old default of True
    only took effect for a deployment that had wired all of them up.
    
    The long-term backend is now inferred from which fields a config fills in
    rather than defaulting. The two backends share no field names, so naming one is
    an unambiguous statement: a config pointing at a `vector_store` cannot mean the
    declarative backend, which has no such field.
    
    The discriminator itself is untouched, deliberately. Flipping `None ->
    declarative` would repoint every pre-discriminator config at Qdrant, against
    data sitting in Neo4j - and those configs name a `vector_graph_store`, so
    inference resolves them exactly as before. test_old_param_data_without_backend_
    loads_as_declarative still passes unchanged. Defaulting to event when nothing is
    named would not work either: such a config has no store ids, so the event
    backend cannot start.
    
    Two shipped configs relied on the old defaults and now state what they want, so
    that this change does not quietly alter them: locomo_config.yaml ran with
    short-term memory on, and deployments/helm configures both memories and would
    otherwise have shipped an llm_model and message_capacity that were never used.
    
    Verified: 1964 tests pass. Seven new tests cover the changed behaviour, and each
    was checked against the old code to confirm it fails there - which caught one
    that did not. Asserting the semantic default on a bare instance passes whatever
    the default is, because the auto-disable fires first; the test now supplies
    every required field so that nothing but the default decides.
    
    
    Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    wanghy73 and claude authored Sep 3, 2026
    Configuration menu
    Copy the full SHA
    8dd1c01 View commit details
    Browse the repository at this point in the history
Loading