A newer version of the Gradio SDK is available: 6.20.0
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
{
"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β toneids usable this act (fromdata/tones.jsonact_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|releasingbaseline per act, orlight|heavy|afraid|tenderfrom the emotion she last voiced). The model stays within it so her feeling persists across turns instead of flip-flopping. The server owns mood (ingame.apply_turn); the response'smoodfield echoes the updated value.
Turn response β inference β server
{
"tones": ["self_i", "self_remember", "other_him", "emo_sad"],
"topic_signal": "relationship",
"internal_emotion": "grief",
"special": null
}
| Field | Type | Notes |
|---|---|---|
tones |
string[] |
Tone ids, 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 ids.
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?"
{ "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"
{ "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)
{ "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)
{ "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):
{ "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:
- Parse JSON; on failure, extract first
{...}block. - Drop any tone not in
active_tones; cap at 7. - If
tonesends up empty, fall back to["self_remember", "truth_pause"](a safe, in-character "drift"). - Coerce
topic_signaltonullif not a known signal.
A malformed model response must NEVER crash a turn or break character.