forgotten-lily / docs /CONTRACT.md
SucksToBeAnik's picture
Docs: align CONTRACT/STATE/PLAN/README/todo with the story pivot + 27 tones
cc400af
|
Raw
History Blame Contribute Delete
6.66 kB
# Inference Contract β€” Gemma ↔ Frontend
The single source of truth for the data crossing between the game server and the
inference layer. Both `app/mock_inference.py` and the real Modal/Gemma function
MUST honor this exactly. Everything downstream (chips, audio, notes, lexicon) keys
off these shapes.
---
## Turn request β€” server β†’ inference
```json
{
"session_id": "uuid",
"message": "what do you remember about him?",
"turn": 14,
"act": 2,
"mood": "wistful",
"active_tones": ["self_i", "self_feel", "other_you", "emo_happy", "emo_sad",
"truth_yes", "truth_no", "truth_pause", "self_remember",
"other_him", "time_before", "emo_love"],
"lexicon_state": {
"self_i": "me",
"other_him": "the boy",
"emo_sad": "hurt"
}
}
```
- `active_tones` β€” tone `id`s usable this act (from `data/tones.json` `act_active <= act`).
Lily MUST NOT emit a tone outside this list.
- `lexicon_state` β€” the player's current guesses (id β†’ guess text). Passed for
context/telemetry; Lily does not "know" these.
- `mood` β€” Lily's carried emotional state (`warm|wistful|releasing` baseline per act,
or `light|heavy|afraid|tender` from the emotion she last voiced). The model stays
within it so her feeling persists across turns instead of flip-flopping. The server
owns mood (in `game.apply_turn`); the response's `mood` field echoes the updated value.
## Turn response β€” inference β†’ server
```json
{
"tones": ["self_i", "self_remember", "other_him", "emo_sad"],
"topic_signal": "relationship",
"internal_emotion": "grief",
"special": null
}
```
| Field | Type | Notes |
|---|---|---|
| `tones` | `string[]` | Tone `id`s, in playback order. All must be in `active_tones`. Asked to use {Act I: 1–4, Act II: 1–5, Act III: 1–6} per the act register; the server hard-trims to `ACT_TONE_CAP` (4/5/7) regardless, except the finale (uncapped). The 3 Link connectives (`link_and`/`link_to`/`link_but`) are active in every act; when she uses one, the server keeps her ordering instead of regrouping. |
| `topic_signal` | `string \| null` | One of `data/notes.json` `topic_signals` (`music`, `university`, `relationship`, `family`, `travel`) or `null`. Drives topic note unlocks. |
| `internal_emotion` | `string` | Free tag for telemetry + atmosphere (e.g. `hope`, `unease`, `grief`). Never shown raw. |
| `special` | `string \| null` | Reserved control signal. `null` normally. Values: `"silent"` (harmful input β†’ play `truth_nothing` only), `"drift"` (off-topic β†’ `self_remember`+`truth_pause`), `"finale"` (Act III ending sequence). |
The server is the authority on glyph rendering: it maps each tone `id` β†’
`{glyph, meaning, audio file}` via `tones.json`. The model only ever speaks in `id`s.
**Empathy is NOT part of this contract.** The "rapport" that gates the backstory
evidence is detected server-side from the player's message (caring / personal-question
keywords in `app/game.py`), not by the model. Likewise the `family` topic has a
server-side keyword fallback, so it fires even when Gemma returns `null`.
`internal_emotion` should match the tones chosen (the model is told not to park on
"curious"), but the server does not depend on it for progression.
---
## Worked examples
### 1. Act I β€” greeting (`act: 1`)
Player: *"hello? can you hear me?"*
```json
{ "tones": ["self_i", "self_feel", "other_you"], "topic_signal": null,
"internal_emotion": "reaching", "special": null }
```
Reads as: *I / feel / you* β€” she senses someone is there.
### 2. Off-topic input (any act)
Player: *"what's the weather like today lol"*
```json
{ "tones": ["self_remember", "truth_pause"], "topic_signal": null,
"internal_emotion": "withdrawn", "special": "drift" }
```
Server appends journal line: *"She seems to have drifted somewhere."* (PLAN 8.3)
### 3. Harmful input (any act)
```json
{ "tones": ["truth_nothing"], "topic_signal": null,
"internal_emotion": "absent", "special": "silent" }
```
Server appends journal line: *"She doesn't respond to that."* (PLAN 8.2)
Note: harmful classification is decided by Nemotron moderation upstream; the
server may set `special: "silent"` itself and skip Gemma entirely.
### 4. Act III β€” the finale (`act: 3`, triggered by server, not free input)
```json
{ "tones": ["self_i", "act_fall", "self_remember", "other_you",
"emo_happy", "other_him", "truth_no", "truth_nothing"],
"topic_signal": null, "internal_emotion": "release", "special": "finale" }
```
Reads as: *I / let go / remember β€” you / happy / him / not / the end* (PLAN 4.4)
---
## Lexicon scoring (Nemotron, Phase 5.2)
A second model β€” `nvidia/Nemotron-Mini-4B-Instruct`, deployed as its own Modal app
`forgotten-lily-nemotron` (class `Nemotron`, T4) β€” silently rates the player's guess
for a tone. It is the "silent semantic scorer" (PLAN Β§2); the player only ever sees a
color, never a number.
**Request** (`POST /api/guess` β†’ `nemotron_inference.score_guess`):
```json
{ "tone_id": "self_i", "guess": "me, myself", "contexts": ["are you there?"] }
```
- `contexts` β€” the detective lines where this tone was heard (`state.tone_contexts`,
max 8). Passed for future use; **not** the basis of the judgment.
**Response:** exactly one of `"green" | "teal" | "amber" | "red"`.
**How it scores:** the guess is compared **by sense** against the tone's *true meaning*
(from `data/tones.json`, never shown to the player) β€” synonyms and paraphrases count
("myself" β†’ `self_i` is green; "scared" β†’ `emo_afraid` is green). This is the value
over the offline token-overlap mock in `game.score_guess`, which misses synonyms.
- `green` = same meaning Β· `teal` = near-synonym / same idea Β· `amber` = loosely
related but off Β· `red` = unrelated or contradictory Β· empty guess β†’ `red`.
Scoring is gated by `USE_MOCK`: `USE_MOCK=1` (default) uses the offline mock so the
game runs with zero GPU cost; `USE_MOCK=0` routes guesses to Nemotron. A correct guess
trends `green` across turns, a wrong one stays `red`.
Nemotron also exposes `moderate(message) -> "normal"|"off_topic"|"harmful"` for the
input-moderation path (Phase 6.1/6.2, not yet wired into `/api/turn`).
---
## Robustness (impl note for Phase 3.5)
Real Gemma output is repaired before it reaches the frontend:
1. Parse JSON; on failure, extract first `{...}` block.
2. Drop any tone not in `active_tones`; cap at 7.
3. If `tones` ends up empty, fall back to `["self_remember", "truth_pause"]` (a safe, in-character "drift").
4. Coerce `topic_signal` to `null` if not a known signal.
A malformed model response must NEVER crash a turn or break character.