docs(spec): considering-state + transport timeout; park streaming

Pivot M2 anti-freeze from streaming to a lighter considering-state: a rotating
in-character 'the Master considers…' indicator (authored content, §13 spirit)
on the proven non-streaming loop, plus the HttpTransport request-timeout fix so
a hung call yields to the fallback instead of spinning forever. Meets §14's
anti-freeze goal by reframing the wait; near-zero new failure surface. Streaming
design PARKED (banner + roadmap ○), revisit if playtest shows the wait hurts.
Conscious §14 deviation recorded per §18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 14:37:20 -05:00
parent 1ed863d3fe
commit 96432bee87
3 changed files with 178 additions and 1 deletions

View File

@@ -49,7 +49,8 @@ The milestones are ordered to reach the POC's one question as early as possible,
### M2 — Prove aliveness ◀ *(the POC's one question)*
-**Client HTTP loop** — client posts its canon log to `/dm/narrate`, displays the prose, harvests `[FACT]` via its `TagExtractor` into its own log, and shows the degraded-DM authored fallback (§13) on any non-200. First end-to-end vertical slice: **state → HTTP → model → prose → screen → fact-harvest.** Pure `DmService` core behind an injectable transport seam; throwaway harness scene for the first on-screen prose. **Live-proven** against qwen3.5 through Docker (grounded scene prose, no numbers per §7). 67/67 client tests; merged to `dev` (`b949cd2`). Surfaced + fixed two Docker-dev gaps in M1 (prompts not packaged, no container→host-Ollama route) — M1's live proof had been venv-on-host only. *§2: state (client owns the loop, consumes text) · goal: put the Narrator on screen.*
-**Bounded NPC conversation**`/npc/speak` with server-owned persona + `knowledge[]` (spoilers stay off the client) and client-computed `available_moves[]`; the client validates each emitted move against live state, applies the valid, silently drops the invalid, and always keeps the prose (§6). Server role is a thin drop-in on the M1 pipeline (`npc.run` mirrors `narrate.py`); the client added a pure `MoveValidator` (membership is the whole test) + a `MoveApplier` that writes town-NPC disposition to `GameState.npc_dispositions` (not the party log), plus `NpcContent.available_moves` as the single legality home. Free text goes straight to the NPC prompt (no Adjudicator needed — the moves are the NPC's). **Live-proven** against qwen3.5: Fenn answered in-voice, grounded strictly in his authored knowledge, no Luck leak (§7), with `reveal`/`offer_quest` moves landing. The live smoke also caught two real tag-format drifts a unit test can't — paren-less zero-arg moves and a dropped `MOVE:` prefix leaking into prose — both fixed by making `TagExtractor` tolerant/scrub-safe (backward-compatible; narrator path unaffected). Built on `feature/npc-conversation`; awaiting the human's `--no-ff` merge to `dev` (§18). *§2: both (text = the NPC's voice · state = the validated moves) · goal: answer the one question.*
-**Streaming** (§14) — server streams Ollama → client; latency reads as *the DM is speaking* rather than *the game is frozen*, hiding the ~2s call. **Gated on aliveness:** landed only after the dialogue experiment above says the prose is worth polishing. Additive to `ollama_client` (shaped for it) + an SSE/chunked response + the client stream consumer. *§2: text · depends on the client loop · goal: polish, not proof.*
-**Considering-state + transport timeout** — while a model call is in flight the client shows a rotating in-character "the Master considers…" indicator (authored content, §13 spirit — even *loading* is content) instead of a frozen surface, and the `HttpTransport` request timeout is set so a hung call gives way to the authored fallback instead of spinning forever. Meets §14's anti-freeze *goal* by **reframing** the wait, on the *proven* non-streaming loop, with essentially no new failure surface. *§2: text/UI · depends on the client loop · goal: the wait is framed (in-character) and bounded (never hangs).*
-**Streaming** (§14) — **PARKED** (`specs/2026-07-10-narrator-streaming-design.md`). Server streams Ollama → client to genuinely *hide* the ~2s call. Deferred behind the considering-state: it is the POC's largest transport rework (client `HTTPRequest` → poll-based `HTTPClient`) and adds mid-stream error/tag-boundary failure surface — too much risk for "polish, not proof" while the aliveness question is already answered. **Revisit** only if playtesting shows the wait hurts immersion (long responses or a slow prod model). Conscious deviation from §14's "stream everything," recorded here per §18. *§2: text · goal: polish, not proof.*
### M3 — Make it a game *(the playable ~30-min slice — dungeon first, per the sequencing call)*
-**Adjudicator**`/dm/adjudicate`: free text → a legal action or an in-world rejection, **strict JSON** (§12), small/fast model, the one-retry-with-error-appended path. Unlocks the hybrid input model (§15) the playable slice needs — the "what do you do?" boundary. *§2: both (text = the in-world reason · state = the chosen action) · depends on the pipeline · goal: free-text world actions.*

View File

@@ -0,0 +1,167 @@
# Considering-state + transport timeout (M2) — design
Date: 2026-07-10
Milestone: **M2 — prove aliveness** (roadmap). Replaces streaming as the M2
anti-freeze item; **polish, not proof**.
Charter side (§2): **text/UI** — authored presentation while a call is in flight,
plus a reliability fix. No game state; no AI; nothing mutates the canon log.
## Goal
Make the model-call wait read as *the DM is speaking* rather than *the game is
frozen* (§14) — by **reframing** the wait in-character instead of hiding it.
While a `narrate()` / `speak()` call is in flight, the client shows a rotating,
authored "the Master considers…" indicator (Claude-Code-style verb rotation on a
~2s cadence). When the response lands, the indicator clears and the prose
appears. If the call hangs, a request **timeout** gives way to the authored
fallback instead of spinning forever — so the wait is both **framed**
(in-character) and **bounded** (never hangs).
This meets §14's *goal* on the **proven, non-streaming loop**, with essentially
no new failure surface. It is the counter-proposal to Narrator streaming (parked,
`2026-07-10-narrator-streaming-design.md`), chosen because streaming is the POC's
largest transport rework and adds mid-stream error/tag-boundary failure surface
that a polish feature shouldn't carry while the aliveness question is already
answered.
## What already exists (and is proven)
- **Client loop:** `DmService.narrate(canon_log)` and `NpcService.speak(...)` are
async; the harness disables its button, `await`s the call, then renders the
result. On any failure the service already degrades to an authored fallback
line (§13) via `FallbackLibrary` (loads `res://content/fallback/*.json`, returns
a hardcoded constant if the file is missing/malformed).
- **Transport:** `HttpTransport` wraps a Godot `HTTPRequest` node and maps any
non-`RESULT_SUCCESS` completion — **including `RESULT_TIMEOUT`** — to
`DmResponse.failed(...)`, which the service degrades on. But `HTTPRequest.timeout`
is left at its default **`0` (disabled)**: a hung proxy never completes, so the
await never returns. This is the deferred `_http.timeout=0` minor.
- **Config:** `ProxyConfig` already exposes `base_url()` from a `ProjectSettings`
key with a default — the pattern for the new timeout setting.
- **Harnesses:** `narrate_harness.gd` and `npc_harness.gd` both build their UI in
code and follow the disable-button → await → render shape.
The two seams this needs (the fallback-as-content loader; the transport's
non-success → degrade path) already work. The wait is currently just *blank*.
## Decisions
Settled during brainstorming; each is load-bearing.
1. **Reframe the wait, don't hide it.** §14's goal is that latency not read as a
freeze. A rotating in-character indicator achieves that by framing the pause as
the GM *thinking* — arguably more in-voice, for a turn-based game, than text
spraying onto the screen. Streaming (which genuinely hides latency) is parked
behind this, revisited only if playtesting shows the wait hurts.
2. **Loading is content (§13 spirit).** The considering phrases are **authored
content**, loaded like the fallback lines — a small pool of dry, on-voice lines
(§3: the narrator is dry and never winks). Not a spinner asset, not a
hardcoded "Loading…". A missing/malformed file degrades to one hardcoded
constant, so the indicator itself cannot fail.
3. **Rotate through the pool on a ~2s cadence.** Like Claude Code cycling
*thinking / considering / cooking*. A typical ~2s call shows one or two lines;
a slow one keeps rotating until the response or the timeout. The **phrase
selection is a pure unit**; the **timer + label wiring is a thin node shim**
the project's standard pure-core/node-shim split.
4. **The rotation index is instance state, not reset per call.** One indicator
instance persists on the harness and is reused across turns; `next()` advances
a running index that `start()`/`stop()` do **not** reset. So turn 2 continues
where turn 1 left off — the first phrase varies turn to turn **without**
randomness (keeps the core deterministic and unit-testable; no `randi()` in the
tested path).
5. **The timeout fix lands here.** `ProxyConfig` gains
`request_timeout_seconds()` (a `ProjectSettings` key, default **35.0**
just above the server's `OLLAMA_TIMEOUT_SECONDS=30` so the server's own
error/degrade surfaces first when the *model* is slow, while still bounding a
fully-hung proxy). `HttpTransport` sets `_http.timeout` to it. A hang →
`RESULT_TIMEOUT` → the **existing** `failed()` → degrade path fires. This is
why the timeout is essential *to this feature specifically*: without it the
considering-state would rotate **forever** on a hang — worse than a frozen
button, because it looks like progress.
6. **No server changes.** The proxy and the `/dm/narrate` · `/npc/speak`
contracts are untouched. This is entirely client-side presentation + one client
config value.
7. **Wire it into both harnesses.** Both prose surfaces (narrate + npc) get the
indicator — same small integration, and the NPC surface is where the aliveness
feel matters most. The component is reusable by the eventual real dialogue UI.
## Components
- `client/scripts/ui/considering_phrases.gd``ConsideringPhrases` (`class_name`,
pure `RefCounted`). `_init(path := DEFAULT_PATH)` loads a string pool from
`res://content/considering.json` (`{"considering": ["…", …]}`) using the same
robust instance-`JSON.parse()` loader `FallbackLibrary` uses (never
`JSON.parse_string()` — the project's gutconfig turns its engine error into a
false test failure). `next() -> String` returns the pool entry at a running
index and advances it (wrapping); an empty/missing pool returns
`LAST_RESORT` ("The Master considers your words."). Unit-tested.
- `client/scripts/ui/considering_indicator.gd``ConsideringIndicator` extends
`Node` (a repeating rotation needs a `Timer` in the tree, so it can't be a pure
`RefCounted`). The harness `add_child()`s it; in `_ready` it creates a repeating
`Timer` child (`ROTATE_SECONDS := 2.0`, `one_shot=false`). `start(label:
Control)` sets the label to `phrases.next()` and starts the timer, whose timeout
sets the label to `phrases.next()`; `stop()` stops the timer and clears the
label. Holds one `ConsideringPhrases` for its lifetime (decision 4). The
timer/tree wiring is the untested node shim (verified by the harness run); the
phrase logic it drives is tested via `ConsideringPhrases`.
- `client/content/considering.json` — authored pool. Starter lines (dry, §3),
reviewed like writing:
- "The Master considers your words."
- "Somewhere, dice are gathered."
- "The tale weighs its next turn."
- "The dark shifts, thinking."
- "A page is turned, unhurried."
- `client/scripts/net/proxy_config.gd` — add `TIMEOUT_SETTING`,
`TIMEOUT_DEFAULT := 35.0`, `request_timeout_seconds() -> float` (mirrors
`base_url()`).
- `client/scripts/net/http_transport.gd` — in `_init`, set
`_http.timeout = ProxyConfig.request_timeout_seconds()`. No other change; the
existing `result != RESULT_SUCCESS → failed()` path already handles the timeout.
- `client/scripts/harness/narrate_harness.gd` and `.../npc_harness.gd` — build a
`ConsideringIndicator`; call `start(status_label)` before `await`ing the service
and `stop()` after it returns (before rendering the result). Add a small status
`Label` if the harness lacks one to host the rotating text.
## Error handling
- **Slow call** → the indicator rotates; on return it stops and the prose renders.
- **Hung proxy / unreachable** → `HTTPRequest` times out at
`request_timeout_seconds()``RESULT_TIMEOUT``DmResponse.failed(...)` → the
service returns its degraded fallback; the harness `stop()`s the indicator and
shows the authored fallback line. No infinite spin, no error string (§13).
- **Missing/malformed `considering.json`** → `ConsideringPhrases` returns its
hardcoded `LAST_RESORT`; the indicator still works.
## Testing
- **`ConsideringPhrases` (pure, GUT):** cycles the pool in order and wraps; the
running index is **not** reset between logical "calls" (decision 4); a
missing/empty file yields `LAST_RESORT`; a well-formed file loads its pool.
- **`ProxyConfig.request_timeout_seconds`:** returns `TIMEOUT_DEFAULT` with no
setting; honours a `ProjectSettings` override.
- **`HttpTransport` timeout wiring:** constructing `HttpTransport` with a real
`HTTPRequest` sets `http.timeout == ProxyConfig.request_timeout_seconds()`
(read the property — a light assertion on the otherwise-untested shim).
- **Degrade-on-timeout is already covered:** the existing
`test_transport_failure_degrades` service tests exercise `failed()` → degraded
result; a timeout maps to `failed()`, so no new service test is needed (note it,
don't duplicate).
- **Indicator:** the `Timer`/tree wiring is verified by running the harness (see
the rotation, see it stop when prose arrives, kill the proxy → see it rotate
then yield to the fallback after the timeout). No unit test for the timer.
## Scope
**In:** `ConsideringPhrases` (pure) + `considering.json`; `ConsideringIndicator`
(node shim); `ProxyConfig.request_timeout_seconds` + `HttpTransport` timeout
wiring; both harnesses showing the indicator around the await; the test set above.
**Out:** streaming (parked, `2026-07-10-narrator-streaming-design.md`); any server
change; real dialogue UI; combat flavor; companion-flavored considering lines
(a nice later touch — Brannoc/Cadwyn voicing the wait — but not now).

View File

@@ -1,5 +1,14 @@
# Narrator streaming (M2) — design
> **STATUS: PARKED (2026-07-10).** Deferred in favour of a lightweight
> considering-state (see `2026-07-10-considering-state-design.md`), which meets
> §14's anti-freeze *goal* by reframing the wait in-character rather than hiding
> it — for a fraction of the risk (this piece is the POC's largest transport
> rework and adds mid-stream error/tag-boundary failure surface that a
> "polish, not proof" feature shouldn't). Revisit only if playtesting shows the
> wait genuinely hurts immersion (long Narrator responses or a slow prod model).
> This design stays ready for that moment.
Date: 2026-07-10
Milestone: **M2 — prove aliveness** (roadmap). The third M2 item; **polish, not
proof** — gated on the aliveness question, which the NPC work answered.