Merge branch 'main' of https://github.com/parththakkar106/AI-DnD
This commit is contained in:
@@ -3,76 +3,77 @@
|
||||
[](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
An AI Dungeon-style interactive storytelling app you can run entirely on your own machine —
|
||||
with your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
|
||||
An AI Dungeon-style interactive storytelling app that runs entirely on your own machine, with
|
||||
your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
|
||||
world, and extend the engine with **JavaScript scripts compatible with real AI Dungeon
|
||||
scripting**.
|
||||
|
||||
> ### ▶️ Try it live: **[parththakkar106.github.io/AI-DnD](https://parththakkar106.github.io/AI-DnD/)**
|
||||
> The project page loads instantly and launches the hosted demo in one tap — play a scenario as
|
||||
> a guest, no sign-up and no API key needed. (The demo runs on a free tier that sleeps, so the
|
||||
> first load after it's been idle takes ~30–60s to wake up.)
|
||||
> The project page loads instantly and launches the hosted demo in one tap. Play a scenario as
|
||||
> a guest: no sign-up and no API key needed. The demo runs on a free tier that sleeps, so the
|
||||
> first load after it's been idle takes about 30 to 60 seconds to wake up.
|
||||
>
|
||||
> Want the internals? The **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**
|
||||
> walk through the context budgeting, the world-state referee and the memory bank, and state the
|
||||
> reasoning behind each one ([Markdown version](docs/GUIDE.md)).
|
||||
> For the internals, read the **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**.
|
||||
> They walk through the context budgeting, the world-state referee, and the memory bank, and
|
||||
> state the reasoning behind each one ([Markdown version](docs/GUIDE.md)).
|
||||
|
||||
Built with FastAPI + SQLAlchemy on the backend and React (Vite) on the frontend, running on
|
||||
SQLite locally and Postgres in the cloud. Works with **any OpenAI-compatible endpoint**: Ollama
|
||||
and LM Studio locally, or OpenRouter / OpenAI / Groq / vLLM in the cloud — endpoint, key, and
|
||||
model are all runtime settings, and OpenRouter's free-tier models make the whole experience $0.
|
||||
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, running on
|
||||
SQLite locally and Postgres in the cloud. It works with **any OpenAI-compatible endpoint**:
|
||||
Ollama and LM Studio locally, or OpenRouter, OpenAI, Groq, or vLLM in the cloud. Endpoint, key,
|
||||
and model are all runtime settings, and OpenRouter's free-tier models make the whole experience
|
||||
cost nothing.
|
||||
|
||||

|
||||
|
||||
*The play screen. The left rail is live world state — the AI proposes changes each turn and a
|
||||
Python engine decides what actually sticks. The chip under the narration reports what changed.
|
||||
The `‹ 2/2 ›` under a turn steps between the takes it has; writing below one that isn't the
|
||||
live one is what starts a new branch.*
|
||||
*The play screen. The left rail shows live world state. The AI proposes changes each turn, and
|
||||
a Python engine decides what actually sticks. The chip under the narration reports what
|
||||
changed. The `‹ 2/2 ›` under a turn steps between the takes it has. Writing below a take that
|
||||
isn't the live one starts a new branch.*
|
||||
|
||||
## Features
|
||||
|
||||
- **The full play loop** — Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
||||
retry, undo, and edit. Reasoning models supported: "thinking" streams into a collapsible 💭
|
||||
panel with its own token budget.
|
||||
- **A branching story tree** — the story is a tree, not a list. Any turn can hold more than one
|
||||
**take**; `‹ 2/4 ›` steps between them, and stepping is free — the story below simply empties,
|
||||
and the server is told nothing. **Writing below a take that isn't the live one is what makes a
|
||||
branch.** Branches borrow their ancestors' turns instead of copying them, so a fork costs about
|
||||
100 bytes and a 20-fork story loads within 1% of the same story flat; switching restores that
|
||||
line's world state, script state and cooldown clocks. A branch panel switches, renames and
|
||||
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
||||
retry, undo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
|
||||
💭 panel with its own token budget.
|
||||
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
|
||||
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
|
||||
and the server is told nothing. Writing below a take that isn't the live one is what makes a
|
||||
branch. Branches borrow their ancestors' turns instead of copying them, so a fork costs about
|
||||
100 bytes, and a 20-fork story loads within 1% of the same story flat. Switching restores that
|
||||
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
|
||||
deletes; **⌗ See the tree** draws every line against the story's own clock
|
||||
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||
- **An RPG world-state engine** — a scenario can declare stats, flags, milestones and a named
|
||||
cast; the adventure carries their live values. The design is **the AI proposes deltas and a
|
||||
Python engine referees them**: it clamps to range, enforces per-turn caps and cooldowns, keeps
|
||||
counters monotonic and milestones sticky, then strips the machine-readable block out of the
|
||||
prose (`backend/app/worldstate/engine.py`). Word-labelled bands (`40–60: minor damage`) are
|
||||
what make the model reliable at it. No dice, no scripting required.
|
||||
- **AI Dungeon-compatible context engine** — memory, author's note, and story cards (world
|
||||
info) triggered by keywords in recent story text, assembled under a token budget
|
||||
- **An RPG world-state engine.** A scenario can declare stats, flags, milestones, and a named
|
||||
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
|
||||
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
|
||||
monotonic and milestones sticky, then strips the machine-readable block out of the prose
|
||||
(`backend/app/worldstate/engine.py`). Word-labeled bands (`40–60: minor damage`) make the
|
||||
model reliable at it. No dice and no scripting are required.
|
||||
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
|
||||
info) are triggered by keywords in recent story text, then assembled under a token budget
|
||||
(`backend/app/context/builder.py`).
|
||||
- **Insights: total prompt transparency** — every turn stores the exact prompt sent to the
|
||||
model; open 🔍 on any AI action to see each context component, its token cost, and why it was
|
||||
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
|
||||
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
|
||||
included.
|
||||
- **JavaScript scripting, AI Dungeon-compatible** — `onInput` / `onModelContext` / `onOutput`
|
||||
modifiers with shared `state` and a `worldEntries` API, executed in an embedded quickjs
|
||||
sandbox (`backend/app/scripting/`). Real AI Dungeon scripts import and run. In-app
|
||||
CodeMirror editor included.
|
||||
- **Auto-summarization + Memory Bank** — the modern AI Dungeon memory system: AI-generated
|
||||
- **JavaScript scripting, AI Dungeon-compatible.** `onInput` / `onModelContext` / `onOutput`
|
||||
modifiers share `state` and a `worldEntries` API, and run in an embedded quickjs sandbox
|
||||
(`backend/app/scripting/`). Real AI Dungeon scripts import and run as is. An in-app CodeMirror
|
||||
editor is included.
|
||||
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
|
||||
memories every few actions, a running story summary, and embedding-based retrieval that
|
||||
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
|
||||
(`backend/app/memorybank.py`).
|
||||
- **Undo and retry that actually rewind** — undo and retry roll back the world state and script
|
||||
state to a per-node snapshot, not just the text, and prune the memories that covered the
|
||||
removed turns. Nothing a retry replaces is thrown away: the old attempt stays as another take
|
||||
of that turn, and is one keystroke and one click from being a branch of its own.
|
||||
- **Import/export** — AI Dungeon-compatible formats for scripts and scenarios; JSON for
|
||||
everything. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree —
|
||||
every branch, every take, and the fork points, because those were chosen rather than computed.
|
||||
- **Undo and retry that actually roll back state.** Undo and retry roll back the world state
|
||||
and script state to a per-node snapshot, not just the text, and prune the memories that
|
||||
covered the removed turns. Nothing a retry replaces is discarded: the old attempt stays as
|
||||
another take of that turn, one keystroke and one click from becoming a branch of its own.
|
||||
- **Import and export.** AI Dungeon-compatible formats for scripts and scenarios; JSON for
|
||||
everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
|
||||
every branch, every take, and the fork points, since those were chosen rather than computed.
|
||||
Files saved in the old single-line format still import.
|
||||
- **Optional accounts for hosted deployments** — by default the app is single-user with zero
|
||||
auth friction; set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed
|
||||
session cookie), can register (email + password) at any point to keep their data, and each
|
||||
- **Optional accounts for hosted deployments.** By default the app is single-user with zero
|
||||
auth friction. Set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed
|
||||
session cookie), can register (email and password) at any point to keep their data, and each
|
||||
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
|
||||
demo key** with a daily turn cap lets people try it without bringing a key
|
||||
(`backend/app/auth.py`).
|
||||
@@ -82,11 +83,11 @@ live one is what starts a new branch.*
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Insights** — the exact prompt for the next turn, broken into components with token counts and the trigger word that pulled each story card in. | **Authoring** — stats with ranges, per-turn caps, cooldowns and word-labelled bands; NPCs the AI addresses by id. |
|
||||
| **Insights**: the exact prompt for the next turn, broken into components with token counts and the trigger word that pulled each story card in. | **Authoring**: stats with ranges, per-turn caps, cooldowns, and word-labeled bands; NPCs the AI addresses by id. |
|
||||
|  |  |
|
||||
| **Scripting** — the three AI Dungeon hooks with shared persistent `state`, run in a quickjs sandbox. | **Home** — continue a story in progress or start from a scenario. |
|
||||
| **Scripting**: the three AI Dungeon hooks with shared persistent `state`, run in a quickjs sandbox. | **Home**: continue a story in progress or start from a scenario. |
|
||||
|  |  |
|
||||
| **The tree** — one lane per line, from the moment it left its parent to the moment it ends. The horizontal axis is the story's own clock, so a short branch reads as short. | **Branches** — every line the story has taken, and the three things you can do to one. A line the one you're reading was forked from can't be deleted, and says so. |
|
||||
| **The tree**: one lane per line, from the moment it left its parent to the moment it ends. The horizontal axis is the story's own clock, so a short branch reads as short. | **Branches**: every line the story has taken, and the three things you can do to one. A line the one you're reading was forked from can't be deleted, and says so. |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -127,8 +128,8 @@ Open **Settings** in the app and point it at any OpenAI-compatible endpoint:
|
||||
| OpenRouter | `https://openrouter.ai/api/v1` | `:free` models cost nothing (no embeddings on the free tier) |
|
||||
| OpenAI / Groq / vLLM / … | provider's `/v1` URL | anything speaking `/v1/chat/completions` |
|
||||
|
||||
Model name, API key, generation parameters, and (optionally) summary/embedding models for the
|
||||
Memory Bank are all configured there too — no config files, no rebuild.
|
||||
Model name, API key, generation parameters, and (optionally) summary and embedding models for
|
||||
the Memory Bank are all configured there too. No config files and no rebuild are needed.
|
||||
|
||||
## How a turn works
|
||||
|
||||
@@ -168,14 +169,14 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
|
||||
```
|
||||
|
||||
In production the backend serves the built SPA from one port (see `Dockerfile`); in
|
||||
development Vite proxies `/api` to FastAPI.
|
||||
In production the backend serves the built SPA from one port (see `Dockerfile`). In
|
||||
development, Vite proxies `/api` to FastAPI.
|
||||
|
||||
## Tests
|
||||
|
||||
497 backend tests — unit plus full HTTP integration through the real quickjs scripting engine,
|
||||
with the LLM provider mocked. CI runs them on every push, alongside the frontend lint/build and
|
||||
a Docker image build.
|
||||
497 backend tests: unit tests plus full HTTP integration through the real quickjs scripting
|
||||
engine, with the LLM provider mocked. CI runs them on every push, alongside the frontend
|
||||
lint/build and a Docker image build.
|
||||
|
||||
```sh
|
||||
cd backend && pip install -r requirements.txt -r requirements-dev.txt
|
||||
@@ -184,71 +185,73 @@ python -m pytest tests/
|
||||
|
||||
## Notes on performance
|
||||
|
||||
Two of the tests exist because of bugs that were measured rather than guessed at, and they're
|
||||
the most interesting engineering in the repo:
|
||||
Two of the tests exist because of bugs that were measured rather than guessed at. They are the
|
||||
most interesting engineering in the repo.
|
||||
|
||||
- **Database egress, cut ~189x.** Every adventure load was pulling `Action.context_snapshot` —
|
||||
the entire assembled prompt, ~74 KB per turn — to read two small fields off it. Moving those
|
||||
fields into their own columns and marking the heavy ones `deferred` took one adventure load
|
||||
from 38.5 MB to 0.20 MB. The backfill runs server-side with dialect-specific SQL so the old
|
||||
data never crosses the wire. `tests/test_egress.py` hooks into SQLAlchemy's cursor events and
|
||||
fails if a bulk load ever names those columns again.
|
||||
- **Turn cost, made flat.** Assembling a turn walked the whole story, so it was O(story length)
|
||||
— 839 KB of reads at turn 200. `backend/app/context/history.py` now serves tails and slices
|
||||
from SQL and measures what it fetched; the same turn costs 129 KB and stops growing at around
|
||||
turn 50.
|
||||
- **Branching that doesn't cost anything to read.** A branch stores no turns — it stores where it
|
||||
left its parent, and borrows everything above that. A 40-turn story forked twenty times loads in
|
||||
31,652 B against 31,433 B for the same story flat: **1.007×**, or about 103 B per branch. Reads
|
||||
stay cheap because the lineage is windowed like the history is, so the number of SQL clauses is
|
||||
bounded by the context window rather than by the number of forks.
|
||||
- **Database egress, cut about 189x.** Every adventure load pulled `Action.context_snapshot`,
|
||||
the entire assembled prompt at about 74 KB per turn, to read two small fields off it. Moving
|
||||
those fields into their own columns and marking the heavy ones `deferred` took one adventure
|
||||
load from 38.5 MB to 0.20 MB. The backfill runs server-side with dialect-specific SQL, so the
|
||||
old data never crosses the wire. `tests/test_egress.py` hooks into SQLAlchemy's cursor events
|
||||
and fails if a bulk load ever names those columns again.
|
||||
- **Turn cost, made flat.** Assembling a turn walked the whole story, so cost scaled with story
|
||||
length: 839 KB of reads at turn 200. `backend/app/context/history.py` now serves tails and
|
||||
slices from SQL and measures what it fetched. The same turn now costs 129 KB and stops
|
||||
growing at around turn 50.
|
||||
- **Branching that costs nothing to read.** A branch stores no turns. It stores where it left
|
||||
its parent, and borrows everything above that. A 40-turn story forked twenty times loads in
|
||||
31,652 bytes against 31,433 bytes for the same story flat: a 1.007x ratio, or about 103 bytes
|
||||
per branch. Reads stay cheap because the lineage is windowed the same way the history is, so
|
||||
the number of SQL clauses is bounded by the context window rather than by the number of
|
||||
forks.
|
||||
|
||||
## Visit analytics
|
||||
|
||||
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` showing
|
||||
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` shows
|
||||
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
|
||||
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
|
||||
`AIDND_ANALYTICS_EMAILS`, and the route 404s for everyone else.
|
||||
`AIDND_ANALYTICS_EMAILS`, and the route returns 404 for everyone else.
|
||||
|
||||
Built into the app rather than bolted on with a third-party script, for reasons specific to
|
||||
this one: the CSP allows `script-src 'self'`, adblockers eat the popular trackers, and none
|
||||
of them can see the measurement that actually matters here — a turn. Counts are aggregated
|
||||
in memory and flushed as UPSERTs, so a visit is a write and never a read, and every dashboard
|
||||
query is a `GROUP BY` returning tens of rows however much traffic sits behind it. That last
|
||||
part is not incidental; see the egress note above for what reading rows per request costs on
|
||||
this stack.
|
||||
This is built into the app rather than added with a third-party script, for reasons specific
|
||||
to this project: the CSP allows only `script-src 'self'`, ad blockers block the popular
|
||||
trackers, and none of those trackers can see the measurement that matters here, a turn. Counts
|
||||
are aggregated in memory and flushed as UPSERTs, so a visit is a write and never a read, and
|
||||
every dashboard query is a `GROUP BY` that returns tens of rows regardless of traffic volume.
|
||||
That matters: see the egress note above for what reading rows per request costs on this stack.
|
||||
|
||||
## Deploy (Render)
|
||||
|
||||
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that
|
||||
serves the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres
|
||||
(the free tier has no persistent disk, so the database lives off-box).
|
||||
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that serves
|
||||
the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres. The free
|
||||
Render tier has no persistent disk, so the database lives off-box.
|
||||
|
||||
1. Create a **Neon** project and copy its pooled connection string.
|
||||
2. In Render: **New → Blueprint**, point it at this repo. Render reads `render.yaml`.
|
||||
3. Fill the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
|
||||
string); to offer a no-signup demo, `AIDND_DEMO_API_KEY` / `AIDND_DEMO_MODELS`; and to
|
||||
see the Visitors dashboard, `AIDND_ANALYTICS_EMAILS` (your own account's email).
|
||||
`AIDND_SECRET_KEY` is generated automatically and kept stable across deploys.
|
||||
4. Deploy. Pushes to `main` auto-deploy thereafter. Health check: `/api/health`.
|
||||
2. In Render, choose **New → Blueprint** and point it at this repo. Render reads
|
||||
`render.yaml`.
|
||||
3. Fill in the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
|
||||
string); `AIDND_DEMO_API_KEY` and `AIDND_DEMO_MODELS` to offer a no-signup demo; and
|
||||
`AIDND_ANALYTICS_EMAILS` (your own account's email) to see the Visitors dashboard.
|
||||
`AIDND_SECRET_KEY` is generated automatically and stays stable across deploys.
|
||||
4. Deploy. Pushes to `main` auto-deploy after this. The health check is `/api/health`.
|
||||
|
||||
On the free tier the service sleeps after ~15 min idle; the first request then takes
|
||||
~30–60s to wake. Point any keep-warm pinger at `/api/health`, which deliberately doesn't
|
||||
touch the database — waking the database around the clock costs far more than the cold start
|
||||
is worth.
|
||||
On the free tier the service sleeps after about 15 minutes idle, and the first request after
|
||||
that takes about 30 to 60 seconds to wake it. Point any keep-warm pinger at `/api/health`,
|
||||
which deliberately doesn't touch the database: waking the database around the clock costs far
|
||||
more than the cold start saves.
|
||||
|
||||
## Repo notes
|
||||
|
||||
- `plan/` — the phased implementation plan this was built from, kept as a build log. All fourteen
|
||||
phases are complete; the later files (11, 12, 14) double as design notes for the state-revert,
|
||||
world-state and story-tree work. [`plan/STATUS.md`](plan/STATUS.md) is the running thread —
|
||||
what shipped, what was measured, and what is owed next.
|
||||
- [`docs/GUIDE.md`](docs/GUIDE.md) — design notes: how each subsystem works and why it was built
|
||||
that way, with the measurements behind the decisions. Also rendered as a
|
||||
- `plan/` holds the phased implementation plan this project was built from, kept as a build
|
||||
log. All fourteen phases are complete. The later files (11, 12, 14) also serve as design
|
||||
notes for the state-revert, world-state, and story-tree work.
|
||||
[`plan/STATUS.md`](plan/STATUS.md) is the running thread: what shipped, what was measured,
|
||||
and what is owed next.
|
||||
- [`docs/GUIDE.md`](docs/GUIDE.md) holds design notes: how each subsystem works and why it was
|
||||
built that way, with the measurements behind the decisions. It is also rendered as a
|
||||
[reading page](https://parththakkar106.github.io/AI-DnD/guide.html).
|
||||
- `backend/.env.example` — the few environment variables the backend reads.
|
||||
- [`docs/self-review.md`](docs/self-review.md) — a full-codebase self-review pass and what came
|
||||
out of it. All correctness findings are resolved.
|
||||
- `backend/.env.example` lists the few environment variables the backend reads.
|
||||
- [`docs/self-review.md`](docs/self-review.md) records a full-codebase self-review pass and
|
||||
what came out of it. All correctness findings are resolved.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+45
-37
@@ -3,25 +3,25 @@
|
||||
The deliberate opposite of analytics.py. That module counts and stores nothing
|
||||
that points at a person; this one records addresses, email addresses and
|
||||
devices, because an access log that cannot identify the access is not an access
|
||||
log. They are kept in separate modules and separate tables on purpose — the
|
||||
anonymity of the counters is then a property of the code rather than of a
|
||||
convention someone has to remember.
|
||||
log. The two live in separate modules and separate tables on purpose, so that
|
||||
the anonymity of the counters is a property of the code rather than a convention
|
||||
someone has to remember.
|
||||
|
||||
Owner-only, and never shown to the people it records.
|
||||
|
||||
Four kinds of row:
|
||||
|
||||
- `session` a browser that has a session made a request — for a guest,
|
||||
their first visit;
|
||||
- `login` an existing account signed in;
|
||||
- `register` a guest upgraded to an account;
|
||||
- `login_failed` a password attempt that didn't match, with the address tried.
|
||||
- `session` A browser that has a session made a request. For a guest, this
|
||||
is their first visit.
|
||||
- `login` An existing account signed in.
|
||||
- `register` A guest upgraded to an account.
|
||||
- `login_failed` A password attempt that did not match, with the address tried.
|
||||
|
||||
Session rows are the only ones that need thinning: `/auth/me` runs on every
|
||||
page load, and a row per load would be noise rather than a log. One is written
|
||||
when the day or the address changes for that user, which is the granularity a
|
||||
log is actually read at — "seen on the 3rd from 1.2.3.4" — and it still catches
|
||||
someone moving networks mid-day.
|
||||
Session rows are the only ones that need thinning. `/auth/me` runs on every page
|
||||
load, and one row per load would be noise rather than a log. A row is written
|
||||
when the day or the address changes for that user. That is the granularity a log
|
||||
is read at, such as seen on the 3rd from 1.2.3.4, and it still records someone
|
||||
moving networks during a day.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -50,31 +50,36 @@ _MAX_TRACKED = 10_000
|
||||
|
||||
|
||||
def _client_ip(request) -> str:
|
||||
# Deferred: limits imports auth, which is imported by the routers that call
|
||||
# this, so a module-level import here would close the loop. The spoof
|
||||
# resistance lives there and must not be reimplemented — a second, laxer
|
||||
# copy of "what is the client's address" is exactly how one of them ends up
|
||||
# trusting a header it shouldn't.
|
||||
# This import is deferred. `limits` imports `auth`, which the routers that
|
||||
# call this function import, so a module-level import here would create a
|
||||
# cycle. The spoof resistance lives in `limits` and must not be
|
||||
# reimplemented. A second, looser answer to which address belongs to the
|
||||
# client is how one of them ends up trusting a header it should not.
|
||||
from . import limits
|
||||
|
||||
return limits.client_ip(request)
|
||||
|
||||
|
||||
def describe(user: models.User) -> str:
|
||||
"""How a user is named in the log. Guests have no email, and their id is
|
||||
the only handle anyone has for them. The third case is a local install's
|
||||
implicit single user, which is also email-less but is the operator rather
|
||||
than a visitor — calling that one "Guest #1" would be a small lie in the
|
||||
one row they are certain to read."""
|
||||
"""Returns how a user is named in the log.
|
||||
|
||||
A guest has no email, and their id is the only handle anyone has for them.
|
||||
The third case is a local install's implicit single user, who also has no
|
||||
email but is the operator rather than a visitor. Naming that user "Guest #1"
|
||||
would be wrong in the one row they are certain to read.
|
||||
"""
|
||||
if user.email:
|
||||
return user.email
|
||||
return f"Guest #{user.id}" if user.is_guest else f"Local user #{user.id}"
|
||||
|
||||
|
||||
def _country(request) -> str:
|
||||
"""The edge's country header, or "" when there isn't one. Blank rather than
|
||||
the counters' "(unknown)" label: a table column reads better as an em dash
|
||||
than as a word, and an empty string is the honest value for "not known"."""
|
||||
"""Returns the edge's country header, or "" when there is none.
|
||||
|
||||
The blank differs from the counters' "(unknown)" label. A table column reads
|
||||
better as a dash than as a word, and an empty string is the correct value for
|
||||
a country that is not known.
|
||||
"""
|
||||
country = analytics.country_of(request.headers)
|
||||
return "" if country == analytics.UNKNOWN else country
|
||||
|
||||
@@ -87,8 +92,11 @@ def record(
|
||||
user: models.User | None = None,
|
||||
who: str | None = None,
|
||||
) -> None:
|
||||
"""Write one row. Never raises: the log watches sign-in, it doesn't guard
|
||||
it, and a logging failure must not be able to lock anyone out."""
|
||||
"""Writes one row.
|
||||
|
||||
This function never raises. The log observes sign-in rather than guarding it,
|
||||
and a logging failure must not lock anyone out.
|
||||
"""
|
||||
try:
|
||||
event = models.AccessEvent(
|
||||
kind=kind,
|
||||
@@ -108,7 +116,7 @@ def record(
|
||||
|
||||
|
||||
def note_session(db: Session, user: models.User, request) -> None:
|
||||
"""A session made a request. Thinned to one row per day per address."""
|
||||
"""Records that a session made a request, at most one row per day per address."""
|
||||
try:
|
||||
today = analytics._today()
|
||||
ip = _client_ip(request)
|
||||
@@ -117,8 +125,8 @@ def note_session(db: Session, user: models.User, request) -> None:
|
||||
return
|
||||
_last_session[user.id] = (today, ip)
|
||||
if len(_last_session) > _MAX_TRACKED:
|
||||
# Nothing here is worth persisting; dropping the map costs at
|
||||
# most one extra row per active user.
|
||||
# Nothing here needs to persist. Clearing the map costs at most
|
||||
# one extra row per active user.
|
||||
_last_session.clear()
|
||||
_last_session[user.id] = (today, ip)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
@@ -135,11 +143,11 @@ def recent(
|
||||
kind: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict:
|
||||
"""A page of the log, newest first.
|
||||
"""Returns a page of the log, newest first.
|
||||
|
||||
Anchored on a row id rather than an offset, like the story pager: rows keep
|
||||
arriving while it is being read, and an offset would shift the page under
|
||||
whoever is reading it.
|
||||
The page is anchored on a row id rather than an offset, as the story pager
|
||||
is. Rows keep arriving while the log is read, and an offset would shift the
|
||||
page under whoever is reading it.
|
||||
"""
|
||||
statement = select(models.AccessEvent).order_by(desc(models.AccessEvent.id))
|
||||
if before_id is not None:
|
||||
@@ -153,8 +161,8 @@ def recent(
|
||||
models.AccessEvent.ip.ilike(like),
|
||||
models.AccessEvent.country.ilike(like),
|
||||
))
|
||||
# One extra row answers "is there more" without a second COUNT over the
|
||||
# whole table.
|
||||
# Requesting one extra row reports whether more rows exist, without a
|
||||
# second COUNT over the whole table.
|
||||
rows = list(db.scalars(statement.limit(limit + 1)))
|
||||
has_more = len(rows) > limit
|
||||
return {"events": rows[:limit], "has_more": has_more}
|
||||
|
||||
+149
-122
@@ -1,34 +1,38 @@
|
||||
"""Visit analytics for the hosted demo.
|
||||
|
||||
A small self-hosted counter answering "did anyone visit, and did they play?",
|
||||
built into the app rather than bolted on with a third-party script: the CSP in
|
||||
main.py allows scripts from 'self' only, adblockers eat the popular trackers,
|
||||
and none of them can see the things actually worth knowing here (turns taken,
|
||||
demo-key spend, which seeded scenario people pick).
|
||||
This is a small self-hosted counter that answers whether anyone visited and
|
||||
whether they played. It is built into the app rather than added with a
|
||||
third-party script, because the CSP in `main.py` allows scripts from 'self'
|
||||
only, ad blockers block the popular trackers, and none of those trackers can see
|
||||
what is worth knowing here: turns taken, demo-key spend, and which seeded
|
||||
scenario people pick.
|
||||
|
||||
Three rules shaped it:
|
||||
Three rules shape the design:
|
||||
|
||||
1. **Nothing personal is stored.** No IP addresses, no user agents, no user
|
||||
ids, no title of anything a player wrote. A visitor appears only as an HMAC
|
||||
of their user id — one-way and salted with the app's secret key, so these
|
||||
tables cannot be joined back to an account even by someone holding the
|
||||
database. Story content never reaches this module at all. What a *specific*
|
||||
person did is deliberately unanswerable; only totals are.
|
||||
2. **Egress is the budget.** Neon bills for bytes leaving the database and this
|
||||
project has already paid for forgetting that once. So counts are aggregated
|
||||
in memory and flushed as UPSERTs — a visit is a write, never a read — and
|
||||
every dashboard query is a GROUP BY returning tens of rows, never per-visit
|
||||
rows. A month of traffic costs a few kilobytes to read back.
|
||||
3. **The numbers are the server's, not the browser's.** The client reports one
|
||||
thing: which page was viewed. Everything that *means* something ("a turn
|
||||
happened", "an account was created") is recorded by the code that does it,
|
||||
where it can be neither faked by a stranger nor blocked by an extension.
|
||||
1. It stores nothing personal. It records no IP addresses, no user agents, no
|
||||
user ids, and no title of anything a player wrote. A visitor appears only as
|
||||
an HMAC of their user id, which is one-way and salted with the app's secret
|
||||
key, so these tables cannot be joined back to an account even by someone
|
||||
holding the database. Story content never reaches this module. What one
|
||||
specific person did is unanswerable by design, and only totals are
|
||||
available.
|
||||
2. Egress is the budget. Neon bills for bytes leaving the database, and this
|
||||
project has already paid for forgetting that once. Counts are therefore
|
||||
aggregated in memory and flushed as UPSERTs, so a visit is a write and never
|
||||
a read, and every dashboard query is a GROUP BY that returns tens of rows
|
||||
rather than per-visit rows. A month of traffic costs a few kilobytes to read
|
||||
back.
|
||||
3. The numbers come from the server, not from the browser. The client reports
|
||||
one thing, which is the page that was viewed. Everything with meaning, such
|
||||
as a turn happening or an account being created, is recorded by the code that
|
||||
performs it, where a stranger cannot fake it and an extension cannot block
|
||||
it.
|
||||
|
||||
Storage is two tables, both bounded. `analytics_daily` is one row per (day,
|
||||
metric, label) counter — a few dozen a day. `analytics_visitor_days` is one row
|
||||
per visitor per day carrying the funnel flags, which is what makes the funnel
|
||||
count *people* rather than clicks; it is the only table that grows with traffic
|
||||
and cleanup ages it out.
|
||||
Storage is two tables, both bounded. `analytics_daily` holds one counter row per
|
||||
day, metric, and label, which is a few dozen rows a day.
|
||||
`analytics_visitor_days` holds one row per visitor per day carrying the funnel
|
||||
flags, which is what makes the funnel count people rather than clicks. It is the
|
||||
only table that grows with traffic, and cleanup ages it out.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
@@ -50,31 +54,31 @@ from . import models, security
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------- Metrics ----------
|
||||
# `metric` is the family, `label` the bucket within it. One generic counter
|
||||
# table beats a column per thing measured: adding a new question later is a
|
||||
# constant, not a migration.
|
||||
# `metric` is the family, and `label` is the bucket within it. One generic
|
||||
# counter table is better than a column per measurement, because adding a new
|
||||
# question later costs nothing rather than a migration.
|
||||
|
||||
M_PAGE = "pageview"
|
||||
M_EVENT = "event"
|
||||
M_REFERRER = "referrer"
|
||||
M_DEVICE = "device"
|
||||
M_COUNTRY = "country"
|
||||
M_SCENARIO = "scenario" # which seeded/public scenario got played
|
||||
M_ERROR = "error" # "<status> <route>" for 4xx/5xx on /api
|
||||
M_SCENARIO = "scenario" # Which seeded or public scenario was played.
|
||||
M_ERROR = "error" # "<status> <route>" for a 4xx or 5xx on /api.
|
||||
|
||||
EV_SCENARIO_OPEN = "scenario_opened"
|
||||
EV_ADVENTURE = "adventure_created"
|
||||
EV_IMPORT = "adventure_imported"
|
||||
EV_TURN = "turn"
|
||||
EV_DEMO_TURN = "demo_turn" # a turn billed to the shared demo key
|
||||
EV_DEMO_TURN = "demo_turn" # A turn billed to the shared demo key.
|
||||
EV_TURN_ERROR = "turn_error"
|
||||
EV_SIGNUP = "signup"
|
||||
EV_LOGIN = "login"
|
||||
|
||||
# Events that are also funnel steps: recording one flips a flag on the
|
||||
# visitor's row for the day, so the funnel counts distinct people-days instead
|
||||
# of repeat clicks. The name -> column map is the whole definition of the
|
||||
# funnel; the dashboard reads it back in this order.
|
||||
# Events that are also funnel steps. Recording one sets a flag on the visitor's
|
||||
# row for the day, so the funnel counts distinct visitor-days rather than repeat
|
||||
# clicks. This name-to-column map is the whole definition of the funnel, and the
|
||||
# dashboard reads it back in this order.
|
||||
FUNNEL_FLAGS = {
|
||||
EV_SCENARIO_OPEN: "opened",
|
||||
EV_ADVENTURE: "created",
|
||||
@@ -87,44 +91,45 @@ NONE_LABEL = "(direct)"
|
||||
UNKNOWN = "(unknown)"
|
||||
|
||||
# ---------- Bounds ----------
|
||||
# Everything below exists so a hostile visitor can add rows to these tables no
|
||||
# These bounds exist so that a hostile visitor can add rows to these tables no
|
||||
# faster than an honest one. The only label a client can influence is the
|
||||
# referrer, and these caps together mean the worst it can do is fill one day's
|
||||
# referrer list with junk and then be folded into "(other)".
|
||||
# referrer, and together these caps mean the worst it can do is fill one day's
|
||||
# referrer list and then be folded into "(other)".
|
||||
|
||||
MAX_LABEL_LEN = 80
|
||||
MAX_LABELS_PER_METRIC = 200 # distinct labels per metric per day, then OTHER
|
||||
MAX_PENDING = 4000 # buffered entries before an inline flush
|
||||
MAX_LABELS_PER_METRIC = 200 # Distinct labels per metric per day, then OTHER.
|
||||
MAX_PENDING = 4000 # Buffered entries before an inline flush.
|
||||
FLUSH_INTERVAL_SECONDS = 60
|
||||
|
||||
# How long the per-visitor-day rows are kept. The daily counters are tiny and
|
||||
# kept forever; these are the ones that scale with traffic. A visitor whose
|
||||
# last visit falls off the end counts as new again — a fair trade at this
|
||||
# horizon, and it keeps the table from being a permanent record of anyone.
|
||||
# How long the per-visitor-day rows are kept. The daily counters are small and
|
||||
# are kept indefinitely. These rows are the ones that scale with traffic. A
|
||||
# visitor whose last visit ages out counts as new again, which is an acceptable
|
||||
# trade at this horizon and keeps the table from being a permanent record of
|
||||
# anyone.
|
||||
RETENTION_DAYS = int(os.environ.get("AIDND_ANALYTICS_RETENTION_DAYS", "400") or 400)
|
||||
|
||||
_HOST_OK = re.compile(r"^[a-z0-9.-]+$")
|
||||
_COUNTRY_OK = re.compile(r"^[A-Z]{2}$")
|
||||
_NUMERIC_SEGMENT = re.compile(r"^\d+$")
|
||||
|
||||
# SPA routes, in the shape the dashboard should show them. Anything else a
|
||||
# client claims to have viewed becomes OTHER, so the page list can neither be
|
||||
# polluted nor accidentally record which adventure someone is reading.
|
||||
# SPA routes, in the form the dashboard shows them. Any other path a client
|
||||
# reports becomes OTHER, so the page list cannot be filled with junk and cannot
|
||||
# record which adventure someone is reading.
|
||||
KNOWN_ROUTES = {
|
||||
"/", "/adventures", "/scenarios", "/scenarios/:id", "/play/:id",
|
||||
"/scripts", "/scripts/:id", "/settings", "/chat", "/analytics",
|
||||
}
|
||||
|
||||
# ---------- In-process buffer ----------
|
||||
# Single-process deployment (same assumption as limits.py), so a plain dict
|
||||
# under a lock is the whole design. Losing up to a minute of counts to a hard
|
||||
# restart is acceptable for traffic numbers, and the flusher also runs on
|
||||
# shutdown; on Render's free tier the service is idle when it sleeps, so the
|
||||
# buffer it sleeps on is empty anyway.
|
||||
# The deployment is a single process, which is the same assumption `limits.py`
|
||||
# makes, so a plain dict under a lock is the whole design. Losing up to a minute
|
||||
# of counts to a hard restart is acceptable for traffic numbers, and the flusher
|
||||
# also runs on shutdown. On Render's free tier the service is idle when it
|
||||
# sleeps, so the buffer it sleeps on is empty.
|
||||
|
||||
_counts: dict[tuple[str, str, str], int] = {}
|
||||
_visits: dict[tuple[str, str], set[str]] = {} # (day, visitor) -> flags
|
||||
_labels_seen: dict[tuple[str, str], set[str]] = {} # (day, metric) -> labels
|
||||
_visits: dict[tuple[str, str], set[str]] = {} # (day, visitor) -> flags.
|
||||
_labels_seen: dict[tuple[str, str], set[str]] = {} # (day, metric) -> labels.
|
||||
_guard = threading.Lock()
|
||||
|
||||
|
||||
@@ -133,8 +138,11 @@ def _today() -> str:
|
||||
|
||||
|
||||
def record(metric: str, label: str = "", *, n: int = 1) -> None:
|
||||
"""Add `n` to one counter. Never raises: analytics must not be able to
|
||||
fail a request it is only watching."""
|
||||
"""Adds `n` to one counter.
|
||||
|
||||
This function never raises. Analytics must not fail a request that it is
|
||||
only observing.
|
||||
"""
|
||||
try:
|
||||
day = _today()
|
||||
label = (label or "").strip()[:MAX_LABEL_LEN]
|
||||
@@ -156,22 +164,26 @@ def record(metric: str, label: str = "", *, n: int = 1) -> None:
|
||||
|
||||
|
||||
def visitor_id(user: models.User) -> str:
|
||||
"""A stable but one-way handle for one visitor.
|
||||
"""Returns a stable, one-way handle for one visitor.
|
||||
|
||||
HMAC of the user id under the app's secret key. Stable, so a returning
|
||||
visitor can be told from a new one; one-way, so nothing in the analytics
|
||||
tables points back at an account; keyed, so a client cannot compute one and
|
||||
claim to be somebody else. One consequence worth knowing: rotating
|
||||
AIDND_SECRET_KEY makes every returning visitor look new again.
|
||||
The handle is an HMAC of the user id under the app's secret key. It is
|
||||
stable, so a returning visitor can be distinguished from a new one. It is
|
||||
one-way, so nothing in the analytics tables points back at an account. It is
|
||||
keyed, so a client cannot compute one and claim to be someone else. One
|
||||
consequence follows: rotating `AIDND_SECRET_KEY` makes every returning
|
||||
visitor look new.
|
||||
"""
|
||||
digest = hmac.new(security.SECRET_KEY, f"visitor:{user.id}".encode(), sha256)
|
||||
return digest.hexdigest()[:32]
|
||||
|
||||
|
||||
def record_visit(user: models.User | None, *, flag: str | None = None) -> None:
|
||||
"""Note that this visitor was here today, optionally flipping one funnel
|
||||
flag. A no-op without a user: a page loaded before a session exists still
|
||||
counts as a pageview, just not as a person."""
|
||||
"""Records that this visitor was here today, and optionally sets one funnel
|
||||
flag.
|
||||
|
||||
Without a user the call does nothing. A page loaded before a session exists
|
||||
still counts as a pageview, but not as a person.
|
||||
"""
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
@@ -184,8 +196,10 @@ def record_visit(user: models.User | None, *, flag: str | None = None) -> None:
|
||||
|
||||
|
||||
def record_event(name: str, user: models.User | None = None) -> None:
|
||||
"""One thing that happened: counted, and — if it is a funnel step —
|
||||
credited to the visitor's day. This is the call sites' whole interface."""
|
||||
"""Records one event, and credits the visitor's day if it is a funnel step.
|
||||
|
||||
This is the whole interface the call sites use.
|
||||
"""
|
||||
record(M_EVENT, name)
|
||||
record_visit(user, flag=FUNNEL_FLAGS.get(name))
|
||||
|
||||
@@ -193,10 +207,10 @@ def record_event(name: str, user: models.User | None = None) -> None:
|
||||
# ---------- Normalizing what the browser reports ----------
|
||||
|
||||
def normalize_route(path: str) -> str:
|
||||
"""A client-reported path, reduced to one of KNOWN_ROUTES.
|
||||
"""Reduces a client-reported path to one of `KNOWN_ROUTES`.
|
||||
|
||||
Numeric segments become ":id" — both to bound the label count and because
|
||||
*which* adventure someone opened is their business, not a statistic.
|
||||
Numeric segments become ":id". That bounds the label count, and it keeps
|
||||
which adventure someone opened out of the statistics.
|
||||
"""
|
||||
path = (path or "/").split("?")[0].split("#")[0]
|
||||
if not path.startswith("/"):
|
||||
@@ -209,8 +223,11 @@ def normalize_route(path: str) -> str:
|
||||
|
||||
|
||||
def normalize_referrer(referrer: str, own_host: str = "") -> str:
|
||||
"""The sending site as a bare host. Our own host means an internal
|
||||
navigation, which is not a referral — "" tells the caller to skip it."""
|
||||
"""Returns the sending site as a bare host.
|
||||
|
||||
This app's own host means an internal navigation, which is not a referral.
|
||||
In that case the function returns "", which tells the caller to skip it.
|
||||
"""
|
||||
if not referrer:
|
||||
return NONE_LABEL
|
||||
host = (urlsplit(referrer).hostname or "").lower().lstrip(".")
|
||||
@@ -222,21 +239,23 @@ def normalize_referrer(referrer: str, own_host: str = "") -> str:
|
||||
|
||||
|
||||
def api_route_label(scope: dict, status: int) -> str:
|
||||
"""An error bucket like "500 /api/adventures/{adventure_id}".
|
||||
"""Returns an error bucket such as "500 /api/adventures/{adventure_id}".
|
||||
|
||||
The route *template* is used, never the request path: it keeps one bucket
|
||||
per endpoint instead of one per adventure id, and — the reason it is not
|
||||
merely tidier — an unmatched path is entirely attacker-chosen, so labelling
|
||||
by it would let anyone mint rows by requesting nonsense.
|
||||
The label uses the route template, never the request path. That keeps one
|
||||
bucket per endpoint rather than one per adventure id. It also bounds the
|
||||
table: an unmatched path is chosen entirely by the caller, so labeling by it
|
||||
would let anyone create rows by requesting arbitrary paths.
|
||||
"""
|
||||
template = getattr(scope.get("route"), "path", None)
|
||||
return f"{status} {template}" if template else f"{status} (unmatched)"
|
||||
|
||||
|
||||
def device_of(user_agent: str) -> str:
|
||||
"""Mobile / tablet / desktop, and nothing finer. The UA string itself is
|
||||
never stored — it is a fingerprint, and the answer worth having is one
|
||||
word."""
|
||||
"""Returns "mobile", "tablet", or "desktop", and nothing more specific.
|
||||
|
||||
The user-agent string itself is never stored, because it is a fingerprint
|
||||
and the useful answer is one word.
|
||||
"""
|
||||
ua = (user_agent or "").lower()
|
||||
if not ua:
|
||||
return UNKNOWN
|
||||
@@ -249,10 +268,10 @@ def device_of(user_agent: str) -> str:
|
||||
return "desktop"
|
||||
|
||||
|
||||
# Geo headers an edge may add. Render fronts services with a CDN that can set
|
||||
# cf-ipcountry; the others cost nothing to look for. A value is trusted only if
|
||||
# it looks like an ISO code, since a client can send any header it likes — the
|
||||
# worst case is therefore a wrong country, never an unbounded label.
|
||||
# Geo headers an edge network may add. Render fronts services with a CDN that
|
||||
# can set `cf-ipcountry`, and the others cost nothing to check. A value is
|
||||
# trusted only if it looks like an ISO code, because a client can send any
|
||||
# header, so the worst case is a wrong country rather than an unbounded label.
|
||||
_GEO_HEADERS = ("cf-ipcountry", "x-vercel-ip-country", "x-geo-country", "x-country-code")
|
||||
|
||||
|
||||
@@ -275,8 +294,8 @@ def _drain() -> tuple[dict, dict]:
|
||||
counts, visits = _counts.copy(), _visits.copy()
|
||||
_counts.clear()
|
||||
_visits.clear()
|
||||
# The label sets only bound cardinality within a day, so let yesterday's
|
||||
# go rather than growing a map that never shrinks.
|
||||
# The label sets bound cardinality within one day, so drop the
|
||||
# previous day's rather than grow a map that never shrinks.
|
||||
today = _today()
|
||||
for key in [k for k in _labels_seen if k[0] != today]:
|
||||
del _labels_seen[key]
|
||||
@@ -284,7 +303,7 @@ def _drain() -> tuple[dict, dict]:
|
||||
|
||||
|
||||
def _restore(counts: dict, visits: dict) -> None:
|
||||
"""Put a failed flush's work back so the next one retries it."""
|
||||
"""Returns a failed flush's work to the buffer, so the next flush retries it."""
|
||||
with _guard:
|
||||
for key, n in counts.items():
|
||||
_counts[key] = _counts.get(key, 0) + n
|
||||
@@ -293,7 +312,7 @@ def _restore(counts: dict, visits: dict) -> None:
|
||||
|
||||
|
||||
def flush(db: Session | None = None) -> None:
|
||||
"""Write the buffer out. Safe to call from anywhere; never raises."""
|
||||
"""Writes the buffer out. This is safe to call from anywhere and never raises."""
|
||||
counts, visits = _drain()
|
||||
if not counts and not visits:
|
||||
return
|
||||
@@ -334,9 +353,9 @@ def _write_visits(db: Session, visits: dict) -> None:
|
||||
return
|
||||
table = models.AnalyticsVisitorDay.__table__
|
||||
ids = {visitor for _, visitor in visits}
|
||||
# One indexed lookup settles new-vs-returning for the whole batch. It is
|
||||
# the only read this module does off the dashboard, and it returns short
|
||||
# hashes for visitors who are active right now — bounded by the batch.
|
||||
# One indexed lookup decides new against returning for the whole batch. It
|
||||
# is the only read this module makes outside the dashboard, and it returns
|
||||
# short hashes for the visitors active right now, so the batch bounds it.
|
||||
known = set(db.scalars(
|
||||
select(models.AnalyticsVisitorDay.visitor)
|
||||
.where(models.AnalyticsVisitorDay.visitor.in_(ids))
|
||||
@@ -354,8 +373,8 @@ def _write_visits(db: Session, visits: dict) -> None:
|
||||
stmt = _insert(db)(table).values(rows)
|
||||
db.execute(stmt.on_conflict_do_update(
|
||||
index_elements=["day", "visitor"],
|
||||
# Flags only ever turn on, and `is_new` is deliberately absent: the
|
||||
# first write of a visitor's first day is the one that decided it.
|
||||
# Flags only turn on, and `is_new` is absent on purpose. The first
|
||||
# write of a visitor's first day is what decided it.
|
||||
set_={
|
||||
column: or_(table.c[column], stmt.excluded[column])
|
||||
for column in FUNNEL_FLAGS.values()
|
||||
@@ -364,9 +383,11 @@ def _write_visits(db: Session, visits: dict) -> None:
|
||||
|
||||
|
||||
def purge_old_visitor_days(db: Session) -> int:
|
||||
"""Drop visitor-day rows past the retention horizon. Called by the cleanup
|
||||
sweeper; the daily counters are never purged — they are aggregate, tiny,
|
||||
and a portfolio project wants to keep its history."""
|
||||
"""Deletes visitor-day rows past the retention horizon.
|
||||
|
||||
The cleanup sweeper calls this. The daily counters are never purged, because
|
||||
they are aggregates, they are small, and this project keeps its history.
|
||||
"""
|
||||
if RETENTION_DAYS <= 0:
|
||||
return 0
|
||||
cutoff = (models.utcnow().date() - timedelta(days=RETENTION_DAYS)).isoformat()
|
||||
@@ -378,9 +399,9 @@ def purge_old_visitor_days(db: Session) -> int:
|
||||
|
||||
|
||||
# ---------- Reading it back ----------
|
||||
# Every query below is an aggregate: the database does the counting and ships
|
||||
# back tens of rows, whatever the traffic behind them. Nothing here can return
|
||||
# a row that belongs to one visitor.
|
||||
# Every query below is an aggregate. The database does the counting and returns
|
||||
# tens of rows, however much traffic is behind them. No query here can return a
|
||||
# row that belongs to one visitor.
|
||||
|
||||
TOP_N = 12
|
||||
|
||||
@@ -390,17 +411,20 @@ def _top(rows: list[dict], limit: int = TOP_N) -> list[dict]:
|
||||
|
||||
|
||||
def summary(db: Session, days: int = 30) -> dict:
|
||||
"""Everything the dashboard shows, for the last `days` days (today
|
||||
included). Flushes first so the numbers include the last minute."""
|
||||
"""Returns everything the dashboard shows for the last `days` days, including
|
||||
today.
|
||||
|
||||
The function flushes first, so the numbers include the last minute.
|
||||
"""
|
||||
flush(db)
|
||||
today = models.utcnow().date()
|
||||
since = (today - timedelta(days=days - 1)).isoformat()
|
||||
daily = models.AnalyticsDaily
|
||||
visitor = models.AnalyticsVisitorDay
|
||||
|
||||
# 1. Every counter in the window, folded to (metric, label) totals: the
|
||||
# page/referrer/country/device/scenario/error tables all come from this
|
||||
# one pass rather than a query each.
|
||||
# 1. Every counter in the window, reduced to (metric, label) totals. The
|
||||
# page, referrer, country, device, scenario, and error tables all come
|
||||
# from this one pass rather than from a query each.
|
||||
by_metric: dict[str, list[dict]] = {}
|
||||
for metric, label, hits in db.execute(
|
||||
select(daily.metric, daily.label, func.sum(daily.hits))
|
||||
@@ -412,7 +436,7 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
rows.sort(key=lambda row: -row["hits"])
|
||||
events = {row["label"]: row["hits"] for row in by_metric.get(M_EVENT, [])}
|
||||
|
||||
# 2. Two per-day series worth drawing.
|
||||
# 2. The two per-day series the dashboard draws.
|
||||
pageviews_by_day = {
|
||||
day: int(hits)
|
||||
for day, hits in db.execute(
|
||||
@@ -430,8 +454,8 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
)
|
||||
}
|
||||
|
||||
# 3. People, per day. One row per visitor per day means COUNT(*) is already
|
||||
# the day's unique visitors — no DISTINCT needed here.
|
||||
# 3. People, per day. There is one row per visitor per day, so COUNT(*) is
|
||||
# already the day's unique visitors and no DISTINCT is needed.
|
||||
visitors_by_day: dict[str, dict] = {}
|
||||
for day, total, fresh in db.execute(
|
||||
select(
|
||||
@@ -444,9 +468,9 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
):
|
||||
visitors_by_day[day] = {"visitors": int(total), "new": int(fresh or 0)}
|
||||
|
||||
# 4. The funnel, over the whole window, counting *people* once each:
|
||||
# 4. The funnel over the whole window, counting each person once.
|
||||
# COUNT(DISTINCT CASE WHEN flag THEN visitor END) ignores the NULLs the
|
||||
# CASE leaves for everyone who didn't reach that step.
|
||||
# CASE leaves for everyone who did not reach that step.
|
||||
unique, unique_new, *reached = db.execute(
|
||||
select(
|
||||
func.count(func.distinct(visitor.visitor)),
|
||||
@@ -480,9 +504,9 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
"until": today.isoformat(),
|
||||
"generated_at": models.utcnow().isoformat(),
|
||||
"totals": {
|
||||
# `visitors` counts each person once for the window; `visits` counts
|
||||
# them once per day they came back, which is the closest honest
|
||||
# thing to "sessions" without tracking sessions.
|
||||
# `visitors` counts each person once for the window. `visits`
|
||||
# counts them once per day they returned, which is the closest
|
||||
# measure to "sessions" that does not track sessions.
|
||||
"visitors": int(unique),
|
||||
"new_visitors": int(unique_new),
|
||||
"visits": visits,
|
||||
@@ -498,8 +522,8 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
"pages_per_visit": round(pageviews / visits, 1) if visits else 0,
|
||||
},
|
||||
"series": series,
|
||||
# Step 0 is everyone who showed up, so the drop-off between it and
|
||||
# "Opened a scenario" is visible as a step like any other.
|
||||
# Step 0 is everyone who arrived, so the drop-off between it and
|
||||
# "Opened a scenario" appears as a step like any other.
|
||||
"funnel": [{"step": "Visited", "count": int(unique)}] + [
|
||||
{"step": step, "count": int(count)}
|
||||
for step, count in zip(
|
||||
@@ -518,8 +542,9 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
|
||||
|
||||
# ---------- Background flusher ----------
|
||||
# Mirrors cleanup's start/stop pair so main.py's lifespan reads the same way
|
||||
# for both. The interval is what bounds how much a hard restart can lose.
|
||||
# This matches the start and stop pair in `cleanup`, so the lifespan in
|
||||
# `main.py` reads the same way for both. The interval bounds how much a hard
|
||||
# restart can lose.
|
||||
|
||||
async def _flush_loop() -> None:
|
||||
import asyncio
|
||||
@@ -528,8 +553,8 @@ async def _flush_loop() -> None:
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
|
||||
# Blocking DB work: keep it off the event loop, which is also serving
|
||||
# SSE turn streams.
|
||||
# This is blocking database work, so keep it off the event loop, which
|
||||
# is also serving SSE turn streams.
|
||||
await run_in_threadpool(flush)
|
||||
|
||||
|
||||
@@ -540,9 +565,11 @@ def start_flusher():
|
||||
|
||||
|
||||
async def stop_flusher(task) -> None:
|
||||
"""Cancel the loop and write out whatever it was holding — a deploy is the
|
||||
one restart that is both frequent and predictable, so it should not be the
|
||||
thing that loses a minute of counts."""
|
||||
"""Cancels the loop and writes out whatever it was holding.
|
||||
|
||||
A deploy is the one restart that is both frequent and predictable, so it
|
||||
should not be what loses a minute of counts.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
+97
-94
@@ -1,33 +1,33 @@
|
||||
"""Phase 14, SP4 — the attempts at one turn.
|
||||
"""Phase 14, SP4: the attempts at one turn.
|
||||
|
||||
A retry used to rewrite the AI action in place and push the discarded take into
|
||||
a JSON list on the same row. That is where seven separate bugs came from: the
|
||||
row's `text` mirrored one entry of a repeating group, `variant_count` mirrored
|
||||
its length, and every reader that touched the story during a retry had to be
|
||||
told to pretend the row was not there.
|
||||
A retry used to rewrite the AI action in place and append the discarded attempt
|
||||
to a JSON list on the same row. Seven separate bugs came from that arrangement.
|
||||
The row's `text` duplicated one entry of a repeating group, `variant_count`
|
||||
duplicated its length, and every reader that touched the story during a retry
|
||||
had to be told to ignore the row.
|
||||
|
||||
Now an attempt is a **node**. Retry writes a sibling at the same
|
||||
`(branch_id, depth)` and marks it live; the previous one stays exactly as it
|
||||
was written, at the same coordinate, `live = False`. Nothing is mirrored, so
|
||||
nothing can drift.
|
||||
Now an attempt is a node. A retry writes a sibling at the same `(branch_id,
|
||||
depth)` and marks it live. The previous attempt stays as it was written, at the
|
||||
same coordinate, with `live = False`. Nothing is duplicated, so nothing can
|
||||
diverge.
|
||||
|
||||
Two invariants hold the arrangement together, and this module is the only place
|
||||
that maintains either:
|
||||
that maintains either one:
|
||||
|
||||
* **Exactly one sibling in a group is live.** `lineage.Path.clause` selects on
|
||||
it, so the losing attempts are invisible to every read of the story without
|
||||
any of those reads knowing that attempts exist.
|
||||
* **The assembled prompt is stored once per turn, on the live sibling.**
|
||||
A `context_snapshot` is ~163 kB of prompt that every attempt at a turn
|
||||
shares, plus a few hundred bytes that differ (`ATTEMPT_KEYS`). Giving each
|
||||
sibling its own copy would have made retry a permanent multiplier on the
|
||||
biggest column in the database — the thing the JSON list was invented to
|
||||
avoid. So the prompt moves with the live flag, and a superseded sibling keeps
|
||||
only its own slices.
|
||||
* Exactly one sibling in a group is live. `lineage.Path.clause` selects on it, so
|
||||
the other attempts are invisible to every read of the story, and none of those
|
||||
reads has to know that attempts exist.
|
||||
* The assembled prompt is stored once per turn, on the live sibling. A
|
||||
`context_snapshot` is about 163 kB of prompt that every attempt at a turn
|
||||
shares, plus a few hundred bytes that differ, listed in `ATTEMPT_KEYS`. Giving
|
||||
each sibling its own copy would make a retry a permanent multiplier on the
|
||||
largest column in the database, which is what the JSON list was invented to
|
||||
avoid. The prompt therefore moves with the live flag, and a superseded sibling
|
||||
keeps only its own slices.
|
||||
|
||||
Ordering inside a group is `variant_index`, an explicit ordinal, not
|
||||
`created_at`. Two attempts made in the same second must still page in the order
|
||||
they were made, and the migration that split the old JSON lists had to be able
|
||||
Ordering inside a group comes from `variant_index`, which is an explicit ordinal
|
||||
rather than `created_at`. Two attempts made in the same second still have to page
|
||||
in the order they were made, and the migration that split the old JSON lists had
|
||||
to state the order rather than reconstruct it.
|
||||
"""
|
||||
|
||||
@@ -38,41 +38,42 @@ from sqlalchemy.orm import Session, undefer
|
||||
from . import models
|
||||
from .context import lineage
|
||||
|
||||
# The slices of a context snapshot that belong to one attempt rather than to
|
||||
# the turn: the world-state delta it proposed and what the referee did with it,
|
||||
# the script report, the model's literal reply, and the endpoint's token
|
||||
# accounting — each attempt is its own API call, and a retry is precisely the
|
||||
# call expected to read the prompt back out of cache. Everything else in a
|
||||
# snapshot is the prompt, which is assembled once per turn.
|
||||
# The slices of a context snapshot that belong to one attempt rather than to the
|
||||
# turn. They are the world-state delta the attempt proposed and what the engine
|
||||
# did with it, the script report, the model's literal reply, and the endpoint's
|
||||
# token accounting. Each attempt is its own API call, and a retry is the call
|
||||
# most likely to read the prompt back out of cache. Everything else in a snapshot
|
||||
# is the prompt, which is assembled once per turn.
|
||||
ATTEMPT_KEYS = ("world_state", "script", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
|
||||
def group(db: Session, action: models.Action) -> list[models.Action]:
|
||||
"""Every attempt at `action`'s turn, oldest first.
|
||||
"""Returns every attempt at `action`'s turn, oldest first.
|
||||
|
||||
Keyed on the **parent**, not on the coordinate (SP9). The two agree right up
|
||||
until a take is forked onto its own branch: it keeps its parent but leaves
|
||||
the (branch, depth) its siblings are still at, so a coordinate would report
|
||||
it as the only take of its turn — `1/1` where the player is owed `1/3`.
|
||||
The query keys on the parent rather than on the coordinate (SP9). The two
|
||||
agree until an attempt is forked onto its own branch. That attempt keeps its
|
||||
parent but leaves the `(branch, depth)` its siblings are still at, so a
|
||||
coordinate would report it as the only attempt at its turn, showing `1/1`
|
||||
where the player should see `1/3`.
|
||||
|
||||
The parent also gets the nesting right without being asked. Takes under C1
|
||||
and takes under C2 share a depth and, until one of them forks, a branch;
|
||||
only the parent separates them, which is what makes a pager under C2 read
|
||||
`2/2` instead of counting C1's three as well.
|
||||
The parent also nests groups correctly without extra work. Attempts under C1
|
||||
and attempts under C2 share a depth, and until one of them forks they share a
|
||||
branch. Only the parent separates them, which is what makes a pager under C2
|
||||
read `2/2` rather than count C1's three as well.
|
||||
|
||||
Two fallbacks, both meaning "this row predates the key being asked about":
|
||||
a node with no branch is a pre-tree row no path contains, and a node with no
|
||||
parent is a pre-SP9 row the backfill could not place. Both are their own
|
||||
only attempt under the rule they were written with.
|
||||
There are two fallbacks, and both mean the row predates the key being asked
|
||||
about. A node with no branch is a pre-tree row that no path contains, and a
|
||||
node with no parent is a pre-SP9 row the backfill could not place. Under the
|
||||
rule each was written with, both are the only attempt at their turn.
|
||||
"""
|
||||
if action.branch_id is None or action.depth is None:
|
||||
return [action]
|
||||
if action.parent_id is None:
|
||||
# Pre-SP9, and the coordinate is the key those rows were written under.
|
||||
# Root nodes land here too and are genuinely alone: nothing is a take of
|
||||
# the opening of a story.
|
||||
# This row is pre-SP9, and the coordinate is the key those rows were
|
||||
# written under. A root node also reaches this branch and is genuinely
|
||||
# alone, because nothing is an attempt at the opening of a story.
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
@@ -96,18 +97,18 @@ def group(db: Session, action: models.Action) -> list[models.Action]:
|
||||
|
||||
|
||||
def on_branch(rows: list[models.Action], node: models.Action) -> list[models.Action]:
|
||||
"""The takes in `rows` that sit on `node`'s own branch.
|
||||
"""Returns the attempts in `rows` that are on `node`'s own branch.
|
||||
|
||||
`group` answers "which takes are of this turn", and since SP9 that spans
|
||||
branches — a take forked onto its own line is still a take of the same turn,
|
||||
which is the whole point of keying on the parent.
|
||||
`group` reports which attempts belong to this turn, and since SP9 that spans
|
||||
branches. An attempt forked onto its own line is still an attempt at the same
|
||||
turn, which is the reason for keying on the parent.
|
||||
|
||||
Deleting is the one caller that must not follow it there. A take on another
|
||||
branch is reachable through that branch and belongs to the story somebody is
|
||||
telling on it; removing it because a turn was undone over here would delete
|
||||
a line nobody asked about. Same parent *and* same branch is the coordinate,
|
||||
which is what "every attempt at this turn" meant before a fork could move
|
||||
one out of it.
|
||||
Deletion is the one caller that must not follow a group across branches. An
|
||||
attempt on another branch is reachable through that branch and belongs to the
|
||||
story someone is telling there. Removing it because a turn was undone here
|
||||
would delete a line nobody asked about. The same parent and the same branch
|
||||
together are the coordinate, which is what every attempt at this turn meant
|
||||
before a fork could move one out of it.
|
||||
"""
|
||||
return [row for row in rows if row.branch_id == node.branch_id]
|
||||
|
||||
@@ -122,12 +123,12 @@ def live_in(rows: list[models.Action]) -> models.Action | None:
|
||||
def preceding(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> models.Action | None:
|
||||
"""The node the story tells immediately before `node`.
|
||||
"""Returns the node the story tells immediately before `node`.
|
||||
|
||||
"Before this turn" as a fact about the path rather than as a snapshot taken
|
||||
from inside the turn — which is what makes the after-snapshots enough on
|
||||
their own. Undefers both of them because the only reason to ask for this
|
||||
row is to put back what it left behind.
|
||||
This reads "before this turn" as a fact about the path rather than as a
|
||||
snapshot taken from inside the turn, which is what makes the after-snapshots
|
||||
sufficient on their own. The query undefers both of them, because the only
|
||||
reason to fetch this row is to restore what it left behind.
|
||||
"""
|
||||
if node.depth is None:
|
||||
return None
|
||||
@@ -150,12 +151,12 @@ def preceding(
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
|
||||
"""Put back the script scoreboard and world state `node` left behind.
|
||||
"""Restores the script state and world state that `node` left behind.
|
||||
|
||||
A NULL snapshot means "leave the live state alone", never "reset it": rows
|
||||
A NULL snapshot means leave the live state as it is, never reset it. Rows
|
||||
written before SP4 that the migration could not derive an outcome for carry
|
||||
NULLs, and clobbering a running adventure's scoreboard with an empty dict
|
||||
would be a far worse answer than doing nothing.
|
||||
NULLs, and overwriting a running adventure's state with an empty dict would
|
||||
be worse than doing nothing.
|
||||
"""
|
||||
if node is None:
|
||||
return
|
||||
@@ -166,7 +167,7 @@ def restore_state(adventure: models.Adventure, node: models.Action | None) -> No
|
||||
|
||||
|
||||
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Record on `node` what the adventure looks like now that it has played."""
|
||||
"""Records on `node` the state of the adventure now that the node has played."""
|
||||
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
node.state_after = copy.deepcopy(state)
|
||||
@@ -176,7 +177,7 @@ def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
def roll_back_before(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> None:
|
||||
"""Rewind the shared state to before `node` was played."""
|
||||
"""Rewinds the shared state to what it was before `node` was played."""
|
||||
restore_state(adventure, preceding(db, adventure, node))
|
||||
|
||||
|
||||
@@ -186,26 +187,28 @@ def add_attempt(
|
||||
previous: models.Action,
|
||||
replacement: models.Action,
|
||||
) -> None:
|
||||
"""Put `replacement` beside `previous` as the newer attempt at that turn.
|
||||
"""Places `replacement` next to `previous` as the newer attempt at that turn.
|
||||
|
||||
Placed by hand rather than through `tree.place_action`, which would read the
|
||||
depth off the legacy `index` and move the head: a sibling is not a new turn,
|
||||
it is another take on the one the head is already standing on.
|
||||
The placement is done here rather than through `tree.place_action`, which
|
||||
would read the depth from the legacy `index` and move the head. A sibling is
|
||||
not a new turn. It is another attempt at the turn the head is already on.
|
||||
"""
|
||||
replacement.branch_id = previous.branch_id
|
||||
replacement.depth = previous.depth
|
||||
# Copied, never resolved from the path: a take belongs to the turn it is a
|
||||
# take *of*, and that is what `group` keys on. Resolving it here would ask
|
||||
# what is live one depth back, which is the same node right now and stops
|
||||
# being once this turn is forked away from.
|
||||
# Copy the parent rather than resolve it from the path. An attempt belongs
|
||||
# to the turn it is an attempt at, and that is what `group` keys on.
|
||||
# Resolving it here would ask which node is live one depth back. That is the
|
||||
# same node right now, and it stops being the same node once the story forks
|
||||
# away from this turn.
|
||||
replacement.parent_id = previous.parent_id
|
||||
replacement.live = True
|
||||
# The end of the group, not one past `previous` — which is only the same
|
||||
# thing when `previous` is the newest take. Switch a three-take turn back to
|
||||
# take 1 and retry, and `previous.variant_index + 1` collides with take 2;
|
||||
# `renumber` then breaks the tie by id and files the new attempt *between*
|
||||
# takes 2 and 3, so the pager walks the takes in an order they were not made
|
||||
# in. `group` is oldest-first, and `replacement` is not in it yet.
|
||||
# Use the end of the group rather than one past `previous`. The two match
|
||||
# only when `previous` is the newest attempt. Switch a three-attempt turn
|
||||
# back to attempt 1 and retry, and `previous.variant_index + 1` collides with
|
||||
# attempt 2. `renumber` then breaks the tie by id and places the new attempt
|
||||
# between attempts 2 and 3, so the pager walks the attempts in an order they
|
||||
# were not made in. `group` returns oldest first, and `replacement` is not in
|
||||
# it yet.
|
||||
siblings = group(db, previous)
|
||||
replacement.variant_index = 1 + max(
|
||||
(s.variant_index for s in siblings if s.variant_index is not None),
|
||||
@@ -213,18 +216,18 @@ def add_attempt(
|
||||
)
|
||||
previous.live = False
|
||||
# The replacement was assembled with a fresh snapshot, so the prompt for
|
||||
# this turn is now the one it carries; the superseded attempt keeps only
|
||||
# what was its own.
|
||||
# this turn is now the one it carries. The superseded attempt keeps only the
|
||||
# slices that were its own.
|
||||
keep_own_slices(previous)
|
||||
|
||||
|
||||
def make_live(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> list[models.Action]:
|
||||
"""Make `node` the attempt the story tells, and put its outcome back.
|
||||
"""Makes `node` the attempt the story tells, and restores its outcome.
|
||||
|
||||
Returns the group, renumbered, so a caller that wants to report on it does
|
||||
not read it twice.
|
||||
Returns the group, renumbered, so that a caller reporting on it does not read
|
||||
it twice.
|
||||
"""
|
||||
rows = group(db, node)
|
||||
previous = live_in(rows)
|
||||
@@ -238,11 +241,11 @@ def make_live(
|
||||
|
||||
|
||||
def renumber(rows: list[models.Action]) -> None:
|
||||
"""Refresh the group-shape cache the page response reads.
|
||||
"""Refreshes the group-shape cache that the page response reads.
|
||||
|
||||
`variant_count` is 0 rather than 1 for a turn nobody retried, because the
|
||||
pager's question is "is there anything to page through?" and the answer for
|
||||
a single attempt is no.
|
||||
pager asks whether there is anything to page through, and for a single
|
||||
attempt the answer is no.
|
||||
"""
|
||||
count = len(rows) if len(rows) > 1 else 0
|
||||
for i, row in enumerate(rows):
|
||||
@@ -253,7 +256,7 @@ def renumber(rows: list[models.Action]) -> None:
|
||||
# ------------------------------------------------- the prompt, stored once
|
||||
|
||||
def keep_own_slices(node: models.Action) -> None:
|
||||
"""Strip `node`'s snapshot back to what is only its own."""
|
||||
"""Reduces `node`'s snapshot to the slices that are only its own."""
|
||||
snapshot = node.context_snapshot
|
||||
if not isinstance(snapshot, dict):
|
||||
return
|
||||
@@ -263,11 +266,11 @@ def keep_own_slices(node: models.Action) -> None:
|
||||
|
||||
|
||||
def hand_over_the_prompt(giver: models.Action, taker: models.Action) -> None:
|
||||
"""Move the turn's assembled prompt from one attempt to another.
|
||||
"""Moves the turn's assembled prompt from one attempt to another.
|
||||
|
||||
Called when the live flag moves, so the row in the story is always the row
|
||||
the Insights viewer can explain. Nothing is copied — the prompt exists once
|
||||
before and once after, on whichever sibling is being read.
|
||||
The caller runs this when the live flag moves, so that the row in the story
|
||||
is always the row the Insights viewer can explain. Nothing is copied. The
|
||||
prompt exists once before and once after, on whichever sibling is being read.
|
||||
"""
|
||||
held = giver.context_snapshot if isinstance(giver.context_snapshot, dict) else {}
|
||||
shared = {k: v for k, v in held.items() if k not in ATTEMPT_KEYS}
|
||||
|
||||
+87
-63
@@ -1,18 +1,19 @@
|
||||
"""Phase 8 — user resolution, sessions, and the shared demo key.
|
||||
"""Phase 8: user resolution, sessions, and the shared demo key.
|
||||
|
||||
Two modes, chosen by the AIDND_MULTI_USER env var:
|
||||
The `AIDND_MULTI_USER` environment variable selects one of two modes:
|
||||
|
||||
- Local mode (default): every request resolves to one auto-created "local
|
||||
user". No cookies, no login UI — a clone/docker-compose behaves exactly
|
||||
like the pre-Phase-8 single-user app.
|
||||
- Multi-user mode (hosted): requests carry a signed session cookie. GET
|
||||
/api/auth/me creates a guest user on first visit; registering upgrades the
|
||||
guest in place so their data survives. Requests without a valid session get
|
||||
401 and the frontend re-establishes via /me.
|
||||
* Local mode, the default. Every request resolves to one automatically created
|
||||
local user. There are no cookies and no login UI, so a clone or a
|
||||
docker-compose run behaves like the single-user app from before Phase 8.
|
||||
* Multi-user mode, used for hosted deployments. Requests carry a signed session
|
||||
cookie. `GET /api/auth/me` creates a guest user on the first visit, and
|
||||
registering upgrades that guest in place so their data survives. A request
|
||||
without a valid session gets a 401, and the frontend re-establishes the
|
||||
session through `/me`.
|
||||
|
||||
The shared demo key (BYOK fallback) is also configured here: users whose
|
||||
settings have no API key are routed to a server-funded endpoint with a model
|
||||
whitelist and a per-day turn cap.
|
||||
The shared demo key, which is the fallback when a user brings no key of their
|
||||
own, is also configured here. A user whose settings hold no API key is routed to
|
||||
a server-funded endpoint with a model allowlist and a per-day turn cap.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -33,9 +34,10 @@ def _env_flag(name: str) -> bool:
|
||||
MULTI_USER = _env_flag("AIDND_MULTI_USER")
|
||||
|
||||
SESSION_COOKIE = "aidnd_session"
|
||||
# Secure cookies default on in multi-user (hosted = HTTPS; browsers also
|
||||
# accept Secure on http://localhost). AIDND_COOKIE_SECURE=0/1 overrides —
|
||||
# e.g. 0 when testing multi-user over plain http on a LAN address.
|
||||
# Secure cookies are on by default in multi-user mode, because a hosted
|
||||
# deployment serves HTTPS and browsers also accept Secure on http://localhost.
|
||||
# `AIDND_COOKIE_SECURE` overrides the default with 0 or 1. Use 0 when testing
|
||||
# multi-user mode over plain HTTP on a LAN address.
|
||||
_cookie_secure_env = os.environ.get("AIDND_COOKIE_SECURE", "").strip().lower()
|
||||
COOKIE_SECURE = (
|
||||
_cookie_secure_env in ("1", "true", "yes", "on")
|
||||
@@ -58,18 +60,19 @@ DEMO_MODELS = [
|
||||
] or ["google/gemma-4-26b-a4b-it:free"]
|
||||
DEMO_TURNS_PER_DAY = int(os.environ.get("AIDND_DEMO_TURNS_PER_DAY", "20") or 20)
|
||||
|
||||
# Trusted testers (by email) who bypass the daily demo cap — unmetered turns on
|
||||
# the shared demo key. Comma-separated emails; matched case-insensitively.
|
||||
# Trusted testers, listed by email, who bypass the daily demo cap and take
|
||||
# unmetered turns on the shared demo key. The list is comma-separated, and the
|
||||
# match ignores case.
|
||||
POWER_USERS = {
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("AIDND_POWER_USERS", "").split(",")
|
||||
if e.strip()
|
||||
}
|
||||
|
||||
# Who can see the visit analytics. Deliberately its own list rather than
|
||||
# POWER_USERS: a trusted tester gets unmetered turns and the AI Chat page,
|
||||
# which is not a reason to hand them the site's traffic numbers. Empty (the
|
||||
# default) means nobody sees the dashboard in a hosted deployment.
|
||||
# Who can see the visit analytics. This is a separate list from `POWER_USERS` on
|
||||
# purpose. A trusted tester gets unmetered turns and the AI Chat page, which is
|
||||
# not a reason to give them the site's traffic numbers. An empty list, which is
|
||||
# the default, means nobody sees the dashboard in a hosted deployment.
|
||||
ANALYTICS_EMAILS = {
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("AIDND_ANALYTICS_EMAILS", "").split(",")
|
||||
@@ -83,15 +86,19 @@ DEMO_CAP_MESSAGE = (
|
||||
|
||||
|
||||
def demo_enabled() -> bool:
|
||||
# The demo key is a hosted-deployment feature; local installs talk to
|
||||
# whatever endpoint Settings points at, even with no API key (Ollama).
|
||||
# The demo key is a hosted-deployment feature. A local install talks to
|
||||
# whatever endpoint Settings points at, even with no API key, such as
|
||||
# Ollama.
|
||||
return MULTI_USER and bool(DEMO_API_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderConfig:
|
||||
"""What the turn engine should actually connect with, after the
|
||||
BYOK-vs-demo decision. Build these with resolve_provider_config()."""
|
||||
"""What the turn engine connects with, after the decision between a
|
||||
user-supplied key and the demo key.
|
||||
|
||||
Build one of these with `resolve_provider_config()`.
|
||||
"""
|
||||
|
||||
endpoint_url: str
|
||||
api_key: str
|
||||
@@ -99,19 +106,20 @@ class ProviderConfig:
|
||||
using_demo: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Belt and braces around server-funded turns: resolve_provider_config()
|
||||
# already pins the model, and this makes it a property of the config
|
||||
# object too, so a future caller can't construct an unpinned one.
|
||||
# Unreachable by design — a raise here means a new code path bypassed
|
||||
# the pinning, which is worth failing loudly rather than billing.
|
||||
# A second guard around server-funded turns. `resolve_provider_config()`
|
||||
# already pins the model, and this makes the pin a property of the config
|
||||
# object too, so a later caller cannot construct an unpinned one. This
|
||||
# raise is unreachable by design. Reaching it means a new code path
|
||||
# bypassed the pinning, which is worth failing on rather than billing
|
||||
# for.
|
||||
#
|
||||
# The test is `using_demo`, NOT `api_key == DEMO_API_KEY`. Keying it on
|
||||
# the key value looks stricter but is wrong: the demo key is a normal
|
||||
# The test is `using_demo`, not `api_key == DEMO_API_KEY`. Keying on the
|
||||
# key value looks stricter and is wrong. The demo key is an ordinary
|
||||
# OpenRouter key, so a user can legitimately paste that same key into
|
||||
# their own Settings as BYOK — and then every resolution raised, 500ing
|
||||
# even GET /auth/me and taking the whole SPA down with it. `using_demo`
|
||||
# is what actually means "the server is paying", and only the demo
|
||||
# branch below sets it.
|
||||
# their own Settings. Every resolution then raised, which returned a 500
|
||||
# even from `GET /auth/me` and took the whole SPA down. `using_demo` is
|
||||
# what means the server is paying, and only the demo branch below sets
|
||||
# it.
|
||||
if self.using_demo and self.model not in DEMO_MODELS:
|
||||
raise ValueError(
|
||||
f"Refusing to use the shared demo key with non-whitelisted model {self.model!r}"
|
||||
@@ -121,20 +129,23 @@ class ProviderConfig:
|
||||
def resolve_provider_config(
|
||||
settings: models.Settings, *, model_override: str | None = None
|
||||
) -> ProviderConfig:
|
||||
"""BYOK when the user has their own key, the shared demo key otherwise.
|
||||
"""Returns the user's own key when they have one, and the shared demo key
|
||||
otherwise.
|
||||
|
||||
THE security-relevant branch is the demo one, and it is the only place the
|
||||
whitelist rule lives — every caller must come through here rather than
|
||||
building a ProviderConfig itself. On the demo key:
|
||||
The demo branch is the security-relevant one, and it is the only place the
|
||||
allowlist rule lives. Every caller has to come through this function rather
|
||||
than build a `ProviderConfig` itself. On the demo key:
|
||||
|
||||
- the model is pinned to DEMO_MODELS, so a caller-supplied override (the AI
|
||||
Chat page) or a hand-edited Settings row cannot aim a server-funded key at
|
||||
a paid model; anything unrecognised falls back to DEMO_MODELS[0];
|
||||
- the endpoint is pinned to DEMO_ENDPOINT_URL, so the key itself can't be
|
||||
redirected to a URL the user controls and harvested.
|
||||
* The model is pinned to `DEMO_MODELS`, so a caller-supplied override from
|
||||
the AI Chat page, or a hand-edited Settings row, cannot point a
|
||||
server-funded key at a paid model. An unrecognized model falls back to
|
||||
`DEMO_MODELS[0]`.
|
||||
* The endpoint is pinned to `DEMO_ENDPOINT_URL`, so the key cannot be
|
||||
redirected to a URL the user controls and captured there.
|
||||
|
||||
`model_override` is a per-request preference (never a grant): it's honoured
|
||||
verbatim under BYOK, and only if whitelisted on the demo key.
|
||||
`model_override` is a per-request preference and never a grant. It is used
|
||||
verbatim with the user's own key, and on the demo key only when the model is
|
||||
on the allowlist.
|
||||
"""
|
||||
key = settings.api_key_plain
|
||||
requested = (model_override or "").strip() or settings.model
|
||||
@@ -149,27 +160,33 @@ def _today() -> str:
|
||||
|
||||
|
||||
def is_power_user(user: models.User) -> bool:
|
||||
"""Trusted testers: unmetered demo turns, plus tooling that isn't part of
|
||||
the game (the AI Chat scratchpad). Local installs are always trusted — it's
|
||||
the operator's own machine and their own API key, same reasoning as the
|
||||
provider debug log being local-only."""
|
||||
"""Returns whether this user is a trusted tester.
|
||||
|
||||
A trusted tester gets unmetered demo turns, plus tooling that is not part of
|
||||
the game, such as the AI Chat scratchpad. A local install is always trusted,
|
||||
because it runs on the operator's own machine with their own API key. The
|
||||
provider debug log is local-only for the same reason.
|
||||
"""
|
||||
if not MULTI_USER:
|
||||
return True
|
||||
return bool(user.email) and user.email.lower() in POWER_USERS
|
||||
|
||||
|
||||
def is_owner(user: models.User) -> bool:
|
||||
"""May this user see the visit analytics? Local installs always can — it is
|
||||
the operator's own machine and their own visits, same reasoning as the
|
||||
provider debug log; hosted deployments check AIDND_ANALYTICS_EMAILS."""
|
||||
"""Returns whether this user may see the visit analytics.
|
||||
|
||||
A local install always may, because it runs on the operator's own machine and
|
||||
shows their own visits. The provider debug log follows the same reasoning. A
|
||||
hosted deployment checks `AIDND_ANALYTICS_EMAILS`.
|
||||
"""
|
||||
if not MULTI_USER:
|
||||
return True
|
||||
return bool(user.email) and user.email.lower() in ANALYTICS_EMAILS
|
||||
|
||||
|
||||
def demo_turns_left(user: models.User) -> int:
|
||||
# Power users are never capped; report the full cap so the banner reads
|
||||
# "N of N" rather than a decrementing count.
|
||||
# A power user is never capped, so report the full cap and let the banner
|
||||
# read "N of N" rather than count down.
|
||||
if is_power_user(user):
|
||||
return DEMO_TURNS_PER_DAY
|
||||
used = user.demo_turns_used if user.demo_turns_date == _today() else 0
|
||||
@@ -177,9 +194,9 @@ def demo_turns_left(user: models.User) -> int:
|
||||
|
||||
|
||||
def count_demo_turn(user: models.User) -> None:
|
||||
"""Record one demo turn; the caller's commit persists it."""
|
||||
"""Records one demo turn. The caller's commit stores it."""
|
||||
if is_power_user(user):
|
||||
return # unmetered — power users don't count against the cap
|
||||
return # A power user's turns do not count against the cap.
|
||||
today = _today()
|
||||
if user.demo_turns_date != today:
|
||||
user.demo_turns_date = today
|
||||
@@ -190,8 +207,11 @@ def count_demo_turn(user: models.User) -> None:
|
||||
# ---------- User resolution ----------
|
||||
|
||||
def local_user(db: Session) -> models.User:
|
||||
"""The single implicit user in local mode (owns pre-Phase-8 data via
|
||||
migration; created lazily on a fresh database)."""
|
||||
"""Returns the single implicit user used in local mode.
|
||||
|
||||
A migration gives this user ownership of data written before Phase 8. On a
|
||||
fresh database the user is created on first use.
|
||||
"""
|
||||
user = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.email.is_(None), models.User.is_guest.is_(False))
|
||||
@@ -209,7 +229,8 @@ def _touch(user: models.User, db: Session) -> None:
|
||||
now = models.utcnow()
|
||||
last = user.last_seen_at
|
||||
if last is not None and last.tzinfo is None:
|
||||
# SQLite hands DateTime columns back naive; they were stored as UTC.
|
||||
# SQLite returns DateTime columns without a timezone. They were stored
|
||||
# as UTC.
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
if last is None or (now - last).total_seconds() > 3600:
|
||||
user.last_seen_at = now
|
||||
@@ -227,8 +248,11 @@ def resolve_session_user(request: Request, db: Session) -> models.User | None:
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> models.User:
|
||||
"""Dependency used by every router. 401 in multi-user mode means the
|
||||
frontend must (re)establish a session via GET /api/auth/me."""
|
||||
"""The dependency every router uses to resolve the current user.
|
||||
|
||||
In multi-user mode a 401 means the frontend has to establish a session again
|
||||
through `GET /api/auth/me`.
|
||||
"""
|
||||
if not MULTI_USER:
|
||||
user = local_user(db)
|
||||
else:
|
||||
|
||||
+156
-139
@@ -1,51 +1,52 @@
|
||||
"""Phase 14, SP6 — the export bundle, as a tree.
|
||||
"""Phase 14, SP6: the export bundle, as a tree.
|
||||
|
||||
A bundle is the one place a story leaves the database, and the only part of the
|
||||
tree no migration can ever reach: a file downloaded today has to still import
|
||||
into a build shipped next year. So the format is versioned, both versions live
|
||||
here, and nothing else in the app knows either of them.
|
||||
tree no migration can reach. A file downloaded today has to still import into a
|
||||
build shipped next year. The format is therefore versioned, both versions are
|
||||
defined here, and nothing else in the app knows either of them.
|
||||
|
||||
**v1** is a flat list of turns, each with an optional `variants` array — the
|
||||
repeating group SP4 unpacked into rows. Nothing writes that shape any more.
|
||||
The *reader* stays, because bundles already on people's disks still have it and
|
||||
a backup that stops importing is not a backup.
|
||||
Version 1 is a flat list of turns, each with an optional `variants` array, which
|
||||
is the repeating group SP4 unpacked into rows. Nothing writes that shape now.
|
||||
The reader stays, because bundles already on people's disks still use it, and a
|
||||
backup that stops importing is not a backup.
|
||||
|
||||
**v2** carries the tree. Three things it holds that v1 could not, each
|
||||
load-bearing:
|
||||
Version 2 carries the tree. It holds three things version 1 could not, and each
|
||||
one is required:
|
||||
|
||||
* **the branches**, because a forked adventure is two stories and a flat list
|
||||
can hold one — v1 export interleaved them by `index`, which read as a mangled
|
||||
story rather than as lost data;
|
||||
* **`live`**, because a coordinate can hold several attempts at one turn and
|
||||
exactly one of them is the story;
|
||||
* **both after-snapshots**, because they are what a branch switch and an undo
|
||||
put back. A bundle carrying the actions but not the outcomes would import a
|
||||
* The branches, because a forked adventure is two stories and a flat list holds
|
||||
one. A version 1 export interleaved them by `index`, which read as a garbled
|
||||
story rather than as lost data.
|
||||
* `live`, because a coordinate can hold several attempts at one turn and exactly
|
||||
one of them is the story.
|
||||
* Both after-snapshots, because they are what a branch switch and an undo
|
||||
restore. A bundle carrying the actions but not the outcomes would import a
|
||||
tree nobody could switch inside.
|
||||
|
||||
## The rule about what a bundle carries
|
||||
|
||||
**What was chosen, never what is derived.** The head *branch*, the fork points,
|
||||
the live flags and the anchors are decisions somebody made; they are in the
|
||||
file. `lineage`, the head *depth*, `index` and the variant ordinals are all
|
||||
computed from those, and they are recomputed on import instead:
|
||||
A bundle carries what was chosen, never what is derived. The head branch, the
|
||||
fork points, the live flags, and the anchors are decisions somebody made, so
|
||||
they are in the file. `lineage`, the head depth, `index`, and the variant
|
||||
ordinals are all computed from those, and the import recomputes them:
|
||||
|
||||
* `lineage` is a cache of `parent` + `fork_depth`. Shipping it too would put a
|
||||
second source of truth for one fact in a file anybody can hand-edit, and the
|
||||
two could then disagree in a way no read would ever report.
|
||||
* the head depth is the tip of the head branch, which is a fact about the nodes
|
||||
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
|
||||
put a second source of truth for one fact into a file anyone can hand-edit,
|
||||
and the two could then disagree without any read reporting it.
|
||||
* The head depth is the tip of the head branch, which is a fact about the nodes
|
||||
that arrived with it.
|
||||
* `index` is the legacy column SP8 drops. Its one remaining job is to hand the
|
||||
next row a number nothing else holds — a fact about the *adventure*, not
|
||||
about a path — so `depth` cannot be it: two branches have a node at depth 4.
|
||||
The import allocates one per turn instead, which keeps `max_action_index`
|
||||
honest and keeps siblings sharing an index the way SP4 leaves them.
|
||||
* the variant ordinals are `attempts.renumber`'s to maintain, and it is the
|
||||
only place allowed to.
|
||||
* `index` is the legacy column SP8 drops. Its one remaining job is to give the
|
||||
next row a number nothing else holds, which is a fact about the adventure
|
||||
rather than about a path, so `depth` cannot serve: two branches each have a
|
||||
node at depth 4. The import allocates one index per turn instead, which keeps
|
||||
`max_action_index` correct and keeps siblings sharing an index the way SP4
|
||||
leaves them.
|
||||
* `attempts.renumber` maintains the variant ordinals, and it is the only place
|
||||
allowed to.
|
||||
|
||||
Every hand-editable coordinate is therefore checked before a row is written
|
||||
(`plan`), not fixed up afterwards: an import that fails halfway leaves an
|
||||
adventure holding half a tree, and a tree missing a branch is a story that
|
||||
silently stops rather than one that reports.
|
||||
Every hand-editable coordinate is therefore checked before a row is written, in
|
||||
`plan`, rather than repaired afterwards. An import that fails partway leaves an
|
||||
adventure holding half a tree, and a tree missing a branch is a story that stops
|
||||
without reporting anything.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -60,24 +61,24 @@ from .context import cursors, lineage
|
||||
FORMAT = "ai-dnd-adventure-v2"
|
||||
LEGACY_FORMAT = "ai-dnd-adventure-v1"
|
||||
|
||||
# VARCHAR(20) on `actions.type`; a raw-dict import bypasses the schemas.
|
||||
# `actions.type` is VARCHAR(20), and a raw-dict import bypasses the schemas.
|
||||
TYPE_MAX = 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- exporting
|
||||
|
||||
def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"""The whole adventure as a v2 bundle.
|
||||
"""Returns the whole adventure as a version 2 bundle.
|
||||
|
||||
Deliberately un-pathed: a backup wants the entire tree, not the branch its
|
||||
owner happens to be standing on. Both after-snapshots are undeferred in the
|
||||
one query — they are per-node columns nothing else reads in bulk, and asking
|
||||
for them a row at a time would be a query per turn.
|
||||
The export is not scoped to a path. A backup holds the entire tree, not the
|
||||
branch its owner is currently reading. Both after-snapshots are undeferred in
|
||||
the one query, because they are per-node columns nothing else reads in bulk
|
||||
and requesting them a row at a time would cost one query per turn.
|
||||
|
||||
No context snapshots. A bundle has never carried the assembled prompts and
|
||||
still does not: they are ~163 kB a turn, they are an explanation of a
|
||||
generation rather than part of the story, and the Insights viewer they feed
|
||||
is reading the adventure it came from.
|
||||
The bundle carries no context snapshots. It has never carried the assembled
|
||||
prompts, and it still does not. They run about 163 kB per turn, they explain
|
||||
a generation rather than form part of the story, and the Insights viewer they
|
||||
feed reads the adventure they came from.
|
||||
"""
|
||||
branches = (
|
||||
db.query(models.Branch)
|
||||
@@ -85,8 +86,9 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
.order_by(models.Branch.id)
|
||||
.all()
|
||||
)
|
||||
# Branch ids are local to the file — positions in this list — because the
|
||||
# database ids they had here are already taken over there.
|
||||
# Branch ids are local to the file and are positions in this list, because
|
||||
# the database ids they hold here are already in use on the importing
|
||||
# side.
|
||||
local = {branch.id: i for i, branch in enumerate(branches)}
|
||||
nodes = (
|
||||
db.query(models.Action)
|
||||
@@ -112,9 +114,9 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"worldState": adventure.world_state,
|
||||
"autoSummarize": adventure.auto_summarize,
|
||||
"memoryBankEnabled": adventure.memory_bank_enabled,
|
||||
# A root entry even for an adventure whose branch row was never created
|
||||
# — a story with no branch is a pre-tree one, and the tree it belongs to
|
||||
# is the root. `_local` puts its nodes there.
|
||||
# Write a root entry even for an adventure whose branch row was never
|
||||
# created. A story with no branch is a pre-tree story, and the tree it
|
||||
# belongs to is the root. `_local` places its nodes there.
|
||||
"branches": [_exported_branch(b, local) for b in branches] or [_ROOT],
|
||||
"headBranch": local.get(adventure.head_branch_id, 0),
|
||||
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
|
||||
@@ -154,10 +156,10 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
dict(_ROOT) if parent is None
|
||||
else {"parent": parent, "forkDepth": branch.fork_depth}
|
||||
)
|
||||
# A name is something a player chose, so it travels — the same rule that
|
||||
# puts the fork points in the file and leaves `lineage` out. An unnamed
|
||||
# branch omits the key rather than carrying a null, which keeps the file
|
||||
# for a tree nobody has named byte-identical to the one SP6 wrote.
|
||||
# A player chose the name, so it goes into the file. That is the same rule
|
||||
# that puts the fork points in the file and leaves `lineage` out. An unnamed
|
||||
# branch omits the key rather than carry a null, which keeps the file for an
|
||||
# unnamed tree byte-identical to the one SP6 wrote.
|
||||
if branch.name:
|
||||
out["name"] = branch.name
|
||||
return out
|
||||
@@ -166,7 +168,7 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
node = {
|
||||
"branch": _local(action.branch_id, local),
|
||||
# A pre-tree row's depth is the number `index` already held.
|
||||
# A pre-tree row's depth is the number `index` already holds.
|
||||
"depth": action.depth if action.depth is not None else action.index,
|
||||
"live": bool(action.live),
|
||||
"type": action.type,
|
||||
@@ -175,11 +177,11 @@ def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
}
|
||||
if action.reasoning:
|
||||
node["reasoning"] = action.reasoning
|
||||
# `{}` and absent mean different things — "this node left an empty
|
||||
# scoreboard behind" against "nobody knows, leave the live state alone" —
|
||||
# so an empty snapshot is written out rather than trimmed. It costs about
|
||||
# eighteen bytes a row and it is the difference between an undo that clears
|
||||
# a score and one that leaves it standing.
|
||||
# `{}` and an absent key mean different things. `{}` means the node left an
|
||||
# empty state behind, and an absent key means the state is unknown and the
|
||||
# live state stays as it is. An empty snapshot is therefore written rather
|
||||
# than omitted. It costs about eighteen bytes per row, and it decides
|
||||
# whether an undo clears a score or leaves it in place.
|
||||
if action.state_after is not None:
|
||||
node["stateAfter"] = action.state_after
|
||||
if action.world_state_after is not None:
|
||||
@@ -194,8 +196,9 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
|
||||
"text": memory.text, "pinned": memory.pinned, "forgotten": memory.forgotten,
|
||||
"sourceStart": memory.source_start, "sourceEnd": memory.source_end,
|
||||
"useCount": memory.use_count,
|
||||
# The node it hangs off. A hand-written memory summarises no node, so it
|
||||
# has a branch and no depth, and keeps that shape here.
|
||||
# The node this memory is attached to. A hand-written memory summarizes
|
||||
# no node, so it has a branch and no depth, and it keeps that shape
|
||||
# here.
|
||||
"branch": _local(memory.branch_id, local),
|
||||
"depth": memory.depth,
|
||||
}
|
||||
@@ -214,7 +217,7 @@ def _exported_anchor(
|
||||
# ---------------------------------------------------------------- importing
|
||||
|
||||
def check_format(bundle: dict) -> str:
|
||||
"""The bundle's version, or 400."""
|
||||
"""Returns the bundle's version, or raises a 400."""
|
||||
fmt = bundle.get("format")
|
||||
if fmt in (FORMAT, LEGACY_FORMAT):
|
||||
return fmt
|
||||
@@ -225,16 +228,16 @@ def check_format(bundle: dict) -> str:
|
||||
|
||||
|
||||
def plan(bundle: dict, version: str) -> dict:
|
||||
"""The bundle's tree, checked and normalised, before a row is written.
|
||||
"""Returns the bundle's tree, checked and normalized, before a row is written.
|
||||
|
||||
Pure: no session, no adventure, nothing created. Everything a hand-edited
|
||||
file can get wrong about the *shape* of a tree is caught here, because the
|
||||
alternative is an import that fails partway and leaves an adventure holding
|
||||
a story with a hole in it.
|
||||
The function has no side effects. It opens no session, needs no adventure,
|
||||
and creates nothing. It catches everything a hand-edited file can get wrong
|
||||
about the shape of a tree, because the alternative is an import that fails
|
||||
partway and leaves an adventure holding a story with a gap in it.
|
||||
|
||||
Both versions land in the same shape, so `write` never learns there are two
|
||||
formats: a v1 bundle is a linear story, which is a tree with one branch, and
|
||||
its `variants` array is a sibling group written the old way.
|
||||
Both versions produce the same shape, so `write` never learns that there are
|
||||
two formats. A version 1 bundle is a linear story, which is a tree with one
|
||||
branch, and its `variants` array is a sibling group written the old way.
|
||||
"""
|
||||
branches = (
|
||||
_planned_branches(bundle) if version == FORMAT else [dict(_ROOT)]
|
||||
@@ -248,8 +251,9 @@ def plan(bundle: dict, version: str) -> dict:
|
||||
"nodes": nodes,
|
||||
"memories": _planned_memories(bundle, len(branches)),
|
||||
"head": _as_index(bundle.get("headBranch"), len(branches), default=0),
|
||||
# v2 knows where the derived work got to; v1 counted it, and a count
|
||||
# cannot be turned into a node until the nodes exist (see `settle`).
|
||||
# Version 2 records where the derived work reached. Version 1 counted
|
||||
# it, and a count cannot become a node until the nodes exist. See
|
||||
# `settle`.
|
||||
"anchors": _planned_anchors(bundle, len(branches)) if version == FORMAT else None,
|
||||
"positions": None if version == FORMAT else {
|
||||
"memory": _as_int(bundle.get("memoryCursor"), 0),
|
||||
@@ -270,11 +274,12 @@ def _planned_branches(bundle: dict) -> list[dict]:
|
||||
if parent is None:
|
||||
specs.append(dict(_ROOT, **({"name": name} if name else {})))
|
||||
continue
|
||||
# A branch may only fork from one listed before it. That is how the
|
||||
# export writes them — branches are numbered in creation order and a
|
||||
# parent always exists first — and requiring it here buys acyclicity for
|
||||
# the price of a comparison: a lineage is computed by walking to the
|
||||
# parent, and a cycle would be an import that never returns.
|
||||
# A branch may fork only from a branch listed before it. The export
|
||||
# writes them that way, because branches are numbered in creation order
|
||||
# and a parent always exists first. Requiring it here guarantees the
|
||||
# graph is acyclic for the cost of one comparison. A lineage is computed
|
||||
# by walking to the parent, so a cycle would be an import that never
|
||||
# returns.
|
||||
if not _is_int(parent) or not 0 <= parent < i:
|
||||
raise HTTPException(
|
||||
400,
|
||||
@@ -296,11 +301,12 @@ def _planned_branches(bundle: dict) -> list[dict]:
|
||||
|
||||
|
||||
def _planned_branch_name(entry: dict, i: int) -> str | None:
|
||||
"""The name a branch entry carries, or None for one nobody named.
|
||||
"""Returns the name a branch entry carries, or `None` if nobody named it.
|
||||
|
||||
Checked before the row is created rather than left to the column, for the
|
||||
reason the whole planner exists: a 400 from a pure function beats a half
|
||||
written adventure and a database error from three branches in.
|
||||
The check runs before the row is created rather than being left to the
|
||||
column, for the reason the planner exists. A 400 from a function with no side
|
||||
effects is better than a half-written adventure and a database error three
|
||||
branches in.
|
||||
"""
|
||||
raw = entry.get("name")
|
||||
if raw is None:
|
||||
@@ -351,15 +357,16 @@ def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
|
||||
|
||||
|
||||
def _planned_v1_nodes(bundle: dict) -> list[dict]:
|
||||
"""A v1 bundle's turns as the nodes they describe: one per attempt.
|
||||
"""Returns a version 1 bundle's turns as nodes, one node per attempt.
|
||||
|
||||
The `variants` array is the repeating group SP4 unpacked, so reading one is
|
||||
the same split migration 60 does — every attempt becomes a row at the turn's
|
||||
coordinate and `variantIndex` picks which is live. Clamped, because a
|
||||
hand-edited bundle can name an attempt its own list does not have, and a
|
||||
turn with no live node is a turn no read can see.
|
||||
The `variants` array is the repeating group SP4 unpacked, so reading one
|
||||
performs the same split that migration 60 does. Every attempt becomes a row
|
||||
at the turn's coordinate, and `variantIndex` selects the live one. The index
|
||||
is clamped, because a hand-edited bundle can name an attempt its own list
|
||||
does not contain, and a turn with no live node is a turn no read can see.
|
||||
|
||||
The depth is the bundle's `index`: v1 is one branch, where the two agree.
|
||||
The depth is the bundle's `index`. A version 1 bundle has one branch, where
|
||||
the two numbers agree.
|
||||
"""
|
||||
raw = bundle.get("actions")
|
||||
nodes: list[dict] = []
|
||||
@@ -404,9 +411,10 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
|
||||
"sourceStart": entry.get("sourceStart"),
|
||||
"sourceEnd": entry.get("sourceEnd"),
|
||||
"useCount": _as_int(entry.get("useCount"), 0),
|
||||
# Out of range rather than absent means a file that disagrees with
|
||||
# itself; the root is the safe reading, because a memory on a branch
|
||||
# nothing can see is a memory that never reaches a prompt again.
|
||||
# A value that is out of range, rather than absent, means the file
|
||||
# disagrees with itself. The root is the safe reading, because a
|
||||
# memory on a branch nothing can see never reaches a prompt
|
||||
# again.
|
||||
"branch": _as_index(entry.get("branch"), branches, default=0),
|
||||
"depth": entry.get("depth") if _is_int(entry.get("depth")) else None,
|
||||
})
|
||||
@@ -429,10 +437,10 @@ def _planned_anchors(bundle: dict, branches: int) -> dict:
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
"""Write a planned tree onto a freshly created adventure.
|
||||
"""Writes a planned tree onto a newly created adventure.
|
||||
|
||||
Order matters and is not negotiable: branches first, because a node needs an
|
||||
id to hang off; then the nodes, because the head and the anchors name one.
|
||||
The order is fixed. Branches come first, because a node needs a branch id.
|
||||
The nodes come next, because the head and the anchors name a node.
|
||||
"""
|
||||
ids = _write_branches(db, adventure, story["branches"])
|
||||
_write_nodes(db, adventure, story["nodes"], ids)
|
||||
@@ -444,13 +452,13 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
def _write_branches(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict]
|
||||
) -> list[int]:
|
||||
"""One row per branch, lineage computed rather than read.
|
||||
"""Writes one row per branch, computing the lineage rather than reading it.
|
||||
|
||||
Inserted through Core and the lineage written second, for the reason
|
||||
`tree.root_branch` spells out: the lineage names the row's own id. The
|
||||
parent's cached ancestry is capped at this fork, which is the same
|
||||
arithmetic `tree.fork` does — a fork made now and a fork made a year ago and
|
||||
exported must produce the same rows.
|
||||
The rows are inserted through Core and the lineage is written second, for the
|
||||
reason `tree.root_branch` gives: the lineage names the row's own id. The
|
||||
parent's cached ancestry is capped at this fork, which is the arithmetic
|
||||
`tree.fork` performs. A fork made now and a fork made a year ago and then
|
||||
exported have to produce the same rows.
|
||||
"""
|
||||
ids: list[int] = []
|
||||
lineages: list[list[list]] = []
|
||||
@@ -486,14 +494,14 @@ def _write_branches(
|
||||
def _write_nodes(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
|
||||
) -> None:
|
||||
"""The nodes, grouped into the turns they are attempts at.
|
||||
"""Writes the nodes, grouped into the turns they are attempts at.
|
||||
|
||||
Two things are allocated here rather than trusted from the file. `index` is
|
||||
handed out one per *turn*, in the order the bundle lists them, so siblings
|
||||
share one and no two coordinates do — which is what `max_action_index` needs
|
||||
to keep issuing numbers nothing holds. And exactly one attempt in each group
|
||||
is made live: a file can name none or several, and a turn with no live node
|
||||
is a turn that vanishes from the story.
|
||||
Two values are allocated here rather than read from the file. `index` is
|
||||
issued once per turn, in the order the bundle lists them, so siblings share
|
||||
one index and no two coordinates do. `max_action_index` needs that to keep
|
||||
issuing numbers nothing holds. Exactly one attempt in each group is also made
|
||||
live, because a file can name none or several, and a turn with no live node
|
||||
disappears from the story.
|
||||
"""
|
||||
groups: dict[tuple[int, int], list[models.Action]] = {}
|
||||
indices: dict[tuple[int, int], int] = {}
|
||||
@@ -541,18 +549,19 @@ def _write_memories(
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
)
|
||||
# A v1 memory has no depth of its own; `source_end` is the index of the
|
||||
# last action it summarises, which on one branch is that node's depth.
|
||||
# A version 1 memory has no depth of its own. `source_end` is the index
|
||||
# of the last action it summarizes, which on one branch is that node's
|
||||
# depth.
|
||||
if memory.depth is None and memory.source_end is not None:
|
||||
memory.depth = memory.source_end
|
||||
# A v1 memory that summarises nothing — one the player typed — has no
|
||||
# depth to derive, and leaving it NULL here would rebuild by import the
|
||||
# exact state migration 62 exists to end: `Path._entry_clause` compares
|
||||
# `depth <= max_depth`, which a NULL fails, so the memory would vanish
|
||||
# from every branch the moment the imported adventure was forked. The
|
||||
# root is the same answer the migration gives, and for the same reason
|
||||
# — 0 is at or before every fork point, so it is visible from every
|
||||
# path this adventure can grow.
|
||||
# A version 1 memory that summarizes nothing, which means one the player
|
||||
# typed, has no depth to derive. Leaving it NULL here would recreate on
|
||||
# import the state migration 62 exists to end. `Path._entry_clause`
|
||||
# compares `depth <= max_depth`, which a NULL fails, so the memory would
|
||||
# disappear from every branch as soon as the imported adventure was
|
||||
# forked. The root is the answer the migration gives, for the same
|
||||
# reason: 0 is at or before every fork point, so the memory is visible
|
||||
# from every path this adventure can grow.
|
||||
if memory.depth is None:
|
||||
memory.depth = lineage.ROOT_DEPTH
|
||||
db.add(memory)
|
||||
@@ -561,12 +570,13 @@ def _write_memories(
|
||||
def _point_the_head(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
"""Where the story is being played, and how deep it goes.
|
||||
"""Sets which branch the story is played on, and how deep it goes.
|
||||
|
||||
The branch comes from the file and the depth does not: the tip of a branch
|
||||
is whatever arrived on it, and a branch with nothing of its own sits at its
|
||||
fork point — the last node its story contains, borrowed but the tip all the
|
||||
same. The same rule as `tree.refresh_head`, applied before a flush.
|
||||
The branch comes from the file and the depth does not. The tip of a branch is
|
||||
whatever arrived on it, and a branch with no nodes of its own sits at its
|
||||
fork point, which is the last node its story contains. That node is borrowed
|
||||
but it is still the tip. This is the rule `tree.refresh_head` applies, run
|
||||
here before a flush.
|
||||
"""
|
||||
head = story["head"]
|
||||
adventure.head_branch_id = ids[head]
|
||||
@@ -581,10 +591,11 @@ def _point_the_head(
|
||||
def _write_anchors(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
"""How far the memories and the summary have read — v2 only.
|
||||
"""Records how far the memories and the summary have read. Version 2 only.
|
||||
|
||||
A v1 bundle counts instead, and a count cannot be resolved to a node until
|
||||
the nodes are in the database; `settle` does that half afterwards.
|
||||
A version 1 bundle stores a count instead, and a count cannot be resolved to
|
||||
a node until the nodes are in the database. `settle` does that part
|
||||
afterwards.
|
||||
"""
|
||||
anchors = story["anchors"]
|
||||
if anchors is None:
|
||||
@@ -595,15 +606,17 @@ def _write_anchors(
|
||||
|
||||
|
||||
def settle(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
"""Line up the two coordinate systems, once the nodes exist.
|
||||
"""Aligns the two coordinate systems, once the nodes exist.
|
||||
|
||||
The anchors and the legacy counts describe the same boundary in different
|
||||
words, and each version of the bundle brings one of them. A v1 file brings
|
||||
the count, so the anchor is found by counting that far along the story; a v2
|
||||
file brings the anchor, so the count is read back off it. The legacy columns
|
||||
are still written either way — they are what a rolled-back build reads.
|
||||
terms, and each version of the bundle carries one of them. A version 1 file
|
||||
carries the count, so the anchor is found by counting that far along the
|
||||
story. A version 2 file carries the anchor, so the count is read back from
|
||||
it. The legacy columns are written either way, because a rolled-back build
|
||||
reads them.
|
||||
|
||||
Called after the flush, because both directions need the actions queryable.
|
||||
The caller runs this after the flush, because both directions need the
|
||||
actions to be queryable.
|
||||
"""
|
||||
positions = story["positions"]
|
||||
for cursor in cursors.ALL:
|
||||
@@ -618,11 +631,12 @@ def settle(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
# Small coercions. A raw-dict import bypasses the schemas entirely, so
|
||||
# everything out of a bundle is whatever JSON happened to hold.
|
||||
# Small coercions. A raw-dict import bypasses the schemas, so every value from a
|
||||
# bundle is whatever the JSON held.
|
||||
|
||||
def _is_int(value) -> bool:
|
||||
"""`True` is an `int` in Python, and is not one in a coordinate."""
|
||||
"""Returns whether `value` is an integer. Python counts `True` as an `int`,
|
||||
and a coordinate does not."""
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
@@ -631,9 +645,12 @@ def _as_int(value, default: int) -> int:
|
||||
|
||||
|
||||
def _as_index(value, count: int, default: int | None = None) -> int | None:
|
||||
"""A local branch number, or `default` when the file names one that is not
|
||||
there. Out of range is a file disagreeing with itself, not a shape a read
|
||||
can be handed."""
|
||||
"""Returns a local branch number, or `default` when the file names a branch
|
||||
that is not present.
|
||||
|
||||
An out-of-range value means the file disagrees with itself, and it is not a
|
||||
value any read can be given.
|
||||
"""
|
||||
return value if _is_int(value) and 0 <= value < count else default
|
||||
|
||||
|
||||
|
||||
+35
-31
@@ -1,10 +1,11 @@
|
||||
"""Retention policy for throwaway guest accounts.
|
||||
|
||||
In multi-user mode every first visit mints a `users` row (GET /api/auth/me),
|
||||
so a public demo accumulates one account per curious visitor — most of whom
|
||||
never come back, each leaving behind whatever scenarios, adventures, actions
|
||||
and memories they generated. This drops guests that have gone quiet for
|
||||
AIDND_GUEST_RETENTION_DAYS (default 5) along with everything they made.
|
||||
In multi-user mode every first visit creates a `users` row through
|
||||
`GET /api/auth/me`, so a public demo accumulates one account per visitor. Most
|
||||
of those visitors never return, and each one leaves behind whatever scenarios,
|
||||
adventures, actions, and memories they generated. This module deletes guests
|
||||
that have been inactive for `AIDND_GUEST_RETENTION_DAYS`, which defaults to 5,
|
||||
along with everything they made.
|
||||
|
||||
Why this is safe to run unattended:
|
||||
|
||||
@@ -12,25 +13,26 @@ Why this is safe to run unattended:
|
||||
clauses are checked rather than either alone. Registering upgrades the row
|
||||
in place (is_guest -> False), so a guest who signs up keeps everything;
|
||||
local mode's implicit single user is also is_guest=False.
|
||||
- Idle time is COALESCE(last_seen_at, created_at). `auth._touch` only writes
|
||||
last_seen_at once an hour, and a guest minted by /auth/me has NULL until
|
||||
its *second* request, so created_at is the honest floor for a brand-new
|
||||
visitor — without the coalesce those rows look infinitely old.
|
||||
- Nothing a guest owns is reachable by anyone else: `is_public` is an
|
||||
output-only field (see schemas.ScenarioBase), so only seeded scenarios —
|
||||
which have user_id NULL and are therefore outside this filter entirely —
|
||||
are shared. Deleting a guest can't take content away from another user.
|
||||
- Idle time is `COALESCE(last_seen_at, created_at)`. `auth._touch` writes
|
||||
`last_seen_at` at most once an hour, and a guest created by `/auth/me` has
|
||||
NULL there until its second request, so `created_at` is the correct floor for
|
||||
a new visitor. Without the coalesce, those rows look arbitrarily old.
|
||||
- Nothing a guest owns is reachable by anyone else. `is_public` is an
|
||||
output-only field, as `schemas.ScenarioBase` shows, so the only shared
|
||||
scenarios are the seeded ones, which have a NULL `user_id` and are outside
|
||||
this filter. Deleting a guest cannot remove content from another user.
|
||||
|
||||
Why one Core DELETE instead of an ORM cascade: `db.delete(user)` would SELECT
|
||||
every adventure, action, memory and story card into Python purely to delete
|
||||
them, which on Neon is exactly the egress pattern that has already cost this
|
||||
project once. Every foreign key from users downwards is ON DELETE CASCADE
|
||||
(users -> scenarios/adventures/scripts/settings -> actions/memories/cards), so
|
||||
the database does the whole graph in one statement and ships back a row count.
|
||||
The sweep uses one Core DELETE rather than an ORM cascade. `db.delete(user)`
|
||||
would SELECT every adventure, action, memory, and story card into Python only to
|
||||
delete them, which on Neon is the egress pattern that has already cost this
|
||||
project once. Every foreign key from `users` downward is ON DELETE CASCADE, from
|
||||
users to scenarios, adventures, scripts, and settings, and from those to actions,
|
||||
memories, and cards, so the database deletes the whole graph in one statement and
|
||||
returns a row count.
|
||||
|
||||
No index is added for the scan: the sweep runs a handful of times a day
|
||||
against a table with at most a few thousand rows, which is not worth a
|
||||
migration and the schema surface that comes with it.
|
||||
The scan gets no index. The sweep runs a few times a day against a table holding
|
||||
at most a few thousand rows, which does not justify a migration and the schema
|
||||
surface it adds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -56,8 +58,8 @@ def _int_env(name: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
# Days of inactivity before a guest account is dropped. 0 or less disables the
|
||||
# policy entirely, for a deployment that would rather keep everything.
|
||||
# Days of inactivity before a guest account is deleted. A value of 0 or less
|
||||
# disables the policy, for a deployment that keeps everything.
|
||||
RETENTION_DAYS = _int_env("AIDND_GUEST_RETENTION_DAYS", 5)
|
||||
|
||||
# How often a long-lived process re-checks. Hours, not minutes: nothing here is
|
||||
@@ -86,10 +88,11 @@ def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
|
||||
"""
|
||||
if RETENTION_DAYS <= 0:
|
||||
return 0
|
||||
# Stored timestamps are naive UTC on both backends (SQLite drops tzinfo;
|
||||
# Postgres columns are TIMESTAMP WITHOUT TIME ZONE with the session pinned
|
||||
# to UTC in database.py). Match that exactly so the comparison can't hinge
|
||||
# on how a given dialect renders an aware value.
|
||||
# Stored timestamps are UTC without a timezone on both backends. SQLite
|
||||
# drops the timezone, and the Postgres columns are TIMESTAMP WITHOUT TIME
|
||||
# ZONE with the session pinned to UTC in `database.py`. Match that, so the
|
||||
# comparison does not depend on how a dialect renders a value that carries a
|
||||
# timezone.
|
||||
reference = now or models.utcnow()
|
||||
cutoff = reference.replace(tzinfo=None) - timedelta(days=RETENTION_DAYS)
|
||||
|
||||
@@ -100,9 +103,10 @@ def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
|
||||
models.User.email.is_(None),
|
||||
func.coalesce(models.User.last_seen_at, models.User.created_at) < cutoff,
|
||||
)
|
||||
# Without this, "auto" can't evaluate coalesce in Python and falls back
|
||||
# to fetching every matching primary key first — a second round trip
|
||||
# for nothing, since this session holds no User objects to synchronize.
|
||||
# Without this option, the "auto" strategy cannot evaluate coalesce in
|
||||
# Python and falls back to fetching every matching primary key first.
|
||||
# That is a second round trip for no benefit, because this session holds
|
||||
# no User objects to synchronize.
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
removed = db.execute(stmt).rowcount or 0
|
||||
|
||||
+11
-11
@@ -1,18 +1,18 @@
|
||||
"""Storing a JSON column compressed.
|
||||
|
||||
`actions.context_snapshot` holds the entire assembled prompt for a turn. It is
|
||||
89% of the database — 150.8 MB of JSON across 944 actions on production, and
|
||||
232 KB a row on the longest adventure — and the free tier this deploys to
|
||||
allows 512 MB. Reads are not the problem: the column is deferred, so a page
|
||||
load never touches it and exactly one endpoint fetches one row of it at a
|
||||
time. Storage is the problem, and storage has a cliff.
|
||||
89% of the database, which is 150.8 MB of JSON across 944 actions in production
|
||||
and 232 kB per row on the longest adventure, and the free tier this deploys to
|
||||
allows 512 MB. Reads are not the problem. The column is deferred, so a page load
|
||||
never touches it and exactly one endpoint fetches one row of it at a time.
|
||||
Storage is the problem, and storage has a hard limit.
|
||||
|
||||
Postgres already compresses it. TOAST brings 150.8 MB down to ~89 MB, a factor
|
||||
of 1.7 — pglz is chosen for decompression speed on data a query might filter
|
||||
on, which this never is. Nothing filters on a prompt; it is written once and
|
||||
read whole, occasionally, by one screen. zlib at the application layer gets
|
||||
three to four times on the same text, and the cost is a decompress on a
|
||||
request that already costs an LLM call.
|
||||
Postgres already compresses the column. TOAST brings 150.8 MB down to about
|
||||
89 MB, a factor of 1.7. Postgres chooses pglz for decompression speed on data a
|
||||
query might filter on, and no query filters on this column. A prompt is written
|
||||
once and read whole, occasionally, by one screen. zlib at the application layer
|
||||
reaches three to four times on the same text, and the cost is one decompression
|
||||
on a request that already makes an LLM call.
|
||||
|
||||
Doing it as a TypeDecorator rather than a second column keeps every call site
|
||||
writing `action.context_snapshot = {...}` and reading a dict back, and keeps
|
||||
|
||||
+112
-98
@@ -10,11 +10,11 @@
|
||||
[Author's Note] injected AUTHORS_NOTE_DEPTH actions before the end of history
|
||||
[Latest player action] (+ script frontMemory right after it, Phase 4)
|
||||
|
||||
Which components are present is AI Dungeon's design, above. The *order* they
|
||||
are laid out in is not: everything fixed is emitted first and everything that
|
||||
moves after the history, because prompt caching bills on a shared prefix and
|
||||
one mutable section high up re-prices the whole prompt under it. See the two
|
||||
"static block" / "live sections" comments in `build_context`.
|
||||
The list above comes from AI Dungeon's design. The order does not. This module
|
||||
emits every fixed section first and every changing section after the history,
|
||||
because prompt caching bills on a shared prefix. A section that changes near the
|
||||
top of the prompt re-prices everything below it. See the comments on the static
|
||||
block and the live sections in `build_context`.
|
||||
"""
|
||||
|
||||
import functools
|
||||
@@ -30,33 +30,33 @@ CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may
|
||||
NPC_WINDOW = 6 # actions of story searched for NPC trigger words ("in scene")
|
||||
SEPARATOR = "\n\n"
|
||||
|
||||
# Output-length guidance. max_output_tokens is a hard wall the endpoint enforces
|
||||
# mid-sentence: hitting it truncates whatever is being written, and since the
|
||||
# state block is emitted last, it is what gets lost. Asking the model to land
|
||||
# just inside the wall keeps the cut from happening in the first place.
|
||||
LENGTH_HEADROOM = 50 # tokens held back from the cap for the state block itself
|
||||
# Output-length guidance. The endpoint enforces `max_output_tokens` as a hard
|
||||
# limit, and it truncates the reply mid-sentence when the model reaches it. The
|
||||
# state block is emitted last, so truncation removes it. Asking the model to
|
||||
# finish inside the limit prevents the truncation.
|
||||
LENGTH_HEADROOM = 50 # Tokens reserved from the cap for the state block.
|
||||
# Models cannot count their own tokens, but they do follow a word budget, so the
|
||||
# reserved budget is stated in words. ~0.75 words per token for English prose.
|
||||
# hint states a number of words. English prose averages 0.75 words per token.
|
||||
WORDS_PER_TOKEN = 0.75
|
||||
# A word budget is a suggestion the model routinely overshoots, and the cap it is
|
||||
# protecting is a hard wall — so aim 10% short of the real ceiling and let the
|
||||
# overshoot land in the slack instead of in the state block.
|
||||
# Models regularly exceed a word budget, and the cap it protects is a hard
|
||||
# limit. Aiming 10% below the real ceiling leaves room for that overshoot, so it
|
||||
# does not consume the state block.
|
||||
LENGTH_BUFFER = 0.90
|
||||
MIN_LENGTH_HINT_WORDS = 40 # below this the hint is noise; a tiny cap speaks for itself
|
||||
# A ceiling alone is a one-sided instruction, and models read it very differently:
|
||||
# a verbose one is held back by it, while a terse one has nothing to act on except
|
||||
# the "write only as much as the moment needs" clause and collapses to two
|
||||
# paragraphs. Stating a floor as well turns the guidance into a band, so the same
|
||||
# prompt lands in the same place regardless of which way the model leans. Set as a
|
||||
# share of the ceiling so the floor can never approach it.
|
||||
MIN_LENGTH_HINT_WORDS = 40 # Below this, the hint adds nothing useful.
|
||||
# A ceiling on its own gives one-sided guidance, and models respond to it
|
||||
# differently. A verbose model treats it as a limit. A terse model has only the
|
||||
# instruction to write as much as the moment needs, and it produces two
|
||||
# paragraphs. Adding a floor turns the guidance into a range, so the same prompt
|
||||
# produces a similar length from either model. The floor is a share of the
|
||||
# ceiling so that it can never approach the ceiling.
|
||||
LENGTH_FLOOR_SHARE = 0.35
|
||||
# Below this a floor is meaningless — at a tight cap a short turn is the correct
|
||||
# turn — and the tight-cap wording is the one measured to keep the state block
|
||||
# alive, so it is left exactly as it was.
|
||||
# Below this word count, a floor means nothing, because a short turn is the
|
||||
# correct turn at a tight cap. The wording used at a tight cap is also the
|
||||
# wording that was measured to preserve the state block, so it is unchanged.
|
||||
MIN_LENGTH_FLOOR_WORDS = 60
|
||||
# The floor exists to stop a collapse to two paragraphs, not to demand an essay:
|
||||
# at a 2400-token cap the share alone would ask for 555 words *minimum*. Past this
|
||||
# point a reader wanting more length can say so in the author's note.
|
||||
# The floor prevents a collapse to two paragraphs. It does not ask for an essay.
|
||||
# At a 2400-token cap, the share alone would request a minimum of 555 words. A
|
||||
# reader who wants longer turns can ask for them in the author's note.
|
||||
MAX_LENGTH_FLOOR_WORDS = 300
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ class Section:
|
||||
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
|
||||
"""Ask for a turn that fits inside the output cap, stated as a word budget.
|
||||
|
||||
Returns "" when the cap is too small to phrase usefully — the hint is a
|
||||
suggestion the model can drift past, so it only earns its tokens when there
|
||||
is enough room for the drift to still land inside the wall.
|
||||
Returns an empty string when the cap is too small to state usefully. The
|
||||
model can exceed the hint, so the hint earns its tokens only when there is
|
||||
enough room for that overshoot to stay inside the cap.
|
||||
"""
|
||||
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
|
||||
if words < MIN_LENGTH_HINT_WORDS:
|
||||
@@ -102,25 +102,27 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
|
||||
else " Bring the turn to a close well inside the limit rather than "
|
||||
"stopping mid-sentence."
|
||||
)
|
||||
# Phrased as a ceiling, never as a budget. Measured against this model, "keep
|
||||
# this turn under about N words" reads as a target to fill: it moved a 174-word
|
||||
# average to 246 (n=5, every run longer than every unhinted one), i.e. the hint
|
||||
# pushed turns toward the very wall it exists to keep them away from. Naming
|
||||
# the number as a limit, plus saying a typical turn is far shorter, left the
|
||||
# average at 170 while still rescuing the state block at tight caps.
|
||||
# State the number as a ceiling, never as a budget. In measurements, the
|
||||
# wording "keep this turn under about N words" read to the model as a target
|
||||
# to fill. It raised the average from 174 words to 246 across five runs, and
|
||||
# every hinted run was longer than every unhinted run. The hint therefore
|
||||
# pushed turns toward the limit it exists to avoid. Naming the number as a
|
||||
# limit, and adding that a typical turn is much shorter, held the average at
|
||||
# 170 while still preserving the state block at tight caps.
|
||||
floor = min(int(words * LENGTH_FLOOR_SHARE), MAX_LENGTH_FLOOR_WORDS)
|
||||
if floor < MIN_LENGTH_FLOOR_WORDS:
|
||||
return (
|
||||
f"[Hard limit: this turn must not exceed {words} words. Write only as "
|
||||
f"much as the moment needs — a typical turn is much shorter.{tail}]"
|
||||
)
|
||||
# Both numbers are bounds, and deliberately asymmetric ones: "must not exceed"
|
||||
# for the wall the endpoint enforces, "should not stop short of" for the floor.
|
||||
# Neither is a target, which is what the measurement above says matters. The
|
||||
# "prefer the lower end" clause does the job the old "a typical turn is much
|
||||
# shorter" line did — holding a verbose model off the wall — but now with a
|
||||
# number under it, so a terse model reading the same clause lands on the floor
|
||||
# instead of at forty words.
|
||||
# Both numbers are bounds, and the wording is deliberately asymmetric. The
|
||||
# ceiling uses "must not exceed", because the endpoint enforces it. The floor
|
||||
# uses "should not stop short of". Neither reads as a target, which the
|
||||
# measurement above shows is what matters. The clause that asks the model to
|
||||
# prefer the lower end does the job the earlier wording did, which was to
|
||||
# keep a verbose model away from the ceiling. It now has a number beneath it,
|
||||
# so a terse model reading the same clause stops at the floor rather than at
|
||||
# forty words.
|
||||
return (
|
||||
f"[Hard limit: this turn must not exceed {words} words, and it should not "
|
||||
f"stop short of about {floor}. Prefer the lower end of that range unless "
|
||||
@@ -136,16 +138,19 @@ def _script_memory(adventure: models.Adventure) -> dict:
|
||||
|
||||
|
||||
def _history_text(action: models.Action) -> str:
|
||||
"""An AI turn as the model should see it in replayed history: its narration
|
||||
with the state block it emitted re-appended (reconstructed from the stored
|
||||
delta). The block is stripped before storage/UI, so without this every past
|
||||
AI turn would look like one that emitted nothing — biasing the model, by
|
||||
imitation, to stop emitting too. Player turns and blockless turns are
|
||||
returned unchanged.
|
||||
"""Returns an AI turn as the model should see it in replayed history.
|
||||
|
||||
Reads `world_delta`, not `context_snapshot`: this runs for every action in
|
||||
the replayed history, and the snapshot is deferred precisely so a turn
|
||||
never drags the prompt archive out of the database."""
|
||||
The result is the narration with its state block appended again,
|
||||
reconstructed from the stored delta. The app strips that block before
|
||||
storing and displaying the turn. Without this function, every past AI turn
|
||||
would appear to have emitted no state, and the model would copy that pattern
|
||||
and stop emitting state itself. Player turns and turns with no block pass
|
||||
through unchanged.
|
||||
|
||||
This function reads `world_delta` rather than `context_snapshot`. It runs
|
||||
for every action in the replayed history, and `context_snapshot` is deferred
|
||||
so that a turn never loads the prompt archive from the database.
|
||||
"""
|
||||
text = action.text
|
||||
wd = action.world_delta if isinstance(action.world_delta, dict) else None
|
||||
if wd:
|
||||
@@ -156,10 +161,13 @@ def _history_text(action: models.Action) -> str:
|
||||
|
||||
|
||||
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
|
||||
"""Defined NPCs whose trigger words appear in the recent story — the ones
|
||||
"in scene", so only their stats get injected. Maps npc id -> display name.
|
||||
"""Returns the NPCs whose trigger words appear in the recent story.
|
||||
|
||||
`actions` is already the last handful (see NPC_WINDOW)."""
|
||||
These are the NPCs in scene, and the prompt includes stats for them only.
|
||||
The result maps an NPC id to its display name.
|
||||
|
||||
`actions` holds only the most recent actions. See `NPC_WINDOW`.
|
||||
"""
|
||||
recent = SEPARATOR.join(a.text for a in actions).lower()
|
||||
visible: dict[str, str] = {}
|
||||
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
||||
@@ -171,8 +179,11 @@ def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str,
|
||||
|
||||
|
||||
def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
|
||||
"""AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word
|
||||
('boat' triggers on 'boats'). Returns one record per card with the keyword that fired."""
|
||||
"""Returns one record per matched story card, naming the keyword that matched.
|
||||
|
||||
Matching follows AI Dungeon's rules. It ignores case, respects spaces, and
|
||||
matches partial words, so "boat" matches "boats".
|
||||
"""
|
||||
haystack = window_text.lower()
|
||||
matched = []
|
||||
for card in cards:
|
||||
@@ -196,20 +207,19 @@ def build_context(
|
||||
`exclude_action_id` omits one action from the story (see history.py)."""
|
||||
script_mem = _script_memory(adventure)
|
||||
|
||||
# ----- The static block: byte-for-byte the same prompt every turn -----
|
||||
# The ordering here is a billing decision, not a stylistic one. Prompt
|
||||
# caching matches a *prefix*: an endpoint reuses the prompt up to the first
|
||||
# byte that differs from last time and no further. So one mutable section
|
||||
# near the top re-prices everything below it, and what is below it is the
|
||||
# story history, which is the bulk of the prompt. Anything that changes
|
||||
# turn to turn therefore goes *after* the history, in the live sections —
|
||||
# which is also where recency serves it best, the same reasoning that
|
||||
# already puts EMIT_REMINDER last.
|
||||
# ----- The static block, which is identical on every turn -----
|
||||
# This ordering exists to reduce cost. Prompt caching matches a prefix. The
|
||||
# endpoint reuses the prompt up to the first byte that differs from the
|
||||
# previous request, and no further. A section that changes near the top
|
||||
# therefore re-prices everything below it, and what sits below it is the
|
||||
# story history, which is most of the prompt. Sections that change from turn
|
||||
# to turn go after the history, among the live sections. Placing them there
|
||||
# also gives them the most recency, which is why `EMIT_REMINDER` goes last.
|
||||
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
|
||||
|
||||
# RPG world state (Phase 12): how to report changes. The live values are a
|
||||
# live section below; the schema-derived guide and the emit rule are fixed
|
||||
# for as long as the scenario is.
|
||||
# RPG world state (Phase 12): the instructions for reporting changes. The
|
||||
# live values go into a live section below. The guide derived from the
|
||||
# schema and the emit rule do not change while the scenario is unchanged.
|
||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
has_ws = worldstate.has_schema(stat_schema)
|
||||
if has_ws:
|
||||
@@ -227,13 +237,14 @@ def build_context(
|
||||
Section("plot_essentials", f"Plot essentials:\n{adventure.memory.strip()}")
|
||||
)
|
||||
|
||||
# ----- Live sections: everything that moves, built here, placed after the
|
||||
# history further down. Ordered least-volatile first, so a turn that
|
||||
# changes only the fastest-moving one keeps the others cached too: the
|
||||
# summary is rewritten every few turns, lore turns over with the scene, the
|
||||
# retrieved memories change on most turns, the stat values on nearly all.
|
||||
# `world_lore` joins them below — it is the history window that triggers
|
||||
# the cards, so it cannot be known yet.
|
||||
# ----- Live sections, which hold everything that changes -----
|
||||
# This code builds them here and places them after the history further down.
|
||||
# They are ordered from least to most volatile, so a turn that changes only
|
||||
# the fastest-moving section leaves the others cached. The summary is
|
||||
# rewritten every few turns. Lore changes with the scene. The retrieved
|
||||
# memories change on most turns, and the stat values change on nearly every
|
||||
# turn. `world_lore` is added below, because the history window determines
|
||||
# which cards trigger and that window is not known yet.
|
||||
summary_section = (
|
||||
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}")
|
||||
if adventure.story_summary.strip()
|
||||
@@ -265,9 +276,9 @@ def build_context(
|
||||
|
||||
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
|
||||
|
||||
# The live sections moved below the history but they are still in the
|
||||
# prompt, so they are still reserved against the budget. (`world_lore` is
|
||||
# not: it is budgeted out of `available` further down, as it always was.)
|
||||
# The live sections sit below the history, but they are still part of the
|
||||
# prompt, so they still count against the budget. `world_lore` is the
|
||||
# exception, because the code below budgets it out of `available`.
|
||||
reserved = (
|
||||
sum(s.tokens for s in system_sections)
|
||||
+ sum(
|
||||
@@ -282,10 +293,11 @@ def build_context(
|
||||
)
|
||||
available = max(256, settings.context_token_budget - reserved)
|
||||
|
||||
# Only the newest actions can reach the prompt: everything below is either
|
||||
# truncated to `available` tokens or stops at the budget. Fetch a window
|
||||
# that is provably larger than that and no more — a long adventure would
|
||||
# otherwise read its entire history every turn to use the tail of it.
|
||||
# Only the newest actions can reach the prompt, because the code below
|
||||
# either truncates the text to `available` tokens or stops at the budget.
|
||||
# Fetch a window that is provably larger than that and no larger. Otherwise
|
||||
# a long adventure reads its whole history on every turn and uses only the
|
||||
# end of it.
|
||||
actions = history.window_covering(
|
||||
adventure, available, count_tokens, exclude_action_id
|
||||
)
|
||||
@@ -319,8 +331,8 @@ def build_context(
|
||||
spent = 0
|
||||
oldest_truncated = False
|
||||
for action in reversed(actions):
|
||||
# Budget on the text as it will actually appear — with the re-attached
|
||||
# state block (B) when this adventure tracks world state.
|
||||
# Budget against the text as it appears in the prompt, which includes
|
||||
# the state block when this adventure tracks world state.
|
||||
rendered = _history_text(action) if has_ws else action.text
|
||||
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
|
||||
if spent + tokens > history_budget:
|
||||
@@ -339,9 +351,9 @@ def build_context(
|
||||
spent += tokens
|
||||
included_actions.reverse()
|
||||
|
||||
# ----- Assemble story text with author's note near the end -----
|
||||
# Re-attach each AI turn's state block (stripped before storage) so recent
|
||||
# history shows the model its own emit pattern to imitate.
|
||||
# ----- Assemble the story text, with the author's note near the end -----
|
||||
# Append each AI turn's state block again. The app strips it before storage,
|
||||
# and the recent history has to show the model the pattern to follow.
|
||||
texts = [_history_text(a) if has_ws else a.text for a in included_actions]
|
||||
note_sections: list[Section] = []
|
||||
if authors_note:
|
||||
@@ -353,21 +365,22 @@ def build_context(
|
||||
note_sections.append(Section("recent_history", SEPARATOR.join(after)))
|
||||
else:
|
||||
note_sections.append(Section("history", SEPARATOR.join(texts)))
|
||||
# The live sections, least volatile first (see where they are built). They
|
||||
# sit below the history so the history caches, and above the tail so the
|
||||
# three sections that are last for a reason stay last.
|
||||
# The live sections, ordered from least to most volatile. See the comment
|
||||
# where they are built. They go below the history so that the history stays
|
||||
# cached, and above the final sections so that those stay last.
|
||||
for live in (summary_section, lore_section, memories_section, world_state_section):
|
||||
if live is not None:
|
||||
note_sections.append(live)
|
||||
if front_memory:
|
||||
note_sections.append(Section("front_memory", front_memory))
|
||||
# Sits just above the emit reminder, which keeps the strongest recency slot:
|
||||
# the length budget is about the narration, the reminder about the block that
|
||||
# comes after it, so this is also the order the model has to act in.
|
||||
# Place the length hint just above the emit reminder, which keeps the last
|
||||
# position. The length budget applies to the narration, and the reminder
|
||||
# applies to the block that follows it, so this is also the order in which
|
||||
# the model acts.
|
||||
note_sections.append(Section("length_hint", length_note))
|
||||
if has_ws:
|
||||
# Terminal reminder: the emit rule sits up in the system block, far from
|
||||
# where the model generates; repeat it last, in the strongest recency slot.
|
||||
# The emit rule sits in the system block, far from where the model
|
||||
# generates text, so repeat it last where it has the most effect.
|
||||
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
|
||||
|
||||
story_sections = [s for s in note_sections if s.text]
|
||||
@@ -388,8 +401,9 @@ def build_context(
|
||||
"memories": memory_bank,
|
||||
"history": {
|
||||
"included": len(included_actions),
|
||||
# The whole story, not just the window fetched above — Insights
|
||||
# reports "N of M actions included" and M is the real total.
|
||||
# The count covers the whole story rather than the window fetched
|
||||
# above. Insights reports how many of the total actions it
|
||||
# included, so this number must be the real total.
|
||||
"total": history.count(adventure, exclude_action_id),
|
||||
"oldest_truncated": oldest_truncated,
|
||||
},
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
"""Phase 14 — how far along a story the derived work has got.
|
||||
"""Phase 14: tracks how far along a story the derived work has reached.
|
||||
|
||||
Two things are built from the story and stored beside it: the memories, and the
|
||||
Story Summary. Both need to know where they left off, and that mark used to be
|
||||
a *count* — "the first 12 story actions are covered". A count is a position in
|
||||
a list, and this list moves: delete an action from in front of the mark and
|
||||
every later action slides down a slot, so the mark now covers one it has never
|
||||
seen. Every rule in `memorybank` about sliding cursors, rewinding them and
|
||||
translating between positions and `Action.index` existed to patch that up, and
|
||||
each was a separate chance to get it wrong in a way nothing reports.
|
||||
Two things are built from the story and stored beside it: the memories and the
|
||||
story summary. Both need to record where they stopped.
|
||||
|
||||
A cursor here is an **anchor**: `(branch_id, depth)`, the node up to and
|
||||
including which the work is done. Deleting an action does not move it, because
|
||||
a depth is not a position — it is a coordinate along a path. "What is not
|
||||
covered yet" becomes `history.count_after(anchor)`, which is a question about
|
||||
the story rather than about a list index, and it answers correctly whatever has
|
||||
been deleted from in front of it.
|
||||
That mark used to be a count, such as "the first 12 story actions are covered".
|
||||
A count is a position in a list, and this list changes. If you delete an action
|
||||
in front of the mark, every later action moves down one slot, so the mark now
|
||||
covers an action it never read. The rules in `memorybank` for sliding cursors,
|
||||
rewinding them, and converting between positions and `Action.index` all existed
|
||||
to correct for that, and each rule was a chance to introduce a silent error.
|
||||
|
||||
The branch half is what makes it survive forking. A depth alone is ambiguous
|
||||
once two branches have a node 41; the anchor says which one, and
|
||||
`Path.depth_on` reads it back as a depth on whatever story is being played —
|
||||
capped at the fork, or "nothing covered" if the anchor sits on ground this path
|
||||
never travelled. Until forking ships there is one branch and that is always a
|
||||
no-op, which is the point: the coordinate system is right before anything needs
|
||||
it to be.
|
||||
A cursor here is an anchor instead. It stores `(branch_id, depth)`, naming the
|
||||
node up to and including which the work is done. Deleting an action does not
|
||||
move it, because a depth is a coordinate along a path rather than a position in
|
||||
a list. The question "what is not covered yet" becomes
|
||||
`history.count_after(anchor)`, which asks about the story rather than about a
|
||||
list index, and it stays correct no matter what is deleted in front of it.
|
||||
|
||||
`NO_DEPTH` (-1) is "nothing covered", so a fresh adventure needs no special
|
||||
case: every node is deeper than -1.
|
||||
The branch half of the anchor is what makes it survive forking. A depth alone is
|
||||
ambiguous once two branches both hold a node at depth 41. The anchor names the
|
||||
branch, and `Path.depth_on` reads it back as a depth on whichever story is being
|
||||
played. That read caps the depth at the fork, or reports nothing covered if the
|
||||
anchor sits on a branch this path does not contain. Until forking ships there is
|
||||
one branch, so this always returns the stored depth. That is the point: the
|
||||
coordinate system is correct before anything depends on it.
|
||||
|
||||
`NO_DEPTH`, which is -1, means nothing is covered. A new adventure therefore
|
||||
needs no special case, because every node is deeper than -1.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -37,12 +38,11 @@ NO_DEPTH = lineage.NO_DEPTH
|
||||
|
||||
|
||||
class Cursor:
|
||||
"""One anchor on the adventure row: the memory bank's, or the summary's.
|
||||
"""One anchor on the adventure row, for either the memory bank or the summary.
|
||||
|
||||
A pair of columns rather than a foreign key to the node. The node can be
|
||||
deleted — that is most of what undo does — and the boundary is still
|
||||
meaningful afterwards, so a pointer that has to resolve would be a pointer
|
||||
that keeps not resolving.
|
||||
The anchor is a pair of columns rather than a foreign key to the node. The
|
||||
node can be deleted, which is what undo does, and the boundary still means
|
||||
something afterwards. A foreign key would repeatedly fail to resolve.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
@@ -53,14 +53,14 @@ class Cursor:
|
||||
# ------------------------------------------------------------- reading
|
||||
|
||||
def stored(self, adventure: models.Adventure) -> tuple[int | None, int]:
|
||||
"""The anchor exactly as written, unread by any path."""
|
||||
"""Returns the anchor as written, without resolving it against a path."""
|
||||
depth = getattr(adventure, self.depth_field)
|
||||
return getattr(adventure, self.branch_field), (
|
||||
NO_DEPTH if depth is None else depth
|
||||
)
|
||||
|
||||
def depth(self, db: Session, adventure: models.Adventure) -> int:
|
||||
"""The anchor as a depth on the story currently being played."""
|
||||
"""Returns the anchor as a depth on the story being played now."""
|
||||
branch_id, depth = self.stored(adventure)
|
||||
return lineage.path_of(db, adventure).depth_on(branch_id, depth)
|
||||
|
||||
@@ -69,44 +69,47 @@ class Cursor:
|
||||
def anchor(
|
||||
self, adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Put the anchor at a coordinate given outright.
|
||||
"""Sets the anchor to a coordinate supplied directly.
|
||||
|
||||
The plain setter under `anchor_at`. Only an import has a coordinate
|
||||
without a node to read it off — a v2 bundle carries the anchor itself
|
||||
(`app/bundle.py`), and the node it named lives in another database.
|
||||
This is the plain setter beneath `anchor_at`. Only an import supplies a
|
||||
coordinate with no node to read it from. A v2 bundle carries the anchor
|
||||
itself, as described in `app/bundle.py`, and the node it named lives in
|
||||
a different database.
|
||||
"""
|
||||
setattr(adventure, self.branch_field, branch_id)
|
||||
setattr(adventure, self.depth_field, max(depth, NO_DEPTH))
|
||||
|
||||
def anchor_at(self, adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Mark the work done up to and including `node`.
|
||||
"""Marks the work done up to and including `node`.
|
||||
|
||||
Takes the node's own branch, not the adventure's head: a block of six
|
||||
actions can end before the fork this branch was made at, and the
|
||||
coverage belongs where the ground is.
|
||||
This uses the node's own branch rather than the adventure's head. A block
|
||||
of six actions can end before the fork that created the current branch,
|
||||
and the coverage belongs where those actions are.
|
||||
"""
|
||||
self.anchor(adventure, node.branch_id, lineage.NO_DEPTH
|
||||
if node.depth is None else node.depth)
|
||||
|
||||
def clear(self, adventure: models.Adventure) -> None:
|
||||
"""Forget the anchor entirely: nothing is covered.
|
||||
"""Clears the anchor, so that nothing counts as covered.
|
||||
|
||||
For when the ground the anchor stood on is gone — a deleted branch. On
|
||||
Postgres a stale branch id would simply never resolve, but SQLite hands
|
||||
a freed id to the next fork, and an anchor that resolves onto a branch
|
||||
it has never seen would report a stretch of story as already
|
||||
summarized. Clearing costs a re-summarize, which is the safe direction.
|
||||
Call this when the branch the anchor referred to is deleted. On Postgres
|
||||
a stale branch id would never resolve. On SQLite the next fork can reuse
|
||||
an id that was just freed, and a stale anchor would then resolve onto a
|
||||
branch it never saw and report that stretch of story as summarized.
|
||||
Clearing the anchor costs one re-summarize, which is the safe direction
|
||||
to be wrong in.
|
||||
"""
|
||||
self.anchor(adventure, None, NO_DEPTH)
|
||||
|
||||
def rewind_to(
|
||||
self, adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Move the anchor back to `depth` if it is past it; never forward.
|
||||
"""Moves the anchor back to `depth` if it is past that depth.
|
||||
|
||||
The one direction that is safe without knowing what else has happened:
|
||||
re-covering ground costs a summarizer call, skipping it loses a stretch
|
||||
of story out of the memories for good.
|
||||
The anchor never moves forward here. Moving backward is the only
|
||||
direction that is safe without knowing what else changed. Covering
|
||||
ground twice costs one summarizer call. Skipping ground removes a
|
||||
stretch of story from the memories permanently.
|
||||
"""
|
||||
_, current = self.stored(adventure)
|
||||
if current <= depth:
|
||||
@@ -123,12 +126,12 @@ ALL = (MEMORY, SUMMARY)
|
||||
def rewind_all(
|
||||
adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Hand a stretch of story back to *both* passes.
|
||||
"""Returns a stretch of story to both the memory pass and the summary pass.
|
||||
|
||||
They move together because they cover the same ground from different sides:
|
||||
the summary folds in the memories, so a memory withdrawn without rewinding
|
||||
the summary leaves the summary claiming to have read something no longer
|
||||
there.
|
||||
The two move together because they cover the same actions from different
|
||||
directions. The summary folds in the memories, so withdrawing a memory
|
||||
without rewinding the summary would leave the summary claiming to have read
|
||||
something that no longer exists.
|
||||
"""
|
||||
for cursor in ALL:
|
||||
cursor.rewind_to(adventure, branch_id, depth)
|
||||
@@ -137,18 +140,19 @@ def rewind_all(
|
||||
def anchor_at_position(
|
||||
adventure: models.Adventure, cursor: Cursor, position: int
|
||||
) -> None:
|
||||
"""Set `cursor` from a count of covered story actions — a v1 bundle's mark,
|
||||
or a database written before the anchors existed.
|
||||
"""Sets `cursor` from a count of covered story actions.
|
||||
|
||||
The position-th story action in depth order is the node that says the same
|
||||
thing, and goes on saying it once something in front of it is deleted. A
|
||||
position past the end of the story is not a bad value: an adventure caught
|
||||
up under the older rule can carry one, and it means the same thing the tip
|
||||
does, so that is where it lands.
|
||||
A v1 bundle stores its mark as a count, and so does a database written
|
||||
before the anchors existed.
|
||||
|
||||
The SQL half of this rule is `migrations._backfill_cursor_anchors`, which
|
||||
has to do it for every adventure at once without loading any of them; the
|
||||
two must agree.
|
||||
The action at `position` in depth order is the node that carries the same
|
||||
meaning, and it keeps that meaning after something in front of it is
|
||||
deleted. A position past the end of the story is not an invalid value. An
|
||||
adventure that was fully caught up under the old rule can hold one, and it
|
||||
means the same thing as the tip, so this function anchors at the tip.
|
||||
|
||||
`migrations._backfill_cursor_anchors` implements this rule in SQL for every
|
||||
adventure at once, without loading any of them. The two must agree.
|
||||
"""
|
||||
if position <= 0:
|
||||
return
|
||||
@@ -158,11 +162,11 @@ def anchor_at_position(
|
||||
|
||||
|
||||
def position_of(adventure: models.Adventure, depth: int) -> int:
|
||||
"""How many story actions lie at or before `depth` — an anchor read back as
|
||||
a count.
|
||||
"""Returns how many story actions lie at or before `depth`.
|
||||
|
||||
The v1 export bundle stores the cursors as positions, and a v1 bundle is
|
||||
read by builds that have never heard of a depth. This is the one place that
|
||||
still speaks that coordinate system, and SP6's v2 format retires it.
|
||||
This reads an anchor back as a count. The v1 export bundle stores cursors as
|
||||
counts, and builds that have never used depths read v1 bundles. This
|
||||
function is the only remaining code that speaks that coordinate system. The
|
||||
v2 format introduced in SP6 replaces it.
|
||||
"""
|
||||
return max(history.count(adventure) - history.count_after(adventure, depth), 0)
|
||||
|
||||
+140
-124
@@ -1,42 +1,44 @@
|
||||
"""Reading the story without reading all of it.
|
||||
"""Reads part of a story without loading all of it.
|
||||
|
||||
`story_actions()` walked `adventure.actions`, which loads every row of the
|
||||
adventure — then every caller threw almost all of it away. The context builder
|
||||
concatenates the story and immediately cuts it back to the token budget; the
|
||||
NPC-in-scene check looks at the last 6; memory retrieval looks at the last 4;
|
||||
the post-turn cursor clamp only wants a count. So a turn on a 200-action
|
||||
adventure read ~840 KB to use maybe 70 KB of it, and the cost grew with every
|
||||
turn played.
|
||||
`story_actions()` used to walk `adventure.actions`, which loads every row of the
|
||||
adventure. Every caller then discarded nearly all of those rows. The context
|
||||
builder joins the story and immediately trims it to the token budget. The
|
||||
in-scene NPC check reads the last 6 actions. Memory retrieval reads the last 4.
|
||||
The post-turn cursor clamp needs only a count. A turn on a 200-action adventure
|
||||
read about 840 KB in order to use about 70 KB, and the cost grew with every
|
||||
turn.
|
||||
|
||||
This module serves those shapes directly from SQL — a tail, a slice, a count —
|
||||
so the read is bounded by the context budget instead of by the length of the
|
||||
story.
|
||||
This module serves those shapes from SQL directly, as a tail, a slice, or a
|
||||
count. A read is therefore bounded by the context budget rather than by the
|
||||
length of the story.
|
||||
|
||||
Three rules hold everything together:
|
||||
Three rules hold the module together:
|
||||
|
||||
* **One definition of "story action".** Membership decides what a reader sees
|
||||
and what the summarizer is handed, so SQL and Python must agree on it
|
||||
exactly. `_STORY_TEXT` and `is_story_text()` are that one definition, written
|
||||
twice; keep them in step.
|
||||
* **Never load twice.** If `adventure.actions` is already in memory (the
|
||||
scripting pipeline hands the whole history to user scripts, as AI Dungeon
|
||||
does), every helper here slices that instead of issuing a query, so a
|
||||
scripted adventure pays what it always paid and nothing more.
|
||||
* **Every read goes through the branch clause** (Phase 14). `adventure.actions`
|
||||
is every branch's actions, not the story being played — so the shortcut above
|
||||
cuts the loaded collection down to the path before slicing it, exactly as the
|
||||
SQL does. This is the line that would silently assemble a prompt out of two
|
||||
different stories, which is why `lineage.Path` owns both halves of it.
|
||||
- There is one definition of a story action. That definition decides both what
|
||||
a reader sees and what the summarizer receives, so the SQL and the Python must
|
||||
agree exactly. `_STORY_TEXT` and `is_story_text()` express the same rule
|
||||
twice. Keep them in step.
|
||||
- No caller loads the same rows twice. If `adventure.actions` is already in
|
||||
memory, every helper here slices that collection instead of running a query.
|
||||
The scripting pipeline hands the whole history to user scripts, as AI Dungeon
|
||||
does, so a scripted adventure costs no more than it did before.
|
||||
- Every read applies the branch clause, as of Phase 14. `adventure.actions`
|
||||
holds the actions of every branch rather than the story being played, so the
|
||||
in-memory path filters the collection down to the path before slicing it, in
|
||||
the same way the SQL does. Skipping that filter would build a prompt from two
|
||||
different stories without reporting an error, which is why `lineage.Path`
|
||||
owns both forms of the rule.
|
||||
|
||||
Ordering is by `depth` now, not `index`. Since SP4 the two can hold the same
|
||||
number on *different rows* — attempts at one turn share both — so only `depth`
|
||||
plus the branch clause's `live` test says which of them the story is.
|
||||
Reads order by `depth` rather than `index`. Since SP4, two different rows can
|
||||
hold the same value for both columns, because the attempts at one turn share
|
||||
them. Only `depth` together with the `live` test in the branch clause
|
||||
identifies the row the story uses.
|
||||
|
||||
SP3 added the reads that count *from a node* rather than from the start —
|
||||
`count_after`, `after`, `newest`. The memory bank used to ask for
|
||||
"positions 12 to 18 of the story", which is a question whose answer moves when
|
||||
an action is deleted from in front of it. It now asks for "the six actions
|
||||
after depth 41", which is the same question a fork has to answer anyway.
|
||||
SP3 added the reads that count from a node rather than from the start:
|
||||
`count_after`, `after`, and `newest`. The memory bank used to ask for positions
|
||||
12 through 18 of the story, and the answer to that question changes when an
|
||||
action in front of those positions is deleted. It now asks for the six actions
|
||||
after depth 41, which is the question that forking requires in any case.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, inspect as sa_inspect
|
||||
@@ -45,17 +47,17 @@ from sqlalchemy.orm import Session, defer, object_session
|
||||
from .. import models
|
||||
from . import lineage
|
||||
|
||||
# How many of the newest actions to read before checking whether the token
|
||||
# budget is covered. When it isn't, the next size is worked out from the
|
||||
# average action length just measured rather than by blind doubling — guessing
|
||||
# high means reading hundreds of actions to use sixty of them.
|
||||
# How many of the newest actions to read before checking whether they cover the
|
||||
# token budget. If they do not, the next size comes from the average action
|
||||
# length just measured rather than from doubling the previous size. Doubling
|
||||
# overshoots, which means reading hundreds of actions in order to use sixty.
|
||||
WINDOW_START = 32
|
||||
WINDOW_MARGIN = 0.15 # aim this far past the budget, so one more round is rare
|
||||
WINDOW_STEP = 8 # ...and at least this many more actions each round
|
||||
WINDOW_MARGIN = 0.15 # Aim this far past the budget, so a second round is rare.
|
||||
WINDOW_STEP = 8 # Read at least this many more actions in each round.
|
||||
|
||||
# Depth is the ordering key; id breaks the tie that a pre-tree row (depth NULL,
|
||||
# and so invisible anyway) or a future sibling pair would otherwise leave to
|
||||
# the database's mood.
|
||||
# `depth` is the ordering key, and `id` breaks ties. Without `id`, the database
|
||||
# would choose the order. Two rows can share a depth: a pre-tree row, which has
|
||||
# a NULL depth and is invisible to reads, or a pair of sibling attempts.
|
||||
_OLDEST_FIRST = (models.Action.depth, models.Action.id)
|
||||
_NEWEST_FIRST = (models.Action.depth.desc(), models.Action.id.desc())
|
||||
|
||||
@@ -63,12 +65,13 @@ _NEWEST_FIRST = (models.Action.depth.desc(), models.Action.id.desc())
|
||||
def _sql_stripped(column):
|
||||
"""`column` with leading/trailing whitespace removed, portably.
|
||||
|
||||
SQLite and Postgres both accept single-argument `trim()`, but it strips
|
||||
spaces only — Python's `.strip()` also drops newlines and tabs, and an
|
||||
action of nothing but a newline would otherwise count as story text here
|
||||
and not in Python. `replace()` and `trim()` are the two string functions
|
||||
both dialects spell identically, so fold the other whitespace into spaces
|
||||
first. (Form feed and vertical tab are not covered; nothing produces them.)
|
||||
SQLite and Postgres both accept `trim()` with a single argument, but that
|
||||
form removes spaces only. Python's `str.strip()` also removes newlines and
|
||||
tabs. Without this helper, an action containing only a newline would count
|
||||
as story text in SQL but not in Python. Both dialects spell `replace()` and
|
||||
`trim()` the same way, so this function converts the other whitespace to
|
||||
spaces first. It does not handle form feed or vertical tab, because nothing
|
||||
produces them.
|
||||
"""
|
||||
folded = column
|
||||
for char in ("\n", "\r", "\t"):
|
||||
@@ -80,15 +83,18 @@ _STORY_TEXT = _sql_stripped(models.Action.text) != ""
|
||||
|
||||
|
||||
def is_story_text(text: str) -> bool:
|
||||
"""The Python half of `_STORY_TEXT` — keep the two in step."""
|
||||
"""Returns whether `text` counts as story text.
|
||||
|
||||
This is the Python form of `_STORY_TEXT`. Keep the two in step.
|
||||
"""
|
||||
return bool(text.strip())
|
||||
|
||||
|
||||
def _loaded_actions(adventure: models.Adventure) -> list[models.Action] | None:
|
||||
"""The adventure's actions if they are already in memory, else None.
|
||||
|
||||
Slicing an already-loaded collection is free; issuing a query beside it
|
||||
would mean paying for the same rows twice.
|
||||
Slicing a collection that is already loaded costs nothing, and running a
|
||||
query beside it would fetch the same rows a second time.
|
||||
"""
|
||||
state = sa_inspect(adventure)
|
||||
if state.detached or "actions" in state.unloaded:
|
||||
@@ -101,11 +107,14 @@ def _from_memory(
|
||||
) -> list[models.Action] | None:
|
||||
"""The story, from the already-loaded collection, or None to go to SQL.
|
||||
|
||||
The collection is the *adventure's* actions — every branch of it. Cutting
|
||||
it down to the path here is the same filter the SQL applies, and skipping
|
||||
it would hand the context builder a prompt assembled from siblings of the
|
||||
story being played. The path needs a session to read the branch row from;
|
||||
without one there is no answer to give, so say so rather than guess.
|
||||
The collection holds the adventure's actions, which means the actions of
|
||||
every branch. Filtering it down to the path here applies the same rule that
|
||||
the SQL applies. Without that filter, the context builder would receive a
|
||||
prompt built from siblings of the story being played.
|
||||
|
||||
Resolving the path requires a session to read the branch row from. If there
|
||||
is no session, this function returns None so that the caller falls back to
|
||||
SQL rather than guessing.
|
||||
"""
|
||||
loaded = _loaded_actions(adventure)
|
||||
if loaded is None:
|
||||
@@ -131,11 +140,10 @@ def _filters(
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
) -> list:
|
||||
# adventure_id is redundant beside the branch clause — branch ids are
|
||||
# unique, so a branch already names one adventure. It stays because it is
|
||||
# the cheap half of the check that catches a node written onto the wrong
|
||||
# adventure's branch, and because a clause nobody can read is a clause
|
||||
# nobody maintains.
|
||||
# `adventure_id` is redundant beside the branch clause, because branch ids
|
||||
# are unique and a branch already identifies one adventure. The filter
|
||||
# remains because it costs little, it catches a node written onto another
|
||||
# adventure's branch, and it makes the query easier to read.
|
||||
conditions = [
|
||||
models.Action.adventure_id == adventure.id,
|
||||
path.clause(models.Action, count=entries),
|
||||
@@ -171,11 +179,11 @@ def _count_query(
|
||||
):
|
||||
"""A real `SELECT count(...)`.
|
||||
|
||||
Deliberately not `_query(...).count()`: that wraps the entity select in a
|
||||
subquery, so the emitted SQL names every column — including the deferred
|
||||
ones this whole design exists to keep off the wire. No bytes come back
|
||||
either way, but the database still has to read them, and an egress guard
|
||||
that greps the SQL cannot tell the two apart.
|
||||
This function deliberately avoids `_query(...).count()`. That form wraps the
|
||||
entity select in a subquery, so the emitted SQL names every column,
|
||||
including the deferred columns that this design keeps off the wire. Neither
|
||||
form returns those bytes to the client, but the database still reads them,
|
||||
and an egress guard that inspects the SQL cannot tell the two forms apart.
|
||||
"""
|
||||
return db.query(func.count(models.Action.id)).filter(
|
||||
*_filters(adventure, path, exclude_action_id, entries)
|
||||
@@ -197,14 +205,14 @@ def story_actions(
|
||||
) -> list[models.Action]:
|
||||
"""Every story action, oldest first.
|
||||
|
||||
Still the right call where the whole story is genuinely wanted — user
|
||||
scripts receive it, per AI Dungeon's scripting API. Prefer `tail`, `slice_`
|
||||
or `count` anywhere the caller only needs part of it.
|
||||
Call this function when you need the whole story. User scripts receive it,
|
||||
which matches AI Dungeon's scripting API. Use `tail`, `slice_`, or `count`
|
||||
when you need only part of the story.
|
||||
|
||||
`exclude_action_id` drops one action from the story — used by retry, where
|
||||
the attempt being replaced is still the live node of its turn (it stays
|
||||
live until a replacement exists) but must not appear in the context
|
||||
assembled to replace it.
|
||||
`exclude_action_id` removes one action from the result. Retry uses it. The
|
||||
attempt being replaced is still the live node of its turn, because it stays
|
||||
live until a replacement exists, but it must not appear in the context that
|
||||
is assembled to replace it.
|
||||
"""
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
@@ -241,18 +249,20 @@ def tail_range(
|
||||
) -> list[models.Action]:
|
||||
"""`limit` story actions ending `skip` actions before the end, oldest first.
|
||||
|
||||
`skip=0` is the newest slice; `skip=32, limit=16` is the 16 actions just
|
||||
older than the newest 32. Lets a growing window fetch only the part it
|
||||
doesn't already have.
|
||||
Passing `skip=0` returns the newest slice. Passing `skip=32` and `limit=16`
|
||||
returns the 16 actions immediately older than the newest 32. A growing
|
||||
window therefore fetches only the actions it does not already hold.
|
||||
|
||||
This is the read the lineage window exists for. The path's ranges are
|
||||
disjoint and descending, so the newest N nodes come from the newest few
|
||||
lineage entries and the rest of the ancestry need not be named at all: a
|
||||
story forked two hundred times reads its tail with as few clauses as one
|
||||
forked never. `prefix_covering` estimates how many entries that takes from
|
||||
depth arithmetic alone; the estimate is only ever short where a middle
|
||||
action was deleted, and then the read widens to the whole lineage and pays
|
||||
one more query.
|
||||
This read is the reason the lineage window exists. The path's ranges do not
|
||||
overlap and they descend, so the newest N nodes come from the newest few
|
||||
lineage entries and the query never has to name the rest of the ancestry. A
|
||||
story that has forked 200 times reads its tail with as few clauses as one
|
||||
that has never forked.
|
||||
|
||||
`prefix_covering` estimates how many entries that takes, using depth
|
||||
arithmetic alone. The estimate falls short only when an action was deleted
|
||||
from the middle of the story. In that case this function widens the read to
|
||||
the whole lineage, at the cost of one more query.
|
||||
"""
|
||||
if limit <= 0 or skip < 0:
|
||||
return []
|
||||
@@ -295,11 +305,12 @@ def slice_(
|
||||
) -> list[models.Action]:
|
||||
"""Story actions at positions [start, start + length), oldest first.
|
||||
|
||||
Positions are into the same filtered, depth-ordered list the memory cursors
|
||||
count in, which is why the filter has to match Python's exactly.
|
||||
Positions index into the same filtered, depth-ordered list that the memory
|
||||
cursors count in, which is why the SQL filter must match the Python filter
|
||||
exactly.
|
||||
|
||||
Counts from the oldest end, so it names the whole lineage: there is no
|
||||
prefix of the ancestry that holds "the story's first ten actions".
|
||||
This function counts from the oldest end, so it names the whole lineage. No
|
||||
prefix of the ancestry contains the first ten actions of the story.
|
||||
"""
|
||||
if length <= 0 or start < 0:
|
||||
return []
|
||||
@@ -321,9 +332,9 @@ def slice_(
|
||||
def depth_of(action: models.Action) -> int:
|
||||
"""`action.depth`, with the no-depth case spelled once.
|
||||
|
||||
A row with no depth is a pre-tree row, which no path contains — so it can
|
||||
only turn up in an already-loaded collection, and it sorts before the story
|
||||
rather than after it.
|
||||
A row with no depth predates the tree. No path contains such a row, so it
|
||||
appears only in a collection that is already loaded. It sorts before the
|
||||
story rather than after it.
|
||||
"""
|
||||
return action.depth if action.depth is not None else lineage.NO_DEPTH
|
||||
|
||||
@@ -333,14 +344,14 @@ def count_after(
|
||||
) -> int:
|
||||
"""How many story actions lie past `depth` on the path.
|
||||
|
||||
The node-anchored replacement for "the story is N long and the cursor is at
|
||||
M". Deleting an action from in front of the boundary makes this number
|
||||
smaller, which is true; it does not make the boundary point somewhere else,
|
||||
which is the bug the positions had.
|
||||
This replaces the older calculation, which compared the length of the story
|
||||
with the position of the cursor. Deleting an action in front of the boundary
|
||||
makes this number smaller, which is correct. It does not move the boundary
|
||||
to a different action, which is the error that positions produced.
|
||||
|
||||
`covering_after` says exactly which lineage entries can hold a node deeper
|
||||
than the boundary, so a cursor near the tip names one branch however many
|
||||
forks are below it.
|
||||
`covering_after` reports which lineage entries can hold a node deeper than
|
||||
the boundary, so a cursor near the tip names one branch however many forks
|
||||
lie below it.
|
||||
"""
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
@@ -367,8 +378,9 @@ def after(
|
||||
) -> list[models.Action]:
|
||||
"""The oldest `limit` story actions past `depth`, oldest first.
|
||||
|
||||
"The next block the summarizer has not seen", asked as a fact about the
|
||||
story rather than as an offset into a list that shifts underneath it.
|
||||
This returns the next block that the summarizer has not read. It asks a
|
||||
question about the story rather than using an offset into a list whose
|
||||
entries move.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
@@ -391,14 +403,15 @@ def after(
|
||||
def newest(adventure: models.Adventure) -> models.Action | None:
|
||||
"""The newest story action, or None on an empty story.
|
||||
|
||||
A row, not a count and an offset: this is the node an anchor moves to when
|
||||
derived work catches up with the end of the story.
|
||||
This returns a row rather than a count and an offset, because it names the
|
||||
node an anchor moves to when derived work reaches the end of the story.
|
||||
|
||||
It used to be the *second* newest — the memory bank held one action back
|
||||
because retry rewrote a row, so a memory covering the newest action could
|
||||
end up describing narration the player had retried away. Since SP4 a retry
|
||||
writes a sibling instead, and the coordinate's derived work is withdrawn
|
||||
when the story at it changes, so there is nothing left to hold back.
|
||||
It used to return the second newest action. The memory bank held one action
|
||||
back because retry rewrote a row, so a memory that covered the newest action
|
||||
could describe narration the player had already replaced. Since SP4, a retry
|
||||
writes a sibling row instead, and the derived work at a coordinate is
|
||||
withdrawn when the story at that coordinate changes. There is nothing left
|
||||
to hold back.
|
||||
"""
|
||||
rows = tail(adventure, 1)
|
||||
return rows[0] if rows else None
|
||||
@@ -407,11 +420,12 @@ def newest(adventure: models.Adventure) -> models.Action | None:
|
||||
def max_action_index(adventure: models.Adventure) -> int:
|
||||
"""Highest `Action.index` in the adventure, story text or not. -1 if empty.
|
||||
|
||||
The one read here that is deliberately *not* path-scoped. `index` is the
|
||||
legacy column, kept unread until SP8 drops it, and its only remaining job
|
||||
is to hand the next row a number nothing else holds — which is a fact about
|
||||
the adventure, not about the story being played. Scoping it to a branch
|
||||
would let two branches issue the same index.
|
||||
This is the only read in the module that is deliberately not scoped to a
|
||||
path. `index` is a legacy column that remains unread until SP8 drops it. Its
|
||||
one remaining job is to give the next row a number that no other row holds,
|
||||
which is a fact about the adventure rather than about the story being
|
||||
played. Scoping the query to a branch would let two branches issue the same
|
||||
index.
|
||||
"""
|
||||
loaded = _loaded_actions(adventure)
|
||||
if loaded is not None:
|
||||
@@ -433,16 +447,18 @@ def window_covering(
|
||||
token_counter,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> list[models.Action]:
|
||||
"""The newest story actions whose combined text exceeds `budget_tokens` —
|
||||
i.e. more than the context builder can possibly include, and never less.
|
||||
"""Returns the newest story actions whose combined text exceeds `budget_tokens`.
|
||||
|
||||
Measures rather than guesses a chars-per-token ratio, so the prompt is
|
||||
byte-for-byte what loading the whole story would have produced. Budgets on
|
||||
the raw text, which is never longer than the rendered history text, so
|
||||
erring here can only mean fetching slightly too much.
|
||||
The result always holds at least as much text as the context builder can
|
||||
include, and never less.
|
||||
|
||||
Each round fetches only the actions it doesn't already hold, so no row is
|
||||
ever read twice however many rounds it takes.
|
||||
This function counts tokens rather than estimating a characters-per-token
|
||||
ratio, so the prompt matches what loading the whole story would produce. It
|
||||
budgets against the raw text, which is never longer than the rendered
|
||||
history text, so any error causes it to fetch slightly more than needed.
|
||||
|
||||
Each round fetches only the actions it does not already hold, so no row is
|
||||
read twice however many rounds the loop takes.
|
||||
"""
|
||||
actions: list[models.Action] = []
|
||||
tokens = 0
|
||||
@@ -452,15 +468,15 @@ def window_covering(
|
||||
adventure, len(actions), size - len(actions), exclude_action_id
|
||||
)
|
||||
if not older:
|
||||
return actions # already holding the whole story
|
||||
return actions # The result already holds the whole story.
|
||||
actions = older + actions
|
||||
tokens += sum(token_counter(a.text) for a in older)
|
||||
if len(actions) < size:
|
||||
return actions # that was the whole story
|
||||
return actions # That was the whole story.
|
||||
if tokens > budget_tokens:
|
||||
return actions
|
||||
# Short. Project how many actions the budget takes at the length these
|
||||
# ones turned out to be, and go straight there.
|
||||
# The window is still short. Estimate how many actions the budget needs
|
||||
# at the average length just measured, then read that many.
|
||||
average = tokens / len(actions)
|
||||
projected = int(budget_tokens / average * (1 + WINDOW_MARGIN)) + WINDOW_STEP
|
||||
size = max(projected, size + WINDOW_STEP)
|
||||
|
||||
+116
-106
@@ -1,35 +1,35 @@
|
||||
"""Phase 14 — which nodes are "this story".
|
||||
"""Phase 14: decides which nodes make up one story.
|
||||
|
||||
`tree.py` decides where a node is written. This module is the other half: it
|
||||
decides which nodes a read can see, and it is the **only** place that knows.
|
||||
`tree.py` decides where a node is written. This module decides which nodes a
|
||||
read can see, and it is the only place that makes that decision.
|
||||
|
||||
A branch owns the nodes played on it and *borrows* everything before its fork
|
||||
point from its ancestors, so "the story on branch C" is not a column you can
|
||||
filter on — it is an OR of ranges::
|
||||
A branch owns the nodes played on it and inherits everything before its fork
|
||||
point from its ancestors. "The story on branch C" is therefore not a value you
|
||||
can filter a column on. It is an OR of ranges::
|
||||
|
||||
(branch_id = C) -- C's own nodes, to the tip
|
||||
(branch_id = C) -- C's own nodes, through to the tip
|
||||
OR (branch_id = B AND depth <= 5)
|
||||
OR (branch_id = A AND depth <= 3)
|
||||
|
||||
which is exactly what `branches.lineage` spells out, newest first, computed
|
||||
once when the fork happens. Reads never walk parent pointers to rebuild it.
|
||||
The `branches.lineage` column records exactly that list, newest first. The fork
|
||||
computes it once, so no read walks parent pointers to rebuild it.
|
||||
|
||||
Two properties fall out of the shape, and both are load-bearing:
|
||||
The shape of the list gives two properties that the windowed reads depend on:
|
||||
|
||||
* **The ranges are disjoint and descending.** A branch's own nodes always sit
|
||||
deeper than its fork point, and each lineage entry is capped at the fork
|
||||
depth of the branch beneath it. So ordering the whole clause by `depth`
|
||||
descending is the same as reading entry 0's nodes, then entry 1's, then
|
||||
entry 2's — which is what lets a tail read use only the newest few entries
|
||||
and stop.
|
||||
* **Clause count is bounded by the context window, not by fork count.** A
|
||||
200-fork story whose newest branch is 40 turns long reads with one clause,
|
||||
because the window is covered before the second entry is reached. That is
|
||||
`prefix_covering`, and it is why `history.window_covering` can keep its shape.
|
||||
- The ranges do not overlap, and they descend. A branch's own nodes always sit
|
||||
deeper than its fork point, and each lineage entry is capped at the fork depth
|
||||
of the branch below it. Ordering the whole clause by `depth` descending
|
||||
therefore reads entry 0's nodes, then entry 1's, then entry 2's. A tail read
|
||||
can use the newest few entries and stop.
|
||||
- The number of clauses depends on the size of the context window, not on how
|
||||
many times the story has forked. A story with 200 forks whose newest branch
|
||||
runs 40 turns reads with a single clause, because the window is covered before
|
||||
the second entry is reached. `prefix_covering` implements this, and it is why
|
||||
`history.window_covering` can keep its current shape.
|
||||
|
||||
Everything here is a read. Nothing in this module creates a branch or writes a
|
||||
row: an adventure with no branch has no story, and healing that is the write
|
||||
side's job (`tree.place_action`, and the flush guard in `models.py` behind it).
|
||||
Everything in this module reads. Nothing here creates a branch or writes a row.
|
||||
An adventure with no branch has no story, and the write side repairs that. See
|
||||
`tree.place_action` and the flush listener in `models.py`.
|
||||
"""
|
||||
|
||||
from sqlalchemy import and_, false, or_
|
||||
@@ -37,31 +37,33 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
|
||||
# The depth of an adventure with no actions. Mirrors tree.NO_DEPTH; kept
|
||||
# separately so a read never has to import the write half.
|
||||
# The depth of an adventure that has no actions. This mirrors `tree.NO_DEPTH`.
|
||||
# It is duplicated here so that a read never has to import the write half.
|
||||
NO_DEPTH = -1
|
||||
|
||||
# The opening node of an adventure. Depth 0 exists only on the root branch — a
|
||||
# fork starts its own nodes after the depth it forked at — so this names one
|
||||
# node per adventure, not one per branch. It is also where migration 62 parked
|
||||
# every memory written before memories had coordinates, which is why the two
|
||||
# places that can retire a memory (`memorybank.forget_node`, and a v1 import
|
||||
# with no depth to read) both have to say something about it.
|
||||
# The opening node of an adventure. Depth 0 exists only on the root branch,
|
||||
# because a fork starts its own nodes after the depth it forked at. This
|
||||
# constant therefore names one node per adventure rather than one per branch.
|
||||
#
|
||||
# Migration 62 also placed every memory written before memories had coordinates
|
||||
# at depth 0. That is why both places that can retire a memory must handle this
|
||||
# depth: `memorybank.forget_node`, and a v1 import that has no depth to read.
|
||||
ROOT_DEPTH = 0
|
||||
|
||||
|
||||
def entries_of(branch: models.Branch) -> list[tuple[int, int | None]]:
|
||||
"""`branch.lineage` as (branch_id, max_depth) pairs, newest first.
|
||||
"""Returns `branch.lineage` as (branch_id, max_depth) pairs, newest first.
|
||||
|
||||
An empty lineage reads as "this branch alone, to its tip" rather than as an
|
||||
error. That is what a root branch's lineage means, and it is what a branch
|
||||
row looks like in the moment between being inserted and having its own id
|
||||
to name — so the fallback is the truth, not a guess.
|
||||
An empty lineage means this branch alone, through to its tip. That is not an
|
||||
error. It is what a root branch's lineage means, and it is also what a
|
||||
branch row holds between the moment it is inserted and the moment its own id
|
||||
is written into the column. The fallback is therefore correct rather than a
|
||||
guess.
|
||||
"""
|
||||
raw = branch.lineage if isinstance(branch.lineage, list) else []
|
||||
entries: list[tuple[int, int | None]] = []
|
||||
for item in raw:
|
||||
# JSON round-trips lists; a hand-written row might hold tuples.
|
||||
# JSON round-trips lists, but a hand-written row might hold tuples.
|
||||
if not isinstance(item, (list, tuple)) or not item:
|
||||
continue
|
||||
branch_id = item[0]
|
||||
@@ -73,10 +75,10 @@ def entries_of(branch: models.Branch) -> list[tuple[int, int | None]]:
|
||||
|
||||
|
||||
class Path:
|
||||
"""One story, as a clause and as a predicate.
|
||||
"""One story, expressed as a SQL clause and as a Python predicate.
|
||||
|
||||
Holds the lineage entries newest first, plus the depth of the tip, which is
|
||||
only used to estimate how much story each entry covers.
|
||||
The object holds the lineage entries newest first, plus the depth of the
|
||||
tip. The tip is used only to estimate how much story each entry covers.
|
||||
"""
|
||||
|
||||
def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None):
|
||||
@@ -96,28 +98,29 @@ class Path:
|
||||
model=models.Action,
|
||||
count: int | None = None,
|
||||
):
|
||||
"""The branch clause, over `model` (`Action` or `Memory`).
|
||||
"""Returns the branch clause over `model`, which is `Action` or `Memory`.
|
||||
|
||||
`count` limits it to the newest `count` lineage entries — the windowed
|
||||
read. `None` is the whole lineage, which is what anything counting from
|
||||
the *oldest* end (a slice, a total) has to use.
|
||||
`count` limits the clause to the newest `count` lineage entries, which
|
||||
produces a windowed read. Pass `None` for the whole lineage. Any caller
|
||||
that counts from the oldest end, such as a slice or a total, must pass
|
||||
`None`.
|
||||
|
||||
Every row this reads has a depth. Memories used to be the exception —
|
||||
a hand-written one had a branch and no depth, and needed an escape
|
||||
clause here to survive being capped at a fork. SP7 anchors them at the
|
||||
head instead (`tree.place_memory`), which is a better answer to the same
|
||||
problem: the memory is not exempt from the path, it is *on* one. A row
|
||||
with no depth is now a pre-tree leftover that no read should see.
|
||||
Every row this clause selects has a depth. Memories were once an
|
||||
exception, because a hand-written memory had a branch but no depth and
|
||||
needed an escape clause here to avoid being capped at a fork. SP7
|
||||
anchors those memories at the head instead. See `tree.place_memory`. A
|
||||
memory is now on a path rather than exempt from one, and a row with no
|
||||
depth is a pre-tree leftover that no read should return.
|
||||
|
||||
Actions also have to be *live* (SP4). A coordinate can hold several
|
||||
attempts at the same turn, and the story tells one of them; the losing
|
||||
siblings sit at the same branch and depth and are excluded here, once,
|
||||
so that no read of the story has to know that retries exist. Only
|
||||
`app/attempts.py` looks past this.
|
||||
Actions must also be live, as of SP4. One coordinate can hold several
|
||||
attempts at a turn, and the story uses one of them. The other attempts
|
||||
sit at the same branch and depth, and this clause excludes them once, so
|
||||
that no read of the story has to account for retries. Only
|
||||
`app/attempts.py` looks past this filter.
|
||||
|
||||
An empty path yields `false`, not "no filter": an adventure whose nodes
|
||||
carry no branch has no story, and the loud version of that is an empty
|
||||
page, not every branch at once.
|
||||
An empty path returns `false` rather than no filter at all. An adventure
|
||||
whose nodes carry no branch has no story, and the correct way to show
|
||||
that is an empty page rather than every branch at once.
|
||||
"""
|
||||
entries = self.entries if count is None else self.entries[:count]
|
||||
if not entries:
|
||||
@@ -136,14 +139,16 @@ class Path:
|
||||
# ------------------------------------------------------------- Python
|
||||
|
||||
def contains(self, node) -> bool:
|
||||
"""The Python half of `clause()` — keep the two in step.
|
||||
"""Returns whether `node` is on this path.
|
||||
|
||||
Used where the rows are already in memory (the scripting pipeline hands
|
||||
user scripts the whole history), so an already-loaded collection can be
|
||||
cut down to the path without a second read.
|
||||
This is the Python equivalent of `clause()`. Keep the two in step.
|
||||
|
||||
`live` is checked first, and only on rows that have the attribute:
|
||||
memories have no siblings to lose to.
|
||||
Callers use it where the rows are already in memory. The scripting
|
||||
pipeline hands user scripts the whole history, so a loaded collection
|
||||
can be reduced to the path without a second query.
|
||||
|
||||
The method checks `live` first, and only on rows that define the
|
||||
attribute, because memories have no siblings.
|
||||
"""
|
||||
if getattr(node, "live", True) is False:
|
||||
return False
|
||||
@@ -157,22 +162,25 @@ class Path:
|
||||
return False
|
||||
|
||||
def sort_key(self, node) -> tuple[int, int]:
|
||||
"""Oldest-first ordering. `depth` is the ordering key; `id` breaks the
|
||||
tie a pre-tree row (depth NULL) or a future sibling pair would leave."""
|
||||
"""Returns a sort key that orders nodes from oldest to newest.
|
||||
|
||||
`depth` is the ordering key. `id` breaks ties, which a pre-tree row with
|
||||
a NULL depth or a pair of siblings can produce.
|
||||
"""
|
||||
return (node.depth if node.depth is not None else NO_DEPTH, node.id or 0)
|
||||
|
||||
# ------------------------------------------------------------ windowing
|
||||
|
||||
def prefix_covering(self, rows: int) -> int:
|
||||
"""How many lineage entries it takes to hold the newest `rows` nodes.
|
||||
"""Returns how many lineage entries hold the newest `rows` nodes.
|
||||
|
||||
An estimate from depth arithmetic, not a query: entry *i* covers the
|
||||
depths between its own cap and the cap of the entry below it, and there
|
||||
is at most one node per depth on a path. So the count it returns is
|
||||
never too many, and is too few only where the story has gaps — an
|
||||
action deleted from the middle. The caller widens to the full lineage
|
||||
if the window comes up short, which costs a second query on a story
|
||||
somebody has deleted from, and nothing at all otherwise.
|
||||
The result is an estimate from depth arithmetic rather than a query.
|
||||
Entry *i* covers the depths between its own cap and the cap of the entry
|
||||
below it, and a path holds at most one node per depth. The estimate is
|
||||
therefore never too large. It is too small only when the story has gaps,
|
||||
which happens after an action is deleted from the middle. In that case
|
||||
the caller widens the read to the whole lineage, which costs one extra
|
||||
query.
|
||||
"""
|
||||
total = len(self.entries)
|
||||
if rows <= 0 or total == 0:
|
||||
@@ -182,9 +190,9 @@ class Path:
|
||||
top = self.tip if max_depth is None else max_depth
|
||||
below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH
|
||||
if top is None or below is None:
|
||||
# No tip recorded, or a cap missing from a hand-written row:
|
||||
# nothing to estimate from, so read the lot rather than guess
|
||||
# short and hide the older half of the story.
|
||||
# Either no tip was recorded, or a hand-written row is missing a
|
||||
# cap. There is nothing to estimate from, so return every entry.
|
||||
# Guessing low would hide the older half of the story.
|
||||
return total
|
||||
covered += max(top - below, 0)
|
||||
if covered >= rows:
|
||||
@@ -192,15 +200,15 @@ class Path:
|
||||
return total
|
||||
|
||||
def covering_after(self, depth: int) -> int:
|
||||
"""How many lineage entries can hold a node deeper than `depth`.
|
||||
"""Returns how many lineage entries can hold a node deeper than `depth`.
|
||||
|
||||
The counterpart to `prefix_covering`, and unlike it this is exact
|
||||
rather than an estimate: entry *i* holds nothing deeper than its own
|
||||
cap, and the caps descend, so the first entry capped at or below
|
||||
`depth` ends the search — it and everything older is behind the
|
||||
boundary. Reading "the story after the cursor" therefore names one
|
||||
branch on any story whose cursor is on its newest branch, however
|
||||
often it has forked.
|
||||
This is the counterpart to `prefix_covering`, and it is exact rather than
|
||||
an estimate. Entry *i* holds nothing deeper than its own cap, and the
|
||||
caps descend, so the first entry capped at or below `depth` ends the
|
||||
search. That entry and every older one fall behind the boundary. As a
|
||||
result, reading the story after the cursor touches one branch on any
|
||||
story whose cursor sits on its newest branch, however many times the
|
||||
story has forked.
|
||||
"""
|
||||
for i, (_, max_depth) in enumerate(self.entries):
|
||||
if max_depth is not None and max_depth <= depth:
|
||||
@@ -208,27 +216,28 @@ class Path:
|
||||
return len(self.entries)
|
||||
|
||||
def depth_on(self, branch_id: int | None, depth: int) -> int:
|
||||
"""A stored `(branch_id, depth)` anchor, read as a depth on *this* path.
|
||||
"""Reads a stored `(branch_id, depth)` anchor as a depth on this path.
|
||||
|
||||
An anchor is how far along a story some derived work has got — which
|
||||
memories cover, what the summary has folded in. It names a node, so
|
||||
moving to another path has to be answered rather than assumed:
|
||||
An anchor records how far along a story some derived work reached, such
|
||||
as which actions the memories cover or what the summary folded in. The
|
||||
anchor names a node, so moving to a different path needs an explicit
|
||||
answer. There are two cases:
|
||||
|
||||
* the anchor's branch is on this path — the depth stands, capped at the
|
||||
fork the path takes off that branch, because nothing past the fork is
|
||||
on this story;
|
||||
* the branch is not on this path at all — the work was done on ground
|
||||
this story never travelled, so nothing here is covered.
|
||||
- The anchor's branch is on this path. The depth stands, capped at the
|
||||
fork where this path leaves that branch, because nothing past the fork
|
||||
belongs to this story.
|
||||
- The anchor's branch is not on this path. The work was done on a branch
|
||||
this story does not contain, so nothing here counts as covered.
|
||||
|
||||
The second case cannot arise while an adventure has one branch: the
|
||||
anchor is always set from a node on it. It exists because the fallback
|
||||
for "I don't know" must be to redo the work, not to skip it.
|
||||
The second case cannot occur while an adventure has one branch, because
|
||||
the anchor is always set from a node on it. It exists because the safe
|
||||
answer to an unknown anchor is to redo the work rather than skip it.
|
||||
"""
|
||||
if depth <= NO_DEPTH:
|
||||
return NO_DEPTH
|
||||
if branch_id is None:
|
||||
# A pre-tree anchor, or one set by hand. There is one story, so the
|
||||
# depth is a position in it and means what it says.
|
||||
# The anchor predates the tree, or someone set it by hand. There is
|
||||
# only one story, so the depth is a position in it.
|
||||
return depth
|
||||
for entry_branch, max_depth in self.entries:
|
||||
if entry_branch == branch_id:
|
||||
@@ -237,18 +246,19 @@ class Path:
|
||||
|
||||
|
||||
def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
|
||||
"""The branch this adventure is being read at, or None if it has none.
|
||||
"""Returns the branch this adventure is read at, or None if it has none.
|
||||
|
||||
Deliberately not `tree.head_branch`, which creates one: a GET must not
|
||||
write. An adventure with no branch row also has no nodes carrying a branch,
|
||||
so the two agree — both say "no story here".
|
||||
This function is deliberately not `tree.head_branch`, which creates a branch.
|
||||
A GET request must not write. An adventure with no branch row also has no
|
||||
nodes that carry a branch, so both answers agree that there is no story.
|
||||
"""
|
||||
if adventure.head_branch_id is not None:
|
||||
branch = db.get(models.Branch, adventure.head_branch_id)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# A head naming a branch that is gone: fall through to the root, the
|
||||
# same recovery `tree.head_branch` makes on the write side.
|
||||
# The head points at a branch that no longer exists. Fall through to the
|
||||
# root, which is the same recovery that `tree.head_branch` performs on
|
||||
# the write side.
|
||||
return (
|
||||
db.query(models.Branch)
|
||||
.filter(
|
||||
@@ -261,7 +271,7 @@ def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
|
||||
|
||||
|
||||
def path_of(db: Session, adventure: models.Adventure) -> Path:
|
||||
"""The story the adventure's head is currently on."""
|
||||
"""Returns the story that the adventure's head currently sits on."""
|
||||
branch = branch_of(db, adventure)
|
||||
if branch is None:
|
||||
return Path([], adventure.head_depth)
|
||||
|
||||
@@ -15,9 +15,10 @@ DB_PATH = (
|
||||
else Path(__file__).resolve().parent.parent / "data.db"
|
||||
)
|
||||
|
||||
# AIDND_DATABASE_URL (or the platform-conventional DATABASE_URL) switches the
|
||||
# app to a server database — any SQLAlchemy URL works, but Postgres is what
|
||||
# hosted deploys use (Phase 9 decision: Neon). Unset = SQLite, as always.
|
||||
# `AIDND_DATABASE_URL`, or the conventional `DATABASE_URL`, switches the app to
|
||||
# a server database. Any SQLAlchemy URL works, and hosted deploys use Postgres,
|
||||
# which Phase 9 settled on Neon for. If neither variable is set, the app uses
|
||||
# SQLite.
|
||||
DATABASE_URL = (
|
||||
os.environ.get("AIDND_DATABASE_URL", "").strip()
|
||||
or os.environ.get("DATABASE_URL", "").strip()
|
||||
|
||||
@@ -54,10 +54,13 @@ def finish_entry(
|
||||
error: str | None = None,
|
||||
usage: dict | None = None,
|
||||
) -> None:
|
||||
"""`usage` is the endpoint's own token accounting when it reported any.
|
||||
On OpenRouter it carries `prompt_tokens_details.cached_tokens`, which is
|
||||
the only direct read on whether the prompt prefix is actually being
|
||||
cached — a number worth seeing beside the request that produced it."""
|
||||
"""Finishes a log entry.
|
||||
|
||||
`usage` is the endpoint's own token accounting, when it reported any. On
|
||||
OpenRouter it carries `prompt_tokens_details.cached_tokens`, which is the
|
||||
only direct measure of whether the prompt prefix is being cached, and it is
|
||||
worth seeing next to the request that produced it.
|
||||
"""
|
||||
entry["response"] = _clip(response)
|
||||
entry["usage"] = usage
|
||||
entry["error"] = error
|
||||
|
||||
@@ -22,9 +22,9 @@ DATA_URI_RE = re.compile(
|
||||
def public_url(scenario_id: int, image: str, version: object) -> str:
|
||||
"""The URL a client should load for this scenario's art ("" if none).
|
||||
|
||||
`version` (any object with a stable repr — normally the row's updated_at)
|
||||
becomes a cache-buster, letting the image response be marked immutable
|
||||
while still refreshing the moment the author swaps the picture.
|
||||
`version` is any object with a stable repr, and is normally the row's
|
||||
`updated_at`. It becomes a cache-buster, so the image response can be marked
|
||||
immutable and still refresh as soon as the author replaces the picture.
|
||||
"""
|
||||
if not image:
|
||||
return ""
|
||||
@@ -40,9 +40,9 @@ def public_url(scenario_id: int, image: str, version: object) -> str:
|
||||
def sanitize(value: object, max_length: int) -> str:
|
||||
"""Coerce an untrusted `image` value from an import bundle to a safe one.
|
||||
|
||||
Anything that isn't a supported data URI or an https URL — or that is too
|
||||
large to store — becomes "", so a hostile or merely foreign bundle can't
|
||||
smuggle in a `javascript:` URI or blow past the column cap.
|
||||
A value that is not a supported data URI or an https URL, or that is too
|
||||
large to store, becomes "". A hostile or merely unfamiliar bundle therefore
|
||||
cannot pass in a `javascript:` URI or exceed the column cap.
|
||||
"""
|
||||
if not isinstance(value, str) or not value or len(value) > max_length:
|
||||
return ""
|
||||
@@ -62,5 +62,6 @@ def decode(image: str) -> tuple[bytes, str] | None:
|
||||
payload = re.sub(r"\s+", "", match.group(2))
|
||||
return base64.b64decode(payload, validate=True), match.group(1).lower()
|
||||
except (binascii.Error, ValueError):
|
||||
# Truncated or hand-edited base64 — treat as "no image" rather than 500.
|
||||
# The base64 is truncated or hand-edited. Treat it as no image rather
|
||||
# than return a 500.
|
||||
return None
|
||||
|
||||
+102
-77
@@ -1,9 +1,10 @@
|
||||
"""Phase 9 — abuse guards for hosted (multi-user) deployments.
|
||||
"""Phase 9: abuse guards for hosted, multi-user deployments.
|
||||
|
||||
Rate limits and row caps are no-ops in local mode: a single local player
|
||||
should never be throttled by their own app. Values are hardcoded on purpose —
|
||||
generous enough that a legitimate player never notices, tight enough that a
|
||||
hostile visitor can't burn the demo key, peg the CPU, or bloat the database.
|
||||
Rate limits and row caps do nothing in local mode, because a single local player
|
||||
should never be throttled by their own app. The values are hardcoded on purpose.
|
||||
They are generous enough that a legitimate player never notices them, and tight
|
||||
enough that a hostile visitor cannot exhaust the demo key, saturate the CPU, or
|
||||
fill the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,22 +20,22 @@ from sqlalchemy.orm import Session
|
||||
from . import auth, models
|
||||
|
||||
# ---------- Rate limiting ----------
|
||||
# Fixed windows per (scope, caller). In-memory: fine for the single-process
|
||||
# deployment this app targets (and the worst case after a restart is a brief
|
||||
# extra allowance).
|
||||
# Fixed windows per scope and caller. The windows live in memory, which is
|
||||
# enough for the single-process deployment this app targets. The worst case
|
||||
# after a restart is a brief extra allowance.
|
||||
|
||||
# scope -> (max requests, window seconds)
|
||||
# Maps a scope to (max requests, window seconds).
|
||||
RATE_LIMITS: dict[str, tuple[int, int]] = {
|
||||
"turn": (10, 60), # AI turn generation (demo key also has a daily cap)
|
||||
"chat": (30, 60), # AI Chat scratchpad (power users only)
|
||||
"script-test": (30, 60), # sandboxed, but each run costs up to 2s CPU
|
||||
"connection-test": (10, 60), # outbound HTTP to a user-supplied URL
|
||||
"import": (30, 60), # large writes
|
||||
"auth": (10, 300), # register/login attempts, per IP
|
||||
"guest": (30, 300), # new guest users, per IP (each is a DB row)
|
||||
# Pageview beacons. Generous — a real reader clicking around a SPA fires a
|
||||
# handful a minute — but low enough that nobody can inflate the traffic
|
||||
# numbers faster than they could by actually reloading the page.
|
||||
"turn": (10, 60), # AI turn generation. The demo key also has a daily cap.
|
||||
"chat": (30, 60), # The AI Chat scratchpad, for power users.
|
||||
"script-test": (30, 60), # Sandboxed, but each run costs up to 2s of CPU.
|
||||
"connection-test": (10, 60), # Outbound HTTP to a user-supplied URL.
|
||||
"import": (30, 60), # Large writes.
|
||||
"auth": (10, 300), # Register and login attempts, per IP.
|
||||
"guest": (30, 300), # New guest users, per IP. Each one is a database row.
|
||||
# Pageview beacons. The limit is generous, because a real reader clicking
|
||||
# around a SPA sends a handful a minute, and it is low enough that nobody
|
||||
# can inflate the traffic numbers faster than by reloading the page.
|
||||
"analytics": (120, 60),
|
||||
}
|
||||
|
||||
@@ -42,26 +43,30 @@ _windows: dict[tuple[str, str], deque] = defaultdict(deque)
|
||||
_windows_guard = threading.Lock()
|
||||
|
||||
|
||||
# How many proxy hops sit between the app and the real client. On Render (and
|
||||
# most PaaS) that's one: the platform's edge appends the connecting IP to the
|
||||
# RIGHT of X-Forwarded-For. A client can prepend anything it likes to the left,
|
||||
# but it cannot push a value past the edge's own append — so the trustworthy
|
||||
# client IP is the (hops)-th entry from the right, NOT uvicorn's leftmost pick.
|
||||
# Trusting the leftmost let anyone rotate X-Forwarded-For to mint a fresh
|
||||
# rate-limit bucket per request and bypass the auth/guest limits entirely.
|
||||
# Override with AIDND_TRUSTED_PROXY_HOPS if the deployment adds more hops.
|
||||
# How many proxy hops sit between the app and the real client. On Render, and on
|
||||
# most platforms, that is one, because the platform's edge appends the connecting
|
||||
# IP to the right of `X-Forwarded-For`. A client can prepend any value on the
|
||||
# left, but it cannot push a value past the edge's own append, so the trustworthy
|
||||
# client IP is the entry that many places from the right rather than uvicorn's
|
||||
# leftmost choice. Trusting the leftmost entry let anyone rotate
|
||||
# `X-Forwarded-For` to get a fresh rate-limit bucket per request and bypass the
|
||||
# auth and guest limits. If the deployment adds more hops, set
|
||||
# `AIDND_TRUSTED_PROXY_HOPS`.
|
||||
TRUSTED_PROXY_HOPS = max(1, int(os.environ.get("AIDND_TRUSTED_PROXY_HOPS", "1") or 1))
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""The real client IP, resistant to a spoofed X-Forwarded-For. Takes the
|
||||
hop the trusted edge appended (rightmost minus any extra trusted hops);
|
||||
falls back to the socket peer when no forwarded header is present
|
||||
(local/dev, or a direct connection).
|
||||
"""Returns the real client IP, resisting a spoofed `X-Forwarded-For`.
|
||||
|
||||
Public because the access log needs the same answer, and two functions that
|
||||
both decide "which address is the caller's" is how one of them ends up
|
||||
trusting a header it shouldn't."""
|
||||
The function reads the hop the trusted edge appended, which is the rightmost
|
||||
entry minus any extra trusted hops. If no forwarded header is present, which
|
||||
happens locally, in development, and on a direct connection, it falls back to
|
||||
the socket peer.
|
||||
|
||||
The function is public because the access log needs the same answer. Two
|
||||
functions that each decide which address belongs to the caller is how one of
|
||||
them ends up trusting a header it should not.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [p.strip() for p in forwarded.split(",") if p.strip()]
|
||||
@@ -71,8 +76,11 @@ def client_ip(request: Request) -> str:
|
||||
|
||||
|
||||
def rate_limit(scope: str, request: Request, user: models.User | None = None) -> None:
|
||||
"""429 when the caller exceeds the scope's window. Keyed per user when one
|
||||
is known (accounts survive IP changes), per IP otherwise."""
|
||||
"""Raises a 429 when the caller exceeds the scope's window.
|
||||
|
||||
The window is keyed per user when a user is known, because an account
|
||||
survives an IP change, and per IP otherwise.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
limit, window_seconds = RATE_LIMITS[scope]
|
||||
@@ -92,25 +100,30 @@ def rate_limit(scope: str, request: Request, user: models.User | None = None) ->
|
||||
|
||||
|
||||
# ---------- Per-account login throttle ----------
|
||||
# Defense in depth beside the per-IP `auth` limit: that one can be diluted by a
|
||||
# botnet (many real source IPs, one bucket each), so it can't by itself stop a
|
||||
# distributed guessing run against a single account. This cap keys on the target
|
||||
# email instead of the caller, so guessing ONE account's password stays
|
||||
# expensive regardless of how many addresses it comes from. Failures only — a
|
||||
# correct password clears the record — and it's a short sliding window, not a
|
||||
# hard lock, so a user mistyping a few times recovers on their own in minutes.
|
||||
# Tradeoff: an attacker can keep a known account throttled (a nuisance), which
|
||||
# is strictly preferable to letting it be brute-forced.
|
||||
LOGIN_FAIL_LIMIT = 8 # failed attempts per account...
|
||||
LOGIN_FAIL_WINDOW = 900 # ...within this many seconds (15 min)
|
||||
# This is defense in depth next to the per-IP `auth` limit. A botnet dilutes
|
||||
# that limit, because many real source IPs each get their own bucket, so it
|
||||
# cannot by itself stop a distributed guessing run against one account. This cap
|
||||
# keys on the target email rather than on the caller, so guessing one account's
|
||||
# password stays expensive however many addresses the guesses come from.
|
||||
#
|
||||
# Only failures count, and a correct password clears the record. The window
|
||||
# slides over a short period rather than locking the account, so a user who
|
||||
# mistypes a few times recovers within minutes. The trade-off is that an
|
||||
# attacker can keep a known account throttled, which is an inconvenience and is
|
||||
# preferable to letting the account be brute-forced.
|
||||
LOGIN_FAIL_LIMIT = 8 # Failed attempts per account.
|
||||
LOGIN_FAIL_WINDOW = 900 # The window in seconds, which is 15 minutes.
|
||||
|
||||
_login_fails: dict[str, deque] = defaultdict(deque)
|
||||
_login_guard = threading.Lock()
|
||||
|
||||
|
||||
def check_login_allowed(email: str) -> None:
|
||||
"""429 when an account has too many recent failed logins. Call before
|
||||
verifying the password so guesses don't even reach the hash."""
|
||||
"""Raises a 429 when an account has too many recent failed logins.
|
||||
|
||||
Call this before verifying the password, so that a guess never reaches the
|
||||
hash.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
now = time.time()
|
||||
@@ -127,13 +140,13 @@ def check_login_allowed(email: str) -> None:
|
||||
|
||||
|
||||
def note_login_failure(email: str) -> None:
|
||||
"""Record one failed attempt against `email`."""
|
||||
"""Records one failed attempt against `email`."""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
now = time.time()
|
||||
with _login_guard:
|
||||
_login_fails[email].append(now)
|
||||
if len(_login_fails) > 10_000: # bound the map on a flood of unique emails
|
||||
if len(_login_fails) > 10_000: # Bound the map against a flood of unique emails.
|
||||
stale = [
|
||||
key for key, window in _login_fails.items()
|
||||
if not window or window[-1] < now - LOGIN_FAIL_WINDOW
|
||||
@@ -143,14 +156,16 @@ def note_login_failure(email: str) -> None:
|
||||
|
||||
|
||||
def note_login_success(email: str) -> None:
|
||||
"""A correct password wipes the account's failure streak."""
|
||||
"""Clears the account's failure record after a correct password."""
|
||||
with _login_guard:
|
||||
_login_fails.pop(email, None)
|
||||
|
||||
|
||||
def _prune(now: float) -> None:
|
||||
"""Drop callers whose whole window has expired (call with guard held) so
|
||||
the per-IP dict can't grow without bound."""
|
||||
"""Drops callers whose whole window has expired, so the per-IP dict stays bounded.
|
||||
|
||||
Call this with the guard held.
|
||||
"""
|
||||
longest = max(seconds for _, seconds in RATE_LIMITS.values())
|
||||
stale = [key for key, window in _windows.items()
|
||||
if not window or window[-1] < now - longest]
|
||||
@@ -163,13 +178,14 @@ def _prune(now: float) -> None:
|
||||
MAX_ADVENTURES_PER_USER = 100
|
||||
MAX_SCENARIOS_PER_USER = 200
|
||||
MAX_SCRIPTS_PER_USER = 200
|
||||
MAX_STORY_CARDS_PER_OWNER = 200 # per scenario or adventure
|
||||
MAX_STORY_CARDS_PER_OWNER = 200 # Per scenario or per adventure.
|
||||
MAX_MEMORIES_PER_ADVENTURE = 1000
|
||||
MAX_ACTIONS_PER_ADVENTURE = 5000
|
||||
# Phase 14, SP6. A branch per divergence somebody built a story on, so a tree
|
||||
# with more of them than a story has turns is a file, not a game. Import-only
|
||||
# for now: forking is a POST that adds one row and has no cap of its own, and
|
||||
# the cap that matters there is `MAX_ACTIONS_PER_ADVENTURE` above it.
|
||||
# Phase 14, SP6. A tree holds one branch per divergence somebody built a story
|
||||
# on, so a tree with more branches than the story has turns came from a file
|
||||
# rather than from play. The cap applies to imports only. Forking is a POST that
|
||||
# adds one row and has no cap of its own, and the cap that matters there is
|
||||
# `MAX_ACTIONS_PER_ADVENTURE` above.
|
||||
MAX_BRANCHES_PER_ADVENTURE = 1000
|
||||
|
||||
|
||||
@@ -182,9 +198,11 @@ def check_row_cap(
|
||||
scenario_id: int | None = None,
|
||||
adventure_id: int | None = None,
|
||||
) -> None:
|
||||
"""409 with a friendly message when creating one more row of `kind` would
|
||||
exceed its cap. Ownership of the passed scenario/adventure has already
|
||||
been checked by the caller."""
|
||||
"""Raises a 409 when creating one more row of `kind` would exceed its cap.
|
||||
|
||||
The caller has already checked ownership of the scenario or adventure passed
|
||||
in.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
if kind == "adventures":
|
||||
@@ -220,18 +238,18 @@ def check_row_cap(
|
||||
"delete some to make room",
|
||||
)
|
||||
elif kind == "actions":
|
||||
# Every action of the adventure — the whole tree, not the path being
|
||||
# played. That is the number that costs storage, and nothing is ever
|
||||
# auto-pruned, so it is the right one to cap on. It does mean a heavily
|
||||
# branched adventure reaches the cap while its *story* is shorter than
|
||||
# the cap, which is why the message counts "actions in this adventure"
|
||||
# rather than turns.
|
||||
# Count every action in the adventure, which is the whole tree rather
|
||||
# than the path being played. That number is what costs storage, and
|
||||
# nothing is pruned automatically, so it is the right one to cap. It does
|
||||
# mean a heavily branched adventure reaches the cap while its story is
|
||||
# shorter than the cap, which is why the message counts "actions in this
|
||||
# adventure" rather than turns.
|
||||
count = _count(db, models.Action, models.Action.adventure_id == adventure.id)
|
||||
cap, subject, hint = (
|
||||
MAX_ACTIONS_PER_ADVENTURE, "actions in this adventure",
|
||||
"export it and continue in a new adventure",
|
||||
)
|
||||
else: # pragma: no cover — programming error, not user input
|
||||
else: # pragma: no cover. This is a programming error, not user input.
|
||||
raise ValueError(f"Unknown row cap kind: {kind}")
|
||||
if count >= cap:
|
||||
raise HTTPException(409, f"You've reached the limit of {cap} {subject} — {hint}.")
|
||||
@@ -250,8 +268,11 @@ _BUNDLE_LIST_CAPS = {
|
||||
|
||||
|
||||
def check_bundle_lists(**lists) -> None:
|
||||
"""409 when an import bundle's lists exceed the same caps live creation
|
||||
enforces (kwargs: story_cards=, memories=, actions=, branches=)."""
|
||||
"""Raises a 409 when an import bundle's lists exceed the caps live creation uses.
|
||||
|
||||
The keyword arguments are `story_cards`, `memories`, `actions`, and
|
||||
`branches`.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
for name, value in lists.items():
|
||||
@@ -264,18 +285,22 @@ def check_bundle_lists(**lists) -> None:
|
||||
|
||||
|
||||
# ---------- Request body size ----------
|
||||
# Generous enough for the biggest legitimate payload (an adventure export with
|
||||
# thousands of actions), applied in every mode — no honest request comes close.
|
||||
# The limit is generous enough for the largest legitimate payload, which is an
|
||||
# adventure export holding thousands of actions. It applies in every mode, and no
|
||||
# honest request approaches it.
|
||||
|
||||
MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||
MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
class BodySizeLimitMiddleware:
|
||||
"""Rejects oversized request bodies by declared Content-Length. Pure ASGI
|
||||
(not BaseHTTPMiddleware) so SSE responses stream through untouched.
|
||||
Chunked uploads without a length are refused — every real client of this
|
||||
API (browser fetch, curl with a file) sends Content-Length."""
|
||||
"""Rejects oversized request bodies by their declared `Content-Length`.
|
||||
|
||||
This is pure ASGI rather than `BaseHTTPMiddleware`, so SSE responses stream
|
||||
through unchanged. A chunked upload with no length is refused, because every
|
||||
real client of this API sends `Content-Length`, including browser fetch and
|
||||
curl with a file.
|
||||
"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
+6
-6
@@ -106,12 +106,12 @@ class SecurityHeadersMiddleware:
|
||||
class ApiErrorMiddleware:
|
||||
"""Counts failed API responses for the analytics dashboard.
|
||||
|
||||
Here rather than in an exception handler because it sees what the client
|
||||
actually got: a 429 from a rate limiter, a 404 from routing, a 500 from a
|
||||
handler that never returned, all the same way. Pure ASGI for the same
|
||||
reason as the headers above — an SSE turn must not be buffered on its way
|
||||
out. Only /api is watched; a 404 on the SPA mount is a page load, not a
|
||||
fault.
|
||||
This is middleware rather than an exception handler, because it observes
|
||||
what the client received. A 429 from a rate limiter, a 404 from routing, and
|
||||
a 500 from a handler that never returned all reach it the same way. It is
|
||||
pure ASGI for the same reason as the headers above: an SSE turn must not be
|
||||
buffered on its way out. It watches `/api` only, because a 404 on the SPA
|
||||
mount is a page load rather than a fault.
|
||||
"""
|
||||
|
||||
def __init__(self, app):
|
||||
|
||||
+186
-151
@@ -1,20 +1,28 @@
|
||||
"""Phase 6 — auto summarization + embedding memory bank
|
||||
(per help.aidungeon.com/faq/the-memory-system).
|
||||
"""Phase 6: automatic summarization and the embedding memory bank.
|
||||
|
||||
After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB
|
||||
session:
|
||||
- every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered
|
||||
block of actions is summarized into a short "memory";
|
||||
- every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in
|
||||
the new memories (the user-edited text is always the base, never clobbered);
|
||||
- new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank
|
||||
is evicted down to capacity ("forgotten" memories are kept for the UI).
|
||||
This follows AI Dungeon's memory system. See
|
||||
help.aidungeon.com/faq/the-memory-system.
|
||||
|
||||
At generation time, `retrieve_memories` embeds the recent story text and ranks
|
||||
the bank by cosine similarity; the top-K become the "Memories" context section.
|
||||
After each turn, `run_post_turn` runs as a fire-and-forget task with its own
|
||||
database session. It does three things:
|
||||
|
||||
All AI calls here are best-effort: failures are logged (debug page) and retried
|
||||
on a later turn because the cursors only advance on success.
|
||||
- Every `MEMORY_INTERVAL` actions, starting once the adventure reaches
|
||||
`MEMORY_START` actions, it summarizes each uncovered block of actions into a
|
||||
short memory.
|
||||
- Every `SUMMARY_INTERVAL` actions, it rewrites the story summary to include the
|
||||
new memories. The rewrite always starts from the text the user edited and
|
||||
never discards it.
|
||||
- It embeds new memories through an OpenAI-compatible `/v1/embeddings` endpoint,
|
||||
then evicts the bank down to its capacity. Evicted memories are marked as
|
||||
forgotten and kept so that the UI can still show them.
|
||||
|
||||
When the app generates a turn, `retrieve_memories` embeds the recent story text
|
||||
and ranks the bank by cosine similarity. The highest-ranked memories become the
|
||||
Memories section of the context.
|
||||
|
||||
Every AI call in this module is best-effort. A failure is logged to the debug
|
||||
page and retried on a later turn, because the cursors advance only after a call
|
||||
succeeds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -53,17 +61,19 @@ SUMMARY_SYSTEM_PROMPT = (
|
||||
|
||||
# Adventures with a post-turn task currently running (single-process app).
|
||||
_running: set[int] = set()
|
||||
# Strong refs to in-flight tasks — the event loop only keeps weak references,
|
||||
# so a fire-and-forget task can otherwise be garbage-collected mid-run.
|
||||
# Strong references to tasks that are still running. The event loop holds only
|
||||
# weak references, so without this set a fire-and-forget task can be garbage
|
||||
# collected before it finishes.
|
||||
_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
# BYOK-only by construction: both factories below take the user's own
|
||||
# endpoint/key straight from Settings and never auth.DEMO_*, so summarization
|
||||
# and embedding can't spend the shared demo key (their call sites are also
|
||||
# skipped when using_demo). Don't "fix" this by passing a ProviderConfig in —
|
||||
# summary_model/embedding_model are free-form user input and are not on the
|
||||
# demo whitelist.
|
||||
# Both factories below use the user's own key by construction. They read the
|
||||
# endpoint and key from `Settings` and never from `auth.DEMO_*`, so
|
||||
# summarization and embedding cannot spend the shared demo key. Their call sites
|
||||
# are also skipped when `using_demo` is true.
|
||||
#
|
||||
# Do not change these to accept a `ProviderConfig`. `summary_model` and
|
||||
# `embedding_model` are free-form user input and are not on the demo allowlist.
|
||||
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
return OpenAICompatibleProvider(
|
||||
settings.endpoint_url,
|
||||
@@ -83,15 +93,15 @@ def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
def set_vector(memory: models.Memory, vector: list[float] | None) -> None:
|
||||
"""Store (or clear) a memory's embedding.
|
||||
|
||||
Every column that describes the vector moves together: `embedding_blob` is
|
||||
what the ranking reads and `embedded` is the flag everything else reads.
|
||||
Going through one function is what keeps them in step — and it is also the
|
||||
only place a stored vector can change, which is what makes the cache below
|
||||
safe to invalidate here and nowhere else.
|
||||
This function updates both columns that describe the vector. The ranking
|
||||
reads `embedding_blob`, and everything else reads the `embedded` flag.
|
||||
Routing every write through one function keeps the two in step, and it makes
|
||||
this the only place a stored vector changes. That is why the cache below can
|
||||
be invalidated here and nowhere else.
|
||||
|
||||
The one caller that legitimately cannot come through here is the bulk
|
||||
clear in `routers/settings.py` when the embedding model changes. It has to
|
||||
set the same two columns by hand; see the note there.
|
||||
One caller cannot use this function: the bulk clear in
|
||||
`routers/settings.py` that runs when the embedding model changes. It sets
|
||||
the same two columns directly. See the comment there.
|
||||
"""
|
||||
memory.embedding_blob = None if vector is None else vectors.pack(vector)
|
||||
memory.embedded = vector is not None
|
||||
@@ -102,31 +112,36 @@ def set_vector(memory: models.Memory, vector: list[float] | None) -> None:
|
||||
|
||||
# ---------- The vector cache ----------
|
||||
|
||||
# adventure id -> {memory id: vector}, most-recently-used last.
|
||||
# Maps an adventure id to a dict of memory id to vector, with the
|
||||
# most recently used adventure last.
|
||||
#
|
||||
# Turns for one adventure arrive back to back, and the bank barely changes
|
||||
# between them, so re-reading every vector each turn is the same 600 KB over
|
||||
# and over. Vectors are held as array("f") — 4 bytes a component, the same
|
||||
# 6 KB the column holds. A list of Python floats would be eight times that.
|
||||
# Turns for one adventure arrive one after another, and the bank changes little
|
||||
# between them. Reading every vector on each turn fetches the same 600 KB
|
||||
# repeatedly. This cache stores vectors as `array("f")`, which uses 4 bytes per
|
||||
# component and matches the 6 KB the column holds. A list of Python floats would
|
||||
# use eight times as much.
|
||||
#
|
||||
# Correctness rests on two things. Anything that *changes* a vector goes
|
||||
# through set_vector, which drops that one entry. Anything that *removes* a
|
||||
# memory from play — eviction, deletion, pruning, an edit clearing the vector —
|
||||
# takes it out of the catalogue query below, and entries missing from the
|
||||
# catalogue are dropped on the next read. So nothing has to remember to call an
|
||||
# invalidate, which is the failure this design is chosen to avoid.
|
||||
# Two rules keep the cache correct. Any code that changes a vector calls
|
||||
# `set_vector`, which removes that entry. Any code that removes a memory from
|
||||
# play removes it from the catalogue query below, and the next read discards
|
||||
# entries that the catalogue no longer lists. Eviction, deletion, pruning, and
|
||||
# an edit that clears the vector all work this way. No code path has to remember
|
||||
# to invalidate the cache, which is the error this design avoids.
|
||||
#
|
||||
# In-process, so it assumes one worker. That is what the deploy runs; a second
|
||||
# worker would each keep their own copy and both would still be correct on
|
||||
# eviction and deletion, but a vector rewritten by one could go stale in the
|
||||
# other until that memory next leaves the catalogue.
|
||||
# The cache lives in the process, so it assumes one worker, which is what the
|
||||
# deploy runs. With two workers, each keeps its own copy. Both stay correct
|
||||
# about eviction and deletion, but a vector rewritten by one worker can remain
|
||||
# stale in the other until that memory leaves the catalogue.
|
||||
_vector_cache: OrderedDict[int, dict[int, array]] = OrderedDict()
|
||||
VECTOR_CACHE_ADVENTURES = 8 # ~600 KB each at a 100-memory bank
|
||||
|
||||
|
||||
def forget_cached_vectors(adventure_id: int) -> None:
|
||||
"""Drop an adventure's cached vectors. Only needed when the adventure
|
||||
itself goes away — everything else self-corrects (see above)."""
|
||||
"""Drops an adventure's cached vectors.
|
||||
|
||||
Call this only when the adventure itself is deleted. Every other case
|
||||
corrects itself, as described in the comment above.
|
||||
"""
|
||||
_vector_cache.pop(adventure_id, None)
|
||||
|
||||
|
||||
@@ -157,27 +172,27 @@ def _vectors_for(db: Session, adventure_id: int, ids: list[int]) -> dict[int, ar
|
||||
def forget_node(db: Session, adventure: models.Adventure, action: models.Action) -> int:
|
||||
"""Withdraw what a node produced, because the node is being removed.
|
||||
|
||||
Call it before deleting `action` (undo, delete-an-action). A memory hangs
|
||||
off the node whose block it ends on, so "which memories described this?" is
|
||||
a lookup on `(branch_id, depth)` rather than a scan for rows whose covered
|
||||
range has fallen off the end of the story — which is what
|
||||
`prune_dangling_memories` did, and it could only ever notice the damage
|
||||
after the fact.
|
||||
Call this before deleting `action`, which undo and the delete-action
|
||||
endpoint both do. A memory attaches to the node its block ends on, so
|
||||
finding the memories that describe a node is a lookup on `(branch_id,
|
||||
depth)`. The earlier `prune_dangling_memories` instead scanned for rows
|
||||
whose covered range no longer existed, so it could only detect the problem
|
||||
after it occurred.
|
||||
|
||||
Discarding the memory is half of it. The stretch of story it covered is
|
||||
still behind the cursors, so without a rewind those actions read as
|
||||
summarized with nothing describing them, silently, for the rest of the
|
||||
adventure. `source_start` is where that stretch began; the anchor goes to
|
||||
the node before it, which is a depth whether or not anything still sits
|
||||
there.
|
||||
Deleting the memory is half the work. The stretch of story it covered still
|
||||
sits behind the cursors. Without a rewind, those actions count as
|
||||
summarized while nothing describes them, and nothing reports the problem for
|
||||
the rest of the adventure. `source_start` records where that stretch began,
|
||||
so the anchor moves to the node before it. That depth is valid whether or
|
||||
not a node still occupies it.
|
||||
|
||||
The opening node is the one exception, because migration 62 parked the
|
||||
whole pre-coordinate bank on it — see the comment on `lineage.ROOT_DEPTH`.
|
||||
The opening node is the one exception, because migration 62 placed the whole
|
||||
pre-coordinate bank on it. See the comment on `lineage.ROOT_DEPTH`.
|
||||
|
||||
Returns how many memories were withdrawn.
|
||||
Returns the number of memories withdrawn.
|
||||
"""
|
||||
if action.branch_id is None or action.depth is None:
|
||||
return 0 # a pre-tree row: no path contains it, so nothing hangs off it
|
||||
return 0 # A pre-tree row. No path contains it, so nothing refers to it.
|
||||
doomed = (
|
||||
db.query(models.Memory)
|
||||
.filter(
|
||||
@@ -188,15 +203,16 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action)
|
||||
.all()
|
||||
)
|
||||
if action.depth == lineage.ROOT_DEPTH:
|
||||
# The opening node is special, and only for memories that describe no
|
||||
# stretch of story. Migration 62 parked every memory written before
|
||||
# memories had coordinates at depth 0 — that was the choice that took
|
||||
# nothing away from anybody, but it also collected them all onto one
|
||||
# node, so withdrawing that node would retire a player's whole bank in
|
||||
# a single click. A memory with no `source_start` was typed (or
|
||||
# migrated), describes nothing that can fall off the end, and so has
|
||||
# nothing to be withdrawn *from*: it stays. A summary that genuinely
|
||||
# ends here is still withdrawn, because the text it describes is going.
|
||||
# Special case for the opening node, and only for memories that
|
||||
# describe no stretch of story. Migration 62 placed every memory written
|
||||
# before memories had coordinates at depth 0. That choice preserved
|
||||
# every memory, but it also placed them all on one node, so withdrawing
|
||||
# that node would delete a player's entire bank in one action.
|
||||
#
|
||||
# A memory with no `source_start` was either typed by the player or
|
||||
# migrated. It describes no actions, so no deletion can invalidate it,
|
||||
# and it stays. A memory that genuinely summarizes a block ending here is
|
||||
# still withdrawn, because the text it describes is being deleted.
|
||||
doomed = [m for m in doomed if m.source_start is not None]
|
||||
if not doomed:
|
||||
return 0
|
||||
@@ -217,11 +233,19 @@ async def retrieve_memories(
|
||||
update_stats: bool,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> dict | None:
|
||||
"""Returns {"used": [{id, text, similarity, pinned}], "error": str|None},
|
||||
or None when the memory bank is off for this adventure. `update_stats`
|
||||
bumps use counters (real turns only, not Insights dry runs).
|
||||
`exclude_action_id` drops the action being retried from the similarity
|
||||
query, so the discarded attempt can't steer which memories come back."""
|
||||
"""Returns the memories to inject, or None when the bank is off.
|
||||
|
||||
The result is a dict of the form
|
||||
`{"used": [{id, text, similarity, pinned}], "error": str | None}`. It is
|
||||
None when the memory bank is disabled for this adventure.
|
||||
|
||||
Set `update_stats` to True to increment the use counters. Only real turns
|
||||
should do this, not the dry runs that Insights performs.
|
||||
|
||||
`exclude_action_id` removes the action being retried from the similarity
|
||||
query, so that a discarded attempt cannot influence which memories are
|
||||
returned.
|
||||
"""
|
||||
if not adventure.memory_bank_enabled:
|
||||
return None
|
||||
if not settings.embedding_model.strip():
|
||||
@@ -230,16 +254,17 @@ async def retrieve_memories(
|
||||
if db is None:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Which memories are in play, and nothing else about them. This used to
|
||||
# walk adventure.memories, which loaded every row of the bank *including
|
||||
# its vector* — ~31 KB a memory, three megabytes a turn, 96% of everything
|
||||
# a turn read. Two ids and a flag per row is about eight bytes.
|
||||
# Select which memories are in play, and nothing else about them. This code
|
||||
# used to walk `adventure.memories`, which loaded every row of the bank,
|
||||
# including its vector. That cost about 31 KB per memory and about 3 MB per
|
||||
# turn, which was 96% of everything a turn read. An id and a flag come to
|
||||
# about eight bytes per row.
|
||||
#
|
||||
# The branch clause is the *whole* lineage here, not the window the story
|
||||
# is read through: retrieval is long-range recall, and a memory of what
|
||||
# happened forty turns ago is exactly what it exists to find. It stays
|
||||
# affordable because memories are sparse — one per six actions — so the
|
||||
# ancestry of even a heavily forked story returns tens of tiny rows.
|
||||
# The branch clause uses the whole lineage here rather than the window the
|
||||
# story is read through. Retrieval exists to recall events from far back in
|
||||
# the story, such as what happened forty turns ago. The full lineage stays
|
||||
# affordable because memories are sparse, at roughly one per six actions, so
|
||||
# even a heavily forked story returns only tens of small rows.
|
||||
catalogue = db.execute(
|
||||
select(models.Memory.id, models.Memory.pinned).where(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
@@ -273,8 +298,9 @@ async def retrieve_memories(
|
||||
key=lambda row: row[0],
|
||||
reverse=True,
|
||||
)
|
||||
# Pinned memories are always used and count toward top_k, so the injected
|
||||
# set never exceeds the configured budget (unless pinned alone exceed it).
|
||||
# Pinned memories are always used, and they count toward `top_k`, so the
|
||||
# injected set stays within the budget unless the pinned memories alone
|
||||
# exceed it.
|
||||
top_k = max(1, settings.memory_top_k)
|
||||
used = [row for row in scored if row[2]]
|
||||
remaining = max(0, top_k - len(used))
|
||||
@@ -283,7 +309,7 @@ async def retrieve_memories(
|
||||
if not used:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Only now, for at most top_k rows, is the text worth fetching.
|
||||
# Fetch the text only now, and only for the `top_k` rows that were chosen.
|
||||
used_ids = [memory_id for _, memory_id, _ in used]
|
||||
texts = dict(
|
||||
db.execute(
|
||||
@@ -293,9 +319,10 @@ async def retrieve_memories(
|
||||
)
|
||||
|
||||
if update_stats:
|
||||
# synchronize_session=False: nothing in this request reads the counters
|
||||
# back, and matching the UPDATE against loaded objects would mean having
|
||||
# loaded them, which is the cost this whole path exists to avoid.
|
||||
# Pass `synchronize_session=False` because nothing in this request
|
||||
# reads the counters back. Matching the UPDATE against loaded objects
|
||||
# would require loading those objects, which is the cost this code path
|
||||
# exists to avoid.
|
||||
db.execute(
|
||||
update(models.Memory)
|
||||
.where(models.Memory.id.in_(used_ids))
|
||||
@@ -343,14 +370,16 @@ async def run_post_turn(adventure_id: int) -> None:
|
||||
)
|
||||
if settings is None:
|
||||
return
|
||||
# No cursor clamp here any more. Undo can leave the story shorter than
|
||||
# the mark, and a *position* past the end of the list was a stalled
|
||||
# pass until the story grew back past it — hence a clamp on every
|
||||
# post-turn run, which had its own trap (clamping to the settled count
|
||||
# rewound a caught-up adventure a step and re-covered an action). An
|
||||
# anchor past the tip is not a broken value: `settled_after` just
|
||||
# reports nothing to do, and the story growing back past it resumes
|
||||
# exactly where it left off.
|
||||
# This code no longer clamps the cursors. Undo can leave the story
|
||||
# shorter than the mark. When the mark was a position, a value past the
|
||||
# end of the list stalled the pass until the story grew back, so every
|
||||
# post-turn run clamped it. That clamp introduced its own error, because
|
||||
# clamping to the settled count rewound a caught-up adventure by one
|
||||
# step and covered an action twice.
|
||||
#
|
||||
# An anchor past the tip is not an invalid value. `settled_after`
|
||||
# reports that there is nothing to do, and once the story grows past the
|
||||
# anchor the pass resumes where it stopped.
|
||||
if adventure.auto_summarize:
|
||||
await _create_due_memories(adventure, settings, db)
|
||||
await _update_story_summary(adventure, settings, db)
|
||||
@@ -367,16 +396,17 @@ async def _create_due_memories(
|
||||
) -> None:
|
||||
provider = summary_provider(settings)
|
||||
for _ in range(MAX_MEMORIES_PER_RUN):
|
||||
# Re-read each pass: a memory just committed doesn't change the story,
|
||||
# but this loop is the only thing that moves the anchor, so both
|
||||
# numbers have to be current.
|
||||
# Re-read the anchor on every pass. Committing a memory does not change
|
||||
# the story, but this loop is the only code that moves the anchor, so
|
||||
# both numbers must be current.
|
||||
anchor = cursors.MEMORY.depth(db, adventure)
|
||||
if history.count_after(adventure, anchor) < MEMORY_INTERVAL:
|
||||
return # no full block of story past the mark
|
||||
return # No full block of story sits past the mark.
|
||||
if history.count(adventure) < MEMORY_START:
|
||||
return # ...and the adventure is too short to have started at all
|
||||
# (that order on purpose: the common answer is "nothing due", and the
|
||||
# first question answers it without asking how long the story is)
|
||||
return # The adventure is too short to have started summarizing.
|
||||
# The order of those two checks is deliberate. The usual answer is that
|
||||
# no memory is due, and the first check settles that without measuring
|
||||
# the length of the whole story.
|
||||
block = history.after(adventure, anchor, MEMORY_INTERVAL)
|
||||
if len(block) < MEMORY_INTERVAL:
|
||||
return
|
||||
@@ -386,7 +416,8 @@ async def _create_due_memories(
|
||||
MEMORY_SYSTEM_PROMPT, f"Story excerpt:\n\n{excerpt}\n\nMemory:"
|
||||
)
|
||||
except ProviderError:
|
||||
return # logged in the debug page; cursor unchanged → retried next turn
|
||||
return # Logged on the debug page. The cursor is unchanged, so the
|
||||
# next turn retries this block.
|
||||
if not text:
|
||||
return
|
||||
memory = models.Memory(
|
||||
@@ -395,11 +426,11 @@ async def _create_due_memories(
|
||||
source_start=block[0].depth,
|
||||
source_end=block[-1].depth,
|
||||
)
|
||||
# Hang it off the node it summarised, so a fork inherits the memories of
|
||||
# the path it forked from and nothing else — and move the mark to that
|
||||
# same node. The two are one statement about where this pass has got to,
|
||||
# and writing them from the same row is what keeps them in step however
|
||||
# gappy the depths underneath are.
|
||||
# Attach the memory to the node it summarizes, so that a fork inherits
|
||||
# the memories of the path it forked from and no others. Then move the
|
||||
# mark to that same node. Both values record how far this pass has
|
||||
# reached, and taking them from one row keeps them in step even when the
|
||||
# depths have gaps.
|
||||
tree.attach_memory(memory, block[-1])
|
||||
db.add(memory)
|
||||
cursors.MEMORY.anchor_at(adventure, block[-1])
|
||||
@@ -413,18 +444,19 @@ async def _update_story_summary(
|
||||
uncovered = history.count_after(adventure, anchor)
|
||||
if uncovered < SUMMARY_INTERVAL:
|
||||
return
|
||||
# Where the summary will stand once this run succeeds. Read before the AI
|
||||
# call, not after: the mark is the end of the story as this pass saw it,
|
||||
# and a turn landing meanwhile must not be quietly claimed as read.
|
||||
# Where the summary stands once this run succeeds. Read this before the AI
|
||||
# call rather than after it. The mark records the end of the story as this
|
||||
# pass saw it, and a turn that arrives during the call must not be counted
|
||||
# as read.
|
||||
caught_up = history.newest(adventure)
|
||||
if caught_up is None:
|
||||
return
|
||||
|
||||
# Fold in the memories of the stretch the summary has not read — every
|
||||
# memory hanging off a node past the anchor. Both marks and every memory
|
||||
# are now depths on one path, so there is no translation between coordinate
|
||||
# systems left to get wrong. Falls back to raw story text if memory
|
||||
# creation is lagging (e.g. it just failed).
|
||||
# Include the memories for the stretch that the summary has not read, which
|
||||
# means every memory attached to a node past the anchor. The marks and the
|
||||
# memories are now depths on one path, so no coordinate conversion remains.
|
||||
# If memory creation has fallen behind, for example because the last attempt
|
||||
# failed, this falls back to the raw story text.
|
||||
new_events = db.execute(
|
||||
select(models.Memory.text)
|
||||
.where(
|
||||
@@ -462,15 +494,17 @@ async def _update_story_summary(
|
||||
async def _embed_pending(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
# A query, not a walk of adventure.memories: this ran every turn and pulled
|
||||
# the whole bank's vectors to find the handful that had none.
|
||||
# Use a query rather than walking `adventure.memories`. That walk ran on
|
||||
# every turn and loaded the whole bank's vectors in order to find the few
|
||||
# rows with none.
|
||||
#
|
||||
# No branch clause, deliberately, here and in the eviction below. Being
|
||||
# embedded is a fact about the row, not about the path being played:
|
||||
# skipping a sibling's memories would only mean embedding them later, at
|
||||
# the moment somebody switched branches and wanted them ranked. Capacity is
|
||||
# the same — the bank belongs to the adventure, and evicting the memories
|
||||
# of a story nobody is reading is exactly the right thing to evict first.
|
||||
# Neither this query nor the eviction below applies a branch clause, and
|
||||
# that is deliberate. Whether a row is embedded is a fact about the row, not
|
||||
# about the path being played. Skipping a sibling branch's memories would
|
||||
# only postpone the work until someone switched branches and needed them
|
||||
# ranked. Capacity works the same way. The bank belongs to the adventure,
|
||||
# and the memories of a branch nobody is reading are the right ones to evict
|
||||
# first.
|
||||
pending = (
|
||||
db.query(models.Memory)
|
||||
.filter(
|
||||
@@ -496,9 +530,10 @@ async def _embed_pending(
|
||||
def _evict_over_capacity(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
# Counting and ranking are both things the database does without sending
|
||||
# anything back. Walking adventure.memories to count them fetched every
|
||||
# vector in the bank, every turn, whether or not anything was over capacity.
|
||||
# The database performs both the count and the ranking, and returns neither
|
||||
# the rows nor the vectors. Counting by walking `adventure.memories` fetched
|
||||
# every vector in the bank on every turn, whether or not the bank was over
|
||||
# capacity.
|
||||
in_this_bank = (models.Memory.adventure_id == adventure.id,
|
||||
models.Memory.forgotten.is_(False))
|
||||
active = db.execute(
|
||||
@@ -507,23 +542,23 @@ def _evict_over_capacity(
|
||||
overflow = active - max(1, settings.memory_bank_capacity)
|
||||
if overflow <= 0:
|
||||
return
|
||||
# Least recently touched goes first, and how often it was used only breaks
|
||||
# a tie. The other way round — use_count first — shut the bank. A memory
|
||||
# written this turn has never been used, so once every survivor had been
|
||||
# retrieved even once the newborn was the lowest row in the bank and was
|
||||
# evicted by the same post-turn run that wrote it, in the pass right after
|
||||
# the one that embedded it. Counts only ever go up, so that state never
|
||||
# ends: the bank an adventure happened to hold when it first filled is the
|
||||
# bank it keeps for good, and everything the story does afterwards is
|
||||
# summarized, marked forgotten, and never ranked.
|
||||
# Evict the least recently used memory first, and use the use count only to
|
||||
# break ties.
|
||||
#
|
||||
# Recency does not have that hole, because a new memory carries the newest
|
||||
# timestamp there is — it is the safest row in the bank rather than the
|
||||
# most doomed, and it gets the whole span until something outlives it to
|
||||
# prove itself. Little is given up by demoting the count: a memory that is
|
||||
# genuinely used stays recently-used by being retrieved, so the two only
|
||||
# disagree about memories that mattered once and have not been wanted
|
||||
# since, which is what a full bank should be dropping anyway.
|
||||
# Ordering by use count first froze the bank. A memory written on this turn
|
||||
# has never been used, so once every other memory had been retrieved at
|
||||
# least once, the new memory held the lowest count in the bank. The same
|
||||
# post-turn run that wrote it then evicted it, one pass after embedding it.
|
||||
# Use counts only increase, so the bank never recovered. An adventure kept
|
||||
# whatever memories it held when the bank first filled, and every later
|
||||
# memory was summarized, marked as forgotten, and never ranked.
|
||||
#
|
||||
# Ordering by recency avoids that. A new memory carries the newest
|
||||
# timestamp, so it is the last row to be evicted rather than the first, and
|
||||
# it remains until other memories are used. Demoting the use count costs
|
||||
# little, because retrieving a useful memory also makes it recent. The two
|
||||
# orderings differ only for memories that were used once and have not been
|
||||
# retrieved since, which are the rows a full bank should evict.
|
||||
doomed = db.execute(
|
||||
select(models.Memory.id)
|
||||
.where(*in_this_bank, models.Memory.pinned.is_(False))
|
||||
@@ -534,7 +569,7 @@ def _evict_over_capacity(
|
||||
.limit(overflow)
|
||||
).scalars().all()
|
||||
if not doomed:
|
||||
return # every active memory is pinned; capacity yields to the pins
|
||||
return # Every active memory is pinned, so the pins override capacity.
|
||||
db.execute(
|
||||
update(models.Memory)
|
||||
.where(models.Memory.id.in_(doomed))
|
||||
@@ -542,6 +577,6 @@ def _evict_over_capacity(
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
# The bulk UPDATE went around any loaded objects, so anything still holding
|
||||
# the collection would see the evicted memories as active.
|
||||
# The bulk UPDATE bypassed the loaded objects, so code that still holds the
|
||||
# collection would otherwise see the evicted memories as active.
|
||||
db.expire(adventure, ["memories"])
|
||||
|
||||
+384
-349
File diff suppressed because it is too large
Load Diff
+247
-223
@@ -15,15 +15,18 @@ def utcnow() -> datetime:
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Phase 8 — optional accounts.
|
||||
"""Phase 8: optional accounts.
|
||||
|
||||
Three kinds of rows share this table:
|
||||
- the "local user" (email NULL, is_guest False): auto-created in
|
||||
single-user/local mode; owns everything a pre-Phase-8 DB had;
|
||||
- guests (email NULL, is_guest True): created on first visit in
|
||||
multi-user mode, identified only by their session cookie;
|
||||
- registered users (email set): a guest upgraded in place, so their
|
||||
data survives registration with no re-parenting.
|
||||
Three kinds of row share this table:
|
||||
|
||||
- The local user has a NULL email and `is_guest` set to False. Single-user
|
||||
mode creates this row automatically. It owns everything that a database
|
||||
from before Phase 8 contained.
|
||||
- Guests have a NULL email and `is_guest` set to True. Multi-user mode
|
||||
creates one on a visitor's first visit and identifies it only by the
|
||||
session cookie.
|
||||
- Registered users have an email. Registration upgrades a guest row in
|
||||
place, so the guest's data survives without being reassigned.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
@@ -64,18 +67,20 @@ class Scenario(Base):
|
||||
authors_note: Mapped[str] = mapped_column(Text, default="")
|
||||
ai_instructions: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[str] = mapped_column(String(500), default="")
|
||||
# Cover art. Either an external "https://…" URL or an inline
|
||||
# "data:image/…;base64,…" URI (the editor downscales uploads before storing
|
||||
# one). Empty means the UI falls back to an emoji sigil or generated art.
|
||||
# Kept in the row rather than on disk because Render's free tier has no
|
||||
# persistent volume, and it makes export bundles self-contained.
|
||||
# Cover art. The value is either an "https://" URL or an inline
|
||||
# "data:image/...;base64,..." URI. The editor downscales uploads before
|
||||
# storing them. An empty value tells the UI to fall back to an emoji sigil
|
||||
# or to generated art. The image is stored in the row rather than on disk,
|
||||
# because Render's free tier provides no persistent volume. Storing it here
|
||||
# also keeps export bundles self-contained.
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
# A single emoji or glyph used when there's no `image` — cheap art for
|
||||
# scenarios nobody wants to find a picture for. Separate from `image`
|
||||
# because it's a character, not a locator: no fetch, no cache, no bytes.
|
||||
# A single emoji or glyph, used when `image` is empty. This is a separate
|
||||
# column because the value is a character rather than a location, so
|
||||
# nothing needs to fetch or cache it.
|
||||
icon: Mapped[str] = mapped_column(String(16), default="")
|
||||
# Phase 12: RPG world-state template — stat definitions (bands, rules) and
|
||||
# milestones. NULL/empty means this scenario has no RPG layer.
|
||||
# Phase 12: the RPG world-state template. It holds stat definitions, which
|
||||
# include bands and rules, and milestones. A NULL or empty value means the
|
||||
# scenario has no RPG layer.
|
||||
stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
@@ -113,31 +118,36 @@ class Adventure(Base):
|
||||
# Phase 6: opt-in per adventure (extra AI calls)
|
||||
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# LEGACY (Phase 6): how many actions had been folded into memories / the
|
||||
# story summary, as a *position* in the story. Unread since SP3, and
|
||||
# unwritten except by a v1 import which is handed one; kept for one release
|
||||
# so a rollback resumes from a real number, and dropped in SP8 beside
|
||||
# `actions.index`. The live mark is the anchor pair below.
|
||||
# Legacy columns from Phase 6. Each holds a count of the actions that were
|
||||
# folded into the memories or the story summary, expressed as a position in
|
||||
# the story. Nothing has read them since SP3, and nothing writes them except
|
||||
# a v1 import. They remain for one release so that a rollback resumes from a
|
||||
# real number. SP8 drops them along with `actions.index`. The columns below
|
||||
# hold the marks that this code actually uses.
|
||||
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Phase 14, SP3: the same two marks as nodes — (branch, depth) of the last
|
||||
# action each pass covered. A position slides when an action in front of it
|
||||
# is deleted and silently starts covering one it has never read; a depth
|
||||
# does not move, because it is a coordinate along a path rather than an
|
||||
# offset into a list. NO_DEPTH (-1) is "nothing covered yet", so the first
|
||||
# block needs no special case. Plain integers, not foreign keys, for the
|
||||
# same reason `head_branch_id` below is one. See `context/cursors.py`.
|
||||
# Phase 14, SP3: the same two marks expressed as coordinates. Each pair
|
||||
# holds the branch and depth of the last action that pass covered. A
|
||||
# position moves when an action in front of it is deleted, so the mark
|
||||
# silently starts covering an action it never read. A depth is a coordinate
|
||||
# along a path, so deleting an action does not move it. NO_DEPTH, which is
|
||||
# -1, means that nothing is covered yet, so the first block needs no special
|
||||
# case. These are plain integers rather than foreign keys, for the reason
|
||||
# given on `head_branch_id` below. See `context/cursors.py`.
|
||||
memory_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
memory_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
summary_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
summary_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
# Phase 14: where the story is being played — which branch, and the depth of
|
||||
# its newest node. Deliberately NOT a ForeignKey: branches.adventure_id
|
||||
# already points this way, and a second constraint back would make the two
|
||||
# tables a cycle that create_all cannot order (the fix for that is
|
||||
# use_alter, which SQLite has no ALTER for). It is a cache of a pointer, and
|
||||
# `tree.head_branch` treats a head naming a branch that no longer exists as
|
||||
# a bug to recover from rather than a state to honour.
|
||||
# Phase 14: where the story is being played. `head_branch_id` names the
|
||||
# branch, and `head_depth` gives the depth of its newest node.
|
||||
#
|
||||
# `head_branch_id` is deliberately not a ForeignKey. `branches.adventure_id`
|
||||
# already points from branches to adventures, so a constraint in this
|
||||
# direction would make the two tables a cycle that `create_all` cannot
|
||||
# order. The usual fix is `use_alter`, which needs an ALTER statement that
|
||||
# SQLite does not provide. The column caches a pointer, and
|
||||
# `tree.head_branch` treats a head that names a missing branch as a bug to
|
||||
# recover from rather than a state to preserve.
|
||||
head_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# The depth of the tip, so the next node is always head_depth + 1.
|
||||
# NO_DEPTH (-1) for an adventure with no actions yet.
|
||||
@@ -149,12 +159,12 @@ class Adventure(Base):
|
||||
story_cards: Mapped[list["StoryCard"]] = relationship(
|
||||
back_populates="adventure", cascade="all, delete-orphan"
|
||||
)
|
||||
# Every action of the adventure — that is, every *branch's*. Not the story
|
||||
# being played, and re-ordering it by depth would not make it one: the
|
||||
# collection is the tree, and a path is a selection out of it. Anything
|
||||
# showing a reader a story goes through `context.history`, which goes
|
||||
# through the branch clause. What is left here is ownership and the
|
||||
# delete-orphan cascade, which are facts about the adventure.
|
||||
# Every action in the adventure, across all branches. This collection is
|
||||
# the tree, not the story being played. Ordering it by depth does not make
|
||||
# it a story, because a path is a selection out of the tree. Code that shows
|
||||
# a story to a reader goes through `context.history`, which applies the
|
||||
# branch clause. This relationship exists for ownership and for the
|
||||
# delete-orphan cascade.
|
||||
actions: Mapped[list["Action"]] = relationship(
|
||||
back_populates="adventure",
|
||||
cascade="all, delete-orphan",
|
||||
@@ -173,24 +183,25 @@ class Adventure(Base):
|
||||
|
||||
|
||||
class Branch(Base):
|
||||
"""Phase 14 — one path through an adventure's story tree.
|
||||
"""Phase 14: one path through an adventure's story tree.
|
||||
|
||||
A branch does not own a copy of the story: it holds the nodes played on it
|
||||
and *borrows* everything before its fork point from its ancestors. Reading
|
||||
branch C means reading C's nodes, plus B's up to where C left it, plus A's
|
||||
up to where B left it — which is what `lineage` spells out, so a read is an
|
||||
OR-clause per entry instead of a walk up parent pointers.
|
||||
A branch does not own a copy of the story. It holds the nodes played on it,
|
||||
and it inherits everything before its fork point from its ancestors. Reading
|
||||
branch C means reading C's nodes, then B's nodes up to the depth where C
|
||||
forked, then A's nodes up to the depth where B forked. The `lineage` column
|
||||
records that list, so a read becomes one OR clause per entry instead of a
|
||||
walk up parent pointers.
|
||||
|
||||
Until forking ships there is exactly one root branch per adventure and
|
||||
every node hangs off it. That is not a half-migrated state: a linear story
|
||||
*is* a tree with one branch, which is why writing these columns changes
|
||||
nothing anyone can observe.
|
||||
Until forking ships, each adventure has one root branch and every node
|
||||
belongs to it. This is not a partly migrated state. A linear story is a tree
|
||||
with one branch, which is why writing these columns changes nothing that a
|
||||
reader can observe.
|
||||
|
||||
No ORM relationships on purpose. `actions.branch_id` and `memories
|
||||
.branch_id` carry ON DELETE CASCADE, so the database removes a deleted
|
||||
branch's nodes; a relationship would have SQLAlchemy load them all to do
|
||||
the same thing, and loading every action of a branch is the exact cost the
|
||||
windowed reads exist to avoid.
|
||||
This class defines no ORM relationships, by design. `actions.branch_id` and
|
||||
`memories.branch_id` both use ON DELETE CASCADE, so the database removes a
|
||||
deleted branch's nodes. A relationship would make SQLAlchemy load those rows
|
||||
first, and loading every action of a branch is what the windowed reads exist
|
||||
to avoid.
|
||||
"""
|
||||
|
||||
__tablename__ = "branches"
|
||||
@@ -201,22 +212,23 @@ class Branch(Base):
|
||||
parent_branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# The depth this branch left its parent at, stored when the fork happens and
|
||||
# never inferred afterwards. Inferring it from where two branches' nodes
|
||||
# first differ would be a guess about how the story was played — and a wrong
|
||||
# one as soon as an attempt happens to repeat its parent's text.
|
||||
# The depth at which this branch left its parent. The fork records this
|
||||
# value, and no code infers it later. Deriving it from the first depth where
|
||||
# two branches' nodes differ would produce a wrong answer whenever an
|
||||
# attempt repeats its parent's text.
|
||||
fork_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# The ancestry, newest first: [[branch_id, max_depth], ...] where max_depth
|
||||
# is NULL for "to the tip" and otherwise the fork_depth of the branch
|
||||
# beneath it, inclusive. Computed once at fork from the parent's lineage
|
||||
# plus one entry, so no read ever reconstructs it.
|
||||
# The ancestry, newest first, as [[branch_id, max_depth], ...]. A NULL
|
||||
# `max_depth` means the entry extends to the tip of that branch. Any other
|
||||
# value is the fork depth of the branch below it, inclusive. The fork
|
||||
# computes this list once from the parent's lineage plus one entry, so no
|
||||
# read has to reconstruct it.
|
||||
lineage: Mapped[list] = mapped_column(JSON, default=list)
|
||||
# What the player called this line of the story, or NULL for one nobody has
|
||||
# named. NULL rather than a generated "branch 4", because a generated name
|
||||
# is derived and this column is for what was chosen — the same rule the v2
|
||||
# bundle is built on. A stored default would also become a lie the moment a
|
||||
# branch before it is deleted and the ordinals shift under it; an unnamed
|
||||
# branch is drawn from its fork depth instead, which nothing can shift.
|
||||
# The name the player gave this line of the story, or NULL if no one named
|
||||
# it. The column stores NULL rather than a generated name such as
|
||||
# "branch 4", because it records what the player chose rather than what the
|
||||
# app derived. A stored default would also become wrong as soon as an
|
||||
# earlier branch is deleted and the ordinals shift. The UI labels an unnamed
|
||||
# branch by its fork depth, which deleting a branch does not change.
|
||||
name: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
@@ -228,11 +240,10 @@ class Memory(Base):
|
||||
NULL until embedded, which also marks it for backfill when an embedding
|
||||
model becomes available.
|
||||
|
||||
Cosine ranking happens in Python, which means the vectors cross the wire.
|
||||
The original comment here sized that by count — "fine at a few hundred" —
|
||||
and it was wrong by the only measure that mattered: a few hundred JSON
|
||||
vectors is ten megabytes, fetched fresh every turn. Weigh new columns in
|
||||
bytes.
|
||||
Cosine ranking runs in Python, so the vectors travel over the wire. Measure
|
||||
that cost in bytes rather than in rows. A few hundred vectors stored as JSON
|
||||
come to about 10 MB, fetched again on every turn. Size any new column by the
|
||||
bytes it adds, not by the number of rows.
|
||||
"""
|
||||
|
||||
__tablename__ = "memories"
|
||||
@@ -240,39 +251,42 @@ class Memory(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
# The vector, little-endian float32. Deferred because it is wider than the
|
||||
# rest of the row put together and exactly one code path wants it: anything
|
||||
# bulk-loading memories (the Memories drawer, eviction, the embed queue)
|
||||
# must project the columns it needs rather than load whole entities.
|
||||
# The vector, stored as little-endian float32. This column is deferred
|
||||
# because it is wider than the rest of the row combined and only one code
|
||||
# path reads it. Code that loads memories in bulk, such as the Memories
|
||||
# drawer, eviction, and the embed queue, must select the columns it needs
|
||||
# instead of loading whole entities.
|
||||
embedding_blob: Mapped[bytes | None] = mapped_column(
|
||||
LargeBinary, nullable=True, deferred=True
|
||||
)
|
||||
# The stretch of story this memory summarizes, as depths on `branch_id`
|
||||
# (null for a hand-written memory, which summarizes nothing). Written as
|
||||
# `Action.index` values before SP3, which held the same numbers.
|
||||
# `source_end` is the depth of the node the memory hangs off, mirrored into
|
||||
# `depth` below; `source_start` is where it began, which is where the
|
||||
# summarizer has to resume from if the memory is ever withdrawn.
|
||||
# The stretch of story this memory summarizes, given as depths on
|
||||
# `branch_id`. Both are NULL for a hand-written memory, which summarizes no
|
||||
# actions. Before SP3 these columns held `Action.index` values, which were
|
||||
# the same numbers. `source_end` is the depth of the node the memory
|
||||
# attaches to, and `depth` below mirrors it. `source_start` is where the
|
||||
# stretch begins, which is where the summarizer resumes if the memory is
|
||||
# withdrawn.
|
||||
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Phase 14: the node that produced this memory — the last action it
|
||||
# summarises. Anything derived attaches to the node it came from, which is
|
||||
# what makes a fork free: a shared ancestor's memories are shared
|
||||
# automatically, and a memory covering a stretch of branch B is invisible
|
||||
# Phase 14: the node that produced this memory, meaning the last action the
|
||||
# memory summarizes. Derived data attaches to the node it came from, which
|
||||
# is what makes forking cheap. Memories on a shared ancestor are shared
|
||||
# automatically, and a memory that covers part of branch B is not visible
|
||||
# from any path that does not go through B.
|
||||
#
|
||||
# Every memory has one, including a hand-written one: it takes the head at
|
||||
# the moment it was written (SP7). A NULL depth used to mean "belongs to the
|
||||
# adventure, not to a path", which is a category no fork could cap — the
|
||||
# memory followed the reader onto branches whose story it never described.
|
||||
# Every memory has a coordinate, including a hand-written one, which takes
|
||||
# the head as of the moment it was written (SP7). A NULL depth used to mean
|
||||
# that the memory belonged to the adventure rather than to a path. A fork
|
||||
# cannot cap a NULL, so such a memory followed the reader onto branches
|
||||
# whose story it did not describe.
|
||||
branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Whether embedding_blob is set. Maintained on write by memorybank
|
||||
# .set_vector, for the same reason actions.variant_count exists beside
|
||||
# actions.variants: every reader wants the one-bit answer and none of them
|
||||
# should have to fetch six kilobytes of vector to get it.
|
||||
# Whether `embedding_blob` is set. `memorybank.set_vector` keeps this
|
||||
# column current, for the same reason that `actions.variant_count` sits
|
||||
# beside `actions.variants`. Readers need only the yes-or-no answer, and
|
||||
# fetching six kilobytes of vector to get it is too expensive.
|
||||
embedded: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
|
||||
@@ -300,10 +314,12 @@ class StoryCard(Base):
|
||||
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
|
||||
entry: Mapped[str] = mapped_column(Text, default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
# Adventure copies only: which piece of the scenario this card came from —
|
||||
# "card:<scenario_card_id>" or "npc:<npc_key>". "Update from scenario"
|
||||
# refreshes/removes exactly these; NULL means player-authored (left alone),
|
||||
# or a copy predating the column (matched by name, then adopted).
|
||||
# Set on adventure copies only. It records which piece of the scenario the
|
||||
# card came from, as either "card:<scenario_card_id>" or "npc:<npc_key>".
|
||||
# The "Update from scenario" action refreshes or removes exactly these
|
||||
# cards. A NULL value means the player wrote the card, so the update leaves
|
||||
# it alone, or that the copy predates this column, in which case the update
|
||||
# matches it by name and then sets this value.
|
||||
source_ref: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards")
|
||||
@@ -312,72 +328,76 @@ class StoryCard(Base):
|
||||
|
||||
class Action(Base):
|
||||
__tablename__ = "actions"
|
||||
# Phase 14: every read of a story is "this branch up to this depth, or that
|
||||
# branch up to that depth, ...", so (branch_id, depth) is the shape every
|
||||
# one of those clauses wants an index on.
|
||||
# Phase 14: every story read selects one branch up to one depth, then
|
||||
# another branch up to another depth, and so on. The pair (branch_id, depth)
|
||||
# is the index those clauses need.
|
||||
__table_args__ = (Index("ix_actions_branch_depth", "branch_id", "depth"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
index: Mapped[int] = mapped_column(Integer)
|
||||
# Phase 14: the node's place in the tree. `depth` is a position along *a*
|
||||
# path, not a global turn number — A4 and B4 are alternatives, not
|
||||
# duplicates — and it replaces `index` as the ordering key.
|
||||
# Phase 14: the node's place in the tree. `depth` is a position along one
|
||||
# path rather than a global turn number. Node A4 and node B4 are
|
||||
# alternatives, not duplicates. `depth` replaces `index` as the ordering
|
||||
# key.
|
||||
#
|
||||
# Nullable because ALTER TABLE cannot add a NOT NULL column with no
|
||||
# default and there is no sensible default for "which branch": the
|
||||
# migration fills them, `tree.place_action` fills them for new nodes, and
|
||||
# from SP2 on a NULL branch_id is a row no read can see. Legacy `index`
|
||||
# stays beside them, unread, until the tree is proven live (SP8 drops it).
|
||||
# Both columns are nullable because ALTER TABLE cannot add a NOT NULL column
|
||||
# without a default, and no default makes sense for a branch. The migration
|
||||
# fills these columns for existing rows, and `tree.place_action` fills them
|
||||
# for new rows. From SP2 onward, a NULL `branch_id` marks a row that no read
|
||||
# can see. The legacy `index` column stays alongside, unread, until the tree
|
||||
# is proven in production. SP8 drops it.
|
||||
branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Phase 14, SP9: the node this one was played after — the take that was
|
||||
# live when it was written, not merely whatever sits at depth - 1 now.
|
||||
# Phase 14, SP9: the node that this node was played after. It names the take
|
||||
# that was live when this row was written, not whatever sits at depth - 1
|
||||
# now.
|
||||
#
|
||||
# It exists for one question: which takes belong to the same turn. A
|
||||
# coordinate cannot answer it, because a take that gets forked onto its own
|
||||
# branch leaves the coordinate its siblings are still at and would read
|
||||
# `1/1` next to their `1/3`. A parent does not move when a branch does.
|
||||
# This column answers one question: which takes belong to the same turn. A
|
||||
# coordinate cannot answer it. A take that is forked onto its own branch
|
||||
# leaves the coordinate that its siblings still occupy, so the pager would
|
||||
# show it as 1/1 next to their 1/3. Forking a branch does not change a
|
||||
# node's parent.
|
||||
#
|
||||
# Read only to group takes — one indexed lookup, never a walk. Paths still
|
||||
# resolve through `lineage`, which is why this column can be added without
|
||||
# touching a single read of the story.
|
||||
# Code reads this column only to group takes, using one indexed lookup
|
||||
# rather than a walk. Paths still resolve through `lineage`, which is why
|
||||
# adding this column required no change to any read of the story.
|
||||
#
|
||||
# NULL on a root node, and on every pre-SP9 row the migration could not
|
||||
# place: `attempts.group` falls back to the coordinate there, which is what
|
||||
# those rows were written under.
|
||||
# The value is NULL on a root node, and on pre-SP9 rows that the migration
|
||||
# could not place. For those rows, `attempts.group` falls back to the
|
||||
# coordinate, which is how they were written.
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("actions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
# Phase 14, SP4: whether this node is the one the story tells at its
|
||||
# coordinate. Retry no longer rewrites a row — it writes a *sibling* at the
|
||||
# same (branch, depth), so a coordinate can hold several attempts and
|
||||
# Phase 14, SP4: whether this node is the one the story uses at its
|
||||
# coordinate. Retry no longer rewrites a row. It writes a sibling at the
|
||||
# same branch and depth, so one coordinate can hold several attempts while
|
||||
# exactly one of them is on the path. `lineage.Path.clause` is the only
|
||||
# place that reads this, for the same reason it is the only place that
|
||||
# knows about branches: an attempt leaking into a read is a story quietly
|
||||
# telling itself twice.
|
||||
# place that reads this column, for the same reason it is the only place
|
||||
# that knows about branches. If a discarded attempt reaches a read, the page
|
||||
# renders the same turn twice.
|
||||
#
|
||||
# A node with no siblings is live, which is why the default is True and why
|
||||
# every pre-SP4 row is correct without being visited.
|
||||
# A node with no siblings is live, so the default is True and every pre-SP4
|
||||
# row is already correct. The migration does not need to visit them.
|
||||
live: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
# Reasoning-model "thinking" that preceded the text (AI actions only).
|
||||
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The full assembled prompt for this turn, for the Insights viewer. By far
|
||||
# the biggest column in the database — 163 KB a row averaged over
|
||||
# production and 232 KB on the longest adventure, 89% of everything stored
|
||||
# — and needed by exactly one endpoint, one action at a time.
|
||||
# The full assembled prompt for this turn, used by the Insights viewer.
|
||||
# This is the largest column in the database. It averages 163 KB per row in
|
||||
# production and 232 KB on the longest adventure, and it accounts for 89% of
|
||||
# everything stored. Only one endpoint reads it, one action at a time.
|
||||
#
|
||||
# Two separate defences, because it is expensive in two separate ways.
|
||||
# `deferred=True` is the read defence: never loaded unless something
|
||||
# touches the attribute, so a page load pays nothing for it. Bulk readers
|
||||
# must NOT touch it; that is what `world_delta` below exists for.
|
||||
# CompressedJSON is the *storage* defence: this is the column that decides
|
||||
# when the free tier's 512 MB runs out. Still a dict either way — see
|
||||
# compression.py.
|
||||
# The column is expensive in two ways, so it has two protections.
|
||||
# `deferred=True` protects reads, because SQLAlchemy loads the column only
|
||||
# when code touches the attribute. A page load therefore costs nothing. Code
|
||||
# that reads actions in bulk must not touch this attribute, which is why
|
||||
# `world_delta` below exists. `CompressedJSON` protects storage, because
|
||||
# this column determines when the free tier's 512 MB limit is reached. The
|
||||
# attribute behaves like a plain dict in both cases. See compression.py.
|
||||
context_snapshot: Mapped[dict | None] = mapped_column(
|
||||
CompressedJSON, nullable=True, deferred=True
|
||||
)
|
||||
@@ -386,58 +406,58 @@ class Action(Base):
|
||||
# and for re-attaching the emit block when replaying history to the model.
|
||||
# Mirrors the active variant, same as text/reasoning/context_snapshot.
|
||||
world_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# LEGACY (SP4): Adventure.script_state / world_state as they were
|
||||
# immediately BEFORE this action's script hooks ran. Unwritten since SP4
|
||||
# and read by nothing — the *after* pair below replaced them, because a
|
||||
# sibling attempt needs its own outcome and a "before" picture is shared by
|
||||
# every attempt at the turn. Kept for one release so a rolled-back build
|
||||
# still finds a real snapshot on every row it wrote itself; SP8 drops them
|
||||
# beside `index` and `variants`.
|
||||
# Legacy columns from before SP4. They hold `Adventure.script_state` and
|
||||
# `Adventure.world_state` as they were immediately before this action's
|
||||
# script hooks ran. Nothing has written or read them since SP4. The pair of
|
||||
# "after" columns below replaced them, because each sibling attempt needs
|
||||
# its own outcome and every attempt at a turn shares the same starting
|
||||
# state. These columns remain for one release so that a rolled-back build
|
||||
# still finds a real snapshot on the rows it wrote. SP8 drops them along
|
||||
# with `index` and `variants`.
|
||||
state_before: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
world_state_before: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
# Phase 14, SP4: the shared script scoreboard and the RPG world state as
|
||||
# they stood once this node had been played — *its* outcome, not its
|
||||
# starting position.
|
||||
# Phase 14, SP4: the shared script state and the RPG world state as they
|
||||
# stood after this node was played. These columns record the node's outcome
|
||||
# rather than its starting position.
|
||||
#
|
||||
# Two things want this and neither can use a "before" picture. Switching
|
||||
# between siblings has to put back the state the chosen attempt produced,
|
||||
# and the attempts differ precisely in what they produced. And rolling back
|
||||
# to before a turn is "the state the node in front of it left behind",
|
||||
# which is one lookup on the path rather than a snapshot that has to be
|
||||
# taken from inside the turn being rolled back.
|
||||
# Two operations need this outcome, and neither can use a snapshot taken
|
||||
# before the turn. Switching between siblings must restore the state that
|
||||
# the chosen attempt produced, and the attempts differ in exactly that.
|
||||
# Rolling back to before a turn means restoring the state that the preceding
|
||||
# node left behind, which is one lookup along the path.
|
||||
#
|
||||
# NULL on rows written before SP4 that the migration could not derive one
|
||||
# for, and tolerated everywhere: a missing snapshot means "leave the live
|
||||
# state alone", never "reset it".
|
||||
# The value is NULL on pre-SP4 rows for which the migration could not derive
|
||||
# one. Every caller tolerates that. A missing snapshot means that the caller
|
||||
# leaves the live state unchanged. It never means reset the state.
|
||||
#
|
||||
# Deferred: only ever read for the one node being switched to, undone or
|
||||
# retried past.
|
||||
# These columns are deferred, because code reads them only for the single
|
||||
# node being switched to, undone, or retried past.
|
||||
state_after: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
world_state_after: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
# LEGACY (SP4): retry history as a JSON repeating group. Every attempt at
|
||||
# this turn is its own row now — see `live` above and `app/attempts.py` —
|
||||
# so nothing reads this. Kept until SP8 for the same reason `index` is, and
|
||||
# read exactly once more on the way out: migration 60 is what turns each
|
||||
# entry into the sibling row it should always have been.
|
||||
# A legacy column from before SP4. It holds the retry history as a repeating
|
||||
# group inside a JSON list. Every attempt at a turn is now its own row, as
|
||||
# described on `live` above and in `app/attempts.py`, so nothing reads this
|
||||
# column. It remains until SP8 for the same reason `index` does. Migration
|
||||
# 60 reads it once more, to turn each entry into a sibling row.
|
||||
variants: Mapped[list | None] = mapped_column(JSON, nullable=True, deferred=True)
|
||||
# Where the row sits in its sibling group: `variant_index` is this
|
||||
# attempt's ordinal, oldest first, and `variant_count` is how many attempts
|
||||
# the group holds (0, not 1, when the turn was never retried — the pager
|
||||
# reads that as "nothing to page through").
|
||||
# Where the row sits in its sibling group. `variant_index` is this
|
||||
# attempt's ordinal, counting from the oldest. `variant_count` is the number
|
||||
# of attempts in the group. It is 0 rather than 1 when the turn was never
|
||||
# retried, which the pager treats as having nothing to page through.
|
||||
#
|
||||
# A cache of two facts about the group, maintained in one place
|
||||
# (`attempts.renumber`) for the same reason it used to be a cache of
|
||||
# `len(variants)`: a page response wants them for every row and must not
|
||||
# pay a query per turn to get them. SP7 replaces the pager with the branch
|
||||
# view and SP8 drops both columns.
|
||||
# Both columns are caches, and `attempts.renumber` is the only place that
|
||||
# maintains them. The reason matches why `variant_count` once cached
|
||||
# `len(variants)`: a page response needs both numbers for every row and
|
||||
# cannot afford one query per turn. SP7 replaces the pager with the branch
|
||||
# view, and SP8 drops both columns.
|
||||
variant_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
variant_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
@@ -450,9 +470,9 @@ class Action(Base):
|
||||
under an AI message. Labels are path-based (no schema needed):
|
||||
`npc.gwen.trust` -> "gwen trust".
|
||||
|
||||
Reads `world_delta`, never `context_snapshot` — this runs for every
|
||||
action in a list response, and touching the deferred snapshot here
|
||||
would drag the whole prompt archive out of the database."""
|
||||
Reads `world_delta`, never `context_snapshot`. This runs for every
|
||||
action in a list response, and touching the deferred snapshot here would
|
||||
drag the entire prompt archive out of the database."""
|
||||
wd = self.world_delta if isinstance(self.world_delta, dict) else None
|
||||
if wd is None:
|
||||
return []
|
||||
@@ -500,10 +520,10 @@ class AdventureScript(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
# The library Script this copy was made from, so it can be re-synced on
|
||||
# demand. NULL for legacy copies (predate this column) and demo-derived
|
||||
# ones whose source isn't owned by the player — those fall back to a
|
||||
# name match, or simply aren't syncable.
|
||||
# The library Script that this copy was made from, which lets the player
|
||||
# re-sync it on demand. The value is NULL for legacy copies that predate
|
||||
# this column, and for demo-derived copies whose source the player does not
|
||||
# own. Those copies fall back to matching by name, or cannot be synced.
|
||||
source_script_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -529,7 +549,8 @@ class Settings(Base):
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, unique=True
|
||||
)
|
||||
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
|
||||
# Fernet-encrypted at rest ("enc:..." — see security.py); use api_key_plain.
|
||||
# Encrypted at rest with Fernet, which produces a value that starts with
|
||||
# "enc:". See security.py. To read the key, use `api_key_plain`.
|
||||
api_key: Mapped[str] = mapped_column(String(500), default="")
|
||||
model: Mapped[str] = mapped_column(String(200), default="")
|
||||
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
|
||||
@@ -557,10 +578,10 @@ class Settings(Base):
|
||||
# Phase 6: auto-summarization + memory bank
|
||||
summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model
|
||||
embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled
|
||||
# Was 200. Lowered on retrieval-quality grounds first: ranking two hundred
|
||||
# memories to pick five means the five are chosen out of a lot of noise,
|
||||
# and older memories describe a story the player has moved on from. That it
|
||||
# also cuts what the bank costs to read is the smaller reason.
|
||||
# This was 200. It was lowered mainly to improve retrieval quality. Ranking
|
||||
# 200 memories to choose 5 selects from a large amount of noise, and the
|
||||
# oldest memories describe a part of the story that the player has left
|
||||
# behind. Cheaper reads are a secondary benefit rather than the reason.
|
||||
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=80)
|
||||
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
|
||||
|
||||
@@ -576,18 +597,19 @@ class Settings(Base):
|
||||
|
||||
|
||||
# ---------- Visit analytics (see analytics.py) ----------
|
||||
# Two deliberately dumb tables. Neither can hold anything a player wrote, and
|
||||
# neither can be joined back to a `users` row: the visitor column is an HMAC,
|
||||
# with no foreign key, so guest cleanup deleting an account leaves the history
|
||||
# it contributed to intact and anonymous.
|
||||
# Two intentionally simple tables. Neither can hold text that a player wrote,
|
||||
# and neither can be joined back to a `users` row, because the visitor column
|
||||
# holds an HMAC and has no foreign key. When guest cleanup deletes an account,
|
||||
# the history that account contributed remains intact and anonymous.
|
||||
|
||||
|
||||
class AnalyticsDaily(Base):
|
||||
"""One counter: how many times `label` happened within `metric` on `day`.
|
||||
|
||||
A generic (metric, label, hits) triple rather than a column per statistic,
|
||||
so measuring something new later costs a constant instead of a migration.
|
||||
Written only by UPSERT, from a buffer — see analytics.flush.
|
||||
The table stores a generic triple of metric, label, and hits rather than one
|
||||
column per statistic. Measuring something new therefore costs a constant
|
||||
rather than a migration. The only writer is an UPSERT that runs from a
|
||||
buffer. See `analytics.flush`.
|
||||
"""
|
||||
|
||||
__tablename__ = "analytics_daily"
|
||||
@@ -607,10 +629,10 @@ class AnalyticsDaily(Base):
|
||||
class AnalyticsVisitorDay(Base):
|
||||
"""One visitor, one day, and which funnel steps they reached on it.
|
||||
|
||||
Exists so the funnel counts people rather than clicks — a player who starts
|
||||
six adventures is one person who started an adventure. `is_new` is set when
|
||||
the visitor has no earlier row, which is also why the visitor column is
|
||||
indexed on its own.
|
||||
This table exists so that the funnel counts people rather than clicks. A
|
||||
player who starts six adventures counts as one person who started an
|
||||
adventure. `is_new` is set when the visitor has no earlier row, which is why
|
||||
the visitor column also has an index of its own.
|
||||
"""
|
||||
|
||||
__tablename__ = "analytics_visitor_days"
|
||||
@@ -634,15 +656,16 @@ class AnalyticsVisitorDay(Base):
|
||||
class AccessEvent(Base):
|
||||
"""One sign-in, registration, failed attempt, or session first-seen.
|
||||
|
||||
The counterpart to the two tables above, and deliberately not mixed in with
|
||||
them: this one identifies people on purpose — address, email, device — so
|
||||
keeping it in its own table (and its own module) means the anonymity of the
|
||||
counters stays a property of the code rather than of a convention.
|
||||
This table is the counterpart to the two above, and it is kept separate from
|
||||
them on purpose. It identifies people by design, recording address, email,
|
||||
and device. Keeping it in its own table and its own module means that the
|
||||
structure enforces the anonymity of the counters rather than a convention.
|
||||
|
||||
`user_id` is a plain integer with no foreign key. An access log that
|
||||
disappeared when the account did would not be an access log, and guest
|
||||
cleanup deletes accounts on a schedule; `who` and `is_guest` are snapshots
|
||||
for the same reason, so a row still reads correctly afterwards.
|
||||
disappeared when the account did would not serve its purpose, and guest
|
||||
cleanup deletes accounts on a schedule. `who` and `is_guest` are snapshots
|
||||
for the same reason, so a row still reads correctly after the account is
|
||||
gone.
|
||||
"""
|
||||
|
||||
__tablename__ = "access_events"
|
||||
@@ -652,8 +675,9 @@ class AccessEvent(Base):
|
||||
# session | login | register | login_failed
|
||||
kind: Mapped[str] = mapped_column(String(16))
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Email for a registered account, "Guest #12" otherwise; for a failed
|
||||
# sign-in, the address that was tried — which is the point of the row.
|
||||
# The email for a registered account, or a label such as "Guest #12"
|
||||
# otherwise. For a failed sign-in, this holds the address that was tried,
|
||||
# which is the reason the row exists.
|
||||
who: Mapped[str] = mapped_column(String(320), default="")
|
||||
is_guest: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
ip: Mapped[str] = mapped_column(String(45), default="") # 45 = max IPv6
|
||||
@@ -662,19 +686,19 @@ class AccessEvent(Base):
|
||||
user_agent: Mapped[str] = mapped_column(String(200), default="")
|
||||
|
||||
|
||||
# Phase 14 — the floor under `tree.place_action`.
|
||||
# Phase 14: the fallback under `tree.place_action`.
|
||||
#
|
||||
# From SP2 a read selects on (branch_id, depth): a node written without them is
|
||||
# a node no page, no context build and no memory pass can see, and it fails by
|
||||
# disappearing rather than by raising. The writers all place their nodes
|
||||
# explicitly, but "all the writers remember" is a promise that has to hold for
|
||||
# every fixture, script and test written from here on, so the session enforces
|
||||
# it on the way to the database instead.
|
||||
# Since SP2, reads filter on `branch_id` and `depth`. A node written without
|
||||
# them is invisible to every page, every context build, and every memory pass.
|
||||
# The failure is silent, because nothing raises an error. Every current writer
|
||||
# places its nodes explicitly, but relying on that would also mean relying on
|
||||
# every fixture, script, and test written from now on. The session therefore
|
||||
# enforces the rule as rows travel to the database.
|
||||
#
|
||||
# Registered here rather than in tree.py so that importing the models is enough
|
||||
# to arm it — the invariant belongs to the rows, not to the module that usually
|
||||
# writes them. The import is deferred into the callback because tree.py imports
|
||||
# this module.
|
||||
# This listener is registered here rather than in tree.py so that importing the
|
||||
# models is enough to enable it. The invariant belongs to the rows, not to the
|
||||
# module that usually writes them. The import sits inside the callback because
|
||||
# tree.py imports this module.
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _place_new_nodes_on_the_tree(session, flush_context, instances):
|
||||
from . import tree
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""SSRF guard for the one place the server makes an outbound request to a
|
||||
user-supplied address: the BYOK `endpoint_url` (connection test + turns/chat).
|
||||
|
||||
Without this, a hosted user could point endpoint_url at an internal service or
|
||||
the cloud metadata endpoint (169.254.169.254) and have the server fetch it —
|
||||
the connection test even echoes part of the response back. We refuse any URL
|
||||
that resolves to a non-public address.
|
||||
Without this guard, a hosted user could point `endpoint_url` at an internal
|
||||
service or at the cloud metadata endpoint, 169.254.169.254, and have the server
|
||||
fetch it. The connection test even returns part of the response. The guard
|
||||
therefore refuses any URL that resolves to a non-public address.
|
||||
|
||||
No-op in local mode: a local install talking to http://localhost:11434 (Ollama)
|
||||
is the normal, intended case — the guard only applies to the hosted, multi-user
|
||||
deployment where the endpoint comes from an untrusted visitor.
|
||||
The guard does nothing in local mode. A local install talking to
|
||||
http://localhost:11434, which is Ollama, is the intended case. The guard applies
|
||||
only to a hosted, multi-user deployment, where the endpoint comes from an
|
||||
untrusted visitor.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
|
||||
@@ -17,10 +17,10 @@ class ProviderError(Exception):
|
||||
|
||||
class Provider(ABC):
|
||||
# The endpoint's own token accounting for the most recent call, when it
|
||||
# reported any — notably `prompt_tokens_details.cached_tokens`, which is
|
||||
# the only direct read on whether the prompt prefix is being cached.
|
||||
# Callers read it after the call they made; one provider is built per
|
||||
# request, so there is nothing to race.
|
||||
# reported any. It notably carries `prompt_tokens_details.cached_tokens`,
|
||||
# which is the only direct measure of whether the prompt prefix is being
|
||||
# cached. A caller reads it after the call it made, and one provider is built
|
||||
# per request, so nothing races.
|
||||
last_usage: dict | None = None
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -6,32 +6,33 @@ import httpx
|
||||
from .. import debuglog, netguard
|
||||
from .base import PromptParts, Provider, ProviderError
|
||||
|
||||
# Framing appended after the story text in chat mode, so chat-tuned models keep
|
||||
# continuing prose instead of replying conversationally.
|
||||
# Appended after the story text in chat mode, so a chat-tuned model continues
|
||||
# the prose rather than replying conversationally.
|
||||
CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]"
|
||||
|
||||
# OpenRouter serves one model from whichever upstream is available, and every
|
||||
# upstream holds its own prompt cache — so a request that lands somewhere new
|
||||
# starts cold however stable the prompt is. Naming a preferred upstream makes
|
||||
# routing deterministic, which is what lets a cache be hit at all.
|
||||
# upstream holds its own prompt cache, so a request routed somewhere new starts
|
||||
# with a cold cache however stable the prompt is. Naming a preferred upstream
|
||||
# makes routing deterministic, which is what allows a cache hit at all.
|
||||
#
|
||||
# `allow_fallbacks` is deliberately left at its default of true: this is a
|
||||
# preference, not a restriction. If the named upstream is down the request
|
||||
# still goes through somewhere else and merely misses the cache, which is the
|
||||
# behaviour we had anyway.
|
||||
# `allow_fallbacks` stays at its default of true on purpose, because this is a
|
||||
# preference rather than a restriction. If the named upstream is down, the
|
||||
# request still goes elsewhere and only misses the cache, which is the behavior
|
||||
# without this setting.
|
||||
#
|
||||
# A whitelist rather than a derivation from the model slug. The vendor half of
|
||||
# a slug is *usually* the provider slug ("deepseek/..." -> "deepseek", verified
|
||||
# against /api/v1/providers) but not reliably: Google's models are served by
|
||||
# "google-ai-studio" and "google-vertex", and there is no "google". Look a
|
||||
# vendor up on the model's Providers tab before adding it here — a slug that
|
||||
# does not exist is a routing preference that silently does nothing at best.
|
||||
# This is a list rather than a value derived from the model slug. The vendor half
|
||||
# of a slug is usually the provider slug, such as "deepseek/..." mapping to
|
||||
# "deepseek", which was verified against /api/v1/providers, but not reliably.
|
||||
# Google's models are served by "google-ai-studio" and "google-vertex", and there
|
||||
# is no "google". Look a vendor up on the model's Providers tab before adding it
|
||||
# here. A slug that does not exist is a routing preference that, at best, does
|
||||
# nothing.
|
||||
_OPENROUTER_HOST = "openrouter.ai"
|
||||
_PREFERRED_UPSTREAM = {"deepseek": "deepseek"}
|
||||
|
||||
|
||||
# Completion endpoints have no roles, so a plain chat has to be flattened into
|
||||
# one labelled transcript that trails off on "Assistant:" for the model to continue.
|
||||
# Completion endpoints have no roles, so a chat has to be flattened into one
|
||||
# labeled transcript that ends on "Assistant:" for the model to continue.
|
||||
_ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant"}
|
||||
|
||||
|
||||
@@ -43,7 +44,11 @@ def flatten_messages(messages: list[dict]) -> str:
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(Provider):
|
||||
"""Adapter for any /v1-style endpoint: Ollama, LM Studio, OpenAI, OpenRouter, vLLM, Groq…"""
|
||||
"""Adapter for any /v1-style endpoint.
|
||||
|
||||
This covers Ollama, LM Studio, OpenAI, OpenRouter, vLLM, and Groq, among
|
||||
others.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -56,17 +61,18 @@ class OpenAICompatibleProvider(Provider):
|
||||
self.base_url = endpoint_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.api_mode = api_mode # "chat" | "completion"
|
||||
# Thinking budget for reasoning models, on top of max_tokens. 0 = the
|
||||
# `reasoning` param is not sent (endpoints that don't know it may
|
||||
# reject unknown fields); negative = explicitly ask the endpoint to
|
||||
# turn reasoning off.
|
||||
self.api_mode = api_mode # Either "chat" or "completion".
|
||||
# The thinking budget for reasoning models, on top of `max_tokens`. A
|
||||
# value of 0 means the `reasoning` parameter is not sent, because an
|
||||
# endpoint that does not know the field may reject it. A negative value
|
||||
# asks the endpoint to turn reasoning off.
|
||||
self.reasoning_max_tokens = reasoning_max_tokens
|
||||
# Token accounting from the last call, when the endpoint reported any:
|
||||
# prompt/completion counts plus, on OpenRouter, `prompt_tokens_details.
|
||||
# cached_tokens` — the number of prompt tokens read from cache instead
|
||||
# of billed in full. Written by every request method, so a caller reads
|
||||
# it after the call it made; one provider is built per request.
|
||||
# The token accounting from the last call, when the endpoint reported
|
||||
# any. It holds the prompt and completion counts, plus, on OpenRouter,
|
||||
# `prompt_tokens_details.cached_tokens`, which is the number of prompt
|
||||
# tokens read from cache rather than billed in full. Every request method
|
||||
# writes it, so a caller reads it after the call it made. One provider is
|
||||
# built per request.
|
||||
self.last_usage: dict | None = None
|
||||
|
||||
def _headers(self) -> dict:
|
||||
@@ -76,14 +82,16 @@ class OpenAICompatibleProvider(Provider):
|
||||
return headers
|
||||
|
||||
def _apply_reasoning_budget(self, body: dict) -> None:
|
||||
"""Give reasoning models their own thinking budget (OpenRouter-style),
|
||||
raising max_tokens so the actual output keeps its full budget.
|
||||
"""Gives reasoning models their own thinking budget, in the OpenRouter style.
|
||||
|
||||
A negative budget means the opposite: send `effort: "none"` to switch
|
||||
reasoning off on models that do it by default (DeepSeek V4 Flash, say).
|
||||
That's distinct from `exclude: true`, which still thinks — and bills —
|
||||
but hides the trace. Zero stays "send nothing at all" so endpoints that
|
||||
reject unknown fields (Ollama) keep working."""
|
||||
The method raises `max_tokens`, so the output keeps its full budget.
|
||||
|
||||
A negative budget does the opposite. It sends `effort: "none"` to turn
|
||||
reasoning off on a model that reasons by default, such as DeepSeek V4
|
||||
Flash. That differs from `exclude: true`, which still reasons and still
|
||||
bills for it while hiding the trace. Zero still means send nothing, so an
|
||||
endpoint that rejects unknown fields, such as Ollama, keeps working.
|
||||
"""
|
||||
if self.api_mode != "chat":
|
||||
return
|
||||
if self.reasoning_max_tokens < 0:
|
||||
@@ -93,11 +101,12 @@ class OpenAICompatibleProvider(Provider):
|
||||
body["max_tokens"] += self.reasoning_max_tokens
|
||||
|
||||
def _apply_provider_routing(self, body: dict) -> None:
|
||||
"""Prefer one upstream on OpenRouter, so the prompt cache is warm.
|
||||
"""Prefers one upstream on OpenRouter, so the prompt cache stays warm.
|
||||
|
||||
Silent no-op everywhere else: `provider` is an OpenRouter extension and
|
||||
Ollama and friends reject fields they do not know — the same trap the
|
||||
`reasoning` param above is written around."""
|
||||
The method does nothing anywhere else. `provider` is an OpenRouter
|
||||
extension, and Ollama and similar servers reject fields they do not know.
|
||||
The `reasoning` parameter above is written around the same constraint.
|
||||
"""
|
||||
if _OPENROUTER_HOST not in self.base_url:
|
||||
return
|
||||
upstream = _PREFERRED_UPSTREAM.get(self.model.split("/", 1)[0].lower())
|
||||
@@ -105,12 +114,13 @@ class OpenAICompatibleProvider(Provider):
|
||||
body["provider"] = {"order": [upstream]}
|
||||
|
||||
def _record_usage(self, payload: dict) -> None:
|
||||
"""Record the endpoint's own token accounting, if it reported any.
|
||||
"""Records the endpoint's own token accounting, if it reported any.
|
||||
|
||||
OpenRouter always reports usage now (`usage: {include: true}` and
|
||||
`stream_options` are deprecated no-ops), and in a stream it rides on a
|
||||
final chunk that carries no choices — which is why this is read
|
||||
separately from the text extraction rather than beside it."""
|
||||
OpenRouter now always reports usage, and `usage: {include: true}` and
|
||||
`stream_options` are deprecated and do nothing. In a stream the usage
|
||||
arrives on a final chunk that carries no choices, which is why this is
|
||||
read separately from the text extraction.
|
||||
"""
|
||||
usage = payload.get("usage")
|
||||
if isinstance(usage, dict) and usage:
|
||||
self.last_usage = usage
|
||||
@@ -147,8 +157,8 @@ class OpenAICompatibleProvider(Provider):
|
||||
if not choices:
|
||||
return ""
|
||||
choice = choices[0]
|
||||
# chat stream → delta.content; completion stream → text;
|
||||
# non-stream fallbacks → message.content / text
|
||||
# A chat stream uses `delta.content`, and a completion stream uses
|
||||
# `text`. The non-stream fallbacks are `message.content` and `text`.
|
||||
delta = choice.get("delta") or {}
|
||||
return (
|
||||
delta.get("content")
|
||||
@@ -159,8 +169,11 @@ class OpenAICompatibleProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def _extract_reasoning(payload: dict) -> str:
|
||||
"""Reasoning-model thinking: OpenRouter normalizes to `reasoning`;
|
||||
DeepSeek-style servers use `reasoning_content`."""
|
||||
"""Returns a reasoning model's thinking text.
|
||||
|
||||
OpenRouter normalizes it to `reasoning`, and DeepSeek-style servers use
|
||||
`reasoning_content`.
|
||||
"""
|
||||
choices = payload.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
@@ -178,7 +191,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
async def generate(
|
||||
self, parts: PromptParts, *, temperature: float, max_tokens: int
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Yields ("text" | "reasoning", chunk) pairs."""
|
||||
"""Yields `("text", chunk)` and `("reasoning", chunk)` pairs."""
|
||||
if not self.model:
|
||||
raise ProviderError("No model configured — set one in Settings.")
|
||||
url, body = self._request(parts, temperature, max_tokens)
|
||||
@@ -188,9 +201,12 @@ class OpenAICompatibleProvider(Provider):
|
||||
async def chat(
|
||||
self, messages: list[dict], *, temperature: float, max_tokens: int
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Plain multi-turn chat — no story framing, no context assembly. Takes
|
||||
[{"role", "content"}, ...] straight to the endpoint. Used by the AI Chat
|
||||
scratchpad; the turn engine uses generate()."""
|
||||
"""Runs a plain multi-turn chat, with no story framing and no context
|
||||
assembly.
|
||||
|
||||
The method sends `[{"role", "content"}, ...]` straight to the endpoint.
|
||||
The AI Chat scratchpad uses it, and the turn engine uses `generate()`.
|
||||
"""
|
||||
if not self.model:
|
||||
raise ProviderError("No model configured — set one in Settings.")
|
||||
if self.api_mode == "completion":
|
||||
@@ -217,10 +233,14 @@ class OpenAICompatibleProvider(Provider):
|
||||
yield event
|
||||
|
||||
async def _stream(self, url: str, body: dict) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Shared SSE plumbing for generate()/chat(): POST a streaming request
|
||||
and yield ("text" | "reasoning", chunk) pairs, logging the exchange."""
|
||||
# SSRF guard (hosted mode): a user-supplied endpoint_url must not point
|
||||
# at an internal/metadata address. No-op for local installs.
|
||||
"""Runs the shared SSE request for `generate()` and `chat()`.
|
||||
|
||||
The method POSTs a streaming request, yields `("text", chunk)` and
|
||||
`("reasoning", chunk)` pairs, and logs the exchange.
|
||||
"""
|
||||
# SSRF guard for hosted mode. A user-supplied `endpoint_url` must not
|
||||
# point at an internal or metadata address. This does nothing for a
|
||||
# local install.
|
||||
reason = netguard.endpoint_block_reason(url)
|
||||
if reason:
|
||||
raise ProviderError(f"This endpoint can't be used — {reason}.")
|
||||
@@ -232,8 +252,8 @@ class OpenAICompatibleProvider(Provider):
|
||||
if resp.status_code != 200:
|
||||
detail = (await resp.aread()).decode(errors="replace")[:500]
|
||||
raise ProviderError(self._friendly_http_error(resp.status_code, detail))
|
||||
# Some servers ignore stream=true and return one plain JSON
|
||||
# body; buffer non-SSE lines so we can fall back to it.
|
||||
# Some servers ignore `stream=true` and return one plain
|
||||
# JSON body, so buffer the non-SSE lines to fall back to.
|
||||
saw_sse = False
|
||||
raw_lines: list[str] = []
|
||||
async for line in resp.aiter_lines():
|
||||
@@ -301,8 +321,11 @@ class OpenAICompatibleProvider(Provider):
|
||||
async def complete(
|
||||
self, system: str, user: str, *, temperature: float = 0.3, max_tokens: int = 400
|
||||
) -> str:
|
||||
"""Single non-streaming completion for background calls (summarization).
|
||||
Unlike generate(), no story-continuation framing is added."""
|
||||
"""Runs a single non-streaming completion, for background calls such as
|
||||
summarization.
|
||||
|
||||
Unlike `generate()`, this adds no story-continuation framing.
|
||||
"""
|
||||
if not self.model:
|
||||
raise ProviderError("No model configured — set one in Settings.")
|
||||
if self.api_mode == "completion":
|
||||
@@ -351,7 +374,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
return text.strip()
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""POST /v1/embeddings; self.model is the embedding model here."""
|
||||
"""POSTs to /v1/embeddings. Here `self.model` is the embedding model."""
|
||||
if not self.model:
|
||||
raise ProviderError("No embedding model configured — set one in Settings.")
|
||||
url = f"{self.base_url}/embeddings"
|
||||
@@ -388,8 +411,9 @@ class OpenAICompatibleProvider(Provider):
|
||||
f"model '{self.model}' exists. {detail}"
|
||||
)
|
||||
if status == 429:
|
||||
# OpenRouter's shared free tier has a per-day cap; distinguish it
|
||||
# from a short-term burst limit so the message is actionable.
|
||||
# OpenRouter's shared free tier has a per-day cap. Distinguish it
|
||||
# from a short-term burst limit, so the message tells the reader what
|
||||
# to do.
|
||||
if "free-models-per-day" in detail:
|
||||
return (
|
||||
"The free demo has hit its daily request limit (resets at "
|
||||
|
||||
+591
-524
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""Visit analytics: one endpoint the browser writes to, one the owner reads.
|
||||
|
||||
The split matters. `/collect` is public and takes exactly one fact — which
|
||||
page was viewed — because anything a stranger can POST is a number a stranger
|
||||
can invent. Everything the dashboard actually relies on (turns, adventures,
|
||||
sign-ups, demo spend, errors) is recorded server-side by the code performing
|
||||
it, so those counts are as trustworthy as the app itself.
|
||||
The split matters. `/collect` is public and accepts one fact, which page was
|
||||
viewed, because anything a stranger can POST is a number a stranger can invent.
|
||||
Everything the dashboard relies on, meaning turns, adventures, sign-ups, demo
|
||||
spend, and errors, is recorded on the server by the code that performs it, so
|
||||
those counts are as trustworthy as the app itself.
|
||||
|
||||
The two reading endpoints are owner-only and 404 for everyone else, the same
|
||||
way the AI Chat router does: a feature nobody else can use is better off not
|
||||
@@ -27,7 +27,8 @@ def owner(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
) -> models.User:
|
||||
"""Gate for the reading half. 404, not 403 — see the module docstring."""
|
||||
"""Gates the reading half. It returns 404 rather than 403. See the module
|
||||
docstring."""
|
||||
if not auth.is_owner(user):
|
||||
raise HTTPException(404, "Not found")
|
||||
return user
|
||||
@@ -39,10 +40,10 @@ Owner = Depends(owner)
|
||||
class Pageview(BaseModel):
|
||||
"""What the SPA reports on a page load or a route change.
|
||||
|
||||
`first` marks a real page load rather than a client-side navigation: the
|
||||
things that describe a *visit* rather than a *view* — where it came from,
|
||||
on what kind of device, from which country — are recorded only then, so a
|
||||
visitor who clicks around five pages is still one referral.
|
||||
`first` marks a real page load rather than a client-side navigation. The
|
||||
facts that describe a visit rather than a view, which are where it came from,
|
||||
on what kind of device, and from which country, are recorded only on a page
|
||||
load, so a visitor who clicks through five pages is still one referral.
|
||||
"""
|
||||
|
||||
path: str = Field("", max_length=300)
|
||||
@@ -67,9 +68,9 @@ def collect(
|
||||
if auth.MULTI_USER
|
||||
else auth.local_user(db)
|
||||
)
|
||||
# The operator's own clicking is not traffic. Only in multi-user mode —
|
||||
# locally everyone is the owner, and excluding them would leave the
|
||||
# dashboard permanently empty on the machine it is developed on.
|
||||
# The operator's own clicks are not traffic. This applies only in
|
||||
# multi-user mode. Locally every user is the owner, and excluding them would
|
||||
# leave the dashboard empty on the machine the app is developed on.
|
||||
if auth.MULTI_USER and user is not None and auth.is_owner(user):
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -95,8 +96,10 @@ def summary(
|
||||
db: Session = Depends(get_db),
|
||||
_user: models.User = Owner,
|
||||
) -> dict:
|
||||
"""The whole dashboard in one aggregate response — a few kilobytes however
|
||||
much traffic sits behind it."""
|
||||
"""Returns the whole dashboard in one aggregate response.
|
||||
|
||||
The response is a few kilobytes however much traffic is behind it.
|
||||
"""
|
||||
return analytics.summary(db, days)
|
||||
|
||||
|
||||
@@ -111,9 +114,9 @@ def access_log(
|
||||
) -> dict:
|
||||
"""A page of the access log, newest first.
|
||||
|
||||
Unlike /summary this returns rows about people, which is the whole point of
|
||||
it — so it is behind the same owner gate, paged rather than dumped, and has
|
||||
no counterpart the people it describes can reach.
|
||||
Unlike `/summary`, this returns rows about people, which is what it is for.
|
||||
It is therefore behind the same owner gate, it is paged rather than returned
|
||||
in full, and the people it describes have no endpoint that reaches it.
|
||||
"""
|
||||
page = accesslog.recent(
|
||||
db, limit=limit, before_id=before_id, kind=kind, query=q
|
||||
|
||||
@@ -53,15 +53,19 @@ def me_payload(user: models.User, db: Session) -> dict:
|
||||
|
||||
@router.get("/me")
|
||||
def me(request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Who am I? In multi-user mode this also bootstraps the session: with no
|
||||
(or an invalid) cookie it creates a guest user and sets one — the
|
||||
frontend calls this on load and after any 401."""
|
||||
"""Returns the current user.
|
||||
|
||||
In multi-user mode this also establishes the session. If the cookie is
|
||||
missing or invalid, the endpoint creates a guest user and sets a cookie. The
|
||||
frontend calls it on load and after any 401.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
user = auth.local_user(db)
|
||||
else:
|
||||
user = auth.resolve_session_user(request, db)
|
||||
if user is None:
|
||||
# Each new guest is a database row — cap how fast one IP can mint them.
|
||||
# Each new guest is a database row, so cap how fast one IP can
|
||||
# create them.
|
||||
limits.rate_limit("guest", request)
|
||||
user = models.User(is_guest=True)
|
||||
db.add(user)
|
||||
@@ -80,8 +84,11 @@ def register(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Upgrade the current guest in place — same user_id, so every adventure,
|
||||
scenario, script and setting they created as a guest is kept."""
|
||||
"""Upgrades the current guest in place.
|
||||
|
||||
The `user_id` does not change, so every adventure, scenario, script, and
|
||||
setting they created as a guest is kept.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
raise HTTPException(400, "Accounts are disabled in local mode.")
|
||||
limits.rate_limit("auth", request)
|
||||
|
||||
+28
-18
@@ -1,14 +1,15 @@
|
||||
"""AI Chat — a plain scratchpad for talking to a model directly.
|
||||
"""AI Chat: a plain scratchpad for talking to a model directly.
|
||||
|
||||
Power users only (the AIDND_POWER_USERS email allowlist). Deliberately thin:
|
||||
no story context, no scripts, no world state, and nothing persisted — the
|
||||
conversation lives in the browser and is posted up whole on each turn. It
|
||||
exists to poke at models, prompts and endpoints without starting an adventure.
|
||||
Power users reach it, which means the `AIDND_POWER_USERS` email allowlist. It is
|
||||
deliberately thin. It adds no story context, no scripts, and no world state, and
|
||||
it persists nothing. The conversation lives in the browser and is posted in full
|
||||
on each turn. It exists for testing models, prompts, and endpoints without
|
||||
starting an adventure.
|
||||
|
||||
Model choice is free-form when the user brought their own API key. On the
|
||||
shared demo key it stays pinned to the AIDND_DEMO_MODELS whitelist, exactly as
|
||||
turns are: the server funds that key, so it must not be able to reach paid
|
||||
models by way of this page.
|
||||
Model choice is free when the user brought their own API key. On the shared demo
|
||||
key the model stays pinned to the `AIDND_DEMO_MODELS` allowlist, exactly as it is
|
||||
for turns. The server funds that key, so this page must not let it reach paid
|
||||
models.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -41,10 +42,13 @@ PowerUser = Depends(power_user)
|
||||
def _resolve_model(
|
||||
settings: models.Settings, requested: str | None
|
||||
) -> tuple[auth.ProviderConfig, str | None]:
|
||||
"""Provider config for this chat, plus a note when the requested model was
|
||||
not honoured. The pinning rule itself lives in resolve_provider_config —
|
||||
this only reports the substitution it made, so there is exactly one place
|
||||
that decides what the demo key is allowed to talk to."""
|
||||
"""Returns the provider config for this chat, plus a note when the requested
|
||||
model was not used.
|
||||
|
||||
The pinning rule lives in `resolve_provider_config`. This function only
|
||||
reports the substitution that call made, so one place decides what the demo
|
||||
key may talk to.
|
||||
"""
|
||||
cfg = auth.resolve_provider_config(settings, model_override=requested)
|
||||
wanted = (requested or "").strip()
|
||||
if wanted and wanted != cfg.model:
|
||||
@@ -60,9 +64,12 @@ async def chat_config(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = PowerUser,
|
||||
):
|
||||
"""What this page can talk to: the resolved endpoint/model, whether model
|
||||
choice is pinned to the demo whitelist, and the endpoint's model listing
|
||||
(best effort — an unreachable endpoint just yields an empty list)."""
|
||||
"""Returns what this page can talk to.
|
||||
|
||||
The response holds the resolved endpoint and model, whether model choice is
|
||||
pinned to the demo allowlist, and the endpoint's model listing. The listing
|
||||
is best effort, and an unreachable endpoint returns an empty list.
|
||||
"""
|
||||
settings = get_settings(db, user)
|
||||
cfg = auth.resolve_provider_config(settings)
|
||||
listing = await list_endpoint_models(cfg)
|
||||
@@ -82,8 +89,11 @@ async def chat_config(
|
||||
|
||||
async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload: schemas.ChatRequest,
|
||||
note: str | None, db: Session, user: models.User):
|
||||
"""SSE generator mirroring the turn stream's event shape: reasoning/chunk
|
||||
while generating, then done — so the frontend reuses the same plumbing."""
|
||||
"""Streams the reply as SSE, using the turn stream's event shape.
|
||||
|
||||
The generator emits `reasoning` and `chunk` events while generating and then
|
||||
a `done` event, so the frontend reuses the same code.
|
||||
"""
|
||||
if note:
|
||||
yield sse({"type": "note", "detail": note})
|
||||
provider = OpenAICompatibleProvider(
|
||||
|
||||
@@ -9,9 +9,11 @@ router = APIRouter(prefix="/api/debug", tags=["debug"])
|
||||
def recent_requests():
|
||||
"""Most-recent-first log of provider requests/responses (no API keys).
|
||||
|
||||
The log is a single process-wide ring buffer with no per-user
|
||||
attribution, so in multi-user (hosted) mode it would leak other players'
|
||||
prompts — disabled there, available on local installs."""
|
||||
The log is a single process-wide ring buffer with no per-user attribution,
|
||||
so in multi-user mode, which is how a hosted deployment runs, it would expose
|
||||
other players' prompts. It is disabled there and available on a local
|
||||
install.
|
||||
"""
|
||||
if auth.MULTI_USER:
|
||||
raise HTTPException(403, "The debug log is only available on local installs.")
|
||||
return debuglog.recent()
|
||||
|
||||
@@ -54,9 +54,10 @@ def get_scenario(
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
scenario = get_scenario_or_404(scenario_id, db, user)
|
||||
# Funnel step. Only for shared scenarios: opening one is the first sign a
|
||||
# visitor is interested, whereas someone editing their own is already past
|
||||
# this point — and their titles are theirs, not a statistic.
|
||||
# A funnel step, recorded for shared scenarios only. Opening one is the
|
||||
# first sign that a visitor is interested, and someone editing their own
|
||||
# scenario is already past this point. Their titles are theirs rather than a
|
||||
# statistic.
|
||||
if scenario.is_public:
|
||||
analytics.record_event(analytics.EV_SCENARIO_OPEN, user)
|
||||
return scenario
|
||||
@@ -204,8 +205,9 @@ def import_scenario(
|
||||
if isinstance(schema, dict):
|
||||
fields["stat_schema"] = schema
|
||||
|
||||
# AI Dungeon bundles carry an `image` too, so this is worth honouring — but
|
||||
# it's untrusted input, hence sanitize() rather than a straight assignment.
|
||||
# An AI Dungeon bundle also carries an `image`, so this reads it. The value
|
||||
# is untrusted input, so it goes through `sanitize()` rather than a direct
|
||||
# assignment.
|
||||
image = images.sanitize(bundle.get("image"), schemas.IMAGE_MAX)
|
||||
if image:
|
||||
fields["image"] = image
|
||||
@@ -216,10 +218,10 @@ def import_scenario(
|
||||
scenario = models.Scenario(**fields, user_id=user.id)
|
||||
if not scenario.title:
|
||||
scenario.title = "Imported Scenario"
|
||||
# Raw-dict import bypasses the schemas — clamp to VARCHAR widths
|
||||
# (Postgres enforces them; see schemas.py). Column defaults haven't been
|
||||
# applied yet at this point (that happens at flush), so a bundle with no
|
||||
# `tags` key leaves the attribute None — hence the `or ""`.
|
||||
# A raw-dict import bypasses the schemas, so truncate to the VARCHAR widths.
|
||||
# Postgres enforces them. See `schemas.py`. Column defaults have not been
|
||||
# applied yet, because that happens at flush, so a bundle with no `tags` key
|
||||
# leaves the attribute None, which is why the code says `or ""`.
|
||||
scenario.title = scenario.title[:schemas.NAME_MAX]
|
||||
scenario.tags = (scenario.tags or "")[:schemas.TAGS_MAX]
|
||||
db.add(scenario)
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_script(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Dry-run one hook against sample text — no AI call, no persistence."""
|
||||
"""Runs one hook against sample text, making no AI call and storing nothing."""
|
||||
script = get_script_or_404(script_id, db, user)
|
||||
limits.rate_limit("script-test", request, user)
|
||||
result = run_hook(
|
||||
@@ -145,7 +145,8 @@ def import_script(
|
||||
|
||||
script = models.Script(
|
||||
user_id=user.id,
|
||||
# Raw-dict import bypasses the schemas — clamp to the VARCHAR width.
|
||||
# A raw-dict import bypasses the schemas, so truncate to the VARCHAR
|
||||
# width.
|
||||
name=(pick("name") or "Imported Script")[:schemas.NAME_MAX],
|
||||
description=pick("description"),
|
||||
library_js=pick("library", "library_js", "sharedLibrary"),
|
||||
|
||||
@@ -10,8 +10,11 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
def get_settings(db: Session, user: models.User) -> models.Settings:
|
||||
"""Per-user settings row, created on first access (Phase 8: settings —
|
||||
endpoint, key, models, memory config — are per user, not global)."""
|
||||
"""Returns the user's settings row, creating it on first access.
|
||||
|
||||
Phase 8 made settings per user rather than global. They cover the endpoint,
|
||||
the key, the models, and the memory configuration.
|
||||
"""
|
||||
settings = (
|
||||
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
|
||||
)
|
||||
@@ -50,14 +53,16 @@ def update_settings(
|
||||
if embedding_model_changed:
|
||||
# Vectors from the old model have a different dimensionality/space;
|
||||
# clear them so the post-turn task re-embeds with the new model.
|
||||
# (This user's adventures only — settings are per-user now.)
|
||||
# This covers only this user's adventures, because settings are per
|
||||
# user now.
|
||||
#
|
||||
# Both columns, and the flag. This is the one place that clears vectors
|
||||
# in bulk rather than through memorybank.set_vector, and when the
|
||||
# vectors moved to embedding_blob it kept nulling the old JSON column
|
||||
# alone: the blob survived, `embedded` stayed true, and _embed_pending
|
||||
# — which looks for embedded IS FALSE — never picked the rows up. The
|
||||
# bank went on ranking against the previous model's vectors forever.
|
||||
# alone. The blob survived, `embedded` stayed true, and
|
||||
# `_embed_pending`, which selects rows where `embedded IS FALSE`, never
|
||||
# found the rows. The bank kept ranking against the previous model's
|
||||
# vectors.
|
||||
owned = (
|
||||
db.query(models.Adventure.id)
|
||||
.filter(models.Adventure.user_id == user.id)
|
||||
@@ -70,15 +75,18 @@ def update_settings(
|
||||
# `embedded` drops these rows out of the catalogue query, so retrieval
|
||||
# stops asking for them, and by the time _embed_pending puts one back
|
||||
# it has gone through set_vector, which evicts that entry. The rule
|
||||
# holds — anything that removes a memory from play self-corrects.
|
||||
# holds: anything that removes a memory from play corrects itself.
|
||||
db.commit()
|
||||
return settings
|
||||
|
||||
|
||||
async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
|
||||
"""GET the endpoint's /models listing. Doubles as a connectivity check, so
|
||||
failures come back as {"ok": False, "detail": ...} rather than raising."""
|
||||
# SSRF guard: never probe a non-public address the user pointed us at.
|
||||
"""Fetches the endpoint's /models listing.
|
||||
|
||||
The call also serves as a connectivity check, so a failure returns
|
||||
`{"ok": False, "detail": ...}` rather than raising.
|
||||
"""
|
||||
# SSRF guard. Never probe a non-public address the user supplied.
|
||||
reason = await run_in_threadpool(netguard.endpoint_block_reason, cfg.endpoint_url)
|
||||
if reason:
|
||||
return {"ok": False, "detail": f"Can't reach that endpoint — {reason}."}
|
||||
@@ -100,7 +108,8 @@ async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
|
||||
data = resp.json()
|
||||
models_available = [m.get("id", "?") for m in data.get("data", [])]
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass # non-JSON or unexpected shape — connectivity is still confirmed
|
||||
pass # The body is not JSON or has an unexpected shape. The endpoint
|
||||
# is still reachable.
|
||||
return {"ok": True, "models": models_available}
|
||||
|
||||
|
||||
@@ -110,8 +119,11 @@ async def test_connection(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Cheap connectivity check against whatever the turn engine would actually
|
||||
use — including the shared demo endpoint when the user has no key."""
|
||||
"""Runs a cheap connectivity check against whatever the turn engine would use.
|
||||
|
||||
That includes the shared demo endpoint, when the user has no key of their
|
||||
own.
|
||||
"""
|
||||
limits.rate_limit("connection-test", request, user)
|
||||
settings = get_settings(db, user)
|
||||
return await list_endpoint_models(auth.resolve_provider_config(settings))
|
||||
|
||||
@@ -6,15 +6,19 @@ from ..database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/story-cards", tags=["story-cards"])
|
||||
|
||||
# AI Dungeon world-info / story-card array format. Its field names differ from
|
||||
# our columns: value<->entry, title<->name, description<->notes. The extra
|
||||
# `useForCharacterCreation` flag has no equivalent here — ignored on import,
|
||||
# emitted as false on export so round-tripping through AI Dungeon stays valid.
|
||||
# The AI Dungeon world-info and story-card array format. Its field names differ
|
||||
# from this app's columns: `value` maps to `entry`, `title` maps to `name`, and
|
||||
# `description` maps to `notes`. The extra `useForCharacterCreation` flag has no
|
||||
# equivalent here. It is ignored on import and written as false on export, so a
|
||||
# round trip through AI Dungeon stays valid.
|
||||
|
||||
|
||||
def _visible_owner(scenario_id, adventure_id, db, user):
|
||||
"""Resolve the scenario/adventure a caller may *read* cards from (public
|
||||
demo scenarios included), or 404/422."""
|
||||
"""Resolves the scenario or adventure a caller may read cards from.
|
||||
|
||||
Public demo scenarios are included. The function raises a 404 or a 422 when
|
||||
it cannot resolve one.
|
||||
"""
|
||||
if (scenario_id is None) == (adventure_id is None):
|
||||
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
|
||||
if scenario_id is not None:
|
||||
|
||||
+110
-97
@@ -5,24 +5,25 @@ from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from . import images
|
||||
|
||||
# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse
|
||||
# limits: Postgres enforces column lengths (SQLite never did), so anything
|
||||
# longer must be a 422 here rather than a 500 at INSERT. Text-column caps are
|
||||
# generous abuse ceilings a legitimate player won't hit.
|
||||
NAME_MAX = 200 # titles/names — VARCHAR(200)
|
||||
TAGS_MAX = 500 # VARCHAR(500)
|
||||
CARD_TYPE_MAX = 100 # VARCHAR(100)
|
||||
PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes...
|
||||
SCRIPT_MAX = 200_000 # one JS source
|
||||
ACTION_MAX = 20_000 # one player action
|
||||
# Length caps (Phase 9). The VARCHAR caps are a correctness requirement rather
|
||||
# than only an abuse limit. Postgres enforces column lengths and SQLite never
|
||||
# did, so a longer value has to be a 422 here rather than a 500 at INSERT. The
|
||||
# text-column caps are generous abuse ceilings that a legitimate player does not
|
||||
# reach.
|
||||
NAME_MAX = 200 # Titles and names. VARCHAR(200).
|
||||
TAGS_MAX = 500 # VARCHAR(500).
|
||||
CARD_TYPE_MAX = 100 # VARCHAR(100).
|
||||
PROSE_MAX = 50_000 # Memory, author's note, prompts, entries, and notes.
|
||||
SCRIPT_MAX = 200_000 # One JavaScript source file.
|
||||
ACTION_MAX = 20_000 # One player action.
|
||||
MEMORY_TEXT_MAX = 5_000
|
||||
# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at
|
||||
# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for
|
||||
# a client that downscales less aggressively without letting anyone park a
|
||||
# multi-megabyte PNG in a row that gets read on every list request.
|
||||
# A scenario cover image, stored inline as a base64 data URI. A 400x300 WebP at
|
||||
# the quality the editor encodes runs about 20 to 40 kB. A cap of 400 kB leaves
|
||||
# room for a client that downscales less aggressively, and it stops anyone from
|
||||
# storing a multi-megabyte PNG in a row that every list request reads.
|
||||
IMAGE_MAX = 400_000
|
||||
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
|
||||
BRANCH_NAME_MAX = 80 # what a player called one line of the story — VARCHAR(80)
|
||||
ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
|
||||
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
|
||||
|
||||
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
||||
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
||||
@@ -77,12 +78,12 @@ class ScenarioBase(BaseModel):
|
||||
authors_note: Prose = ""
|
||||
ai_instructions: Prose = ""
|
||||
tags: Tags = ""
|
||||
# Cover art — an https URL or a base64 data URI. See app/images.py.
|
||||
# Cover art, either an https URL or a base64 data URI. See `app/images.py`.
|
||||
image: Image = ""
|
||||
# Emoji/glyph shown when `image` is empty.
|
||||
# The emoji or glyph shown when `image` is empty.
|
||||
icon: Icon = ""
|
||||
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
|
||||
# None means no RPG layer.
|
||||
# Phase 12: the RPG world-state template, holding stat definitions, bands,
|
||||
# rules, and milestones. `None` means the scenario has no RPG layer.
|
||||
stat_schema: dict | None = None
|
||||
|
||||
|
||||
@@ -106,7 +107,7 @@ class ScenarioUpdate(BaseModel):
|
||||
|
||||
class ScenarioOut(ORMModel, ScenarioBase):
|
||||
id: int
|
||||
is_public: bool = False # shared demo content — read-only for everyone
|
||||
is_public: bool = False # Shared demo content, read-only for everyone.
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
story_cards: list[StoryCardOut] = []
|
||||
@@ -120,8 +121,9 @@ class ScenarioListItem(ORMModel):
|
||||
tags: str
|
||||
is_public: bool = False
|
||||
updated_at: datetime
|
||||
# Read off the row so `image_url` can be derived, but excluded from the
|
||||
# response: a list of base64 data URIs would be megabytes of JSON.
|
||||
# Read from the row so that `image_url` can be derived, and excluded from
|
||||
# the response, because a list of base64 data URIs would be megabytes of
|
||||
# JSON.
|
||||
image: str = Field("", exclude=True)
|
||||
icon: str = ""
|
||||
|
||||
@@ -136,7 +138,8 @@ class ScenarioListItem(ORMModel):
|
||||
class AdventureCreate(BaseModel):
|
||||
scenario_id: int | None = None
|
||||
title: Name | None = None
|
||||
# ${Placeholder} values collected from the player at start (AI Dungeon behavior).
|
||||
# The `${Placeholder}` values collected from the player at the start, which
|
||||
# is the AI Dungeon behavior.
|
||||
placeholders: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -151,27 +154,32 @@ class AdventureUpdate(BaseModel):
|
||||
|
||||
|
||||
class AdventureRefresh(BaseModel):
|
||||
"""Body for "Update from scenario". `placeholders` supplies answers the
|
||||
adventure has no stored value for (see AdventureCreate.placeholders); they
|
||||
are merged over the stored ones and saved."""
|
||||
"""The body for "Update from scenario".
|
||||
|
||||
`placeholders` supplies answers the adventure has no stored value for. See
|
||||
`AdventureCreate.placeholders`. The answers are merged over the stored ones
|
||||
and saved.
|
||||
"""
|
||||
|
||||
placeholders: dict[str, str] = {}
|
||||
|
||||
|
||||
class RefreshPlan(BaseModel):
|
||||
"""What a refresh would change — drives the confirm dialog."""
|
||||
"""What a refresh would change. The confirm dialog is built from this."""
|
||||
|
||||
scenario_id: int
|
||||
scenario_title: str
|
||||
has_changes: bool
|
||||
# field name -> {"old": ..., "new": ...}, only for fields that differ.
|
||||
# Maps a field name to `{"old": ..., "new": ...}`, for differing fields
|
||||
# only.
|
||||
fields: dict[str, dict] = {}
|
||||
# {"added"|"updated"|"removed": [card name, ...]}
|
||||
# Maps "added", "updated", or "removed" to a list of card names.
|
||||
cards: dict[str, list[str]] = {}
|
||||
# {"added"|"removed": [stat path, ...]} — live values are otherwise kept.
|
||||
# Maps "added" or "removed" to a list of stat paths. Live values are
|
||||
# otherwise kept.
|
||||
world_state: dict[str, list[str]] = {}
|
||||
# ${Placeholder} names the scenario asks for that the adventure has no
|
||||
# stored answer to; the client must collect these and send them back.
|
||||
# The `${Placeholder}` names the scenario asks for that the adventure has
|
||||
# no stored answer to. The client collects these and sends them back.
|
||||
placeholders_needed: list[str] = []
|
||||
|
||||
|
||||
@@ -182,42 +190,44 @@ class ActionOut(ORMModel):
|
||||
type: str
|
||||
text: str
|
||||
reasoning: str | None = None
|
||||
# Phase 12: compact RPG state changes for this turn (from the model property).
|
||||
# Phase 12: the compact RPG state changes for this turn, read from the
|
||||
# model property.
|
||||
world_changes: list[dict] = []
|
||||
# Retry history: how many attempts exist for this turn (0 = never retried)
|
||||
# and which one is live. The attempts themselves come from
|
||||
# GET /actions/{id}/variants so this payload stays small.
|
||||
# Retry history: how many attempts exist for this turn, where 0 means the
|
||||
# turn was never retried, and which attempt is live. The attempts themselves
|
||||
# come from `GET /actions/{id}/variants`, so this payload stays small.
|
||||
variant_count: int = 0
|
||||
variant_index: int = 0
|
||||
# SP9: the pager. How many takes this turn has, and which one is on screen —
|
||||
# `2/4`. Keyed on the parent, so it counts the takes of *this* turn and not
|
||||
# every node that happens to share a depth, and so it keeps counting them
|
||||
# after one has been forked onto a branch of its own.
|
||||
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
|
||||
# and which one is on screen. It is keyed on the parent, so it counts the
|
||||
# attempts of this turn rather than every node that shares a depth, and it
|
||||
# keeps counting them after one has been forked onto its own branch.
|
||||
#
|
||||
# 1/1 for a turn nobody has retaken, which is most of them; the client draws
|
||||
# no pager for a count of one. That is a different convention from
|
||||
# `variant_count`, which says 0 for the same case — those two are the
|
||||
# pre-SP9 pair and SP8 drops them.
|
||||
# A turn nobody has retaken reads 1/1, which is most turns, and the client
|
||||
# draws no pager for a count of one. `variant_count` uses a different
|
||||
# convention and reports 0 for the same case. Those two fields are the
|
||||
# pre-SP9 pair, and SP8 drops them.
|
||||
take_count: int = 1
|
||||
take_index: int = 0
|
||||
# Which line this node is on, so the pager can tell the two kinds of step
|
||||
# apart without asking the server first: a take on this branch is a leaf
|
||||
# with nothing under it, and showing it is a local matter; a take on another
|
||||
# branch has a story of its own, and going there is a branch switch.
|
||||
# Which line this node is on, so the pager can distinguish the two kinds of
|
||||
# step without asking the server. An attempt on this branch is a leaf with
|
||||
# nothing below it, so showing it is a local change. An attempt on another
|
||||
# branch has a story of its own, so moving to it is a branch switch.
|
||||
branch_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class VariantOut(BaseModel):
|
||||
# Since SP4 every attempt is its own node, so each one has an id — and the
|
||||
# client needs it: forking is addressed by the attempt being taken, not by
|
||||
# its ordinal in a group that renumbers whenever one is added.
|
||||
# Since SP4 every attempt is its own node, so each one has an id, and the
|
||||
# client needs that id. A fork is addressed by the attempt being promoted,
|
||||
# not by its position in a group that renumbers whenever an attempt is
|
||||
# added.
|
||||
id: int
|
||||
index: int
|
||||
text: str
|
||||
reasoning: str | None = None
|
||||
# See ActionOut.branch_id: it decides whether choosing this take is a local
|
||||
# step or a branch switch.
|
||||
# See `ActionOut.branch_id`. It decides whether choosing this attempt is a
|
||||
# local step or a branch switch.
|
||||
branch_id: int | None = None
|
||||
created_at: str | None = None
|
||||
active: bool = False
|
||||
@@ -230,12 +240,11 @@ class VariantSelect(BaseModel):
|
||||
class BranchOut(ORMModel):
|
||||
"""One line through the story tree (Phase 14, SP5).
|
||||
|
||||
Enough to draw the tree and nothing more: `fork_depth` is where this line
|
||||
leaves its parent and `depth` is where it currently ends, so a fork is two
|
||||
numbers rather than a walk. `own_actions` counts the turns played on this
|
||||
branch itself — the rest of its story is borrowed from its ancestors, which
|
||||
is the whole point and also why the number is smaller than the reader
|
||||
expects.
|
||||
This carries enough to draw the tree and nothing more. `fork_depth` is where
|
||||
this line leaves its parent, and `depth` is where it currently ends, so a
|
||||
fork is two numbers rather than a walk. `own_actions` counts the turns played
|
||||
on this branch itself. The rest of its story is borrowed from its ancestors,
|
||||
which is why the number is smaller than a reader expects.
|
||||
"""
|
||||
|
||||
id: int
|
||||
@@ -244,14 +253,14 @@ class BranchOut(ORMModel):
|
||||
depth: int
|
||||
own_actions: int = 0
|
||||
is_head: bool = False
|
||||
# NULL for a branch nobody has named. The client draws those from the fork
|
||||
# depth rather than the server inventing one — see the column comment.
|
||||
# NULL for a branch nobody has named. The client labels those from the fork
|
||||
# depth rather than the server inventing a name. See the column comment.
|
||||
name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BranchRename(BaseModel):
|
||||
"""A name a player chose, or `null` to go back to being unnamed."""
|
||||
"""A name a player chose, or `null` to make the branch unnamed again."""
|
||||
|
||||
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
|
||||
|
||||
@@ -263,23 +272,23 @@ class ActionUpdate(BaseModel):
|
||||
class ActionCreate(BaseModel):
|
||||
type: Literal["do", "say", "story", "continue"]
|
||||
text: ActionText = ""
|
||||
# The node this action is played after (SP9). Omitted means "the tip",
|
||||
# which is every ordinary turn.
|
||||
# The node this action is played after (SP9). Omitting it means the tip,
|
||||
# which is what every ordinary turn uses.
|
||||
#
|
||||
# Naming a take the story moved past is how a branch gets made: stepping
|
||||
# between takes costs nothing and creates nothing, and the fork happens on
|
||||
# the first thing written below one. That is the only moment the player has
|
||||
# said which line they mean — before it, they were reading.
|
||||
# Naming an attempt the story moved past is what creates a branch. Stepping
|
||||
# between attempts costs nothing and creates nothing, and the fork happens
|
||||
# on the first text written below one. That is the first moment the player
|
||||
# states which line they mean. Before it, they were reading.
|
||||
after_id: int | None = None
|
||||
|
||||
|
||||
class TakeCreate(BaseModel):
|
||||
"""Another take of a turn (SP9).
|
||||
"""Another attempt at a turn (SP9).
|
||||
|
||||
`text` is what the player is saying instead, and is theirs to write only
|
||||
when the turn was theirs. An AI turn's other take is generated, so the field
|
||||
is ignored there rather than refused — the client asks the same way for both
|
||||
and the node type decides what happens.
|
||||
`text` is what the player says instead, and it applies only when the turn was
|
||||
the player's. An AI turn's other attempt is generated, so the field is
|
||||
ignored there rather than rejected. The client makes the same request for
|
||||
both, and the node type decides what happens.
|
||||
"""
|
||||
|
||||
text: ActionText = ""
|
||||
@@ -298,9 +307,9 @@ class AdventureOut(ORMModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
story_cards: list[StoryCardOut] = []
|
||||
# The NEWEST window of the story, not all of it — older pages arrive from
|
||||
# GET /{id}/actions as the reader scrolls up. `action_count` is the whole
|
||||
# story's length, which is how the client knows there is more above.
|
||||
# The newest window of the story, not all of it. Older pages arrive from
|
||||
# `GET /{id}/actions` as the reader scrolls up. `action_count` is the whole
|
||||
# story's length, which is how the client knows more actions exist above.
|
||||
actions: list[ActionOut] = []
|
||||
action_count: int = 0
|
||||
|
||||
@@ -310,7 +319,7 @@ class ActionPage(BaseModel):
|
||||
|
||||
actions: list[ActionOut] = []
|
||||
total: int = 0
|
||||
# Whether anything older than this slice exists. Computed server-side so
|
||||
# Whether anything older than this slice exists. The server computes it, so
|
||||
# the client never has to do arithmetic on positions to find the end.
|
||||
has_more: bool = False
|
||||
|
||||
@@ -348,10 +357,10 @@ class AdventureListItem(ORMModel):
|
||||
title: str
|
||||
updated_at: datetime
|
||||
action_count: int = 0
|
||||
# "Where you left off" — the tail of the most recent narrative beat, so a
|
||||
# Continue card can show the story instead of just a turn count.
|
||||
# The end of the most recent narration, so a Continue card can show the
|
||||
# story rather than only a turn count.
|
||||
snippet: str = ""
|
||||
# Cover art inherited from the parent scenario (see app/images.py).
|
||||
# Cover art inherited from the parent scenario. See `app/images.py`.
|
||||
image_url: str = ""
|
||||
icon: str = ""
|
||||
|
||||
@@ -403,8 +412,9 @@ class AdventureScriptOut(ORMModel):
|
||||
input_js: str
|
||||
context_js: str
|
||||
output_js: str
|
||||
# Set by the router (not stored): True when a syncable library version
|
||||
# exists whose code differs from this copy; None when nothing to sync.
|
||||
# The router sets this field, which is not stored. It is `True` when a
|
||||
# syncable library version exists whose code differs from this copy, and
|
||||
# `None` when there is nothing to sync from.
|
||||
out_of_date: bool | None = None
|
||||
|
||||
|
||||
@@ -419,9 +429,9 @@ class AdventureScriptUpdate(BaseModel):
|
||||
# ---------- Auth (Phase 8) ----------
|
||||
|
||||
class AuthCredentials(BaseModel):
|
||||
email: Annotated[str, Field(max_length=320)] # VARCHAR(320)
|
||||
# Upper bound keeps scrypt cost flat — hashing megabyte "passwords" is CPU
|
||||
# an attacker would otherwise get for free.
|
||||
email: Annotated[str, Field(max_length=320)] # VARCHAR(320).
|
||||
# The upper bound keeps the scrypt cost constant. Without it, hashing a
|
||||
# megabyte password would give an attacker free CPU time.
|
||||
password: Annotated[str, Field(max_length=128)]
|
||||
|
||||
|
||||
@@ -429,7 +439,8 @@ class AuthCredentials(BaseModel):
|
||||
|
||||
class SettingsOut(ORMModel):
|
||||
endpoint_url: str
|
||||
# The key itself is never echoed back (encrypted at rest, write-only).
|
||||
# The key itself is never returned. It is encrypted at rest and
|
||||
# write-only.
|
||||
has_api_key: bool
|
||||
model: str
|
||||
api_mode: str
|
||||
@@ -449,12 +460,12 @@ ScenarioOut.model_rebuild()
|
||||
|
||||
|
||||
# ---------- AI Chat (power users) ----------
|
||||
# A scratchpad for talking to a model directly, with no story framing. Nothing
|
||||
# is persisted server-side, so these caps are purely per-request abuse limits.
|
||||
# A scratchpad for talking to a model directly, with no story framing. The
|
||||
# server persists nothing, so these caps are per-request abuse limits only.
|
||||
|
||||
CHAT_MESSAGE_MAX = 100_000 # one message
|
||||
CHAT_TOTAL_MAX = 400_000 # whole conversation sent up per request
|
||||
CHAT_MESSAGES_MAX = 200 # turns per request
|
||||
CHAT_MESSAGE_MAX = 100_000 # One message.
|
||||
CHAT_TOTAL_MAX = 400_000 # The whole conversation sent per request.
|
||||
CHAT_MESSAGES_MAX = 200 # Turns per request.
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
@@ -464,22 +475,24 @@ class ChatMessage(BaseModel):
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
messages: Annotated[list[ChatMessage], Field(min_length=1, max_length=CHAT_MESSAGES_MAX)]
|
||||
# Empty/omitted = fall back to the user's configured model.
|
||||
# If this field is empty or omitted, the user's configured model is used.
|
||||
model: Name | None = None
|
||||
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
||||
max_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500)
|
||||
# Encryption expands the stored value ~4/3 into the same VARCHAR(500):
|
||||
# 256 plaintext chars is the largest safe input ("enc:" + Fernet + base64).
|
||||
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500).
|
||||
# Encryption expands the stored value by about four thirds into the same
|
||||
# VARCHAR(500), so 256 plaintext characters is the largest safe input. The
|
||||
# stored form is "enc:" plus Fernet plus base64.
|
||||
api_key: Annotated[str, Field(max_length=256)] | None = None
|
||||
model: Name | None = None
|
||||
api_mode: Annotated[str, Field(max_length=20)] | None = None
|
||||
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
||||
max_output_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
||||
# -1 = explicitly off (sends `reasoning: {effort: none}`); 0 = send nothing.
|
||||
# A value of -1 turns reasoning off explicitly, which sends
|
||||
# `reasoning: {effort: none}`. A value of 0 sends nothing.
|
||||
reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None
|
||||
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
|
||||
narrator_prompt: Prose | None = None
|
||||
|
||||
@@ -94,8 +94,9 @@ def run_hook(
|
||||
story_cards: list[dict],
|
||||
info: dict,
|
||||
) -> HookResult:
|
||||
"""Run one modifier hook. Never raises: failures come back as .error with
|
||||
text/state/cards unchanged, so a bad script can't break a turn."""
|
||||
"""Run one modifier hook. This function never raises. Failures return as
|
||||
`.error` with text, state, and cards unchanged, so a bad script cannot
|
||||
break a turn."""
|
||||
unchanged = HookResult(text=text, state=state, story_cards=story_cards)
|
||||
source = f"{library_js}\n;\n{hook_js}" if library_js.strip() else hook_js
|
||||
if not source.strip():
|
||||
|
||||
@@ -24,19 +24,20 @@ class ScriptPipeline:
|
||||
return msg if isinstance(msg, str) and msg.strip() else None
|
||||
|
||||
def _history(self) -> list[dict]:
|
||||
# The path, not `adventure.actions` — that collection is every branch's
|
||||
# actions, and this is the documented history API a user script reads.
|
||||
# Handing a script the siblings of the turn it is running on would be
|
||||
# the same bug as building a prompt from them, only user-visible.
|
||||
# Read the path rather than `adventure.actions`. That collection holds
|
||||
# every branch's actions, and this is the documented history API a user
|
||||
# script reads. Giving a script the siblings of the turn it is running on
|
||||
# would be the same bug as building a prompt from them, and visible to
|
||||
# the user.
|
||||
#
|
||||
# `story_actions` also drops blank-text rows, which `adventure.actions`
|
||||
# kept, so this array is shorter than it used to be for an adventure
|
||||
# that has any — and `info.actionCount` counts the same way. That is
|
||||
# deliberate: a row with no text is this app's bookkeeping, it has no
|
||||
# counterpart in the AI Dungeon history a ported script was written
|
||||
# against, and the prompt has never included one. A script keyed on
|
||||
# "every N actions" will land on different turns than it did before
|
||||
# phase 14; there is no reading of this that is compatible with both.
|
||||
# `story_actions` also drops rows with blank text, which
|
||||
# `adventure.actions` kept, so this array is shorter than it was for an
|
||||
# adventure that has any such rows. `info.actionCount` counts the same
|
||||
# way. That is intended. A row with no text is this app's bookkeeping, it
|
||||
# has no counterpart in the AI Dungeon history a ported script was
|
||||
# written against, and the prompt has never included one. A script keyed
|
||||
# on every N actions lands on different turns than it did before phase
|
||||
# 14, and no reading of this is compatible with both.
|
||||
return [
|
||||
{"text": a.text, "rawText": a.text, "type": a.type}
|
||||
for a in context_history.story_actions(self.adventure)
|
||||
|
||||
+15
-13
@@ -1,18 +1,19 @@
|
||||
"""Phase 8 — secrets and crypto primitives for optional accounts.
|
||||
"""Phase 8: secrets and crypto primitives for optional accounts.
|
||||
|
||||
Everything keys off one server-side secret:
|
||||
- session cookies are HMAC-signed with it,
|
||||
- stored LLM API keys are Fernet-encrypted with a key derived from it.
|
||||
Everything derives from one server-side secret:
|
||||
|
||||
The secret comes from AIDND_SECRET_KEY, or is auto-generated once into
|
||||
`secret.key` next to the database so local installs and Docker volumes work
|
||||
with zero configuration (losing the file logs everyone out and orphans
|
||||
stored API keys — users just re-enter them). Multi-user deploys must set the
|
||||
env var: hosted filesystems are ephemeral, and a secret.key regenerated on
|
||||
every deploy would silently log out all users each time.
|
||||
* Session cookies are HMAC-signed with it.
|
||||
* Stored LLM API keys are Fernet-encrypted with a key derived from it.
|
||||
|
||||
Passwords use hashlib.scrypt (stdlib, OpenSSL-backed) so we don't need a
|
||||
separate hashing dependency.
|
||||
The secret comes from `AIDND_SECRET_KEY`, or it is generated once into
|
||||
`secret.key` next to the database, so a local install and a Docker volume work
|
||||
with no configuration. Losing that file logs everyone out and makes the stored
|
||||
API keys unreadable, and users then re-enter them. A multi-user deployment has
|
||||
to set the environment variable, because a hosted filesystem is ephemeral and a
|
||||
`secret.key` regenerated on every deploy would log out every user each time.
|
||||
|
||||
Passwords use `hashlib.scrypt`, which is in the standard library and backed by
|
||||
OpenSSL, so this needs no separate hashing dependency.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -81,7 +82,8 @@ def verify_password(password: str, stored: str) -> bool:
|
||||
|
||||
|
||||
# ---------- Session tokens ----------
|
||||
# "v1.<user_id>.<hmac>" — no expiry (long-lived guest sessions are the point).
|
||||
# The token is "v1.<user_id>.<hmac>". It does not expire, because a long-lived
|
||||
# guest session is what this is for.
|
||||
|
||||
def sign_session(user_id: int) -> str:
|
||||
payload = f"v1.{user_id}"
|
||||
|
||||
+8
-7
@@ -8,11 +8,11 @@ adventure copies the scenario's story cards and scripts into the adventure, so
|
||||
the seeded scripts run for guests too.
|
||||
|
||||
Seed files are the source of truth for demo content: a scenario is inserted if
|
||||
missing, and reconciled in place when a seed file's content changes (so edits
|
||||
ship on the next deploy). When a seed already matches, nothing is written, so
|
||||
this stays cheap to run on every boot. Existing adventures already started from
|
||||
a demo keep their own copied cards/scripts and are unaffected — only new
|
||||
adventures pick up the updated content.
|
||||
missing, and reconciled in place when a seed file's content changes, so an edit
|
||||
ships on the next deploy. When a seed already matches, nothing is written, so
|
||||
this stays cheap to run on every boot. An adventure already started from a demo
|
||||
keeps its own copied cards and scripts and is unchanged. Only a new adventure
|
||||
picks up the updated content.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -122,8 +122,9 @@ def _insert_scenario(db, data: dict) -> None:
|
||||
|
||||
def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
|
||||
_apply_scalars(scenario, data)
|
||||
# Replace child content wholesale — demo content is server-owned and cheap
|
||||
# to rebuild, and this keeps the scenario row (and adventure FKs) intact.
|
||||
# Replace the child content in full. Demo content is owned by the server and
|
||||
# cheap to rebuild, and replacing it this way keeps the scenario row, and the
|
||||
# adventure foreign keys that point at it, intact.
|
||||
for card in list(scenario.story_cards):
|
||||
db.delete(card)
|
||||
for script in list(scenario.scripts):
|
||||
|
||||
+144
-142
@@ -1,25 +1,24 @@
|
||||
"""Phase 14 — putting nodes on the story tree.
|
||||
"""Phase 14: writes nodes onto the story tree.
|
||||
|
||||
The write half of the tree. Which branch a new node hangs off, what depth it
|
||||
gets, and where an adventure's head points all live here, because every one of
|
||||
them is the kind of thing that is silently wrong when it is spread across four
|
||||
call sites: a node written without a branch is a node no read can see, and it
|
||||
fails by disappearing rather than by raising.
|
||||
This module is the write half of the tree. It decides which branch a new node
|
||||
goes on, what depth the node gets, and where the adventure's head points. All
|
||||
three decisions live here because a mistake in any of them is silent. A node
|
||||
written without a branch is invisible to every read, and nothing raises an
|
||||
error.
|
||||
|
||||
The read half — the lineage clause that turns a branch into "this story" —
|
||||
lives beside it in `context/lineage.py`.
|
||||
The read half is `context/lineage.py`. It turns a branch into the set of nodes
|
||||
that make up one story.
|
||||
|
||||
Until forking ships there is exactly one branch per adventure and `depth` is
|
||||
the number `index` already held, so everything in this module is bookkeeping
|
||||
that changes nothing observable. That is the point: by the time a read depends
|
||||
on these columns, every row has them — including the rows written between the
|
||||
two deploys, which no migration will ever visit.
|
||||
Until forking ships, each adventure has one branch and `depth` mirrors `index`,
|
||||
so nothing here changes observable behavior yet. That is intentional. By the
|
||||
time reads depend on these columns, every row already has them, including the
|
||||
rows written between the two deploys that no migration visits.
|
||||
|
||||
SP2 added `place_new_nodes`, which the session calls on every flush. Wiring the
|
||||
call sites was enough while nothing read the columns; now that reads select on
|
||||
them, "every writer remembers" is a promise that has to hold for every fixture,
|
||||
script and test ever written too, and its breach is a story quietly missing
|
||||
turns. So the invariant is enforced at the flush instead of asked for.
|
||||
SP2 added `place_new_nodes`, which runs on every flush. Wiring up individual
|
||||
call sites worked while nothing read the columns. Now that reads filter on them,
|
||||
relying on each writer to remember would also mean relying on every fixture,
|
||||
script, and test. A missed call produces a story with missing turns, so the
|
||||
flush enforces the rule instead.
|
||||
"""
|
||||
|
||||
import copy
|
||||
@@ -30,18 +29,19 @@ from sqlalchemy.orm import Session
|
||||
from . import models
|
||||
from .context import lineage
|
||||
|
||||
# The head depth of an adventure with no actions. Keeps "the next node goes at
|
||||
# head_depth + 1" true with no special case, and mirrors migrations.NO_DEPTH.
|
||||
# Head depth of an adventure that has no actions. Using -1 keeps the rule "the
|
||||
# next node goes at head_depth + 1" true without a special case. This matches
|
||||
# `migrations.NO_DEPTH`.
|
||||
NO_DEPTH = -1
|
||||
|
||||
|
||||
def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""The adventure's root branch, created on first use.
|
||||
"""Returns the adventure's root branch, creating it on first use.
|
||||
|
||||
Get-or-create rather than created-with-the-adventure, because the adventures
|
||||
that need one most are the ones that already exist: a bundle being imported,
|
||||
a fixture built straight through the ORM, or a database whose migration ran
|
||||
before this code shipped.
|
||||
This function gets or creates the branch instead of creating it alongside
|
||||
the adventure. The adventures that need a root branch are usually ones that
|
||||
already exist: an imported bundle, a fixture built through the ORM, or a
|
||||
database migrated before this code shipped.
|
||||
"""
|
||||
branch = (
|
||||
db.query(models.Branch)
|
||||
@@ -54,11 +54,10 @@ def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# Inserted through Core rather than through the unit of work, because this
|
||||
# also runs from `place_new_nodes` inside a flush, and a nested ORM flush
|
||||
# inside a flush raises. Same transaction either way, so it rolls back with
|
||||
# everything else. The lineage names the branch's own id, so it takes a
|
||||
# second statement — once per adventure, ever.
|
||||
# Use a Core insert instead of the ORM. `place_new_nodes` can call this
|
||||
# during a flush, and a nested ORM flush raises an error. Both paths share
|
||||
# one transaction. The lineage refers to the branch's own id, so it needs a
|
||||
# second statement, which runs once per adventure.
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
@@ -77,52 +76,50 @@ def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
|
||||
|
||||
def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""The branch new nodes are played onto."""
|
||||
"""Returns the branch that new nodes are played onto."""
|
||||
if adventure.head_branch_id is not None:
|
||||
branch = db.get(models.Branch, adventure.head_branch_id)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# A head naming a branch that is gone is a bug somewhere else. Recover
|
||||
# onto the root instead of refusing to play — the alternative is an
|
||||
# adventure nobody can add to.
|
||||
# The head points at a branch that no longer exists, which means a bug
|
||||
# elsewhere. Fall back to the root instead of refusing to play,
|
||||
# otherwise the adventure becomes unusable.
|
||||
branch = root_branch(db, adventure)
|
||||
adventure.head_branch_id = branch.id
|
||||
return branch
|
||||
|
||||
|
||||
def fork(db: Session, adventure: models.Adventure, node: models.Action) -> models.Branch:
|
||||
"""Take the story down `node`, on a branch of its own.
|
||||
"""Moves `node` onto a new branch so the story can continue from it.
|
||||
|
||||
`node` is a discarded attempt at a turn the story has already moved past.
|
||||
Making it live where it stands would orphan every turn played after it —
|
||||
they were written as a continuation of the attempt that won — so it moves
|
||||
onto a new branch instead, forked from the depth just before it. The parent
|
||||
keeps its story, complete and untouched; the new branch borrows everything
|
||||
up to the fork and owns exactly one node.
|
||||
`node` is a discarded attempt at a turn that the story has already moved
|
||||
past. Making it live where it stands would orphan every turn played after
|
||||
it, because those turns continue the attempt that won. Instead, `node` moves
|
||||
to a new branch that forks from the depth just before it. The parent branch
|
||||
keeps its story unchanged. The new branch inherits everything up to the fork
|
||||
and owns this one node.
|
||||
|
||||
**One row is inserted and one row is moved. Nothing is copied.** That is
|
||||
the whole claim of the design: a fork costs a `branches` row and the
|
||||
ancestry cached on it, whatever the story behind it is worth.
|
||||
This function inserts one row and moves one row. It copies nothing, so a
|
||||
fork costs one `branches` row plus the ancestry cached on it, regardless of
|
||||
how long the story is.
|
||||
|
||||
Nothing derived moves with it, and that is not an omission. A memory hangs
|
||||
off the coordinate its block ends on, and what it describes is whatever
|
||||
attempt was live there — which stays on the parent. From the new branch it
|
||||
is simply out of range: the lineage caps the parent at `fork_depth`, so the
|
||||
memory sits one depth past the border and neither the retrieval clause nor
|
||||
the cursors can see it. The block is summarized again, from the text this
|
||||
branch actually tells, without a line of bookkeeping.
|
||||
Derived data stays on the parent, by design. A memory attaches to the node
|
||||
its block ends on, and that node does not move. From the new branch, the
|
||||
lineage caps the parent at `fork_depth`, so the memory sits one depth past
|
||||
the border. Neither retrieval nor the cursors can see it, and the block is
|
||||
summarized again from the text this branch contains.
|
||||
|
||||
One thing does stay behind: the attempts this node leaves. They are still
|
||||
takes on the parent's turn, and one of them has to be the parent's story —
|
||||
the oldest, so the line the parent keeps is the one it was written on.
|
||||
The other attempts at this turn also stay on the parent, because they are
|
||||
still takes on the parent's turn. If none of them is live, the oldest one
|
||||
becomes live, so the parent keeps the line it was written on.
|
||||
"""
|
||||
parent = db.get(models.Branch, node.branch_id)
|
||||
if parent is None or node.depth is None:
|
||||
raise ValueError("cannot fork from a node that is not on a branch")
|
||||
fork_depth = node.depth - 1
|
||||
# The attempts this node is leaving, read *before* it moves. The session
|
||||
# does not autoflush, so asking afterwards would still find the node here
|
||||
# and renumber it back into the group it just left.
|
||||
# Read the sibling attempts before moving the node. The session does not
|
||||
# autoflush, so a later read still finds the node here and renumbers it back
|
||||
# into the group it just left.
|
||||
remaining = [
|
||||
row for row in db.query(models.Action)
|
||||
.filter(
|
||||
@@ -134,17 +131,17 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
|
||||
.all()
|
||||
if row is not node
|
||||
]
|
||||
# The parent's ancestry, every entry capped at the fork. Only the first can
|
||||
# actually move — an older entry is already capped at the fork depth of the
|
||||
# branch beneath it, which is shallower than any node on the parent — but
|
||||
# capping them all says the invariant instead of relying on it.
|
||||
# The parent's ancestry, with every entry capped at the fork depth. Only the
|
||||
# first entry can change in practice, because older entries are already
|
||||
# capped at a shallower depth. Capping all of them states the invariant
|
||||
# directly.
|
||||
inherited = [
|
||||
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
|
||||
for branch_id, cap in lineage.entries_of(parent)
|
||||
]
|
||||
# Inserted through Core, and its lineage written second, for the reason
|
||||
# `root_branch` spells out: this can run inside a flush, and the lineage
|
||||
# names the row's own id.
|
||||
# Core insert with the lineage written second, for the reason given in
|
||||
# `root_branch`. This code can run inside a flush, and the lineage refers to
|
||||
# the new row's own id.
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
@@ -180,26 +177,29 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
|
||||
def branch_at(
|
||||
db: Session, adventure: models.Adventure, fork_depth: int
|
||||
) -> models.Branch:
|
||||
"""An empty branch leaving the path being read at `fork_depth`.
|
||||
"""Creates an empty branch that leaves the current path at `fork_depth`.
|
||||
|
||||
`fork` moves a node that already exists onto a line of its own. This is the
|
||||
same branch with nothing in it yet, for the case where the take that will
|
||||
live there has not been written: the player asking for another take of a
|
||||
turn the story has moved past (SP9). The head lands at `fork_depth`, so the
|
||||
next node written is the new take, at the same depth as the one it is a take
|
||||
of, with the same parent — `place_action` resolves that from the path, and
|
||||
the path now ends at exactly the node the original hangs off.
|
||||
`fork` moves an existing node onto its own branch. This function creates the
|
||||
same kind of branch with no nodes on it yet, for the case where the take
|
||||
that will live there does not exist. A player asking for another take of a
|
||||
turn the story has moved past reaches this path (SP9).
|
||||
|
||||
The line being left is not touched at all. It keeps its node at that depth,
|
||||
it keeps that node live, and it keeps everything played after it.
|
||||
The head lands at `fork_depth`, so the next node written becomes the new
|
||||
take. That node gets the same depth as the original and the same parent,
|
||||
which `place_action` derives from the path.
|
||||
|
||||
This function does not modify the branch being left. That branch keeps its
|
||||
node at that depth, the node stays live, and every turn played after it
|
||||
stays in place.
|
||||
"""
|
||||
parent = head_branch(db, adventure)
|
||||
inherited = [
|
||||
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
|
||||
for branch_id, cap in lineage.entries_of(parent)
|
||||
]
|
||||
# Core insert with the lineage written second, for the reason `root_branch`
|
||||
# spells out: this can run inside a flush, and the lineage names its own id.
|
||||
# Core insert with the lineage written second, for the reason given in
|
||||
# `root_branch`. This code can run inside a flush, and the lineage refers to
|
||||
# the new row's own id.
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
@@ -226,20 +226,20 @@ def place_action(
|
||||
branch: models.Branch | None = None,
|
||||
parent: models.Action | None = None,
|
||||
) -> models.Branch:
|
||||
"""Put `action` on the head branch and move the head to it.
|
||||
"""Puts `action` on the head branch and moves the head to it.
|
||||
|
||||
`depth` follows `index` while the two coexist. They have to agree: a read
|
||||
ordering by depth and a cursor counting in index space are describing the
|
||||
same story, and SP2 swaps one for the other under everything at once.
|
||||
`depth` follows `index` while both columns exist. The two must agree,
|
||||
because a read that orders by depth and a cursor that counts in index space
|
||||
describe the same story, and SP2 swaps one for the other in a single step.
|
||||
|
||||
`branch` is the head, already resolved, for a caller placing several nodes
|
||||
at once — see `place_new_nodes` for why that is worth a parameter.
|
||||
Pass `branch` when you have already resolved the head and are placing
|
||||
several nodes at once. See `place_new_nodes` for why that is worth doing.
|
||||
|
||||
`parent` is the take this node was played after (SP9), and is what groups a
|
||||
turn's takes. Resolved from the path when the caller does not say, which is
|
||||
the honest default: a node written now follows whatever the player is
|
||||
reading now. A caller placing several nodes in one flush should chain it —
|
||||
the second node's parent is the first, and the database has not seen either.
|
||||
`parent` is the take that this node was played after (SP9), and it is what
|
||||
groups the takes of one turn. If you omit it, this function derives it from
|
||||
the path, which is the correct default: a node written now follows the story
|
||||
the player is reading now. If you place several nodes in one flush, chain
|
||||
`parent` explicitly, because the database has not seen any of them yet.
|
||||
"""
|
||||
branch = branch or head_branch(db, adventure)
|
||||
action.branch_id = branch.id
|
||||
@@ -258,12 +258,12 @@ def place_action(
|
||||
def _preceding_id(
|
||||
db: Session, adventure: models.Adventure, branch: models.Branch, depth: int
|
||||
) -> int | None:
|
||||
"""The id of the live node one step back along `branch`'s path.
|
||||
"""Returns the id of the live node one step back along `branch`'s path.
|
||||
|
||||
Asked of the whole lineage rather than of `branch` alone, because a branch
|
||||
borrows the story before its fork point: the node in front of a forked
|
||||
branch's first turn lives on an ancestor, and that is exactly the parent a
|
||||
pager needs to find its siblings through.
|
||||
The query searches the whole lineage instead of `branch` alone, because a
|
||||
branch inherits the story before its fork point. The node in front of a
|
||||
forked branch's first turn lives on an ancestor, and that node is the parent
|
||||
a pager needs in order to find siblings.
|
||||
"""
|
||||
path = lineage.Path(lineage.entries_of(branch))
|
||||
return (
|
||||
@@ -286,19 +286,19 @@ def place_memory(
|
||||
memory: models.Memory,
|
||||
branch: models.Branch | None = None,
|
||||
) -> models.Branch:
|
||||
"""Attach a memory to the node it belongs to.
|
||||
"""Attaches a memory to the node it belongs to.
|
||||
|
||||
`source_end` is the index of the last action the memory summarises, which is
|
||||
that node's depth. A hand-written memory summarises nothing, so it takes the
|
||||
head instead: **the story you were reading when you wrote it.**
|
||||
`source_end` is the index of the last action the memory summarizes, which is
|
||||
that node's depth. A hand-written memory summarizes no actions, so it uses
|
||||
the head instead. That records the story the author was reading at the time.
|
||||
|
||||
That anchor is what makes a memory mean one thing (SP7). Before it, a
|
||||
hand-written memory kept a NULL depth and "belonged to the adventure rather
|
||||
than to a path" — which sounded harmless and meant it followed you onto
|
||||
branches whose story it did not describe, because a NULL cannot be capped at
|
||||
a fork. Every memory now sits at a coordinate, so "is this part of the story
|
||||
I am reading?" has one answer for every row in the bank, and it is the same
|
||||
answer the lineage already gives for nodes.
|
||||
Giving every memory a coordinate is what makes its scope unambiguous (SP7).
|
||||
Before SP7, hand-written memories had a NULL depth and belonged to the
|
||||
adventure rather than to a path. A fork cannot cap a NULL, so those memories
|
||||
followed the reader onto branches whose story they did not describe. Now the
|
||||
question "is this memory part of the story I am reading?" has one answer for
|
||||
every row in the bank, and it is the same answer the lineage gives for
|
||||
nodes.
|
||||
"""
|
||||
branch = branch or head_branch(db, adventure)
|
||||
memory.branch_id = branch.id
|
||||
@@ -311,35 +311,37 @@ def place_memory(
|
||||
|
||||
|
||||
def attach_memory(memory: models.Memory, node: models.Action) -> None:
|
||||
"""Hang a memory off the node it was derived from.
|
||||
"""Attaches a memory to the node it was derived from.
|
||||
|
||||
The general rule, of which the memory bank is the first instance: anything
|
||||
derived from the story attaches to the node that produced it, and is then
|
||||
visible from exactly the paths that node is on. A fork inherits its
|
||||
ancestors' memories because it inherits their nodes — nothing is copied and
|
||||
nothing is recreated — and a memory made on a sibling is invisible here
|
||||
because that node is not on this path.
|
||||
This follows the general rule for derived data, and the memory bank is the
|
||||
first case of it. Anything derived from the story attaches to the node that
|
||||
produced it, and is then visible from exactly the paths that contain that
|
||||
node. A fork inherits its ancestors' memories because it inherits their
|
||||
nodes, so nothing is copied. A memory made on a sibling branch is not
|
||||
visible, because that node is not on this path.
|
||||
|
||||
Not `place_memory`: this takes the branch from the *node*, which is not
|
||||
always the head. A block of story can end before the fork the current
|
||||
branch was made at, and the memory belongs where the ground is.
|
||||
This function differs from `place_memory` because it takes the branch from
|
||||
`node`, which is not always the head. A block of story can end before the
|
||||
fork that created the current branch, and the memory belongs where that
|
||||
block is.
|
||||
"""
|
||||
memory.branch_id = node.branch_id
|
||||
memory.depth = node.depth
|
||||
|
||||
|
||||
def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
|
||||
"""Give a node the state it left behind, if its writer did not.
|
||||
"""Records the state a node left behind, if the writer did not record it.
|
||||
|
||||
The floor under `attempts.snapshot_outcome`, and it is here for the same
|
||||
reason `place_action` has one: from SP4 a node with no outcome is a node
|
||||
undo and retry cannot roll back past, and it fails by leaving the
|
||||
scoreboard where it was rather than by raising. The turn engine records the
|
||||
outcome itself and this skips those rows; what it catches is every fixture,
|
||||
script and import that writes a story straight through the ORM.
|
||||
This is the fallback under `attempts.snapshot_outcome`, and it exists for
|
||||
the same reason `place_action` has one. Since SP4, undo and retry cannot
|
||||
roll back past a node that has no outcome, and the failure is silent: the
|
||||
script state and world state stay where they were. The turn engine records
|
||||
the outcome itself, so this function skips those rows. It catches fixtures,
|
||||
scripts, and imports that write a story directly through the ORM.
|
||||
|
||||
What it writes is the truth as of the flush: a writer that changes no state
|
||||
between two nodes leaves the same state behind both of them.
|
||||
The values written are the state as of the flush. That is correct, because a
|
||||
writer that changes no state between two nodes leaves the same state behind
|
||||
both of them.
|
||||
"""
|
||||
if action.state_after is None:
|
||||
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
|
||||
@@ -350,25 +352,25 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
|
||||
|
||||
|
||||
def place_new_nodes(session: Session) -> None:
|
||||
"""Place every unplaced node about to be inserted. Runs on every flush.
|
||||
"""Places every unplaced node that is about to be inserted.
|
||||
|
||||
The call sites still call `place_action` / `place_memory` themselves, and
|
||||
should: a node placed at the call site is placed *before* the code around
|
||||
it reads the row back, and the explicit call is what makes the ordering
|
||||
visible. This is the floor under them — a fixture built straight through
|
||||
the ORM, a script, a test, or a call site added next year gets a branch
|
||||
without knowing the tree exists.
|
||||
This runs on every flush. Call sites still call `place_action` and
|
||||
`place_memory` directly, and they should. Placing a node at the call site
|
||||
happens before the surrounding code reads the row back, and the explicit
|
||||
call makes that ordering visible. This function is the fallback under those
|
||||
calls, so a fixture, script, test, or a call site added later still gets a
|
||||
branch without knowing the tree exists.
|
||||
|
||||
Nodes whose adventure has not been inserted yet are left alone: there is no
|
||||
id to hang a branch off, and an Action needs `adventure_id` to be written
|
||||
at all, so the case does not arise from any writer we have.
|
||||
Nodes whose adventure has not been inserted yet are skipped, because there
|
||||
is no id to attach a branch to. This case does not arise in practice, since
|
||||
an Action needs `adventure_id` before it can be written at all.
|
||||
|
||||
The head is resolved once per adventure per flush, and held in `heads` for
|
||||
the length of the call. That is not just saving a dictionary lookup: the
|
||||
identity map holds *weak* references, so a branch row nobody keeps a strong
|
||||
reference to is collected between two nodes and read back from the database
|
||||
for the next one. Resolving per node turned a fixture writing two hundred
|
||||
actions in one flush into two hundred SELECTs on `branches`.
|
||||
The head is resolved once per adventure per flush and cached in `heads`.
|
||||
This saves more than a dictionary lookup. The identity map holds weak
|
||||
references, so a branch row that nothing else refers to is collected between
|
||||
two nodes and read from the database again for the next one. Resolving the
|
||||
head per node turned a fixture that wrote 200 actions in one flush into 200
|
||||
separate queries.
|
||||
"""
|
||||
heads: dict[int, models.Branch] = {}
|
||||
for obj in list(session.new):
|
||||
@@ -394,11 +396,11 @@ def place_new_nodes(session: Session) -> None:
|
||||
|
||||
|
||||
def refresh_head(db: Session, adventure: models.Adventure) -> None:
|
||||
"""Re-derive the head depth after nodes were removed (undo, delete).
|
||||
"""Recomputes the head depth after nodes are removed by undo or delete.
|
||||
|
||||
A branch with nothing on it sits at its fork point, because that is the last
|
||||
node its story contains — borrowed from the parent, but the tip all the
|
||||
same. A root branch with nothing on it has no story at all.
|
||||
A branch with no nodes of its own sits at its fork point, because that is
|
||||
the last node its story contains. The node is inherited from the parent, but
|
||||
it is still the tip. A root branch with no nodes has no story at all.
|
||||
"""
|
||||
branch = head_branch(db, adventure)
|
||||
tip = (
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Storing and comparing embedding vectors.
|
||||
|
||||
A 1536-dimension vector written as a JSON list is about 31 KB, because every
|
||||
component is spelled out as a decimal string of seventeen-odd digits. The same
|
||||
vector as packed float32 is 6,144 bytes — a straight 5x, and the memory bank is
|
||||
read in full on every turn, so those bytes are paid over and over.
|
||||
A 1536-dimension vector written as a JSON list is about 31 kB, because every
|
||||
component is written as a decimal string of about seventeen digits. The same
|
||||
vector as packed float32 is 6,144 bytes, which is five times smaller, and the
|
||||
memory bank is read in full on every turn, so those bytes are paid repeatedly.
|
||||
|
||||
**Float32 is not an approximation here.** The embedding endpoints return
|
||||
vectors computed in float32, rendered into JSON as the shortest decimal string
|
||||
that round-trips through a double; converting that back to float32 recovers the
|
||||
original bits exactly. Nothing is lost that was ever there, which is why the
|
||||
conversion needs no re-embedding and carries no retrieval-quality risk.
|
||||
Float32 is not an approximation here. The embedding endpoints return vectors
|
||||
computed in float32 and render them into JSON as the shortest decimal string
|
||||
that round-trips through a double. Converting that back to float32 recovers the
|
||||
original bits exactly. Nothing that was present is lost, which is why the
|
||||
conversion needs no re-embedding and carries no risk to retrieval quality.
|
||||
|
||||
Dimensions are deliberately unchanged. Dropping to 512 or 768 would have saved
|
||||
another 3x and cost an API call per stored memory to re-embed, against a bank
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Phase 12 — RPG world state: the AI proposes stat/milestone deltas, this
|
||||
module validates and clamps them against a scenario's stat_schema."""
|
||||
"""Phase 12: RPG world state.
|
||||
|
||||
The AI proposes stat and milestone deltas, and this module validates and clamps
|
||||
them against a scenario's `stat_schema`.
|
||||
"""
|
||||
|
||||
from .engine import (
|
||||
EMIT_REMINDER,
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
"""RPG world-state engine.
|
||||
|
||||
The scenario carries a `stat_schema` (the template: which stats exist, their
|
||||
bands and rules, and the milestones). An adventure carries a live `world_state`
|
||||
instantiated from it. Each turn the AI proposes a *delta* (only what changed);
|
||||
`apply_delta` is the referee — it clamps to min/max, caps per-turn change,
|
||||
enforces cooldowns, and marks milestones sticky.
|
||||
The scenario carries a `stat_schema`, which is the template: which stats exist,
|
||||
their bands and rules, and the milestones. An adventure carries a live
|
||||
`world_state` instantiated from it. Each turn the AI proposes a delta holding
|
||||
only what changed. `apply_delta` decides what the delta is allowed to do. It
|
||||
clamps values to min and max, caps the change per turn, enforces cooldowns, and
|
||||
makes milestones sticky.
|
||||
|
||||
Nothing here ever raises on bad AI output: a malformed delta yields `{}` and the
|
||||
turn continues, exactly like a broken script never breaks a turn.
|
||||
Nothing here raises on bad AI output. A malformed delta returns `{}` and the
|
||||
turn continues, the same way a broken script never breaks a turn.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
|
||||
# stat_schema top-level sections that hold stat definitions.
|
||||
# The `stat_schema` top-level sections that hold stat definitions.
|
||||
STAT_SECTIONS = ("world", "player")
|
||||
|
||||
# Appended once to the system prompt so the model knows how to report changes.
|
||||
# Appended once to the system prompt, so the model knows how to report
|
||||
# changes.
|
||||
EMIT_RULE = (
|
||||
"You maintain a numeric world state. Treat your own narration as authoritative: "
|
||||
"whenever what you write implies a change to any tracked value — health or resources "
|
||||
@@ -45,8 +47,8 @@ EMIT_RULE = (
|
||||
'"player.outfit": "torn traveling cloak"}\n```'
|
||||
)
|
||||
|
||||
# Short terminal reminder placed at the very end of the prompt (strongest
|
||||
# recency position) so the emit rule is fresh right where the model generates.
|
||||
# A short reminder placed at the end of the prompt, which is the strongest
|
||||
# recency position, so the emit rule is close to where the model generates.
|
||||
EMIT_REMINDER = (
|
||||
"[Reminder: end your reply with a ```state block of the changes this turn "
|
||||
"(deltas only), or omit it if truly nothing changed.]"
|
||||
@@ -54,9 +56,12 @@ EMIT_REMINDER = (
|
||||
|
||||
|
||||
def render_delta_block(delta: dict) -> str:
|
||||
"""Render a stored delta back into the fenced `state` block the AI emitted,
|
||||
for re-injecting past turns into context so the model imitates the format.
|
||||
Empty delta -> empty string (the turn legitimately changed nothing)."""
|
||||
"""Renders a stored delta back into the fenced `state` block the AI emitted.
|
||||
|
||||
The caller replays past turns into the context with this, so the model copies
|
||||
the format. An empty delta returns an empty string, which means the turn
|
||||
changed nothing.
|
||||
"""
|
||||
if not isinstance(delta, dict) or not delta:
|
||||
return ""
|
||||
return "```state\n" + json.dumps(delta, ensure_ascii=False) + "\n```"
|
||||
@@ -68,7 +73,7 @@ _TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL)
|
||||
|
||||
|
||||
def has_schema(stat_schema: dict | None) -> bool:
|
||||
"""True when a scenario actually defines an RPG layer."""
|
||||
"""Returns `True` when a scenario defines an RPG layer."""
|
||||
if not isinstance(stat_schema, dict):
|
||||
return False
|
||||
return any(
|
||||
@@ -83,8 +88,11 @@ def npc_name(ndef: dict, key: str) -> str:
|
||||
|
||||
|
||||
def npc_triggers(ndef: dict, key: str) -> list[str]:
|
||||
"""Lower-cased trigger words for detecting an NPC in scene: its `keys`
|
||||
field, falling back to its display name."""
|
||||
"""Returns the lowercased trigger words that detect an NPC in a scene.
|
||||
|
||||
The words come from the NPC's `keys` field, or from its display name when
|
||||
`keys` is empty.
|
||||
"""
|
||||
raw = ndef.get("keys") or npc_name(ndef, key)
|
||||
return [k.strip().lower() for k in str(raw).split(",") if k.strip()]
|
||||
|
||||
@@ -98,7 +106,7 @@ def _initials(defs: dict) -> dict:
|
||||
|
||||
|
||||
def instantiate(stat_schema: dict | None) -> dict:
|
||||
"""Build a fresh live world_state from a schema (initial values only)."""
|
||||
"""Builds a fresh live `world_state` from a schema, using initial values only."""
|
||||
if not has_schema(stat_schema):
|
||||
return {}
|
||||
ws: dict = {}
|
||||
@@ -121,24 +129,26 @@ def instantiate(stat_schema: dict | None) -> dict:
|
||||
|
||||
|
||||
def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict, dict]:
|
||||
"""Bring a live world_state back in line with an edited schema.
|
||||
"""Brings a live `world_state` back in line with an edited schema.
|
||||
|
||||
Deliberately NOT `instantiate`: a value the schema still defines keeps
|
||||
whatever it has reached in play (re-instantiating would heal the player to
|
||||
full and wipe their milestones). Only the difference is applied — stats,
|
||||
NPCs, flags and milestones the schema gained appear at their initial value,
|
||||
and ones it no longer defines are dropped, along with their `last_changed`
|
||||
bookkeeping. Returns (new_state, report) where the report lists paths under
|
||||
`added` / `removed` so the UI can show what a refresh would do.
|
||||
This is not `instantiate`. A value the schema still defines keeps whatever it
|
||||
reached in play, because re-instantiating would restore the player to full
|
||||
health and clear their milestones. Only the difference is applied. Stats,
|
||||
NPCs, flags, and milestones the schema gained appear at their initial value,
|
||||
and ones it no longer defines are removed along with their `last_changed`
|
||||
bookkeeping. The return value is `(new_state, report)`, where the report
|
||||
lists paths under `added` and `removed`, so the UI can show what a refresh
|
||||
would do.
|
||||
|
||||
Note the additions are mostly cosmetic: rendering and delta-application both
|
||||
fall back to a stat def's `initial` when the live state has no value for it,
|
||||
so a newly added stat already behaves correctly. This materialises it (and,
|
||||
unlike those read-through paths, actually cleans up removals).
|
||||
The additions are mostly cosmetic. Rendering and delta application both fall
|
||||
back to a stat definition's `initial` when the live state has no value for
|
||||
it, so a newly added stat already behaves correctly. This function stores the
|
||||
value, and unlike those read-through paths it also removes what the schema
|
||||
dropped.
|
||||
"""
|
||||
report: dict = {"added": [], "removed": []}
|
||||
if not has_schema(stat_schema):
|
||||
# The scenario dropped its RPG layer entirely — so does the adventure.
|
||||
# The scenario dropped its RPG layer, so the adventure drops it too.
|
||||
stale = bool(world_state)
|
||||
if stale:
|
||||
report["removed"].append("(all world state)")
|
||||
@@ -199,9 +209,10 @@ def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict,
|
||||
report["removed"].append(f"flags.{name}")
|
||||
ws["flags"] = new_flags
|
||||
|
||||
# Milestones store only the ones reached, so there is nothing to add here —
|
||||
# an unreached milestone is simply absent. Drop reached ones the scenario
|
||||
# no longer defines, or they'd sit in "Achieved" forever with no label.
|
||||
# Milestones store only the ones reached, so there is nothing to add here.
|
||||
# An unreached milestone is absent. Drop reached milestones the scenario no
|
||||
# longer defines, because they would otherwise stay in "Achieved" with no
|
||||
# label.
|
||||
milestone_defs = stat_schema.get("milestones") or {}
|
||||
reached = ws.get("milestones") if isinstance(ws.get("milestones"), dict) else {}
|
||||
ws["milestones"] = {k: v for k, v in reached.items() if k in milestone_defs}
|
||||
@@ -209,8 +220,8 @@ def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict,
|
||||
if k not in milestone_defs:
|
||||
report["removed"].append(f"milestones.{k}")
|
||||
|
||||
# Cooldown bookkeeping for paths that no longer exist would never be read,
|
||||
# but it accumulates in every stored snapshot — prune it with the rest.
|
||||
# Cooldown bookkeeping for paths that no longer exist is never read, but it
|
||||
# accumulates in every stored snapshot, so remove it with the rest.
|
||||
meta = ws.setdefault("_meta", {})
|
||||
last_changed = meta.get("last_changed")
|
||||
if isinstance(last_changed, dict):
|
||||
@@ -226,10 +237,10 @@ def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict,
|
||||
|
||||
|
||||
def band_label(stat_def: dict, value) -> str | None:
|
||||
"""The word label for `value` from a stat def's bands, if any.
|
||||
"""Returns the word label for `value` from a stat definition's bands, if any.
|
||||
|
||||
Bands are [lo, hi, label]; matched as lo <= value < hi, with the top band
|
||||
inclusive of its upper bound so a maxed stat still gets a label.
|
||||
A band is `[lo, hi, label]` and matches when `lo <= value < hi`. The top band
|
||||
includes its upper bound, so a stat at its maximum still gets a label.
|
||||
"""
|
||||
bands = stat_def.get("bands")
|
||||
if not isinstance(bands, list) or not isinstance(value, (int, float)):
|
||||
@@ -265,12 +276,12 @@ def _tolerant_load(blob: str) -> dict:
|
||||
|
||||
|
||||
def extract_delta(text: str) -> tuple[str, dict]:
|
||||
"""Pull the trailing state block out of an AI response.
|
||||
"""Removes the trailing state block from an AI response.
|
||||
|
||||
Returns (clean_text, delta). `delta` is `{}` when there is no block or it
|
||||
can't be parsed; `clean_text` has the block removed. Only strips a bare
|
||||
trailing object when it actually parses to a delta, so ordinary prose
|
||||
ending in `}` is never eaten.
|
||||
The return value is `(clean_text, delta)`. `delta` is `{}` when there is no
|
||||
block or the block cannot be parsed, and `clean_text` has the block removed.
|
||||
A bare trailing object is stripped only when it parses to a delta, so
|
||||
ordinary prose that ends in `}` is left alone.
|
||||
"""
|
||||
matches = list(_FENCE_RE.finditer(text))
|
||||
if matches:
|
||||
@@ -293,7 +304,7 @@ def extract_delta(text: str) -> tuple[str, dict]:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _coerce_number(value):
|
||||
if isinstance(value, bool): # bool is an int subclass — reject here
|
||||
if isinstance(value, bool): # `bool` is an `int` subclass, so reject it here.
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
@@ -349,9 +360,12 @@ def _apply_stat(container: dict, key: str, stat_def: dict, change,
|
||||
|
||||
def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
path: str, action_index: int, meta: dict, report: dict) -> None:
|
||||
"""Free-text stats replace rather than add: the AI sends the new value in
|
||||
full, not a delta. No clamping/bands apply — only an optional cooldown and
|
||||
an optional max_length truncation."""
|
||||
"""Applies a free-text stat, which replaces rather than adds.
|
||||
|
||||
The AI sends the new value in full rather than a delta. No clamping and no
|
||||
bands apply. Only an optional cooldown and an optional `max_length`
|
||||
truncation apply.
|
||||
"""
|
||||
if not isinstance(change, str):
|
||||
report["rejected"].append({"path": path, "reason": "not a string"})
|
||||
return
|
||||
@@ -369,7 +383,7 @@ def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
|
||||
old = container.get(key, stat_def.get("initial", ""))
|
||||
if new == old:
|
||||
return # no actual change — silent no-op
|
||||
return # Nothing changed, so do nothing.
|
||||
|
||||
container[key] = new
|
||||
meta["last_changed"][path] = action_index
|
||||
@@ -377,14 +391,17 @@ def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
|
||||
|
||||
def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tuple[dict, dict]:
|
||||
"""Directly set live values — a manual author/admin edit, not an AI turn.
|
||||
"""Sets live values directly, as a manual author edit rather than an AI turn.
|
||||
|
||||
Unlike `apply_delta`: numeric stats are SET rather than added to, and
|
||||
`cooldown`/`max_delta_per_turn`/the counter-can't-decrease rule are all
|
||||
ignored (a deliberate correction, not an AI move to police). Milestones
|
||||
can be toggled either way, not only marked reached. Values are still
|
||||
validated against the schema (unknown path/type is rejected) and numeric
|
||||
values still clamp to min/max."""
|
||||
This differs from `apply_delta` in three ways. Numeric stats are set rather
|
||||
than added to. `cooldown`, `max_delta_per_turn`, and the rule that a counter
|
||||
cannot decrease are all ignored, because this is a deliberate correction
|
||||
rather than an AI move to check. Milestones can be toggled in both
|
||||
directions rather than only marked reached.
|
||||
|
||||
Values are still validated against the schema, so an unknown path or a wrong
|
||||
type is rejected, and numeric values still clamp to min and max.
|
||||
"""
|
||||
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
|
||||
if not ws:
|
||||
ws = instantiate(stat_schema)
|
||||
@@ -492,8 +509,11 @@ def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tup
|
||||
|
||||
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
action_index: int) -> tuple[dict, dict]:
|
||||
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
|
||||
`world_state`. Returns (new_world_state, report)."""
|
||||
"""Validates and clamps `delta` against `stat_schema`, then applies it.
|
||||
|
||||
The delta is applied to a copy of `world_state`. The return value is
|
||||
`(new_world_state, report)`.
|
||||
"""
|
||||
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
|
||||
if not ws:
|
||||
ws = instantiate(stat_schema)
|
||||
@@ -512,7 +532,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
path = str(raw_path)
|
||||
parts = path.split(".")
|
||||
|
||||
# flags.<name> — free two-way boolean, either value accepted.
|
||||
# `flags.<name>` is a two-way boolean, and either value is accepted.
|
||||
if parts[0] == "flags" and len(parts) == 2:
|
||||
fid = parts[1]
|
||||
if fid not in flag_defs:
|
||||
@@ -528,7 +548,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
report["applied"].append({"path": path, "old": old, "new": change})
|
||||
continue
|
||||
|
||||
# milestones.<id> — sticky boolean, only `true` accepted.
|
||||
# `milestones.<id>` is a sticky boolean, and only `true` is accepted.
|
||||
if parts[0] == "milestones" and len(parts) == 2:
|
||||
mid = parts[1]
|
||||
if mid not in milestones:
|
||||
@@ -539,7 +559,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
continue
|
||||
reached = ws.setdefault("milestones", {})
|
||||
if reached.get(mid, {}).get("reached"):
|
||||
continue # already done — silent no-op
|
||||
continue # Already reached, so do nothing.
|
||||
reached[mid] = {"reached": True, "at": action_index}
|
||||
report["applied"].append({"path": path, "old": False, "new": True})
|
||||
continue
|
||||
@@ -559,7 +579,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
action_index, meta, report)
|
||||
continue
|
||||
|
||||
# npc.<npcId>.<stat> — each NPC has its own stat defs.
|
||||
# `npc.<npcId>.<stat>`. Each NPC has its own stat definitions.
|
||||
if parts[0] == "npc" and len(parts) == 3:
|
||||
ndef = npcs.get(parts[1])
|
||||
if not isinstance(ndef, dict):
|
||||
@@ -608,8 +628,11 @@ def _stat_line(defs: dict, values: dict) -> str:
|
||||
|
||||
def render_state_section(world_state: dict, stat_schema: dict,
|
||||
visible_npcs: dict[str, str]) -> str:
|
||||
"""Compact, always-included context block. `visible_npcs` maps card-id ->
|
||||
display name for NPCs currently in scene."""
|
||||
"""Returns the compact context block that every turn includes.
|
||||
|
||||
`visible_npcs` maps a card id to a display name, for the NPCs currently in
|
||||
the scene.
|
||||
"""
|
||||
ws = world_state if isinstance(world_state, dict) else {}
|
||||
lines: list[str] = []
|
||||
|
||||
@@ -658,13 +681,16 @@ def render_state_section(world_state: dict, stat_schema: dict,
|
||||
|
||||
|
||||
def _describe_stat(name: str, d: dict) -> str | None:
|
||||
"""One reference line for a stat. Description and band-ladder are independent —
|
||||
each is included only when present, so a stat may have either, both, or neither."""
|
||||
"""Returns one reference line for a stat.
|
||||
|
||||
The description and the band ladder are independent, and each is included
|
||||
only when present, so a stat may have either, both, or neither.
|
||||
"""
|
||||
bits: list[str] = []
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
# Fragments are joined with "; " and end with a single ".", so drop any
|
||||
# trailing period the author already put on the description.
|
||||
# Fragments are joined with "; " and end with a single ".", so remove
|
||||
# any trailing period the author put on the description.
|
||||
bits.append(desc.strip().rstrip("."))
|
||||
if d.get("type") == "text":
|
||||
bits.append("free text")
|
||||
@@ -684,8 +710,12 @@ def _describe_stat(name: str, d: dict) -> str | None:
|
||||
|
||||
|
||||
def render_reference(stat_schema: dict) -> str:
|
||||
"""A fixed, per-scenario legend describing what each stat means (its `desc`)
|
||||
and its band ladder. Static across turns — separate from the live values."""
|
||||
"""Returns a fixed, per-scenario legend for the stats.
|
||||
|
||||
Each line gives what a stat means, from its `desc`, and its band ladder. The
|
||||
legend does not change from turn to turn, and it is separate from the live
|
||||
values.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for section in STAT_SECTIONS:
|
||||
for name, d in (stat_schema.get(section) or {}).items():
|
||||
|
||||
@@ -19,7 +19,8 @@ migrations.bootstrap(engine)
|
||||
DEMO_PREFIX = "[Demo]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample scripts — AI Dungeon contract: define modifier(text), call it last.
|
||||
# Sample scripts. The AI Dungeon contract is to define `modifier(text)` and call
|
||||
# it last.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DICE_ROLLER = dict(
|
||||
@@ -227,7 +228,7 @@ try:
|
||||
scripts = [models.Script(**s) for s in SCRIPTS]
|
||||
db.add_all(scripts)
|
||||
|
||||
# Scenario with cards and scripts attached — public starter content.
|
||||
# A scenario with cards and scripts attached, as public starter content.
|
||||
scenario = models.Scenario(**SCENARIO, is_public=True)
|
||||
scenario.scripts = scripts
|
||||
db.add(scenario)
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
"""Make a database look like an older schema version, so a migration can run.
|
||||
|
||||
`create_all` always builds the *current* schema. A test that wants to watch a
|
||||
migration happen therefore has to take the newer columns back off before it
|
||||
stamps an older version — otherwise the migration meets a table that already
|
||||
has its column and dies on a duplicate.
|
||||
`create_all` always builds the current schema. A test that wants to watch a
|
||||
migration run must remove the newer columns first and then stamp an older
|
||||
version. Otherwise the migration finds a column that already exists and
|
||||
fails on a duplicate.
|
||||
|
||||
Rewinding the stamp alone was enough for a while, which is why two test files
|
||||
did exactly that. It stopped being enough the moment another `ADD COLUMN`
|
||||
landed after theirs: the replay then runs migrations they never meant to
|
||||
exercise, against columns `create_all` had already made. This module is that
|
||||
rewind done properly, in one place, so appending a migration means adding its
|
||||
inverse here rather than discovering three unrelated test failures.
|
||||
Rewinding the stamp alone worked for a while, so two test files did exactly
|
||||
that. It stopped working when another `ADD COLUMN` migration landed. Without
|
||||
this rewind, the replay runs migrations the tests never intended to
|
||||
exercise, against columns `create_all` already added. This module rewinds
|
||||
properly in one place. Adding a migration now means adding its inverse here,
|
||||
instead of tracking down failures in three unrelated test files.
|
||||
|
||||
SQLite only — every test that replays migrations runs on a temp file, and
|
||||
`PRAGMA user_version` is where the stamp lives there. Migrations that change a
|
||||
column's *type* (43–45, JSON to compressed bytes) have no clean inverse and are
|
||||
not listed: they get replayed as-is, which is what the tests using them already
|
||||
relied on.
|
||||
This module supports SQLite only. Every test that replays migrations runs on
|
||||
a temp file, and SQLite stores the stamp in `PRAGMA user_version`.
|
||||
Migrations that change a column's type (43-45, JSON to compressed bytes)
|
||||
have no clean inverse, so this list omits them. Those migrations replay
|
||||
as-is, which is what the tests using them already expect.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
# (version that added it, statements that take it back off), newest first.
|
||||
# Each entry is (version that added the column, statements that remove it).
|
||||
# The list is ordered newest first.
|
||||
#
|
||||
# Phase 14's `branch_id` columns are deliberately absent: SQLite refuses to drop
|
||||
# a column a foreign key names ("unknown column in foreign key definition"), so
|
||||
# a current-schema database cannot be rewound past them at all. That is what
|
||||
# `migrations._column_already_there` is for — the replay skips DDL that has
|
||||
# already happened, so the tree migrations run their backfill against a schema
|
||||
# that already has the columns, which is exactly the situation here.
|
||||
# Phase 14's `branch_id` columns are missing on purpose. SQLite refuses to
|
||||
# drop a column that a foreign key references, so a current-schema database
|
||||
# cannot be rewound past them. `migrations._column_already_there` handles
|
||||
# this case. It skips DDL that already ran, so the tree migrations run their
|
||||
# backfill against a schema that already has the columns.
|
||||
_UNDO: list[tuple[int, tuple[str, ...]]] = [
|
||||
# Packed float32 vectors and the flag beside them.
|
||||
(39, ("ALTER TABLE memories DROP COLUMN embedded",)),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""The access log — app/accesslog.py and GET /api/analytics/access.
|
||||
"""The access log: app/accesslog.py and GET /api/analytics/access.
|
||||
|
||||
This is the half of the analytics work that identifies people on purpose, so
|
||||
the things worth pinning are the ones that would quietly make it wrong: that
|
||||
the address recorded is the hardened one and not a header a client chose, that
|
||||
session rows are thinned instead of written per page load, and that a row
|
||||
outlives the account it describes — guest cleanup runs on a schedule, and a log
|
||||
that deletes itself is not a log.
|
||||
This is the half of the analytics work that identifies people on purpose,
|
||||
so these tests pin the details that would quietly make it wrong. The
|
||||
address recorded must be the hardened one, not a header a client chose.
|
||||
Session rows must be thinned instead of written on every page load. And a
|
||||
row must outlive the account it describes, because guest cleanup deletes
|
||||
accounts on a schedule, and a log that deletes itself is not a log.
|
||||
|
||||
python -m pytest tests/test_accesslog.py -v
|
||||
"""
|
||||
@@ -57,7 +57,7 @@ def client(monkeypatch):
|
||||
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
|
||||
|
||||
# /auth/me resolves its own session, so the cookie flow below is the real
|
||||
# one; every other endpoint goes through get_current_user, and `act_as`
|
||||
# one. Every other endpoint goes through get_current_user, and `act_as`
|
||||
# decides who that is.
|
||||
acting = {"id": ids["owner"]}
|
||||
|
||||
@@ -111,9 +111,9 @@ def test_a_new_session_is_logged(client):
|
||||
|
||||
def test_the_address_is_the_hardened_one_not_the_clients(client):
|
||||
visit(client)
|
||||
# The client prepended its own value; only the hop the edge appended counts.
|
||||
# Recording the leftmost would make every row forgeable, which for a log is
|
||||
# worse than having no log.
|
||||
# The client prepended its own value. Only the hop the edge appended counts.
|
||||
# Recording the leftmost value would make every row forgeable, which is
|
||||
# worse for a log than having no log at all.
|
||||
assert rows()[0].ip == EDGE
|
||||
|
||||
|
||||
@@ -141,15 +141,16 @@ def test_sign_in_and_failure_are_both_logged(client):
|
||||
assert accesslog.LOGIN_FAILED in kinds and accesslog.LOGIN in kinds
|
||||
|
||||
failure = rows(accesslog.LOGIN_FAILED)[0]
|
||||
# The address tried, not the account that owns it: a run against an address
|
||||
# with no account behind it is exactly what this row is for.
|
||||
# This records the address that was tried, not the account it belongs to.
|
||||
# A failed attempt against an address with no matching account is
|
||||
# exactly what this row exists to capture.
|
||||
assert failure.who == "player@example.com"
|
||||
assert failure.user_id is None
|
||||
assert rows(accesslog.LOGIN)[0].user_id == client.ids["member"]
|
||||
|
||||
|
||||
def test_registering_is_logged_against_the_upgraded_account(client):
|
||||
visit(client) # mints the guest whose session registers
|
||||
visit(client) # creates the guest whose session then registers
|
||||
client.act_as(rows()[0].user_id)
|
||||
client.post("/api/auth/register", json={"email": "new@example.com", "password": "hunter2long"})
|
||||
entry = rows(accesslog.REGISTER)[0]
|
||||
@@ -165,8 +166,9 @@ def test_a_row_outlives_the_account_it_describes(client):
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
# No foreign key, and `who` is a snapshot — guest cleanup deletes accounts
|
||||
# on a schedule, and a log that vanishes with them is not a log.
|
||||
# There is no foreign key, and `who` is a snapshot. Guest cleanup deletes
|
||||
# accounts on a schedule, and a log that vanishes along with them is not
|
||||
# a log.
|
||||
survivor = rows()[0]
|
||||
assert survivor.who == entry.who and survivor.ip == EDGE
|
||||
|
||||
@@ -178,7 +180,7 @@ def test_a_long_user_agent_is_truncated(client):
|
||||
|
||||
def test_a_logging_failure_does_not_break_the_request(client, monkeypatch):
|
||||
monkeypatch.setattr(accesslog, "_client_ip", lambda request: 1 / 0)
|
||||
# The log watches sign-in; it must not be able to stand in its way.
|
||||
# The log observes sign-in. A logging failure must not block the request.
|
||||
assert visit(client).status_code == 200
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Opening an adventure fetches a window, not the whole story.
|
||||
|
||||
A story only ever gets longer. Production's longest is 607 actions and 589.5 kB
|
||||
in one response, and nothing about that curve bends on its own — so the page
|
||||
load returns the newest ACTION_PAGE and the reader pages upward.
|
||||
A story only ever gets longer. Production's longest is 607 actions and
|
||||
589.5 kB in one response, and that number never decreases on its own. The
|
||||
page load returns the newest `ACTION_PAGE` window, and the reader pages
|
||||
upward from there.
|
||||
|
||||
The paging anchors on an action id rather than an offset, and these tests are
|
||||
mostly about why. An offset counted back from the newest shifts every older
|
||||
position the moment a turn lands, which is precisely when a reader is likely
|
||||
The paging anchors on an action id rather than an offset, and these tests
|
||||
cover why. An offset counted back from the newest shifts every older
|
||||
position the moment a turn lands, which is exactly when a reader is likely
|
||||
to be scrolling. An anchor means the same thing before and after.
|
||||
|
||||
python -m pytest tests/test_action_paging.py -v
|
||||
@@ -104,7 +105,7 @@ def test_the_page_load_returns_only_the_newest_window(client):
|
||||
body = r.json()
|
||||
assert len(body["actions"]) == ACTION_PAGE
|
||||
assert body["action_count"] == TOTAL
|
||||
# ...and it is the *newest* window, ending on the last action.
|
||||
# It is the newest window, ending on the last action.
|
||||
assert body["actions"][-1]["index"] == TOTAL - 1
|
||||
assert body["actions"][0]["index"] == TOTAL - ACTION_PAGE
|
||||
|
||||
@@ -123,8 +124,8 @@ def test_a_short_story_is_returned_whole(client):
|
||||
|
||||
|
||||
def test_the_page_load_does_not_grow_with_the_story(client):
|
||||
"""The point of the change. Whatever the story's length, opening it costs
|
||||
a window."""
|
||||
"""Confirm that opening a story costs a window, regardless of the
|
||||
story's length."""
|
||||
meter = dbmeter.Meter()
|
||||
meter.attach(engine)
|
||||
try:
|
||||
@@ -134,8 +135,9 @@ def test_the_page_load_does_not_grow_with_the_story(client):
|
||||
finally:
|
||||
meter.detach()
|
||||
|
||||
# Each action carries ~1 kB of text and there are 187 of them; a window is
|
||||
# 60. Generous ceiling, but far below the whole story.
|
||||
# Each action carries about 1 KB of text, and there are 187 of them. A
|
||||
# window holds 60 actions. This ceiling is generous but still far below
|
||||
# the size of the whole story.
|
||||
assert windowed < ACTION_PAGE * 2_000, f"{windowed:,} B for one window"
|
||||
assert windowed < TOTAL * 500, (
|
||||
f"{windowed:,} B — that is the whole story, not a window"
|
||||
@@ -182,9 +184,10 @@ def test_each_page_is_ordered_oldest_first(client):
|
||||
# ------------------------------------------------- the reason for the anchor
|
||||
|
||||
def test_a_turn_arriving_mid_scroll_does_not_shift_the_next_page(client):
|
||||
"""The failure an offset would have. Read the newest page, let a turn land,
|
||||
then page up: the reader must get exactly what precedes what they hold —
|
||||
no duplicate, no skipped action."""
|
||||
"""Reproduce the failure an offset-based scheme would have. Read the
|
||||
newest page, let a turn land, then page up. The reader must get exactly
|
||||
what precedes the actions they already hold, with no duplicate and no
|
||||
skipped action."""
|
||||
first = page(client)
|
||||
oldest_held = first["actions"][0]
|
||||
|
||||
@@ -194,13 +197,13 @@ def test_a_turn_arriving_mid_scroll_does_not_shift_the_next_page(client):
|
||||
assert older["actions"][-1]["index"] == oldest_held["index"] - 1, \
|
||||
"the page shifted when a turn landed"
|
||||
assert all(a["index"] < oldest_held["index"] for a in older["actions"])
|
||||
# The new turn moved the total, which is fine — it must not move the window.
|
||||
# The new turn changes the total, which is expected. It must not move the window.
|
||||
assert older["total"] == TOTAL + 1
|
||||
|
||||
|
||||
def test_a_deleted_anchor_reports_the_end_rather_than_a_duplicate_page(client):
|
||||
"""Undo can remove the action a slow scroll was anchored to. Better to stop
|
||||
than to hand back a page the reader already has."""
|
||||
"""Undo can remove the action a slow scroll was anchored to. The endpoint
|
||||
must stop instead of returning a page the reader already has."""
|
||||
body = page(client)
|
||||
anchor = body["actions"][0]
|
||||
|
||||
@@ -220,7 +223,7 @@ def test_a_deleted_anchor_reports_the_end_rather_than_a_duplicate_page(client):
|
||||
|
||||
def test_limit_is_honoured_and_capped(client):
|
||||
assert len(page(client, limit=5)["actions"]) == 5
|
||||
# A client asking for the whole story does not get to undo the paging.
|
||||
# A client that asks for the whole story cannot bypass the paging cap.
|
||||
assert len(page(client, limit=100_000)["actions"]) <= ACTION_PAGE * 4
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Visit analytics — app/analytics.py and the two endpoints in front of it.
|
||||
"""Visit analytics: app/analytics.py and the two endpoints in front of it.
|
||||
|
||||
Three things are worth testing here and the rest is arithmetic. That the
|
||||
counters survive the buffer/UPSERT round trip (a flush must add to what is
|
||||
already stored, not replace it, or every number is only ever the last minute).
|
||||
That the funnel counts *people* rather than clicks, which is the only reason
|
||||
the visitor-day table exists. And that the gate holds: a stranger cannot read
|
||||
the dashboard, and cannot inflate what it says beyond hitting the page.
|
||||
This file tests three things, and the rest is arithmetic. The counters must
|
||||
survive the buffer/UPSERT round trip: a flush adds to what is already
|
||||
stored instead of replacing it, or every number would show only the last
|
||||
minute. The funnel counts people rather than clicks, which is the only
|
||||
reason the visitor-day table exists. The gate holds: a stranger cannot read
|
||||
the dashboard, and cannot inflate what it reports beyond hitting the page.
|
||||
|
||||
python -m pytest tests/test_analytics.py -v
|
||||
"""
|
||||
@@ -102,7 +102,7 @@ def test_label_cardinality_is_capped(db):
|
||||
analytics.flush(db)
|
||||
labels = db.query(models.AnalyticsDaily).filter_by(metric=analytics.M_REFERRER).count()
|
||||
# Everything past the cap is folded into one bucket, so a referrer flood
|
||||
# cannot mint rows without limit.
|
||||
# cannot create unlimited rows.
|
||||
assert labels == analytics.MAX_LABELS_PER_METRIC + 1
|
||||
assert counter(db, analytics.M_REFERRER, analytics.OTHER) == 25
|
||||
|
||||
@@ -115,8 +115,9 @@ def test_visitor_id_is_stable_and_keyed(db, monkeypatch):
|
||||
assert handle == analytics.visitor_id(user) # a returning visitor
|
||||
assert handle != analytics.visitor_id(make_user(db)) # is still one visitor
|
||||
assert len(handle) == 32 and int(handle, 16) >= 0 # opaque hex, not an id
|
||||
# Keyed on the app secret, not a bare hash of the user id: otherwise anyone
|
||||
# holding this table could rebuild the mapping by hashing 1, 2, 3, …
|
||||
# Keyed on the app secret, not a bare hash of the user id. Otherwise
|
||||
# anyone holding this table could rebuild the mapping by hashing
|
||||
# sequential ids.
|
||||
monkeypatch.setattr(analytics.security, "SECRET_KEY", b"a-different-secret")
|
||||
assert analytics.visitor_id(user) != handle
|
||||
|
||||
@@ -128,8 +129,8 @@ def test_a_repeat_visitor_is_new_only_once(db):
|
||||
rows = db.query(models.AnalyticsVisitorDay).all()
|
||||
assert len(rows) == 1 and rows[0].is_new
|
||||
|
||||
# Same visitor, a later day: seen before, so not new — and not merged into
|
||||
# the first day's row either.
|
||||
# Same visitor, a later day: seen before, so not new. The row is not
|
||||
# merged into the first day's row either.
|
||||
tomorrow = (models.utcnow().date() + timedelta(days=1)).isoformat()
|
||||
analytics._visits[(tomorrow, analytics.visitor_id(user))] = set()
|
||||
analytics.flush(db)
|
||||
@@ -234,7 +235,7 @@ def test_summary_counts_people_once_per_step(db):
|
||||
steps = {row["step"]: row["count"] for row in result["funnel"]}
|
||||
assert steps["Visited"] == 2
|
||||
assert steps["Opened a scenario"] == 2
|
||||
assert steps["Played a turn"] == 1 # not 3 — one person, three turns
|
||||
assert steps["Played a turn"] == 1 # not 3, because one person made three turns
|
||||
assert steps["Signed up"] == 0
|
||||
# Raw event totals still count every occurrence.
|
||||
assert result["totals"]["turns"] == 3
|
||||
@@ -361,10 +362,11 @@ def test_api_errors_are_counted_by_route(client):
|
||||
# ---------- The dialect the tests never run on ----------
|
||||
|
||||
def test_the_upserts_compile_for_postgres():
|
||||
"""Prod is Neon; these tests are SQLite, and a failed flush is caught and
|
||||
logged rather than raised. A dialect mistake would therefore be invisible
|
||||
until the dashboard quietly stayed empty — so compile both statements
|
||||
against Postgres without ever connecting to one.
|
||||
"""Prod runs on Neon, but these tests run on SQLite, and a failed flush
|
||||
is caught and logged instead of raised. A dialect mistake would
|
||||
therefore stay invisible until the dashboard quietly stayed empty. This
|
||||
test compiles both statements against Postgres without connecting to
|
||||
one.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Phase 14 SP4 — a retry writes a sibling node instead of rewriting a row.
|
||||
"""Phase 14 SP4: a retry writes a sibling node instead of rewriting a row.
|
||||
|
||||
`test_retry_variants.py` is the behavioural contract, unchanged since before
|
||||
the tree, and it still passes: the same URLs, the same payload shape, the same
|
||||
outcomes. This file asserts the things that are *only* true of the new storage
|
||||
— that a turn can be several rows, that exactly one of them is the story, and
|
||||
that the arrangement costs neither an extra prompt nor an extra turn.
|
||||
`test_retry_variants.py` is the behavioral contract from before the tree
|
||||
existed, and it still passes unchanged: the same URLs, the same payload
|
||||
shape, the same outcomes. This file asserts the things that are true only of
|
||||
the new storage. A turn can be several rows, exactly one of them is the
|
||||
story, and the arrangement costs neither an extra prompt nor an extra turn.
|
||||
|
||||
python -m pytest tests/test_attempt_siblings.py -v
|
||||
"""
|
||||
@@ -122,8 +122,9 @@ def _page(client) -> dict:
|
||||
def _rows(adv_id) -> list[models.Action]:
|
||||
"""Every action row of the adventure, story or not, live or not.
|
||||
|
||||
Undeferred, because the session is closed before the caller looks: the
|
||||
columns this file is about are exactly the ones a page load never loads.
|
||||
The query undefers these columns because the session closes before the
|
||||
caller reads the result. This file specifically tests the columns that a
|
||||
page load never loads.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -156,7 +157,7 @@ def test_a_retry_writes_a_second_row_at_the_same_coordinate(client):
|
||||
assert [a.text for a in ai] == ["Attempt one.", "Attempt two."]
|
||||
# Exactly one of them is the story, and it is the newer take.
|
||||
assert [a.live for a in ai] == [False, True]
|
||||
# ...and the discarded attempt is untouched, not a copy of anything.
|
||||
# The discarded attempt is untouched, not a copy of anything.
|
||||
assert ai[0].state_after is not None
|
||||
|
||||
|
||||
@@ -173,9 +174,10 @@ def test_the_story_shows_and_counts_the_turn_once(client):
|
||||
|
||||
|
||||
def test_a_discarded_attempt_never_reaches_the_prompt(client):
|
||||
"""The trap the branch clause exists to close, at sibling scale: the losing
|
||||
attempt sits at the same branch and depth as the live one, so anything
|
||||
reading the story by coordinate alone would replay both."""
|
||||
"""This is the failure case the branch clause exists to prevent, at
|
||||
sibling scale. The losing attempt sits at the same branch and depth as
|
||||
the live one, so anything reading the story by coordinate alone would
|
||||
replay both."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
@@ -198,21 +200,22 @@ def test_switching_moves_the_story_onto_the_other_row(client):
|
||||
r = client.post(
|
||||
f"/api/adventures/{client.adv_id}/actions/{newest_id}/variant", json={"index": 0})
|
||||
assert r.status_code == 200, r.text
|
||||
# A different row answers — that is the whole change.
|
||||
# A different row answers the request. That is the only change.
|
||||
assert r.json()["id"] != newest_id
|
||||
assert r.json()["text"].startswith("A scratch")
|
||||
|
||||
rows = _rows(client.adv_id)
|
||||
ai = [a for a in rows if a.type == "ai"]
|
||||
assert [a.live for a in ai] == [True, False]
|
||||
# Both takes are still there, byte for byte.
|
||||
# Both takes remain unchanged in the database.
|
||||
assert [a.text.split(".")[0] for a in ai] == ["A scratch", "A beating"]
|
||||
|
||||
|
||||
def test_the_assembled_prompt_is_stored_once_per_turn(client):
|
||||
"""A snapshot is ~160 kB of prompt every attempt at a turn shares. Giving
|
||||
each sibling a copy would have made retry a permanent multiplier on the
|
||||
biggest column in the database, so the prompt moves with the live flag."""
|
||||
"""A snapshot holds about 160 kB of prompt that every attempt at a turn
|
||||
shares. Giving each sibling its own copy would make retry multiply the
|
||||
size of the largest column in the database. Instead, the prompt moves
|
||||
with the live flag."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
@@ -278,12 +281,12 @@ def test_deleting_a_turn_through_a_discarded_attempt_still_takes_the_turn(client
|
||||
def test_retrying_withdraws_the_memory_the_turn_produced(client):
|
||||
"""Why summarization no longer holds the newest action back.
|
||||
|
||||
A memory covering the newest turn used to be unreachable-by-construction:
|
||||
the summarizer stopped one action short, because a retry rewrote the row
|
||||
under a mark that had already moved past it. Now the mark and the memory
|
||||
both name the node, and replacing what a node says withdraws them — the
|
||||
same repair undo and delete already made, so the holdback was the only
|
||||
thing left that a retry needed.
|
||||
A memory covering the newest turn used to be unreachable by
|
||||
construction. The summarizer stopped one action short, because a retry
|
||||
rewrote the row under a mark that had already moved past it. Now the
|
||||
mark and the memory both name the node, so replacing what a node says
|
||||
withdraws them. Undo and delete already had this repair; the holdback
|
||||
was the only gap a retry still needed to close.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
@@ -311,8 +314,8 @@ def test_retrying_withdraws_the_memory_the_turn_produced(client):
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
assert db.query(models.Memory).count() == 0, "the withdrawn memory is gone"
|
||||
# ...and the ground it covered is handed back, so the block is summarized
|
||||
# again from where it began rather than silently skipped.
|
||||
# The depth range it covered is released, so the block is
|
||||
# summarized again from where it began instead of being skipped.
|
||||
assert cursors.MEMORY.depth(db, adventure) == 0
|
||||
assert cursors.SUMMARY.depth(db, adventure) == 0
|
||||
assert covered_depth > 0
|
||||
@@ -382,12 +385,14 @@ def test_attempts_module_agrees_with_the_endpoint(client):
|
||||
|
||||
|
||||
def test_export_carries_every_attempt_as_its_own_node(client):
|
||||
"""SP6 changed the answer here, and the reason is the whole of that subphase.
|
||||
"""SP6 changed the answer here, for the same reasons as the rest of that
|
||||
subphase.
|
||||
|
||||
A v1 bundle had one entry per turn and folded the group back into a
|
||||
`variants` array, because the format had nowhere else to put a second take.
|
||||
A v2 bundle has coordinates, so an attempt is a node in the file exactly as
|
||||
it is a node in the database, and `live` says which one is the story.
|
||||
`variants` array, because the format had nowhere else to put a second
|
||||
take. A v2 bundle has coordinates, so an attempt is a node in the file
|
||||
exactly as it is a node in the database, and `live` says which one is
|
||||
the story.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
@@ -399,7 +404,7 @@ def test_export_carries_every_attempt_as_its_own_node(client):
|
||||
assert len({(a["branch"], a["depth"]) for a in ai}) == 1, "one turn, two takes"
|
||||
assert "variants" not in ai[0], "nothing writes the repeating group any more"
|
||||
|
||||
# ...and importing it puts the group back exactly as it stood.
|
||||
# Importing the bundle puts the group back exactly as it stood.
|
||||
imported = client.post("/api/adventures/import", json=bundle).json()["id"]
|
||||
rows = _rows(imported)
|
||||
ai_rows = [a for a in rows if a.type == "ai"]
|
||||
@@ -410,11 +415,12 @@ def test_export_carries_every_attempt_as_its_own_node(client):
|
||||
def test_a_retry_after_switching_back_files_the_new_attempt_last(client):
|
||||
"""The group stays in the order the attempts were made.
|
||||
|
||||
`add_attempt` used to number a new take one past the take it replaced, which
|
||||
is the end of the group only when the story is standing on the newest one.
|
||||
Switch a three-take turn back to the first and retry, and the new attempt
|
||||
collided with take 2 — `renumber` then broke the tie by id and filed it
|
||||
*between* takes 2 and 3, so the pager walked them in an order nobody played.
|
||||
`add_attempt` used to number a new take one past the take it replaced.
|
||||
That numbering is correct only when the story is standing on the newest
|
||||
take. Switch a three-take turn back to the first and retry, and the new
|
||||
attempt collides with take 2. `renumber` broke the tie by id and placed
|
||||
the new attempt between takes 2 and 3, so the pager listed them in an
|
||||
order the player never produced.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two.", "Three.", "Four."]
|
||||
_play(client)
|
||||
@@ -439,9 +445,10 @@ def test_a_retry_after_switching_back_files_the_new_attempt_last(client):
|
||||
def test_the_adventure_list_quotes_the_take_the_story_tells(client):
|
||||
"""The index screen and the story have to agree.
|
||||
|
||||
Siblings share a depth and the newest of them has the highest id, so a
|
||||
snippet ordered by `(depth, id)` alone quotes whichever attempt was written
|
||||
last — which, after switching back, is the one the player threw away.
|
||||
Siblings share a depth, and the newest of them has the highest id. A
|
||||
snippet ordered by `(depth, id)` alone quotes whichever attempt was
|
||||
written last. After switching back, that attempt is the one the player
|
||||
discarded.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"""Phase 14 SP2 — a read sees one story, and knows which one.
|
||||
"""Phase 14 SP2: a read sees one story, and knows which one.
|
||||
|
||||
These tests build the fork by hand: three branch rows and their nodes, written
|
||||
straight to the database, arranged as the design doc's own worked example. That
|
||||
was the only way to build one when this file was written (nothing forked until
|
||||
SP5) and it stays that way now that `tree.fork` exists — a fixture that agreed
|
||||
with the code under test could not catch it being wrong. The two are checked
|
||||
against each other in `test_branch_forking.py`.
|
||||
These tests build the fork by hand. Three branch rows and their nodes are
|
||||
written straight to the database, arranged as the design doc's own worked
|
||||
example. That was the only way to build one when this file was written,
|
||||
because nothing forked until SP5. It stays that way now that `tree.fork`
|
||||
exists, because a fixture built with the same code under test could not
|
||||
catch that code being wrong. `test_branch_forking.py` checks the two
|
||||
against each other.
|
||||
|
||||
branch C, tip at depth 7, lineage [(C, 7), (B, 5), (A, 3)]
|
||||
→ A0 A1 A2 A3 B4 B5 C6 C7
|
||||
-> A0 A1 A2 A3 B4 B5 C6 C7
|
||||
|
||||
The point of building it by hand is that every read in the app is supposed to
|
||||
go through one module, and a forgotten clause does not raise — it quietly shows
|
||||
a story assembled out of two different ones. So the fixture deliberately leaves
|
||||
nodes lying where a forgotten clause would pick them up: A kept playing past
|
||||
the fork (A4, A5), B kept playing past its own (B6), and a second adventure
|
||||
holds a whole story of its own. None of them may appear on C.
|
||||
The point of building the fixture by hand is that every read in the app
|
||||
must go through one module. A forgotten clause does not raise an error. It
|
||||
silently shows a story assembled out of two different branches. The
|
||||
fixture deliberately leaves nodes where a forgotten clause would pick them
|
||||
up: A kept playing past the fork (A4, A5), B kept playing past its own
|
||||
(B6), and a second adventure holds a whole story of its own. None of these
|
||||
nodes may appear on C.
|
||||
|
||||
python -m pytest tests/test_branch_clause.py -v
|
||||
"""
|
||||
@@ -46,8 +48,9 @@ from tools import dbmeter
|
||||
def make_branch(db, adventure, parent=None, fork_depth=None):
|
||||
"""A branch row whose lineage is its parent's, capped, plus itself.
|
||||
|
||||
The same computation SP5 will do at fork time; written out here so the
|
||||
fixture cannot pass by agreeing with a bug in the code under test.
|
||||
This function performs the same computation SP5 does at fork time. It
|
||||
is written out here so the fixture cannot pass by repeating a bug in
|
||||
the code under test.
|
||||
"""
|
||||
branch = models.Branch(
|
||||
adventure_id=adventure.id,
|
||||
@@ -89,7 +92,7 @@ def make_adventure(db, user, title):
|
||||
|
||||
@pytest.fixture()
|
||||
def forked():
|
||||
"""The worked example, plus everything a forgotten clause would sweep up."""
|
||||
"""The worked example, plus everything a forgotten clause would expose."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="branch@example.com")
|
||||
@@ -158,9 +161,10 @@ def test_the_worked_example_reads_back_as_the_design_doc_says(forked):
|
||||
def test_a_siblings_nodes_are_invisible(forked):
|
||||
db, adventure, _ = forked
|
||||
seen = labels(history.story_actions(adventure))
|
||||
# A4/A5 are A's own continuation past B's fork; B6 is B's past C's.
|
||||
# A4 and A5 are A's own continuation past B's fork. B6 is B's own
|
||||
# continuation past C's fork.
|
||||
assert "A4" not in seen and "A5" not in seen and "B6" not in seen
|
||||
# And nothing from the adventure next door.
|
||||
# Nothing from the other adventure appears either.
|
||||
assert not [text for text in seen if text.startswith("X")]
|
||||
|
||||
|
||||
@@ -213,11 +217,12 @@ def test_a_window_that_reaches_past_the_fork_still_reads_in_order(forked):
|
||||
# ------------------------------------------- the loaded-collection short cut
|
||||
|
||||
def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
|
||||
"""`history._from_memory`'s shortcut, which was the highest-risk line here.
|
||||
"""Tests `history._from_memory`'s shortcut, the highest-risk line here.
|
||||
|
||||
`adventure.actions` is every branch's actions. Slicing it without the path
|
||||
would assemble a prompt out of two different stories, and nothing would
|
||||
raise — so load it deliberately and check the answer is the path anyway.
|
||||
`adventure.actions` returns every branch's actions. Slicing it without
|
||||
the path would assemble a prompt out of two different stories, and
|
||||
nothing would raise an error. This test loads the collection
|
||||
deliberately and checks that the answer is still the path.
|
||||
"""
|
||||
db, adventure, _ = forked
|
||||
loaded = list(adventure.actions) # every branch, ordered by index
|
||||
@@ -230,8 +235,8 @@ def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
|
||||
|
||||
|
||||
def test_user_scripts_are_handed_the_path(forked):
|
||||
"""The same trap, one layer up and user-visible: `pipeline._history()` is
|
||||
the documented scripting history API."""
|
||||
"""The same risk one layer up, in code visible to users:
|
||||
`pipeline._history()` is the documented scripting history API."""
|
||||
db, adventure, _ = forked
|
||||
list(adventure.actions) # the pipeline's caller has usually loaded these
|
||||
pipeline = ScriptPipeline(adventure, db)
|
||||
@@ -268,8 +273,8 @@ def test_the_page_the_reader_opens_is_the_path(client):
|
||||
assert [a["text"] for a in body["actions"]] == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
# `action_count` is what tells the reader there is more above, so it counts
|
||||
# the path too — 8, not the 13 rows the adventure holds.
|
||||
# `action_count` tells the reader whether more actions exist above. It
|
||||
# counts the path too: 8, not the 13 rows the adventure holds.
|
||||
assert body["action_count"] == 8
|
||||
|
||||
|
||||
@@ -324,11 +329,11 @@ def test_the_index_screen_quotes_the_branch_being_played(client):
|
||||
# ------------------------------------------------------- the flush guard
|
||||
|
||||
def test_a_node_written_without_a_branch_is_placed_anyway(forked):
|
||||
"""SP1 wired the writers; from SP2 an unplaced node is an invisible one.
|
||||
"""SP1 wired the writers. Since SP2, an unplaced node is an invisible one.
|
||||
|
||||
This is what lets a fixture, a script or a test built straight through the
|
||||
ORM keep working — and it is why the baseline contract still passes with
|
||||
its actions written directly to the database.
|
||||
This behavior lets a fixture, a script, or a test built straight through
|
||||
the ORM keep working. It is also why the baseline contract still passes
|
||||
with its actions written directly to the database.
|
||||
"""
|
||||
db, adventure, ids = forked
|
||||
written = models.Action(
|
||||
@@ -356,11 +361,11 @@ def test_a_memory_written_without_a_branch_is_placed_anyway(forked):
|
||||
def test_placing_a_flush_of_nodes_reads_the_branch_once(forked, emitted_sql):
|
||||
"""The guard resolves the head once per flush, not once per node.
|
||||
|
||||
The identity map holds weak references, so a branch row nobody keeps a
|
||||
strong reference to is collected between two nodes and read back for the
|
||||
next one. Writing two hundred actions in one flush was two hundred SELECTs
|
||||
on `branches` before the head was hoisted out of the loop, and nothing
|
||||
about the result would have told you.
|
||||
The identity map holds weak references. A branch row with no strong
|
||||
reference gets collected between two nodes and read back again for the
|
||||
next one. Writing two hundred actions in one flush ran two hundred
|
||||
SELECTs on `branches` before the head lookup moved outside the loop.
|
||||
The test result alone would not have shown this.
|
||||
"""
|
||||
db, adventure, _ = forked
|
||||
emitted_sql.clear()
|
||||
@@ -376,10 +381,11 @@ def test_placing_a_flush_of_nodes_reads_the_branch_once(forked, emitted_sql):
|
||||
|
||||
|
||||
def test_an_adventure_with_no_branch_at_all_reads_as_empty(forked):
|
||||
"""The loud version of a missing branch: nothing, rather than everything.
|
||||
"""A missing branch must fail loudly: nothing, rather than everything.
|
||||
|
||||
A row with no branch cannot be shown without guessing which story it is
|
||||
on, and a guess here is how a sibling's turns end up in a prompt.
|
||||
A row with no branch cannot be shown without guessing which story it
|
||||
belongs to, and a wrong guess here puts a sibling's turns into a
|
||||
prompt.
|
||||
"""
|
||||
db, adventure, ids = forked
|
||||
stray = make_adventure(db, db.get(models.User, ids["user"]), "Stray")
|
||||
@@ -400,9 +406,9 @@ def test_an_adventure_with_no_branch_at_all_reads_as_empty(forked):
|
||||
def deeply_forked():
|
||||
"""A story forked twenty times, then played forty turns past the last one.
|
||||
|
||||
The shape the design is betting on: reading the tail of this must cost what
|
||||
reading the tail of an unforked story costs, because the window is covered
|
||||
long before the ancestry runs out.
|
||||
This shape tests the design's core assumption: reading the tail of this
|
||||
story must cost the same as reading the tail of an unforked story,
|
||||
because the window is covered long before the ancestry runs out.
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
@@ -477,7 +483,7 @@ def test_a_window_reaching_past_the_forks_names_only_what_it_needs(
|
||||
rows = history.tail(adventure, 41) # 40 on the tip branch, one older
|
||||
assert len(rows) == 41
|
||||
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
|
||||
# Two lineage entries reach 41 deep (40 + 2); the other twenty stay
|
||||
# Two lineage entries reach 41 deep (40 + 2). The other twenty stay
|
||||
# unnamed. Every fork past the window costs the query nothing.
|
||||
assert max(branch_terms(s) for s in selects) == 2
|
||||
|
||||
@@ -495,13 +501,13 @@ def test_the_estimate_is_arithmetic_not_a_query(deeply_forked):
|
||||
def test_forking_twenty_times_costs_the_same_bytes_as_never_forking(
|
||||
deeply_forked,
|
||||
):
|
||||
"""The design's bet, in bytes.
|
||||
"""The design's cost assumption, measured in bytes.
|
||||
|
||||
Two stories of the same length, one played straight through and one forked
|
||||
twenty times, read their newest window for the same money — because the
|
||||
window is covered by the newest lineage entry either way, and the ancestry
|
||||
is never named. The forked read pays for one extra row: the branch it read
|
||||
the lineage off.
|
||||
Two stories of the same length, one played straight through and one
|
||||
forked twenty times, cost the same to read their newest window. The
|
||||
window is covered by the newest lineage entry either way, and the
|
||||
ancestry is never named. The forked read pays for one extra row: the
|
||||
branch it reads the lineage from.
|
||||
"""
|
||||
db, forked_adventure = deeply_forked
|
||||
flat = make_adventure(db, db.get(models.User, forked_adventure.user_id), "Flat")
|
||||
@@ -511,10 +517,10 @@ def test_forking_twenty_times_costs_the_same_bytes_as_never_forking(
|
||||
flat.head_branch_id = branch.id
|
||||
flat.head_depth = 83
|
||||
flat_id, forked_id = flat.id, forked_adventure.id
|
||||
# Commit and let go of the connection: the meter wraps the pool's factory,
|
||||
# so a connection checked out before it attaches is a connection it never
|
||||
# sees. Building the fixture is a write path nobody plays, and is not
|
||||
# charged to either scope.
|
||||
# Commit and release the connection. The meter wraps the connection
|
||||
# pool's factory, so a connection checked out before the meter attaches
|
||||
# is never visible to it. Building the fixture is a write path the test
|
||||
# does not measure, and it is not charged to either scope.
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
@@ -541,8 +547,8 @@ def test_forking_twenty_times_costs_the_same_bytes_as_never_forking(
|
||||
def test_a_gap_in_the_story_widens_the_read_rather_than_shortening_it(
|
||||
deeply_forked, emitted_sql
|
||||
):
|
||||
"""The estimate counts depths, and a deleted action leaves a depth with no
|
||||
row behind it. The read has to notice it came up short and widen."""
|
||||
"""The estimate counts depths, and a deleted action leaves a depth with
|
||||
no row behind it. The read must notice it came up short and widen."""
|
||||
db, adventure = deeply_forked
|
||||
victim = (
|
||||
db.query(models.Action)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Phase 14 SP5 — continuing from a discarded attempt forks a branch.
|
||||
"""Phase 14 SP5: continuing from a discarded attempt forks a branch.
|
||||
|
||||
SP4 made every attempt at a turn a node. While the attempts sit at the tip they
|
||||
are leaves and cost nothing: switching between them just moves the `live` flag.
|
||||
The moment the player takes the story down one the line has already moved past,
|
||||
the two futures have to coexist — and that is a branch.
|
||||
SP4 made every attempt at a turn a node. While the attempts sit at the
|
||||
tip, they are leaves and cost nothing: switching between them just moves
|
||||
the `live` flag. The moment the player continues from an attempt the line
|
||||
has already moved past, the two futures must coexist. That is a branch.
|
||||
|
||||
What this file is really watching is the claim the whole design rests on: **a
|
||||
fork inserts one row and moves one row, whatever the story behind it is worth.**
|
||||
Everything before the fork is borrowed, not copied, and the arithmetic that
|
||||
makes borrowing readable is `lineage`.
|
||||
This file tests the claim the whole design rests on: a fork inserts one
|
||||
row and moves one row, no matter how large the story behind it is.
|
||||
Everything before the fork is borrowed, not copied, and the arithmetic
|
||||
that makes borrowing possible lives in `lineage`.
|
||||
|
||||
python -m pytest tests/test_branch_forking.py -v
|
||||
"""
|
||||
@@ -32,8 +32,8 @@ from app.main import app
|
||||
from app.providers import PromptParts
|
||||
from app.routers import adventures
|
||||
|
||||
# `hp` moves freely; `mana` has a cooldown of 2 turns, so a clock that advances
|
||||
# when it should not shows up as a change the referee should have rejected.
|
||||
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
|
||||
# advance shows up as a change the referee should have rejected.
|
||||
SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
@@ -164,12 +164,12 @@ def _rows(adv_id) -> list[models.Action]:
|
||||
|
||||
|
||||
def _divergent_story(client):
|
||||
"""A story that retried turn 2, continued from the newer take, and left the
|
||||
older one behind as a leaf.
|
||||
"""A story that retried turn 2, continued from the newer take, and left
|
||||
the older one behind as a leaf.
|
||||
|
||||
start · do · [attempt one | ATTEMPT TWO] · do · next turn
|
||||
start > do > [attempt one | ATTEMPT TWO] > do > next turn
|
||||
|
||||
Returns the id of the attempt nobody built on.
|
||||
Returns the id of the discarded attempt.
|
||||
"""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
||||
_play(client)
|
||||
@@ -194,9 +194,10 @@ def test_a_fork_inserts_one_branch_row_and_copies_no_actions(client):
|
||||
forked = [b for b in branches if b["parent_branch_id"] is not None][0]
|
||||
assert forked["is_head"] is True
|
||||
assert forked["own_actions"] == 1, "the promoted attempt, and nothing else"
|
||||
# The fork point is the depth just before the attempt, stored rather than
|
||||
# inferred: inferring it from where two branches first differ would be a
|
||||
# guess, and a wrong one as soon as an attempt repeats its parent's text.
|
||||
# The fork point is the depth just before the attempt. The code stores
|
||||
# this value instead of inferring it from where two branches first
|
||||
# differ, because that inference would guess wrong as soon as an
|
||||
# attempt repeats its parent's text.
|
||||
assert forked["fork_depth"] == forked["depth"] - 1
|
||||
|
||||
|
||||
@@ -225,7 +226,7 @@ def test_both_branches_read_independently(client):
|
||||
assert _texts(client) == [
|
||||
"You enter a cave.", "> You look around.", "Attempt one.",
|
||||
]
|
||||
# And the line it left is exactly as it was, turns after the fork included.
|
||||
# The branch it left behind is unchanged, including turns after the fork.
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _texts(client) == [
|
||||
@@ -235,8 +236,9 @@ def test_both_branches_read_independently(client):
|
||||
|
||||
|
||||
def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
|
||||
"""Promoting the loser must not leave the parent with a hole in its story:
|
||||
a coordinate with no live node is a turn that disappears from the read."""
|
||||
"""Promoting the other attempt must not leave the parent with a gap in
|
||||
its story. A coordinate with no live node is a turn that disappears
|
||||
from the read."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
|
||||
@@ -251,8 +253,8 @@ def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
|
||||
for (branch_id, depth), group in per_coordinate.items():
|
||||
live = [a for a in group if a.live]
|
||||
assert len(live) == 1, f"branch {branch_id} depth {depth}"
|
||||
# The parent's turn 2 is now a single take, so the pager stops offering
|
||||
# a page through attempts that have gone their own way.
|
||||
# The parent's turn 2 is now a single take, so the pager no longer
|
||||
# offers a page through attempts that diverged onto another branch.
|
||||
parent_turn = per_coordinate[(parent_id, 2)]
|
||||
assert len(parent_turn) == 1
|
||||
assert parent_turn[0].variant_count == 0
|
||||
@@ -261,9 +263,10 @@ def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
|
||||
|
||||
|
||||
def test_playing_on_a_fork_continues_that_branchs_depths(client):
|
||||
"""A depth is a position along *this* story. Numbering the next node from
|
||||
the adventure-wide index would leave a hole where the other branch's turns
|
||||
are, which every windowing estimate then has to work around."""
|
||||
"""A depth is a position along this story. Numbering the next node from
|
||||
the adventure-wide index would leave a gap where the other branch's
|
||||
turns are, and every windowing estimate would then have to work around
|
||||
that gap."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
ScriptedProvider.replies = ["Onward."]
|
||||
@@ -283,8 +286,8 @@ def test_playing_on_a_fork_continues_that_branchs_depths(client):
|
||||
# ------------------------------------------------------------- not a fork
|
||||
|
||||
def test_forking_at_the_tip_switches_without_making_a_branch(client):
|
||||
"""Attempts nobody has built on stay leaves — that is what keeps the
|
||||
lineage a list of divergences rather than of every retry ever."""
|
||||
"""Attempts nobody has built on stay leaves. This is what keeps the
|
||||
lineage a list of divergences instead of a list of every retry."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
@@ -297,8 +300,8 @@ def test_forking_at_the_tip_switches_without_making_a_branch(client):
|
||||
|
||||
|
||||
def test_forking_the_attempt_already_in_the_story_does_nothing(client):
|
||||
"""Idempotent, because a client that has lost track of which take is live
|
||||
must not be able to fork a branch per click."""
|
||||
"""This call is idempotent. A client that has lost track of which take
|
||||
is live must not create a new branch on every click."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
promoted = [a.id for a in _rows(client.adv_id) if a.type == "ai" and a.live
|
||||
@@ -323,10 +326,11 @@ def test_forking_a_turn_that_is_already_the_story_is_a_no_op(client):
|
||||
|
||||
|
||||
def test_forking_a_live_node_on_another_branch_is_refused(client):
|
||||
"""A live node off the path is another line's story, not an attempt going
|
||||
spare — so the refusal names the tool that would actually do it. It used to
|
||||
answer "only one take", which was true of the group and no help at all: the
|
||||
caller does not want another take, it wants the branch this one is on."""
|
||||
"""A live node off the path belongs to another branch's story, not to a
|
||||
spare attempt on this one. The refusal names the tool that actually
|
||||
switches branches. It used to answer "only one take", which was true
|
||||
of the attempt group but useless here: the caller does not want
|
||||
another take, it wants the branch this node is on."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
parent_id = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
|
||||
@@ -336,8 +340,8 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
|
||||
r = _fork(client, stranded)
|
||||
assert r.status_code == 400
|
||||
assert "another branch" in r.json()["detail"]
|
||||
# And refusing left the tree alone — the bug this guards is a fork that
|
||||
# promotes a sibling on the branch it was called against.
|
||||
# Refusing must leave the tree alone. The bug this guards against is a
|
||||
# fork that promotes a sibling on the branch it was called against.
|
||||
assert len(_branches(client)) == 2
|
||||
|
||||
|
||||
@@ -366,10 +370,10 @@ def test_switching_restores_the_script_and_world_state(client):
|
||||
|
||||
|
||||
def test_the_cooldown_clock_travels_with_the_branch(client):
|
||||
"""The world-state clock is a depth, and depths repeat across branches — so
|
||||
it can only be right if each branch carries its own. It does, for free: the
|
||||
clock lives inside `_meta.last_changed`, which is part of the world state a
|
||||
switch restores."""
|
||||
"""The world-state clock is a depth, and depths repeat across branches,
|
||||
so it can only be correct if each branch carries its own. It does,
|
||||
without extra work: the clock lives inside `_meta.last_changed`, which
|
||||
is part of the world state a switch restores."""
|
||||
ScriptedProvider.replies = [
|
||||
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
||||
"Untouched.",
|
||||
@@ -393,9 +397,9 @@ def test_the_cooldown_clock_travels_with_the_branch(client):
|
||||
|
||||
|
||||
def test_a_retry_does_not_advance_the_cooldown_clock(client):
|
||||
"""SP5's one carried-over open item. A retry re-runs the *same* turn, so
|
||||
the clock the cooldown rules read must not move — it used to be the reused
|
||||
`index` that guaranteed this, and it is the reused depth now."""
|
||||
"""SP5's one carried-over open item. A retry re-runs the same turn, so
|
||||
the clock the cooldown rules read must not move. The reused `index`
|
||||
used to guarantee this; the reused depth guarantees it now."""
|
||||
ScriptedProvider.replies = [
|
||||
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
||||
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
|
||||
@@ -404,18 +408,19 @@ def test_a_retry_does_not_advance_the_cooldown_clock(client):
|
||||
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
|
||||
_retry(client)
|
||||
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
|
||||
# ...and the second attempt's drain landed, rather than being rejected for
|
||||
# a cooldown it should never have been measured against.
|
||||
# The second attempt's drain must land, instead of being rejected for a
|
||||
# cooldown it was never actually subject to.
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40
|
||||
|
||||
|
||||
# --------------------------------------------------------- derived work
|
||||
|
||||
def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
||||
"""Nothing is moved or withdrawn when a branch forks. The memory hangs off
|
||||
the coordinate the *parent's* attempt still occupies, and the lineage caps
|
||||
the parent one depth short of it — so the fork simply cannot see it, and
|
||||
resummarizes that ground from the text it actually tells."""
|
||||
"""Nothing is moved or removed when a branch forks. The memory attaches
|
||||
to the coordinate the parent's attempt still occupies, and the lineage
|
||||
caps the parent one depth short of it. The fork therefore cannot see
|
||||
this memory, and resummarizes that span from the text it actually
|
||||
contains."""
|
||||
from app import tree
|
||||
|
||||
discarded = _divergent_story(client)
|
||||
@@ -441,8 +446,8 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
# Still there, untouched — it describes the parent's story, which is
|
||||
# unchanged.
|
||||
# The memory is still there, untouched, because it describes the
|
||||
# parent's story, which is unchanged.
|
||||
assert [m.text for m in db.query(models.Memory).all()] == ["Attempt two happened."]
|
||||
path = lineage.path_of(db, adventure)
|
||||
visible = db.query(models.Memory).filter(
|
||||
@@ -450,8 +455,8 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
||||
path.clause(models.Memory),
|
||||
).all()
|
||||
assert visible == [], "a sibling's memory reached this branch"
|
||||
# ...and the mark reads as one depth short of it, so the block is due
|
||||
# again on this branch rather than silently claimed as read.
|
||||
# The cursor reads one depth short of the memory, so this branch
|
||||
# treats the block as due again instead of silently marking it read.
|
||||
assert cursors.MEMORY.depth(db, adventure) == 1
|
||||
finally:
|
||||
db.close()
|
||||
@@ -460,20 +465,22 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
||||
# ------------------------------------------------------------------- undo
|
||||
|
||||
def test_undo_stops_at_the_fork(client):
|
||||
"""Taking back a turn on a fork must never reach across into the line it
|
||||
was forked from — those turns are that branch's story too."""
|
||||
"""Undoing a turn on a fork must never reach into the branch it forked
|
||||
from. Those turns belong to that branch's story too."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
rows_before = len(_rows(client.adv_id))
|
||||
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
# The promoted attempt goes, and the player action in front of it stays:
|
||||
# it is on the parent, and the parent still tells it.
|
||||
# The promoted attempt is removed, and the player action before it
|
||||
# stays, because that action belongs to the parent and the parent
|
||||
# still has it.
|
||||
assert len(_rows(client.adv_id)) == rows_before - 1
|
||||
assert _texts(client) == ["You enter a cave.", "> You look around."]
|
||||
|
||||
# Nothing left of this branch's own: refuse rather than eat the parent's.
|
||||
# Nothing is left of this branch's own turns, so undo must refuse
|
||||
# instead of removing the parent's turns.
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 400
|
||||
assert "forked from" in r.json()["detail"]
|
||||
@@ -497,10 +504,11 @@ def test_the_branch_list_is_the_tree(client):
|
||||
|
||||
|
||||
def test_fork_agrees_with_the_lineage_computed_by_hand(client):
|
||||
"""`test_branch_clause.make_branch` has computed a fork's lineage since SP2,
|
||||
by hand, precisely so the fixture could not pass by agreeing with a bug in
|
||||
the code under test. SP5 is when that code exists — so check the two
|
||||
against each other rather than letting them drift apart."""
|
||||
"""`test_branch_clause.make_branch` has computed a fork's lineage by
|
||||
hand since SP2, precisely so the fixture could not pass by repeating a
|
||||
bug in the code under test. SP5 introduces that code, so this test
|
||||
checks the two against each other instead of letting them drift
|
||||
apart."""
|
||||
from tests.test_branch_clause import make_branch
|
||||
|
||||
discarded = _divergent_story(client)
|
||||
@@ -512,8 +520,8 @@ def test_fork_agrees_with_the_lineage_computed_by_hand(client):
|
||||
real = lineage.branch_of(db, adventure)
|
||||
parent = db.get(models.Branch, real.parent_branch_id)
|
||||
by_hand = make_branch(db, adventure, parent=parent, fork_depth=real.fork_depth)
|
||||
# Same shape, its own id: compare the ancestry, which is the part that
|
||||
# is arithmetic rather than allocation.
|
||||
# The two branches share a shape but not an id, so compare only the
|
||||
# ancestry, which is the arithmetic part rather than the allocated id.
|
||||
assert lineage.entries_of(real)[1:] == lineage.entries_of(by_hand)[1:]
|
||||
assert lineage.entries_of(real)[0] == (real.id, None)
|
||||
finally:
|
||||
@@ -521,9 +529,9 @@ def test_fork_agrees_with_the_lineage_computed_by_hand(client):
|
||||
|
||||
|
||||
def test_a_deep_fork_chain_reads_for_what_one_branch_costs(client):
|
||||
"""Clause count is bounded by the window, not by fork count — the property
|
||||
the whole lineage cache exists for, now measured through real forks rather
|
||||
than hand-built rows."""
|
||||
"""Clause count is bounded by the window, not by fork count. This is
|
||||
the property the whole lineage cache exists for, now measured through
|
||||
real forks instead of hand-built rows."""
|
||||
from tools import dbmeter
|
||||
|
||||
ScriptedProvider.replies = ["First take.", "Second take.", "Onward."]
|
||||
@@ -555,9 +563,9 @@ def test_a_deep_fork_chain_reads_for_what_one_branch_costs(client):
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
entries = lineage.entries_of(lineage.branch_of(db, adventure))
|
||||
# The whole ancestry is there to be named...
|
||||
# The whole ancestry is available to be named.
|
||||
assert len(entries) == len(branches)
|
||||
# ...and the windowed read names as few of them as the window needs.
|
||||
# The windowed read names as few of them as the window needs.
|
||||
path = lineage.path_of(db, adventure)
|
||||
assert path.prefix_covering(60) <= len(entries)
|
||||
finally:
|
||||
@@ -565,8 +573,8 @@ def test_a_deep_fork_chain_reads_for_what_one_branch_costs(client):
|
||||
|
||||
|
||||
def test_switching_to_a_branch_of_another_adventure_is_a_404(client):
|
||||
"""A branch id names one adventure, so the two ids in the URL have to
|
||||
agree — otherwise a guessed number reads somebody else's story."""
|
||||
"""A branch id names one adventure, so the two ids in the URL must
|
||||
agree. Otherwise a guessed number reads somebody else's story."""
|
||||
other = client.post("/api/adventures", json={"title": "Elsewhere"}).json()["id"]
|
||||
ScriptedProvider.replies = ["Elsewhere."]
|
||||
r = client.post(f"/api/adventures/{other}/actions", json={"type": "do", "text": "wait"})
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""Phase 14 SP7 — naming a branch, and throwing one away.
|
||||
"""Phase 14 SP7: renaming a branch and deleting one.
|
||||
|
||||
SP5 gave the tree a fork and a switch. Neither of them ever removes anything,
|
||||
and nothing in the design prunes a tree on its own, so an adventure that is
|
||||
retried and forked enough grows without a ceiling. Delete is what stands
|
||||
between the tree and that, which is why it ships with the view that first makes
|
||||
a fork reachable rather than in some later subphase.
|
||||
SP5 gave the tree a fork and a switch. Neither operation removes anything,
|
||||
and nothing in the design prunes a tree automatically, so an adventure that
|
||||
is retried and forked enough grows without a limit. Delete is what limits
|
||||
that growth. It ships with the same view that first makes a fork reachable,
|
||||
rather than in a later subphase.
|
||||
|
||||
Two rules carry most of this file:
|
||||
|
||||
* **A name is chosen, so it is stored; a label is derived, so it is not.** An
|
||||
unnamed branch keeps NULL and the client draws it from its fork depth. A
|
||||
generated "branch 4" in the column would be a lie the moment branch 3 is
|
||||
deleted.
|
||||
* **The delete may never take the ground under the reader.** Refusing the head
|
||||
is the obvious half; refusing an *ancestor* of the head is the same mistake
|
||||
wearing a disguise, and it is the one that would leave `head_branch_id`
|
||||
pointing at a row the cascade removed.
|
||||
* A name is chosen, so the database stores it. A label is derived, so the
|
||||
database does not. An unnamed branch keeps NULL, and the client draws the
|
||||
label from its fork depth. A generated label such as "branch 4" stored in
|
||||
the column would become wrong the moment branch 3 is deleted.
|
||||
* Delete must never remove a branch the reader depends on. Refusing to
|
||||
delete the head is the obvious case. Refusing to delete an ancestor of the
|
||||
head is the same rule applied one level up: deleting it would leave
|
||||
`head_branch_id` pointing at a row the cascade removed.
|
||||
|
||||
python -m pytest tests/test_branch_management.py -v
|
||||
"""
|
||||
@@ -136,7 +136,7 @@ def _texts(client) -> list[str]:
|
||||
|
||||
|
||||
def _discarded_on(adv_id, branch_id=None) -> int:
|
||||
"""An AI attempt nobody built on, optionally restricted to one branch."""
|
||||
"""An AI attempt with no turn built on it, optionally restricted to one branch."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(models.Action).filter(
|
||||
@@ -152,7 +152,7 @@ def _discarded_on(adv_id, branch_id=None) -> int:
|
||||
|
||||
|
||||
def _forked(client):
|
||||
"""A story with one fork. Returns (root id, forked id); the fork is head.
|
||||
"""A story with one fork. Returns (root id, forked id). The fork is head.
|
||||
|
||||
start · do · [attempt one | ATTEMPT TWO] · do · next turn
|
||||
└── forked here
|
||||
@@ -184,10 +184,10 @@ def _counts(adv_id, branch_ids):
|
||||
# ------------------------------------------------------------------ naming
|
||||
|
||||
def test_a_branch_starts_unnamed(client):
|
||||
"""NULL, not a generated label — the client draws one from the fork depth.
|
||||
"""NULL, not a generated label. The client draws a label from the fork depth.
|
||||
|
||||
A name written here would go stale the moment a branch before it is
|
||||
deleted and the ordinals shift under it.
|
||||
A name stored here would go stale the moment a branch before it is
|
||||
deleted and the ordinals shift.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
assert [b["name"] for b in _branches(client)] == [None, None]
|
||||
@@ -206,9 +206,8 @@ def test_a_name_is_stored_and_read_back(client):
|
||||
def test_a_blank_name_goes_back_to_unnamed(client):
|
||||
"""A name of spaces is not a name anyone chose.
|
||||
|
||||
Storing one would give the client an empty label to draw where it would
|
||||
otherwise fall back to the fork depth — a branch that looks nameless and
|
||||
reads as broken.
|
||||
Storing it would give the client an empty label instead of falling back
|
||||
to the fork depth. The branch would look nameless and appear broken.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
_rename(client, forked, "briefly named")
|
||||
@@ -226,9 +225,10 @@ def test_a_name_longer_than_the_column_is_refused(client):
|
||||
def test_a_rename_hands_back_the_row_the_listing_would_give(client):
|
||||
"""Renaming a branch does not change how many turns are on it.
|
||||
|
||||
`own_actions` was hard-coded to 0 in this response, which only stayed
|
||||
invisible because the panel throws the body away and refetches. Anything
|
||||
that trusted the reply would draw a branch that had just lost its turns.
|
||||
`own_actions` was hard-coded to 0 in this response. That bug stayed
|
||||
invisible because the panel discards the response body and refetches.
|
||||
Anything that trusted the reply would show a branch that had just lost
|
||||
its turns.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
listed = {b["id"]: b for b in _branches(client)}
|
||||
@@ -278,10 +278,11 @@ def test_the_branch_being_read_cannot_be_deleted(client):
|
||||
|
||||
|
||||
def test_an_ancestor_of_the_branch_being_read_cannot_be_deleted(client):
|
||||
"""The same mistake as deleting the head, wearing a disguise.
|
||||
"""Deleting an ancestor of the head is the same mistake as deleting the head.
|
||||
|
||||
`parent_branch_id` cascades, so deleting a branch the head was forked from
|
||||
would take the head with it and leave `head_branch_id` pointing at nothing.
|
||||
`parent_branch_id` cascades, so deleting a branch the head was forked
|
||||
from would delete the head too, and leave `head_branch_id` pointing at
|
||||
nothing.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
# A fork of the fork, so `forked` is an ancestor of the head rather than
|
||||
@@ -310,7 +311,8 @@ def test_deleting_a_branch_leaves_the_line_it_forked_from_untouched(client):
|
||||
|
||||
|
||||
def test_deleting_a_branch_takes_its_nodes_and_its_descendants(client):
|
||||
"""One statement, however deep the subtree — the cascade does the walking."""
|
||||
"""One statement deletes the whole subtree, regardless of depth. The
|
||||
cascade performs the traversal."""
|
||||
root, forked = _forked(client)
|
||||
_retry(client)
|
||||
_play(client, "press on")
|
||||
@@ -333,10 +335,11 @@ def test_deleting_a_branch_takes_its_nodes_and_its_descendants(client):
|
||||
def test_deleting_a_branch_clears_a_cursor_that_stood_on_it(client):
|
||||
"""Harmless on Postgres, a real bug on SQLite.
|
||||
|
||||
Postgres never reuses a branch id, so a stale anchor simply never resolves.
|
||||
SQLite hands the freed id to the next fork, at which point the anchor
|
||||
resolves onto a branch it has never seen and reports a stretch of story as
|
||||
already summarized — which loses it from the memories for good.
|
||||
Postgres never reuses a branch id, so a stale anchor simply never
|
||||
resolves. SQLite assigns the freed id to the next fork. The anchor then
|
||||
resolves onto a branch it never saw, and reports a stretch of story as
|
||||
already summarized. That stretch is then permanently excluded from the
|
||||
memories.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
db = SessionLocal()
|
||||
@@ -355,7 +358,7 @@ def test_deleting_a_branch_clears_a_cursor_that_stood_on_it(client):
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
assert cursors.MEMORY.stored(adventure) == (None, cursors.NO_DEPTH)
|
||||
# The one standing on ground that survived is left exactly where it was.
|
||||
# The cursor on a branch that still exists is left exactly where it was.
|
||||
assert cursors.SUMMARY.stored(adventure) == (root, 1)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -398,11 +401,11 @@ def test_a_hand_written_memory_is_anchored_where_it_was_written(client):
|
||||
|
||||
|
||||
def test_the_drawer_shows_the_path_being_read_and_nothing_else(client):
|
||||
"""The bank you can see is the bank the model can see.
|
||||
"""The memories a reader can see match the memories the model can retrieve.
|
||||
|
||||
An adventure-wide list would show memories from branches this story never
|
||||
went down — which are never retrieved — and a reader cannot tell those from
|
||||
the ones actually in play.
|
||||
An adventure-wide list would include memories from branches this story
|
||||
never took. Those memories are never retrieved, and a reader could not
|
||||
distinguish them from the ones actually in play.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
on_the_fork = _add_memory(client, "Took the other door.")
|
||||
@@ -412,9 +415,10 @@ def test_the_drawer_shows_the_path_being_read_and_nothing_else(client):
|
||||
assert {m["id"] for m in _memories(client)} == {on_the_root}, \
|
||||
"the fork's memory is not on this story"
|
||||
|
||||
# Switching to the fork shows its own memory — and the root's, because a
|
||||
# fork borrows its ancestors up to the point it left them. The relationship
|
||||
# is asymmetric on purpose; the parent never went down the fork.
|
||||
# Switching to the fork shows its own memory and the root's. A fork
|
||||
# borrows its ancestors up to the point where it diverged from them.
|
||||
# The relationship is asymmetric on purpose: the parent never took the
|
||||
# fork's path.
|
||||
_switch(client, forked)
|
||||
listed = {m["id"] for m in _memories(client)}
|
||||
assert on_the_fork in listed
|
||||
@@ -422,8 +426,9 @@ def test_the_drawer_shows_the_path_being_read_and_nothing_else(client):
|
||||
|
||||
|
||||
def test_the_drawer_and_retrieval_agree_on_what_is_visible(client):
|
||||
"""One predicate, so a memory can never be listed but unretrievable (or the
|
||||
reverse). Two spellings of "on this path" would eventually drift."""
|
||||
"""One predicate decides visibility, so a memory can never be listed but
|
||||
unretrievable, or the reverse. Two separate definitions of "on this
|
||||
path" would eventually diverge."""
|
||||
root, forked = _forked(client)
|
||||
_add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
@@ -447,10 +452,11 @@ def test_the_drawer_and_retrieval_agree_on_what_is_visible(client):
|
||||
|
||||
|
||||
def test_deleting_a_branch_deletes_the_memories_written_on_it(client):
|
||||
"""Not merely out of view — the row goes with the branch, through the
|
||||
cascade. That is what keeps "the drawer shows only your path" from
|
||||
stranding anything: a memory you cannot see is on a branch you can still
|
||||
switch to, and deleting that branch takes it for good."""
|
||||
"""Deleting a branch removes its memories from the database, not just
|
||||
from view: the cascade deletes the row along with the branch. This
|
||||
keeps a hidden memory from becoming unreachable in a different way: a
|
||||
memory you cannot currently see is on a branch you can still switch to,
|
||||
and deleting that branch deletes the memory permanently."""
|
||||
root, forked = _forked(client)
|
||||
doomed = _add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
@@ -473,10 +479,12 @@ def test_deleting_a_branch_deletes_the_memories_written_on_it(client):
|
||||
# ------------------------------------------------------------------- backup
|
||||
|
||||
def test_a_bundle_carries_the_name_a_player_chose(client):
|
||||
"""A name is a decision, so it travels — the rule the v2 format is built on.
|
||||
"""A name is a decision, so the export includes it. This is the rule the
|
||||
v2 format is built on.
|
||||
|
||||
`lineage` and the head depth stay out because they are computed from what
|
||||
the file already carries; a name is computed from nothing.
|
||||
`lineage` and the head depth stay out of the export because the
|
||||
importer can compute them from what the file already carries. A name
|
||||
is computed from nothing, so the export must carry it.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
_rename(client, root, "the long way")
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
"""Phase 14 SP6 — the export bundle carries the tree.
|
||||
"""Phase 14 SP6: the export bundle carries the tree.
|
||||
|
||||
A bundle is the only part of this phase a migration can never reach: the file
|
||||
is already on somebody's disk. So there are two formats, and the two halves of
|
||||
this file watch different things.
|
||||
A bundle is the only part of this phase a migration can never reach: the
|
||||
file already exists on somebody's disk. So there are two formats, and the
|
||||
two halves of this file check different properties.
|
||||
|
||||
**v2 has to be lossless for a story that forked**, which v1 could not be — it
|
||||
had one list and there were two stories, so it interleaved them by `index` and
|
||||
read as a mangled story. Losslessness here means the *tree*: every branch, the
|
||||
fork point it left its parent at, which attempt at each turn is the story, and
|
||||
what each node left behind — because that last one is what a branch switch puts
|
||||
back, and a tree nobody can switch inside is not the tree that was exported.
|
||||
v2 must be lossless for a story that forked. v1 could not be, because it
|
||||
stored one list for two stories, interleaved by `index`, which read back
|
||||
as a mangled story. Losslessness here means the tree: every branch, the
|
||||
fork point it left on its parent, which attempt at each turn is the
|
||||
story, and what each node left behind. That last item is what a branch
|
||||
switch restores, and a tree nobody can switch inside is not the tree
|
||||
that was exported.
|
||||
|
||||
**v1 has to still import**, because a backup that stops importing is not a
|
||||
v1 must still import, because a backup that stops importing is not a
|
||||
backup.
|
||||
|
||||
Underneath both is the rule the module is built on: a bundle carries what was
|
||||
*chosen* and never what is *derived*. The lineage, the head depth, the legacy
|
||||
`index` and the variant ordinals are all rebuilt on the way in, so a
|
||||
hand-edited file cannot disagree with itself — and the tests that matter most
|
||||
here are the ones that hand it a file which does.
|
||||
Both formats follow one rule: a bundle carries what was chosen and never
|
||||
what is derived. The lineage, the head depth, the legacy `index`, and the
|
||||
variant ordinals are all rebuilt on the way in, so a hand-edited file
|
||||
cannot disagree with itself. The tests that matter most here hand the
|
||||
importer a file that does disagree with itself.
|
||||
|
||||
python -m pytest tests/test_bundle_v2.py -v
|
||||
"""
|
||||
@@ -44,8 +45,8 @@ from app.routers import adventures
|
||||
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
|
||||
# Ten gold a turn, so the script scoreboard is a number that says how many turns
|
||||
# the story behind it has — which makes an after-snapshot visible from outside.
|
||||
# Ten gold a turn, so the stored gold total tells how many turns the
|
||||
# story behind it played. This makes an after-snapshot visible from outside.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
@@ -161,7 +162,7 @@ def _texts(client, adv_id) -> list[str]:
|
||||
|
||||
|
||||
def _every_branch_story(client, adv_id) -> list[list[str]]:
|
||||
"""What each branch tells, in branch order — the whole tree as text."""
|
||||
"""What each branch tells, in branch order. This is the whole tree as text."""
|
||||
stories = []
|
||||
for branch in _branches(client, adv_id):
|
||||
_switch(client, adv_id, branch["id"])
|
||||
@@ -213,13 +214,14 @@ def _script_state(adv_id) -> dict:
|
||||
|
||||
|
||||
def _forked_story(client) -> int:
|
||||
"""A story that went two ways, and stayed both.
|
||||
"""A story that went two ways and stayed both.
|
||||
|
||||
root: start · do · [attempt two] · do · next turn
|
||||
fork: [ATTEMPT ONE] · do · elsewhere
|
||||
root: start > do > [attempt two] > do > next turn
|
||||
fork: [ATTEMPT ONE] > do > elsewhere
|
||||
|
||||
The *discarded* attempt is the one that gets promoted, because a fork moves
|
||||
the take you are leaving for and leaves the line you came from untouched.
|
||||
The discarded attempt is the one this function promotes, because a
|
||||
fork moves the attempt being left for and leaves the line it came
|
||||
from untouched.
|
||||
|
||||
Returns the adventure id, with the head on the fork.
|
||||
"""
|
||||
@@ -237,11 +239,11 @@ def _forked_story(client) -> int:
|
||||
# ------------------------------------------------------- the round trip (v2)
|
||||
|
||||
def test_a_forked_story_survives_the_round_trip(client):
|
||||
"""The headline: both futures come back, and both are still readable.
|
||||
"""The main claim: both futures come back, and both are still readable.
|
||||
|
||||
This is the thing v1 could not do. The check is not "the same rows" — the
|
||||
ids are new — but "the same stories", read the way a player reads them: by
|
||||
switching to a branch and looking at what it says.
|
||||
This is the thing v1 could not do. The check is not "the same rows",
|
||||
since the ids are new, but "the same stories", read the way a player
|
||||
reads them: by switching to a branch and looking at what it says.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
before = _every_branch_story(client, original)
|
||||
@@ -254,11 +256,11 @@ def test_a_forked_story_survives_the_round_trip(client):
|
||||
|
||||
|
||||
def test_the_fork_point_comes_back_where_it_was_put(client):
|
||||
"""`fork_depth` is stored, never inferred — including through a file.
|
||||
"""`fork_depth` is stored, never inferred, including through a file.
|
||||
|
||||
Inferring it from where two branches' nodes first differ would be a guess
|
||||
about how the story was played, and a wrong one the moment an attempt
|
||||
happens to repeat its parent's text.
|
||||
Inferring it from where two branches' nodes first differ would guess
|
||||
at how the story was played, and that guess fails as soon as an
|
||||
attempt happens to repeat its parent's text.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
before = [(b["parent_branch_id"] is None, b["fork_depth"]) for b in _branches(client, original)]
|
||||
@@ -275,22 +277,23 @@ def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
|
||||
|
||||
copy = _imported(client, _export(client, original))
|
||||
assert [b["is_head"] for b in _branches(client, copy)] == head_before
|
||||
# And the tip it sits at is derived from the nodes that arrived, not read
|
||||
# out of the file — the bundle never says how deep a branch goes.
|
||||
# The tip it sits at is derived from the nodes that arrived, not
|
||||
# read from the file. The bundle never states how deep a branch goes.
|
||||
assert _texts(client, copy) == _texts(client, original)
|
||||
|
||||
|
||||
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
|
||||
"""The after-snapshots are why the bundle carries them.
|
||||
"""This test justifies why the bundle carries after-snapshots.
|
||||
|
||||
The gold script adds ten a turn, so the scoreboard is a count of the story
|
||||
behind it. A bundle that carried the actions but not the outcomes would
|
||||
import a tree that reads correctly and switches wrong.
|
||||
The gold script adds ten a turn, so the stored gold total counts the
|
||||
turns behind it. A bundle that carried the actions but not the
|
||||
outcomes would import a tree that reads correctly but switches to the
|
||||
wrong state.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
# One more turn on the fork, so the two tips are genuinely different
|
||||
# numbers: played turn for turn, the branches earn the same gold and a
|
||||
# switch that restored nothing at all would still look right.
|
||||
# Play one more turn on the fork, so the two tips end up at
|
||||
# genuinely different totals. Turn for turn, both branches earn the
|
||||
# same gold, so a switch that restored nothing would still look right.
|
||||
ScriptedProvider.replies = ["Further still."]
|
||||
_play(client, original, "press on")
|
||||
|
||||
@@ -340,12 +343,12 @@ def test_a_memory_comes_back_on_the_node_it_hangs_off(client):
|
||||
# --------------------------------------------------- what is not in the file
|
||||
|
||||
def test_the_lineage_is_rebuilt_rather_than_carried(client):
|
||||
"""A cache of `parent` + `fork_depth` is not a second thing to ship.
|
||||
"""A cache of `parent` plus `fork_depth` is not a second thing to ship.
|
||||
|
||||
The file says where each branch forked; the ancestry that makes the fork
|
||||
readable is computed from that on the way in, capped at the fork exactly as
|
||||
`tree.fork` caps it. Shipping the cache too would put two sources of truth
|
||||
for one fact in a file anybody can hand-edit.
|
||||
The file states where each branch forked. The ancestry that makes the
|
||||
fork readable is computed from that value on the way in, capped at
|
||||
the fork exactly as `tree.fork` caps it. Shipping the cache too would
|
||||
put two sources of truth for one fact in a file anybody can hand-edit.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
exported = _export(client, original)
|
||||
@@ -354,18 +357,19 @@ def test_the_lineage_is_rebuilt_rather_than_carried(client):
|
||||
root, forked = _branch_rows(_imported(client, exported))
|
||||
assert root.lineage == [[root.id, None]]
|
||||
assert forked.lineage == [[forked.id, None], [root.id, forked.fork_depth]]
|
||||
# Which is the arithmetic the reader depends on: the parent is capped one
|
||||
# depth short of the attempt that was promoted, so the fork cannot see it.
|
||||
# This is the arithmetic the reader depends on. The parent is capped
|
||||
# one depth short of the attempt that was promoted, so the fork
|
||||
# cannot see it.
|
||||
assert lineage.entries_of(forked) == [(forked.id, None), (root.id, forked.fork_depth)]
|
||||
|
||||
|
||||
def test_the_legacy_index_is_reissued_so_two_branches_never_share_one(client):
|
||||
"""`index` is a fact about the adventure, and `depth` is one about a path.
|
||||
"""`index` describes the adventure. `depth` describes a path.
|
||||
|
||||
Two branches have a node at depth 3, so depth cannot be the number
|
||||
`max_action_index` hands out next. The import allocates one per turn
|
||||
instead: siblings share it, the way SP4 leaves them, and no two coordinates
|
||||
do.
|
||||
Two branches can each have a node at depth 3, so depth cannot be the
|
||||
number `max_action_index` hands out next. The import allocates one
|
||||
index per turn instead. Siblings share an index, the way SP4 leaves
|
||||
them, and no two coordinates share one.
|
||||
"""
|
||||
copy = _imported(client, _export(client, _forked_story(client)))
|
||||
rows = _rows(copy)
|
||||
@@ -376,8 +380,9 @@ def test_the_legacy_index_is_reissued_so_two_branches_never_share_one(client):
|
||||
"one index per turn, whatever branch it is on"
|
||||
assert len(by_index) == len({(r.branch_id, r.depth) for r in rows})
|
||||
|
||||
# The case that makes the rule necessary: the fork and the line it left
|
||||
# both hold a turn at depth 2, and they are not the same turn.
|
||||
# This is the case that makes the rule necessary: the fork and the
|
||||
# branch it left both hold a turn at depth 2, and they are not the
|
||||
# same turn.
|
||||
at_depth_2 = [r for r in rows if r.depth == 2]
|
||||
assert len({r.branch_id for r in at_depth_2}) == 2
|
||||
assert len({r.index for r in at_depth_2}) == 2, "same depth, different turns"
|
||||
@@ -386,8 +391,9 @@ def test_the_legacy_index_is_reissued_so_two_branches_never_share_one(client):
|
||||
# ------------------------------------------------------- a file that is wrong
|
||||
|
||||
def test_a_node_naming_a_branch_the_file_does_not_list_is_refused(client):
|
||||
"""Refused, not half-applied. A tree missing a branch is a story that
|
||||
silently stops, which is the failure this whole phase exists to end."""
|
||||
"""The import must refuse this file rather than half-apply it. A tree
|
||||
missing a branch is a story that silently stops, which is the failure
|
||||
this whole phase exists to prevent."""
|
||||
payload = _export(client, _forked_story(client))
|
||||
payload["branches"] = payload["branches"][:1]
|
||||
before = _adventure_count()
|
||||
@@ -399,8 +405,9 @@ def test_a_node_naming_a_branch_the_file_does_not_list_is_refused(client):
|
||||
|
||||
|
||||
def test_a_branch_forking_from_one_listed_after_it_is_refused(client):
|
||||
"""The ordering rule buys acyclicity for the price of a comparison — and a
|
||||
cycle would be an import that never returns rather than one that fails."""
|
||||
"""The ordering rule guarantees no cycles at the cost of one
|
||||
comparison. Without it, a cycle would produce an import that never
|
||||
returns instead of one that fails cleanly."""
|
||||
payload = _export(client, _forked_story(client))
|
||||
payload["branches"] = [{"parent": 1, "forkDepth": 0}, {"parent": None, "forkDepth": None}]
|
||||
before = _adventure_count()
|
||||
@@ -439,8 +446,9 @@ def test_more_branches_than_the_cap_is_refused(client, monkeypatch):
|
||||
def test_a_turn_the_file_gives_no_live_attempt_still_tells_one(client):
|
||||
"""A coordinate with nothing live is a turn no read can see.
|
||||
|
||||
The file is allowed to be wrong about this — it is a text file — so the
|
||||
import picks the first attempt rather than importing a story with a hole.
|
||||
The file is allowed to be wrong about this, since it is a text file
|
||||
someone can edit. The import picks the first attempt instead of
|
||||
importing a story with a gap.
|
||||
"""
|
||||
payload = _export(client, _forked_story(client))
|
||||
for node in payload["actions"]:
|
||||
@@ -456,7 +464,8 @@ def test_a_turn_the_file_gives_no_live_attempt_still_tells_one(client):
|
||||
# ------------------------------------------------------------- the v1 reader
|
||||
|
||||
def test_a_v1_bundle_still_imports(client):
|
||||
"""The reader stays after the writer goes: those files are already saved."""
|
||||
"""The v1 reader must remain even after the v1 writer is gone, because
|
||||
those files already exist and are saved."""
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT,
|
||||
"title": "Old backup",
|
||||
@@ -474,8 +483,8 @@ def test_a_v1_bundle_still_imports(client):
|
||||
copy = _imported(client, payload)
|
||||
|
||||
assert _texts(client, copy) == [OPENING, "> You go north.", "Two."]
|
||||
# One branch, and the `variants` array split back into the sibling group it
|
||||
# always described.
|
||||
# The import produces one branch, and the `variants` array splits
|
||||
# back into the sibling group it always described.
|
||||
assert len(_branches(client, copy)) == 1
|
||||
ai = [r for r in _rows(copy) if r.type == "ai"]
|
||||
assert [(r.text, r.live) for r in ai] == [("One.", False), ("Two.", True)]
|
||||
@@ -483,8 +492,9 @@ def test_a_v1_bundle_still_imports(client):
|
||||
|
||||
|
||||
def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client):
|
||||
"""v1 counts covered actions; the tree anchors them. The translation needs
|
||||
the nodes to exist, so it happens after they are written."""
|
||||
"""v1 counts covered actions. The tree anchors them to a node instead.
|
||||
The translation needs the nodes to exist first, so it runs after they
|
||||
are written."""
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
|
||||
"memoryCursor": 2, "summaryCursor": 2,
|
||||
@@ -507,7 +517,8 @@ def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client):
|
||||
|
||||
|
||||
def test_a_v2_bundle_brings_its_anchors_back(client):
|
||||
"""The other direction: v2 carries the anchor and the count is read off it."""
|
||||
"""The other direction: v2 carries the anchor directly, and the legacy
|
||||
count is derived from it."""
|
||||
original = _forked_story(client)
|
||||
tip = [a for a in _rows(original) if a.live][-1]
|
||||
db = SessionLocal()
|
||||
@@ -535,13 +546,14 @@ def test_a_v2_bundle_brings_its_anchors_back(client):
|
||||
|
||||
|
||||
def test_a_v1_memory_that_summarises_nothing_lands_on_the_root(client):
|
||||
"""The import has to answer the question migration 62 answered.
|
||||
"""The import must answer the question migration 62 already answered.
|
||||
|
||||
A v1 file has no depths, and a memory the player typed has no `sourceEnd`
|
||||
to derive one from — so it used to come back with a NULL depth, which is the
|
||||
exact state SP7 removed from the schema. `Path._entry_clause` compares
|
||||
`depth <= max_depth` and a NULL fails it, so the memory would read fine
|
||||
until the imported adventure was forked and then vanish from the new branch.
|
||||
A v1 file has no depths, and a memory the player typed has no
|
||||
`sourceEnd` to derive one from. It used to come back with a NULL
|
||||
depth, the exact state SP7 removed from the schema.
|
||||
`Path._entry_clause` compares `depth <= max_depth`, and a NULL fails
|
||||
that comparison. The memory would read fine until the imported
|
||||
adventure forked, then vanish from the new branch.
|
||||
"""
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
|
||||
@@ -572,12 +584,13 @@ def test_a_v1_memory_that_summarises_nothing_lands_on_the_root(client):
|
||||
|
||||
|
||||
def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypatch):
|
||||
"""The cap has to count what gets written, not what the file lists.
|
||||
"""The cap must count what gets written, not what the file lists.
|
||||
|
||||
A v1 turn carries its retries in a `variants` array, and SP4 made every
|
||||
attempt a row — so one entry can become ten. Counting entries lets a file
|
||||
inside the cap write a multiple of it, and the body limit is no help: the
|
||||
text is tiny, it is the row count that is the cost.
|
||||
A v1 turn carries its retries in a `variants` array, and SP4 made
|
||||
every attempt a row, so one entry can expand into ten. Counting
|
||||
entries instead of rows would let a file inside the cap write a
|
||||
multiple of it. The body-size limit does not help here: the text is
|
||||
tiny, and the row count is the actual cost.
|
||||
"""
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
|
||||
|
||||
+23
-18
@@ -1,7 +1,8 @@
|
||||
"""HTTP tests for the AI Chat scratchpad (power users only).
|
||||
|
||||
Covers the access gate, the streamed reply, and the demo-key model pinning —
|
||||
the part that must not let a public visitor reach paid models through this page.
|
||||
Covers the access gate, the streamed reply, and the demo-key model pinning.
|
||||
This pinning must not let a public visitor reach paid models through this
|
||||
page.
|
||||
|
||||
python -m pytest tests/test_chat.py -v
|
||||
"""
|
||||
@@ -59,13 +60,14 @@ def client(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(chat, "OpenAICompatibleProvider", FakeProvider)
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
# Multi-user mode is what makes the power-user gate meaningful (local mode
|
||||
# trusts everyone); the allowlist is set per-test.
|
||||
# Multi-user mode is what makes the power-user gate meaningful, because
|
||||
# local mode trusts everyone. The allowlist is set per test.
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(auth, "POWER_USERS", {"power@example.com"})
|
||||
# These tests deliberately do NOT stub resolve_provider_config: the point is
|
||||
# to exercise the real BYOK-vs-demo decision, since that is what keeps the
|
||||
# shared key off paid models. Each test picks a mode with _byok/_demo below.
|
||||
# These tests deliberately do not stub resolve_provider_config. The
|
||||
# point is to exercise the real BYOK-vs-demo decision, since that
|
||||
# decision is what keeps the shared key off paid models. Each test
|
||||
# picks a mode with _byok/_demo below.
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
@@ -152,8 +154,9 @@ def test_demo_key_pins_model_to_whitelist(client, monkeypatch):
|
||||
|
||||
|
||||
def test_demo_key_ignores_an_off_whitelist_settings_model(client, monkeypatch):
|
||||
"""The override isn't the only untrusted input — Settings.model is user-set
|
||||
too, and it must be pinned the same way when there's no BYOK key."""
|
||||
"""The override is not the only untrusted input. `Settings.model` is
|
||||
also user-set, and it must be pinned the same way when there is no
|
||||
BYOK key."""
|
||||
_demo(monkeypatch)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -167,8 +170,8 @@ def test_demo_key_ignores_an_off_whitelist_settings_model(client, monkeypatch):
|
||||
|
||||
|
||||
def test_demo_key_endpoint_cannot_be_redirected(client, monkeypatch):
|
||||
"""A user-controlled endpoint_url would leak the key itself, which is worse
|
||||
than spending it — the demo branch pins the URL too."""
|
||||
"""A user-controlled `endpoint_url` would leak the key itself, which is
|
||||
worse than spending it. The demo branch pins the URL too."""
|
||||
_demo(monkeypatch)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -183,8 +186,8 @@ def test_demo_key_endpoint_cannot_be_redirected(client, monkeypatch):
|
||||
|
||||
def test_provider_config_refuses_server_funded_paid_model(monkeypatch):
|
||||
"""The structural backstop: a hand-built config (a future code path that
|
||||
forgets to go through resolve_provider_config) can't run a server-funded
|
||||
turn on an off-whitelist model."""
|
||||
forgets to go through resolve_provider_config) cannot run a
|
||||
server-funded turn on an off-whitelist model."""
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
|
||||
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
|
||||
with pytest.raises(ValueError):
|
||||
@@ -196,9 +199,10 @@ def test_provider_config_refuses_server_funded_paid_model(monkeypatch):
|
||||
|
||||
def test_byok_user_may_reuse_the_demo_keys_value(client, monkeypatch):
|
||||
"""Regression: the demo key is just an OpenRouter key, so a user can paste
|
||||
that same value into their own Settings. That's BYOK — they're paying — and
|
||||
it must not trip the guard. It used to raise on every resolution, which
|
||||
500'd GET /auth/me and took the whole SPA down (no nav, no chat)."""
|
||||
that same value into their own Settings. That is still BYOK, because
|
||||
the user is paying, and it must not trip the guard. It used to raise
|
||||
on every resolution, which returned a 500 from `GET /auth/me` and
|
||||
broke the entire SPA (no nav, no chat)."""
|
||||
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "shared-key")
|
||||
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
|
||||
@@ -221,14 +225,15 @@ def test_byok_user_may_reuse_the_demo_keys_value(client, monkeypatch):
|
||||
|
||||
|
||||
def test_resolve_provider_config_is_the_single_choke_point(monkeypatch):
|
||||
"""Turns, AI Chat and the connection test all resolve through this one
|
||||
"""Turns, AI Chat, and the connection test all resolve through this one
|
||||
function, so pinning it here pins every caller. No DB or HTTP needed."""
|
||||
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
|
||||
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
|
||||
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
|
||||
|
||||
# No key of their own: endpoint AND model are pinned, whatever they set.
|
||||
# No key of their own: both endpoint and model are pinned, regardless
|
||||
# of what they set.
|
||||
no_key = models.Settings(endpoint_url="http://mine/v1", api_key="", model="expensive/paid")
|
||||
assert auth.resolve_provider_config(no_key) == auth.ProviderConfig(
|
||||
"http://demo", "demo-key", "free/allowed", True)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Guards on how much the database is asked for.
|
||||
|
||||
context_snapshot holds the entire assembled prompt for a turn — 163 KB a row
|
||||
averaged over production, 232 KB on the longest adventure, and 89% of the
|
||||
database. It used to be pulled for every action on every adventure load and
|
||||
every turn, to read two tiny things out of it. These tests fail if that
|
||||
regresses.
|
||||
`context_snapshot` holds the entire assembled prompt for a turn. That is 163 kB
|
||||
per row averaged over production, 232 kB on the longest adventure, and 89% of the
|
||||
database. It used to be fetched for every action on every adventure load and
|
||||
every turn, to read two small values out of it. These tests fail if that
|
||||
returns.
|
||||
|
||||
Two kinds of guard live here, and both are needed:
|
||||
|
||||
* **column guards** assert which columns a statement names. That is the shape
|
||||
both of this project's egress blowouts took — one query quietly carrying a
|
||||
column nobody read.
|
||||
* **byte ceilings** assert what a request actually costs. Every column guard
|
||||
would still pass if a response grew tenfold within the columns it is allowed
|
||||
to read, which is what a story that keeps getting longer does.
|
||||
* Column guards assert which columns a statement names. That is the shape both
|
||||
of this project's egress regressions took: one query carrying a column nobody
|
||||
read.
|
||||
* Byte ceilings assert what a request costs. Every column guard would still pass
|
||||
if a response grew tenfold within the columns it is allowed to read, which is
|
||||
what a story that keeps getting longer does.
|
||||
|
||||
python -m pytest tests/test_egress.py -v
|
||||
"""
|
||||
@@ -46,10 +46,10 @@ from tools.fakeprose import prose
|
||||
# column enormous, plus the small world_state slice the UI actually needs.
|
||||
#
|
||||
# Varied text, not `"x" * 20_000`. The column is stored compressed now
|
||||
# (migration 43), and a repeated character compresses about a thousandfold —
|
||||
# which would make the byte ceilings below pass against a fixture that costs
|
||||
# nothing, testing nothing. Prose-shaped filler compresses like the prompts
|
||||
# this stands in for.
|
||||
# by migration 43, and a repeated character compresses about a thousandfold.
|
||||
# That would make the byte ceilings below pass against a fixture that costs
|
||||
# nothing, which tests nothing. Prose-shaped filler compresses like the prompts
|
||||
# it stands in for.
|
||||
_SNAPSHOT_RNG = random.Random(20_260_817)
|
||||
BIG_SNAPSHOT = {
|
||||
"system": prose(_SNAPSHOT_RNG, 20_000),
|
||||
@@ -149,8 +149,8 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
|
||||
"""All four are rollback snapshots, only ever needed for the single node
|
||||
being undone, retried past or switched to.
|
||||
|
||||
The `_after` pair is the live one since SP4 and the `_before` pair is dead
|
||||
weight until SP8 drops it — a page load must pay for neither.
|
||||
The `_after` pair is the live one since SP4, and the `_before` pair is
|
||||
unused until SP8 drops it. A page load must pay for neither.
|
||||
"""
|
||||
client.get(f"/api/adventures/{client.adv_id}")
|
||||
selects = action_selects(sql_log)
|
||||
@@ -195,8 +195,11 @@ def test_variant_count_survives_variants_being_deferred(client):
|
||||
|
||||
def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log):
|
||||
"""A count that wraps the entity select in a subquery names every column in
|
||||
the emitted SQL — no bytes come back, but the database still reads them and
|
||||
the guard above cannot tell it apart from a real bulk fetch."""
|
||||
the emitted SQL.
|
||||
|
||||
No bytes come back, but the database still reads them, and the guard above
|
||||
cannot distinguish that from a real bulk fetch.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
@@ -214,7 +217,8 @@ def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log):
|
||||
|
||||
|
||||
def test_snapshot_is_still_reachable_on_demand(client):
|
||||
"""Deferred means lazy, not gone — Insights still gets the full thing."""
|
||||
"""Deferred means lazy rather than absent. Insights still gets the whole
|
||||
snapshot."""
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
action_id = r.json()["actions"][0]["id"]
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/actions/{action_id}/context")
|
||||
@@ -231,9 +235,9 @@ def as_json_snapshot_column(db) -> None:
|
||||
|
||||
Migration 36 lifts world_delta out of the snapshot with SQL JSON
|
||||
functions, so it can only run while the column still *is* JSON. In a real
|
||||
upgrade it always is — 36 runs seven migrations before 43 compresses the
|
||||
column into a BLOB — but `create_all` builds today's schema, so a test
|
||||
calling that backfill has to rebuild the schema it was written against.
|
||||
upgrade it always is, because 36 runs seven migrations before 43 compresses
|
||||
the column into a BLOB. `create_all` builds today's schema, so a test that
|
||||
calls that backfill has to rebuild the schema it was written against.
|
||||
"""
|
||||
db.execute(text("ALTER TABLE actions DROP COLUMN context_snapshot"))
|
||||
db.execute(text("ALTER TABLE actions ADD COLUMN context_snapshot JSON"))
|
||||
@@ -269,9 +273,11 @@ def test_backfill_populates_world_delta_from_existing_snapshots(client):
|
||||
|
||||
|
||||
def test_backfill_populates_variant_count_from_existing_variants(client):
|
||||
"""Migration 37 counts the lists server-side — reading them into Python to
|
||||
count them would mean pulling the column across the wire once to stop
|
||||
pulling it across forever."""
|
||||
"""Migration 37 counts the lists on the server.
|
||||
|
||||
Reading them into Python to count them would fetch the column over the wire
|
||||
once in order to stop fetching it on every request.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(text("UPDATE actions SET variant_count = 0"))
|
||||
@@ -304,20 +310,20 @@ def test_backfill_leaves_actions_without_world_state_alone(client):
|
||||
|
||||
# ---------------------------------------------------------------- byte ceilings
|
||||
#
|
||||
# The tests above assert which *columns* a statement names, which is the shape
|
||||
# both of this project's egress blowouts took. They would all still pass if a
|
||||
# response quietly grew tenfold within the columns it is allowed to read — and
|
||||
# a story that keeps getting longer does exactly that. These put a number on it.
|
||||
# The tests above assert which columns a statement names, which is the shape both
|
||||
# of this project's egress regressions took. They would all still pass if a
|
||||
# response grew tenfold within the columns it is allowed to read, and a story
|
||||
# that keeps getting longer does that. These tests put a number on it.
|
||||
#
|
||||
# Ceilings are per action rather than absolute, so they mean the same thing
|
||||
# whatever size the fixture is set to, and they are generous: the point is to
|
||||
# catch a tenfold regression, not to freeze today's byte count.
|
||||
# The ceilings are per action rather than absolute, so they mean the same thing
|
||||
# whatever size the fixture is, and they are generous. They exist to catch a
|
||||
# tenfold regression rather than to freeze today's byte count.
|
||||
|
||||
ACTIONS_IN_FIXTURE = 12
|
||||
|
||||
# 3 kB an action against a real 994 B, measured on production 2026-08-17.
|
||||
# Anything that pulls a deferred column blows past this by two orders of
|
||||
# magnitude — see test_the_ceiling_discriminates below.
|
||||
# magnitude. See `test_the_ceiling_discriminates` below.
|
||||
PAGE_LOAD_BYTES_PER_ACTION = 3_000
|
||||
|
||||
|
||||
@@ -325,8 +331,9 @@ PAGE_LOAD_BYTES_PER_ACTION = 3_000
|
||||
def meter():
|
||||
"""A byte meter on the shared engine, removed again afterwards.
|
||||
|
||||
Requested *after* `client` in a test's arguments so that building the
|
||||
fixture — a write path nobody plays — is not charged to any scope.
|
||||
A test requests this after `client` in its arguments, so that building the
|
||||
fixture, which is a write path no player takes, is not charged to any
|
||||
scope.
|
||||
"""
|
||||
m = dbmeter.Meter()
|
||||
m.attach(engine)
|
||||
@@ -364,8 +371,8 @@ def test_the_action_list_stays_under_its_byte_ceiling(client, meter):
|
||||
|
||||
|
||||
def test_reading_one_action_does_not_cost_the_whole_story(client, meter):
|
||||
"""The snapshot is reachable on demand, and that request should pay for
|
||||
one row's worth — not the adventure's."""
|
||||
"""The snapshot is reachable on demand, and that request pays for one row
|
||||
rather than for the whole adventure."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
action_id = db.query(models.Action.id).order_by(models.Action.id).first()[0]
|
||||
@@ -459,10 +466,10 @@ def test_the_ceiling_discriminates(client, meter):
|
||||
budget. If this ever stops exceeding it, the fixture has gone too small for
|
||||
the tests above to mean anything.
|
||||
|
||||
The margin used to be a hundredfold and is now about six. That is not the
|
||||
guard weakening — it is migration 43 compressing the column, and the
|
||||
fixture text being prose-shaped so it compresses like a real prompt rather
|
||||
than like a repeated character.
|
||||
The margin used to be a hundredfold and is now about six. The guard has not
|
||||
weakened. Migration 43 compresses the column, and the fixture text is
|
||||
prose-shaped, so it compresses like a real prompt rather than like a repeated
|
||||
character.
|
||||
"""
|
||||
budget = ACTIONS_IN_FIXTURE * PAGE_LOAD_BYTES_PER_ACTION
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Migration 38: embeddings move from a JSON list to packed float32.
|
||||
|
||||
The conversion has to be exact, because nothing re-embeds — a memory whose
|
||||
vector shifts is silently ranked wrong forever, with no error anywhere to say
|
||||
so. So these tests check the numbers survive the round trip bit for bit, and
|
||||
that the migration reaches every row however many there are.
|
||||
The conversion has to be exact, because nothing re-embeds. A memory whose
|
||||
vector shifts is silently ranked wrong forever, with no error anywhere to
|
||||
report it. These tests check that the numbers survive the round trip bit
|
||||
for bit, and that the migration reaches every row, however many there are.
|
||||
|
||||
python -m pytest tests/test_embedding_blob.py -v
|
||||
"""
|
||||
@@ -29,8 +29,8 @@ from tests import schema_rewind
|
||||
|
||||
|
||||
def float32(value: float) -> float:
|
||||
"""`value` as the double nearest to its float32 truncation — what an
|
||||
embedding endpoint's JSON actually holds."""
|
||||
"""`value` as the double nearest to its float32 truncation. This is
|
||||
what an embedding endpoint's JSON actually holds."""
|
||||
return struct.unpack("<f", struct.pack("<f", value))[0]
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ def test_pack_round_trips_exactly():
|
||||
|
||||
|
||||
def test_packed_vector_is_four_bytes_per_dimension():
|
||||
"""The whole point: 1536 dims is 6 KB here against ~31 KB as JSON."""
|
||||
"""1536 dims takes 6 KB packed, against about 31 KB as JSON."""
|
||||
vector = sample_vector(random.Random(2))
|
||||
blob = vectors.pack(vector)
|
||||
assert len(blob) == 1536 * 4
|
||||
@@ -84,9 +84,9 @@ def test_pack_handles_the_extremes():
|
||||
|
||||
|
||||
def test_unpack_returns_a_compact_array():
|
||||
"""These are held in memory between turns, so the container matters: an
|
||||
array("f") is the 4 bytes a component the column is, a list of Python
|
||||
floats is eight times that."""
|
||||
"""These vectors stay in memory between turns, so the container type
|
||||
matters. An array("f") stores each component in 4 bytes, the same width
|
||||
as the column. A list of Python floats uses eight times that."""
|
||||
vector = sample_vector(random.Random(9))
|
||||
unpacked = vectors.unpack(vectors.pack(vector))
|
||||
assert unpacked.typecode == "f"
|
||||
@@ -95,15 +95,16 @@ def test_unpack_returns_a_compact_array():
|
||||
|
||||
|
||||
def test_pack_rounds_a_value_float32_cannot_hold():
|
||||
"""The guarantee is exactness for vectors that came from an embedding
|
||||
model, which computes in float32 — not for arbitrary doubles. Worth
|
||||
pinning down, because it is the line the round-trip claim sits on."""
|
||||
"""The exactness guarantee applies only to vectors that came from an
|
||||
embedding model, which computes in float32, not to arbitrary doubles.
|
||||
This test pins down that boundary, because it is where the round-trip
|
||||
claim holds."""
|
||||
assert vectors.unpack(vectors.pack([1e-38]))[0] != 1e-38
|
||||
assert vectors.unpack(vectors.pack([1e-38]))[0] == pytest.approx(1e-38)
|
||||
|
||||
|
||||
def test_cosine_moved_but_still_reachable_from_memorybank():
|
||||
"""Callers import it from memorybank; the maths lives in vectors."""
|
||||
"""Callers import it from memorybank. The math lives in vectors."""
|
||||
assert memorybank.cosine is vectors.cosine
|
||||
assert vectors.cosine([1.0, 0.0], [1.0, 0.0]) == pytest.approx(1.0)
|
||||
assert vectors.cosine([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0)
|
||||
@@ -149,11 +150,11 @@ def test_set_vector_none_clears_both(db, adventure):
|
||||
def add_legacy_json_column(db) -> None:
|
||||
"""Put `memories.embedding` back for the length of a test.
|
||||
|
||||
Migration 42 dropped it and the model no longer declares it, so
|
||||
`create_all` does not produce it — but everything below is testing the
|
||||
upgrade *from* a database that still has it, which is the only state in
|
||||
which the backfill has any work to do. Re-adding it by hand is what keeps
|
||||
these tests honest about the schema they claim to be starting from.
|
||||
Migration 42 dropped it, and the model no longer declares it, so
|
||||
`create_all` does not produce it. Everything below tests the upgrade
|
||||
from a database that still has the column, which is the only state
|
||||
where the backfill has any work to do. Re-adding it by hand keeps these
|
||||
tests honest about the schema they claim to start from.
|
||||
"""
|
||||
db.execute(text("ALTER TABLE memories ADD COLUMN embedding JSON"))
|
||||
db.commit()
|
||||
@@ -193,8 +194,9 @@ def test_backfill_converts_every_existing_vector(db, adventure):
|
||||
|
||||
|
||||
def test_backfill_reaches_past_one_batch(db, adventure):
|
||||
"""It loops on id, and an off-by-one there would silently leave the tail
|
||||
of a big bank unconverted — which reads as "not embedded yet"."""
|
||||
"""It loops on id, and an off-by-one there would silently leave the
|
||||
tail of a big bank unconverted. That failure reads as "not embedded
|
||||
yet"."""
|
||||
count = migrations.BACKFILL_BATCH * 2 + 3
|
||||
expected = seed_json_only(db, adventure, count=count, dims=4)
|
||||
|
||||
@@ -237,8 +239,8 @@ def test_backfill_is_idempotent(db, adventure):
|
||||
|
||||
|
||||
def test_backfill_skips_a_malformed_row_without_stopping(db, adventure):
|
||||
"""One bad row must not strand every row after it — the loop orders by id,
|
||||
so an exception here would leave the rest of the bank unconverted."""
|
||||
"""One bad row must not strand every row after it. The loop orders by
|
||||
id, so an exception here would leave the rest of the bank unconverted."""
|
||||
expected = seed_json_only(db, adventure, count=2)
|
||||
broken = models.Memory(adventure_id=adventure.id, text="broken")
|
||||
db.add(broken)
|
||||
@@ -288,9 +290,9 @@ def test_bootstrap_adds_the_columns_and_backfills_them(db, adventure):
|
||||
# embedded must still read as not embedded afterwards.
|
||||
assert by_id[unembedded_id] == (None, False)
|
||||
|
||||
# ...and migration 42, at the end of the same run, takes the JSON column
|
||||
# away. Ordering matters: 38 reads it, 42 drops it, and an upgrade that
|
||||
# ran them the other way round would arrive with an empty bank.
|
||||
# Migration 42, at the end of the same run, removes the JSON column.
|
||||
# Ordering matters: 38 reads it, 42 drops it, and an upgrade that ran
|
||||
# them in the other order would arrive with an empty bank.
|
||||
with engine.begin() as conn:
|
||||
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(memories)"))}
|
||||
assert "embedding" not in columns
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""Switching embedding models must re-embed the bank.
|
||||
|
||||
Vectors from two different models are not comparable — different space, often
|
||||
different width — so changing the model has to throw the stored ones away and
|
||||
let the post-turn pass rebuild them.
|
||||
Vectors from two different models are not comparable. They live in
|
||||
different spaces and often have different widths. Changing the model must
|
||||
discard the stored vectors and let the post-turn pass rebuild them.
|
||||
|
||||
That worked while the vectors lived in `memories.embedding`: the settings
|
||||
route nulled that column and the embed queue picked the rows up. Migration 38
|
||||
moved the vectors to `embedding_blob` with an `embedded` flag beside them, and
|
||||
the bulk clear kept nulling the old column alone. The blob survived, the flag
|
||||
stayed true, `_embed_pending` (which looks for `embedded IS FALSE`) never saw
|
||||
the rows, and the bank went on ranking against the previous model's vectors
|
||||
for good.
|
||||
This worked while the vectors lived in `memories.embedding`. The settings
|
||||
route nulled that column, and the embed queue picked up the rows. Migration
|
||||
38 moved the vectors to `embedding_blob` and added an `embedded` flag beside
|
||||
them, but the bulk clear kept nulling only the old column. The blob
|
||||
survived, the flag stayed true, and `_embed_pending` (which filters on
|
||||
`embedded IS FALSE`) never saw the rows. The bank kept ranking against the
|
||||
previous model's vectors.
|
||||
|
||||
Nothing reports this. `cosine` returns 0.0 on a width mismatch, so a
|
||||
different-width model scores every memory zero and retrieval quietly returns
|
||||
whichever rows sort first; a same-width model scores plausible-looking
|
||||
garbage.
|
||||
Nothing reports this failure. `cosine` returns 0.0 on a width mismatch, so a
|
||||
different-width model scores every memory zero, and retrieval silently
|
||||
returns whichever rows sort first. A same-width model scores plausible
|
||||
garbage instead.
|
||||
|
||||
python -m pytest tests/test_embedding_model_switch.py -v
|
||||
"""
|
||||
@@ -114,8 +114,9 @@ def test_changing_the_model_clears_every_vector(client):
|
||||
|
||||
|
||||
def test_cleared_memories_are_queued_for_re_embedding(client):
|
||||
"""The flag is not cosmetic: it is the only thing `_embed_pending` filters
|
||||
on, so this is the assertion that the bank actually recovers."""
|
||||
"""The `embedded` flag is not cosmetic. It is the only condition
|
||||
`_embed_pending` filters on, so this test confirms the bank actually
|
||||
recovers."""
|
||||
client.put("/api/settings", json={"embedding_model": "model-b"})
|
||||
|
||||
db = SessionLocal()
|
||||
@@ -155,7 +156,7 @@ def test_retrieval_uses_no_stale_vector_after_the_switch(client, monkeypatch):
|
||||
|
||||
|
||||
def test_an_unrelated_settings_change_keeps_the_vectors(client):
|
||||
"""Only an embedding-model change may clear the bank — re-embedding costs
|
||||
"""Only an embedding-model change may clear the bank. Re-embedding costs
|
||||
an API call per memory."""
|
||||
r = client.put("/api/settings", json={"model": "some-other-chat-model"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Guest retention policy — app/cleanup.py.
|
||||
"""Guest retention policy: app/cleanup.py.
|
||||
|
||||
Covers the two things that matter: that idle guests and their whole data
|
||||
graph actually go, and that nothing else ever does.
|
||||
These tests cover the two things that matter. Idle guests and their whole
|
||||
data graph must actually be deleted, and nothing else must ever be
|
||||
deleted.
|
||||
|
||||
python -m pytest tests/test_guest_cleanup.py -v
|
||||
"""
|
||||
@@ -30,8 +31,8 @@ def db(tmp_path):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk(dbapi_connection, _record):
|
||||
# The whole policy leans on ON DELETE CASCADE; SQLite ignores every
|
||||
# one of them unless this is set (same as database.py does).
|
||||
# The whole policy relies on ON DELETE CASCADE. SQLite ignores every
|
||||
# one of them unless this is set, the same as database.py does.
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.close()
|
||||
@@ -86,7 +87,7 @@ def test_keeps_guest_inside_the_window(db):
|
||||
|
||||
|
||||
def test_boundary_is_not_yet_stale(db):
|
||||
# Exactly 5 days survives; the comparison is strict.
|
||||
# Exactly 5 days survives. The comparison is strict.
|
||||
user = make_user(db, days_idle=cleanup.RETENTION_DAYS)
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
@@ -112,8 +113,9 @@ def test_recent_visit_beats_an_old_created_at(db):
|
||||
# ---------- what must never go ----------
|
||||
|
||||
def test_spares_registered_users(db):
|
||||
"""Registering upgrades the guest row in place, so an idle account here is
|
||||
a real user with real data — the whole point of signing up."""
|
||||
"""Registering upgrades the guest row in place. An idle account here is
|
||||
a real user with real data, which is what signing up is meant to
|
||||
protect."""
|
||||
user = make_user(db, days_idle=400, guest=False, email="a@b.com")
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
@@ -127,7 +129,7 @@ def test_spares_the_local_mode_user(db):
|
||||
|
||||
|
||||
def test_spares_a_guest_flagged_row_that_has_an_email(db):
|
||||
# Shouldn't exist, but both clauses are checked so it can't be collected.
|
||||
# This row should not exist, but both clauses are checked so it cannot be collected.
|
||||
user = make_user(db, days_idle=400, guest=True, email="odd@b.com")
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
@@ -164,10 +166,10 @@ def test_enabled_requires_multi_user(monkeypatch):
|
||||
# ---------- the cascade ----------
|
||||
|
||||
def test_deletes_the_whole_data_graph(db):
|
||||
"""One DELETE has to take the adventure, its actions and memories, the
|
||||
story cards and the settings row with it — nothing is loaded into Python,
|
||||
so if the FK cascade isn't reaching, rows are silently orphaned (or the
|
||||
statement errors) rather than tidied."""
|
||||
"""One DELETE must remove the adventure, its actions and memories, the
|
||||
story cards, and the settings row. Nothing is loaded into Python, so if
|
||||
the FK cascade does not reach a table, its rows are silently orphaned,
|
||||
or the statement fails, instead of being removed."""
|
||||
user = make_user(db, days_idle=30)
|
||||
scenario = models.Scenario(user_id=user.id, title="S")
|
||||
db.add(scenario)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""The context builder reads a window of the story, not all of it.
|
||||
|
||||
Walking `adventure.actions` every turn made a turn cost O(story length), so a
|
||||
long adventure read hundreds of KB to use the tail of it — and the cost grew
|
||||
with every turn played. `app.context.history` serves tails, slices and counts
|
||||
from SQL instead.
|
||||
Walking `adventure.actions` every turn made the turn cost O(story length).
|
||||
A long adventure read hundreds of KB to use only the tail of it, and the
|
||||
cost grew with every turn played. `app.context.history` serves tails,
|
||||
slices, and counts from SQL instead.
|
||||
|
||||
Two things have to hold, and both are easy to break by accident:
|
||||
Two things must hold, and both are easy to break by accident:
|
||||
|
||||
* the window must produce **exactly** the prompt the full story produced, or
|
||||
this is a behaviour change wearing an optimization's clothes;
|
||||
* the helpers must agree with the old list arithmetic, because memorybank's
|
||||
cursors are *positions* in that list and a cursor off by one silently
|
||||
summarizes the wrong actions.
|
||||
* The window must produce exactly the prompt the full story produced.
|
||||
Otherwise, the change alters behavior even though it looks like a pure
|
||||
optimization.
|
||||
* The helpers must agree with the old list arithmetic, because
|
||||
memorybank's cursors are positions in that list. A cursor off by one
|
||||
silently summarizes the wrong actions.
|
||||
|
||||
python -m pytest tests/test_history_window.py -v
|
||||
"""
|
||||
@@ -92,16 +93,16 @@ def story():
|
||||
|
||||
|
||||
def full_window(adventure, budget_tokens, token_counter, exclude_action_id=None):
|
||||
"""Stand-in for window_covering that hands back the entire story, i.e. the
|
||||
behaviour this module replaced."""
|
||||
"""Stand-in for `window_covering` that returns the entire story. This is
|
||||
the behavior this module replaced."""
|
||||
return history.story_actions(adventure, exclude_action_id)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def actions_loaded():
|
||||
"""Counts Action rows the ORM materializes, i.e. how much of the story was
|
||||
actually fetched. rowcount is meaningless for SELECT on SQLite, so count
|
||||
the objects the mapper builds instead."""
|
||||
"""Counts the `Action` rows the ORM materializes, which shows how much of
|
||||
the story was actually fetched. `rowcount` is meaningless for a SELECT
|
||||
on SQLite, so this counts the objects the mapper builds instead."""
|
||||
loaded = {"n": 0}
|
||||
|
||||
def on_load(target, context):
|
||||
@@ -133,7 +134,7 @@ def test_window_builds_the_same_prompt_as_the_whole_story(story, budget, monkeyp
|
||||
|
||||
|
||||
def test_window_matches_on_the_retry_shape(story, monkeypatch):
|
||||
"""Retry excludes the action being regenerated; the exclusion has to reach
|
||||
"""Retry excludes the action being regenerated. The exclusion must reach
|
||||
the window query, not just the in-memory filter."""
|
||||
db, adventure, settings = story
|
||||
last = history.tail(adventure, 1)[0]
|
||||
@@ -148,7 +149,8 @@ def test_window_matches_on_the_retry_shape(story, monkeypatch):
|
||||
|
||||
|
||||
def test_reported_total_is_the_whole_story_not_the_window(story):
|
||||
"""Insights says "N of M actions included"; M must not become the window."""
|
||||
"""Insights reports "N of M actions included." M must not become the
|
||||
window size."""
|
||||
db, adventure, settings = story
|
||||
settings.context_token_budget = 4096
|
||||
report = builder.build_context(adventure, settings)[2]
|
||||
@@ -160,7 +162,7 @@ def test_reported_total_is_the_whole_story_not_the_window(story):
|
||||
|
||||
def test_building_context_reads_far_less_than_the_whole_story(story, actions_loaded):
|
||||
db, adventure, settings = story
|
||||
# Expire first: expiring afterwards would discard the unflushed change and
|
||||
# Expire first. Expiring afterward would discard the unflushed change and
|
||||
# silently put the budget back to its default.
|
||||
db.expire_all()
|
||||
# Small enough that the budget, not the length of the story, decides.
|
||||
@@ -171,9 +173,10 @@ def test_building_context_reads_far_less_than_the_whole_story(story, actions_loa
|
||||
included = report["history"]["included"]
|
||||
|
||||
assert included < ACTION_COUNT, "fixture is too short to prove anything"
|
||||
# The window aims a margin past the budget and re-asks if it fell short, so
|
||||
# it reads somewhat more than it includes. What matters is that the read is
|
||||
# a function of the token budget, not of how long the story has got.
|
||||
# The window targets a margin past the budget and requests more if it
|
||||
# falls short, so it reads somewhat more than it includes. What matters
|
||||
# is that the read depends on the token budget, not on the length of
|
||||
# the story.
|
||||
assert actions_loaded["n"] < ACTION_COUNT // 2, (
|
||||
f"read {actions_loaded['n']} action rows out of {ACTION_COUNT} to "
|
||||
f"include {included} — the window is not bounding the read"
|
||||
@@ -213,13 +216,14 @@ def test_helpers_agree_with_the_full_list(story):
|
||||
|
||||
|
||||
def test_a_depth_boundary_survives_a_middle_action_being_deleted(story):
|
||||
"""The case that has broken the cursors twice before, and the reason they
|
||||
are depths now.
|
||||
"""The case that broke the cursors twice before. This is the reason the
|
||||
cursors are depths now.
|
||||
|
||||
A *position* answers "how much story is past this point?" by counting from
|
||||
the start, so deleting anything in front of the mark changes which action
|
||||
the mark names. A depth names the same node either way — the only thing
|
||||
that changes is the count of what comes after, which is what did change.
|
||||
A position answers "how much story is past this point?" by counting
|
||||
from the start. Deleting anything in front of the mark changes which
|
||||
action the mark names. A depth names the same node either way. The
|
||||
only thing that changes is the count of what comes after, and that
|
||||
count is the one thing that should change here.
|
||||
"""
|
||||
db, adventure, settings = story
|
||||
actions = history.story_actions(adventure)
|
||||
@@ -236,9 +240,9 @@ def test_a_depth_boundary_survives_a_middle_action_being_deleted(story):
|
||||
assert history.count_after(adventure, mark) == before, "the mark moved"
|
||||
assert [a.id for a in history.after(adventure, mark, 3)] == next_three
|
||||
|
||||
# ...and deleting something *after* it is the one thing that does change
|
||||
# the count, because that is a fact about the story rather than about the
|
||||
# coordinate system.
|
||||
# Deleting something after the mark is the one change that does affect
|
||||
# the count, because that count reflects the story, not the coordinate
|
||||
# system.
|
||||
db.delete(history.after(adventure, mark, 1)[0])
|
||||
db.commit()
|
||||
db.expire(adventure)
|
||||
@@ -258,7 +262,7 @@ def test_blank_actions_are_excluded_the_same_way_in_sql_and_python(story):
|
||||
# SQL path (relationship not loaded)
|
||||
from_sql = history.count(adventure)
|
||||
# Python path (relationship loaded)
|
||||
adventure.actions # noqa: B018 — force the collection into memory
|
||||
adventure.actions # noqa: B018 - force the collection into memory
|
||||
from_python = history.count(adventure)
|
||||
|
||||
assert from_sql == from_python == ACTION_COUNT
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"""The turn prompt asks for a turn that fits inside `max_output_tokens`.
|
||||
|
||||
`max_output_tokens` is a hard wall the endpoint enforces mid-sentence. The state
|
||||
block is emitted *after* the narration, so a long turn hits the wall partway
|
||||
through the block and the deltas are lost — silently, since nothing reads
|
||||
`finish_reason`. The prompt now carries a word budget derived from the cap so
|
||||
the model lands just inside it.
|
||||
`max_output_tokens` is a hard limit the endpoint enforces mid-sentence. The
|
||||
state block is emitted after the narration, so a long turn hits the limit
|
||||
partway through the block, and the deltas are lost. Nothing reads
|
||||
`finish_reason`, so this loss happens silently. The prompt now carries a
|
||||
word budget derived from the cap, so the model lands just inside it.
|
||||
|
||||
Two things are easy to break here:
|
||||
|
||||
* the hint must be stated in **words**, not tokens — a model cannot count its
|
||||
own tokens, and a hint it cannot follow is just wasted budget;
|
||||
* it must not displace `EMIT_REMINDER` from the last position, which is the
|
||||
whole mechanism keeping the state block emitted at all (see
|
||||
* The hint must be stated in words, not tokens. A model cannot count its
|
||||
own tokens, and a hint it cannot follow is wasted budget.
|
||||
* The hint must not displace `EMIT_REMINDER` from the last position, which
|
||||
is the whole mechanism that keeps the state block emitted at all (see
|
||||
test_worldstate.py and the emit-reliability fix).
|
||||
|
||||
python -m pytest tests/test_length_hint.py -v
|
||||
@@ -100,7 +100,7 @@ def asked_words(cap):
|
||||
|
||||
def test_buffer_leaves_room_for_overshoot():
|
||||
"""The stated number must sit meaningfully under the real ceiling, or an
|
||||
on-target-but-slightly-long turn still hits the wall."""
|
||||
on-target-but-slightly-long turn still hits the limit."""
|
||||
for cap in (400, 800, 1500, 2400):
|
||||
asked = asked_words(cap)
|
||||
ceiling = (cap - builder.LENGTH_HEADROOM) * builder.WORDS_PER_TOKEN
|
||||
@@ -109,9 +109,10 @@ def test_buffer_leaves_room_for_overshoot():
|
||||
|
||||
|
||||
def test_hint_is_phrased_as_a_ceiling_not_a_budget():
|
||||
"""Measured: budget phrasing ("keep this turn under about N words") reads as a
|
||||
target to fill and moved the mean turn from 174 to 246 words — toward the wall
|
||||
it exists to avoid. The limit framing must survive future prompt edits."""
|
||||
"""Measured: budget phrasing ("keep this turn under about N words")
|
||||
reads as a target to fill. It moved the mean turn from 174 to 246
|
||||
words, toward the limit it exists to avoid. The limit framing must
|
||||
survive future prompt edits."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
assert "must not exceed" in hint
|
||||
assert "under about" not in hint
|
||||
@@ -119,15 +120,15 @@ def test_hint_is_phrased_as_a_ceiling_not_a_budget():
|
||||
|
||||
|
||||
def test_hint_states_a_floor_as_well_as_a_ceiling():
|
||||
"""A ceiling alone is one-sided: a terse model has nothing to act on but the
|
||||
"only as much as the moment needs" clause and collapses to two paragraphs.
|
||||
The floor is what makes the same prompt land in the same place across models
|
||||
that lean opposite ways."""
|
||||
"""A ceiling alone is one-sided: a terse model has nothing to act on but
|
||||
the "only as much as the moment needs" clause and produces only two
|
||||
paragraphs. The floor is what makes the same prompt produce a similar
|
||||
length across models with different tendencies."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
assert "506" in hint and "177" in hint
|
||||
assert "should not stop short of" in hint
|
||||
# Asymmetric on purpose: the wall is a wall, the floor is a floor, and neither
|
||||
# is phrased as a number to hit.
|
||||
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
|
||||
# soft target, and neither is phrased as a specific number to reach.
|
||||
assert hint.index("must not exceed") < hint.index("should not stop short of")
|
||||
|
||||
|
||||
@@ -139,9 +140,10 @@ def test_floor_stays_well_under_the_ceiling():
|
||||
|
||||
|
||||
def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
|
||||
"""At a tight cap a short turn is the correct turn, and the tight-cap wording
|
||||
is the one measured to keep the state block alive (0/6 truncations at cap 250
|
||||
against 2/6 unhinted) — so it is left exactly as it was."""
|
||||
"""At a tight cap a short turn is the correct turn. The tight-cap
|
||||
wording is the one measured to keep the state block from being
|
||||
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
|
||||
is left exactly as it was."""
|
||||
hint = builder.length_hint(250, has_ws=True)
|
||||
assert "should not stop short of" not in hint
|
||||
assert "much shorter" in hint
|
||||
@@ -199,13 +201,15 @@ def test_emit_reminder_keeps_the_last_word(story):
|
||||
|
||||
|
||||
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
|
||||
"""Regression guard: the hint is appended after history has already spent
|
||||
the budget, so it must be reserved up front like EMIT_REMINDER is.
|
||||
"""Regression guard: the hint is appended after history has already
|
||||
spent the budget, so it must be reserved up front like `EMIT_REMINDER`
|
||||
is.
|
||||
|
||||
Weak on purpose — the history loop stops *before* crossing its budget, so it
|
||||
leaves about one action of slack and the ~30-token hint hides inside it.
|
||||
This catches a hint that grows large, not a missing reservation; the
|
||||
reservation itself is not observable from the outside."""
|
||||
This check is weak on purpose. The history loop stops before crossing
|
||||
its budget, so it leaves about one action of slack, and the roughly
|
||||
30-token hint fits inside that slack. This catches a hint that grows
|
||||
large, not a missing reservation. The reservation itself is not
|
||||
observable from the outside."""
|
||||
db, adventure, settings, _ = story
|
||||
for i in range(4, 120):
|
||||
db.add(models.Action(
|
||||
@@ -225,8 +229,9 @@ def test_prompt_stays_inside_the_budget_on_a_long_story(story):
|
||||
|
||||
|
||||
def test_hint_is_counted_in_the_reported_totals(story):
|
||||
"""Insights reports what the turn actually costs; a section that reaches the
|
||||
model but not the accounting makes that number a lie."""
|
||||
"""Insights reports what the turn actually costs. A section that
|
||||
reaches the model but not the accounting makes that reported cost
|
||||
inaccurate."""
|
||||
db, adventure, settings, _ = story
|
||||
settings.max_output_tokens = 800
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"""Phase 14 SP3 — memories hang off nodes, and the marks are nodes too.
|
||||
"""Phase 14 SP3: memories attach to nodes, and the marks are nodes too.
|
||||
|
||||
Two claims, and neither of them fails loudly if it is wrong:
|
||||
Two claims, and neither fails loudly if it is wrong:
|
||||
|
||||
* **A memory belongs to the path that produced it.** A memory made on branch B
|
||||
* A memory belongs to the path that produced it. A memory made on branch B
|
||||
must be invisible from A, and the memories of a shared ancestor must be
|
||||
visible from both — without anything being copied when a fork happens. The
|
||||
failure mode is a prompt quietly carrying a summary of a story the player
|
||||
abandoned.
|
||||
* **Retrieval reads the *whole* lineage, and that stays affordable.** The story
|
||||
is read through a window, but recall is long-range by definition and cannot
|
||||
be — so the clause names every ancestor, and the bet is that memories are
|
||||
sparse enough (one per six actions) for that to be tens of small rows even
|
||||
twenty forks deep. Measured below rather than asserted.
|
||||
visible from both, without anything being copied when a fork happens. The
|
||||
failure mode is a prompt that quietly carries a summary of a story the
|
||||
player abandoned.
|
||||
* Retrieval reads the whole lineage, and that stays affordable. The story
|
||||
is read through a window, but recall is long-range by definition and
|
||||
cannot use one. So the clause names every ancestor. The bet is that
|
||||
memories are sparse enough, one per six actions, for that to stay tens of
|
||||
small rows even twenty forks deep. This file measures that bet below
|
||||
rather than asserting it.
|
||||
|
||||
Nothing in the product forks yet, so the fork is built by hand, exactly as
|
||||
`test_branch_clause.py` builds it.
|
||||
Nothing in the product forks yet, so this file builds the fork by hand,
|
||||
exactly as `test_branch_clause.py` builds it.
|
||||
|
||||
python -m pytest tests/test_memory_nodes.py -v
|
||||
"""
|
||||
@@ -50,9 +51,10 @@ class StubEmbedder:
|
||||
# --------------------------------------------------------------- the fixture
|
||||
|
||||
def make_branch(db, adventure, parent=None, fork_depth=None):
|
||||
"""A branch row whose lineage is its parent's, capped, plus itself — the
|
||||
computation SP5 will do at fork time, written out so the fixture cannot
|
||||
pass by agreeing with a bug in the code under test."""
|
||||
"""A branch row whose lineage is its parent's lineage, capped, plus
|
||||
itself. This is the computation SP5 performs at fork time. The fixture
|
||||
reimplements it here so it cannot pass by agreeing with a bug in the
|
||||
code under test."""
|
||||
branch = models.Branch(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=parent.id if parent else None,
|
||||
@@ -106,12 +108,13 @@ def add_memory(db, adventure, text, node, vector=(1.0, 0.0, 0.0), **kwargs):
|
||||
|
||||
@pytest.fixture()
|
||||
def forked():
|
||||
"""A0..A3, then B4 B5 off A3, then C6 C7 off B5 — with a memory hung off
|
||||
one node of each branch, and A playing on past the fork it was left at.
|
||||
"""A0..A3, then B4 B5 off A3, then C6 C7 off B5, with a memory attached
|
||||
to one node of each branch. A keeps playing past the fork point where B
|
||||
left it.
|
||||
|
||||
The head is C, so the story is A0 A1 A2 A3 B4 B5 C6 C7 and the memories in
|
||||
play are A's and B's and C's — but not the one on A5, which is on a sibling
|
||||
of B4 and belongs to a story nobody is reading.
|
||||
The head is C, so the story is A0 A1 A2 A3 B4 B5 C6 C7, and the
|
||||
memories in play are A's, B's, and C's. The one on A5 is excluded: it
|
||||
is on a sibling of B4 and belongs to a story nobody is reading.
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
@@ -181,8 +184,9 @@ def retrieved(adventure, settings) -> set[str]:
|
||||
# ------------------------------------------------------------- the isolation
|
||||
|
||||
def test_a_memory_on_a_sibling_is_not_retrieved(forked):
|
||||
"""The whole point. A5 is a node of the story that was abandoned when B
|
||||
forked, and the memory hanging off it must not reach a prompt on C."""
|
||||
"""The whole point of this file. A5 is a node of the story that was
|
||||
abandoned when B forked, and the memory attached to it must not reach a
|
||||
prompt on C."""
|
||||
db, adventure, settings, ids = forked
|
||||
assert retrieved(adventure, settings) == {
|
||||
"on the shared trunk", "on B", "on C"
|
||||
@@ -196,30 +200,30 @@ def test_a_shared_ancestor_is_visible_from_both_branches(forked):
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
from_a = retrieved(adventure, settings)
|
||||
assert "on the shared trunk" in from_a
|
||||
# ...and from A, the branches taken off it are the ones out of reach.
|
||||
# From A, the branches taken off it are the ones out of reach.
|
||||
assert from_a == {"on the shared trunk", "on A's own continuation"}
|
||||
|
||||
|
||||
def test_the_lineage_is_read_whole_not_windowed(forked):
|
||||
"""The story is read through a window; recall is not. The trunk memory is
|
||||
four nodes and two forks back, and is still a candidate."""
|
||||
"""The story is read through a window. Recall is not. The trunk memory
|
||||
is four nodes and two forks back, and is still a candidate."""
|
||||
db, adventure, settings, ids = forked
|
||||
path = lineage.path_of(db, adventure)
|
||||
assert len(path) == 3
|
||||
# The window a *story* read would use here names one entry. Retrieval names
|
||||
# all three, which is the difference this test exists to pin.
|
||||
# The window a story read would use here names one entry. Retrieval
|
||||
# names all three, which is the difference this test checks.
|
||||
assert path.prefix_covering(2) == 1
|
||||
assert "on the shared trunk" in retrieved(adventure, settings)
|
||||
|
||||
|
||||
def test_a_hand_written_memory_is_anchored_where_it_was_typed(forked):
|
||||
"""SP7: a typed memory takes the head, so it obeys the same rule as a
|
||||
summarised one.
|
||||
summarized one.
|
||||
|
||||
It used to carry no depth, which sounded like "belongs to the whole
|
||||
adventure" and behaved like "cannot be capped at a fork" — it followed the
|
||||
reader onto branches whose story it never described. Anchoring it makes the
|
||||
bank answer one question rather than two.
|
||||
adventure" and behaved like "cannot be capped at a fork." It followed
|
||||
the reader onto branches whose story it never described. Anchoring it
|
||||
makes the bank answer one question instead of two.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
@@ -228,14 +232,14 @@ def test_a_hand_written_memory_is_anchored_where_it_was_typed(forked):
|
||||
|
||||
|
||||
def test_a_typed_memory_survives_a_fork_of_the_ground_it_was_typed_on(forked):
|
||||
"""The half of the old behaviour that was right, kept.
|
||||
"""The half of the old behavior that was correct, kept.
|
||||
|
||||
Typed on the shared trunk it is still there after forking away — but
|
||||
because the fork's path goes through that node, not because the memory was
|
||||
exempt from being capped.
|
||||
A memory typed on the shared trunk is still there after forking away,
|
||||
but only because the fork's path goes through that node, not because
|
||||
the memory is exempt from being capped.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 3) # the trunk B, and so C, branch from
|
||||
switch_to(db, adventure, ids["a"], 3) # the node B, and so C, forked from
|
||||
add_memory(db, adventure, "typed on the trunk", None)
|
||||
|
||||
switch_to(db, adventure, ids["c"], 7)
|
||||
@@ -243,12 +247,12 @@ def test_a_typed_memory_survives_a_fork_of_the_ground_it_was_typed_on(forked):
|
||||
|
||||
|
||||
def test_a_typed_memory_does_not_follow_you_onto_a_path_it_is_not_on(forked):
|
||||
"""And the half that was wrong, fixed.
|
||||
"""The other half of the old behavior, which was wrong, is now fixed.
|
||||
|
||||
A5 is A's own continuation past the point B left it, so it is a sibling of
|
||||
the story C tells — precisely where the `sibling` memory sits, and excluded
|
||||
for precisely the same reason. Typing rather than summarising buys no
|
||||
exemption from the path.
|
||||
A5 is A's own continuation past the point where B left it, so it is a
|
||||
sibling of the story C tells. This is exactly where the `sibling`
|
||||
memory sits, and it is excluded for the same reason. Typing a memory
|
||||
instead of summarizing it grants no exemption from the path rule.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
@@ -271,10 +275,10 @@ def test_a_mark_moves_to_the_node_the_memory_covers(forked):
|
||||
|
||||
|
||||
def test_a_mark_from_a_sibling_reads_as_nothing_covered(forked):
|
||||
"""A mark is a node, so moving to another story has to be answered rather
|
||||
than assumed. Ground this path never travelled is not covered ground, and
|
||||
the fallback for 'I don't know' has to be redoing the work, not skipping
|
||||
it."""
|
||||
"""A mark is a node, so switching to another story must resolve the
|
||||
mark's meaning rather than assume it. A path segment this story never
|
||||
took is not covered, and the fallback for "not covered" must be redoing
|
||||
the work, not skipping it."""
|
||||
db, adventure, settings, ids = forked
|
||||
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["C7"])
|
||||
db.commit()
|
||||
@@ -301,9 +305,10 @@ def test_a_mark_never_moves_forward_on_a_rewind(forked):
|
||||
# ---------------------------------------------------- what the passes read
|
||||
|
||||
def test_the_summary_folds_in_only_the_path_it_is_on(forked, monkeypatch):
|
||||
"""`_update_story_summary` gathers the memories past its mark. On C that is
|
||||
B's and C's — never the one on A's own continuation, whose depth would
|
||||
otherwise put it squarely inside the range."""
|
||||
"""`_update_story_summary` gathers the memories past its mark. On C
|
||||
that is B's and C's memories. It never includes the one on A's own
|
||||
continuation, even though that memory's depth would otherwise put it
|
||||
inside the range."""
|
||||
db, adventure, settings, ids = forked
|
||||
monkeypatch.setattr(memorybank, "SUMMARY_INTERVAL", 1)
|
||||
|
||||
@@ -360,7 +365,7 @@ def test_a_block_is_summarized_from_the_path_and_hung_off_its_last_node(
|
||||
assert ["B4", "B5", "C6", "C7"] == [line for line in second.split() if line[0] in "ABC"]
|
||||
made = db.query(models.Memory).filter_by(text="Memory 1.").one()
|
||||
assert (made.branch_id, made.depth) == (ids["a"], 3)
|
||||
# The mark ends up on the node the *second* block hangs off — the tip.
|
||||
# The mark ends up on the node the second block attaches to, which is the tip.
|
||||
assert cursors.MEMORY.stored(adventure) == (ids["c"], 7)
|
||||
|
||||
|
||||
@@ -368,8 +373,8 @@ def test_a_block_is_summarized_from_the_path_and_hung_off_its_last_node(
|
||||
|
||||
@pytest.fixture()
|
||||
def deeply_forked():
|
||||
"""A story forked twenty times, with a memory every six actions — the
|
||||
density the post-turn pass actually produces."""
|
||||
"""A story forked twenty times, with a memory every six actions. This
|
||||
is the density the post-turn pass actually produces."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="deepmem@example.com")
|
||||
@@ -423,10 +428,11 @@ def deeply_forked():
|
||||
|
||||
|
||||
def test_retrieving_from_a_deep_fork_costs_what_a_flat_story_costs(deeply_forked):
|
||||
"""The bet, in bytes. Retrieval names all twenty-two branches instead of
|
||||
one — but it is fetching an id and a flag per memory, and there are the
|
||||
same fourteen either way, so the clause is where the difference is and the
|
||||
clause is not what crosses the wire."""
|
||||
"""The bet from the module docstring, measured in bytes. Retrieval
|
||||
names all twenty-two branches instead of one, but it fetches only an id
|
||||
and a flag per memory, and both stories return the same fourteen
|
||||
memories. The clause is where the difference shows up, and the clause
|
||||
is not what crosses the wire."""
|
||||
db, flat_story, forked_story = deeply_forked
|
||||
settings = db.query(models.Settings).one()
|
||||
flat_id, forked_id = flat_story.id, forked_story.id
|
||||
@@ -445,8 +451,8 @@ def test_retrieving_from_a_deep_fork_costs_what_a_flat_story_costs(deeply_forked
|
||||
finally:
|
||||
meter.detach()
|
||||
|
||||
# Measured 2026-08-18: 1,807 B against 1,823 B — the same fourteen rows,
|
||||
# named through twenty-two branch terms instead of one.
|
||||
# Measured 2026-08-18: 1,807 B against 1,823 B. Both figures cover the
|
||||
# same fourteen rows, named through twenty-two branch terms instead of one.
|
||||
assert flat_bytes > 0, "the meter saw nothing; it is measuring the wrong connection"
|
||||
assert forked_bytes < flat_bytes * 1.5, (
|
||||
f"retrieval on a 20-fork story cost {forked_bytes:,} B against the "
|
||||
@@ -457,17 +463,19 @@ def test_retrieving_from_a_deep_fork_costs_what_a_flat_story_costs(deeply_forked
|
||||
# ------------------------------------------------------- the opening node
|
||||
|
||||
def test_a_typed_memory_on_the_opening_node_survives_that_node_going(forked):
|
||||
"""The one place a node and its memories part company.
|
||||
"""The one exception where a node and its memories are not withdrawn
|
||||
together.
|
||||
|
||||
A memory anchored to a node is withdrawn with the node, which is the rule
|
||||
and is deliberate: it described that turn, and the turn is leaving. But
|
||||
migration 62 parked *every* memory written before memories had coordinates
|
||||
on depth 0 — the only landing spot visible from every branch — so the
|
||||
opening node carries a whole bank it never produced. Withdrawing it would
|
||||
retire all of that in one click, for every adventure predating the tree.
|
||||
A memory anchored to a node is withdrawn with the node. This is the
|
||||
rule, and it is deliberate: the memory described that turn, and the
|
||||
turn is leaving. But migration 62 parked every memory written before
|
||||
memories had coordinates on depth 0, the only landing spot visible from
|
||||
every branch. As a result, the opening node carries a whole bank of
|
||||
memories it never produced. Withdrawing it would delete all of those
|
||||
memories at once, for every adventure that predates the tree.
|
||||
|
||||
A memory with no `source_start` covers no stretch of story, so nothing about
|
||||
it can go stale. It stays.
|
||||
A memory with no `source_start` covers no stretch of story, so nothing
|
||||
about it can go stale. It stays.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
typed = models.Memory(
|
||||
@@ -489,9 +497,10 @@ def test_a_typed_memory_on_the_opening_node_survives_that_node_going(forked):
|
||||
def test_a_summary_of_the_opening_node_is_still_withdrawn(forked):
|
||||
"""The exception is about memories that describe nothing, not about depth 0.
|
||||
|
||||
A summary that genuinely ends on the opening node describes text that is
|
||||
going, so it goes too — otherwise the root would collect exactly the
|
||||
dangling rows `forget_node` replaced `prune_dangling_memories` to prevent.
|
||||
A summary that genuinely ends on the opening node describes text that
|
||||
is being removed, so the summary is removed too. Otherwise the root
|
||||
would collect exactly the dangling rows that `forget_node` replaced
|
||||
`prune_dangling_memories` to prevent.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
derived = add_memory(db, adventure, "the opening, summarised", ids["nodes"]["A0"])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Ranking the memory bank without reading the memory bank.
|
||||
|
||||
Retrieval used to walk `adventure.memories`, which loaded every row *with its
|
||||
vector* — 96% of everything a turn read. It now asks SQL which memories are in
|
||||
play, holds their vectors in process, and fetches text for the five it picks.
|
||||
Retrieval used to walk `adventure.memories`, which loaded every row with
|
||||
its vector. That vector data was 96% of everything a turn read. Retrieval
|
||||
now asks SQL which memories are in play, holds their vectors in process,
|
||||
and fetches text for only the five it picks.
|
||||
|
||||
Three things have to stay true for that to be safe, and each is a separate
|
||||
failure that no error message would ever report:
|
||||
failure that no error message would report:
|
||||
|
||||
* the ranking picks the same memories it always did;
|
||||
* nothing bulk-reads a vector column again;
|
||||
@@ -80,8 +81,8 @@ def adventure(db, settings):
|
||||
)
|
||||
db.add(adv)
|
||||
db.flush()
|
||||
# Retrieval builds its query from the newest actions; with none, it returns
|
||||
# before ranking anything.
|
||||
# Retrieval builds its query from the newest actions. With none, it
|
||||
# returns before ranking anything.
|
||||
for i in range(2):
|
||||
db.add(models.Action(
|
||||
adventure_id=adv.id, index=i, type="ai", text=f"Something happened {i}."
|
||||
@@ -189,8 +190,8 @@ def test_update_stats_bumps_only_the_used(db, adventure, settings, bank):
|
||||
|
||||
|
||||
def test_dry_runs_do_not_bump_the_counters(db, adventure, settings, bank):
|
||||
"""Insights assembles a context without spending a turn; it must not look
|
||||
like the memories were used."""
|
||||
"""Insights assembles a context without spending a turn. It must not
|
||||
look like the memories were used."""
|
||||
retrieve(adventure, settings, StubEmbedder(), update_stats=False)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
@@ -207,18 +208,19 @@ def memory_selects(statements):
|
||||
|
||||
|
||||
def test_the_json_column_is_gone(db):
|
||||
"""`memories.embedding` held the vectors before migration 38 and nothing
|
||||
read it afterwards; migration 42 dropped it. Bringing it back would restore
|
||||
4 MB of dead weight and a second place vectors can be written from — which
|
||||
is how the model-switch bug happened (test_embedding_model_switch.py)."""
|
||||
"""`memories.embedding` held the vectors before migration 38, and
|
||||
nothing read it afterward. Migration 42 dropped it. Restoring it would
|
||||
bring back 4 MB of dead weight and a second place vectors can be
|
||||
written from. That second place is how the model-switch bug happened
|
||||
(test_embedding_model_switch.py)."""
|
||||
columns = {c["name"] for c in sa_inspect(engine).get_columns("memories")}
|
||||
assert "embedding" not in columns
|
||||
assert {"embedding_blob", "embedded"} <= columns
|
||||
|
||||
|
||||
def test_the_catalogue_query_carries_no_vectors(db, adventure, settings, bank, sql_log):
|
||||
"""The query that decides *which* memories are in play must stay tiny —
|
||||
this is the one that used to drag the whole bank across."""
|
||||
"""The query that decides which memories are in play must stay tiny.
|
||||
This is the query that used to pull the whole bank across the wire."""
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
catalogue = [s for s in memory_selects(sql_log) if "memories.pinned" in s]
|
||||
assert catalogue, "expected a catalogue query"
|
||||
@@ -261,9 +263,10 @@ def test_only_top_k_texts_are_fetched(db, adventure, settings, bank, sql_log):
|
||||
# ---------------------------------------------------------------- staleness
|
||||
|
||||
def test_a_rewritten_vector_is_not_served_from_cache(db, adventure, settings, bank):
|
||||
"""The cache's one genuine hazard: a memory keeps its id while its vector
|
||||
changes, so an id-set check alone would go on serving the old one. Editing
|
||||
a memory's text and re-embedding it does exactly that.
|
||||
"""The cache's one genuine hazard: a memory keeps its id while its
|
||||
vector changes, so an id-set check alone would continue serving the
|
||||
old vector. Editing a memory's text and re-embedding it does exactly
|
||||
that.
|
||||
"""
|
||||
settings.memory_top_k = 1
|
||||
db.commit()
|
||||
@@ -325,9 +328,9 @@ def test_eviction_marks_the_least_recently_used(db, adventure, settings):
|
||||
|
||||
|
||||
def test_eviction_breaks_ties_on_use_count(db, adventure, settings):
|
||||
"""Two memories last wanted at the same moment: the one the story has
|
||||
leaned on less goes. Only a tiebreak — ranking on the count first is what
|
||||
used to freeze the bank (see below)."""
|
||||
"""Two memories last used at the same moment: the one the story has
|
||||
used less is the one that goes. This must be only a tiebreak. Ranking
|
||||
on the count first is what used to freeze the bank (see below)."""
|
||||
settings.memory_bank_capacity = 1
|
||||
db.commit()
|
||||
now = models.utcnow()
|
||||
@@ -344,12 +347,13 @@ def test_eviction_breaks_ties_on_use_count(db, adventure, settings):
|
||||
|
||||
|
||||
def test_a_newborn_is_not_evicted_by_the_bank_it_joins(db, adventure, settings):
|
||||
"""The bank used to shut itself. Eviction ranked on use_count first, and a
|
||||
memory written this turn has never been used, so the moment every survivor
|
||||
had been retrieved even once the newborn was the lowest row in the bank and
|
||||
was retired in the same post-turn run that wrote it — before retrieval ever
|
||||
saw it. That state is absorbing: counts only go up, so no memory written
|
||||
after it could ever get in either."""
|
||||
"""The bank used to stop accepting new memories. Eviction ranked on
|
||||
use_count first, and a memory written this turn has never been used.
|
||||
Once every existing memory had been retrieved even once, the newborn
|
||||
became the lowest-ranked row in the bank. Eviction then removed it in
|
||||
the same post-turn run that wrote it, before retrieval ever saw it.
|
||||
That state never recovers: counts only go up, so no memory written
|
||||
after it could get in either."""
|
||||
settings.memory_bank_capacity = 3
|
||||
db.commit()
|
||||
now = models.utcnow()
|
||||
@@ -370,9 +374,9 @@ def test_a_newborn_is_not_evicted_by_the_bank_it_joins(db, adventure, settings):
|
||||
|
||||
|
||||
def test_a_full_bank_still_turns_over(db, adventure, settings):
|
||||
"""The same failure seen over several turns: a bank at capacity has to keep
|
||||
taking on what the story is doing now, or the adventure stops remembering
|
||||
anything past the point it filled up."""
|
||||
"""The same failure seen over several turns: a bank at capacity must
|
||||
keep accepting new memories, or the adventure stops remembering
|
||||
anything past the point where it filled up."""
|
||||
settings.memory_bank_capacity = 3
|
||||
now = models.utcnow()
|
||||
db.commit()
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
"""Memories must never describe narration that is no longer in the story, and
|
||||
must never skip a stretch of it.
|
||||
|
||||
For six phases the answer was a **holdback**: summarization stopped one action
|
||||
short of the newest, because only the last action was retryable and a retry
|
||||
rewrote `Action.text` under a mark that had already moved past it. SP4 ended
|
||||
that — a retry writes a sibling node and the coordinate's derived work is
|
||||
withdrawn as it does, which is the same repair undo and delete already made.
|
||||
So the holdback is gone, and the first half of this file now asserts the
|
||||
property that replaced it: a block forms as soon as there is a block, and
|
||||
changing what a coordinate says takes back what was derived from it.
|
||||
For six phases, the answer was a holdback. Summarization stopped one action
|
||||
short of the newest, because only the last action was retryable, and a
|
||||
retry rewrote `Action.text` under a mark that had already moved past it.
|
||||
SP4 ended that: a retry writes a sibling node, and the coordinate's derived
|
||||
work is withdrawn as it happens, using the same repair that undo and delete
|
||||
already made. The holdback is gone, so the first half of this file now
|
||||
asserts the property that replaced it. A block forms as soon as there is a
|
||||
block, and changing what a coordinate says takes back what was derived from
|
||||
it.
|
||||
|
||||
Phase 14 SP3 changed what the mark *is*. It used to be a count of covered story
|
||||
actions, and the second half of this file is the price of that: deleting an
|
||||
action from in front of a position slid a never-summarized action into the
|
||||
covered range, so every delete had to slide the cursors too. The mark is a node
|
||||
now — `(branch_id, depth)` — and a node does not move when something in front
|
||||
of it is deleted, so those tests assert that nothing happens where they used to
|
||||
assert that the right correction happened.
|
||||
Phase 14 SP3 changed what the mark is. It used to be a count of covered
|
||||
story actions, and the second half of this file is the cost of that.
|
||||
Deleting an action from in front of a position slid a never-summarized
|
||||
action into the covered range, so every delete had to slide the cursors
|
||||
too. The mark is a node now, `(branch_id, depth)`, and a node does not move
|
||||
when something in front of it is deleted. Those tests now assert that
|
||||
nothing happens, where they used to assert that the right correction
|
||||
happened.
|
||||
|
||||
python -m pytest tests/test_memory_settling.py -v
|
||||
"""
|
||||
@@ -110,13 +112,14 @@ def run_memories(db, adventure, stub, monkeypatch):
|
||||
# --------------------------------------------------- no holdback, since SP4
|
||||
|
||||
def test_a_block_forms_as_soon_as_the_story_holds_one(db, monkeypatch):
|
||||
"""Covered to action 5 with 12 actions: block 6-11 ends on the *newest*
|
||||
"""Covered to action 5 with 12 actions: block 6-11 ends on the newest
|
||||
action, and is summarized now rather than a turn later.
|
||||
|
||||
This is exactly the case the holdback existed to refuse. What makes it safe
|
||||
is no longer that the block stops short — it is that a retry of node 11
|
||||
would withdraw this memory on its way past (see
|
||||
`test_deleting_a_summarized_node_withdraws_its_memory`, the same repair).
|
||||
This is exactly the case the holdback existed to refuse. What makes it
|
||||
safe is no longer that the block stops short. It is that a retry of
|
||||
node 11 would withdraw this memory on its way past (see
|
||||
`test_deleting_a_summarized_node_withdraws_its_memory`, the same
|
||||
repair).
|
||||
"""
|
||||
adventure = make_adventure(db, 12)
|
||||
cover(db, adventure, 6)
|
||||
@@ -128,8 +131,8 @@ def test_a_block_forms_as_soon_as_the_story_holds_one(db, monkeypatch):
|
||||
assert "Action 11." in stub.excerpts[0]
|
||||
memory = db.query(models.Memory).one()
|
||||
assert (memory.source_start, memory.source_end) == (6, 11)
|
||||
# The mark and the memory name the same node — that is what keeps them from
|
||||
# drifting apart however gappy the depths underneath are.
|
||||
# The mark and the memory name the same node. That is what keeps them
|
||||
# from drifting apart, however gappy the underlying depths are.
|
||||
assert (memory.branch_id, memory.depth) == cursors.MEMORY.stored(adventure)
|
||||
assert covered_depth(db, adventure) == 11
|
||||
|
||||
@@ -155,13 +158,13 @@ def test_the_first_memory_lands_at_memory_start(db, monkeypatch):
|
||||
|
||||
|
||||
def test_legacy_caught_up_adventure_is_not_rewound(db, monkeypatch):
|
||||
"""An adventure summarized under the OLD rule carries a cursor equal to its
|
||||
action count — one past the end of the story. That used to need a clamp on
|
||||
every post-turn pass, and clamping it to the settled count re-covered an
|
||||
action.
|
||||
"""An adventure summarized under the old rule carries a cursor equal to
|
||||
its action count, one past the end of the story. That used to require a
|
||||
clamp on every post-turn pass, and clamping it to the settled count
|
||||
re-covered an action.
|
||||
|
||||
A mark that names a node has no such edge: the newest action is the node,
|
||||
and "everything after it" is empty until the story grows.
|
||||
A mark that names a node has no such edge. The newest action is the
|
||||
node, and "everything after it" is empty until the story grows.
|
||||
"""
|
||||
adventure = make_adventure(db, 12)
|
||||
db.add(models.Memory(adventure_id=adventure.id, text="A", source_start=0, source_end=5))
|
||||
@@ -238,9 +241,9 @@ def test_deleting_a_middle_action_leaves_the_mark_where_it_was(db):
|
||||
with node 5 gone.
|
||||
"""
|
||||
adventure = summarized_adventure(db)
|
||||
# Node 4 is inside memory A's block but is not the node it hangs off, so
|
||||
# nothing is withdrawn — the same reading the old code had, where only a
|
||||
# memory whose *end* had fallen off the story was pruned.
|
||||
# Node 4 is inside memory A's block but is not the node it hangs off,
|
||||
# so nothing is withdrawn. The old code read it the same way: only a
|
||||
# memory whose end had fallen off the story was pruned.
|
||||
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=4).one()
|
||||
|
||||
assert memorybank.forget_node(db, adventure, victim) == 0
|
||||
@@ -267,12 +270,13 @@ def test_deleting_a_later_action_leaves_the_mark_alone(db):
|
||||
|
||||
|
||||
def test_deleting_a_summarized_node_withdraws_its_memory(db):
|
||||
"""Discarding the memory isn't enough — the story it covered is still
|
||||
behind the mark, so the mark has to come back to where that block began.
|
||||
"""Discarding the memory is not enough. The story it covered is still
|
||||
behind the mark, so the mark has to move back to where that block began.
|
||||
|
||||
Memory B ends on node 11, so deleting node 11 is what withdraws it. The old
|
||||
code found this by scanning for a memory whose covered range had fallen off
|
||||
the end of the story; the memory hangs off the node now, so it is a lookup.
|
||||
Memory B ends on node 11, so deleting node 11 withdraws it. The old code
|
||||
found this by scanning for a memory whose covered range had fallen off
|
||||
the end of the story. Now the memory hangs off the node, so finding it
|
||||
is a lookup.
|
||||
"""
|
||||
adventure = summarized_adventure(db)
|
||||
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=11).one()
|
||||
|
||||
@@ -22,7 +22,7 @@ def hosted(monkeypatch):
|
||||
|
||||
|
||||
def _resolves_to(monkeypatch, ip: str):
|
||||
"""Pin getaddrinfo so we test the address decision, not real DNS."""
|
||||
"""Pin `getaddrinfo` so the test exercises the address decision, not real DNS."""
|
||||
monkeypatch.setattr(
|
||||
netguard.socket, "getaddrinfo",
|
||||
lambda *a, **k: [(2, 1, 6, "", (ip, 443))],
|
||||
@@ -64,6 +64,6 @@ def test_unresolvable_host_is_blocked(hosted, monkeypatch):
|
||||
|
||||
def test_noop_in_local_mode(monkeypatch):
|
||||
monkeypatch.setattr(auth, "MULTI_USER", False)
|
||||
# Local installs legitimately reach localhost (Ollama) — never blocked.
|
||||
# Local installs must reach localhost (Ollama). The guard never blocks local mode.
|
||||
assert netguard.endpoint_block_reason("http://localhost:11434/v1") is None
|
||||
assert netguard.endpoint_block_reason("http://127.0.0.1:11434/v1") is None
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
"""Prompt caching: the prompt has to start with the same bytes every turn.
|
||||
|
||||
Every endpoint that caches prompts caches a *prefix* — it reuses the request up
|
||||
to the first byte that differs from last time and no further. So the cost of a
|
||||
turn is decided by layout: one section that changes each turn, placed near the
|
||||
top, re-prices everything underneath it, and underneath it is the story
|
||||
history, which is most of the prompt.
|
||||
Every endpoint that caches prompts caches a prefix. It reuses the request
|
||||
up to the first byte that differs from last time, and no further. The cost
|
||||
of a turn is therefore decided by layout: one section that changes each
|
||||
turn, placed near the top, re-prices everything underneath it, and
|
||||
underneath it is the story history, which makes up most of the prompt.
|
||||
|
||||
Three things have to hold, and each is easy to undo by accident:
|
||||
Three things must hold, and each is easy to undo by accident:
|
||||
|
||||
* the static block is byte-identical across turns — adding a section that moves
|
||||
(live stats, retrieved memories, a rewritten summary) to `system_sections` is
|
||||
the mistake this file exists to catch;
|
||||
* the sections that move sit *after* the history, but still *before* the tail
|
||||
that is last for its own reasons (front memory, the length hint, and
|
||||
EMIT_REMINDER, which is what keeps the state block emitted at all);
|
||||
* moving a section out of the system block does not drop it from the token
|
||||
budget — it is still in the prompt.
|
||||
* The static block is byte-identical across turns. Adding a section that
|
||||
moves (live stats, retrieved memories, a rewritten summary) to
|
||||
`system_sections` is the mistake this file exists to catch.
|
||||
* The sections that move sit after the history, but still before the tail
|
||||
that is last for its own reasons: front memory, the length hint, and
|
||||
`EMIT_REMINDER`, which is what keeps the state block emitted at all.
|
||||
* Moving a section out of the system block does not drop it from the
|
||||
token budget. It is still in the prompt.
|
||||
|
||||
Plus the two request-level halves: preferring one OpenRouter upstream, since
|
||||
each upstream holds its own cache, and reading back the usage the endpoint
|
||||
reports so the hit rate is measurable rather than assumed.
|
||||
This file also covers two request-level concerns: preferring one
|
||||
OpenRouter upstream, since each upstream holds its own cache, and reading
|
||||
back the usage the endpoint reports, so the hit rate is measurable rather
|
||||
than assumed.
|
||||
|
||||
python -m pytest tests/test_prompt_caching.py -v
|
||||
"""
|
||||
@@ -68,8 +69,8 @@ def test_fallbacks_stay_on():
|
||||
|
||||
|
||||
def test_non_openrouter_endpoints_get_no_provider_field():
|
||||
"""Ollama and friends reject fields they do not know — the same trap the
|
||||
`reasoning` param is written around."""
|
||||
"""Ollama and other providers reject fields they do not know. This is
|
||||
the same problem the `reasoning` param works around."""
|
||||
body = _routed("http://localhost:11434/v1", "deepseek/deepseek-v4-flash-0731")
|
||||
assert "provider" not in body
|
||||
|
||||
@@ -85,8 +86,9 @@ def test_unknown_vendors_are_left_alone():
|
||||
# ------------------------------------------------------ reading usage back
|
||||
|
||||
def test_usage_is_recorded_from_a_final_chunk():
|
||||
"""In a stream the usage block rides on a last chunk carrying no choices,
|
||||
which is why it is read separately from the text extraction."""
|
||||
"""In a stream, the usage block arrives in a final chunk that carries
|
||||
no choices, which is why it is read separately from the text
|
||||
extraction."""
|
||||
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
|
||||
assert provider.last_usage is None
|
||||
provider._record_usage({"choices": [{"delta": {"content": "hi"}}]})
|
||||
@@ -109,8 +111,8 @@ def test_a_later_chunk_without_usage_does_not_erase_it():
|
||||
# ------------------------------------------------------------ prompt layout
|
||||
|
||||
def _with_hp(world_state, hp):
|
||||
"""`world_state` is nested by group, and the JSON column only notices a
|
||||
whole new object — so build one rather than mutating in place."""
|
||||
"""`world_state` is nested by group, and the JSON column only detects a
|
||||
whole new object. Build a new one instead of mutating in place."""
|
||||
return {**world_state, "player": {**world_state["player"], "hp": hp}}
|
||||
|
||||
|
||||
@@ -153,8 +155,9 @@ def story():
|
||||
|
||||
|
||||
def test_changing_a_stat_leaves_the_static_block_untouched(story):
|
||||
"""The whole point. Live values used to sit third from the top, so a single
|
||||
point of damage re-priced the instructions, the plot and the history."""
|
||||
"""The whole point. Live values used to sit third from the top, so a
|
||||
single point of damage re-priced the instructions, the plot, and the
|
||||
history."""
|
||||
db, adventure, settings = story
|
||||
before, _, _ = builder.build_context(adventure, settings)
|
||||
adventure.world_state = _with_hp(adventure.world_state, 40)
|
||||
@@ -169,8 +172,8 @@ def test_the_static_block_holds_the_things_that_do_not_move(story):
|
||||
system_text, story_text, _ = builder.build_context(adventure, settings)
|
||||
for fixed in ("Write in second person.", "The hero hunts bandits."):
|
||||
assert fixed in system_text
|
||||
# The stat *guide* is derived from the schema and so is fixed; the live
|
||||
# values it describes are not, and belong to the story text.
|
||||
# The stat guide is derived from the schema, so it is fixed. The live
|
||||
# values it describes are not fixed, and belong to the story text.
|
||||
assert "Stat guide" in system_text
|
||||
for moves in ("The hero left the village.", "hp 100/100"):
|
||||
assert moves not in system_text
|
||||
@@ -186,8 +189,9 @@ def test_volatile_sections_sit_after_the_history(story):
|
||||
|
||||
|
||||
def test_the_tail_stays_the_tail(story):
|
||||
"""front memory, the length hint and EMIT_REMINDER are last for reasons of
|
||||
their own, and the live sections must not have displaced them."""
|
||||
"""Front memory, the length hint, and `EMIT_REMINDER` are last for
|
||||
reasons of their own, and the live sections must not have displaced
|
||||
them."""
|
||||
db, adventure, settings = story
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
labels = [s["label"] for s in report["sections"]]
|
||||
@@ -199,8 +203,8 @@ def test_the_tail_stays_the_tail(story):
|
||||
|
||||
def test_live_sections_are_still_charged_to_the_budget(story):
|
||||
"""They moved out of `system_sections`, so it would be easy to stop
|
||||
counting them in `reserved` — and then the history, which is budgeted with
|
||||
what is left over, would quietly overrun."""
|
||||
counting them in `reserved`. If that happened, the history, which is
|
||||
budgeted with what is left over, would quietly overrun."""
|
||||
db, adventure, settings = story
|
||||
for i in range(6, 90):
|
||||
db.add(models.Action(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Regression tests for the X-Forwarded-For rate-limit bypass and the
|
||||
per-account login throttle added to close it.
|
||||
|
||||
Background: uvicorn's --forwarded-allow-ips "*" trusted the LEFTMOST
|
||||
X-Forwarded-For entry, which the client controls, so rotating the header
|
||||
handed out a fresh rate-limit bucket per request. client_ip now reads the
|
||||
hop the trusted edge appends (rightmost), and login has an email-keyed throttle
|
||||
that no IP trick can dilute.
|
||||
Background: uvicorn's `--forwarded-allow-ips "*"` trusted the leftmost
|
||||
`X-Forwarded-For` entry. The client controls that entry, so rotating the
|
||||
header issued a fresh rate-limit bucket on every request. `client_ip` now
|
||||
reads the hop the trusted edge appends, which is the rightmost one. Login
|
||||
also has an email-keyed throttle that no IP trick can weaken.
|
||||
|
||||
python -m pytest tests/test_ratelimit_hardening.py -v
|
||||
"""
|
||||
@@ -35,15 +35,15 @@ class _Req:
|
||||
|
||||
def test_client_ip_takes_appended_rightmost_hop(monkeypatch):
|
||||
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
|
||||
# Attacker prepends a fake IP; the edge appends the real one on the right.
|
||||
# An attacker prepends a fake IP. The edge appends the real one on the right.
|
||||
req = _Req("203.0.113.9, 198.51.100.77")
|
||||
assert limits.client_ip(req) == "198.51.100.77"
|
||||
|
||||
|
||||
def test_client_ip_ignores_spoofed_leftmost(monkeypatch):
|
||||
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
|
||||
# Whatever the client stuffs to the left, the keyed IP stays the real hop —
|
||||
# so rotating it no longer mints a new bucket.
|
||||
# The keyed IP stays the real hop regardless of what the client adds on
|
||||
# the left, so rotating that value no longer creates a new bucket.
|
||||
a = limits.client_ip(_Req("1.1.1.1, 198.51.100.77"))
|
||||
b = limits.client_ip(_Req("2.2.2.2, 198.51.100.77"))
|
||||
c = limits.client_ip(_Req("evil, junk, 198.51.100.77"))
|
||||
@@ -74,11 +74,11 @@ def _multi_user(monkeypatch):
|
||||
|
||||
def test_login_throttle_blocks_after_limit():
|
||||
email = "victim@example.com"
|
||||
# Up to the limit: allowed, each a recorded failure.
|
||||
# Each attempt up to the limit is allowed and recorded as a failure.
|
||||
for _ in range(limits.LOGIN_FAIL_LIMIT):
|
||||
limits.check_login_allowed(email) # does not raise
|
||||
limits.note_login_failure(email)
|
||||
# One more crosses the line.
|
||||
# One more failure exceeds the limit.
|
||||
with pytest.raises(limits.HTTPException) as exc:
|
||||
limits.check_login_allowed(email)
|
||||
assert exc.value.status_code == 429
|
||||
@@ -89,7 +89,7 @@ def test_login_throttle_is_per_account():
|
||||
limits.note_login_failure("a@example.com")
|
||||
with pytest.raises(limits.HTTPException):
|
||||
limits.check_login_allowed("a@example.com")
|
||||
# A different account is unaffected — this is not an IP bucket.
|
||||
# A different account is unaffected because the throttle keys on email, not IP.
|
||||
limits.check_login_allowed("b@example.com") # must not raise
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def test_successful_login_clears_the_streak():
|
||||
for _ in range(limits.LOGIN_FAIL_LIMIT):
|
||||
limits.note_login_failure(email)
|
||||
limits.note_login_success(email)
|
||||
limits.check_login_allowed(email) # streak wiped — must not raise
|
||||
limits.check_login_allowed(email) # failure streak cleared, must not raise
|
||||
|
||||
|
||||
def test_throttle_is_noop_in_local_mode(monkeypatch):
|
||||
|
||||
@@ -17,7 +17,7 @@ def _body(reasoning_max_tokens, api_mode="chat", max_tokens=1000):
|
||||
|
||||
|
||||
def test_zero_sends_nothing():
|
||||
"""Ollama and friends reject unknown fields — 0 must stay silent."""
|
||||
"""Ollama and other providers reject unknown fields. Sending 0 must not add a `reasoning` field."""
|
||||
assert "reasoning" not in _body(0)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_negative_turns_reasoning_off():
|
||||
|
||||
|
||||
def test_off_is_not_merely_excluded():
|
||||
"""`exclude: true` still thinks and still bills; we want it actually off."""
|
||||
"""`exclude: true` still generates and bills for reasoning tokens. The off setting must omit the field entirely instead of relying on `exclude`."""
|
||||
assert _body(-1)["reasoning"].get("exclude") is None
|
||||
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ def test_retry_keeps_the_discarded_attempt(client):
|
||||
|
||||
_retry(client)
|
||||
actions = _actions(client)
|
||||
# One AI action still, not two — the retry replaced the live text in place.
|
||||
# One AI action still, not two. The retry replaced the live text in place.
|
||||
assert [a["type"] for a in actions] == ["start", "do", "ai"]
|
||||
last = actions[-1]
|
||||
assert last["text"] == "Attempt two."
|
||||
@@ -151,18 +151,18 @@ def test_retry_keeps_the_discarded_attempt(client):
|
||||
|
||||
|
||||
def test_retry_context_excludes_the_attempt_being_replaced(client):
|
||||
"""The whole point of a retry is a fresh take on the *same* turn. The row
|
||||
now survives the retry (it holds the variant history), so it is still in
|
||||
`adventure.actions` while the replacement context is assembled — it must be
|
||||
filtered out, or the model is asked to continue *past* the attempt it is
|
||||
supposed to be replacing and writes a sequel that blends both."""
|
||||
"""A retry produces a fresh take on the same turn. The row survives the
|
||||
retry because it holds the variant history, so it is still in
|
||||
`adventure.actions` while the replacement context is assembled. The
|
||||
context builder must filter it out, or the model continues past the
|
||||
attempt it is replacing and writes a sequel that blends both."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
|
||||
retry_story = ScriptedProvider.prompts[-1][1]
|
||||
assert "Attempt one." not in retry_story
|
||||
# The turn's own player action must still be there — it's what to respond to.
|
||||
# The turn's own player action must still be there. It is what the model responds to.
|
||||
assert "look around" in retry_story
|
||||
assert "You enter a cave." in retry_story
|
||||
|
||||
@@ -257,7 +257,7 @@ def test_cannot_switch_a_turn_the_story_moved_past(client):
|
||||
f"/api/adventures/{client.adv_id}/actions/{retried['id']}/variant", json={"index": 0})
|
||||
assert r.status_code == 400
|
||||
assert "latest message" in r.json()["detail"]
|
||||
# Still readable, though — that's the whole point of keeping them.
|
||||
# The variant is still readable. Keeping every attempt browsable is why it still exists.
|
||||
variants = client.get(
|
||||
f"/api/adventures/{client.adv_id}/actions/{retried['id']}/variants").json()
|
||||
assert [v["text"] for v in variants] == ["One.", "Two."]
|
||||
@@ -333,7 +333,7 @@ def test_export_and_import_round_trips_variants(client):
|
||||
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||
# SP6: the attempts are nodes in the bundle too, sharing one coordinate,
|
||||
# and `live` says which of them the story tells. The `variants` array
|
||||
# survives only in the v1 *reader* — see the hand-edited bundle below.
|
||||
# survives only in the v1 reader. See the hand-edited bundle below.
|
||||
ai = [a for a in bundle["actions"] if a["type"] == "ai"]
|
||||
assert [(a["text"], a["live"]) for a in ai] == [("One.", False), ("Two.", True)]
|
||||
assert len({(a["branch"], a["depth"]) for a in ai}) == 1
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Scenario cover art + the Continue-card snippet.
|
||||
"""Scenario cover art and the Continue-card snippet.
|
||||
|
||||
Unit tests for the data-URI handling in app/images.py, then HTTP tests that the
|
||||
list endpoints advertise a cacheable `image_url` (never the inline base64), that
|
||||
the image route serves real bytes, and that an adventure's snippet comes from
|
||||
the latest *narration* rather than the player's last line.
|
||||
Unit tests for the data-URI handling in app/images.py. Then HTTP tests
|
||||
confirm that the list endpoints advertise a cacheable `image_url` instead
|
||||
of the inline base64, that the image route serves real bytes, and that an
|
||||
adventure's snippet comes from the latest narration rather than the
|
||||
player's last line.
|
||||
|
||||
python -m pytest tests/test_scenario_art.py -v
|
||||
"""
|
||||
@@ -41,13 +42,14 @@ def test_decode_returns_bytes_and_content_type():
|
||||
|
||||
|
||||
def test_decode_tolerates_wrapped_base64():
|
||||
"""A hand-pasted URI can carry newlines; b64decode(validate=True) won't."""
|
||||
"""A hand-pasted URI can carry newlines. `b64decode(validate=True)` rejects them."""
|
||||
wrapped = "data:image/png;base64," + "\n".join(
|
||||
base64.b64encode(PNG_BYTES).decode()[i:i + 24] for i in range(0, 100, 24)
|
||||
)
|
||||
# Only asserting it doesn't raise and doesn't silently return a partial
|
||||
# decode of a truncated payload — the wrapped prefix here is not the whole
|
||||
# image, so a None result is also acceptable; what matters is no exception.
|
||||
# This test only confirms that the call does not raise and does not
|
||||
# silently return a partial decode of a truncated payload. The wrapped
|
||||
# prefix here is not the whole image, so a None result is also
|
||||
# acceptable. What matters is that no exception occurs.
|
||||
images.decode(wrapped)
|
||||
|
||||
|
||||
@@ -59,7 +61,7 @@ def test_decode_rejects_non_data_uris_and_garbage():
|
||||
|
||||
|
||||
def test_decode_rejects_svg():
|
||||
"""SVG can carry script and these bytes are served from our own origin."""
|
||||
"""SVG can carry script, and the app serves these bytes from its own origin."""
|
||||
svg = "data:image/svg+xml;base64," + base64.b64encode(b"<svg/>").decode()
|
||||
assert images.decode(svg) is None
|
||||
|
||||
@@ -151,7 +153,7 @@ def test_scenario_list_advertises_a_url_and_hides_the_base64(client):
|
||||
|
||||
pictured = rows["Pictured"]
|
||||
assert pictured["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
|
||||
# The whole point: a list response must never carry the inline image.
|
||||
# A list response must never carry the inline image.
|
||||
assert "image" not in pictured
|
||||
|
||||
assert rows["Emoji"]["image_url"] == ""
|
||||
@@ -180,7 +182,7 @@ def test_single_scenario_still_returns_the_raw_uri_for_editing(client):
|
||||
def test_adventure_list_carries_snippet_and_inherited_art(client):
|
||||
row = next(r for r in client.get("/api/adventures").json()
|
||||
if r["id"] == client.ids["adventure"])
|
||||
# Latest narration, whitespace collapsed — not the player's "I draw my sword."
|
||||
# Latest narration, whitespace collapsed. Not the player's line, "I draw my sword."
|
||||
assert row["snippet"] == "Steel rings. The corridor answers."
|
||||
assert row["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
|
||||
assert row["action_count"] == 3
|
||||
|
||||
@@ -75,12 +75,13 @@ def make_scenario(client, **kwargs):
|
||||
|
||||
|
||||
def edit_scenario(scenario_id, cards=None, **fields):
|
||||
"""Author-side edit, straight to the DB (the scenario API is tested elsewhere).
|
||||
"""Author-side edit, straight to the database. The scenario API is
|
||||
tested elsewhere.
|
||||
|
||||
`cards` is the scenario's full card list afterwards. Cards are matched to
|
||||
existing rows by name and edited in place, exactly as ScenarioEditor does
|
||||
(PATCH /story-cards/{id}) — card ids are stable across authoring, which is
|
||||
what source_ref tracking relies on.
|
||||
`cards` is the scenario's full card list afterwards. Cards are matched
|
||||
to existing rows by name and edited in place, exactly as ScenarioEditor
|
||||
does (PATCH /story-cards/{id}). Card ids stay stable across authoring,
|
||||
which is what source_ref tracking relies on.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""context_snapshot, stored compressed.
|
||||
|
||||
The column is 89% of the database and the free tier allows 512 MB. Reads were
|
||||
solved by deferring it; this is about the storage ceiling. Postgres already
|
||||
TOASTs it and only gets 1.7x, because pglz is tuned for fast decompression of
|
||||
data a query might filter on — and nothing ever filters on an assembled
|
||||
prompt.
|
||||
The column makes up 89% of the database, and the free tier allows only
|
||||
512 MB. Deferring the column already solved the read cost, so this is
|
||||
about the storage ceiling. Postgres already TOASTs the column and gets
|
||||
only a 1.7x ratio, because pglz favors fast decompression for data a query
|
||||
might filter on. Nothing ever filters on an assembled prompt.
|
||||
|
||||
Three things have to hold, and only the first is obvious:
|
||||
Three things must hold, and only the first is obvious:
|
||||
|
||||
* what goes in comes back out, exactly, including a snapshot written before
|
||||
the conversion and one that is NULL;
|
||||
* the model still hands callers a dict, so no call site changes;
|
||||
* migration 44 drops the original column, so the backfill is the one
|
||||
destructive step in this file — it must convert every row or abort.
|
||||
* What goes in comes back out exactly, including a snapshot written before
|
||||
the conversion and one that is NULL.
|
||||
* The model still hands callers a dict, so no call site changes.
|
||||
* Migration 44 drops the original column, so the backfill is the one
|
||||
destructive step in this file. It must convert every row or abort.
|
||||
|
||||
python -m pytest tests/test_snapshot_compression.py -v
|
||||
"""
|
||||
@@ -147,8 +147,9 @@ def test_null_stays_null(db, adventure):
|
||||
|
||||
|
||||
def test_an_unreadable_snapshot_reads_as_none_rather_than_raising(db, adventure):
|
||||
"""One corrupt row must not 500 the turn that happens to load it. The
|
||||
snapshot is a debugging view; the story is the thing that matters."""
|
||||
"""One corrupt row must not return a 500 error for the turn that loads
|
||||
it. The snapshot is a debugging view. The story is what actually
|
||||
matters."""
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id, index=0, type="ai", text="t",
|
||||
context_snapshot={"a": "b"},
|
||||
@@ -236,9 +237,9 @@ def test_bootstrap_leaves_the_column_named_context_snapshot(db, adventure):
|
||||
def test_the_backfill_aborts_rather_than_dropping_unconvertible_data(
|
||||
db, adventure, monkeypatch
|
||||
):
|
||||
"""Migration 44 destroys the original. If anything cannot be converted the
|
||||
whole run has to roll back with the column still there — the alternative is
|
||||
losing somebody's prompts to a bug in this file."""
|
||||
"""Migration 44 destroys the original column. If anything fails to
|
||||
convert, the whole run must roll back with the column still there.
|
||||
Otherwise a bug in this file could lose someone's prompts."""
|
||||
seed_pre_43(db, adventure)
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Tests for undo/retry rolling back the shared script_state scoreboard
|
||||
"""Tests for undo and retry rolling back the shared `script_state`
|
||||
(plan/11-state-revert-and-retry-fix.md).
|
||||
|
||||
Phase 14 SP4 turned the snapshots around. An action used to carry the state as
|
||||
it stood *before* it ran, and rolling back read the snapshot off the action
|
||||
being removed. It carries what it left *behind* now, and rolling back reads it
|
||||
off the node in front — which is the same number arrived at from the other
|
||||
side, and the only version a retry can use: attempts at one turn share a
|
||||
starting position and differ precisely in their outcome.
|
||||
Phase 14 SP4 reversed the snapshots. An action used to carry the state as
|
||||
it stood before it ran, and rolling back read the snapshot off the action
|
||||
being removed. Now it carries the state it left behind, and rolling back
|
||||
reads that state off the node in front of it. This is the same value
|
||||
reached from the other direction, and it is the only version a retry can
|
||||
use, because attempts at one turn share a starting position and differ
|
||||
only in their outcome.
|
||||
|
||||
Run from the backend dir: python -m pytest tests/test_state_revert.py -v
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Point the app at a throwaway SQLite file BEFORE importing anything that binds
|
||||
# the engine at import time (app.database reads AIDND_DB_PATH on import).
|
||||
# Point the app at a throwaway SQLite file before importing anything that
|
||||
# binds the engine at import time. `app.database` reads `AIDND_DB_PATH`
|
||||
# on import.
|
||||
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp.close()
|
||||
os.environ["AIDND_DB_PATH"] = _tmp.name
|
||||
@@ -77,9 +79,9 @@ def _forget_snapshots(db, adv):
|
||||
# ---------------------------------------------------------------- undo
|
||||
|
||||
def test_undo_reverts_state_to_before_the_turn(db):
|
||||
# A turn took the scoreboard from {gold:0} -> {gold:10}. The node in front
|
||||
# of the turn is what says where it started; current state is the mutated
|
||||
# one.
|
||||
# A turn moved script_state from {gold:0} to {gold:10}. The node in
|
||||
# front of the turn records where it started. The current state is
|
||||
# the mutated one.
|
||||
user, adv = _make_adventure(db, {"gold": 10})
|
||||
_add(db, adv, 0, "start", state_after={"gold": 0})
|
||||
_add(db, adv, 1, "do", state_after={"gold": 0})
|
||||
@@ -165,9 +167,9 @@ def test_undo_prunes_memory_covering_removed_actions(db):
|
||||
# -------------------------------------------------------- withdrawing a node
|
||||
|
||||
def test_forget_node_withdraws_only_what_that_node_produced(db):
|
||||
"""Phase 14 SP3: a memory hangs off the node its block ends on, so removing
|
||||
a node is a lookup rather than a scan for memories that have fallen off the
|
||||
end of the story."""
|
||||
"""Phase 14 SP3: a memory attaches to the node where its block ends, so
|
||||
removing a node is a lookup rather than a scan for memories that
|
||||
reference actions the story no longer has."""
|
||||
user, adv = _make_adventure(db, {})
|
||||
_add(db, adv, 0, "do")
|
||||
second = _add(db, adv, 1, "ai")
|
||||
@@ -216,9 +218,9 @@ def test_restore_state_ignores_a_node_with_no_outcome(db):
|
||||
# ---------------------------------------------------------------- retry
|
||||
|
||||
def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
|
||||
# Retry must roll the scoreboard back to what the node in front of the AI
|
||||
# action left behind, so regeneration doesn't stack output mutations on top
|
||||
# of the attempt being replaced.
|
||||
# Retry must roll script_state back to what the node in front of the AI
|
||||
# action left behind, so regeneration does not stack output mutations
|
||||
# on top of the attempt being replaced.
|
||||
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
|
||||
_add(db, adv, 0, "start", state_after={"gold": 0})
|
||||
_add(db, adv, 1, "do", state_after={"gold": 10})
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"""Phase 14 SP0 — the regression contract for the story tree.
|
||||
"""Phase 14 SP0: the regression contract for the story tree.
|
||||
|
||||
This file exists to answer one question, over and over, as the storage model is
|
||||
replaced underneath the app: **do existing adventures still behave exactly as
|
||||
they did?**
|
||||
This file exists to answer one question repeatedly, as the storage model is
|
||||
replaced underneath the app: do existing adventures still behave exactly as
|
||||
they did?
|
||||
|
||||
So it drives the product the way a player does — over HTTP, asserting only on
|
||||
API responses — and never reaches into the ORM to check how something is
|
||||
It drives the product the way a player does, over HTTP, asserting only on
|
||||
API responses. It never reaches into the ORM to check how something is
|
||||
stored. Everything it asserts is true of the linear implementation today and
|
||||
must stay true of the tree, because a linear story is a tree with one branch.
|
||||
|
||||
python -m pytest tests/test_story_tree_baseline.py -v
|
||||
|
||||
**It must pass UNMODIFIED through SP1 (schema), SP2 (branch clause) and SP3
|
||||
(memories on nodes).** If a change here looks necessary in one of those
|
||||
subphases, the change is wrong, not the test. SP4 is the first subphase allowed
|
||||
to move it, and only for the variant-count semantics called out in plan/14.
|
||||
This file must pass unmodified through SP1 (schema), SP2 (branch clause),
|
||||
and SP3 (memories on nodes). If a change here looks necessary in one of
|
||||
those subphases, the change is wrong, not the test. SP4 is the first
|
||||
subphase allowed to move it, and only for the variant-count semantics
|
||||
called out in plan/14.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
@@ -73,9 +74,9 @@ class ScriptedProvider:
|
||||
|
||||
|
||||
def _make_world(monkeypatch, *, seeded_actions: int = 0):
|
||||
"""A user + scenario + adventure, with `seeded_actions` extra story actions
|
||||
written straight to the database (paging wants more turns than it is worth
|
||||
playing one at a time)."""
|
||||
"""Create a user, a scenario, and an adventure, with `seeded_actions`
|
||||
extra story actions written straight to the database. Paging tests need
|
||||
more turns than it is practical to play one at a time."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="baseline@example.com")
|
||||
@@ -181,8 +182,8 @@ def _state(adv_id):
|
||||
# ------------------------------------------------------- opening the story
|
||||
|
||||
def test_adventure_opens_on_a_window_with_a_total(long_client):
|
||||
"""The payload is the newest window plus the whole story's length — that is
|
||||
how the client knows there is more above."""
|
||||
"""The payload includes the newest window and the whole story's length.
|
||||
That is how the client knows more actions exist above it."""
|
||||
payload = _open(long_client)
|
||||
seeded = adventures.ACTION_PAGE + 11 # + the start action
|
||||
assert payload["action_count"] == seeded
|
||||
@@ -247,7 +248,7 @@ def test_retry_replaces_the_text_and_keeps_the_attempt(client):
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
actions = _actions(client)
|
||||
# Still one AI action for this turn — a retry is a new take, not a new turn.
|
||||
# This turn still has one AI action. A retry creates a new take, not a new turn.
|
||||
assert [a["type"] for a in actions] == ["start", "do", "ai"]
|
||||
assert actions[-1]["text"] == "Attempt two."
|
||||
|
||||
@@ -279,14 +280,14 @@ def test_switching_back_to_an_earlier_attempt_restores_it(client):
|
||||
json={"index": 0})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _texts(client)[-1] == "Attempt one."
|
||||
# And the script state that attempt produced comes back with it.
|
||||
# The script state that attempt produced comes back with it.
|
||||
script_state, _ = _state(client.adv_id)
|
||||
assert script_state["gold"] == 10
|
||||
|
||||
|
||||
def test_only_the_newest_turn_can_be_switched(client):
|
||||
"""An older turn's alternatives stay readable but not selectable — the
|
||||
story after it was written as a continuation of what is live."""
|
||||
"""An older turn's alternatives stay readable but not selectable. The
|
||||
story after it continues from what is live."""
|
||||
ScriptedProvider.replies = ["One.", "Again.", "Two."]
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
@@ -313,7 +314,7 @@ def test_undo_removes_the_whole_turn_and_rolls_state_back(client):
|
||||
# Both the AI action and the player action that prompted it are gone.
|
||||
assert [a["type"] for a in page["actions"]] == ["start", "do", "ai"]
|
||||
assert page["total"] == 3
|
||||
# ...and the second turn's ten gold went with them.
|
||||
# The second turn's ten gold is also gone.
|
||||
script_state, _ = _state(client.adv_id)
|
||||
assert script_state["gold"] == 10
|
||||
|
||||
@@ -338,9 +339,9 @@ def test_the_opening_cannot_be_undone(client):
|
||||
# ------------------------------------------------------------------ paging
|
||||
|
||||
def test_paging_walks_back_to_the_start_without_gaps_or_repeats(long_client):
|
||||
"""The property that matters: page all the way up and you have seen every
|
||||
action exactly once, in order. This is the invariant a tree must preserve —
|
||||
the anchor is an action, never a position."""
|
||||
"""The property that matters: paging all the way up must show every
|
||||
action exactly once, in order. A tree must preserve this invariant. The
|
||||
anchor is always an action, never a position."""
|
||||
payload = _open(long_client)
|
||||
total = payload["action_count"]
|
||||
seen = [a["id"] for a in payload["actions"]]
|
||||
@@ -376,7 +377,7 @@ def test_paging_past_the_start_reports_the_end(long_client):
|
||||
r = long_client.get(f"/api/adventures/{long_client.adv_id}/actions",
|
||||
params={"limit": 5})
|
||||
oldest = r.json()["actions"][0]["id"]
|
||||
# Walk right off the front.
|
||||
# Continue paging until it reaches the oldest action.
|
||||
seen_all = False
|
||||
cursor = oldest
|
||||
for _ in range(100):
|
||||
@@ -399,8 +400,9 @@ def test_editing_an_action_sticks(client):
|
||||
r = client.patch(f"/api/adventures/{client.adv_id}/actions/{action_id}",
|
||||
json={"text": "Edited."})
|
||||
assert r.status_code == 200, r.text
|
||||
# Re-read rather than trusting the response: an edit that only lives in the
|
||||
# response has silently reverted for anyone who reloads.
|
||||
# Re-read from the database instead of trusting the response. An edit
|
||||
# that only appears in the response has already reverted for anyone who
|
||||
# reloads.
|
||||
assert _texts(client)[-1] == "Edited."
|
||||
|
||||
|
||||
@@ -450,13 +452,13 @@ def test_memories_are_created_listed_and_deleted(client):
|
||||
# ------------------------------------------------------------------ export
|
||||
|
||||
def test_export_carries_the_whole_story(client):
|
||||
"""A bundle is a backup: every action, not the window.
|
||||
"""A bundle is a backup: it contains every action, not just the window.
|
||||
|
||||
SP6 changed the format assertion below, as this note said it would. It also
|
||||
changed one more line in this file than the note allowed for — the
|
||||
`variants` array in `test_export_keeps_retry_attempts`, which is the same
|
||||
fact seen from the other side: a bundle that has coordinates has no use for
|
||||
a repeating group. Everything else here still passes unmodified.
|
||||
SP6 changed the format assertion below, as this note said it would. It
|
||||
also changed one more line than the note allowed for: the `variants`
|
||||
array in `test_export_keeps_retry_attempts`. That change reflects the
|
||||
same fact: a bundle that stores coordinates has no use for a repeating
|
||||
group. Everything else here still passes unmodified.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client, "go north")
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Phase 14 SP9 — editing the take you are actually reading.
|
||||
"""Phase 14 SP9: editing the take you are actually reading.
|
||||
|
||||
The pager can park a turn on take 2 of 4. The transcript row it sits in is
|
||||
still keyed by the *live* take, because that is the row the story tells and the
|
||||
one the window carries; the take being read is only in the client's hand, by
|
||||
its own node id.
|
||||
The pager can display a turn on take 2 of 4. The transcript row for that
|
||||
turn stays keyed by the live take, because that is the row the transcript
|
||||
reports and the one the window carries. The take currently displayed is
|
||||
known only to the client, by its own node id.
|
||||
|
||||
So "edit this" has two ids to choose from, and the page shipped choosing the
|
||||
wrong one: it opened the editor on the live take's text and saved over it, from
|
||||
a row that was showing take 2. That is a client bug and it is fixed in
|
||||
Play.jsx, but the fix rests on something only the server can promise —
|
||||
So "edit this" has two ids to choose from, and the page shipped with the
|
||||
wrong one. It opened the editor on the live take's text and saved over it,
|
||||
even from a row that was displaying take 2. This is a client bug, and it
|
||||
is fixed in Play.jsx. The fix depends on a guarantee only the server can
|
||||
make:
|
||||
|
||||
a take is an ordinary row to the edit endpoint, addressed by its own id,
|
||||
whether or not it is the one the path runs through
|
||||
A take is an ordinary row to the edit endpoint, addressed by its own id,
|
||||
whether or not it is the one the path runs through.
|
||||
|
||||
— and on the group listing telling the truth about it afterwards. Both are
|
||||
asserted here so the client's fix cannot be quietly undermined.
|
||||
The fix also depends on the group listing reporting this correctly
|
||||
afterward. This file asserts both guarantees, so the client's fix cannot
|
||||
be silently broken.
|
||||
|
||||
python -m pytest tests/test_take_edit.py -v
|
||||
"""
|
||||
@@ -119,7 +121,7 @@ def _edit(client, action_id, text):
|
||||
|
||||
|
||||
def _live_ai(client):
|
||||
"""The AI row the story currently tells, as the transcript reports it."""
|
||||
"""The AI row the transcript currently reports as live."""
|
||||
adv = client.get(f"/api/adventures/{client.adv_id}").json()
|
||||
return [a for a in adv["actions"] if a["type"] == "ai"][-1]
|
||||
|
||||
@@ -145,8 +147,9 @@ def test_the_pager_reads_four_distinct_takes(client):
|
||||
def test_editing_a_take_that_is_not_live_edits_that_take(client):
|
||||
"""The bug, at the level the client's fix depends on.
|
||||
|
||||
Saving against take 2's own id must land on take 2 — not be refused for
|
||||
being off the path, and not be redirected onto the live row.
|
||||
Saving against take 2's own id must land on take 2. The request must
|
||||
not be refused for being off the path, and must not be redirected onto
|
||||
the live row.
|
||||
"""
|
||||
row, takes = _four_takes(client)
|
||||
second = takes[1]
|
||||
@@ -171,12 +174,13 @@ def test_editing_a_take_leaves_the_live_one_alone(client):
|
||||
|
||||
|
||||
def test_a_take_edit_survives_paging_away_and_back(client):
|
||||
"""The listing is the pager's only source, so the edit has to be in it.
|
||||
"""The listing is the pager's only source, so the edit must appear in it.
|
||||
|
||||
(The client caches this list per message; the fix drops that cache after an
|
||||
edit. If the server ever started answering from a copy of its own, stepping
|
||||
away and back would show the words before the edit and nobody would see it
|
||||
here — hence the round trip.)
|
||||
The client caches this list per message, and the fix drops that cache
|
||||
after an edit. If the server ever started answering from a stale copy
|
||||
of its own, stepping away and back would show the text before the
|
||||
edit, and this test would not catch it. That is why this test makes
|
||||
the round trip.
|
||||
"""
|
||||
row, takes = _four_takes(client)
|
||||
_edit(client, takes[1]["id"], "Take 2, rewritten.")
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Phase 14 SP9 — a turn's takes are grouped by their parent, not by where they sit.
|
||||
"""Phase 14 SP9: a turn's takes are grouped by their parent, not by where
|
||||
they sit.
|
||||
|
||||
SP4 made every take a node at the same (branch, depth). That coordinate answers
|
||||
"which takes belong to this turn" right up until one of them is forked onto its
|
||||
own branch — at which point it *leaves* the coordinate and reads as the only
|
||||
take of its turn, with its siblings unreachable from the line it was taken on.
|
||||
SP4 made every take a node at the same (branch, depth). That coordinate
|
||||
answers "which takes belong to this turn" until one of them forks onto its
|
||||
own branch. At that point it leaves the coordinate and reads as the only
|
||||
take of its turn, with its siblings unreachable from the line it was taken
|
||||
on.
|
||||
|
||||
The parent does not move when a branch does, which is the whole of the fix. It
|
||||
also gets the nesting right for free: takes under C1 and takes under C2 share a
|
||||
depth and, until one forks, a branch. Only the parent separates them.
|
||||
The parent does not move when a branch does, which is the whole fix. It
|
||||
also gets the nesting right without extra work: takes under C1 and takes
|
||||
under C2 share a depth and, until one forks, a branch. Only the parent
|
||||
separates them.
|
||||
|
||||
And the branch itself is lazy now. Stepping between takes creates nothing —
|
||||
looking is free. The fork happens on the first thing *written* below a take the
|
||||
story moved past, which is the first moment the player has said which line they
|
||||
mean.
|
||||
The branch itself is lazy now. Stepping between takes creates nothing:
|
||||
looking is free. The fork happens on the first write below a take the
|
||||
story moved past, which is the first moment the player has said which line
|
||||
they mean.
|
||||
|
||||
python -m pytest tests/test_take_parentage.py -v
|
||||
"""
|
||||
@@ -124,7 +127,8 @@ def _branch_count(adv_id) -> int:
|
||||
|
||||
|
||||
def _ai_rows(adv_id) -> list[models.Action]:
|
||||
"""Every AI node ever written, oldest first — live or not, any branch."""
|
||||
"""Every AI node ever written, oldest first. Includes both live and
|
||||
discarded nodes, on any branch."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
@@ -152,7 +156,7 @@ def _group_size(action_id: int) -> int:
|
||||
# ------------------------------------------------------------------ tests
|
||||
|
||||
def test_retaken_turn_groups_all_its_takes(client):
|
||||
"""The baseline the rest of the file leans on: three takes, one turn."""
|
||||
"""The baseline the rest of the file relies on: three takes, one turn."""
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_retry(client)
|
||||
@@ -178,7 +182,7 @@ def test_a_forked_take_keeps_its_siblings(client):
|
||||
first_take = _ai_rows(client.adv_id)[0]
|
||||
assert first_take.live is False
|
||||
|
||||
# Writing below it is what forks -- see the next test.
|
||||
# Writing below it is what forks. See the next test.
|
||||
_play(client, "go back and try this instead", after_id=first_take.id)
|
||||
|
||||
assert _group_size(first_take.id) == 3, (
|
||||
@@ -219,9 +223,9 @@ def test_writing_below_a_passed_take_forks_exactly_once(client):
|
||||
def test_takes_under_one_parent_do_not_count_takes_under_its_sibling(client):
|
||||
"""The player's own example: 3/3 on one line, 2/2 on the other.
|
||||
|
||||
C1 and C2 are takes of the same turn. What is played *below* each of them
|
||||
is a different turn, and the two must not pool -- they share a depth, and
|
||||
until the fork they share a branch too.
|
||||
C1 and C2 are takes of the same turn. What is played below each of them
|
||||
is a different turn, and the two turns must not merge. They share a
|
||||
depth, and until the fork they share a branch too.
|
||||
"""
|
||||
_play(client)
|
||||
_retry(client) # two takes at this turn: C1, C2 (C2 live)
|
||||
@@ -276,12 +280,12 @@ def _done_action(response) -> dict:
|
||||
|
||||
|
||||
def test_the_streamed_action_carries_the_pager_too(client):
|
||||
"""Found by driving it, not by testing it.
|
||||
"""Found while exercising the app manually, not by a targeted test.
|
||||
|
||||
A retry's reply *is* the second take of its turn, so it arrives needing a
|
||||
pager. The stream builds its own ActionOut and so missed the annotation:
|
||||
the pager appeared only once the page was reloaded, which is exactly the
|
||||
moment nobody reloads.
|
||||
A retry's reply is the second take of its turn, so it arrives needing a
|
||||
pager. The stream builds its own ActionOut and missed the annotation.
|
||||
The pager appeared only once the page was reloaded, which is exactly
|
||||
the moment nobody reloads.
|
||||
"""
|
||||
_play(client)
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
@@ -365,11 +369,12 @@ def test_a_players_own_turn_can_be_played_again(client):
|
||||
|
||||
|
||||
def test_a_retaken_player_turn_is_not_formatted_twice(client):
|
||||
"""Found by driving it: "> You > You open the door."
|
||||
"""Found while exercising the app manually: "> You > You open the door."
|
||||
|
||||
The editor is seeded from the stored text, which is already the formatted
|
||||
form — the same text plain edit puts in the box and writes back verbatim.
|
||||
Running it through the formatter again doubles the prefix.
|
||||
The editor is seeded from the stored text, which is already in the
|
||||
formatted form. Plain edit puts that same text in the box and writes
|
||||
it back verbatim. Running it through the formatter again doubles the
|
||||
prefix.
|
||||
"""
|
||||
_play(client, "open the door")
|
||||
_play(client, "press on")
|
||||
@@ -423,7 +428,7 @@ def test_an_ai_turn_at_the_tip_takes_no_branch(client):
|
||||
|
||||
|
||||
def test_an_ai_turn_the_story_moved_past_takes_a_branch(client):
|
||||
"""Retry could never reach here at all — it only ever saw the newest turn."""
|
||||
"""Retry could never reach this case: it only ever saw the newest turn."""
|
||||
_play(client)
|
||||
_play(client, "press on")
|
||||
before = _branch_count(client.adv_id)
|
||||
@@ -445,7 +450,7 @@ def test_the_old_line_still_has_its_continuation(client):
|
||||
first_ai = _ai_rows(client.adv_id)[0]
|
||||
_take(client, first_ai.id, "")
|
||||
|
||||
# The new take is what this branch tells; "press on" belonged to the other.
|
||||
# The new take is what this branch tells. "press on" belonged to the other.
|
||||
blob = "\n".join(_path_texts(client))
|
||||
assert "press on" not in blob
|
||||
kept = "\n".join(a.text for a in _user_rows(client.adv_id))
|
||||
@@ -471,10 +476,10 @@ def test_the_opening_has_no_other_take(client):
|
||||
def test_retaking_even_the_newest_player_turn_forks(client):
|
||||
"""A player turn is never the tip: the reply to it is.
|
||||
|
||||
Retaking the *last* thing the player typed still has a story to protect —
|
||||
the AI answered it, and that answer was written for the old text. So the
|
||||
branch is owed here too, and the guard against forking for nothing only
|
||||
ever fires for a player action with no reply under it.
|
||||
Retaking the last thing the player typed still puts existing story at
|
||||
risk: the AI answered it, and that answer was written for the old
|
||||
text. So this case must fork too. The guard against forking for
|
||||
nothing only fires for a player action with no reply under it.
|
||||
"""
|
||||
_play(client, "open the door")
|
||||
before = _branch_count(client.adv_id)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Phase 14 SP9 — what a take does to the shared state.
|
||||
"""Phase 14 SP9: what a take does to the shared state.
|
||||
|
||||
A turn does not only write text. A script mutates `script_state`, the referee
|
||||
mutates `world_state`, and both are *shared* — they belong to the adventure, not
|
||||
to the node. So playing a turn again has to put them back to where they were
|
||||
before that turn ran, or the new take stacks its mutations on top of the one it
|
||||
replaces and the numbers drift every time the player asks for another take.
|
||||
A turn does not only write text. A script mutates `script_state`, and the
|
||||
referee mutates `world_state`. Both are shared: they belong to the
|
||||
adventure, not to the node. Playing a turn again must put them back to
|
||||
where they were before that turn ran. Otherwise the new take stacks its
|
||||
mutations on top of the one it replaces, and the numbers drift every time
|
||||
the player asks for another take.
|
||||
|
||||
`retry` has done this since SP4 (`attempts.roll_back_before`). These are the
|
||||
same guarantee for the two roads SP9 opened: a take of a turn the story moved
|
||||
past, and a take of the player's own turn. Both create a branch, which is the
|
||||
interesting part — the rollback has to survive leaving the line it was on.
|
||||
`retry` has provided this guarantee since SP4 (`attempts.roll_back_before`).
|
||||
These tests confirm the same guarantee for the two roads SP9 opened: a take
|
||||
of a turn the story moved past, and a take of the player's own turn. Both
|
||||
create a branch, which matters because the rollback must survive leaving
|
||||
the line it was on.
|
||||
|
||||
python -m pytest tests/test_take_state.py -v
|
||||
"""
|
||||
@@ -34,9 +36,9 @@ from app.routers import adventures
|
||||
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
|
||||
# Ten gold a turn, every turn. A number that only ever goes up is the clearest
|
||||
# possible witness to a rollback: if a take stacks instead of replacing, it says
|
||||
# so in one digit.
|
||||
# Ten gold a turn, every turn. A number that only ever increases makes a
|
||||
# rollback failure obvious: if a take stacks instead of replacing, the gold
|
||||
# total is off by exactly one turn's worth.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
@@ -146,7 +148,7 @@ def _rows(adv_id, type_):
|
||||
|
||||
|
||||
def test_the_script_runs_once_a_turn(client):
|
||||
"""The premise the rest of the file rests on."""
|
||||
"""This test establishes the baseline the rest of the file depends on."""
|
||||
_play(client)
|
||||
assert _gold(client.adv_id) == 10
|
||||
_play(client, "press on")
|
||||
@@ -156,9 +158,9 @@ def test_the_script_runs_once_a_turn(client):
|
||||
def test_a_take_of_a_past_ai_turn_does_not_stack_its_script(client):
|
||||
"""Two turns played, then the first one taken again.
|
||||
|
||||
The take leaves the path just before turn one, so the state it starts from
|
||||
is the state turn one started from — nothing, not the twenty that two turns
|
||||
had accumulated. Then its own run adds ten.
|
||||
The take leaves the path just before turn one. The state it starts from
|
||||
is the state turn one started from: zero gold, not the twenty that two
|
||||
turns accumulated. The take's own run then adds ten.
|
||||
"""
|
||||
_play(client)
|
||||
_play(client, "press on")
|
||||
@@ -182,11 +184,11 @@ def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
|
||||
|
||||
|
||||
def test_writing_below_a_passed_take_starts_from_that_take_s_state(client):
|
||||
"""The `after_id` road, which forks on the way to writing.
|
||||
"""The `after_id` path, which forks while writing.
|
||||
|
||||
The take being written under produced the state its own turn left behind —
|
||||
ten — and the turn played on top of it adds the next ten. The twenty the
|
||||
abandoned line reached has nothing to do with this branch.
|
||||
The take being written under produced a state of ten gold, from its own
|
||||
turn. The turn played on top of it adds another ten. The twenty gold
|
||||
that the abandoned line reached has no effect on this branch.
|
||||
"""
|
||||
_play(client)
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Phase 14 SP1 — every existing adventure becomes a tree with one branch.
|
||||
"""Phase 14 SP1: every existing adventure becomes a tree with one branch.
|
||||
|
||||
The migration this file watches is the one that cannot be re-run: it reads
|
||||
`index` and writes `depth`, and from SP2 on the reads follow `depth`. If it
|
||||
mis-maps a row, that row does not error — it *disappears from the story*, which
|
||||
is why the assertions here are about every row rather than about a sample.
|
||||
The migration this file tests cannot be re-run. It reads `index` and
|
||||
writes `depth`, and from SP2 on, the reads follow `depth`. If it mis-maps
|
||||
a row, that row does not raise an error. It disappears from the story,
|
||||
which is why the assertions here check every row instead of a sample.
|
||||
|
||||
The fixture is a genuine **schema 45** database, not a current one with an old
|
||||
stamp. `create_all` always builds the current schema, so the three tables the
|
||||
tree touches are dropped and rebuilt from frozen pre-tree DDL below; the
|
||||
migration then runs its real ALTERs against them, including the one that adds a
|
||||
foreign key. A pre-migration database built any other way (stamp rewound,
|
||||
columns left in place) would quietly skip the DDL and test half the change.
|
||||
The fixture is a genuine schema-45 database, not a current one with an
|
||||
old stamp. `create_all` always builds the current schema, so the three
|
||||
tables the tree touches are dropped and rebuilt from the frozen pre-tree
|
||||
DDL below. The migration then runs its real ALTER statements against
|
||||
them, including the one that adds a foreign key. A pre-migration database
|
||||
built any other way, such as a rewound stamp with the columns left in
|
||||
place, would silently skip the DDL and test only half the change.
|
||||
|
||||
python -m pytest tests/test_tree_migration.py -v
|
||||
"""
|
||||
@@ -34,10 +35,11 @@ from app.context import history
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
# The three tables as they stood at schema 45, frozen. This is a snapshot of a
|
||||
# past schema and must NOT be updated to track models.py — the whole point is
|
||||
# that it lacks what SP1 adds. SQLite spelling only; the migration's Postgres
|
||||
# half is exercised against a real server at deploy time (see plan/14).
|
||||
# The three tables as they stood at schema 45, frozen. This is a snapshot
|
||||
# of a past schema. It must not be updated to track `models.py`, because
|
||||
# the whole point is that it lacks what SP1 adds. This DDL uses SQLite
|
||||
# syntax only. The migration's Postgres half is exercised against a real
|
||||
# server at deploy time (see plan/14).
|
||||
PRE_TREE_DDL = (
|
||||
"""
|
||||
CREATE TABLE adventures (
|
||||
@@ -96,15 +98,17 @@ PRE_TREE_DDL = (
|
||||
""",
|
||||
)
|
||||
|
||||
# The story of adventure "Gapped": index 3 is missing, because deleting a middle
|
||||
# action never renumbered the ones after it. The gap has to survive as a gap.
|
||||
# The story of adventure "Gapped": index 3 is missing, because deleting a
|
||||
# middle action never renumbered the ones after it. The migration must
|
||||
# preserve the gap.
|
||||
GAPPED_INDEXES = (0, 1, 2, 4)
|
||||
STRAIGHT_INDEXES = (0, 1)
|
||||
# "Blank" holds an action whose text is nothing but whitespace. It is a row of
|
||||
# the adventure but not of the *story*, so a cursor counting covered actions
|
||||
# never counted it — and migration 56 has to skip it the same way, using a
|
||||
# frozen copy of the story-text predicate. This is the one duplicated
|
||||
# definition in the change, so it gets the one case that can tell.
|
||||
# "Blank" holds an action whose text is nothing but whitespace. It is a
|
||||
# row of the adventure but not of the story, so a cursor counting covered
|
||||
# actions never counted it. Migration 56 must skip it the same way, using
|
||||
# a frozen copy of the story-text predicate. This predicate is the one
|
||||
# duplicated definition in the change, so this is the one test case that
|
||||
# can catch it drifting.
|
||||
BLANK_INDEXES = (0, 1, 2, 3)
|
||||
BLANK_AT = 2
|
||||
|
||||
@@ -113,15 +117,16 @@ BLANK_AT = 2
|
||||
def pre_tree():
|
||||
"""A schema-45 database with three adventures in it, returned as the ids
|
||||
(gapped, straight, empty) their stories were written under."""
|
||||
# Every test in this module shares one temp file, and a setup that fails
|
||||
# before its yield never reaches a teardown — so start from empty rather
|
||||
# than from whatever the last one left.
|
||||
# Every test in this module shares one temp file, and a setup that
|
||||
# fails before its yield never reaches a teardown. Start from empty
|
||||
# instead of from whatever the previous test left.
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
# `branches` and the six new columns never existed at 45. Dropping the
|
||||
# tables is the only way to lose the columns: SQLite refuses to drop a
|
||||
# column a foreign key names, which is exactly the case for branch_id.
|
||||
# `branches` and the six new columns never existed at schema 45.
|
||||
# Dropping the tables is the only way to remove the columns.
|
||||
# SQLite refuses to drop a column that a foreign key references,
|
||||
# and that is exactly the case for `branch_id`.
|
||||
for table in ("actions", "memories", "branches", "adventures"):
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||
for ddl in PRE_TREE_DDL:
|
||||
@@ -132,11 +137,12 @@ def pre_tree():
|
||||
))
|
||||
|
||||
# The cursors as schema 45 held them: counts of covered story actions.
|
||||
# Gapped's story is 0,1,2,4 — so "3 covered" is the node at depth 2 and
|
||||
# "4 covered" is the node at depth 4, which is the whole reason a count
|
||||
# and a depth are not the same number. Straight is caught up past its
|
||||
# own end (5 covered, 2 actions), which is a state the older rule left
|
||||
# behind and the clamp used to paper over every post-turn pass.
|
||||
# Gapped's story is 0, 1, 2, 4, so "3 covered" is the node at depth
|
||||
# 2 and "4 covered" is the node at depth 4. This is the whole
|
||||
# reason a count and a depth are not the same number. Straight is
|
||||
# caught up past its own end (5 covered, 2 actions), a state the
|
||||
# older rule left behind that a clamp used to mask on every
|
||||
# post-turn pass.
|
||||
cursors_at = {"Gapped": (3, 4), "Straight": (5, 0), "Empty": (0, 0),
|
||||
"Blank": (3, 0)}
|
||||
ids = {}
|
||||
@@ -276,18 +282,19 @@ def test_memories_attach_to_the_node_they_summarised(pre_tree):
|
||||
assert depth == source_end, "the memory hangs off the last action it covered"
|
||||
assert branch_id is not None
|
||||
|
||||
# A hand-written memory summarised no node, so SP7's migration 62 lands it
|
||||
# at depth 0 of its branch. 0 is at or before every fork point, so it stays
|
||||
# visible from exactly the paths it was visible from before — anchoring
|
||||
# takes nothing out of anybody's existing bank.
|
||||
# A hand-written memory summarised no node, so SP7's migration 62
|
||||
# lands it at depth 0 of its branch. Depth 0 is at or before every
|
||||
# fork point, so the memory stays visible from exactly the paths it
|
||||
# was visible from before. Anchoring it this way removes nothing from
|
||||
# anybody's existing memory bank.
|
||||
manual = rows("SELECT depth, branch_id FROM memories WHERE source_end IS NULL")
|
||||
assert manual and all(depth == 0 and branch is not None for depth, branch in manual)
|
||||
|
||||
|
||||
def test_the_cursors_become_the_nodes_they_named(pre_tree):
|
||||
"""SP3, migration 56. A count of covered actions and a depth are different
|
||||
numbers the moment the story has a gap in it, which every adventure anyone
|
||||
has ever deleted from does."""
|
||||
"""SP3, migration 56. A count of covered actions and a depth become
|
||||
different numbers as soon as the story has a gap in it, and every
|
||||
adventure with a deleted action has one."""
|
||||
migrations.bootstrap(engine)
|
||||
|
||||
def marks(title):
|
||||
@@ -298,9 +305,9 @@ def test_the_cursors_become_the_nodes_they_named(pre_tree):
|
||||
)
|
||||
return row
|
||||
|
||||
# Gapped's story is 0,1,2,4. "3 covered" is the *third* action, at depth 2 —
|
||||
# reading the count as a depth would have handed the summarizer node 3,
|
||||
# which does not exist, and quietly skipped node 4 forever.
|
||||
# Gapped's story is 0, 1, 2, 4. "3 covered" is the third action, at
|
||||
# depth 2. Reading the count as a depth would hand the summarizer node
|
||||
# 3, which does not exist, and silently skip node 4 forever.
|
||||
memory_depth, summary_depth, memory_branch, summary_branch = marks("Gapped")
|
||||
assert (memory_depth, summary_depth) == (2, 4)
|
||||
root = scalar(
|
||||
@@ -319,10 +326,11 @@ def test_the_cursors_become_the_nodes_they_named(pre_tree):
|
||||
# Nothing covered stays nothing covered, and names no branch.
|
||||
assert marks("Empty") == (migrations.NO_DEPTH, migrations.NO_DEPTH, None, None)
|
||||
|
||||
# A whitespace-only action is a row but not a story action, so it was never
|
||||
# counted — "3 covered" of 0,1,[blank],3 is the node at depth 3, not 2. The
|
||||
# migration's copy of the story-text predicate is the only place that rule
|
||||
# is written twice, so this is the case that catches it drifting.
|
||||
# A whitespace-only action is a row but not a story action, so it was
|
||||
# never counted. "3 covered" of 0, 1, [blank], 3 is the node at depth
|
||||
# 3, not 2. The migration's copy of the story-text predicate is the
|
||||
# only place that rule is written twice, so this is the test case
|
||||
# that catches it drifting.
|
||||
assert marks("Blank")[0] == 3
|
||||
|
||||
# The legacy columns are left exactly as they were: a rolled-back build
|
||||
@@ -333,8 +341,9 @@ def test_the_cursors_become_the_nodes_they_named(pre_tree):
|
||||
|
||||
|
||||
def test_the_branch_clause_index_exists(pre_tree):
|
||||
"""SP2's reads are only cheap if this exists — and `create_all` does not add
|
||||
an index to a table it did not create, which is what migration 52 is for."""
|
||||
"""SP2's reads are only cheap if this index exists. `create_all` does
|
||||
not add an index to a table it did not create, which is what
|
||||
migration 52 handles."""
|
||||
migrations.bootstrap(engine)
|
||||
|
||||
assert scalar(
|
||||
@@ -354,10 +363,10 @@ def test_running_it_again_changes_nothing(pre_tree):
|
||||
rows("SELECT id, branch_id, depth FROM memories ORDER BY id"),
|
||||
)
|
||||
|
||||
# Twice through the deploy path, then the data pass on its own — the stamp
|
||||
# stops the first, the NULL guards stop the second, and a migration that
|
||||
# only survives because of the stamp is one bad rescue away from doubling
|
||||
# every branch.
|
||||
# Run the deploy path twice, then the data pass on its own. The stamp
|
||||
# stops the first run, the NULL guards stop the second, and a
|
||||
# migration that only survives because of the stamp is one bad rescue
|
||||
# away from doubling every branch.
|
||||
migrations.bootstrap(engine)
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_tree(conn)
|
||||
@@ -379,8 +388,8 @@ def test_running_it_again_changes_nothing(pre_tree):
|
||||
def client(monkeypatch):
|
||||
"""The app on a migrated database, so new rows go through the real writers.
|
||||
|
||||
Everything the migration fixes is only half the job: no migration will ever
|
||||
visit a row written after it ran, and a row without a branch is a row no
|
||||
Fixing existing rows is only half the job. No migration ever revisits
|
||||
a row written after it ran, and a row without a branch is a row no
|
||||
read can see.
|
||||
"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
@@ -444,9 +453,10 @@ def test_a_blank_adventure_has_a_branch_before_anything_is_played(client):
|
||||
def test_a_hand_written_memory_is_anchored_at_the_head(client):
|
||||
"""SP7: nothing carries a NULL depth anymore.
|
||||
|
||||
On an adventure with no story yet the head is NO_DEPTH (-1), which reads as
|
||||
"before the first node" and so is in range of every branch — right for a
|
||||
note written before anything has happened.
|
||||
On an adventure with no story yet, the head is `NO_DEPTH` (-1), which
|
||||
reads as "before the first node" and so falls in range of every
|
||||
branch. This is correct for a note written before anything has
|
||||
happened.
|
||||
"""
|
||||
adventure_id = client.post("/api/adventures", json={}).json()["id"]
|
||||
|
||||
@@ -466,9 +476,10 @@ def test_a_hand_written_memory_is_anchored_at_the_head(client):
|
||||
|
||||
|
||||
def test_deleting_a_branch_takes_its_nodes_with_it(client):
|
||||
"""`ON DELETE CASCADE` on both `branch_id` columns, so the database removes a
|
||||
branch's nodes rather than any code remembering to. SP7 ships delete-a-branch
|
||||
on top of exactly this, and nothing else has to load a branch to do it."""
|
||||
"""`ON DELETE CASCADE` on both `branch_id` columns means the database
|
||||
removes a branch's nodes instead of relying on application code to
|
||||
remember to. SP7 builds delete-a-branch directly on top of this, and
|
||||
no other code needs to load a branch to do it."""
|
||||
adventure_id = client.post(
|
||||
"/api/adventures", json={"scenario_id": client.scenario_id}
|
||||
).json()["id"]
|
||||
@@ -509,8 +520,8 @@ def test_deleting_an_adventure_takes_its_branch_with_it(client):
|
||||
|
||||
|
||||
def test_deleting_the_newest_action_moves_the_head_back(client):
|
||||
"""The head is a cache, and a cache that only ever moves forward is wrong
|
||||
the first time someone undoes a turn."""
|
||||
"""The head is a cache. A cache that only ever moves forward becomes
|
||||
wrong the first time someone undoes a turn."""
|
||||
adventure_id = client.post(
|
||||
"/api/adventures", json={"scenario_id": client.scenario_id}
|
||||
).json()["id"]
|
||||
@@ -540,10 +551,11 @@ def test_deleting_the_newest_action_moves_the_head_back(client):
|
||||
|
||||
# ---------------------------------------- SP4: variants become sibling rows
|
||||
|
||||
# One turn's retry history as schema 45 stored it: a JSON array on the AI row,
|
||||
# with `variant_index` naming the entry `text` mirrors. The live one is
|
||||
# deliberately not the last written — a migration that assumed it was would
|
||||
# look right on every fixture where the player never went back.
|
||||
# One turn's retry history as schema 45 stored it: a JSON array on the
|
||||
# AI row, with `variant_index` naming the entry `text` mirrors. The live
|
||||
# entry is deliberately not the last one written. A migration that
|
||||
# assumed it was would still pass on every fixture where the player
|
||||
# never paged back.
|
||||
RETRY_VARIANTS = [
|
||||
{"text": "Attempt one.", "reasoning": None,
|
||||
"script_state": {"gold": 10}, "created_at": "2026-01-01T00:00:00",
|
||||
@@ -563,10 +575,10 @@ RETRY_VARIANTS = [
|
||||
]
|
||||
LIVE_VARIANT = 1
|
||||
|
||||
# The whole turn's assembled prompt, stored once. The attempts differ only in
|
||||
# the three slices above, which is the arrangement SP4 has to preserve — giving
|
||||
# each sibling a copy of this would multiply the biggest column in the database
|
||||
# by the retry count.
|
||||
# The whole turn's assembled prompt, stored once. The attempts differ
|
||||
# only in the three slices above, and SP4 must preserve that arrangement.
|
||||
# Giving each sibling a copy of this prompt would multiply the largest
|
||||
# column in the database by the retry count.
|
||||
RETRY_SNAPSHOT = {
|
||||
"sections": [{"label": "history", "text": "A long prompt.", "tokens": 4}],
|
||||
"prompt": {"system": "S", "story": "A long prompt."},
|
||||
@@ -578,11 +590,13 @@ RETRY_SNAPSHOT = {
|
||||
|
||||
@pytest.fixture()
|
||||
def pre_split():
|
||||
"""A schema-45 adventure with one retried turn, plus a plain turn each side.
|
||||
"""A schema-45 adventure with one retried turn, plus a plain turn on
|
||||
each side.
|
||||
|
||||
Separate from `pre_tree` so SP1's assertions keep counting what they were
|
||||
written to count. The story is: 0 start, 1 do, 2 ai (three attempts), 3 do,
|
||||
and the adventure's live state is the one attempt 1 produced.
|
||||
This fixture is separate from `pre_tree` so SP1's assertions keep
|
||||
counting what they were written to count. The story is: 0 start, 1
|
||||
do, 2 ai (three attempts), 3 do. The adventure's live state is the
|
||||
one attempt 1 produced.
|
||||
"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -603,8 +617,9 @@ def pre_split():
|
||||
adventure_id = conn.execute(
|
||||
text("SELECT id FROM adventures WHERE title = 'Retried'")
|
||||
).scalar()
|
||||
# `state_before` on each row: the scoreboard as that action found it.
|
||||
# SP4 reads them one row along to build the `state_after` pair.
|
||||
# `state_before` on each row records the script state as that
|
||||
# action found it. SP4 reads each row's `state_before` from the
|
||||
# next row to build the `state_after` pair.
|
||||
for index, kind, before in (
|
||||
(0, "start", None), (1, "do", {"gold": 0}),
|
||||
(2, "ai", {"gold": 0}), (3, "do", {"gold": 20}),
|
||||
@@ -653,7 +668,7 @@ def test_each_attempt_becomes_a_row_at_the_turns_coordinate(pre_split):
|
||||
'AND "index" = 2', a=pre_split,
|
||||
)
|
||||
assert len(coordinates) == 1
|
||||
# ...and the rest of the story is untouched, still one row per turn.
|
||||
# The rest of the story is untouched, still one row per turn.
|
||||
assert scalar("SELECT count(*) FROM actions WHERE adventure_id = :a", a=pre_split) == 6
|
||||
|
||||
|
||||
@@ -703,10 +718,11 @@ def test_each_attempt_keeps_the_outcome_it_produced(pre_split):
|
||||
]
|
||||
assert parsed[0][2] == {"player": {"hp": 95}}
|
||||
assert parsed[1][2] == {"player": {"hp": 60}}
|
||||
# Attempt three recorded no world state — an adventure with no RPG layer,
|
||||
# or a take made before the column existed. It stays NULL rather than
|
||||
# borrowing a neighbour's, and switching to it leaves the RPG layer alone:
|
||||
# exactly what `apply_variant` did with an entry that had no world state.
|
||||
# Attempt three recorded no world state: either an adventure with no
|
||||
# RPG layer, or a take made before the column existed. It stays NULL
|
||||
# instead of borrowing a neighbor's, so switching to it leaves the RPG
|
||||
# layer alone. This matches what `apply_variant` did with an entry
|
||||
# that had no world state.
|
||||
assert parsed[2][2] is None
|
||||
|
||||
|
||||
@@ -739,7 +755,8 @@ def test_the_split_survives_being_run_again(pre_split):
|
||||
|
||||
|
||||
def test_the_migrated_story_reads_back_as_one_turn(pre_split):
|
||||
"""The point of all of it: the reads see a four-action story, not six."""
|
||||
"""This is the point of the whole migration: reads see a four-action
|
||||
story, not six."""
|
||||
migrations.bootstrap(engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""End-to-end HTTP tests for undo/retry state revert, driving real turns through
|
||||
the actual routes + scripting engine with only the LLM provider mocked.
|
||||
"""End-to-end HTTP tests for undo and retry state revert. These tests drive
|
||||
real turns through the actual routes and scripting engine, with only the LLM
|
||||
provider mocked.
|
||||
|
||||
A script's output hook adds 10 gold each turn; we assert the shared scoreboard
|
||||
behaves correctly across play / undo / retry.
|
||||
A script's output hook adds 10 gold each turn. These tests confirm that the
|
||||
adventure's stored gold total stays correct across play, undo, and retry.
|
||||
|
||||
python -m pytest tests/test_turn_flow_integration.py -v
|
||||
"""
|
||||
@@ -64,7 +65,7 @@ def client(monkeypatch):
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
# Force a real (non-demo) turn that uses our fake provider.
|
||||
# Force a real, non-demo turn that uses the fake provider.
|
||||
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", FakeProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
@@ -107,7 +108,7 @@ def test_play_then_undo_reverts_gold(client):
|
||||
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _state(client.adv_id) == {} # scoreboard rolled back
|
||||
assert _state(client.adv_id) == {} # gold reverted to zero
|
||||
|
||||
|
||||
def test_two_turns_then_undo_reverts_only_last(client):
|
||||
@@ -123,7 +124,8 @@ def test_retry_does_not_double_apply_gold(client):
|
||||
_play(client)
|
||||
assert _state(client.adv_id) == {"gold": 10}
|
||||
|
||||
# Before the fix this produced 20 (output hook ran twice); now it stays 10.
|
||||
# Before the fix, this produced 20 because the output hook ran twice.
|
||||
# Now it stays 10.
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _state(client.adv_id) == {"gold": 10}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Unit tests for the RPG world-state engine (Phase 12): delta extraction and
|
||||
the clamp/cooldown/milestone referee.
|
||||
the code that enforces clamp, cooldown, and milestone rules.
|
||||
|
||||
python -m pytest tests/test_worldstate.py -v
|
||||
"""
|
||||
@@ -67,7 +67,8 @@ def test_text_stat_noop_when_unchanged():
|
||||
def test_per_npc_distinct_stats():
|
||||
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.drake.ferocity": 20}, 1)
|
||||
assert ws["npc"]["drake"]["ferocity"] == 70
|
||||
# gwen has no "ferocity" stat, drake has no "trust" — cross paths are rejected.
|
||||
# gwen has no `ferocity` stat, and drake has no `trust` stat. Cross paths
|
||||
# are rejected.
|
||||
ws, report = w.apply_delta(ws, SCHEMA, {"npc.gwen.ferocity": 5, "npc.bogus.trust": 5}, 2)
|
||||
reasons = {r["reason"] for r in report["rejected"]}
|
||||
assert reasons == {"unknown npc stat", "unknown npc"}
|
||||
@@ -134,7 +135,8 @@ def test_flags_toggle_both_ways():
|
||||
ws, report = w.apply_delta(ws, SCHEMA, {"flags.has_key": True}, 1)
|
||||
assert ws["flags"]["has_key"] is True
|
||||
assert report["applied"]
|
||||
# flip back off — flags are two-way (unlike sticky milestones).
|
||||
# Setting it back to false works, because flags are two-way, unlike
|
||||
# sticky milestones.
|
||||
ws, _ = w.apply_delta(ws, SCHEMA, {"flags.has_key": False}, 2)
|
||||
assert ws["flags"]["has_key"] is False
|
||||
# setting to the same value is a no-op.
|
||||
@@ -150,8 +152,9 @@ def test_flag_rejects_non_bool_and_unknown():
|
||||
|
||||
|
||||
def test_override_sets_absolute_value_bypassing_cap():
|
||||
# A manual override isn't policed by max_delta_per_turn like a turn is —
|
||||
# it sets the value directly (still clamped to min/max).
|
||||
# A manual override does not obey max_delta_per_turn the way a turn
|
||||
# does. It sets the value directly, though the value is still clamped
|
||||
# to min/max.
|
||||
ws, report = w.apply_override(fresh(), SCHEMA, {"player.hp": 10})
|
||||
assert ws["player"]["hp"] == 10
|
||||
assert report["applied"] == [{"path": "player.hp", "old": 100, "new": 10}]
|
||||
@@ -202,9 +205,10 @@ def test_override_rejects_unknown_and_bad_type():
|
||||
|
||||
def test_reference_includes_desc_and_bands_independently():
|
||||
guide = w.render_reference(SCHEMA)
|
||||
# hp has both a description and a band ladder.
|
||||
# hp has both a description and a set of value bands.
|
||||
assert "very weak" in guide and "range 0–100" in guide
|
||||
# day (a counter here has no desc/bands) contributes nothing; flags show desc.
|
||||
# A counter like day has no desc or bands, so it contributes nothing
|
||||
# here. Flags do show their desc.
|
||||
assert "has_key (flag) — Holds the key." in guide
|
||||
# NPCs contribute their own description and per-NPC stat lines.
|
||||
assert "NPC Gwen (gwen) — A loyal ranger." in guide
|
||||
@@ -232,7 +236,8 @@ def test_extract_no_block():
|
||||
|
||||
|
||||
def test_extract_prose_ending_in_brace_not_eaten():
|
||||
# A bare object with no dotted keys is not a delta — leave the text alone.
|
||||
# A bare object with no dotted keys is not a delta, so the text stays
|
||||
# as is.
|
||||
clean, delta = w.extract_delta('He said {this}')
|
||||
assert delta == {}
|
||||
assert clean == "He said {this}"
|
||||
|
||||
@@ -36,8 +36,9 @@ SCHEMA = {
|
||||
"milestones": {"win": {"desc": "Win the fight"}},
|
||||
}
|
||||
|
||||
# The faked model narrates and appends a delta that exceeds the per-turn cap
|
||||
# (so we can see the engine clamp it), flips a flag, and completes a milestone.
|
||||
# The faked model narrates and appends a delta that exceeds the per-turn
|
||||
# cap, so the test can confirm the engine clamps it. It also flips a flag
|
||||
# and completes a milestone.
|
||||
AI_REPLY = (
|
||||
"The goblin's blade bites deep and Gwen nods at your resolve.\n\n"
|
||||
'```state\n{"player.hp": -80, "npc.gwen.trust": 15, "flags.alarm": true, "milestones.win": true}\n```'
|
||||
@@ -131,7 +132,7 @@ def test_turn_applies_clamped_delta_and_strips_block(client):
|
||||
# The state block is not shown to the player.
|
||||
assert "```state" not in _last_ai_text(client.adv_id)
|
||||
assert "goblin's blade" in _last_ai_text(client.adv_id)
|
||||
# ...but the raw model reply (with the block) is kept for the Insights view.
|
||||
# The raw model reply, including the block, is kept for the Insights view.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
snap = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
|
||||
@@ -175,7 +176,7 @@ def test_override_world_state_endpoint(client):
|
||||
# persisted to the DB, not just the response.
|
||||
assert _world(client.adv_id)["player"]["hp"] == 5
|
||||
|
||||
# bypasses max_delta_per_turn (30) — a direct correction, not a turn.
|
||||
# This bypasses max_delta_per_turn (30) because it is a direct correction, not a turn.
|
||||
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
|
||||
assert r.json()["state"]["player"]["hp"] == 100
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Build a bootable adventure that has actually gone two ways (Phase 14, SP7).
|
||||
|
||||
`tools.stress_session --keep` answers the scroll question and nothing else: its
|
||||
world state and script state are both empty, so it cannot show whether a branch
|
||||
switch puts the scoreboard back. This builds the small counterpart — a stat
|
||||
`tools.stress_session --keep` answers the scrolling question and nothing else.
|
||||
Its world state and script state are both empty, so it cannot show whether a
|
||||
branch switch restores them. This script builds the small counterpart: a stat
|
||||
schema, a script that counts gold, two attempts at one turn that do visibly
|
||||
different damage, a fork, and a hand-written memory on each branch.
|
||||
|
||||
@@ -17,9 +17,10 @@ branches apart, and then four of them went on showing the branch just left.
|
||||
AIDND_DB_PATH=/tmp/branches.db .venv/Scripts/python.exe \\
|
||||
-m uvicorn app.main:app --port 8010
|
||||
|
||||
Then open http://127.0.0.1:8010/ — the SPA is served out of `frontend/dist`, so
|
||||
run `npm run build` first if it is stale. Expect hp 60 on "The hard way down"
|
||||
and hp 95 on the fork, and expect both to move the moment you switch.
|
||||
Then open http://127.0.0.1:8010/. The SPA is served out of `frontend/dist`, so
|
||||
run `npm run build` first if that directory is stale. Expect hp 60 on "The hard
|
||||
way down" and hp 95 on the fork, and expect both to change as soon as you
|
||||
switch.
|
||||
|
||||
No LLM is called: the provider is scripted and its replies carry their own
|
||||
fenced `state` blocks.
|
||||
@@ -119,9 +120,9 @@ def play(text):
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# Turn one: a scratch. Retried into a beating. The story continues from the
|
||||
# beating, so the scratch is the attempt left behind — and the two differ by 35
|
||||
# hit points, which is the number a switch has to put back.
|
||||
# Turn one is a scratch, retried into a beating. The story continues from the
|
||||
# beating, so the scratch is the attempt left behind. The two differ by 35 hit
|
||||
# points, which is the number a switch has to restore.
|
||||
ScriptedProvider.replies = [
|
||||
"You ease the door open and a nail catches your wrist.\n"
|
||||
"```state\n{\"player.hp\": -5}\n```",
|
||||
|
||||
+14
-12
@@ -4,7 +4,8 @@ Query *counts* are easy to see and have never been the problem here. Both
|
||||
egress blowouts this project has had were one query fetching a column nobody
|
||||
read: `context_snapshot` on every action, then every memory's embedding on
|
||||
every turn. Counting statements would have shown nothing wrong in either case,
|
||||
and at development scale — ten rows, no embeddings — so would a stopwatch.
|
||||
and at development scale, meaning ten rows and no embeddings, so would a
|
||||
stopwatch.
|
||||
|
||||
So this meter measures bytes, and it measures them at the only place the truth
|
||||
is available: the DBAPI cursor, after the driver has decoded a row and before
|
||||
@@ -20,9 +21,9 @@ Usage:
|
||||
print(meter.render())
|
||||
|
||||
`attach()` wraps the pool's connection factory, so it reuses the engine the app
|
||||
already configured rather than rebuilding one beside it — nothing about
|
||||
connect args, pre-ping or the SQLite foreign-key pragma has to be repeated
|
||||
here, and drift between the metered engine and the real one is impossible.
|
||||
already configured rather than building a second one. Nothing about connect
|
||||
args, pre-ping, or the SQLite foreign-key pragma is repeated here, and the
|
||||
metered engine cannot diverge from the real one.
|
||||
Existing pooled connections are dropped first, so a connection opened before
|
||||
attaching cannot quietly stay unmetered.
|
||||
|
||||
@@ -68,8 +69,8 @@ def value_bytes(value) -> int:
|
||||
if isinstance(value, (int, float)):
|
||||
return 8
|
||||
if isinstance(value, (dict, list)):
|
||||
# A JSON column the driver already parsed (psycopg does; SQLite does
|
||||
# not). Separators match what a database emits — no spaces.
|
||||
# A JSON column the driver already parsed. psycopg parses it and SQLite
|
||||
# does not. The separators match what a database emits, with no spaces.
|
||||
return len(json.dumps(value, separators=(",", ":"), default=str).encode("utf-8"))
|
||||
return len(str(value).encode("utf-8"))
|
||||
|
||||
@@ -82,8 +83,9 @@ def _table_of(statement: str) -> str:
|
||||
match = _TABLE_RE.search(statement)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
# No table to name — BEGIN, a PRAGMA, a savepoint. Group those under the
|
||||
# keyword so they stay countable instead of collapsing into one "?" bucket.
|
||||
# The statement names no table, such as BEGIN, a PRAGMA, or a savepoint.
|
||||
# Group those under the keyword, so they stay countable rather than collapse
|
||||
# into one "?" bucket.
|
||||
head = statement.strip().split(None, 1)
|
||||
return head[0].lower()[:20] if head else "(empty)"
|
||||
|
||||
@@ -236,10 +238,10 @@ class _MeteredConnection:
|
||||
class _MeteredCursor:
|
||||
"""Counts the bytes of every row handed back.
|
||||
|
||||
The fetch methods are wrapped rather than `execute`, because what a
|
||||
statement *costs* is not knowable when it is sent — `SELECT * FROM
|
||||
memories` and `SELECT count(*) FROM memories` look alike going out and
|
||||
differ by three megabytes coming back.
|
||||
The wrapper covers the fetch methods rather than `execute`, because what a
|
||||
statement costs is not known when it is sent. `SELECT * FROM memories` and
|
||||
`SELECT count(*) FROM memories` look alike going out and differ by three
|
||||
megabytes coming back.
|
||||
"""
|
||||
|
||||
def __init__(self, cursor, meter: Meter) -> None:
|
||||
|
||||
@@ -16,12 +16,12 @@ import json
|
||||
import random
|
||||
import sys
|
||||
|
||||
# `stress_session` FIRST, and it is not a style preference. Importing it is what
|
||||
# points `AIDND_DB_PATH` at a throwaway file, and `app.database` reads that at
|
||||
# module scope — so an `app` import above this line silently runs the whole
|
||||
# fixture against `backend/data.db` instead. It fails by *working*: the first
|
||||
# Import `stress_session` first. This is not a style preference. Importing it is
|
||||
# what points `AIDND_DB_PATH` at a throwaway file, and `app.database` reads that
|
||||
# at module scope, so an `app` import above this line runs the whole fixture
|
||||
# against `backend/data.db` instead. The failure looks like success: the first
|
||||
# run seeds a synthetic user and adventure into the local database and reports
|
||||
# perfectly good numbers, and only the second run trips over the unique email.
|
||||
# plausible numbers, and only the second run fails on the unique email.
|
||||
from tools import stress_session as stress # noqa: I001 (see above)
|
||||
|
||||
from app import bundle, models, tree
|
||||
@@ -31,8 +31,9 @@ from app.database import SessionLocal
|
||||
def _v1_shape(v2: dict) -> dict:
|
||||
"""The same story as v1 would have written it, for a like-for-like count.
|
||||
|
||||
One entry per turn, the siblings folded back into a `variants` array, no
|
||||
coordinates and no outcomes — which is exactly what v1 could carry.
|
||||
There is one entry per turn, with the siblings collected back into a
|
||||
`variants` array, no coordinates, and no outcomes. That is what version 1
|
||||
could carry.
|
||||
"""
|
||||
turns: dict[tuple[int, int], list[dict]] = {}
|
||||
order: list[tuple[int, int]] = []
|
||||
@@ -89,11 +90,12 @@ def main(argv=None) -> int:
|
||||
try:
|
||||
adventure = db.get(models.Adventure, adv_id)
|
||||
if forks:
|
||||
# Twenty divergences off one line, each a little deeper — the shape
|
||||
# SP5 measured the fork cost on. The nodes are chosen up front and
|
||||
# the session is flushed after every fork: `fork` moves a row onto
|
||||
# its new branch, and with `autoflush=False` a query issued before
|
||||
# that move is written still finds the node where it used to be.
|
||||
# Twenty divergences off one line, each a little deeper, which is
|
||||
# the shape SP5 measured the fork cost on. The nodes are chosen up
|
||||
# front and the session is flushed after every fork. `fork` moves a
|
||||
# row onto its new branch, and with `autoflush=False` a query issued
|
||||
# before that move is written still finds the node at its old
|
||||
# location.
|
||||
root = adventure.head_branch_id
|
||||
candidates = [
|
||||
node.id for node in
|
||||
|
||||
@@ -5,12 +5,13 @@ with one stat, because a map only cares about the shape. A screenshot cares
|
||||
about everything else: the world-state rail wants a scenario with bands, flags,
|
||||
milestones and a named cast, and the story wants prose somebody would read.
|
||||
|
||||
So this drives the seeded **Bandit Camp** demo — the same scenario the older
|
||||
images were shot on, so the set stays one product — through eight turns of
|
||||
written prose and written deltas, and forks three discarded takes onto branches
|
||||
of their own — one of them off a branch, so the map has to nest. It leaves the reader on the first telling, on a
|
||||
turn that has a second take, so one screen shows the world state, the take
|
||||
pager and the branch rail at once.
|
||||
So this script drives the seeded Bandit Camp demo through eight turns of written
|
||||
prose and written deltas. That is the same scenario the older images were shot
|
||||
on, which keeps the set consistent. It then forks three discarded attempts onto
|
||||
branches of their own, one of them off a branch, so the map has to nest. It
|
||||
leaves the reader on the first telling, on a turn that has a second attempt, so
|
||||
one screen shows the world state, the attempt pager, and the branch rail at
|
||||
once.
|
||||
|
||||
cd backend
|
||||
.venv/Scripts/python.exe -m tools.shots_fixture /tmp/shots.db
|
||||
@@ -123,9 +124,9 @@ TURNS = [
|
||||
),
|
||||
]
|
||||
|
||||
# The takes the story did not keep. Each is retried at the turn of the same
|
||||
# index below, and then forked onto a branch of its own — a discarded take is
|
||||
# the only thing a fork can be made of.
|
||||
# The attempts the story did not keep. Each one is retried at the turn with the
|
||||
# same index below, and then forked onto a branch of its own. A discarded attempt
|
||||
# is the only thing a fork can be made from.
|
||||
RETAKES = {
|
||||
3: (
|
||||
"He passes close enough that you can smell the tar on his coat, and the "
|
||||
@@ -145,8 +146,11 @@ RETAKES = {
|
||||
|
||||
|
||||
class ScriptedProvider:
|
||||
"""Serves whatever the driver loaded, so a retry can differ from the take
|
||||
it replaces — which is the whole thing being photographed."""
|
||||
"""Serves whatever the driver loaded, so a retry can differ from the attempt
|
||||
it replaces.
|
||||
|
||||
That difference is what the screenshots show.
|
||||
"""
|
||||
|
||||
next_reply = ("", {})
|
||||
|
||||
@@ -165,9 +169,9 @@ auth.resolve_provider_config = lambda s: auth.ProviderConfig(
|
||||
limits.rate_limit = lambda *a, **k: None
|
||||
limits.check_row_cap = lambda *a, **k: None
|
||||
|
||||
# `bootstrap()` runs at import and seeds the public demo scenarios, so the
|
||||
# Bandit Camp is already here — the point of shooting on it is that it is the
|
||||
# scenario a visitor actually meets.
|
||||
# `bootstrap()` runs at import and seeds the public demo scenarios, so the Bandit
|
||||
# Camp already exists. Shooting on it matters because it is the scenario a
|
||||
# visitor meets.
|
||||
client = TestClient(app)
|
||||
|
||||
db = SessionLocal()
|
||||
@@ -206,9 +210,11 @@ def retake(i):
|
||||
|
||||
|
||||
def retry_last():
|
||||
"""Retry whatever is at the tip, with whatever reply is loaded, and hand
|
||||
back the take the story just walked away from — the only thing a fork can
|
||||
be made of."""
|
||||
"""Retries whatever is at the tip with whatever reply is loaded.
|
||||
|
||||
Returns the attempt the story just left, which is the only thing a fork can
|
||||
be made from.
|
||||
"""
|
||||
before = live_ai_ids()
|
||||
r = client.post(f"{base}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -264,9 +270,9 @@ name([b for b in branches() if b["is_head"]][0]["id"], "Loud, and early")
|
||||
|
||||
# ---- and a line that left that one again, so the map has to nest ----
|
||||
#
|
||||
# Forking the take at the tip only switches to it — the attempts there are
|
||||
# still leaves nobody has built on. So the story is moved one turn past it
|
||||
# first, and only then is the take it walked away from worth a branch.
|
||||
# Forking the attempt at the tip only switches to it, because the attempts there
|
||||
# are still leaves that nothing was built on. The story is therefore moved one
|
||||
# turn past it first, and only then does the attempt it left deserve a branch.
|
||||
ScriptedProvider.next_reply = (
|
||||
"Nobody comes. The horn was the wrong horn, or the camp has been empty of "
|
||||
"anyone who cares since before you got here.", {"npc.gwen.trust": -4})
|
||||
@@ -298,8 +304,8 @@ r = client.post(f"{base}/actions", json={"type": "do", "text": "Say nothing and
|
||||
assert r.status_code == 200, r.text
|
||||
name([b for b in branches() if b["is_head"]][0]["id"], "Said nothing")
|
||||
|
||||
# Leave the reader on the first telling, standing on the turn that has two
|
||||
# takes — the one screen that shows the rail, the pager and the branches.
|
||||
# Leave the reader on the first telling, on the turn that has two attempts. That
|
||||
# is the one screen showing the rail, the pager, and the branches.
|
||||
client.post(f"{base}/branches/{root}/switch")
|
||||
|
||||
with engine.begin() as conn:
|
||||
|
||||
+222
-194
@@ -1,4 +1,4 @@
|
||||
"""Drive a production-sized adventure through the real routes and report what
|
||||
"""Drives a production-sized adventure through the real routes and reports what
|
||||
each one costs in database bytes.
|
||||
|
||||
cd backend
|
||||
@@ -6,43 +6,42 @@ each one costs in database bytes.
|
||||
.venv/Scripts/python.exe -m tools.stress_session --actions 200 --memories 100
|
||||
.venv/Scripts/python.exe -m tools.stress_session --no-embeddings
|
||||
|
||||
**The memory bank is ON by default, and that is the point.** The round-two
|
||||
stress harness ran without an embedding model configured, and embedding
|
||||
providers are BYOK-only by construction, so `retrieve_memories` returned early
|
||||
every time — the whole exercise measured the turn loop with its heaviest read
|
||||
switched off, and reported 23 MB for a playthrough that actually costs an order
|
||||
of magnitude more. `--no-embeddings` reproduces that blindness deliberately, to
|
||||
show the gap; it is never the default and it prints a warning.
|
||||
The memory bank is on by default, and that matters. The round-two stress
|
||||
harness ran with no embedding model configured, and embedding providers are
|
||||
BYOK-only by construction, so `retrieve_memories` returned early every time.
|
||||
That run measured the turn loop with its heaviest read disabled and reported
|
||||
23 MB for a playthrough that costs an order of magnitude more.
|
||||
`--no-embeddings` reproduces that configuration on purpose, to show the gap. It
|
||||
is never the default, and it prints a warning.
|
||||
|
||||
Everything here is synthetic. The fixture is generated to production *shape* —
|
||||
1536-dimension embeddings, ~74 KB context snapshots, retry variants — and no
|
||||
real adventure, user or backup is ever read.
|
||||
Everything here is synthetic. The fixture is generated to the shape of
|
||||
production data, with 1536-dimension embeddings, context snapshots of about
|
||||
74 kB, and retry variants. It never reads a real adventure, user, or backup.
|
||||
|
||||
Only the network is faked: the LLM and the embedding endpoint. Routing,
|
||||
sessions, the ORM, the scripting engine and the context builder are the real
|
||||
ones, because the bugs this exists to catch live in exactly the layer a mock
|
||||
would replace.
|
||||
Only the network is faked, which means the LLM and the embedding endpoint.
|
||||
Routing, sessions, the ORM, the scripting engine, and the context builder are
|
||||
the real ones, because the bugs this harness exists to catch are in the layer a
|
||||
mock would replace.
|
||||
|
||||
It runs on a throwaway SQLite file by default. What is being measured is which
|
||||
columns of which rows a code path asks for, and that is decided by the ORM,
|
||||
identically on both dialects. The dialects disagree on how a value is encoded
|
||||
on the wire — JSON especially — so treat the absolute figures as
|
||||
production-shaped rather than production-exact, and compare before against
|
||||
after.
|
||||
It runs on a throwaway SQLite file by default. The measurement is which columns
|
||||
of which rows a code path requests, and the ORM decides that identically on both
|
||||
dialects. The dialects differ in how a value is encoded on the wire, especially
|
||||
JSON, so treat the absolute figures as production-shaped rather than
|
||||
production-exact, and compare one run against another.
|
||||
|
||||
To measure the encodings SQLite cannot reach — bytea for the packed vectors,
|
||||
and json columns psycopg parses before the meter sees them — set
|
||||
AIDND_STRESS_DATABASE_URL to a **throwaway** Postgres database:
|
||||
To measure the encodings SQLite cannot reach, which are bytea for the packed
|
||||
vectors and json columns that psycopg parses before the meter sees them, set
|
||||
`AIDND_STRESS_DATABASE_URL` to a throwaway Postgres database:
|
||||
|
||||
AIDND_STRESS_DATABASE_URL=postgresql://…/stress_scratch \
|
||||
.venv/Scripts/python.exe -m tools.stress_session
|
||||
|
||||
The harness writes, so it refuses any target whose database name does not say
|
||||
'stress' or 'scratch'. Never point it at a database holding real users.
|
||||
The harness writes, so it refuses any target whose database name does not
|
||||
contain 'stress' or 'scratch'. Never point it at a database holding real users.
|
||||
|
||||
Calibration. The fixture is sized from production, re-measured 2026-08-17
|
||||
against the live Neon database (aggregates only — counts and octet_length
|
||||
sums, never row contents):
|
||||
Calibration. The fixture is sized from production and was re-measured on
|
||||
2026-08-17 against the live Neon database, reading aggregates only: counts and
|
||||
`octet_length` sums, never row contents.
|
||||
|
||||
per action, text 886 B -> --narration-bytes 1700, alternating
|
||||
with a one-line player input
|
||||
@@ -51,14 +50,13 @@ sums, never row contents):
|
||||
memory bank, largest 100 memories, 6,144 B a vector
|
||||
|
||||
The previous defaults were wrong in both directions at once and happened to
|
||||
land near the right total: actions were modelled at ~2.1 KB against a real
|
||||
886 B, and stories at 200 actions against a real 607. Width was flattering,
|
||||
length was not, and length is what a page load pays for.
|
||||
land near the right total. Actions were modeled at about 2.1 kB against a real
|
||||
886 B, and stories at 200 actions against a real 607. The width was too large
|
||||
and the length was too small, and the length is what a page load pays for.
|
||||
|
||||
Filler text is generated word by word rather than repeated. A repeated
|
||||
sentence compresses about a hundredfold and prose three- or fourfold, so the
|
||||
old fixture would have made any compression measurement on context_snapshot
|
||||
meaningless.
|
||||
Filler text is generated word by word rather than repeated. A repeated sentence
|
||||
compresses about a hundredfold and prose three- or fourfold, so the old fixture
|
||||
would have made any compression measurement on `context_snapshot` meaningless.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -68,11 +66,12 @@ from pathlib import Path
|
||||
|
||||
|
||||
def _early_keep(argv: list[str]) -> str:
|
||||
"""--keep, read before argparse exists.
|
||||
"""Reads `--keep` before argparse exists.
|
||||
|
||||
Where the database lives has to be decided before app.database is
|
||||
imported, and that import is three lines below. argparse still declares
|
||||
the flag, so --help documents it and a typo is still an error."""
|
||||
The database location has to be decided before `app.database` is imported,
|
||||
and that import is a few lines below. argparse still declares the flag, so
|
||||
`--help` documents it and a typo is still an error.
|
||||
"""
|
||||
for i, arg in enumerate(argv):
|
||||
if arg == "--keep" and i + 1 < len(argv):
|
||||
return argv[i + 1]
|
||||
@@ -83,17 +82,17 @@ def _early_keep(argv: list[str]) -> str:
|
||||
|
||||
_keep = _early_keep(sys.argv[1:])
|
||||
|
||||
# Must precede the app import: database.py reads these at module scope.
|
||||
# This has to run before the app import, because `database.py` reads these at
|
||||
# module scope.
|
||||
#
|
||||
# Default is a throwaway SQLite file. AIDND_STRESS_DATABASE_URL points the
|
||||
# The default is a throwaway SQLite file. `AIDND_STRESS_DATABASE_URL` points the
|
||||
# harness at a real Postgres instead, which is the only way to reach the
|
||||
# encodings SQLite cannot exercise: bytea for the packed vectors, and json
|
||||
# columns that psycopg parses into Python before the meter ever sees them.
|
||||
# columns that psycopg parses into Python before the meter sees them.
|
||||
#
|
||||
# The name guard is not paranoia. This harness *writes* — it builds a whole
|
||||
# synthetic adventure — so a URL that happened to point at the production
|
||||
# database would quietly seed it with fake users and fake play. The target
|
||||
# must say it is disposable.
|
||||
# The name guard matters. This harness writes a whole synthetic adventure, so a
|
||||
# URL that pointed at the production database would seed it with fake users and
|
||||
# fake play. The target has to name itself as disposable.
|
||||
_stress_url = os.environ.get("AIDND_STRESS_DATABASE_URL", "").strip()
|
||||
if _stress_url:
|
||||
if _keep:
|
||||
@@ -111,10 +110,10 @@ if _stress_url:
|
||||
os.environ["AIDND_DATABASE_URL"] = _stress_url
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
elif _keep:
|
||||
# A fixture to boot the app against rather than a temp file the report
|
||||
# discards. Rebuilt from empty every run: build_fixture() assumes an empty
|
||||
# database on the SQLite path, and a second run would otherwise stack a
|
||||
# second adventure beside the first.
|
||||
# A fixture to start the app against, rather than a temporary file the
|
||||
# report discards. Every run rebuilds it from empty, because
|
||||
# `build_fixture()` assumes an empty database on the SQLite path and a
|
||||
# second run would otherwise add a second adventure next to the first.
|
||||
_keep_path = Path(_keep).resolve()
|
||||
_keep_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_keep_path.unlink(missing_ok=True)
|
||||
@@ -149,21 +148,22 @@ from .fakeprose import prose
|
||||
|
||||
EMBEDDING_DIMS = 1536
|
||||
|
||||
# ~232 KB, measured on production's largest adventure (2026-08-17). The old
|
||||
# figure here was 74 KB, taken from the comment in models.py; the real column
|
||||
# averages 163 KB a row across the whole table and 232 KB on the adventure that
|
||||
# matters, because the assembled prompt grows with the story behind it.
|
||||
# About 232 kB, measured on the largest adventure in production on 2026-08-17.
|
||||
# The old figure here was 74 kB, taken from the comment in `models.py`. The real
|
||||
# column averages 163 kB per row across the whole table and 232 kB on the
|
||||
# adventure that matters, because the assembled prompt grows with the story
|
||||
# behind it.
|
||||
#
|
||||
# Built from varied text rather than one sentence repeated. A repeated sentence
|
||||
# compresses about a hundredfold and real prose three- or fourfold, so a
|
||||
# fixture made of repeats would make any compression measurement meaningless —
|
||||
# and shrinking this column is the open question it exists to answer.
|
||||
# The text is varied rather than one sentence repeated. A repeated sentence
|
||||
# compresses about a hundredfold and real prose three- or fourfold, so a fixture
|
||||
# built from repeats would make any compression measurement meaningless, and
|
||||
# shrinking this column is the open question the fixture exists to answer.
|
||||
SNAPSHOT_SYSTEM = None # set by _build_text()
|
||||
SNAPSHOT_STORY = None
|
||||
|
||||
PLAYER_INPUT = "> You crouch and look more closely at the grit on the floor."
|
||||
|
||||
# All three are bound by _build_text() from the fixture arguments.
|
||||
# `_build_text()` sets all three from the fixture arguments.
|
||||
NARRATION = None
|
||||
MEMORY_TEXT = (
|
||||
"You found a bandit camp above the ford and agreed to guide Gwen through "
|
||||
@@ -172,15 +172,15 @@ MEMORY_TEXT = (
|
||||
|
||||
|
||||
def _build_text(args, rng: random.Random) -> None:
|
||||
"""Size the three variable-length fixture strings from the arguments.
|
||||
"""Sizes the three variable-length fixture strings from the arguments.
|
||||
|
||||
Separate from build_fixture so the sizes are decided once, before anything
|
||||
is written, and so a shape's cost is a function of the flags rather than of
|
||||
how many rows happened to be generated first.
|
||||
This is separate from `build_fixture` so that the sizes are decided once,
|
||||
before anything is written, and so that a shape's cost depends on the flags
|
||||
rather than on how many rows were generated first.
|
||||
"""
|
||||
global NARRATION, SNAPSHOT_SYSTEM, SNAPSHOT_STORY
|
||||
NARRATION = prose(rng, args.narration_bytes)
|
||||
# The assembled prompt is a system block and the story so far; the split
|
||||
# The assembled prompt is a system block plus the story so far. The split
|
||||
# is roughly one to five in production.
|
||||
SNAPSHOT_SYSTEM = prose(rng, args.snapshot_bytes // 6)
|
||||
SNAPSHOT_STORY = prose(rng, args.snapshot_bytes - args.snapshot_bytes // 6)
|
||||
@@ -190,7 +190,11 @@ def _build_text(args, rng: random.Random) -> None:
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
"""The LLM. Streams one fixed line; no network, no cost, no variance."""
|
||||
"""Stands in for the LLM, streaming one fixed line.
|
||||
|
||||
It makes no network call, costs nothing, and returns the same text every
|
||||
time.
|
||||
"""
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
@@ -203,8 +207,11 @@ class FakeProvider:
|
||||
|
||||
|
||||
class FakeEmbeddings:
|
||||
"""The embedding endpoint. Returns vectors of the real width, so what the
|
||||
turn writes back weighs what production weighs."""
|
||||
"""Stands in for the embedding endpoint.
|
||||
|
||||
It returns vectors of the production width, so what the turn writes back is
|
||||
the size production writes back.
|
||||
"""
|
||||
|
||||
def __init__(self, rng: random.Random):
|
||||
self.rng = rng
|
||||
@@ -218,32 +225,34 @@ class FakeEmbeddings:
|
||||
|
||||
# -------------------------------------------------------------- rich fixture
|
||||
|
||||
# The correctness fixture, as against the scale one.
|
||||
# The correctness fixture, as opposed to the scale fixture.
|
||||
#
|
||||
# The default fixture is sized from production and exists to weigh bytes, so it
|
||||
# leaves every column it does not weigh at its default. That makes it a poor
|
||||
# witness for anything *semantic* — and phase 14 replaces the storage model
|
||||
# underneath all of it. Measured on a freshly built default fixture, the
|
||||
# columns a story tree has to migrate correctly look like this:
|
||||
# The default fixture is sized from production and exists to measure bytes, so it
|
||||
# leaves every column it does not measure at its default. That makes it a poor
|
||||
# test of semantics, and phase 14 replaces the storage model underneath all of
|
||||
# it. On a freshly built default fixture, the columns a story tree has to migrate
|
||||
# correctly hold this:
|
||||
#
|
||||
# state_after / world_state_after the same value on all 600 rows (they
|
||||
# state_after, world_state_after The same value on all 600 rows. They
|
||||
# were state_before, and NULL, until SP4
|
||||
# turned them round). A rollback over
|
||||
# identical snapshots proves nothing.
|
||||
# scenario_id / world_state absent. No RPG layer, so the cooldown
|
||||
# clock SP5 must not advance never runs.
|
||||
# adventure_scripts none. script_state rollback is exactly
|
||||
# what a branch switch reuses.
|
||||
# memory_cursor / summary_cursor both 0. SP3 replaces the cursors.
|
||||
# sibling attempts two of byte-identical text with the
|
||||
# first always live — so "which attempt
|
||||
# is live?", the one question SP4 has to
|
||||
# answer, has no observable answer.
|
||||
# reversed them. A rollback over identical
|
||||
# snapshots tests nothing.
|
||||
# scenario_id, world_state Absent. There is no RPG layer, so the
|
||||
# cooldown clock SP5 must not advance
|
||||
# never runs.
|
||||
# adventure_scripts None. A branch switch reuses the
|
||||
# script_state rollback.
|
||||
# memory_cursor, summary_cursor Both 0. SP3 replaces the cursors.
|
||||
# sibling attempts Two attempts of byte-identical text with
|
||||
# the first always live, so the one
|
||||
# question SP4 has to answer, which
|
||||
# attempt is live, has no observable
|
||||
# answer.
|
||||
#
|
||||
# --rich fills in exactly those and changes nothing else, so the measuring
|
||||
# fixture's numbers stay comparable run to run. It is a *correctness* fixture:
|
||||
# prefer it small (--rich --actions 30), because what it is for is variety per
|
||||
# row, not rows.
|
||||
# `--rich` fills in those columns and changes nothing else, so the measuring
|
||||
# fixture's numbers stay comparable from run to run. It is a correctness fixture,
|
||||
# so keep it small, such as `--rich --actions 30`. It provides variety per row
|
||||
# rather than many rows.
|
||||
|
||||
RICH_SUMMARY = (
|
||||
"You tracked the bandits to a camp above the ford, freed Gwen from the "
|
||||
@@ -260,8 +269,8 @@ RICH_CARDS = [
|
||||
"Taken from the quartermaster. Opens something below the camp."),
|
||||
]
|
||||
|
||||
# Ten gold a turn, so a state snapshot that failed to roll back reads as a
|
||||
# wrong total rather than as nothing. Same shape the retry tests use.
|
||||
# Ten gold a turn, so a state snapshot that failed to roll back shows a wrong
|
||||
# total rather than no value. The retry tests use the same shape.
|
||||
RICH_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
@@ -272,28 +281,31 @@ modifier(text);
|
||||
|
||||
|
||||
def rich_stat_schema() -> dict:
|
||||
"""The demo RPG schema, read from the seed data rather than invented here.
|
||||
"""Returns the demo RPG schema, read from the seed data rather than invented.
|
||||
|
||||
Using the real one means the fixture exercises bands, cooldowns,
|
||||
max_delta_per_turn and NPC stat blocks as they are actually shaped — an
|
||||
invented schema would drift from the thing it is standing in for.
|
||||
Using the real schema means the fixture exercises bands, cooldowns,
|
||||
`max_delta_per_turn`, and NPC stat blocks in their real shapes. An invented
|
||||
schema would diverge from the one it stands in for.
|
||||
"""
|
||||
path = seed.SEED_DIR / "04-rpg-world-state.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))["stat_schema"]
|
||||
|
||||
|
||||
def rich_script_state(turn: int) -> dict:
|
||||
"""The scoreboard as of `turn`. Monotonic, so any snapshot identifies the
|
||||
turn it was taken at — which is what makes a bad rollback visible."""
|
||||
"""Returns the script state as of `turn`.
|
||||
|
||||
The values increase with the turn, so any snapshot identifies the turn it was
|
||||
taken at, which is what makes an incorrect rollback visible.
|
||||
"""
|
||||
return {"gold": turn * 10, "turn": turn}
|
||||
|
||||
|
||||
def rich_world_state(schema: dict, turn: int) -> dict:
|
||||
"""A live world state that has actually been played to `turn`.
|
||||
"""Returns a live world state that has been played to `turn`.
|
||||
|
||||
`instantiate` gives the initial picture; a fixture whose every row holds
|
||||
that same picture cannot tell a restored snapshot from an unrestored one.
|
||||
So hp declines, mana drains and a flag flips partway through.
|
||||
`instantiate` returns the initial state, and a fixture whose every row holds
|
||||
that same state cannot distinguish a restored snapshot from an unrestored
|
||||
one. Here hp declines, mana declines, and a flag changes partway through.
|
||||
"""
|
||||
ws = worldstate.instantiate(schema)
|
||||
ws["player"]["hp"] = max(20, 100 - turn)
|
||||
@@ -306,28 +318,28 @@ def rich_world_state(schema: dict, turn: int) -> dict:
|
||||
|
||||
|
||||
def rich_attempts(rng: random.Random, index: int) -> tuple[list[str], int]:
|
||||
"""Distinguishable retry attempts, and which one the story tells.
|
||||
"""Returns distinguishable retry attempts, and which one the story tells.
|
||||
|
||||
Every attempt in the default fixture carries the same text with the live
|
||||
one pinned at 0. That is the one thing SP4 has to get right and the one
|
||||
thing that fixture cannot witness, so here the texts differ, the counts
|
||||
differ, and the live one is often not the last.
|
||||
Every attempt in the default fixture carries the same text, with the live one
|
||||
fixed at index 0. That is the behavior SP4 has to get right and the behavior
|
||||
that fixture cannot test, so here the texts differ, the counts differ, and
|
||||
the live attempt is often not the last.
|
||||
"""
|
||||
count = 3 if index % 12 == 1 else 2
|
||||
texts = [
|
||||
f"[attempt {n + 1} of {count} at turn {index}] {prose(rng, 240)}"
|
||||
for n in range(count)
|
||||
]
|
||||
# Deliberately not always the newest: a player who retried twice and then
|
||||
# went back to the first take is the case that breaks anything assuming the
|
||||
# live attempt is the last one written.
|
||||
# The live attempt is not always the newest. A player who retried twice and
|
||||
# then went back to the first attempt is the case that breaks code assuming
|
||||
# the live attempt is the last one written.
|
||||
return texts, (0 if index % 18 == 1 else count - 1)
|
||||
|
||||
|
||||
def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None:
|
||||
"""Everything --rich adds beside the actions themselves.
|
||||
"""Adds everything `--rich` contributes apart from the actions themselves.
|
||||
|
||||
Called with the adventure already flushed, before the actions are written,
|
||||
The caller flushes the adventure first and writes the actions afterwards,
|
||||
because the actions need the schema to snapshot a world state from.
|
||||
"""
|
||||
schema = rich_stat_schema()
|
||||
@@ -343,11 +355,11 @@ def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None:
|
||||
adventure.scenario_id = scenario.id
|
||||
adventure.world_state = rich_world_state(schema, args.actions)
|
||||
adventure.script_state = rich_script_state(args.actions)
|
||||
# The post-turn passes only do anything when summarization is on and the
|
||||
# marks are somewhere other than the start. Written as positions here and
|
||||
# translated into anchors once the actions exist (see build_fixture) — a
|
||||
# position is what a database being migrated to SP3 still holds, so the
|
||||
# fixture carries both and they have to say the same thing.
|
||||
# The post-turn passes do nothing unless summarization is on and the marks
|
||||
# are past the start. They are written here as positions and translated into
|
||||
# anchors once the actions exist. See `build_fixture`. A database being
|
||||
# migrated to SP3 still holds positions, so the fixture carries both forms
|
||||
# and the two have to agree.
|
||||
adventure.auto_summarize = True
|
||||
adventure.story_summary = RICH_SUMMARY
|
||||
adventure.memory_cursor = max(0, args.actions - 8)
|
||||
@@ -366,10 +378,12 @@ def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None:
|
||||
|
||||
|
||||
def add_second_adventure(db, rng: random.Random, user) -> int:
|
||||
"""A short second adventure, so 'does this leak across adventures?' is a
|
||||
question the fixture can answer. A tree scopes every read by branch, and a
|
||||
branch clause that forgot its adventure would still look right on a
|
||||
database holding exactly one."""
|
||||
"""Adds a short second adventure, so the fixture can detect cross-adventure
|
||||
leaks.
|
||||
|
||||
A tree scopes every read by branch, and a branch clause that omitted its
|
||||
adventure would still look correct on a database holding one adventure.
|
||||
"""
|
||||
other = models.Adventure(
|
||||
user_id=user.id, title="Stress (second)", script_state={},
|
||||
memory_bank_enabled=True, auto_summarize=False,
|
||||
@@ -398,12 +412,12 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
|
||||
|
||||
|
||||
def _assert_live_variant_invariant(db, adventure_id: int) -> None:
|
||||
"""Exactly one attempt per turn is live, on every turn.
|
||||
"""Checks that exactly one attempt per turn is live, on every turn.
|
||||
|
||||
Checked here rather than trusted, because a coordinate with two live
|
||||
siblings tells its story twice and a coordinate with none drops a turn out
|
||||
of it — and both fail by *reading* wrong, never by raising. A fixture that
|
||||
quietly violated it would let wrong code look right.
|
||||
The check runs here rather than being assumed, because a coordinate with two
|
||||
live siblings renders its turn twice and a coordinate with none omits the
|
||||
turn. Both failures produce wrong output rather than an exception, so a
|
||||
fixture that broke the rule would let incorrect code look correct.
|
||||
"""
|
||||
groups: dict[tuple, list[models.Action]] = {}
|
||||
for action in (
|
||||
@@ -430,13 +444,15 @@ def _assert_live_variant_invariant(db, adventure_id: int) -> None:
|
||||
|
||||
|
||||
def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
"""A user, settings and one adventure at production scale. Returns
|
||||
(adventure_id, user_id)."""
|
||||
# A SQLite run gets a brand-new temp file every time, so the fixture can
|
||||
# assume an empty database. A Postgres scratch target persists between
|
||||
# runs, and the second one would collide on the fixture user's unique
|
||||
# email — so empty it first. Only ever reached for a target whose name
|
||||
# passed the 'stress'/'scratch' guard at the top of this module.
|
||||
"""Builds a user, settings, and one adventure at production scale.
|
||||
|
||||
Returns `(adventure_id, user_id)`.
|
||||
"""
|
||||
# A SQLite run gets a new temporary file every time, so the fixture can
|
||||
# assume an empty database. A Postgres scratch target persists between runs,
|
||||
# and a second run would collide on the fixture user's unique email, so empty
|
||||
# it first. This runs only for a target whose name passed the 'stress' or
|
||||
# 'scratch' guard at the top of this module.
|
||||
if _stress_url:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -450,7 +466,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
api_key=security.encrypt_secret("stress-key"),
|
||||
model="stress-model",
|
||||
endpoint_url="https://fake.invalid/v1",
|
||||
# The default this whole tool exists to stop anyone forgetting.
|
||||
# The default this tool exists to keep anyone from forgetting.
|
||||
embedding_model="" if args.no_embeddings else "openai/text-embedding-3-small",
|
||||
memory_bank_capacity=args.capacity,
|
||||
))
|
||||
@@ -459,8 +475,9 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
title="Stress",
|
||||
script_state={},
|
||||
memory_bank_enabled=True,
|
||||
# Off so a turn measures the turn. The post-turn pass is its own
|
||||
# shape below; letting it fire mid-measurement would mix the two.
|
||||
# Off, so that a turn measures only the turn. The post-turn pass
|
||||
# is its own shape below, and running it during the measurement
|
||||
# would combine the two.
|
||||
auto_summarize=False,
|
||||
)
|
||||
db.add(adventure)
|
||||
@@ -472,7 +489,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
is_ai = bool(i % 2)
|
||||
retried = is_ai and i % 6 == 1
|
||||
if args.rich:
|
||||
# Distinguishable attempts, and a live one that is often not
|
||||
# Distinguishable attempts, with a live one that is often not
|
||||
# the last written.
|
||||
texts, live = rich_attempts(rng, i) if retried else ([], 0)
|
||||
if not retried:
|
||||
@@ -490,10 +507,10 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
type="ai" if is_ai else "do",
|
||||
text=body,
|
||||
# The turn's assembled prompt is stored once, on the
|
||||
# attempt the story tells; a superseded sibling keeps only
|
||||
# its own slices. That is the invariant `app/attempts.py`
|
||||
# maintains, and a fixture that ignored it would multiply
|
||||
# the biggest column in the database by the retry count.
|
||||
# attempt the story tells. A superseded sibling keeps only
|
||||
# its own slices. `app/attempts.py` maintains that
|
||||
# invariant, and a fixture that ignored it would multiply
|
||||
# the largest column in the database by the retry count.
|
||||
context_snapshot=(
|
||||
{"system": SNAPSHOT_SYSTEM, "story": SNAPSHOT_STORY}
|
||||
if n == live else
|
||||
@@ -506,20 +523,21 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
live=(n == live),
|
||||
variant_index=n,
|
||||
variant_count=len(texts) if len(texts) > 1 else 0,
|
||||
# Monotonic under --rich, so a bad rollback reads as a
|
||||
# wrong number rather than as nothing. Left to
|
||||
# `tree.stamp_outcome` otherwise, which writes the
|
||||
# adventure's (unchanging) state — true, and no witness.
|
||||
# Under `--rich` the value increases with the turn, so an
|
||||
# incorrect rollback shows a wrong number rather than no
|
||||
# value. Otherwise `tree.stamp_outcome` writes the
|
||||
# adventure's state, which does not change and therefore
|
||||
# tests nothing.
|
||||
state_after=rich_script_state(i) if args.rich else None,
|
||||
world_state_after=(
|
||||
rich_world_state(schema, i) if args.rich else None
|
||||
),
|
||||
)
|
||||
# A fresh database is built by create_all and stamped LATEST,
|
||||
# so no migration ever runs against it and the tree backfill
|
||||
# never sees it. The fixture has to stamp its own nodes, or it
|
||||
# would be the one database in the project whose actions have
|
||||
# no branch.
|
||||
# `create_all` builds a fresh database and stamps it LATEST,
|
||||
# so no migration runs against it and the tree backfill never
|
||||
# sees it. The fixture has to stamp its own nodes, or it would
|
||||
# be the one database in the project whose actions have no
|
||||
# branch.
|
||||
tree.place_action(db, adventure, action)
|
||||
db.add(action)
|
||||
|
||||
@@ -529,14 +547,15 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
text=f"{MEMORY_TEXT} ({i})",
|
||||
source_start=i * memorybank.MEMORY_INTERVAL,
|
||||
source_end=i * memorybank.MEMORY_INTERVAL + memorybank.MEMORY_INTERVAL - 1,
|
||||
# A bank in play is not uniformly active: some memories are
|
||||
# pinned (always retrieved) and some evicted but kept for the
|
||||
# UI. Both states have to survive being re-attached to nodes.
|
||||
# A bank in play is not uniformly active. Some memories are
|
||||
# pinned, which means they are always retrieved, and some are
|
||||
# evicted but kept for the UI. Both states have to survive being
|
||||
# re-attached to nodes.
|
||||
pinned=bool(args.rich and i % 9 == 0),
|
||||
forgotten=bool(args.rich and i % 11 == 5),
|
||||
)
|
||||
# Through the same door the app uses, so the fixture cannot end up
|
||||
# storing vectors in a shape production never produces.
|
||||
# Use the same call the app uses, so the fixture cannot store
|
||||
# vectors in a shape production never produces.
|
||||
memorybank.set_vector(
|
||||
memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)]
|
||||
)
|
||||
@@ -544,11 +563,11 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
db.add(memory)
|
||||
|
||||
if args.rich:
|
||||
# The memory/summary marks as nodes, translated from the positions
|
||||
# `add_rich_layers` set now that there are actions to point at —
|
||||
# through the same call the v1 importer uses. A fixture stamped
|
||||
# LATEST never meets a migration, so if this is skipped it is the
|
||||
# one database whose marks are only positions.
|
||||
# Translate the memory mark and the summary mark from the
|
||||
# positions `add_rich_layers` set into nodes, now that actions exist
|
||||
# to point at. This uses the same call the v1 importer uses. A
|
||||
# fixture stamped LATEST never runs a migration, so skipping this
|
||||
# would leave the one database whose marks are only positions.
|
||||
db.flush()
|
||||
cursors.anchor_at_position(adventure, cursors.MEMORY, adventure.memory_cursor)
|
||||
cursors.anchor_at_position(adventure, cursors.SUMMARY, adventure.summary_cursor)
|
||||
@@ -571,8 +590,8 @@ def install_fakes(user_id: int, rng: random.Random) -> None:
|
||||
)
|
||||
limits.rate_limit = lambda *a, **k: None
|
||||
limits.check_row_cap = lambda *a, **k: None
|
||||
# Fire-and-forget post-turn work would land inside whichever scope happened
|
||||
# to be open. It is measured on purpose, as its own shape.
|
||||
# Post-turn work scheduled in the background would be counted inside
|
||||
# whatever scope was open. It is measured deliberately, as its own shape.
|
||||
memorybank.schedule_post_turn = lambda adventure: None
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -585,21 +604,23 @@ def install_fakes(user_id: int, rng: random.Random) -> None:
|
||||
|
||||
|
||||
def shape_list(client, meter, adv_id):
|
||||
"""The adventures index — every adventure's latest narration."""
|
||||
"""Measures the adventures index, which returns each adventure's latest
|
||||
narration."""
|
||||
with meter.scope("GET /adventures (index)"):
|
||||
r = client.get("/api/adventures")
|
||||
_check(r)
|
||||
|
||||
|
||||
def shape_load(client, meter, adv_id):
|
||||
"""Opening a finished adventure: the whole story, in one response."""
|
||||
"""Measures opening a finished adventure, which returns the whole story in
|
||||
one response."""
|
||||
with meter.scope(f"GET /adventures/{{id}} (page load)"):
|
||||
r = client.get(f"/api/adventures/{adv_id}")
|
||||
_check(r)
|
||||
|
||||
|
||||
def shape_turn(client, meter, adv_id):
|
||||
"""One played turn, memory retrieval included."""
|
||||
"""Measures one played turn, including memory retrieval."""
|
||||
with meter.scope("POST /adventures/{id}/actions (one turn)"):
|
||||
r = client.post(
|
||||
f"/api/adventures/{adv_id}/actions",
|
||||
@@ -609,21 +630,24 @@ def shape_turn(client, meter, adv_id):
|
||||
|
||||
|
||||
def shape_insights(client, meter, adv_id):
|
||||
"""The Insights dry run — assembles a context without spending a turn."""
|
||||
"""Measures the Insights dry run, which assembles a context without playing
|
||||
a turn."""
|
||||
with meter.scope("GET /adventures/{id}/context (insights)"):
|
||||
r = client.get(f"/api/adventures/{adv_id}/context")
|
||||
_check(r)
|
||||
|
||||
|
||||
def shape_memories(client, meter, adv_id):
|
||||
"""The Memories drawer — every memory, and none of their vectors."""
|
||||
"""Measures the Memories drawer, which returns every memory and no
|
||||
vectors."""
|
||||
with meter.scope("GET /adventures/{id}/memories (drawer)"):
|
||||
r = client.get(f"/api/adventures/{adv_id}/memories")
|
||||
_check(r)
|
||||
|
||||
|
||||
def shape_post_turn(client, meter, adv_id):
|
||||
"""Summarization, embedding and eviction, after the turn is saved."""
|
||||
"""Measures summarization, embedding, and eviction after the turn is
|
||||
saved."""
|
||||
with meter.scope("run_post_turn (background)"):
|
||||
asyncio.run(memorybank.run_post_turn(adv_id))
|
||||
|
||||
@@ -647,21 +671,24 @@ def _check(response) -> None:
|
||||
|
||||
|
||||
def make_bootable() -> None:
|
||||
"""Two edits that turn a measurement fixture into a database the app will
|
||||
actually serve. Both exist because build_fixture() builds a database for
|
||||
the meter, not for a browser."""
|
||||
"""Applies the two edits that make a measurement fixture servable by the app.
|
||||
|
||||
Both edits are needed because `build_fixture()` builds a database for the
|
||||
meter rather than for a browser.
|
||||
"""
|
||||
from app.migrations import LATEST_VERSION
|
||||
|
||||
with engine.begin() as conn:
|
||||
# create_all() builds the current schema but leaves the stamp at its
|
||||
# default, and bootstrap() reads a stamped-but-not-fresh database as
|
||||
# ancient — it would replay all of the migrations against a schema
|
||||
# that already has every column, and fail on the first one.
|
||||
# `create_all()` builds the current schema but leaves the stamp at its
|
||||
# default, and `bootstrap()` reads an unstamped existing database as
|
||||
# very old. It would replay every migration against a schema that
|
||||
# already has every column, and fail on the first one.
|
||||
conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}"))
|
||||
# In local mode (AIDND_MULTI_USER unset) get_current_user() looks for
|
||||
# the row with email IS NULL and is_guest false. The fixture's user is
|
||||
# a registered one, so without this nothing owns the adventure and the
|
||||
# app opens on an empty library.
|
||||
# In local mode, which is when `AIDND_MULTI_USER` is unset,
|
||||
# `get_current_user()` looks for the row with `email IS NULL` and
|
||||
# `is_guest` false. The fixture's user is a registered one, so without
|
||||
# this update nothing owns the adventure and the app opens on an empty
|
||||
# library.
|
||||
conn.execute(text("UPDATE users SET email = NULL, is_guest = 0"))
|
||||
|
||||
|
||||
@@ -675,9 +702,9 @@ def print_keep_notes(path: str, actions: int) -> None:
|
||||
print(f" .venv/Scripts/python.exe -m uvicorn app.main:app --port {port}")
|
||||
print(f" cd frontend && AIDND_API_PORT={port} npm run dev")
|
||||
print()
|
||||
# 8000 is the vite proxy's default and another local app squats it, which
|
||||
# shadows this API with its own SPA catch-all and looks like an empty
|
||||
# database rather than a proxy problem.
|
||||
# 8000 is the vite proxy's default, and another local app already listens
|
||||
# there. That app shadows this API with its own SPA catch-all route, which
|
||||
# looks like an empty database rather than a proxy problem.
|
||||
print(f" Port {port} rather than 8000 on purpose; AIDND_API_PORT points vite at it.")
|
||||
|
||||
|
||||
@@ -688,8 +715,8 @@ def parse_args(argv=None):
|
||||
p = argparse.ArgumentParser(
|
||||
prog="tools.stress_session", description=__doc__.splitlines()[0]
|
||||
)
|
||||
# 607 is production's longest adventure as of 2026-08-17, and length is
|
||||
# the dimension the old default (200) got wrong: real actions are lighter
|
||||
# 607 is the longest adventure in production as of 2026-08-17. Length is
|
||||
# the dimension the old default of 200 got wrong. Real actions are smaller
|
||||
# than this fixture used to make them, but real stories run three times
|
||||
# longer, and length is what a page load pays for.
|
||||
p.add_argument("--actions", type=int, default=600,
|
||||
@@ -697,14 +724,14 @@ def parse_args(argv=None):
|
||||
"production's longest adventure is 607)")
|
||||
p.add_argument("--memories", type=int, default=100,
|
||||
help="memories, all embedded (default: 100)")
|
||||
# Deliberately not the app's default (80): a measuring instrument should
|
||||
# hold the fixture at the size asked for rather than evict it mid-run.
|
||||
# This is not the app's default of 80. A measuring instrument holds the
|
||||
# fixture at the requested size rather than evict from it during a run.
|
||||
p.add_argument("--capacity", type=int, default=200,
|
||||
help="Settings.memory_bank_capacity; lower it below "
|
||||
"--memories to exercise eviction (default: 200)")
|
||||
# Production's longest adventure carries 886 B of text per action averaged
|
||||
# over both kinds. AI actions alternate with a one-line player input, so
|
||||
# the AI half has to be about twice that.
|
||||
# The longest adventure in production carries 886 B of text per action,
|
||||
# averaged over both kinds. AI actions alternate with a one-line player
|
||||
# input, so the AI half has to be about twice that.
|
||||
p.add_argument("--narration-bytes", type=int, default=1700,
|
||||
help="length of an AI action's text; alternating with a "
|
||||
"one-line player input this averages ~890 B/action, "
|
||||
@@ -730,9 +757,10 @@ def parse_args(argv=None):
|
||||
"one — prefer it small (--rich --actions 30). Changes "
|
||||
"what the shapes cost, so do not compare a --rich run "
|
||||
"against a plain one")
|
||||
# Read at import time by _early_keep as well — the database location has
|
||||
# to be settled before app.database loads. Declared here so it appears in
|
||||
# --help and an unknown spelling is still rejected.
|
||||
# `_early_keep` also reads this flag at import time, because the database
|
||||
# location has to be decided before `app.database` loads. It is declared
|
||||
# here so that it appears in `--help` and an unknown flag is still
|
||||
# rejected.
|
||||
p.add_argument("--keep", metavar="PATH", default="",
|
||||
help="write the fixture to PATH and leave it bootable, so "
|
||||
"the app can serve it in a browser (default: a temp "
|
||||
@@ -756,8 +784,8 @@ def main(argv=None) -> int:
|
||||
install_fakes(user_id, rng)
|
||||
|
||||
meter = Meter()
|
||||
# After the fixture: building it is a write path nobody plays, and its
|
||||
# bytes would drown everything the shapes report.
|
||||
# Attach after the fixture is built. Building it is a write path no player
|
||||
# takes, and its bytes would hide everything the shapes report.
|
||||
meter.attach(engine)
|
||||
|
||||
print(f"fixture: {args.actions} actions × {args.narration_bytes} B "
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""A tree with a shape worth drawing — for driving the branch map by hand.
|
||||
"""A tree with a shape worth drawing, for driving the branch map by hand.
|
||||
|
||||
`tools.branch_fixture` builds two branches of equal length, which is the case
|
||||
the panel-refresh bug needed and the smallest tree that proves a switch. A map
|
||||
|
||||
+164
-163
@@ -108,19 +108,19 @@ player input
|
||||
Two design choices are visible in that list before any of the details.
|
||||
|
||||
**The prompt is snapshotted, not reconstructed.** Every AI action stores the exact text
|
||||
that was sent to the model. That's what powers the Insights panel — open any turn and see
|
||||
that was sent to the model. That's what powers the Insights panel: open any turn and see
|
||||
each context component, its token cost, and why it was included. It's also what makes
|
||||
prompt bugs findable. The cost is storage (~74 KB per turn), which turns into a real
|
||||
performance problem later — see [2.5](#25-the-189x-egress-fix).
|
||||
performance problem later. See [2.5](#25-the-189x-egress-fix).
|
||||
|
||||
**Every node records the state it leaves behind.** `state_after` and `world_state_after`
|
||||
are stapled onto the action once its hooks and its delta have run, so a node carries the
|
||||
scoreboard and the RPG stats as they stood when that turn finished. Rewinding to *before*
|
||||
a turn is then a read of the node in front of it, which is the same move as switching to
|
||||
another branch — one mechanism, and it is the whole reason undo, retry and branch
|
||||
switching all put the numbers back rather than only rewriting text. (These were `*_before`
|
||||
pictures originally; a tree wants the *after*, because a branch's tip is what a reader
|
||||
standing on it should see.)
|
||||
are attached to the action once its hooks and its delta have run, so a node carries the
|
||||
stats and the RPG values as they stood when that turn finished. Rewinding to *before*
|
||||
a turn is then a read of the node in front of it, which is the same operation as switching
|
||||
to another branch: one mechanism, and the reason undo, retry, and branch switching all put
|
||||
the numbers back instead of only rewriting text. (These were `*_before` fields originally; a
|
||||
tree needs the *after* value, because a branch's tip is what a reader standing on it should
|
||||
see.)
|
||||
|
||||
---
|
||||
|
||||
@@ -138,7 +138,7 @@ whether the story stays coherent.
|
||||
|
||||
Send the last N turns. That breaks in two directions: N turns of short exchanges wastes the
|
||||
window, and N turns of long ones overflows it. It also throws away the things that matter
|
||||
most — the premise, the character sheet, the fact that you promised the innkeeper you'd
|
||||
most: the premise, the character sheet, the fact that you promised the innkeeper you'd
|
||||
return.
|
||||
|
||||
### What this app does
|
||||
@@ -149,12 +149,12 @@ Fixed (always included, whatever they cost):
|
||||
|
||||
| Section | What it is |
|
||||
|---|---|
|
||||
| `narrator` | The system prompt — how to write. |
|
||||
| `narrator` | The system prompt: how to write. |
|
||||
| `world_state_guide` | The stat legend: what each stat means, its range, its bands. |
|
||||
| `world_state` | Current values of every stat, plus NPCs in scene. |
|
||||
| `world_state_rule` | How to report changes. |
|
||||
| `ai_instructions` | Per-adventure steering. |
|
||||
| `plot_essentials` | AI Dungeon's "Memory" — the premise. |
|
||||
| `plot_essentials` | AI Dungeon's "Memory": the premise. |
|
||||
| `story_summary` | The auto-maintained running summary. |
|
||||
| `used_memories` | Top-K retrievals from the memory bank. |
|
||||
|
||||
@@ -184,7 +184,7 @@ of "the model has no idea what just happened". Cards that don't fit are still *r
|
||||
to Insights with `included: false`, so the UI can show the lore that got squeezed out.
|
||||
|
||||
**History fills newest-first and stops.** Oldest turns fall out. This is the right
|
||||
direction because the old material is not actually lost — it has been summarized into
|
||||
direction because the old material is not actually lost: it has been summarized into
|
||||
memories and the running summary, which are in the fixed section.
|
||||
|
||||
**If even the single newest turn is over budget, it gets hard-truncated** rather than
|
||||
@@ -193,8 +193,8 @@ of the last turn produces something.
|
||||
|
||||
**The author's note is injected 3 actions from the end**, not at the top.
|
||||
`AUTHORS_NOTE_DEPTH = 3`. Instructions placed near the end of a prompt have more influence
|
||||
on what comes next than instructions at the top — recency. The author's note is a steering
|
||||
control ("keep it tense"), so it goes where steering works.
|
||||
on what comes next than instructions at the top, because of recency. The author's note is a
|
||||
steering control ("keep it tense"), so it goes where steering works.
|
||||
|
||||
**The world-state reminder goes dead last.** The full emit rule lives up in the system
|
||||
block, hundreds of tokens away from where the model starts writing. A one-line reminder
|
||||
@@ -203,15 +203,15 @@ forgotten.
|
||||
|
||||
**Past AI turns get their state block re-attached.** The state block is stripped from the
|
||||
text before it's stored, so a replayed history would show the model twenty of its own past
|
||||
turns that *contain no state block* — which teaches it, by imitation, to stop emitting one.
|
||||
turns that *contain no state block*. That teaches it, by imitation, to stop emitting one.
|
||||
So `_history_text()` reconstructs the block from the stored delta and re-appends it when
|
||||
building history. The model sees its own pattern and keeps following it.
|
||||
|
||||
### The performance trap hiding in this
|
||||
|
||||
Building the context needs the newest ~6,000 tokens of story. The obvious implementation
|
||||
reads `adventure.actions` — which loads every row of the adventure — and then throws 90%
|
||||
of it away. At turn 200 that was 839 KB of database reads to use maybe 70 KB, and it grew
|
||||
reads `adventure.actions`, which loads every row of the adventure, and then throws 90% of
|
||||
it away. At turn 200 that was 839 KB of database reads to use maybe 70 KB, and it grew
|
||||
every single turn.
|
||||
|
||||
`backend/app/context/history.py` fixes it by serving three shapes directly from SQL: a
|
||||
@@ -225,13 +225,13 @@ projected = int(budget / average * 1.15) + 8
|
||||
```
|
||||
|
||||
Each round fetches only what it doesn't already hold, so no row is read twice. Result: the
|
||||
same turn costs 129 KB instead of 839 KB, and stops growing at around turn 50 — the cost
|
||||
is bounded by the context budget instead of by the length of the story.
|
||||
same turn costs 129 KB instead of 839 KB, and stops growing at around turn 50. The cost is
|
||||
bounded by the context budget instead of by the length of the story.
|
||||
|
||||
There's a second rule in that module worth naming: **if the actions are already loaded in
|
||||
memory, slice them instead of querying.** The scripting pipeline hands the whole history to
|
||||
user scripts (AI Dungeon's API requires it), so on a scripted adventure the rows are
|
||||
already there — issuing a query beside them would mean paying twice.
|
||||
user scripts (AI Dungeon's API requires it), so on a scripted adventure the rows are already
|
||||
there. Issuing a query beside them would mean paying twice.
|
||||
|
||||
---
|
||||
|
||||
@@ -241,19 +241,19 @@ Source: `backend/app/worldstate/engine.py`, `plan/12-phase-rpg-world-state.md`.
|
||||
|
||||
### The problem
|
||||
|
||||
You want an RPG layer — hit points, trust, quest progress. Who owns the numbers?
|
||||
You want an RPG layer: hit points, trust, quest progress. Who owns the numbers?
|
||||
|
||||
### Three options, and why two lose
|
||||
|
||||
**Option A: a deterministic dice engine.** The player types "attack the goblin", the
|
||||
engine rolls, applies damage, and the model narrates the result. This is what a real RPG
|
||||
does. It loses here because the action space is unbounded — the player can type anything,
|
||||
and mapping arbitrary natural language onto a fixed rules system is a harder problem than
|
||||
the one being solved.
|
||||
does. It loses here because the action space is unbounded: the player can type anything, and
|
||||
mapping arbitrary natural language onto a fixed rules system is a harder problem than the
|
||||
one being solved.
|
||||
|
||||
**Option B: the model owns the numbers.** Let it track hp in the prose and trust it. This
|
||||
fails immediately. Models are bad at arithmetic, worse at remembering a number across
|
||||
twenty turns, and completely unable to obey their own frequency rules — tell one "only
|
||||
twenty turns, and completely unable to obey their own frequency rules. Tell one "only
|
||||
change this every 5 turns" and it will change it every turn.
|
||||
|
||||
**Option C, chosen: the model proposes, the engine disposes.** The model narrates and
|
||||
@@ -279,10 +279,10 @@ The engine then applies, in order:
|
||||
| `max_delta_per_turn` | Losing 90 hp to a stubbed toe |
|
||||
| Clamp to `min`/`max` | Negative hp, trust above 100 |
|
||||
| Milestones are sticky, `true` only | Un-completing a quest |
|
||||
| Flags are two-way booleans | (Deliberately unrestricted — that's what flags are for) |
|
||||
| Flags are two-way booleans | (Deliberately unrestricted: that's what flags are for) |
|
||||
|
||||
Everything it rejects is *reported*, not silently swallowed — the Insights panel shows
|
||||
applied, clamped and rejected paths per turn, and the chip under each narration shows what
|
||||
Everything it rejects is *reported*, not silently swallowed. The Insights panel shows
|
||||
applied, clamped, and rejected paths per turn, and the chip under each narration shows what
|
||||
actually changed.
|
||||
|
||||
### The reliability mechanism: word bands
|
||||
@@ -295,33 +295,33 @@ A stat can carry **bands**:
|
||||
[60,90,"healthy"],[90,100,"full health"]] }
|
||||
```
|
||||
|
||||
Two things use them. The live state block shows the current band label — `hp 55/100 (minor
|
||||
damage)` — so the model reads a *word*, not just a number. And the stat guide shows the
|
||||
Two things use them. The live state block shows the current band label, `hp 55/100 (minor
|
||||
damage)`, so the model reads a *word*, not just a number. And the stat guide shows the
|
||||
whole ladder once per turn, so the model can see the full scale it's reasoning across.
|
||||
|
||||
The point: models reason well over semantics and badly over arithmetic. "He's badly hurt,
|
||||
so a solid hit should take him to very weak" is a judgement a model can make. "55 minus 22
|
||||
is 33" is one it will get wrong often enough to matter.
|
||||
so a solid hit should take him to very weak" is a judgment a model can make. "55 minus 22 is
|
||||
33" is one it will get wrong often enough to matter.
|
||||
|
||||
### The failure philosophy
|
||||
|
||||
Nothing in the world-state engine raises. A malformed delta returns `{}` and the turn
|
||||
continues. The parser is deliberately tolerant — it strips trailing commas and leading `+`
|
||||
continues. The parser is deliberately tolerant: it strips trailing commas and leading `+`
|
||||
signs on numbers, both of which weaker free models emit and strict JSON rejects. It accepts
|
||||
a fence labelled `state`, one labelled `json`, or an unlabelled one, and falls back to a
|
||||
bare JSON object hugging the end of the text — but only if it parses into something that
|
||||
looks like a delta, so prose ending in `}` is never eaten.
|
||||
a fence labeled `state`, one labeled `json`, or an unlabeled one, and falls back to a bare
|
||||
JSON object at the end of the text, but only if it parses into something that looks like a
|
||||
delta, so prose ending in `}` is never eaten.
|
||||
|
||||
This matters because the hosted demo runs on free-tier models. A stricter parser would mean
|
||||
a good model works and a free one doesn't.
|
||||
|
||||
### One call, not two
|
||||
|
||||
The model narrates *and* emits the delta in a single request. The alternative — narrate,
|
||||
then a second call to extract structured state — is more reliable per call and costs twice
|
||||
the latency and twice the rate-limit budget. On the free tier (20 requests/minute) that
|
||||
would halve the playable turn rate. The tolerant parser plus the terminal reminder was the
|
||||
cheaper way to buy the same reliability.
|
||||
The model narrates *and* emits the delta in a single request. The alternative, narrating and
|
||||
then making a second call to extract structured state, is more reliable per call and costs
|
||||
twice the latency and twice the rate-limit budget. On the free tier (20 requests/minute)
|
||||
that would halve the playable turn rate. The tolerant parser plus the terminal reminder was
|
||||
the cheaper way to buy the same reliability.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,9 +339,9 @@ Tell the model its budget: *"keep this turn under about N words"*.
|
||||
|
||||
### What the measurement showed
|
||||
|
||||
Average turn length went from **174 words to 246** — every run longer than every unhinted
|
||||
run (n=5). Phrased as a budget, the number reads as a *target to fill*. The hint pushed
|
||||
turns toward the very wall it existed to protect.
|
||||
Average turn length went from **174 words to 246**, and every run was longer than every
|
||||
unhinted run (n=5). Phrased as a budget, the number reads as a *target to fill*. The hint
|
||||
pushed turns toward the very wall it existed to protect.
|
||||
|
||||
### The fix
|
||||
|
||||
@@ -361,12 +361,12 @@ Average came back to 170 words, and the state block survived at tight caps.
|
||||
words = int((max_output_tokens - 50) * 0.75 * 0.90)
|
||||
```
|
||||
|
||||
- `- 50` (`LENGTH_HEADROOM`) — tokens held back for the state block itself.
|
||||
- `* 0.75` (`WORDS_PER_TOKEN`) — models can't count their own tokens, but they do follow a
|
||||
- `- 50` (`LENGTH_HEADROOM`): tokens held back for the state block itself.
|
||||
- `* 0.75` (`WORDS_PER_TOKEN`): models can't count their own tokens, but they do follow a
|
||||
word budget. English prose is roughly 0.75 words per token.
|
||||
- `* 0.90` (`LENGTH_BUFFER`) — a word budget is a suggestion the model overshoots; the cap
|
||||
- `* 0.90` (`LENGTH_BUFFER`): a word budget is a suggestion the model overshoots, and the cap
|
||||
it protects is a hard wall. Aim 10% short so the overshoot lands in slack.
|
||||
- Below 40 words the hint is dropped entirely — it stops earning its tokens.
|
||||
- Below 40 words the hint is dropped entirely: it stops earning its tokens.
|
||||
|
||||
---
|
||||
|
||||
@@ -377,7 +377,7 @@ Source: `backend/app/memorybank.py`.
|
||||
### The problem
|
||||
|
||||
Story history falls out of the context window as the adventure grows. Turn 4 said you
|
||||
promised the innkeeper you'd return. At turn 90 that's long gone from the prompt — but if
|
||||
promised the innkeeper you'd return. At turn 90 that's long gone from the prompt, but if
|
||||
you walk back into the inn, it should come back.
|
||||
|
||||
### The three layers
|
||||
@@ -401,23 +401,23 @@ back into the prompt.
|
||||
|
||||
### The decisions inside it
|
||||
|
||||
**A memory hangs off the node whose block it ends on.** Not off the adventure, and not off
|
||||
a *position* in a list of actions — off a `(branch_id, depth)` coordinate. That is what
|
||||
**A memory attaches to the node whose block it ends on.** Not to the adventure, and not to
|
||||
a *position* in a list of actions, but to a `(branch_id, depth)` coordinate. That is what
|
||||
makes "which memories described this turn?" an indexed lookup rather than a scan for rows
|
||||
whose covered range has fallen off the end of the story, and it is what makes memories
|
||||
inherit correctly across a fork: the ones above the fork point already sit on ancestors
|
||||
both lines read.
|
||||
|
||||
It is also the repair. When a turn's text is replaced or removed — a retry, an undo, a
|
||||
deleted action — `forget_node` withdraws the memory hanging off that coordinate *and*
|
||||
This is also how repair works. When a turn's text is replaced or removed (a retry, an undo,
|
||||
a deleted action), `forget_node` withdraws the memory attached to that coordinate and
|
||||
rewinds both marks to just before the stretch it covered, so the ground is summarized again
|
||||
from what the story now says. An earlier version instead held the newest action back a turn
|
||||
so it could never be summarized before it stopped being retryable; that is no longer needed,
|
||||
because the repair exists whether or not the invalidation happens at the tip.
|
||||
so it could never be summarized before it stopped being retryable. That is no longer
|
||||
needed, because the repair exists whether or not the invalidation happens at the tip.
|
||||
|
||||
**Cursors only advance on success.** Every AI call in this module is best-effort. If
|
||||
summarization fails, the function returns and the cursor is unchanged, so the same block is
|
||||
retried on a later turn. There is no retry loop, no dead-letter queue, no backoff — the
|
||||
retried on a later turn. There is no retry loop, no dead-letter queue, and no backoff: the
|
||||
cadence *is* the retry mechanism. Failures are logged to the debug page.
|
||||
|
||||
**Summarization is fire-and-forget, in a background task with its own DB session.** The
|
||||
@@ -437,7 +437,7 @@ truncate and score garbage silently. An explicit length check returns 0.0 instea
|
||||
|
||||
**Eviction is LRU-ish, and evicted memories are kept.** Over capacity (default 200), the
|
||||
least-used, least-recently-used unpinned memories are marked `forgotten` rather than
|
||||
deleted — so the UI can still show them and you can un-forget one.
|
||||
deleted, so the UI can still show them and you can un-forget one.
|
||||
|
||||
**Background calls never spend the shared demo key.** The summarization and embedding
|
||||
providers are built directly from the user's own settings and never from the demo config,
|
||||
@@ -454,7 +454,7 @@ anything makes a 20-second generation feel broken.
|
||||
|
||||
**Server-Sent Events (SSE)** is the mechanism: an HTTP response that stays open and pushes
|
||||
`data: {...}` lines as they become available. It's one-directional (server → browser),
|
||||
which is exactly what's needed here — WebSockets would be a bidirectional connection for a
|
||||
which is exactly what's needed here. WebSockets would be a bidirectional connection for a
|
||||
unidirectional problem.
|
||||
|
||||
The chain:
|
||||
@@ -473,7 +473,7 @@ text), `stopped` (a script blocked the turn), `error`, `done`.
|
||||
|
||||
Two production details that only show up when hosted:
|
||||
|
||||
- `X-Accel-Buffering: no` — nginx-style reverse proxies buffer responses by default, which
|
||||
- `X-Accel-Buffering: no`: nginx-style reverse proxies buffer responses by default, which
|
||||
turns a stream into one big delivery at the end. This header tells them to flush each
|
||||
event.
|
||||
- The security-headers and body-size middlewares are written as **pure ASGI** rather than
|
||||
@@ -481,7 +481,7 @@ Two production details that only show up when hosted:
|
||||
break streaming.
|
||||
|
||||
**The empty-reply case is diagnosed, not reported as "empty".** If a reasoning model
|
||||
streams thinking but no story text, it spent its whole budget thinking — the error says so
|
||||
streams thinking but no story text, it spent its whole budget thinking. The error says so
|
||||
and tells you which three settings to change.
|
||||
|
||||
---
|
||||
@@ -498,18 +498,18 @@ The safety properties are mostly structural:
|
||||
|
||||
| Property | How |
|
||||
|---|---|
|
||||
| No filesystem, network, or process access | QuickJS has none by default — nothing was removed, nothing was added |
|
||||
| No filesystem, network, or process access | QuickJS has none by default: nothing was removed, nothing was added |
|
||||
| Memory cap | 16 MB per run |
|
||||
| CPU cap | 2 seconds per run |
|
||||
| No shared state between runs | A fresh `Context` per hook execution |
|
||||
| A broken script can't break a turn | Every failure comes back as `.error` with text/state/cards unchanged; the pipeline logs it and continues |
|
||||
|
||||
Data crosses the boundary as JSON — Python serializes `{state, text, history, storyCards,
|
||||
Data crosses the boundary as JSON: Python serializes `{state, text, history, storyCards,
|
||||
info}` in, and the script's results out. There is no object bridge to exploit.
|
||||
|
||||
One deliberate bug-compatibility: `addStoryCard` returns the new card's *index*, so the
|
||||
first card returns `0`, which is falsy, so `if (!addStoryCard(...))` misfires. That's
|
||||
upstream AI Dungeon's behaviour. It's documented in the code and left alone, because
|
||||
upstream AI Dungeon's behavior. It's documented in the code and left alone, because
|
||||
matching real scripts is the whole point of the feature.
|
||||
|
||||
---
|
||||
@@ -517,7 +517,7 @@ matching real scripts is the whole point of the feature.
|
||||
## 1.8 Why there is no agent framework
|
||||
|
||||
Graph-based agent frameworks (LangGraph and similar) earn their complexity with
|
||||
**branching, cyclic, multi-step control flow** — a graph of nodes where the path depends on
|
||||
**branching, cyclic, multi-step control flow**: a graph of nodes where the path depends on
|
||||
what the model decides, with loops, retries, tool calls, and persisted state between steps.
|
||||
|
||||
This turn pipeline is a **fixed linear sequence with exactly one model call**. There is no
|
||||
@@ -533,13 +533,13 @@ abstraction, its serialization model, and its debugging surface to express a str
|
||||
There is also a specific reason a framework's context handling wouldn't fit: **the
|
||||
budgeting logic is the product.** Buffer-window and summary-memory abstractions are
|
||||
opinionated about how to fit history into a window. Here the Insights panel exposes each
|
||||
context component, its token cost, and the trigger word that pulled it in — which means the
|
||||
assembly has to be explicit and inspectable.
|
||||
context component, its token cost, and the trigger word that pulled it in, so the assembly
|
||||
has to be explicit and inspectable.
|
||||
|
||||
**When it would be the right call:** if the design went toward the two-call version —
|
||||
narrate, then a separate structured-extraction step, with a retry branch when extraction
|
||||
fails and a tool-calling path for dice — that is a graph, and hand-rolling it would get
|
||||
ugly fast.
|
||||
**When it would be the right call:** if the design went toward the two-call version, where
|
||||
narration is followed by a separate structured-extraction step, with a retry branch when
|
||||
extraction fails and a tool-calling path for dice, that is a graph, and hand-rolling it
|
||||
would get ugly fast.
|
||||
|
||||
**This is not the same branching as the story tree (§2.2).** "No branching" here
|
||||
describes *control flow*: the code path a single turn takes through the backend. That path
|
||||
@@ -583,8 +583,8 @@ behind it.
|
||||
### The problem
|
||||
|
||||
The story used to be a list, and a mutable one. Retry rewrote the last entry in place;
|
||||
undo and delete removed entries from the middle. Everything derived from the story —
|
||||
the memories, the running summary, the two marks saying how far each had got — was
|
||||
undo and delete removed entries from the middle. Everything derived from the story, such as
|
||||
the memories, the running summary, and the two marks saying how far each had got, was
|
||||
indexed by *position in that list*, and a position means something different after
|
||||
anything in front of it is deleted.
|
||||
|
||||
@@ -610,8 +610,8 @@ Make the story a tree, and none of them are reachable.
|
||||
|
||||
Every action is a **node** with a `branch_id` and a `depth`. A **branch** is one line
|
||||
through the tree; it holds the nodes played on it and *borrows* everything before its
|
||||
fork point from its ancestors. Nothing is ever copied and — apart from an explicit
|
||||
delete — nothing is ever removed.
|
||||
fork point from its ancestors. Nothing is ever copied, and apart from an explicit delete,
|
||||
nothing is ever removed.
|
||||
|
||||
```
|
||||
branches(id, adventure_id, parent_branch_id, fork_depth, lineage, name)
|
||||
@@ -643,38 +643,38 @@ keep the shape they already had.
|
||||
### The lineage, and why fork count doesn't cost anything
|
||||
|
||||
The OR-clause above is not reconstructed per read. It is stored on the branch row as
|
||||
`lineage` — `[(C, ∞), (B, 5), (A, 3)]` — computed once when the fork happens, from the
|
||||
parent's lineage plus one entry. `context/lineage.py` is the only module that knows how
|
||||
to turn it into a query, which is deliberate: one forgotten clause shows the wrong story
|
||||
and reports nothing.
|
||||
`lineage`, for example `[(C, ∞), (B, 5), (A, 3)]`, computed once when the fork happens,
|
||||
from the parent's lineage plus one entry. `context/lineage.py` is the only module that
|
||||
knows how to turn it into a query, which is deliberate: one forgotten clause shows the
|
||||
wrong story and reports nothing.
|
||||
|
||||
Two properties of the shape do the real work:
|
||||
|
||||
- **The ranges are disjoint and descending.** A branch's own nodes always sit deeper than
|
||||
its fork point, and each ancestor is capped at the fork depth of the branch beneath it.
|
||||
So ordering the whole clause by `depth DESC` reads entry 0's nodes, then entry 1's,
|
||||
then entry 2's — which means a tail read can use the newest few entries and stop.
|
||||
then entry 2's, which means a tail read can use the newest few entries and stop.
|
||||
- **Clause count is bounded by the context window, not by fork count.** A 200-fork story
|
||||
whose newest branch is 40 turns long reads with *one* clause, because the window is
|
||||
covered before the second entry is reached.
|
||||
|
||||
A branch stores no story of its own, so a fork costs an id, a parent, a fork depth and a
|
||||
cached ancestry. Measured on a 40-turn story forked twenty times against the same story
|
||||
flat: a page load of **31,652 B against 31,433 B — 1.007×**, or about **103 bytes per
|
||||
branch**. No migration, no vacuum, no copy.
|
||||
flat: a page load of **31,652 B against 31,433 B, a 1.007× ratio**, or about **103 bytes
|
||||
per branch**. No migration, no vacuum, no copy.
|
||||
|
||||
### What a player actually does
|
||||
|
||||
None of the above is what the screen shows. In the player's words:
|
||||
|
||||
> Any turn can gain another **take**. On an AI turn that means regenerate; on your own
|
||||
> message it means type something else. Stepping between takes with `‹ 2/4 ›` is free —
|
||||
> message it means type something else. Stepping between takes with `‹ 2/4 ›` is free:
|
||||
> the story below simply empties, because that take has no children yet. **A branch is
|
||||
> created when you write below a take that is not the live one**, never before.
|
||||
|
||||
That rule collapses two operations into one and deletes a distinction from the UI. The
|
||||
first version of this screen had a chip that *switched* at the tip and only *previewed*
|
||||
above it, with a second button to take that line — one control whose meaning depended on
|
||||
above it, with a second button to take that line: one control whose meaning depended on
|
||||
where the reader was standing. The rule above replaced it with a pager that only ever
|
||||
steps, a fork button on every turn, and no tip-versus-past distinction at all. The
|
||||
distinction survives in the implementation, where it decides whether a write needs a
|
||||
@@ -685,7 +685,7 @@ switch and no branch is created.
|
||||
|
||||
The load-bearing detail, and the one that is not obvious.
|
||||
|
||||
The natural way to find "the other takes of this turn" is by coordinate — same branch,
|
||||
The natural way to find "the other takes of this turn" is by coordinate: same branch,
|
||||
same depth. It is wrong in both directions:
|
||||
|
||||
```
|
||||
@@ -700,8 +700,8 @@ land on different branches. It gets `C` wrong: once C has been forked onto a bra
|
||||
its own it is alone at its coordinate and reads `1/1`, having lost C1 and C2 from a pager
|
||||
that must still say `1/3`.
|
||||
|
||||
So a node carries `parent_id`, read for nothing but this. The alternative — making a
|
||||
branch's fork point a *node* rather than a depth, so a promoted take never moves — was
|
||||
So a node carries `parent_id`, read for nothing but this. The alternative, making a
|
||||
branch's fork point a *node* rather than a depth so a promoted take never moves, was
|
||||
rejected: the whole point of `lineage` is that a read is an OR-clause per branch instead
|
||||
of a walk up parent pointers, and re-pointing the fork at a node changes path resolution
|
||||
itself, dragging in the cursors, memory depths and both bundle formats. `parent_id` is
|
||||
@@ -709,7 +709,7 @@ one indexed lookup, never a walk, and nothing about how a path resolves changes.
|
||||
|
||||
### Cursors become anchors
|
||||
|
||||
The two marks — how far the memory bank has got, how far the summary has got — used to
|
||||
The two marks, how far the memory bank has got and how far the summary has got, used to
|
||||
be counts. A count is a position in a list, and every rule about sliding them, rewinding
|
||||
them and translating between positions and `Action.index` existed to patch up the fact
|
||||
that the list moves.
|
||||
@@ -727,23 +727,23 @@ existed because a retry could rewrite an action the mark had already passed.
|
||||
|
||||
### Derived work attaches to the node that produced it
|
||||
|
||||
Generalise the rule and a lot falls out: *anything derived hangs off the node that
|
||||
produced it*. A memory covering depths 37–42 hangs off that branch's node 42 and is
|
||||
Generalize the rule and a lot falls out: *anything derived attaches to the node that
|
||||
produced it*. A memory covering depths 37–42 attaches to that branch's node 42 and is
|
||||
invisible to any path that does not run through it. Shared ancestors are therefore shared
|
||||
automatically, so **a fork needs nothing recreated** — the memories above the fork point
|
||||
automatically, so **a fork needs nothing recreated**: the memories above the fork point
|
||||
are already on the ancestors both lines read.
|
||||
|
||||
The subtle case is the memory sitting *at* the forked coordinate. The first cut moved it
|
||||
onto the new branch and re-anchored the marks naming it. Both are wrong for the same
|
||||
reason: that memory describes whichever attempt was live at that coordinate, which is the
|
||||
one staying on the parent. The right answer needs no code — the lineage caps the parent
|
||||
one depth short of the fork, so the memory is simply out of range from the new branch,
|
||||
one staying on the parent. The right answer needs no code: the lineage caps the parent one
|
||||
depth short of the fork, so the memory is simply out of range from the new branch,
|
||||
invisible to both the retrieval clause and the anchor read. The new line summarizes that
|
||||
ground again, from the text it actually tells.
|
||||
|
||||
Hand-written memories obey the same rule. One used to carry a NULL depth, described as
|
||||
"belongs to the adventure rather than to a path" — which sounds harmless and is not: a
|
||||
NULL is a coordinate no fork can cap, so a note typed on one line followed the reader onto
|
||||
"belongs to the adventure rather than to a path". That sounds harmless and is not: a NULL
|
||||
is a coordinate no fork can cap, so a note typed on one line followed the reader onto
|
||||
branches whose events it never described. They are anchored at the head instead: *the
|
||||
story you were reading when you wrote it*.
|
||||
|
||||
@@ -754,7 +754,7 @@ also why branch management could not be a nice-to-have: without a way to delete
|
||||
storage grows without limit.
|
||||
|
||||
The delete rule has two halves and the second is easy to miss. Refusing to delete the
|
||||
line being read is obvious. The other half is refusing any line it was **forked from** —
|
||||
line being read is obvious. The other half is refusing any line it was **forked from**:
|
||||
`parent_branch_id` cascades, so deleting an ancestor takes the head with it and leaves
|
||||
`head_branch_id` pointing at a row that is gone. One membership test against the head's
|
||||
own lineage covers both, because a lineage already names itself and every branch it
|
||||
@@ -764,7 +764,7 @@ button can say so before it is pressed.
|
||||
### The migration, and what it deliberately did not do
|
||||
|
||||
There is no feature flag. **A linear story is a tree with one branch**, so the
|
||||
intermediate states were not half-migrated — they were the same product with a superset
|
||||
intermediate states were not half-migrated: they were the same product with a superset
|
||||
schema underneath, which made "existing adventures are unaffected" a literal, testable
|
||||
pass condition at every step. A flag would have bought two live code paths through the
|
||||
context builder, the memory bank, undo and retry at once.
|
||||
@@ -774,8 +774,8 @@ were kept unread for a release rather than dropped with the migration that stopp
|
||||
them, so that a redeploy of the previous build is still a way out. Dropping columns is the
|
||||
one step that isn't.
|
||||
|
||||
One operational note that generalises: on Postgres, a migration that rewrites every row of
|
||||
`actions` roughly doubles the table and only `VACUUM FULL` gives it back — 79 MB reclaimed
|
||||
One operational note that generalizes: on Postgres, a migration that rewrites every row of
|
||||
`actions` roughly doubles the table, and only `VACUUM FULL` gives it back: 79 MB reclaimed
|
||||
in 5.5 s on one occasion. But bloat scales with the **heap**, and `context_snapshot` is 94%
|
||||
of this table and lives out of line, so a migration touching only small columns reuses the
|
||||
existing TOAST pointer and costs a tenth of that. Read the sizes from `sum(octet_length())`
|
||||
@@ -786,14 +786,14 @@ makes bloat look smaller.
|
||||
|
||||
- **The two marks are one pair on the adventure**, not one per branch. Switching branches
|
||||
makes the mark on the line being left unreadable from the new one, and that ground is
|
||||
summarized again. It answers "nothing covered", which is the safe direction — redo the
|
||||
work, never skip it — but switching back and forth costs AI calls. Per-branch cursors
|
||||
summarized again. It answers "nothing covered", which is the safe direction: redo the
|
||||
work, never skip it. But switching back and forth costs AI calls. Per-branch cursors
|
||||
are the fix if it ever matters.
|
||||
- **Story cards stay adventure-wide.** A card invented on branch B shows on branch A.
|
||||
Event-sourcing card changes onto nodes was considered and rejected.
|
||||
- **Editing an already-summarized action still leaves its memory stale.** The machinery to
|
||||
fix it now exists — an edit could write a sibling take and switch to it, which is a retry
|
||||
the player typed — but it does not do that yet.
|
||||
fix it now exists: an edit could write a sibling take and switch to it, which is a retry
|
||||
the player typed. It does not do that yet.
|
||||
|
||||
## 2.3 Undo and retry that actually rewind
|
||||
|
||||
@@ -801,17 +801,17 @@ Most implementations of undo delete the last message. That's wrong here, because
|
||||
mutates three things: the text, the scripting scoreboard (`script_state`), and the RPG
|
||||
stats (`world_state`).
|
||||
|
||||
**The mechanism:** every node carries `state_after` and `world_state_after` — deep copies
|
||||
**The mechanism:** every node carries `state_after` and `world_state_after`, deep copies
|
||||
of what the adventure looked like once that turn had played. Rewinding to before a turn is
|
||||
a read of the node in front of it, so undo, retry and a branch switch are the same
|
||||
restore. The cooldown clock comes along for free: it lives inside the world state, in
|
||||
`_meta.last_changed`, so each line of the story carries its own without anything having to
|
||||
know there is one.
|
||||
|
||||
**Nothing a retry replaces is thrown away.** The old attempt stays as another **take** of
|
||||
that turn — a sibling node at the same coordinate, `live` false — and the pager steps
|
||||
between them. Which is to say retry is not a special case: it is the tree, with the branch
|
||||
not yet created. See [2.2](#22-the-story-is-a-tree).
|
||||
**Nothing a retry replaces is discarded.** The old attempt stays as another **take** of
|
||||
that turn, a sibling node at the same coordinate with `live` set to false, and the pager
|
||||
steps between them. Retry is not a special case: it is the tree, with the branch not yet
|
||||
created. See [2.2](#22-the-story-is-a-tree).
|
||||
|
||||
Three details that are easy to get wrong:
|
||||
|
||||
@@ -832,9 +832,10 @@ take belonging to a line nobody asked about. Anything that reads a take group an
|
||||
*writes* has to say whether it means the turn or the coordinate.
|
||||
|
||||
**If the regeneration fails, the rollback is reversed.** `generate_turn` wraps the
|
||||
generator in a `try/finally`: if it ends without saving — a provider error, an empty reply,
|
||||
a script `stop`, or the browser hanging up — the previous take is put back in charge.
|
||||
Otherwise the state on the server would drift from the text still on the user's screen.
|
||||
generator in a `try/finally`: if it ends without saving, whether from a provider error, an
|
||||
empty reply, a script `stop`, or the browser hanging up, the previous take is put back in
|
||||
charge. Otherwise the state on the server would drift from the text still on the user's
|
||||
screen.
|
||||
|
||||
## 2.4 The turn lock
|
||||
|
||||
@@ -861,11 +862,11 @@ async def with_turn_lock(adventure_id, gen): # wraps the SSE generator
|
||||
```
|
||||
|
||||
In-memory, so it's a single-process guarantee. That's honest for the deployment this
|
||||
targets — one Render web service. Two processes would need the lock in the database.
|
||||
targets: one Render web service. Two processes would need the lock in the database.
|
||||
|
||||
## 2.5 The 189x egress fix
|
||||
|
||||
**The setup:** `Action.context_snapshot` holds the entire assembled prompt for a turn —
|
||||
**The setup:** `Action.context_snapshot` holds the entire assembled prompt for a turn,
|
||||
about 74 KB per row, 94% of the database.
|
||||
|
||||
**The bug:** every adventure load pulled that column for every action, to read two small
|
||||
@@ -876,7 +877,7 @@ report). SQLAlchemy loads all columns by default.
|
||||
|
||||
1. Move the two small things that *are* needed for every action into their own column
|
||||
(`Action.world_delta`).
|
||||
2. Mark the heavy columns `deferred` — `context_snapshot`, `variants`, `reasoning` — so
|
||||
2. Mark the heavy columns `deferred` (`context_snapshot`, `variants`, `reasoning`), so
|
||||
they're only fetched when explicitly asked for.
|
||||
3. Backfill the new column with dialect-specific server-side SQL, so the old data is
|
||||
extracted inside the database and never crosses the wire.
|
||||
@@ -890,7 +891,7 @@ not on a timing.
|
||||
|
||||
One more detail from that test's design: the count query is written as a real
|
||||
`SELECT count(...)` rather than `query.count()`, because SQLAlchemy's `.count()` wraps the
|
||||
entity select in a subquery, so the emitted SQL names every column — including the deferred
|
||||
entity select in a subquery, so the emitted SQL names every column, including the deferred
|
||||
ones. No bytes come back either way, but the database still has to read them, and a guard
|
||||
that greps SQL cannot tell the two apart.
|
||||
|
||||
@@ -906,7 +907,7 @@ No Alembic. An append-only list of `(version, SQL)` pairs, with the current vers
|
||||
in SQLite's `PRAGMA user_version` or a one-row table on Postgres. 64 versions so far.
|
||||
|
||||
- A **fresh** database is created by `Base.metadata.create_all()` (always current) and
|
||||
stamped at the latest version — it never replays history.
|
||||
stamped at the latest version; it never replays history.
|
||||
- An **existing** database runs every migration above its stored version, in order.
|
||||
|
||||
Why this and not Alembic: for a single-file SQLite app that a user might have been running
|
||||
@@ -916,9 +917,9 @@ environment. This is 250 lines and you can read all of it.
|
||||
|
||||
The constraint it creates is written at the top of the file: change `models.py` (so fresh
|
||||
databases are current) *and* append a pair here (so existing ones upgrade). Migrations 2–23
|
||||
predate Postgres support and use SQLite-only syntax — harmless, because every Postgres
|
||||
database starts fresh and never replays them, but anything added since must run on both
|
||||
dialects.
|
||||
predate Postgres support and use SQLite-only syntax. This is harmless, because every
|
||||
Postgres database starts fresh and never replays them, but anything added since must run
|
||||
on both dialects.
|
||||
|
||||
One migration worth reading (#10, repairing duplicate action indexes) uses `UPDATE … FROM`
|
||||
with a window function rather than a correlated subquery, because SQLite may evaluate a
|
||||
@@ -936,7 +937,7 @@ correlated subquery against partially-updated rows and produce duplicates again
|
||||
| | Local (default) | Hosted |
|
||||
|---|---|---|
|
||||
| Users | One auto-created "local user" | Guest on first visit, optional account |
|
||||
| Auth | None — no cookies, no login UI | Signed session cookie |
|
||||
| Auth | None: no cookies, no login UI | Signed session cookie |
|
||||
| Rate limits | Off | On |
|
||||
| Row caps | Off | On |
|
||||
| API docs (`/docs`) | On | Off |
|
||||
@@ -948,13 +949,13 @@ deployment needs all four of those to be the opposite. Rather than two builds, t
|
||||
differences are gated at each site.
|
||||
|
||||
**Guests upgrade in place.** A visitor gets a guest `User` row on first load. Registering
|
||||
sets `email` and `password_hash` on that *same row* — so every adventure they played as a
|
||||
sets `email` and `password_hash` on that *same row*, so every adventure they played as a
|
||||
guest survives with no re-parenting and no migration step. Three kinds of row share the
|
||||
users table: local (email NULL, not guest), guest (email NULL, guest), registered (email
|
||||
set).
|
||||
|
||||
**Guests expire; accounts don't.** One row per curious visitor adds up, so `cleanup.py`
|
||||
deletes guests idle for `AIDND_GUEST_RETENTION_DAYS` (default 5) — measured as
|
||||
deletes guests idle for `AIDND_GUEST_RETENTION_DAYS` (default 5), measured as
|
||||
`COALESCE(last_seen_at, created_at)`, because `_touch` only writes `last_seen_at` hourly
|
||||
and a guest minted by `/auth/me` has NULL until its second request. The filter requires
|
||||
both `is_guest` *and* `email IS NULL`, so upgrading in place is also how you opt out of
|
||||
@@ -964,7 +965,7 @@ every few hours.
|
||||
It's a single Core `DELETE`, not `db.delete(user)`: the ORM path would SELECT every
|
||||
adventure, action and memory into Python purely to delete them, and the FK graph is
|
||||
`ON DELETE CASCADE` from `users` all the way down, so the database can do the whole graph
|
||||
in one statement. Nothing a guest owns is visible to anyone else either — `is_public` is
|
||||
in one statement. Nothing a guest owns is visible to anyone else either: `is_public` is
|
||||
output-only, so shared content is exactly the seeded scenarios, which have `user_id NULL`
|
||||
and never match the filter.
|
||||
|
||||
@@ -976,11 +977,11 @@ is a spending surface, so it's the most defended code in the project.
|
||||
`resolve_provider_config()` is the single place the BYOK-vs-demo decision is made, and on
|
||||
the demo branch it pins **two** things:
|
||||
|
||||
- **The model** — to a whitelist. A caller-supplied override or a hand-edited settings row
|
||||
can't aim a server-funded key at an expensive model. Anything unrecognised falls back to
|
||||
the first whitelisted model.
|
||||
- **The endpoint** — to the configured demo URL. Otherwise the key could be redirected to a
|
||||
URL the user controls and harvested.
|
||||
- **The model**, pinned to a whitelist. A caller-supplied override or a hand-edited
|
||||
settings row can't aim a server-funded key at an expensive model. Anything unrecognized
|
||||
falls back to the first whitelisted model.
|
||||
- **The endpoint**, pinned to the configured demo URL. Otherwise the key could be
|
||||
redirected to a URL the user controls and harvested.
|
||||
|
||||
Plus a daily per-user turn cap (default 20), checked *before* the player's input is stored
|
||||
so a capped player doesn't get their message saved with no reply, and counted only after a
|
||||
@@ -989,12 +990,12 @@ successful turn.
|
||||
There's a defensive `__post_init__` on the config object that raises if a demo config
|
||||
somehow carries a non-whitelisted model. The comment on it records a real bug: the check
|
||||
tests `using_demo`, **not** `api_key == DEMO_API_KEY`. Keying on the key value looks
|
||||
stricter but is wrong — the demo key is an ordinary OpenRouter key, so a user can
|
||||
stricter but is wrong: the demo key is an ordinary OpenRouter key, so a user can
|
||||
legitimately paste that same key into their own settings as BYOK, and then every resolution
|
||||
raised, 500ing even `GET /auth/me` and taking the whole SPA down. `using_demo` is what
|
||||
actually means "the server is paying".
|
||||
|
||||
Background work (summarization, embeddings) is excluded from the demo key entirely — those
|
||||
Background work (summarization, embeddings) is excluded from the demo key entirely: those
|
||||
are unmetered calls, and unmetered calls on a server-funded key is a bill.
|
||||
|
||||
## 3.3 Secrets
|
||||
@@ -1004,11 +1005,11 @@ Everything derives from one server-side secret (`AIDND_SECRET_KEY`).
|
||||
| Thing | Mechanism |
|
||||
|---|---|
|
||||
| Passwords | `hashlib.scrypt`, N=2^14, r=8, p=1, per-password salt, constant-time compare. Stdlib, so no extra dependency. |
|
||||
| Sessions | `v1.<user_id>.<HMAC-SHA256>`, no expiry — long-lived guest sessions are the point. A cookie can outlive a swept guest row; that resolves to a 401, which the frontend already turns into a fresh session. |
|
||||
| Stored LLM API keys | Fernet (AES) encryption at rest, key derived from the secret, `enc:` prefix so legacy plaintext rows are recognisable and migratable. |
|
||||
| Sessions | `v1.<user_id>.<HMAC-SHA256>`, no expiry: long-lived guest sessions are the point. A cookie can outlive a swept guest row; that resolves to a 401, which the frontend already turns into a fresh session. |
|
||||
| Stored LLM API keys | Fernet (AES) encryption at rest, key derived from the secret, `enc:` prefix so legacy plaintext rows are recognizable and migratable. |
|
||||
|
||||
The secret auto-generates into a file next to the database for local installs (zero config),
|
||||
but **multi-user mode refuses to start without the env var** — with an error message that
|
||||
but **multi-user mode refuses to start without the env var**, with an error message that
|
||||
explains why and gives you the command to generate one. Hosted filesystems are ephemeral; a
|
||||
regenerated secret on every deploy would silently log out every user and orphan their stored
|
||||
API keys.
|
||||
@@ -1032,11 +1033,11 @@ rather than raising, so the user just re-enters their key instead of hitting a 5
|
||||
Rate limits are keyed per user when one is known (accounts survive IP changes) and per IP
|
||||
otherwise, in fixed windows held in memory, with a pruning pass so the per-IP dict can't
|
||||
grow without bound. Import endpoints check bundle list lengths against the same caps live
|
||||
creation enforces — otherwise the cap is trivially bypassed by uploading a file.
|
||||
creation enforces, otherwise the cap is trivially bypassed by uploading a file.
|
||||
|
||||
Security headers on every response: `nosniff`, `X-Frame-Options: DENY`,
|
||||
`Referrer-Policy: same-origin`, and a CSP allowing exactly what the SPA uses — same-origin
|
||||
everything, inline styles (React needs them), Google Fonts.
|
||||
`Referrer-Policy: same-origin`, and a CSP allowing exactly what the SPA uses: same-origin
|
||||
everything, inline styles (React needs them), and Google Fonts.
|
||||
|
||||
## 3.5 Deployment
|
||||
|
||||
@@ -1060,13 +1061,13 @@ push.
|
||||
|
||||
A hosted demo raises a question a local app never does: is anyone using it, and do they get
|
||||
anywhere? The answer is an owner-only dashboard at `/analytics`, gated on
|
||||
`AIDND_ANALYTICS_EMAILS` — a list kept separate from `AIDND_POWER_USERS`, since an unmetered
|
||||
`AIDND_ANALYTICS_EMAILS`, a list kept separate from `AIDND_POWER_USERS`, since an unmetered
|
||||
tester is not automatically someone who should see the traffic.
|
||||
|
||||
**Why it isn't a third-party script.** The CSP allows `script-src 'self'`, so a tracker would
|
||||
mean loosening it; adblockers eat the popular ones, which silently biases exactly the
|
||||
technical audience this project is shown to; and none of them can see the measurement that
|
||||
matters here — a *turn*. The interesting funnel step is not a pageview.
|
||||
mean loosening it. Ad blockers block the popular ones, which silently biases exactly the
|
||||
technical audience this project is shown to. And none of them can see the measurement that
|
||||
matters here: a *turn*. The interesting funnel step is not a pageview.
|
||||
|
||||
**Egress is the budget.** After the 189x fix (§2.5) it would be perverse to add a feature
|
||||
that reads rows per request. So counts accumulate in a process-local dict and flush every 60
|
||||
@@ -1075,23 +1076,23 @@ seconds as UPSERTs: **a visit is a write and never a read**. Storage is a generi
|
||||
Every dashboard query is a `GROUP BY` that returns tens of rows regardless of the traffic
|
||||
behind it, so a month costs a few kilobytes to read back. The cost of the buffer is that a
|
||||
hard restart can lose up to a minute; the flusher also runs on shutdown, and on a tier that
|
||||
sleeps when idle the buffer it sleeps on is empty anyway.
|
||||
sleeps when idle, the buffer it sleeps on is empty anyway.
|
||||
|
||||
**The numbers are the server's, not the browser's.** The client reports one fact — which page
|
||||
was viewed — and even that is normalized to a route (`/play/12` → `/play/:id`) against a
|
||||
**The numbers are the server's, not the browser's.** The client reports one fact, which page
|
||||
was viewed, and even that is normalized to a route (`/play/12` → `/play/:id`) against a
|
||||
whitelist, so the page list cannot be polluted by anything a stranger posts. Everything that
|
||||
means something — a turn, an adventure, a sign-up — is recorded by the code that performs it.
|
||||
That also fixes a blind spot: a failed turn is an HTTP 200 with a bad ending, so a
|
||||
status-code tally cannot see it, and a demo whose model has started refusing looks perfectly
|
||||
healthy from outside. `turn_error` is counted where the SSE error is written.
|
||||
means something, such as a turn, an adventure, or a sign-up, is recorded by the code that
|
||||
performs it. That also fixes a blind spot: a failed turn is an HTTP 200 with a bad ending, so
|
||||
a status-code tally cannot see it, and a demo whose model has started refusing looks
|
||||
perfectly healthy from outside. `turn_error` is counted where the SSE error is written.
|
||||
|
||||
The funnel counts **people, not clicks** — a player who starts six adventures is one person
|
||||
who started an adventure — which is the entire reason the per-visitor-day table exists.
|
||||
The funnel counts **people, not clicks**: a player who starts six adventures is one person
|
||||
who started an adventure, which is the entire reason the per-visitor-day table exists.
|
||||
|
||||
One smaller decision worth naming: error buckets are labelled by the matched *route template*,
|
||||
never the requested path. That gives one bucket per endpoint instead of one per adventure id,
|
||||
and — the reason it isn't merely tidier — an unmatched path is entirely attacker-chosen, so
|
||||
labelling by it would let anyone mint rows.
|
||||
One smaller decision worth naming: error buckets are labeled by the matched *route
|
||||
template*, never the requested path. That gives one bucket per endpoint instead of one per
|
||||
adventure id. The reason it isn't merely tidier is that an unmatched path is entirely
|
||||
attacker-chosen, so labeling by it would let anyone mint rows.
|
||||
|
||||
---
|
||||
|
||||
@@ -1099,9 +1100,9 @@ labelling by it would let anyone mint rows.
|
||||
|
||||
For the parts that are just how the web works, not decisions.
|
||||
|
||||
**Frontend and backend are two programs.** In development they're two servers — Vite on
|
||||
5173 serving React, FastAPI on 8000 serving the API — and Vite proxies `/api` to FastAPI so
|
||||
the browser thinks it's all one origin (which avoids CORS entirely). In production there's
|
||||
**Frontend and backend are two programs.** In development they're two servers: Vite on 5173
|
||||
serving React, and FastAPI on 8000 serving the API. Vite proxies `/api` to FastAPI so
|
||||
the browser thinks it's all one origin, which avoids CORS entirely. In production there's
|
||||
one server: FastAPI serves the built React files as static assets from the same port.
|
||||
|
||||
**SPA routing.** React Router handles URLs like `/play/3` in the browser without a round
|
||||
@@ -1112,7 +1113,7 @@ unaffected.
|
||||
|
||||
**Sessions.** A cookie is a small value the browser stores and automatically attaches to
|
||||
every request to that site. Here it holds `v1.<user_id>.<signature>`. The server doesn't
|
||||
store sessions anywhere — it re-verifies the signature on each request, which is why there's
|
||||
store sessions anywhere; it re-verifies the signature on each request, which is why there's
|
||||
no session table.
|
||||
|
||||
**The 401 retry.** If the cookie is missing or stale, any API call returns 401. The frontend
|
||||
@@ -1143,7 +1144,7 @@ text appears to type itself.
|
||||
| Context defaults | author's note at depth 3, cards capped at 40% of elastic budget |
|
||||
| Memory cadence | memory / 6 turns, summary / 15 turns, top-5 retrieval |
|
||||
|
||||
Two of the tests encode a performance property rather than a behaviour:
|
||||
Two of the tests encode a performance property rather than a behavior:
|
||||
`test_egress.py` asserts on the SQL the ORM emits, and `test_history_window.py` asserts
|
||||
that the read cost stops growing with story length.
|
||||
|
||||
@@ -1157,7 +1158,7 @@ nobody has to discover them the hard way.
|
||||
lock) and the rate limiter in Redis.
|
||||
- **No vector index.** Retrieval does cosine similarity in Python over the whole bank. Fine
|
||||
at the 200-memory cap; at 10,000 it would want pgvector.
|
||||
- **Prompt snapshots are heavy** even after the egress fix — they're deferred, not smaller.
|
||||
- **Prompt snapshots are heavy** even after the egress fix; they're deferred, not smaller.
|
||||
Compressing them or expiring old ones is the real fix.
|
||||
- **In-memory rate-limit windows reset on restart**, so a restart grants a brief extra
|
||||
allowance.
|
||||
@@ -1167,7 +1168,7 @@ nobody has to discover them the hard way.
|
||||
detect after the fact by string-matching the 429 body.
|
||||
- **The two memory marks are one pair on the adventure, not one per branch.** Switching
|
||||
lines makes the mark on the line being left unreadable from the new one, so that ground is
|
||||
summarized again. It fails in the safe direction — redo, never skip — but switching back
|
||||
summarized again. It fails in the safe direction: redo, never skip. But switching back
|
||||
and forth costs AI calls. Per-branch cursors are the fix if it matters.
|
||||
- **Story cards are adventure-wide**, so a card invented on one branch shows on all of them.
|
||||
- **Editing an already-summarized turn leaves its memory stale.** Replacing a turn withdraws
|
||||
@@ -1175,8 +1176,8 @@ nobody has to discover them the hard way.
|
||||
|
||||
## Cleanup backlog
|
||||
|
||||
`docs/self-review.md` carries an open list of non-bugs — reuse, simplification and
|
||||
efficiency items — kept deliberately separate from the correctness list, which is empty.
|
||||
`docs/self-review.md` carries an open list of non-bugs (reuse, simplification, and
|
||||
efficiency items) kept deliberately separate from the correctness list, which is empty.
|
||||
The largest ones:
|
||||
|
||||
- `Section.tokens` is uncached, so the context gets tokenized two or three times a turn.
|
||||
|
||||
+55
-53
@@ -1,72 +1,73 @@
|
||||
# Self-review log
|
||||
|
||||
**Every correctness bug on this page is resolved** — 20 fixed, 1 intentionally skipped with the
|
||||
reasoning recorded below. The record is kept because the reasoning outlives the verdicts;
|
||||
several of these are traps worth remembering. The *cleanup backlog* at the bottom is a
|
||||
deliberately open list of non-bugs (reuse, simplification, efficiency), not outstanding defects.
|
||||
**Every correctness bug on this page is resolved.** 20 are fixed, and 1 is intentionally
|
||||
skipped, with the reasoning recorded below. The record is kept because the reasoning outlives
|
||||
the verdicts, and several of these are traps worth remembering. The *cleanup backlog* at the
|
||||
bottom is a deliberately open list of non-bugs (reuse, simplification, efficiency), not
|
||||
outstanding defects.
|
||||
|
||||
Original review: 2026-07-05, whole project in scope (no git history at the time).
|
||||
Status key: `fixed` = applied, `skipped` = intentionally not fixed, `pending` = outstanding
|
||||
(none remain).
|
||||
Status key: `fixed` means applied, `skipped` means intentionally not fixed, and `pending` means
|
||||
outstanding (none remain).
|
||||
|
||||
**2026-07-06 update (branch `bugfix-code-review`):** every finding re-verified against
|
||||
current code. #1/#2/#3/#4/#6/#12 had already been fixed in earlier sessions (statuses were
|
||||
stale); #5/#7/#8/#9/#10/#13/#14 (backend) and #15–#21 (frontend) fixed in this pass.
|
||||
#11 is skipped: real AI Dungeon's `addStoryCard` also returns the new card's index
|
||||
(0-falsy included) per the official scripting guidebook, so changing it would break
|
||||
compatibility — documented in `engine.py`'s prelude instead. The cleanup backlog
|
||||
**2026-07-06 update (branch `bugfix-code-review`):** every finding was re-verified against
|
||||
current code. #1, #2, #3, #4, #6, and #12 had already been fixed in earlier sessions, so their
|
||||
statuses were stale. #5, #7, #8, #9, #10, #13, #14 (backend) and #15 through #21 (frontend)
|
||||
were fixed in this pass. #11 is skipped: real AI Dungeon's `addStoryCard` also returns the new
|
||||
card's index (0-falsy included) per the official scripting guidebook, so changing it would
|
||||
break compatibility. This is documented in `engine.py`'s prelude instead. The cleanup backlog
|
||||
(R/S/E/A items) below remains open by design.
|
||||
|
||||
## Correctness bugs
|
||||
|
||||
### 1. [fixed] seed_demo.py doesn't stamp schema version → server crashes on next start
|
||||
- `backend/seed_demo.py:10`
|
||||
- Fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. Next server start sees tables exist, replays every ALTER TABLE migration → `duplicate column name` crash.
|
||||
- A fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. The next server start sees the tables exist and replays every ALTER TABLE migration, causing a `duplicate column name` crash.
|
||||
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
|
||||
|
||||
### 2. [fixed] Turn-lock race: two simultaneous turns can run on the same adventure
|
||||
- `backend/app/routers/adventures.py:302`
|
||||
- `ensure_not_generating()` runs in the route handler but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-click Continue → both requests pass the 409 check → duplicate Action.index rows, interleaved generations.
|
||||
- `ensure_not_generating()` runs in the route handler, but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-clicking Continue sends both requests past the 409 check, producing duplicate Action.index rows and interleaved generations.
|
||||
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
|
||||
|
||||
### 3. [fixed] Migration 10 renumbers indexes with a correlated subquery on the table being updated
|
||||
- `backend/app/migrations.py:34`
|
||||
- SQLite may evaluate the subquery against partially-updated rows → duplicate indexes survive the "repair".
|
||||
- SQLite may evaluate the subquery against partially updated rows, so duplicate indexes can survive the "repair".
|
||||
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
|
||||
|
||||
### 4. [fixed] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
|
||||
- `backend/app/database.py:8`
|
||||
- Deleting a Script leaves orphaned `scenario_scripts` rows; SQLite rowid reuse can attach a future script to an old scenario.
|
||||
- Deleting a Script leaves orphaned `scenario_scripts` rows. SQLite rowid reuse can then attach a future script to an old scenario.
|
||||
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
|
||||
|
||||
### 5. [fixed] Provider generate() silently yields nothing for non-SSE 200 responses
|
||||
- `backend/app/providers/openai_compatible.py:84`
|
||||
- Server that ignores `stream=true` and returns plain JSON → no `data:` lines → empty AI action, no error.
|
||||
- Fix: buffer non-SSE body and fall back to parsing it as a single JSON completion.
|
||||
- A server that ignores `stream=true` and returns plain JSON produces no `data:` lines, so the result is an empty AI action with no error.
|
||||
- Fix: buffer the non-SSE body and fall back to parsing it as a single JSON completion.
|
||||
|
||||
### 6. [fixed] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
|
||||
- `backend/app/memorybank.py:146`
|
||||
- `asyncio.create_task` result not referenced; task can vanish silently; adventure ID can stay stuck in `_running`.
|
||||
- The result of `asyncio.create_task` is not referenced, so the task can vanish silently and leave an adventure ID stuck in `_running`.
|
||||
- Fix: keep strong refs in a set, discard in done-callback.
|
||||
|
||||
### 7. [fixed] Memory cursors are list positions but Memory.source_start/end are Action.index values
|
||||
- `backend/app/memorybank.py:213`
|
||||
- After any action deletion (indexes keep gaps, positions shift), summarization skips/duplicates blocks and `_update_story_summary` folds the wrong memories.
|
||||
- Fix: use one space consistently — track cursors by Action.index (position-independent), or renumber on delete.
|
||||
- After any action deletion, indexes keep gaps and positions shift, so summarization skips or duplicates blocks and `_update_story_summary` folds the wrong memories.
|
||||
- Fix: use one space consistently. Track cursors by Action.index (position-independent), or renumber on delete.
|
||||
|
||||
### 8. [fixed] Pinned memories don't count toward memory_top_k cap
|
||||
- `backend/app/memorybank.py:120`
|
||||
- 6 pinned + top_k=5 → 11 memories injected, blowing token budget.
|
||||
- 6 pinned plus top_k=5 injects 11 memories, blowing the token budget.
|
||||
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
|
||||
|
||||
### 9. [fixed] Embedding-model change → cosine() zips different-dimension vectors silently
|
||||
- `backend/app/memorybank.py:69`
|
||||
- Old 768-dim embeddings scored against new 1536-dim query → garbage similarity, no error, never re-embedded.
|
||||
- Old 768-dimension embeddings scored against a new 1536-dimension query produce garbage similarity with no error, and are never re-embedded.
|
||||
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
|
||||
|
||||
### 10. [fixed] MAX_STORY_CARDS cap is a no-op for cards created in one hook
|
||||
- `backend/app/scripting/pipeline.py:59`
|
||||
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards (seen_ids ⊂ existing). A script can insert unbounded cards in one turn.
|
||||
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards, since seen_ids is a subset of existing. A script can insert an unbounded number of cards in one turn.
|
||||
- Fix: count inserts made during the loop.
|
||||
|
||||
### 11. [skipped] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
|
||||
@@ -80,17 +81,17 @@ compatibility — documented in `engine.py`'s prelude instead. The cleanup backl
|
||||
|
||||
### 13. [fixed] test_connection 500s on non-dict JSON from /models
|
||||
- `backend/app/routers/settings.py:54`
|
||||
- Only ValueError caught; `data.get`/`m.get` on non-dict raises AttributeError → 500 instead of `{ok:false}`.
|
||||
- Only ValueError is caught. `data.get`/`m.get` on non-dict input raises AttributeError, producing a 500 instead of `{ok:false}`.
|
||||
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
|
||||
|
||||
### 14. [fixed] AI Dungeon exports with `worldInformation` key lose all story cards silently
|
||||
- `backend/app/routers/scenarios.py:133`
|
||||
- Import reads only `storyCards`/`worldInfo`; `worldInformation` is in _IGNORED_KEYS so it's dropped and not reported.
|
||||
- Import reads only `storyCards`/`worldInfo`. `worldInformation` is in _IGNORED_KEYS, so it is dropped without being reported.
|
||||
- Fix: accept `worldInformation` as a card source too.
|
||||
|
||||
### 15. [fixed] Shared debounce timer loses edits (Play PlotPanel)
|
||||
- `frontend/src/pages/Play.jsx:28`
|
||||
- One `saveTimer` shared by all plot fields AND story-card saves; editing a second thing within 600ms cancels the first pending PATCH → silent data loss.
|
||||
- One `saveTimer` is shared by all plot fields and story-card saves. Editing a second field within 600ms cancels the first pending PATCH, causing silent data loss.
|
||||
- Fix: per-key timers (e.g. a Map keyed by field/card id).
|
||||
|
||||
### 16. [fixed] Same shared-debounce data loss in ScenarioEditor
|
||||
@@ -99,49 +100,50 @@ compatibility — documented in `engine.py`'s prelude instead. The cleanup backl
|
||||
|
||||
### 17. [fixed] Continue button silently discards typed input text
|
||||
- `frontend/src/pages/Play.jsx:469`
|
||||
- Clicking Continue with text in the box sends type 'continue' (backend ignores text) and clears the input.
|
||||
- Clicking Continue with text in the box sends type 'continue' (the backend ignores the text) and clears the input.
|
||||
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
|
||||
|
||||
### 18. [fixed] retry() optimistically deletes last AI action with no rollback on failure
|
||||
- `frontend/src/pages/Play.jsx:475`
|
||||
- Failed retry (409/network) leaves UI missing an action that still exists server-side.
|
||||
- A failed retry (409 or network error) leaves the UI missing an action that still exists server-side.
|
||||
- Fix: restore the removed action in the catch path (or only remove on first stream event).
|
||||
|
||||
### 19. [fixed] Settings test()/save() have no error handling → stuck on "Testing…"
|
||||
- `frontend/src/pages/Settings.jsx:66`
|
||||
- Rejection leaves `{pending:true}` forever + unhandled rejection.
|
||||
- A rejection leaves `{pending:true}` forever and produces an unhandled rejection.
|
||||
- Fix: try/catch → setTestResult({ok:false, error:msg}).
|
||||
|
||||
### 20. [fixed] InsightsPanel race: slow earlier request overwrites newer report
|
||||
- `frontend/src/pages/Play.jsx:302`
|
||||
- No staleness guard; slow getAdventureContext can clobber a newer action snapshot.
|
||||
- Fix: track a request id / cancelled flag in the effect.
|
||||
- There is no staleness guard, so a slow getAdventureContext call can overwrite a newer action snapshot.
|
||||
- Fix: track a request id or cancelled flag in the effect.
|
||||
|
||||
### 21. [fixed] extractPlaceholders ignores ${...} in story-card trigger keys
|
||||
- `frontend/src/pages/Scenarios.jsx:50`
|
||||
- Backend fills placeholders in card.keys but the modal never prompts for those names → literal `${hero}` keys never match.
|
||||
- The backend fills placeholders in card.keys, but the modal never prompts for those names, so a literal `${hero}` key never matches.
|
||||
- Fix: also scan card.keys when collecting placeholder names.
|
||||
|
||||
## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later)
|
||||
|
||||
- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34` — three copies of the debounced-autosave + story-card handlers; extract a `useDebouncedSave` hook / shared StoryCardList component. (Fixing bugs #15/#16 properly may accomplish this.)
|
||||
- **R2** `backend/seed_demo.py:228` — re-implements create_adventure; call the router logic instead.
|
||||
- **R3** `backend/app/providers/openai_compatible.py:122` — complete() duplicates _request() body building; add a `stream` param to _request().
|
||||
- **R4** `backend/app/routers/adventures.py:476` — six copies of child-resource get+owner-check+404; extract `get_owned_or_404`.
|
||||
- **R5** `frontend/src/api.js:26` — streamSSE duplicates request()'s error extraction; extract `throwIfNotOk(resp)`.
|
||||
- **S1** `backend/app/models.py:210` — `Settings.stream` is dead state (never read); delete column + schema fields.
|
||||
- **S2** `frontend/src/pages/Play.jsx:6` — MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo computed twice.
|
||||
- **E1** `backend/app/context/builder.py:119` — joins+tokenizes the ENTIRE adventure history every turn for the trigger window; walk reversed(actions) until budget instead.
|
||||
- **E2** `backend/app/scripting/pipeline.py:77` — rebuilds full history dicts + JSON + a blocking commit per script per hook; build once per hook, slice to HISTORY_WINDOW first, commit once.
|
||||
- **E3** `frontend/src/pages/Play.jsx:561` — every SSE chunk re-renders all action rows; isolate streaming text in a child component / React.memo rows.
|
||||
- **E4** `backend/app/context/builder.py:48` — Section.tokens uncached, whole context tokenized 2-3×/turn; cache counts, sum sections.
|
||||
- **E5** `frontend/src/pages/Play.jsx:490` — keydown effect has no dep array → listener re-registered every render.
|
||||
- **E6** `backend/app/memorybank.py:182` — catch-up summarization awaits blocks sequentially; gather independent blocks.
|
||||
- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34`: three copies of the debounced-autosave and story-card handlers. Extract a `useDebouncedSave` hook and a shared StoryCardList component. Fixing bugs #15/#16 properly may accomplish this.
|
||||
- **R2** `backend/seed_demo.py:228`: re-implements create_adventure. Call the router logic instead.
|
||||
- **R3** `backend/app/providers/openai_compatible.py:122`: complete() duplicates _request()'s body building. Add a `stream` param to _request().
|
||||
- **R4** `backend/app/routers/adventures.py:476`: six copies of child-resource get+owner-check+404. Extract `get_owned_or_404`.
|
||||
- **R5** `frontend/src/api.js:26`: streamSSE duplicates request()'s error extraction. Extract `throwIfNotOk(resp)`.
|
||||
- **S1** `backend/app/models.py:210`: `Settings.stream` is dead state (never read). Delete the column and its schema fields.
|
||||
- **S2** `frontend/src/pages/Play.jsx:6`: MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo are computed twice.
|
||||
- **E1** `backend/app/context/builder.py:119`: joins and tokenizes the entire adventure history every turn for the trigger window. Walk reversed(actions) until budget instead.
|
||||
- **E2** `backend/app/scripting/pipeline.py:77`: rebuilds full history dicts, JSON, and a blocking commit per script per hook. Build once per hook, slice to HISTORY_WINDOW first, and commit once.
|
||||
- **E3** `frontend/src/pages/Play.jsx:561`: every SSE chunk re-renders all action rows. Isolate streaming text in a child component or React.memo rows.
|
||||
- **E4** `backend/app/context/builder.py:48`: Section.tokens is uncached, so the whole context gets tokenized 2 to 3 times per turn. Cache counts and sum sections.
|
||||
- **E5** `frontend/src/pages/Play.jsx:490`: the keydown effect has no dependency array, so the listener is re-registered every render.
|
||||
- **E6** `backend/app/memorybank.py:182`: catch-up summarization awaits blocks sequentially. Gather independent blocks instead.
|
||||
- **A1** ~~no UniqueConstraint('adventure_id','index'); index allocation is ad-hoc per writer.~~
|
||||
**Overtaken by phase 14 (2026-08).** `index` is a legacy column that nothing reads: ordering is
|
||||
`(branch_id, depth)` now, allocated in one place (`tree.place_action`). The column is kept unread
|
||||
for one release and then dropped, so a constraint on it would be a constraint on a corpse.
|
||||
- **A2** `backend/app/providers/openai_compatible.py:45` — CHAT_CONTINUE_HINT appended below the budgeting layer; assemble prompts in context builder.
|
||||
- **A3** `backend/app/routers/adventures.py:390` — import endpoints hand-coerce raw dicts; use a Pydantic bundle schema.
|
||||
- **A4** `backend/app/routers/adventures.py:207` — onModelContext flattens (system, story) and ships everything as user content if modified; pass structure through the hook.
|
||||
- **A5** `frontend/src/pages/Home.jsx:87` — client appends 'Z' to naive datetimes; emit ISO-8601 with offset from the API instead.
|
||||
**Overtaken by phase 14 (2026-08).** `index` is a legacy column that nothing reads: ordering
|
||||
is `(branch_id, depth)` now, allocated in one place (`tree.place_action`). The column is kept
|
||||
unread for one release and then dropped, so a constraint on it would apply to a column that
|
||||
no longer does anything.
|
||||
- **A2** `backend/app/providers/openai_compatible.py:45`: CHAT_CONTINUE_HINT is appended below the budgeting layer. Assemble prompts in the context builder instead.
|
||||
- **A3** `backend/app/routers/adventures.py:390`: import endpoints hand-coerce raw dicts. Use a Pydantic bundle schema.
|
||||
- **A4** `backend/app/routers/adventures.py:207`: onModelContext flattens (system, story) and ships everything as user content if modified. Pass structure through the hook instead.
|
||||
- **A5** `frontend/src/pages/Home.jsx:87`: the client appends 'Z' to naive datetimes. Emit ISO-8601 with an offset from the API instead.
|
||||
|
||||
+1
-1
@@ -13,4 +13,4 @@ The React Compiler is not enabled on this template because of its impact on dev
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
If you are developing a production application, use TypeScript with type-aware lint rules enabled. See the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for how to integrate TypeScript and Oxlint's TypeScript-related rules in your project.
|
||||
|
||||
Reference in New Issue
Block a user