Files
settled-reach/docs/workshops/llm-voice-pipeline/tyre-round3.md
T
jpmschweitzer 23d9ff0a58 Merge remote-tracking branch 'origin/main' into planning
# Conflicts:
#	CHANGELOG.md
#	content/_meta/README.md
#	content/_meta/npc-authoring-style-guide.md
#	wiki/_templates/cultural-group.md
#	wiki/_templates/institution.md
#	wiki/_templates/star-system.md
#	wiki/characters/devra.md
#	wiki/characters/drin.md
#	wiki/characters/harek.md
#	wiki/characters/lera-sessik.md
#	wiki/characters/maret-korr.md
#	wiki/characters/naia-tamm.md
#	wiki/characters/nils-davan.md
#	wiki/characters/pell.md
#	wiki/characters/renn.md
#	wiki/characters/resha.md
#	wiki/characters/sabel.md
#	wiki/characters/sera-venn.md
#	wiki/characters/torek-lintar.md
#	wiki/characters/voss.md
#	wiki/star-systems/krenn/index.md
2026-03-14 00:24:53 +01:00

25 KiB

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Tyre Round 3: Implementation Specification Spike 1 implementation spec, model test plan, spike 2 outline, and hardware detection design workshop archived llm-voice-pipeline tyre 3 2026-03-07

Tyre Round 3: Implementation Specification

Domain: Technical architecture Round: 3 — Decision & Implementation Plan Deliverables: Spike 1 implementation spec, model test plan, Spike 2 outline, hardware detection design


1. Spike 1 Implementation Spec: sr-voice CLI Tool

Purpose

A standalone Rust CLI that loads a GGUF model, accepts prompts, and returns generated text. No game integration, no queue, no cache. This is the plumbing that Jeroen, Mellanie, and Paula will feed manually-crafted prompts through to answer: "does this even play?"

Crate structure

server/
  sr-voice/
    Cargo.toml
    src/
      main.rs          # CLI entry point
      inference.rs     # Model loading and generation wrapper
      prompt.rs        # Prompt file parsing and construction

sr-voice is a separate crate in the server workspace, not compiled into the game binary. It depends on llama-cpp-rs (or llama-cpp-2 — see build notes) and produces a standalone binary: sr-voice.

Cargo.toml dependencies

[package]
name = "sr-voice"
version = "0.1.0"
edition = "2021"

[dependencies]
llama-cpp-2 = { version = "0.1", features = ["metal", "vulkan"] }
# Note: "metal" and "vulkan" are optional features, compile-time gated.
# CPU-only is the default and always available.
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[features]
default = []
gpu-metal = ["llama-cpp-2/metal"]
gpu-vulkan = ["llama-cpp-2/vulkan"]

Build note on llama-cpp-rs vs llama-cpp-2: Both wrap the same C library. llama-cpp-2 is the more actively maintained fork as of early 2026 and has cleaner safe Rust wrappers. Evaluate both at spike start; pick whichever compiles cleanly on Linux + macOS + Windows without manual C++ toolchain intervention. Pin the llama.cpp commit hash in Cargo.toml to prevent upstream API breaks.

Build dependency: Requires a C/C++ compiler (gcc/clang/MSVC). CMake is pulled in by the llama.cpp build system. This is a compile-time dependency, not a runtime dependency — the final binary is self-contained.

CLI interface

sr-voice --model <path-to-gguf> [OPTIONS] [PROMPT_FILE]

Options:
  --model <path>        Path to GGUF model file (required)
  --threads <n>         CPU threads for inference (default: physical_cores - 1)
  --ctx-size <n>        Context window size in tokens (default: 512)
  --max-tokens <n>      Maximum output tokens (default: 64)
  --temperature <f>     Sampling temperature (default: 0.7)
  --top-p <f>           Top-p sampling (default: 0.9)
  --seed <n>            RNG seed for sampling (default: random)
  --json                Output as JSON: {"input": "...", "output": "...", "tokens_per_sec": N}
  --batch               Process multiple prompts from a JSONL file (one per line)
  --benchmark           Run 5 inference calls and report avg tokens/sec

PROMPT_FILE:
  Read prompt from file (plain text). If omitted, reads from stdin.

Core function signatures

// inference.rs

/// Configuration for the inference engine.
pub struct InferenceConfig {
    pub model_path: PathBuf,
    pub n_threads: u32,
    pub ctx_size: u32,
    pub seed: Option<u32>,
}

/// A loaded model ready for inference.
pub struct InferenceEngine {
    // Wraps llama_model + llama_context from llama-cpp-2.
    // Model is loaded once; context is reused across calls.
    model: LlamaModel,
    ctx: LlamaContext,
}

impl InferenceEngine {
    /// Load a GGUF model from disk. Returns an error if the model
    /// doesn't fit in available RAM or the file is invalid.
    ///
    /// Typical load time: 2-5 seconds for a 2B Q4 model from SSD.
    pub fn load(config: &InferenceConfig) -> Result<Self, VoiceError>;

    /// Run inference on a prompt string. Returns the generated text.
    ///
    /// `max_tokens`: maximum output tokens (stops early on EOS).
    /// `temperature`: sampling temperature (0.0 = greedy, 1.0 = creative).
    /// `top_p`: nucleus sampling threshold.
    pub fn generate(
        &mut self,
        prompt: &str,
        max_tokens: u32,
        temperature: f32,
        top_p: f32,
    ) -> Result<GenerationResult, VoiceError>;
}

/// Result of a single inference call.
pub struct GenerationResult {
    /// Generated text (stripped of prompt echo).
    pub text: String,
    /// Number of tokens generated.
    pub tokens_generated: u32,
    /// Wall-clock time for generation (excludes prompt processing).
    pub generation_time_ms: u64,
    /// Tokens per second (generation phase only).
    pub tokens_per_sec: f32,
    /// Wall-clock time for prompt processing (prefill).
    pub prefill_time_ms: u64,
}

pub enum VoiceError {
    ModelLoadFailed(String),
    InferenceFailed(String),
    OutOfMemory,
    InvalidModel(String),
}
// prompt.rs

/// A structured prompt payload for the spike test matrix.
/// Parsed from JSON files that Mellanie/Paula/Jeroen prepare.
#[derive(Debug, Deserialize)]
pub struct PromptPayload {
    /// Unique ID for tracking results.
    pub id: String,
    /// Content type being re-voiced.
    pub content_type: ContentType,
    /// The fully assembled prompt string (system + injectors + base text).
    pub prompt: String,
    /// The original base text (for output comparison).
    pub base_text: String,
    /// Expected semantic core (for tell payloads — optional).
    pub semantic_core: Option<String>,
}

#[derive(Debug, Deserialize)]
pub enum ContentType {
    Behavior,
    Tell,
    Dialogue,
}

Batch mode for the test matrix

The --batch flag processes a JSONL file where each line is a PromptPayload JSON object. Output is JSONL with the original payload + generated text + timing:

{"id":"rural-farmer-1","content_type":"Behavior","base_text":"tends crops in the field","output":"works the irrigation channels before morning rotation","tokens_per_sec":8.2,"prefill_ms":340,"generation_ms":3650}
{"id":"nervous-tell-1","content_type":"Tell","base_text":"shifts weight and checks the time without reason","output":"shifts from foot to foot, void-ward glances at the clock","tokens_per_sec":7.9,"prefill_ms":380,"generation_ms":3800}

This enables Mellanie and Paula to prepare prompt files, run them through both models, and compare output side by side. The JSON output feeds directly into a comparison spreadsheet or diff tool.

What Spike 1 does NOT include

  • No game integration
  • No queue or priority system
  • No cache
  • No thread pool management
  • No prompt construction logic (prompts are hand-crafted by the content team for the spike)
  • No save/load of voiced content
  • No Godot interaction

2. Model Test Plan

Candidates

Model Parameters Q4_K_M Size Why it's here
Gemma 2 2B 2.6B ~1.5 GB Primary candidate. Google origin. Good instruction following for size.
Phi-3-mini 3.8B ~2.2 GB Fallback candidate. Microsoft origin. Better quality, larger footprint.

Addressing the Phi-3 size discrepancy: The original proposal called both "2B class." This is incorrect. Phi-3-mini is 3.8B parameters — nearly 50% larger. This matters for:

  • RAM: +700MB at Q4 (+47% over Gemma 2B)
  • Throughput: ~30% slower decode due to larger weight matrix
  • Install size: +700MB in the distribution bundle

Phi-3-mini is the quality fallback, not a peer candidate. If Gemma 2B passes the quality bar, Phi-3 is unnecessary. If Gemma 2B fails, Phi-3 tells us whether more parameters solve the problem or the task itself is wrong for small models.

Test matrix

Prompt payloads (prepared by Mellanie/Paula/Jeroen — Tyre provides the structure):

ID Content type Base text Zone Culture Traits Mood TellCategory Notes
B-01 Behavior "tends crops in the field" rural van-maanens-star Bold, Honest Neutral Simple role action
B-02 Behavior "checks a manifest against a handheld scanner, lips moving" industrial van-maanens-star Curious, Social Neutral Detailed role action
B-03 Behavior "catches {target}'s eye and nods across the room" industrial van-maanens-star Social, Compassionate Neutral Relationship behavior — named target preservation
B-04 Behavior "talks past {target} without making eye contact" industrial van-maanens-star Deceptive, Bold Neutral Negative relationship — social signal preservation
B-05 Behavior "sits alone in the break room rubbing the back of her neck, datapad face-down on the table" industrial van-maanens-star Stressed Long-form atmospheric behavior
T-01 Tell "shifts weight and checks the time without reason" any van-maanens-star Nervous Nervous fidget — phenomenon must survive
T-02 Tell "affects exaggerated calm" any van-maanens-star Deceptive Guarded Suppression — constrained re-voicing test
T-03 Tell "checks surroundings repeatedly" any van-maanens-star Cautious Nervous Surveillance — must not become avoidance
D-01 Dialogue "You need a keycard for that door." industrial van-maanens-star Bold Neutral Simple informational dialogue
D-02 Dialogue "I haven't seen Kael since second shift. Why?" industrial van-maanens-star Suspicious, Cautious Guarded Guarded Dialogue with active tell context — tell shapes tone
D-03 Dialogue "The cargo manifest doesn't match what's in bay seven." industrial van-maanens-star Honest, Curious Alert Information-bearing dialogue — must preserve factual content

11 payloads total. Each run through both models = 22 outputs per prompt template variant.

Prompt template variants to test

For each payload, test 2-3 prompt template variants to find the optimal instruction format:

Variant 1 — Instruction-first:

[System instruction]
[Culture injector with closed vocabulary]
[Personality + mood]
[Tell context if applicable]
Rephrase: "[base text]"

Variant 2 — Few-shot:

[System instruction]
[Culture injector]

Examples:
Base: "repairs equipment by hand" → Voiced: "strips the housing down and rebuilds it, no manual needed"
Base: "arranges goods on a portable display" → Voiced: "squares the goods on the fold-out, everything where it should be"

[Personality + mood]
[Tell context if applicable]
Rephrase: "[base text]"

Variant 3 — Negative-constraint-heavy (Miri's recommendation):

[System instruction]
[Culture injector]
DO NOT use: military ranks, sir/ma'am, religious references, quips, banter.
DO NOT reference: religion, sports, nationality, Earth-origin social structures.
Technology terms ONLY: insert, span gate, horizon gate, void, the Reach.
Exclamations ONLY: "void take it", "stars", "blood and void", "cold vacuum", "damn all".

[Personality + mood]
Rephrase: "[base text]"

Evaluation criteria

Each output is scored on 5 axes (1-5 scale, scored by Paula and Mellanie independently):

Criterion What it measures Pass threshold
Register accuracy Does it sound like Van Maanen's Star working-class? Not formal, not quippy, not military. >= 3
Oath preservation If an exclamation appears, is it from the canonical list? No franchise bleed? >= 4 (hard requirement)
Semantic preservation Does the output preserve the action/information from the base text? >= 4 (hard requirement)
Named target survival For B-03/B-04: does the {target} name survive in the output? Pass/Fail
Tell phenomenon class For T-01/T-02/T-03: does the tell still express the same TellCategory? Pass/Fail

Spike 1 success criteria:

  • At least one model achieves >= 3 average on register accuracy across all payloads
  • Both hard requirements (oath, semantic) pass on >= 9/11 payloads
  • Named target survival: pass on both relationship payloads
  • Tell phenomenon class: pass on all 3 tell payloads

If Gemma 2B meets these criteria, it's the selected model. If only Phi-3 meets them, we accept the RAM/size tradeoff and document why. If neither meets them, the spike has failed and we fall back to base-text-only (Proposal A without re-voicing, which is the current system with scaling managed by content authoring).

Benchmark protocol

On Spike 1 hardware (developer machine), record for each model:

  • Tokens/sec at Q4_K_M with --threads set to physical_cores - 1
  • RAM usage during inference (peak RSS)
  • Model load time from SSD

On a representative minimum-spec machine (if available — otherwise note the hardware used and extrapolate using Troblum's bandwidth formula):

  • Same metrics
  • Thermal behavior during 5-minute sustained inference run

3. Spike 2 Outline: Integration Architecture

Spike 2 wires the validated Spike 1 runner into the game. This outline is ticket-ready, not full implementation design.

3.1 Inference thread pool

┌──────────────────────────────────────────┐
│ Game Process                              │
│                                           │
│  ┌───────────────┐   ┌────────────────┐  │
│  │ Simulation    │   │ Voice Pipeline │  │
│  │ (server)      │   │ (separate pool)│  │
│  │               │   │                │  │
│  │ World gen ────┼──>│ Work queue     │  │
│  │ NPC spawn     │   │ InferenceEngine│  │
│  │ Tick loop     │   │ Voice cache    │  │
│  └───────────────┘   └────────────────┘  │
│                                           │
│  ┌───────────────┐                        │
│  │ Godot client  │ <── reads cache ──┘    │
│  └───────────────┘                        │
└──────────────────────────────────────────┘

Thread count: 1 dedicated inference thread. llama.cpp uses its own internal threading (set to physical_cores - world_gen_threads - 1). The inference thread owns the InferenceEngine instance — no model sharing across threads.

Priority: Below-normal OS thread priority. Inference yields to simulation and rendering.

3.2 Work queue

/// A single unit of work for the voice pipeline.
pub struct VoiceWorkItem {
    /// Cache key for storing the result.
    pub cache_key: VoiceCacheKey,
    /// Priority tier (lower number = higher priority).
    pub priority: VoicePriority,
    /// The fully constructed prompt string.
    pub prompt: String,
    /// Maximum output tokens.
    pub max_tokens: u32,
}

pub enum VoicePriority {
    /// P0: Plot-critical NPCs the player is about to interact with.
    Critical = 0,
    /// P1: NPCs in the current zone the player may interact with.
    High = 1,
    /// P2: NPCs in adjacent/anticipated zones.
    Standard = 2,
    /// P3: Ambient NPCs in distant zones (opportunistic).
    Background = 3,
}

Queue implementation: crossbeam-channel bounded channel (capacity: 256). Items sorted by priority. Producer: the world generation system, triggered by ZonePopulated event. Consumer: the inference thread.

Backpressure: If the queue is full, new items are dropped silently — the game continues with base text. No blocking the simulation thread.

Zone transition pause: When the simulation emits a ZoneTransitionStart event, the inference thread pauses (drains current item, then waits). Resumes on ZoneTransitionComplete. This prevents CPU contention during the loading spike.

3.3 Voice cache

pub struct VoiceCacheKey {
    pub world_seed: u64,
    pub culture_id: String,       // "van-maanens-star"
    pub npc_stable_id: StableId,
    pub content_type: ContentType, // Behavior | Dialogue
    pub content_index: u8,         // which behavior/dialogue line
}

pub struct VoiceCache {
    /// In-memory cache for current session.
    entries: BTreeMap<VoiceCacheKey, String>,
    /// Model identifier used to generate these entries.
    model_id: String,
}

Persistence: Written to user://voice_cache/{seed}.msgpack on zone transition or autosave. Loaded on game start if seed matches. Format version tag for migration.

Invalidation: Full cache invalidation on: seed change, model update (game patch). Per-NPC invalidation on: relationship change that affects behavior text.

Baked content: Hub zone voiced content ships as a game asset at res://voice_baked/{zone_id}.msgpack. Loaded into the cache on zone entry. Never regenerated at runtime.

3.4 Tell-as-context prompt construction

Per Jeroen's decision: tells are passthrough (never re-voiced), but they INFORM the re-voicing prompt for behaviors and dialogue.

/// Build the re-voicing prompt for an NPC's behavior or dialogue.
fn build_prompt(
    base_text: &str,
    content_type: ContentType,
    culture: &CultureProfile,
    npc: &NpcBlueprint,
    active_tell: Option<TellCategory>,  // from DerivedTellState
) -> String {
    let mut prompt = String::with_capacity(512);

    // System instruction
    prompt.push_str(SYSTEM_INSTRUCTION);

    // Culture injector (from NpcBlueprint.cultural_markers — per Miri's recommendation)
    prompt.push_str(&format_culture_injector(&npc.cultural_markers, culture));

    // Personality injector
    prompt.push_str(&format_personality(&npc.traits));

    // Tell-as-context: if the NPC has an active tell, inject it as mood/state context
    // The tell itself is NOT being re-voiced — it's informing the tone
    if let Some(tell) = active_tell {
        prompt.push_str(&format_tell_context(tell));
        // e.g., "The character is currently guarded and evasive.
        //        Their dialogue should reflect this state without stating it directly."
    }

    // Negative constraints (franchise bleed prevention)
    prompt.push_str(NEGATIVE_CONSTRAINTS);

    // Base text to re-voice
    match content_type {
        ContentType::Behavior => {
            prompt.push_str(&format!("\nRephrase this action: \"{}\"", base_text));
        }
        ContentType::Dialogue => {
            prompt.push_str(&format!("\nRephrase this dialogue line: \"{}\"", base_text));
        }
    }

    prompt
}

fn format_tell_context(tell: TellCategory) -> String {
    match tell {
        TellCategory::Nervous => {
            "\nState: The character is anxious. Their speech is clipped, distracted.\n".into()
        }
        TellCategory::Angry => {
            "\nState: The character is angry. Their speech is terse, barely controlled.\n".into()
        }
        TellCategory::Friendly => {
            "\nState: The character is warm and open. Their speech is relaxed.\n".into()
        }
        TellCategory::Guarded => {
            "\nState: The character is guarded. They deflect and keep things vague.\n".into()
        }
        TellCategory::RoutineDeviation => {
            "\nState: The character is preoccupied. Something else is on their mind.\n".into()
        }
    }
}

This is the critical design: the TellCategory enum flows into the prompt as a mood/state modifier, not as content to re-voice. The tell behavior string stays untouched. The dialogue and ambient behaviors around the tell are colored by the NPC's state.

3.5 Observer integration

The observer snapshot system already reads DerivedTellState and observable_behaviors. Integration point:

Observer reads NPC behavior string:
  1. Check voice cache for (seed, culture, npc_id, behavior_index)
  2. If cache hit → use voiced string
  3. If cache miss → use base text string (fallback)
  4. Tell state → always from DerivedTellState (passthrough, never from cache)

No changes to the wire format (ObserverSnapshot). The client doesn't know or care whether the behavior string was voiced or base text.

3.6 Baked content generation

A build-time step that runs the inference engine on all hub zone NPCs:

# Build tool (not the game binary)
sr-voice-bake \
  --model models/gemma-2b-q4.gguf \
  --zones content/global/van-maanens-star-*.ron \
  --culture content/global/culture-van-maanens-star.ron \
  --output client/assets/voice_baked/ \
  --seed 0  # baked content uses seed 0 as the canonical reference

Output: one .msgpack file per zone containing all voiced behavior and dialogue strings. Checked into the repository (text-only, compresses to ~20-50KB per zone). Human-reviewed by Paula/Mellanie before ship.


4. Hardware Detection Design

Layer 1: RAM check (can the model load?)

On first toggle of "AI-Enhanced Dialogue":

fn check_ram_available() -> RamCheckResult {
    let available_mb = get_available_system_ram_mb();
    let model_size_mb = 1600; // Gemma 2B Q4 + KV cache overhead

    if available_mb < model_size_mb {
        RamCheckResult::InsufficientRam {
            available_mb,
            required_mb: model_size_mb,
        }
    } else {
        RamCheckResult::Ok
    }
}

User-facing message if insufficient:

"AI-Enhanced Dialogue requires approximately 1.6 GB of free RAM. Your system currently has {available_mb} MB available. The feature may cause instability. Enable anyway?"

Player can always override. No hard block.

Layer 2: Time-per-token benchmark (is inference useful?)

If RAM check passes, run a 5-token benchmark on first enable:

fn benchmark_inference(engine: &mut InferenceEngine) -> BenchmarkResult {
    let test_prompt = "Rephrase: \"walks down the corridor.\"";
    let result = engine.generate(test_prompt, 5, 0.7, 0.9)?;
    let tpt_ms = result.generation_time_ms as f32 / result.tokens_generated as f32;

    BenchmarkResult {
        tokens_per_sec: result.tokens_per_sec,
        time_per_token_ms: tpt_ms,
    }
}

Thresholds:

Tokens/sec Recommendation User message
>= 5 t/s Full enable "AI-Enhanced Dialogue is active."
2-5 t/s Enable with warning "AI-Enhanced Dialogue is active. On your hardware, voiced content will generate slowly. Some NPCs may show plain text until generation catches up."
< 2 t/s Recommend disable "Your hardware generates voiced content very slowly. We recommend disabling AI-Enhanced Dialogue for the best experience. Enable anyway?"

No hard floor. Player can always choose to run it. The benchmark runs once, result is cached in user settings. Player can re-run from the settings menu.

Layer 3: Runtime monitoring

During gameplay, the inference thread monitors its own throughput:

// In the inference thread main loop:
if current_tokens_per_sec < 1.0 {
    // Sustained very-slow inference — likely thermal throttle or power saver
    pause_inference();
    notify_ui("AI dialogue generation paused — system is running slowly.");
    // Resume after 60 seconds or on user action
}

Battery/power-saver detection: On Windows, check GetSystemPowerStatus(). If on battery with power saver active, auto-pause inference and show notification. On Linux/macOS, check /sys/class/power_supply/ or equivalent. Resume when plugged in or power mode changes.

Settings UI

[Settings > Audio & Dialogue]

AI-Enhanced Dialogue: [ON / OFF]
  Status: Active (8.2 tokens/sec)

  [Re-run benchmark]

  Note: When enabled, NPC dialogue and behaviors are enhanced with
  culture-specific voice. This uses additional CPU resources.
  Disable if you experience performance issues.

Effort Estimates

Work item Sprints Dependencies
Spike 1: sr-voice CLI tool 1 None — can start immediately
Spike 1: Prompt crafting + model testing 1 sr-voice CLI (Mellanie/Paula/Jeroen run the tests)
Spike 2: Queue + cache + thread pool 1.5 Spike 1 model selection
Spike 2: Tell-as-context prompt construction 0.5 Queue infrastructure
Spike 2: Observer integration 0.5 Cache system
Spike 2: Baked content generation tool 0.5 Queue + cache
Hardware detection system 0.5 InferenceEngine (from Spike 1)
Total 5.5 Spike 1 and 2 are sequential; sub-items within each spike are partially parallelizable

Spike 1 can start next sprint. The CLI tool is self-contained Rust with no game dependencies. While the content team runs manual prompt tests, Spike 2 infrastructure design can begin in parallel.