| # 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. |
|
|