Close plan/13 and point STATUS at the tree

Records what the six changes measured, and replaces the pick-up item -- which
was step 6 -- with plan/14, since there is nothing left in 13.

Two things a future session needs and cannot infer from the code. The VACUUM:
migration 43 compresses context_snapshot but Postgres does not return the disk
by itself, so until `VACUUM FULL actions` runs the storage win exists only on
paper and the table is temporarily larger, not smaller. And the gap: nothing
exercises the scroll behaviour in a browser, because the frontend has no test
runner, and prepend-and-restore-scroll is the part most likely to feel wrong
even when it is correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-17 14:25:48 +05:30
co-authored by Claude Opus 5
parent cf8ec22e8b
commit 4f067516de
2 changed files with 121 additions and 42 deletions
+45 -2
View File
@@ -164,8 +164,10 @@ additions the plan did not anticipate:
forgotten. Vectors are held as `array("f")` — 6 KB each, matching the column; forgotten. Vectors are held as `array("f")` — 6 KB each, matching the column;
a list of Python floats would have been eight times the plan's RAM estimate. a list of Python floats would have been eight times the plan's RAM estimate.
Remaining: **step 6, infinite scroll upward in `Play.jsx`.** The page load is **Step 6 landed on 2026-08-17**, along with everything else this plan left open — see
unchanged at 426.7 kB and is now the largest single read in the app. "Closing the plan" at the end. The page load is a window of 60 actions now: 62.6 kB on
a 600-action fixture, down from 606.0 kB, and no longer a function of the story's
length.
## Guardrails to add with this work ## Guardrails to add with this work
@@ -272,3 +274,44 @@ helps and does not solve it.
None of the numbers above required reading a single row of anyone's content: counts, None of the numbers above required reading a single row of anyone's content: counts,
`octet_length` sums and catalog sizes only. `octet_length` sums and catalog sizes only.
## Closing the plan, 2026-08-17
Everything above landed the same day the verification did.
| | before | after |
|---|---|---|
| page load, 600 actions | 606.0 kB | **62.6 kB**, and flat in story length |
| adventures index, 6 adventures | 469.7 kB | **0.3 kB** |
| `context_snapshot` stored | ~89 MB | ~43 MB (after a VACUUM) |
| a played turn | 6.4 MB (2026-08-16) | 123 kB |
**Step 6, the window.** `GET /adventures/{id}` returns the newest `ACTION_PAGE`
actions and the story's length; `GET /{id}/actions?before_id=` walks back from there.
Anchored on an action rather than an offset — the offset version breaks precisely when
a turn lands mid-scroll, handing the reader one action twice and hiding another — and
that choice is also what makes it survive the story tree, since it compares indices to
order a branch rather than treating them as positions.
**Byte assertions.** `tests/test_egress.py` now carries per-action budgets as well as
column guards, plus one test whose only job is to fail if the fixture ever gets too
small for the budgets to catch anything.
**Column projections.** `ACTION_LIST_COLUMNS` and `MEMORY_LIST_COLUMNS` name what a
list response renders, and the adventures index selects four columns instead of the
entity. That one was not just future-proofing: an Adventure carries seven text and JSON
columns the index never shows.
**The JSON vector column is gone**, and dropping it exposed a live bug — changing your
embedding model had stopped re-embedding the bank the day migration 38 shipped. See
`tests/test_embedding_model_switch.py`.
**Storage.** `context_snapshot` is zlib-compressed through a TypeDecorator
(`app/compression.py`, migrations 43–45), so the call sites never learned about it.
3.5x on real Postgres. **This does not shrink anything until `VACUUM FULL actions`
runs** — Postgres marks dropped columns rather than reclaiming them, and the backfill
leaves a dead tuple per row.
Not done: nothing exercises the scroll behaviour in a browser. The frontend has no test
runner, and prepend-and-restore-scroll is the part most likely to feel wrong even when
it is correct.
+76 -40
View File
@@ -18,36 +18,42 @@ project page points at (`docs/index.html`), not the service name in the blueprin
--- ---
## Needs a human ## Needs a human: one VACUUM after the next deploy
**The free tier's storage ceiling is closer than the egress work suggested.** The Neon Migration 43 compresses `context_snapshot`, and **Postgres does not hand the disk back
database is 99.6 MB of a 512 MB allowance and `actions.context_snapshot` is essentially on its own.** `DROP COLUMN` only marks a column dropped, and the backfill leaves a dead
all of it. See "Storage, which this plan did not cost" in `plan/13`. This is now the tuple per row, so `actions` gets *bigger* before it gets smaller — peaking near twice
most likely thing to break the deploy, ahead of anything on the read path. its size while both columns are live. Once the deploy is up and healthy, run once:
```sql
VACUUM FULL actions;
```
It needs exclusive access to the table and free space equal to the finished copy. On
the 2026-08-17 figures: 99.6 MB now, peaking near 200 during the migration, settling
around 53 afterwards, against a 512 MB tier. Skipping it is safe and simply leaves the
win unrealised — the database keeps working, it just stays large.
Same caveat applies to migration 42 dropping `memories.embedding` (4 MB).
--- ---
## Pick up here ## Pick up here
**`plan/13-memory-embedding-cost.md`, step 6 — infinite scroll upward in `Play.jsx`.** **`plan/14-phase-story-tree.md` — the tree itself.** `plan/13` is finished. Its design
is settled in `plan/14` and nothing about it has been built.
Opening a finished adventure is comfortably the largest single read in the app — a turn Two things from the egress work are worth carrying into it:
is now 122 kB, Insights 118 kB, the Memories drawer 24 kB. Measured on production
(2026-08-17), the largest real adventure is **607 actions and 589.5 kB in one
response**; the 426.7 kB the harness reports is a 200-action fixture whose actions are
about **twice as heavy as real ones** (994 B/action in production). So the fixture
overstates width and understates length — real stories get *longer* than it models,
which is the direction that hurts. The backend already has the
windowing primitives (`context/history.py`: `tail_range`, `slice_`, `count`), and
`GET /adventures/{id}/actions` exists. What is missing is a paged shape for it and a
`Play.jsx` that loads the newest turns and fetches older ones as the reader scrolls up.
Watch for: the story is a flat list today, and **the story tree replaces it** - **Paging already anticipates the tree.** `action_window` in `routers/adventures.py`
(`plan/14-phase-story-tree.md`). Paging that reads by *position from the end* survives anchors on an action id and orders by comparing `Action.index`, never by treating
that change; paging that assumes `Action.index` is a dense 0..n sequence does not. index as a position. A branch changes which actions are on the path, not how two of
them order, so the anchor survives; anything counting offsets would not.
- **Weigh new columns in bytes.** A tree adds parent/branch columns to `actions`, which
is already the table that fills the disk. `tests/test_egress.py` has byte ceilings
now — they will tell you.
After that: the tree itself. Its design is settled in `plan/14`; nothing about it has **Before the next deploy:** one `VACUUM FULL actions;` — see below.
been built.
--- ---
@@ -115,7 +121,41 @@ Migrations 39/40 add `memories.embedded`, migration 41 drops the capacity defaul
--- ---
## What happened on 2026-08-17 ## What happened on 2026-08-17, part two
Everything left open in `plan/13` closed, plus two bugs that fell out of doing it.
| shape | before today | after |
|---|---|---|
| page load, 600 actions | 606.0 kB | **62.6 kB** — and no longer grows with the story |
| adventures index, 6 fat adventures | 469.7 kB | **0.3 kB** |
| `context_snapshot` on disk | ~89 MB | ~43 MB (3.5x, after a VACUUM) |
| database total | 99.6 MB | ~53 MB projected |
**The story is a window now.** `GET /adventures/{id}` returns the newest 60 actions and
`action_count`; older pages come from `GET /{id}/actions?before_id=`. Anchored on an
action, never an offset — an offset counted back from the newest shifts every older
position the moment a turn lands, which is exactly when someone is scrolling. `Play.jsx`
prepends and restores scroll position in a `useLayoutEffect`, before paint.
**`context_snapshot` is compressed** (migrations 43–45, `app/compression.py`) via a
TypeDecorator, so every call site still reads and writes a dict. Verified end to end on
a throwaway Neon database: 720,864 B of JSON to 204,293 B of bytea, every row equal.
**The JSON vector column is gone** (migration 42) — and dropping it exposed that
changing your embedding model had silently stopped re-embedding the bank since
migration 38. The settings route cleared the dead column and left `embedded` true, so
`_embed_pending` never saw those rows and retrieval kept ranking against the old
model's vectors. Nothing reported it: `cosine` returns 0.0 on a width mismatch.
`tests/test_embedding_model_switch.py`.
**Byte ceilings exist** (`tests/test_egress.py`), including one test whose only job is
to prove the ceilings would catch something.
**List responses name their columns.** The index was loading whole Adventure entities —
seven text and JSON columns, ~15 kB a row — to render a title and a snippet.
## What happened on 2026-08-17, part one
No new behaviour — a verification pass on what shipped the day before, because every No new behaviour — a verification pass on what shipped the day before, because every
number in the section above had been measured on SQLite against a synthetic fixture. number in the section above had been measured on SQLite against a synthetic fixture.
@@ -169,25 +209,21 @@ with `pg_total_relation_size`, and do not mix them up.
--- ---
## Still open from `plan/13` ## `plan/13` is closed
- **Step 6, infinite scroll upward** — the pick-up item above. All six of its open items landed on 2026-08-17. What is left is not from that plan:
- **Query-count / byte assertions per endpoint**, extending `tests/test_egress.py`
against production-sized fixtures. `dbmeter` is importable from tests (`from tools
import dbmeter`) and was built with this in mind; nothing uses it there yet.
- **Explicit column projections on read paths**, so the next heavy column is opt-**in**.
Done for the memory paths, not as a general rule.
- **Drop `memories.embedding`** (the JSON column) in a follow-up migration. It is still
written by `set_vector` and read by nothing, kept so a rollback finds the vectors.
`tests/test_memory_retrieval.py` has a guard asserting nothing selects it. Measured
on production: dropping it reclaims 4.05 MB, 4% of the database.
- **`context_snapshot` and the 512 MB ceiling** — new, and now the biggest open item.
See the two sections named above. The egress case for leaving it in the database
still stands; the storage case does not.
Deliberately not taken: moving `context_snapshot` out of the database (~$0.02/mo, costs - **The VACUUM**, above. Until it runs, the storage win is on paper.
nothing on reads now that it is deferred), and pgvector (breaks the SQLite dev parity - **Nothing verifies the scroll behaviour in a browser.** The paging is covered by
this codebase protects on purpose). `tests/test_action_paging.py` and was exercised against a running backend, but the
frontend has no test runner and the prepend-and-restore is the part most likely to
feel wrong. Worth thirty seconds of scrolling a long adventure before trusting it.
- **`ACTION_PAGE = 60` is a guess.** It should be a page or two of reading. If loading
older turns feels like it interrupts, that is the number to move.
Deliberately not taken: moving `context_snapshot` out of the database entirely
(compressing it bought the same runway for a much smaller change), and pgvector (breaks
the SQLite dev parity this codebase protects on purpose).
--- ---
@@ -195,7 +231,7 @@ this codebase protects on purpose).
``` ```
cd backend cd backend
.venv/Scripts/python.exe -m pytest tests/ # 225 tests .venv/Scripts/python.exe -m pytest tests/ # 259 tests
.venv/Scripts/python.exe -m tools.stress_session # egress report (SQLite) .venv/Scripts/python.exe -m tools.stress_session # egress report (SQLite)
# Same harness against a real Postgres. The target must be a THROWAWAY database # Same harness against a real Postgres. The target must be a THROWAWAY database