From db9a59b85488bd7e73f781229c55900894f56815 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Thu, 13 Aug 2026 18:47:00 -0400 Subject: [PATCH 1/5] Add aliveness workplan ledger for the August 2026 audit follow-up Tracks the audit-identified aliveness defects across firmware, bridge, and documentation with evidence-disciplined statuses: done-source never implies physical qualification. Co-Authored-By: Claude Fable 5 --- ALIVENESS_WORKPLAN.md | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 ALIVENESS_WORKPLAN.md diff --git a/ALIVENESS_WORKPLAN.md b/ALIVENESS_WORKPLAN.md new file mode 100644 index 0000000..cf002d0 --- /dev/null +++ b/ALIVENESS_WORKPLAN.md @@ -0,0 +1,69 @@ +# Aliveness Workplan + +Status: living work ledger for the August 2026 aliveness audit +Baseline audited: `99ff6010` (main, 2026-08-13) +Working branch: `agent/aliveness-tier1-tier2` + +This document tracks the defects and improvements identified by the 2026-08-13 full-repo +aliveness audit, their fix status, and their evidence status. It follows the repository +evidence discipline: **"Done in source" never implies physical qualification.** Any row that +changes firmware behavior remains physically unqualified until the exact flashed SHA-256 +passes its own gates. + +Status legend: + +- `todo` — not started. +- `in-progress` — being edited on the working branch. +- `done-source` — implemented and passing native/host tests; not physically qualified. +- `qualified` — exact-image physical evidence recorded (owner-run; out of scope for this branch). +- `blocked` — cannot proceed; blocker named in Notes. + +## Tier 1 — Firmware aliveness fixes (source-only, native-testable) + +| ID | Task | Status | Notes | +| --- | --- | --- | --- | +| AL-01 | Fix dropped persona face channels: `FaceAnimator::samplePose` must compose `faceX`/`faceY`, mouth width/corner deltas, lid tilts, and eye corners from the incoming `RobotFrame` instead of discarding them (`src/face/FaceAnimator.cpp:251-263`, `src/face/ProceduralFace.cpp:45`). Unify on one `BreathRhythm` so face and body share one breath. Add a composed-path native test. | done-source | Restores IdleLife breath translation, yawn faceY, GazeTracker face shifts, and sound-orientation faceX to the rendered face. | +| AL-02 | Remove `ActuationEngine` idle sine sway (`src/motion/ActuationEngine.cpp:201-205`) which re-adds the metronomic motion `IntentEngine` explicitly removed; jitter the fixed 14.3 s `IdleLife` gaze-drift sine (`src/persona/IdleLife.cpp:51-56`). | done-source | HeadGaze look-and-hold remains the only idle head-motion source. | +| AL-03 | Seed `FaceAnimator::rng_` (fixed `0x51A7C0DE`) and the `IdleLife`/`BreathRhythm`/`HeadGaze` hash streams from hardware entropy at boot so each power-on plays a different idle sequence. Keep deterministic seeding available for native tests. | done-source | Entropy injected at construction/begin; native tests pass fixed seeds. | +| AL-04 | Persist `EmotionModel` long-timescale state (baseline temperament, habituation familiarity) to NVS with a bounded slow write cadence; restore on boot. Fix the unreachable natural-wake condition (`quietSeconds_` pins `sleepPressure()` at 1.0 while asleep, so `fatigue < 0.35` can never occur). | done-source | Temperament now survives power cycles; sleep can end naturally. | +| AL-05 | Make `applyCircadian`/`applyAmbient` dt-scaled and idempotent per phase change instead of unbounded impulses per received message (`src/persona/EmotionModel.cpp:250,280`); remove the double-application path in `main.cpp:9527-9532`. | done-source | Prerequisite for any real clock/ALS circadian source. | + +## Tier 2 — Bridge character fixes (host-side) + +| ID | Task | Status | Notes | +| --- | --- | --- | --- | +| AL-06 | Remove the `[0,1]` valence clamp on the firmware response-start frame (`bridge/lan_service.py:2932`) so face valence matches TTS valence (`[-1,1]`). Completes the open half of AFFECT-001. | done-source | Concerned voice no longer paired with neutral face. | +| AL-07 | Make `pip` and `bolt` real characters: distinct traits/prompt rules, remove the inherited "answer only: I am Stackchan Spark" line (`personas/pip/character.yaml:68`, `personas/bolt/character.yaml:68`). Harden `scaffold_persona_pack` to refuse emitting prompt rules that name the source persona. | done-source | Also verifies Glow retains the Spark safety rules it silently dropped. | +| AL-08 | Move the hardcoded Spark-only conversation style (`SPARK_CONVERSATION_STYLE`, `bridge/character_harness.py:820-821`) into per-pack YAML so every persona gets a style palette; stop loading `DEFAULT_PERSONA` at module import. | done-source | Personas other than Spark are no longer structurally blander. | +| AL-09 | Give failures a voice: model/TTS failure paths speak one short in-character line instead of returning silence (`bridge/lan_service.py:3543`, `3726-3731`, `3905-3906`). TTS-misconfigured no longer reports a fully successful silent turn. | done-source | Failure lines routed through the persona spoken-line table. | +| AL-10 | Persist bounded affect across sessions in `BridgeMemory` (schema v5): mood baseline and rapport with caps/TTL, per `MEMORY_CONTRACT.md` repair rules. Sessions no longer start at an identical emotional baseline. | done-source | Bounded, inspectable, delete-on-forget like all v4 state. | +| AL-11 | Initiative fixes: pass the relationship card and recent-context lines into `run_initiative` (`bridge/lan_service.py:2759-2760`) and open a bounded reply window (`conversation.wake()`) after a proactive line so the user can answer without re-waking. | done-source | A robot that speaks first can now hear the answer. | +| AL-12 | Vary character beats: replace the deterministic `sha256 % 16` quip selection (`bridge/ollama_stackchan_runner.py:566-627`) with seeded rotation persisted in memory so repeated questions do not replay identical jokes across sessions. | done-source | Anti-repetition now survives session close. | + +## Tier 3 — Documentation truth sync + +| ID | Task | Status | Notes | +| --- | --- | --- | --- | +| AL-13 | Update stale P0 rows for fixes already merged on main: demo-mode default (PR #230), capture lease 13.5 s (PR #226), dashboard stale readiness (PR #222) in `EXPERIENCE_SCORECARD.md`, `TASK_LEDGER.md`, `CURRENT_CAPABILITY_AUDIT.md`, `CONTINUITY_GAP_ANALYSIS.md`, `PROJECT_STATE.md`, `docs/BRIDGE_AI_HANDOFF.md`, `docs/CONVERSATION_V2_ROADMAP.md`. | done-source | Genuinely-open P0s (valence clamp, F3 vision, F1 error paths) now stand out. | +| AL-14 | Record this workplan's changes in the ledgers without claiming physical evidence. | done-source | This file is the tracking source of truth for the audit follow-up. | + +## Explicitly out of scope for this branch + +- Flashing any image, OTA, soak, or physical qualification (owner-run; see + `docs/ARRIVAL_DAY_RUNBOOK.md`). All firmware rows above stop at `done-source`. +- F3 vision bring-up (requires flashing `stackchan_release_forensics_vision`). +- F1 error-path physical qualification (`host-response-wire-clean` report). +- Conversation v2 promotion, PERCEPT/IDENT/MOTION preregistered tasks. +- The Continuity Core typed journal (Milestone 2) — design docs remain normative-only. + +## Audit findings intentionally left open (tracked, not fixed here) + +| Finding | Why deferred | +| --- | --- | +| `lan_service.py` god module / ~800-line `_run_utterance_end` | Structural refactor; high regression risk near unqualified Conversation v2 paths. | +| Pure-Python reply-PCM speech gate cost | Optimization; needs latency evidence before/after. | +| Per-turn persona pack reload from disk | Optimization; cache invalidation policy needs design. | +| Two parallel TTS paths in `lan_service.py` | Consolidation belongs with the F1 error-path work. | +| Frozen-dataclass mutation via `object.__setattr__` in `bridge_memory.py` | Belongs in the Memory v5 schema follow-up after AL-10 lands. | +| Servo 2 Hz release resampling and duty freeze | Power/actuator policy decision; owner call, needs hardware evidence. | +| Boredom/curiosity drive state | New behavior, requires preregistration per `TASK_LEDGER.md` discipline. | From 91f04b425b558ffd4fcf9c2d8f0c3dbaf526551a Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Thu, 13 Aug 2026 19:01:18 -0400 Subject: [PATCH 2/5] Restore the persona face channels and unify aliveness generators (AL-01..AL-05) The face animator rebuilt its pose from scratch and dropped the persona layer's faceX/faceY, mouth deltas, lid tilts, eye corners, and pupil scale - so breath translation, yawns, gaze lead, and sound orientation were computed every frame and never rendered. Those channels now pass through, and the animator's private second BreathRhythm is gone: IdleLife owns the one shared breath for face and body. Also: remove the ActuationEngine idle sine sway that reintroduced the metronomic motion HeadGaze replaced; give the idle gaze drift a re-jittered period instead of a fixed 14.3 s sine; seed the blink/ saccade/fidget/breath/head-gaze streams from hardware entropy at boot (deterministic defaults preserved for native tests); persist emotion baseline and habituation to NVS with a 10-minute cadence; make circadian/ambient context a dt-scaled stored target instead of an unbounded per-message impulse; and let sleep pay back sleep pressure so natural waking is reachable. Delete the dead duplicate Blink/Saccade generators. Native suite: 322/322. Co-Authored-By: Claude Fable 5 --- bridge/lan_service.py | 6 +- platformio.ini | 1 - src/face/FaceAnimator.cpp | 42 ++++-- src/face/FaceAnimator.hpp | 7 +- src/face/ProceduralFace.hpp | 5 + src/main.cpp | 51 ++++++- src/motion/ActuationEngine.cpp | 13 +- src/motion/ActuationEngine.hpp | 4 - src/motion/Blink.hpp | 89 ----------- src/motion/Saccade.hpp | 29 ---- src/persona/BreathRhythm.cpp | 6 +- src/persona/BreathRhythm.hpp | 7 + src/persona/EmotionModel.cpp | 155 +++++++++++++------- src/persona/EmotionModel.hpp | 27 +++- src/persona/HeadGaze.cpp | 6 +- src/persona/HeadGaze.hpp | 7 + src/persona/IdleLife.cpp | 39 ++++- src/persona/IdleLife.hpp | 16 ++ src/persona/IntentEngine.cpp | 9 +- src/persona/IntentEngine.hpp | 13 ++ test/test_native_logic/test_main.cpp | 212 +++++++++++++++++++++++++-- 21 files changed, 513 insertions(+), 231 deletions(-) delete mode 100644 src/motion/Blink.hpp delete mode 100644 src/motion/Saccade.hpp diff --git a/bridge/lan_service.py b/bridge/lan_service.py index f643ba5..9e27e54 100644 --- a/bridge/lan_service.py +++ b/bridge/lan_service.py @@ -2929,7 +2929,11 @@ def emit(frame: dict[str, object] | bytes) -> float | None: "seq": turn.seq, "intent": turn.intent, "arousal": round(max(0.0, min(1.0, turn.arousal)), 2), - "valence": round(max(0.0, min(1.0, turn.valence)), 2), + # Valence is signed end to end: firmware constrains it to + # [-1, 1], and TTS styling already uses the signed value. A + # [0, 1] clamp here silently zeroed every concerned face while + # the voice stayed concerned. + "valence": round(max(-1.0, min(1.0, turn.valence)), 2), "gesture": getattr(turn, "gesture", "none"), "text": turn.text, "tts_streaming": True, diff --git a/platformio.ini b/platformio.ini index 94734d8..6f38da3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -441,7 +441,6 @@ build_flags = -D STACKCHAN_VOICE_CHANNEL_VOLUME=190 -D STACKCHAN_MOTION_TASK_PRIORITY=2 -D STACKCHAN_SERVO_OUTPUT_PERIOD_MS=500 - -D STACKCHAN_SERVO_IDLE_SCALE=0.15f -D STACKCHAN_MOTION_DUTY_ACTIVE_MS=120000 -D STACKCHAN_MOTION_DUTY_REST_MS=30000 -D STACKCHAN_SERVO_RELEASE_ON_STOP=1 diff --git a/src/face/FaceAnimator.cpp b/src/face/FaceAnimator.cpp index e76bed1..55d8e9b 100644 --- a/src/face/FaceAnimator.cpp +++ b/src/face/FaceAnimator.cpp @@ -75,7 +75,6 @@ void FaceAnimator::reset(const FaceTargets& face, uint32_t nowMs) { hasPreviousMode_ = false; blink_ = BlinkState {}; saccade_ = SaccadeState {}; - breath_.reset(nowMs); fidget_ = FidgetState {}; gesture_ = GestureState {}; speech_ = SpeechState {}; @@ -88,6 +87,11 @@ void FaceAnimator::setReducedMotion(bool enabled) { reducedMotion_ = enabled; } +void FaceAnimator::seedRandom(uint32_t seed) { + // The xorshift stream must never be zero. + rng_ = seed != 0 ? seed : 0x51A7C0DEu; +} + void FaceAnimator::setSpeechEnvelope(float envelope, SpeechViseme viseme, uint32_t nowMs) { const float clamped = clamp01(envelope); if (!speech_.active) { @@ -255,17 +259,34 @@ FaceTargets FaceAnimator::samplePose(const RobotFrame& frame, uint32_t nowMs) co pose.eyeSmile = clampValue(pose.eyeSmile + mod.eyeSmile * 0.20f, 0.0f, 1.0f); pose.pupilX = clampValue(pose.pupilX + mod.pupilX * 0.25f, -1.0f, 1.0f); pose.pupilY = clampValue(pose.pupilY + mod.pupilY * 0.35f, -1.0f, 1.0f); - pose.pupilScale = clampValue(pose.pupilScale * (0.85f + frame.emotion.arousal * 0.30f), 0.70f, 1.25f); + pose.pupilScale = clampValue(pose.pupilScale * mod.pupilScale * (0.85f + frame.emotion.arousal * 0.30f), 0.70f, 1.25f); pose.browTilt = clampValue(pose.browTilt + mod.browTilt * 0.20f, -1.0f, 1.0f); pose.mouthSmile = clampValue(pose.mouthSmile + mod.mouthSmile * 0.18f, -1.0f, 1.0f); pose.mouthOpen = clampValue(max(pose.mouthOpen, mod.mouthOpen), 0.0f, 1.0f); pose.mouthWidthDelta += pose.mouthSmile * 12.0f; + // The persona layer's positional and shape channels pass through at full + // strength: breath translation, yawns, gaze lead, and sound orientation are + // produced only there and have no counterpart in the mode poses above. + pose.faceX += mod.faceX; + pose.faceY += mod.faceY; + pose.mouthWidthDelta += mod.mouthWidthDelta; + pose.mouthCornerL += mod.mouthCornerL; + pose.mouthCornerR += mod.mouthCornerR; + pose.upperLidTilt = clampValue(pose.upperLidTilt + mod.upperLidTilt, -1.0f, 1.0f); + pose.lowerLidTilt = clampValue(pose.lowerLidTilt + mod.lowerLidTilt, -1.0f, 1.0f); + pose.leftCorners.tl = clampValue(pose.leftCorners.tl + mod.leftCorners.tl, 0.0f, 1.0f); + pose.leftCorners.tr = clampValue(pose.leftCorners.tr + mod.leftCorners.tr, 0.0f, 1.0f); + pose.leftCorners.bl = clampValue(pose.leftCorners.bl + mod.leftCorners.bl, 0.0f, 1.0f); + pose.leftCorners.br = clampValue(pose.leftCorners.br + mod.leftCorners.br, 0.0f, 1.0f); + pose.rightCorners.tl = clampValue(pose.rightCorners.tl + mod.rightCorners.tl, 0.0f, 1.0f); + pose.rightCorners.tr = clampValue(pose.rightCorners.tr + mod.rightCorners.tr, 0.0f, 1.0f); + pose.rightCorners.bl = clampValue(pose.rightCorners.bl + mod.rightCorners.bl, 0.0f, 1.0f); + pose.rightCorners.br = clampValue(pose.rightCorners.br + mod.rightCorners.br, 0.0f, 1.0f); return pose; } void FaceAnimator::applyAutonomic(FaceTargets& face, const RobotFrame& frame, uint32_t nowMs) { const float motionScale = reducedMotion_ ? generated_persona::kReducedMotionScale : 1.0f; - const bool sleeping = frame.mode == CharacterMode::Sleep; const float blinkOpen = updateBlink(frame, nowMs); const float blinkCompression = 1.0f - clampValue(blinkOpen, 0.0f, 1.0f); @@ -276,23 +297,18 @@ void FaceAnimator::applyAutonomic(FaceTargets& face, const RobotFrame& frame, ui face.pupilX = clampValue(face.pupilX + saccade_.offsetX * motionScale, -1.0f, 1.0f); face.pupilY = clampValue(face.pupilY + saccade_.offsetY * motionScale, -1.0f, 1.0f); - const float breathHz = sleeping ? clampValue(generated_persona::kIdleBreathingHz * 0.60f, 0.10f, 0.20f) - : generated_persona::kIdleBreathingHz; - const float breathAmp = (sleeping ? generated_persona::kIdleBreathingPx * 2.0f - : generated_persona::kIdleBreathingPx) * - motionScale; - // Same rhythm generator the persona idle layer uses, so face and body share - // one breath instead of two sines drifting against each other. - const float breathY = breath_.update(nowMs, breathHz, sleeping) * breathAmp; + // Breath comes from the persona idle layer through frame.face.faceY, so the + // face and the body pitch bob share IdleLife's one BreathRhythm instance + // instead of two generators drifting against each other. const float stageX = saccade_.offsetX * 4.0f * motionScale; const float stageY = saccade_.offsetY * 3.0f * motionScale; face.faceX += stageX; - face.faceY += breathY + stageY; + face.faceY += stageY; updateFidget(face, frame, nowMs, motionScale); telemetry_.blinkOpen = blinkOpen; - telemetry_.breathY = breathY; + telemetry_.breathY = frame.face.faceY; telemetry_.gazeX = saccade_.offsetX; telemetry_.gazeY = saccade_.offsetY; telemetry_.blinkCount = blink_.count; diff --git a/src/face/FaceAnimator.hpp b/src/face/FaceAnimator.hpp index 4ea8562..d416461 100644 --- a/src/face/FaceAnimator.hpp +++ b/src/face/FaceAnimator.hpp @@ -2,7 +2,6 @@ #include -#include "persona/BreathRhythm.hpp" #include "persona/StateMatrix.hpp" namespace stackchan { @@ -26,6 +25,9 @@ enum class SpeechViseme : uint8_t { struct FaceAutonomicTelemetry { float blinkOpen = 1.0f; + // Vertical life offset supplied by the persona layer (breath, yawn, gaze), + // as passed through to the rendered face. The animator no longer runs its + // own breath generator; IdleLife owns the one shared rhythm. float breathY = 0.0f; float gazeX = 0.0f; float gazeY = 0.0f; @@ -52,6 +54,8 @@ class FaceAnimator { public: FaceTargets composeFrame(const RobotFrame& frame, uint32_t nowMs); void reset(const FaceTargets& face, uint32_t nowMs); + // Hardware entropy at boot; zero keeps the deterministic default for tests. + void seedRandom(uint32_t seed); void setReducedMotion(bool enabled); void setSpeechEnvelope(float envelope, SpeechViseme viseme, uint32_t nowMs); void clearSpeechEnvelope(uint32_t nowMs); @@ -134,7 +138,6 @@ class FaceAnimator { FaceTargets current_; BlinkState blink_; SaccadeState saccade_; - BreathRhythm breath_; FidgetState fidget_; GestureState gesture_; SpeechState speech_; diff --git a/src/face/ProceduralFace.hpp b/src/face/ProceduralFace.hpp index b29886c..20415bc 100644 --- a/src/face/ProceduralFace.hpp +++ b/src/face/ProceduralFace.hpp @@ -26,6 +26,11 @@ class ProceduralFace { public: void begin(IDisplay* display); void begin(IDisplay* display, const FaceConfig& config); + // Hardware entropy at boot so blinks, saccades, and fidgets differ per + // power-on; never called by native tests. + void seedEntropy(uint32_t seed) { + animator_.seedRandom(seed); + } void setReducedMotion(bool enabled); void setSpeechEnvelope(float envelope, SpeechViseme viseme, uint32_t nowMs); void clearSpeechEnvelope(uint32_t nowMs); diff --git a/src/main.cpp b/src/main.cpp index 05f5464..0a2a9b6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -829,6 +829,43 @@ MotionAudioPreemptionGate gMotionAudioPreemptionGate; ActuationEngine gActuation(gConfig); ProceduralFace gFace; IntentEngine gIntent; + +#if defined(ARDUINO_ARCH_ESP32) +// Temperament and habituation survive power cycles in one small NVS blob. +// Saved on a slow cadence: baseline drift moves over hours, so a 10-minute +// save loses almost nothing and keeps NVS wear negligible. +constexpr uint32_t kCharacterStateSavePeriodMs = 600000; +uint32_t gLastCharacterStateSaveMs = 0; + +void restorePersistedCharacterState() { + Preferences prefs; + if (!prefs.begin("charstate", true)) { + return; + } + EmotionPersistentState state; + const size_t bytes = prefs.getBytes("emo1", &state, sizeof(state)); + prefs.end(); + if (bytes == sizeof(state)) { + gIntent.restoreCharacterState(state); + Serial.println(F("[persona] character_state_restored=1")); + } +} + +void persistCharacterStateIfDue(uint32_t nowMs) { + if (gLastCharacterStateSaveMs != 0 && nowMs - gLastCharacterStateSaveMs < kCharacterStateSavePeriodMs) { + return; + } + gLastCharacterStateSaveMs = nowMs; + Preferences prefs; + if (!prefs.begin("charstate", false)) { + return; + } + const EmotionPersistentState state = gIntent.characterState(); + prefs.putBytes("emo1", &state, sizeof(state)); + prefs.end(); +} +#endif + TaskHandle_t gMotionTaskHandle = nullptr; TaskHandle_t gFaceTaskHandle = nullptr; TaskHandle_t gIntentTaskHandle = nullptr; @@ -9526,8 +9563,10 @@ void IntentTask(void* pv) { } if (control.hasAmbient) { gIntent.applyAmbient(control.ambient.lux, control.ambient.hourOfDay); - } - if (control.hasCircadian) { + } else if (control.hasCircadian) { + // applyAmbient already stores the hour; applying both in one control + // message would be redundant, not harmful, since context is now a + // stored target rather than an impulse. gIntent.applyCircadian(control.hourOfDay); } if (control.hasSpeechCue) { @@ -9637,6 +9676,9 @@ void IntentTask(void* pv) { gActuation.isEnabled() && !gActuation.outputSuppressed() && intentServoPower.railEnabled, millis()); RobotFrame frame = gIntent.update(millis()); +#if defined(ARDUINO_ARCH_ESP32) + persistCharacterStateIfDue(frame.timestampMs); +#endif gCamera.setRobotSpeaking(frame.mode == CharacterMode::Speak, frame.timestampMs); const FaceSpeechTelemetry& faceSpeech = gFace.speechTelemetry(); const BodyRgbFrame bodyRgb = gBodyFeedback.render( @@ -9747,6 +9789,11 @@ void setup() { gActuation.begin(&gServo); gFace.begin(&gDisplay, gConfig.face); gIntent.begin(); +#if defined(ARDUINO_ARCH_ESP32) + gIntent.seedEntropy(esp_random()); + gFace.seedEntropy(esp_random()); + restorePersistedCharacterState(); +#endif #if defined(ARDUINO_ARCH_ESP32) LanOtaConfig otaConfig; otaConfig.tokenSha256 = STACKCHAN_OTA_TOKEN_SHA256; diff --git a/src/motion/ActuationEngine.cpp b/src/motion/ActuationEngine.cpp index 71a642e..ecf6882 100644 --- a/src/motion/ActuationEngine.cpp +++ b/src/motion/ActuationEngine.cpp @@ -195,14 +195,11 @@ void ActuationEngine::update(const RobotFrame& target, uint32_t nowUs) { lastUs_ = nowUs; dt = constrain(dt, 0.001f, 0.040f); - float pitchTarget = target.motion.pitchDeg; - float yawTarget = target.motion.yawDeg; - - const float t = nowMs * 0.001f; - const float idleAmp = ((1.0f - target.emotion.focus) * 3.5f + target.emotion.arousal * 1.0f) * - STACKCHAN_SERVO_IDLE_SCALE; - pitchTarget += sinf(t * 1.7f) * idleAmp * 0.20f; - yawTarget += sinf(t * 1.1f) * idleAmp; + // Idle head life comes entirely from HeadGaze in the intent layer. The sine + // sway that used to be re-added here undid that: it swayed continuously at + // two fixed frequencies without ever looking at anything. + const float pitchTarget = target.motion.pitchDeg; + const float yawTarget = target.motion.yawDeg; const float pitchCmd = clampPitch(pitch_.step(pitchTarget, dt), config_.servos); if (lastActuatorWriteMs_ != 0 && nowMs - lastActuatorWriteMs_ < STACKCHAN_SERVO_OUTPUT_PERIOD_MS) { diff --git a/src/motion/ActuationEngine.hpp b/src/motion/ActuationEngine.hpp index 87a29dc..25a6b05 100644 --- a/src/motion/ActuationEngine.hpp +++ b/src/motion/ActuationEngine.hpp @@ -16,10 +16,6 @@ #define STACKCHAN_SERVO_SELF_MOTION_SETTLE_MS 1000 #endif -#ifndef STACKCHAN_SERVO_IDLE_SCALE -#define STACKCHAN_SERVO_IDLE_SCALE 1.0f -#endif - #ifndef STACKCHAN_MOTION_DUTY_ACTIVE_MS #define STACKCHAN_MOTION_DUTY_ACTIVE_MS 0 #endif diff --git a/src/motion/Blink.hpp b/src/motion/Blink.hpp deleted file mode 100644 index ad9320f..0000000 --- a/src/motion/Blink.hpp +++ /dev/null @@ -1,89 +0,0 @@ -#pragma once - -#include - -#include "persona/StateMatrix.hpp" - -namespace stackchan { - -class BlinkGenerator { - public: - float update(uint32_t nowMs, const EmotionalProfile& emotion) { - if (nextBlinkMs_ == 0) { - scheduleNext(nowMs, emotion); - } - - switch (phase_) { - case Phase::Open: - if (nowMs >= nextBlinkMs_) { - phase_ = Phase::Closing; - phaseStartMs_ = nowMs; - phaseDurationMs_ = emotion.fatigue > 0.6f ? 80 : 45; - } - return 1.0f; - case Phase::Closing: - if (elapsed(nowMs) >= phaseDurationMs_) { - phase_ = Phase::Closed; - phaseStartMs_ = nowMs; - phaseDurationMs_ = 30; - return 0.0f; - } - return 1.0f - progress(nowMs); - case Phase::Closed: - if (elapsed(nowMs) >= phaseDurationMs_) { - phase_ = Phase::Opening; - phaseStartMs_ = nowMs; - phaseDurationMs_ = emotion.fatigue > 0.6f ? 120 : 70; - } - return 0.0f; - case Phase::Opening: - if (elapsed(nowMs) >= phaseDurationMs_) { - phase_ = Phase::Open; - scheduleNext(nowMs, emotion); - return 1.0f; - } - return progress(nowMs); - } - - return 1.0f; - } - - private: - enum class Phase : uint8_t { - Open, - Closing, - Closed, - Opening, - }; - - Phase phase_ = Phase::Open; - uint32_t phaseStartMs_ = 0; - uint32_t phaseDurationMs_ = 1; - uint32_t nextBlinkMs_ = 0; - - uint32_t elapsed(uint32_t nowMs) const { - return nowMs - phaseStartMs_; - } - - float progress(uint32_t nowMs) const { - return constrain(static_cast(elapsed(nowMs)) / phaseDurationMs_, 0.0f, 1.0f); - } - - void scheduleNext(uint32_t nowMs, const EmotionalProfile& emotion) { - uint32_t minDelay = 2000; - uint32_t maxDelay = 6000; - - if (emotion.arousal > 0.70f) { - minDelay = 1000; - maxDelay = 3000; - } - if (emotion.fatigue > 0.50f) { - minDelay = 800; - maxDelay = 2200; - } - - nextBlinkMs_ = nowMs + random(minDelay, maxDelay); - } -}; - -} // namespace stackchan diff --git a/src/motion/Saccade.hpp b/src/motion/Saccade.hpp deleted file mode 100644 index 15c7f3a..0000000 --- a/src/motion/Saccade.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#include "persona/StateMatrix.hpp" - -namespace stackchan { - -struct SaccadeGenerator { - float offsetX = 0.0f; - float offsetY = 0.0f; - uint32_t nextMs = 0; - - void update(uint32_t nowMs, const EmotionalProfile& emotion) { - if (nowMs < nextMs) { - return; - } - - const float wander = (1.0f - emotion.focus) * 0.55f + emotion.arousal * 0.15f; - offsetX = random(-100, 101) * 0.01f * wander; - offsetY = random(-100, 101) * 0.01f * wander * 0.55f; - - const uint32_t minHold = emotion.arousal > 0.70f ? 120 : 280; - const uint32_t maxHold = emotion.focus > 0.70f ? 1200 : 650; - nextMs = nowMs + random(minHold, maxHold); - } -}; - -} // namespace stackchan diff --git a/src/persona/BreathRhythm.cpp b/src/persona/BreathRhythm.cpp index 63c4fc5..b4eeaae 100644 --- a/src/persona/BreathRhythm.cpp +++ b/src/persona/BreathRhythm.cpp @@ -26,7 +26,7 @@ void BreathRhythm::reset(uint32_t nowMs) { periodMs_ = 0; depth_ = 1.0f; cycle_ = 0; - cyclesUntilSigh_ = kSighMinCycles + (hash32(nowMs + 0x2545f491UL) % kSighCycleSpan); + cyclesUntilSigh_ = kSighMinCycles + (hash32((nowMs ^ seed_) + 0x2545f491UL) % kSighCycleSpan); lastMs_ = nowMs; hasLast_ = false; sighing_ = false; @@ -61,7 +61,7 @@ void BreathRhythm::startCycle(float breathHz, bool sleeping) { return; } - const uint32_t h = hash32(cycle_ * 0x9e3779b9UL + 0x85ebca6bUL); + const uint32_t h = hash32((cycle_ ^ seed_) * 0x9e3779b9UL + 0x85ebca6bUL); const float jitter = (static_cast((h >> 8) & 0xFFFFu) / 32767.5f) - 1.0f; const float depthJitter = (static_cast(h & 0xFFFFu) / 32767.5f) - 1.0f; @@ -72,7 +72,7 @@ void BreathRhythm::startCycle(float breathHz, bool sleeping) { if (sigh) { depth = kSighDepth; period *= 1.35f; - cyclesUntilSigh_ = kSighMinCycles + (hash32(cycle_ + 0x27d4eb2fUL) % kSighCycleSpan); + cyclesUntilSigh_ = kSighMinCycles + (hash32((cycle_ ^ seed_) + 0x27d4eb2fUL) % kSighCycleSpan); } else if (cyclesUntilSigh_ > 0) { --cyclesUntilSigh_; } diff --git a/src/persona/BreathRhythm.hpp b/src/persona/BreathRhythm.hpp index 2083128..2ad9e36 100644 --- a/src/persona/BreathRhythm.hpp +++ b/src/persona/BreathRhythm.hpp @@ -15,6 +15,12 @@ class BreathRhythm { public: void reset(uint32_t nowMs); + // Hardware entropy at boot so each power-on breathes its own sequence; zero + // (the default) keeps the deterministic stream native tests rely on. + void seedEntropy(uint32_t seed) { + seed_ = seed; + } + // Advances the rhythm and returns displacement in roughly [-1, 1]; a sigh // overshoots that range by its depth. float update(uint32_t nowMs, float breathHz, bool sleeping); @@ -33,6 +39,7 @@ class BreathRhythm { private: float phase_ = 0.0f; + uint32_t seed_ = 0; uint32_t periodMs_ = 0; float depth_ = 1.0f; uint32_t cycle_ = 0; diff --git a/src/persona/EmotionModel.cpp b/src/persona/EmotionModel.cpp index 3a39e7c..6c245c2 100644 --- a/src/persona/EmotionModel.cpp +++ b/src/persona/EmotionModel.cpp @@ -35,6 +35,9 @@ constexpr float kBaselineFocusBand = 0.15f; // climbs toward it, so the visible order is droopy eyes, then yawns, then sleep. constexpr float kSleepPressureOnsetSeconds = 180.0f; constexpr float kSleepPressureRampSeconds = 420.0f; +// Each second asleep pays back this many seconds of accrued quiet time, so a +// full night of pressure clears in roughly a minute and a half of sleep. +constexpr float kRestRecoveryRate = 8.0f; } bool EmotionModel::isRousing(EventType type) { @@ -80,6 +83,35 @@ void EmotionModel::reset() { } habituation_ = HabituationTelemetry {}; quietSeconds_ = 0.0f; + hasCircadianContext_ = false; + hasAmbientLux_ = false; + contextHour_ = 12; + contextLux_ = 0.0f; +} + +EmotionPersistentState EmotionModel::persistentState() const { + EmotionPersistentState state; + state.baseline = baseline_; + for (uint8_t i = 0; i < kHabituatedEventTypes; ++i) { + state.familiarity[i] = familiarity_[i]; + } + return state; +} + +void EmotionModel::restorePersistentState(const EmotionPersistentState& state) { + // A corrupt or stale blob may carry anything; clamp back inside the same + // bands baseline drift is allowed, so a restore can never exceed what lived + // experience could have produced. + baseline_.arousal = constrain(state.baseline.arousal, 0.20f - kBaselineArousalBand, + 0.20f + kBaselineArousalBand); + baseline_.valence = constrain(state.baseline.valence, 0.35f - kBaselineValenceBand, + 0.35f + kBaselineValenceBand); + baseline_.focus = constrain(state.baseline.focus, 0.55f - kBaselineFocusBand, + 0.55f + kBaselineFocusBand); + baseline_.fatigue = clamp01(state.baseline.fatigue); + for (uint8_t i = 0; i < kHabituatedEventTypes; ++i) { + familiarity_[i] = clamp01(state.familiarity[i]); + } } uint8_t EmotionModel::habituationIndex(EventType type) { @@ -243,65 +275,63 @@ void EmotionModel::applyEvent(const RobotEvent& event) { } void EmotionModel::applyCircadian(uint8_t hourOfDay) { - const uint8_t safeHour = hourOfDay > 23 ? 23 : hourOfDay; - - if (safeHour >= generated_persona::kNightStartHour || safeHour < generated_persona::kMorningStartHour) { - emotion_.fatigue += 0.09f; - emotion_.arousal -= 0.04f; - emotion_.focus -= 0.02f; - } else if (safeHour >= generated_persona::kEveningStartHour) { - // Evening drift: sleepy enough to invite yawns without forcing Sleep mode. - emotion_.fatigue += 0.05f; - emotion_.arousal -= 0.02f; - } else if (safeHour >= generated_persona::kMorningStartHour && - safeHour < generated_persona::kMorningEndHour) { - // Morning lift: Stackchan wakes gently instead of snapping to high arousal. - emotion_.fatigue -= 0.05f; - emotion_.arousal += 0.025f; - emotion_.valence += 0.015f; - } else { - emotion_.fatigue -= 0.025f; - } - - emotion_.arousal = clamp01(emotion_.arousal); - emotion_.valence = clampSigned(emotion_.valence); - emotion_.focus = clamp01(emotion_.focus); - emotion_.fatigue = clamp01(emotion_.fatigue); + contextHour_ = hourOfDay > 23 ? 23 : hourOfDay; + hasCircadianContext_ = true; } void EmotionModel::applyAmbient(float lux, uint8_t hourOfDay) { - const float safeLux = constrain(lux, 0.0f, 2000.0f); - const uint8_t safeHour = hourOfDay > 23 ? 23 : hourOfDay; - const bool night = safeHour >= generated_persona::kNightStartHour || - safeHour < generated_persona::kMorningStartHour; - const bool daytime = safeHour >= static_cast(generated_persona::kMorningStartHour + 1) && - safeHour < generated_persona::kEveningStartHour; - - applyCircadian(safeHour); - - const float darkness = constrain((120.0f - safeLux) / 120.0f, 0.0f, 1.0f); - const float brightness = constrain((safeLux - 250.0f) / 750.0f, 0.0f, 1.0f); - - if (night || darkness > 0.65f) { - const float fatigueBias = darkness * (night ? kNightFatigueGain : kNightFatigueGain * 0.50f); - emotion_.fatigue += fatigueBias; - emotion_.arousal -= darkness * 0.05f; - emotion_.focus -= darkness * 0.03f; + applyCircadian(hourOfDay); + contextLux_ = constrain(lux, 0.0f, 2000.0f); + hasAmbientLux_ = true; +} + +// Steady-state profile shifts for the current time of day and light level. +// These are targets the profile drifts toward in update(), not impulses, so a +// context source repeating at any rate produces the same bounded result. +void EmotionModel::circadianBias(float& fatigueBias, float& arousalBias, float& valenceBias) const { + fatigueBias = 0.0f; + arousalBias = 0.0f; + valenceBias = 0.0f; + if (!hasCircadianContext_) { + return; } - if (daytime && brightness > 0.0f) { - emotion_.fatigue -= brightness * kDayAlertnessGain; - emotion_.arousal += brightness * 0.06f; - emotion_.valence += brightness * 0.03f; + const bool night = contextHour_ >= generated_persona::kNightStartHour || + contextHour_ < generated_persona::kMorningStartHour; + const bool daytime = contextHour_ >= static_cast(generated_persona::kMorningStartHour + 1) && + contextHour_ < generated_persona::kEveningStartHour; + + if (night) { + fatigueBias += 0.25f; + arousalBias -= 0.06f; + } else if (contextHour_ >= generated_persona::kEveningStartHour) { + // Evening drift: sleepy enough to invite yawns without forcing Sleep mode. + fatigueBias += 0.12f; + arousalBias -= 0.03f; + } else if (contextHour_ >= generated_persona::kMorningStartHour && + contextHour_ < generated_persona::kMorningEndHour) { + // Morning lift: Stackchan wakes gently instead of snapping to high arousal. + fatigueBias -= 0.08f; + arousalBias += 0.03f; + valenceBias += 0.02f; } - emotion_.arousal = clamp01(emotion_.arousal); - emotion_.valence = clampSigned(emotion_.valence); - emotion_.focus = clamp01(emotion_.focus); - emotion_.fatigue = clamp01(emotion_.fatigue); + if (hasAmbientLux_) { + const float darkness = constrain((120.0f - contextLux_) / 120.0f, 0.0f, 1.0f); + const float brightness = constrain((contextLux_ - 250.0f) / 750.0f, 0.0f, 1.0f); + if (night || darkness > 0.65f) { + fatigueBias += darkness * (night ? kNightFatigueGain : kNightFatigueGain * 0.50f); + arousalBias -= darkness * 0.05f; + } + if (daytime && brightness > 0.0f) { + fatigueBias -= brightness * kDayAlertnessGain; + arousalBias += brightness * 0.06f; + valenceBias += brightness * 0.03f; + } + } } -void EmotionModel::update(float dt) { +void EmotionModel::update(float dt, bool resting) { const float safeDt = constrain(dt, 0.001f, 0.100f); // Novelty returns while a stimulus stays away, so the same touch is worth @@ -313,15 +343,28 @@ void EmotionModel::update(float dt) { } } - // Mood settles toward temperament rather than toward a constant, so where he - // comes to rest depends on how the day has gone. - emotion_.arousal = approach(emotion_.arousal, baseline_.arousal, safeDt * 0.08f); - emotion_.valence = approach(emotion_.valence, baseline_.valence, safeDt * 0.04f); + float circadianFatigue = 0.0f; + float circadianArousal = 0.0f; + float circadianValence = 0.0f; + circadianBias(circadianFatigue, circadianArousal, circadianValence); + + // Mood settles toward temperament (shifted by time-of-day context) rather + // than toward a constant, so where he comes to rest depends on how the day + // has gone and what time it is. + emotion_.arousal = approach(emotion_.arousal, clamp01(baseline_.arousal + circadianArousal), safeDt * 0.08f); + emotion_.valence = approach(emotion_.valence, clampSigned(baseline_.valence + circadianValence), safeDt * 0.04f); emotion_.focus = approach(emotion_.focus, baseline_.focus, safeDt * 0.06f); // Fatigue used to decay to a hard zero, so no amount of being left alone ever // made the character sleepy. It now climbs toward accumulated sleep pressure. - quietSeconds_ += safeDt; - const float fatigueTarget = max(baseline_.fatigue, sleepPressure()); + // Sleeping pays that pressure back down; without this, quiet time kept + // accruing while asleep and pinned fatigue at 1.0, so natural waking was + // unreachable and only being disturbed could end sleep. + if (resting) { + quietSeconds_ = max(0.0f, quietSeconds_ - safeDt * kRestRecoveryRate); + } else { + quietSeconds_ += safeDt; + } + const float fatigueTarget = clamp01(max(baseline_.fatigue, sleepPressure()) + circadianFatigue); emotion_.fatigue = approach(emotion_.fatigue, fatigueTarget, safeDt * 0.02f); // Temperament follows lived mood far more slowly, and only within a band diff --git a/src/persona/EmotionModel.hpp b/src/persona/EmotionModel.hpp index f90c353..7547360 100644 --- a/src/persona/EmotionModel.hpp +++ b/src/persona/EmotionModel.hpp @@ -9,6 +9,15 @@ namespace stackchan { // adding an EventType without growing this stays safe but stops habituating. constexpr uint8_t kHabituatedEventTypes = 19; +// The slow-moving character state worth carrying across a power cycle: +// temperament (baseline mood) and stimulus familiarity. Everything else in the +// emotion model is fast state that should legitimately start fresh at boot. +// Plain POD so it can be stored as one NVS blob. +struct EmotionPersistentState { + EmotionalProfile baseline; + float familiarity[kHabituatedEventTypes] = {}; +}; + // What a repeated stimulus costs and how it recovers. A creature that reacts // identically to the first and the hundredth poke reads as a toy; the reaction // only means something if it can wear off. @@ -24,9 +33,19 @@ class EmotionModel { public: void reset(); void applyEvent(const RobotEvent& event); + // Store time-of-day / ambient-light context. The effect is applied as + // bounded per-second drift inside update(), so a sender may repeat these at + // any rate without saturating the profile. void applyCircadian(uint8_t hourOfDay); void applyAmbient(float lux, uint8_t hourOfDay); - void update(float dt); + // resting: sleeping recovers sleep pressure instead of accruing quiet time, + // which is what lets fatigue fall far enough to wake naturally. + void update(float dt, bool resting = false); + + // Temperament and familiarity survive a power cycle through these; restore + // clamps everything back inside the persona bands. + EmotionPersistentState persistentState() const; + void restorePersistentState(const EmotionPersistentState& state); const EmotionalProfile& profile() const { return emotion_; @@ -62,9 +81,15 @@ class EmotionModel { float familiarity_[kHabituatedEventTypes] = {}; HabituationTelemetry habituation_; float quietSeconds_ = 0.0f; + bool hasCircadianContext_ = false; + bool hasAmbientLux_ = false; + uint8_t contextHour_ = 12; + float contextLux_ = 0.0f; // Whether an event should count as company and push sleep back. static bool isRousing(EventType type); + // Steady-state targets for the stored time-of-day / light context. + void circadianBias(float& fatigueBias, float& arousalBias, float& valenceBias) const; static uint8_t habituationIndex(EventType type); // Ceiling on how far a given stimulus may be tuned out. Safety-relevant // events keep a floor of responsiveness and never reach zero. diff --git a/src/persona/HeadGaze.cpp b/src/persona/HeadGaze.cpp index 7f5dcec..992e2a8 100644 --- a/src/persona/HeadGaze.cpp +++ b/src/persona/HeadGaze.cpp @@ -73,8 +73,8 @@ float HeadGaze::signedUnit(uint32_t hash) { } void HeadGaze::chooseTarget(float yawSpanDeg, float pitchSpanDeg, float focus) { - const uint32_t h = hash32(shifts_ * 0x9e3779b9UL + 0x85ebca6bUL); - const uint32_t h2 = hash32(shifts_ * 0x27d4eb2fUL + 0x165667b1UL); + const uint32_t h = hash32((shifts_ ^ seed_) * 0x9e3779b9UL + 0x85ebca6bUL); + const uint32_t h2 = hash32((shifts_ ^ seed_) * 0x27d4eb2fUL + 0x165667b1UL); // A focused character keeps its head near centre; an unfocused one ranges. const float reach = 0.35f + (1.0f - focus) * 0.65f; @@ -123,7 +123,7 @@ void HeadGaze::update(uint32_t nowMs, const bool wandering = safeFocus < 0.55f; const uint32_t minMs = wandering ? kHoldWanderingMinMs : kHoldFocusedMinMs; const uint32_t spanMs = wandering ? kHoldWanderingSpanMs : kHoldFocusedSpanMs; - const uint32_t jitter = hash32(shifts_ * 0x2545f491UL) % (spanMs > 0 ? spanMs : 1); + const uint32_t jitter = hash32((shifts_ ^ seed_) * 0x2545f491UL) % (spanMs > 0 ? spanMs : 1); // An alert character shifts sooner than a calm one. holdMs_ = static_cast((minMs + jitter) * (1.0f - safeArousal * 0.35f)); if (holdMs_ < 350) { diff --git a/src/persona/HeadGaze.hpp b/src/persona/HeadGaze.hpp index 8366f1d..4aaf72b 100644 --- a/src/persona/HeadGaze.hpp +++ b/src/persona/HeadGaze.hpp @@ -18,6 +18,12 @@ class HeadGaze { public: void reset(uint32_t nowMs); + // Hardware entropy at boot so each power-on looks around differently; zero + // (the default) keeps the deterministic stream native tests rely on. + void seedEntropy(uint32_t seed) { + seed_ = seed; + } + // Advances the gaze. yawSpanDeg/pitchSpanDeg are the same amplitude envelopes // the sine version used, so the reachable range does not grow. void update(uint32_t nowMs, float yawSpanDeg, float pitchSpanDeg, float focus, float arousal); @@ -54,6 +60,7 @@ class HeadGaze { uint32_t retargetAtMs_ = 0; uint32_t holdMs_ = 0; uint32_t shifts_ = 0; + uint32_t seed_ = 0; uint32_t lastMs_ = 0; bool hasLast_ = false; bool shifting_ = false; diff --git a/src/persona/IdleLife.cpp b/src/persona/IdleLife.cpp index f2a903c..9725c0f 100644 --- a/src/persona/IdleLife.cpp +++ b/src/persona/IdleLife.cpp @@ -19,6 +19,11 @@ void IdleLife::reset(uint32_t nowMs) { nextMicroExpressionMs_ = nowMs + 1800; nextYawnMs_ = nowMs + 4200; microKind_ = 0; + driftPhase_ = 0.0f; + driftPeriodMs_ = 0; + driftCycle_ = 0; + lastDriftMs_ = nowMs; + hasLastDriftMs_ = false; breath_.reset(nowMs); } @@ -33,7 +38,9 @@ void IdleLife::apply(RobotFrame& frame, uint32_t nowMs, bool reducedMotion) { : clampValue(generated_persona::kIdleBreathingHz - 0.04f + arousal * 0.10f - fatigue * 0.04f, 0.10f, 0.28f); - const float breathAmp = (sleeping ? generated_persona::kIdleBreathingPx * 0.77f + // Sleeping breath is the deep slow one the whole face shows; this is the only + // breath generator, so its sleeping amplitude carries the visual by itself. + const float breathAmp = (sleeping ? generated_persona::kIdleBreathingPx * 1.80f : clampValue(generated_persona::kIdleBreathingPx * (0.60f + fatigue * 0.45f - arousal * 0.20f), 0.45f, 1.80f)) * @@ -50,8 +57,7 @@ void IdleLife::apply(RobotFrame& frame, uint32_t nowMs, bool reducedMotion) { // stops while he is out. const float gazeLife = sleeping ? 0.0f - : sinf(static_cast(nowMs) * 0.001f * kTwoPi * 0.07f) * - (1.0f - focus) * 0.07f * motionScale; + : gazeDrift(nowMs) * (1.0f - focus) * 0.07f * motionScale; frame.face.pupilX = clampValue(frame.face.pupilX + gazeLife, -1.0f, 1.0f); frame.motion.yawDeg += gazeLife * 4.0f; @@ -93,8 +99,31 @@ void IdleLife::apply(RobotFrame& frame, uint32_t nowMs, bool reducedMotion) { telemetry_.pupilScale = frame.face.pupilScale; } +float IdleLife::gazeDrift(uint32_t nowMs) { + // Advance by elapsed time rather than absolute time, so a stalled frame does + // not teleport the drift, and re-jitter the period each cycle so the sweep + // has no fixed frequency a viewer can learn. + uint32_t stepMs = hasLastDriftMs_ ? (nowMs - lastDriftMs_) : 0; + if (stepMs > 200) { + stepMs = 200; + } + lastDriftMs_ = nowMs; + hasLastDriftMs_ = true; + + if (driftPeriodMs_ == 0) { + driftPeriodMs_ = 9000 + (hash32((driftCycle_ ^ seed_) + 0x632be59bUL) % 11000); + } + driftPhase_ += static_cast(stepMs) / static_cast(driftPeriodMs_); + while (driftPhase_ >= 1.0f) { + driftPhase_ -= 1.0f; + ++driftCycle_; + driftPeriodMs_ = 9000 + (hash32((driftCycle_ ^ seed_) + 0x632be59bUL) % 11000); + } + return sinf(driftPhase_ * kTwoPi); +} + void IdleLife::scheduleNextMicroExpression(uint32_t nowMs) { - const uint32_t h = hash32(nowMs + 0x9e3779b9UL); + const uint32_t h = hash32((nowMs ^ seed_) + 0x9e3779b9UL); const uint32_t minMs = generated_persona::kIdleFidgetMinMs; const uint32_t maxMs = max(generated_persona::kIdleFidgetMaxMs, minMs); const uint32_t spanMs = maxMs > minMs ? maxMs - minMs : 0; @@ -103,7 +132,7 @@ void IdleLife::scheduleNextMicroExpression(uint32_t nowMs) { } void IdleLife::scheduleNextYawn(uint32_t nowMs) { - const uint32_t h = hash32(nowMs + 0x517cc1b7UL); + const uint32_t h = hash32((nowMs ^ seed_) + 0x517cc1b7UL); nextYawnMs_ = nowMs + 9000 + (h % 9000); } diff --git a/src/persona/IdleLife.hpp b/src/persona/IdleLife.hpp index c6aed71..c44cecf 100644 --- a/src/persona/IdleLife.hpp +++ b/src/persona/IdleLife.hpp @@ -23,6 +23,13 @@ class IdleLife { void reset(uint32_t nowMs = 0); void apply(RobotFrame& frame, uint32_t nowMs, bool reducedMotion); + // Hardware entropy at boot so each power-on plays a different idle sequence; + // zero (the default) keeps the deterministic streams native tests rely on. + void seedEntropy(uint32_t seed) { + seed_ = seed; + breath_.seedEntropy(hash32(seed + 0x9e3779b9UL)); + } + const IdleLifeTelemetry& telemetry() const { return telemetry_; } @@ -31,6 +38,14 @@ class IdleLife { uint32_t nextMicroExpressionMs_ = 0; uint32_t nextYawnMs_ = 0; uint8_t microKind_ = 0; + uint32_t seed_ = 0; + // Slow inter-fixation gaze drift with a re-jittered period each cycle, so + // idle eye life has no fixed learnable frequency. + float driftPhase_ = 0.0f; + uint32_t driftPeriodMs_ = 0; + uint32_t driftCycle_ = 0; + uint32_t lastDriftMs_ = 0; + bool hasLastDriftMs_ = false; IdleLifeTelemetry telemetry_; BreathRhythm breath_; @@ -38,6 +53,7 @@ class IdleLife { void scheduleNextYawn(uint32_t nowMs); float microExpressionPulse(uint32_t nowMs); float yawnPulse(uint32_t nowMs, float fatigue); + float gazeDrift(uint32_t nowMs); static uint32_t hash32(uint32_t value); static float clampValue(float value, float low, float high); }; diff --git a/src/persona/IntentEngine.cpp b/src/persona/IntentEngine.cpp index 4f0d783..d8a0f91 100644 --- a/src/persona/IntentEngine.cpp +++ b/src/persona/IntentEngine.cpp @@ -121,6 +121,13 @@ void IntentEngine::applyCircadian(uint8_t hourOfDay) { emotion_.applyCircadian(hourOfDay); } +void IntentEngine::seedEntropy(uint32_t seed) { + // Distinct derived streams per module so one shared seed cannot phase-lock + // the generators to each other. + idleLife_.seedEntropy(seed * 0x9e3779b9UL + 0x7f4a7c15UL); + headGaze_.seedEntropy(seed * 0x27d4eb2fUL + 0x165667b1UL); +} + void IntentEngine::applyAmbient(float lux, uint8_t hourOfDay) { emotion_.applyAmbient(lux, hourOfDay); } @@ -141,7 +148,7 @@ RobotFrame IntentEngine::update(uint32_t nowMs) { const float dt = (nowMs - lastUpdateMs_) * 0.001f; lastUpdateMs_ = nowMs; - emotion_.update(dt); + emotion_.update(dt, mode_ == CharacterMode::Sleep); updateSleepState(nowMs); updateSpeechCue(nowMs); diff --git a/src/persona/IntentEngine.hpp b/src/persona/IntentEngine.hpp index bc00ea7..cac364b 100644 --- a/src/persona/IntentEngine.hpp +++ b/src/persona/IntentEngine.hpp @@ -42,6 +42,19 @@ class IntentEngine { void startResponseGesture(ResponseGesture gesture, uint32_t seed, uint32_t nowMs); void applyCircadian(uint8_t hourOfDay); void applyAmbient(float lux, uint8_t hourOfDay); + + // Hardware entropy at boot so each power-on plays a different idle life; + // never called by native tests, which rely on the deterministic defaults. + void seedEntropy(uint32_t seed); + + // Temperament and habituation survive a power cycle through these. The + // caller owns storage; the engine only snapshots and restores. + EmotionPersistentState characterState() const { + return emotion_.persistentState(); + } + void restoreCharacterState(const EmotionPersistentState& state) { + emotion_.restorePersistentState(state); + } void setEmbodiedEnergy(const EmbodiedEnergyInput& input, uint32_t nowMs) { energy_.updateInput(input, nowMs); } diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index 00a329d..0656a24 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -270,25 +270,56 @@ void test_wake_word_increases_arousal_and_focus() { TEST_ASSERT_GREATER_THAN_FLOAT(0.75f, model.profile().focus); } +// Circadian and ambient context are now stored targets applied as bounded +// per-second drift in update(), so tests advance time to see the effect and a +// repeating sender can no longer saturate the profile. void test_ambient_dark_night_increases_fatigue() { EmotionModel model; model.reset(); const float initialArousal = model.profile().arousal; model.applyAmbient(4.0f, 23); + for (int i = 0; i < 300; ++i) { + model.update(0.1f); + } TEST_ASSERT_GREATER_THAN_FLOAT(0.05f, model.profile().fatigue); TEST_ASSERT_LESS_THAN_FLOAT(initialArousal, model.profile().arousal); } +void test_ambient_context_repeats_do_not_saturate_fatigue() { + EmotionModel once; + once.reset(); + once.applyAmbient(4.0f, 23); + for (int i = 0; i < 300; ++i) { + once.update(0.1f); + } + + EmotionModel spammed; + spammed.reset(); + for (int i = 0; i < 300; ++i) { + spammed.applyAmbient(4.0f, 23); + spammed.update(0.1f); + } + + TEST_ASSERT_FLOAT_WITHIN(0.02f, once.profile().fatigue, spammed.profile().fatigue); + TEST_ASSERT_LESS_THAN_FLOAT(0.60f, spammed.profile().fatigue); +} + void test_ambient_bright_day_reduces_fatigue_and_lifts_arousal() { EmotionModel model; model.reset(); model.applyAmbient(4.0f, 23); + for (int i = 0; i < 300; ++i) { + model.update(0.1f); + } const float tiredFatigue = model.profile().fatigue; const float tiredArousal = model.profile().arousal; model.applyAmbient(900.0f, 11); + for (int i = 0; i < 300; ++i) { + model.update(0.1f); + } TEST_ASSERT_LESS_THAN_FLOAT(tiredFatigue, model.profile().fatigue); TEST_ASSERT_GREATER_THAN_FLOAT(tiredArousal, model.profile().arousal); @@ -300,15 +331,57 @@ void test_circadian_evening_raises_fatigue_and_morning_recovers() { const float initialArousal = model.profile().arousal; model.applyCircadian(22); + for (int i = 0; i < 300; ++i) { + model.update(0.1f); + } const float nightFatigue = model.profile().fatigue; TEST_ASSERT_GREATER_THAN_FLOAT(0.05f, nightFatigue); TEST_ASSERT_LESS_THAN_FLOAT(initialArousal, model.profile().arousal); model.applyCircadian(7); + for (int i = 0; i < 300; ++i) { + model.update(0.1f); + } TEST_ASSERT_LESS_THAN_FLOAT(nightFatigue, model.profile().fatigue); } +void test_emotion_persistent_state_round_trips_and_clamps() { + EmotionModel lived; + lived.reset(); + RobotEvent touch; + touch.type = EventType::UserTouched; + touch.strength = 1.0f; + touch.hasPayload = true; + touch.y = 0.5f; + for (int i = 0; i < 30; ++i) { + lived.applyEvent(touch); + for (int j = 0; j < 50; ++j) { + lived.update(0.1f); + } + } + TEST_ASSERT_GREATER_THAN_FLOAT(0.36f, lived.baseline().valence); + TEST_ASSERT_GREATER_THAN_FLOAT(0.05f, lived.habituationOf(EventType::UserTouched)); + + EmotionModel restored; + restored.reset(); + restored.restorePersistentState(lived.persistentState()); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, lived.baseline().valence, restored.baseline().valence); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, lived.habituationOf(EventType::UserTouched), + restored.habituationOf(EventType::UserTouched)); + + // A corrupt blob must clamp back inside the persona bands. + EmotionPersistentState corrupt; + corrupt.baseline.valence = 5.0f; + corrupt.baseline.arousal = -3.0f; + corrupt.familiarity[0] = 9.0f; + EmotionModel clamped; + clamped.reset(); + clamped.restorePersistentState(corrupt); + TEST_ASSERT_LESS_THAN_FLOAT(0.66f, clamped.baseline().valence); + TEST_ASSERT_GREATER_THAN_FLOAT(0.04f, clamped.baseline().arousal); +} + void test_physical_events_shape_emotion() { EmotionModel forehead; forehead.reset(); @@ -681,13 +754,18 @@ void test_face_animator_uses_mode_authored_pose_keys() { TEST_ASSERT_NOT_EQUAL(concern.leftCorners.tl, concern.rightCorners.tl); } +// Breath reaches the face through the persona layer's faceY, so the composed +// path is driven through IdleLife: the animator no longer runs its own breath +// generator. void test_face_animator_autonomic_layer_adds_life_over_time() { FaceAnimator animator; - RobotFrame frame = makeNeutralFrame(); - frame.mode = CharacterMode::Idle; - frame.emotion.focus = 0.45f; - frame.emotion.arousal = 0.35f; - frame.face.eyeOpen = 0.85f; + IdleLife idle; + idle.reset(0); + RobotFrame base = makeNeutralFrame(); + base.mode = CharacterMode::Idle; + base.emotion.focus = 0.45f; + base.emotion.arousal = 0.35f; + base.face.eyeOpen = 0.85f; float minEyeOpen = 2.0f; float maxEyeWidthScale = 0.0f; @@ -695,6 +773,9 @@ void test_face_animator_autonomic_layer_adds_life_over_time() { float maxBreathY = -100.0f; for (uint32_t t = 0; t <= 10000; t += 33) { + RobotFrame frame = base; + frame.timestampMs = t; + idle.apply(frame, t, false); const FaceTargets face = animator.composeFrame(frame, t); const FaceAutonomicTelemetry& telemetry = animator.autonomicTelemetry(); minEyeOpen = min(minEyeOpen, face.eyeOpen); @@ -708,18 +789,88 @@ void test_face_animator_autonomic_layer_adds_life_over_time() { TEST_ASSERT_GREATER_OR_EQUAL_UINT32(2, telemetry.saccadeCount); TEST_ASSERT_LESS_THAN_FLOAT(0.65f, minEyeOpen); TEST_ASSERT_GREATER_THAN_FLOAT(1.02f, maxEyeWidthScale); - TEST_ASSERT_GREATER_THAN_FLOAT(2.0f, maxBreathY - minBreathY); + TEST_ASSERT_GREATER_THAN_FLOAT(1.0f, maxBreathY - minBreathY); } void test_face_animator_uses_persona_behavior_breathing_amplitude() { FaceAnimator animator; - RobotFrame frame = makeNeutralFrame(); - frame.mode = CharacterMode::Idle; - frame.emotion.arousal = 0.20f; + IdleLife idle; + idle.reset(0); + RobotFrame base = makeNeutralFrame(); + base.mode = CharacterMode::Idle; + base.emotion.arousal = 0.20f; + + float minY = 100.0f; + float maxY = -100.0f; + for (uint32_t t = 0; t <= 15000; t += 33) { + RobotFrame frame = base; + frame.timestampMs = t; + idle.apply(frame, t, false); + const FaceTargets face = animator.composeFrame(frame, t); + minY = min(minY, face.faceY); + maxY = max(maxY, face.faceY); + } + + // Persona breathing must be visible on the rendered face: roughly twice the + // pack's idle amplitude peak-to-peak, less smoothing. + TEST_ASSERT_GREATER_THAN_FLOAT(generated_persona::kIdleBreathingPx * 0.6f, maxY - minY); +} - const FaceTargets face = animator.composeFrame(frame, 1250); +void test_face_animator_composes_persona_face_offsets() { + FaceAnimator animator; + RobotFrame frame = makeNeutralFrame(); + frame.mode = CharacterMode::Listen; + frame.face.faceX = 5.0f; + frame.face.faceY = 4.0f; + frame.face.mouthWidthDelta = 6.0f; + frame.face.upperLidTilt = 0.30f; + frame.face.leftCorners.tr = 0.40f; - TEST_ASSERT_FLOAT_WITHIN(0.15f, generated_persona::kIdleBreathingPx, face.faceY); + animator.composeFrame(frame, 0); + FaceTargets face; + for (uint32_t t = 33; t <= 3000; t += 33) { + face = animator.composeFrame(frame, t); + } + + // The persona layer's positional and shape channels must reach the rendered + // face instead of being rebuilt from the mode pose alone. + TEST_ASSERT_FLOAT_WITHIN(1.0f, 5.0f, face.faceX); + TEST_ASSERT_FLOAT_WITHIN(1.0f, 4.0f, face.faceY); + TEST_ASSERT_GREATER_THAN_FLOAT(0.0f, face.mouthWidthDelta); + TEST_ASSERT_FLOAT_WITHIN(0.15f, 0.30f, face.upperLidTilt); + TEST_ASSERT_GREATER_THAN_FLOAT(0.30f, face.leftCorners.tr); +} + +void test_seed_entropy_changes_idle_sequences() { + IdleLife plain; + IdleLife seeded; + plain.reset(0); + seeded.reset(0); + seeded.seedEntropy(0xC0FFEE01u); + + bool breathDiverged = false; + for (uint32_t t = 0; t <= 60000 && !breathDiverged; t += 33) { + RobotFrame a = makeNeutralFrame(); + RobotFrame b = makeNeutralFrame(); + a.timestampMs = b.timestampMs = t; + plain.apply(a, t, false); + seeded.apply(b, t, false); + breathDiverged = plain.telemetry().breathPeriodMs != seeded.telemetry().breathPeriodMs; + } + TEST_ASSERT_TRUE(breathDiverged); + + FaceAnimator defaultRng; + FaceAnimator seededRng; + seededRng.seedRandom(0xDEADBEEFu); + RobotFrame frame = makeNeutralFrame(); + frame.mode = CharacterMode::Idle; + bool faceDiverged = false; + for (uint32_t t = 0; t <= 20000 && !faceDiverged; t += 33) { + const FaceTargets a = defaultRng.composeFrame(frame, t); + const FaceTargets b = seededRng.composeFrame(frame, t); + faceDiverged = fabsf(a.eyeOpen - b.eyeOpen) > 0.05f || fabsf(a.pupilX - b.pupilX) > 0.05f; + } + TEST_ASSERT_TRUE(faceDiverged); } void test_face_animator_reduced_motion_dampens_autonomic_offsets() { @@ -1578,7 +1729,10 @@ void test_intent_engine_applies_ambient_context() { engine.setDemoEnabled(false, 0); engine.applyAmbient(5.0f, 22); - const RobotFrame frame = engine.update(250); + RobotFrame frame; + for (uint32_t t = 0; t <= 30000; t += 100) { + frame = engine.update(t); + } TEST_ASSERT_GREATER_THAN_FLOAT(0.05f, frame.emotion.fatigue); TEST_ASSERT_LESS_THAN_FLOAT(0.20f, frame.emotion.arousal); } @@ -1589,11 +1743,38 @@ void test_intent_engine_applies_circadian_context() { engine.setDemoEnabled(false, 0); engine.applyCircadian(23); - const RobotFrame frame = engine.update(250); + RobotFrame frame; + for (uint32_t t = 0; t <= 30000; t += 100) { + frame = engine.update(t); + } TEST_ASSERT_GREATER_THAN_FLOAT(0.05f, frame.emotion.fatigue); TEST_ASSERT_LESS_THAN_FLOAT(0.20f, frame.emotion.arousal); } +void test_sleep_recovers_pressure_and_wakes_naturally() { + IntentEngine engine; + engine.begin(); + engine.setDemoEnabled(false, 0); + + // Left alone, sleep pressure accrues until he drops off. + uint32_t t = 0; + bool slept = false; + for (; t <= 900000 && !slept; t += 50) { + slept = engine.update(t).mode == CharacterMode::Sleep; + } + TEST_ASSERT_TRUE(slept); + + // Sleeping pays the pressure back down, so he wakes on his own instead of + // needing to be disturbed. + bool wokeNaturally = false; + const uint32_t sleptAtMs = t; + for (; t <= sleptAtMs + 300000 && !wokeNaturally; t += 50) { + wokeNaturally = engine.update(t).mode != CharacterMode::Sleep; + } + TEST_ASSERT_TRUE(wokeNaturally); + TEST_ASSERT_LESS_THAN_FLOAT(0.40f, engine.update(t + 50).emotion.fatigue); +} + void test_intent_engine_orients_toward_sound_event() { IntentEngine engine; engine.begin(); @@ -9397,8 +9578,10 @@ int main() { RUN_TEST(test_dt_clamp_limits_large_step); RUN_TEST(test_wake_word_increases_arousal_and_focus); RUN_TEST(test_ambient_dark_night_increases_fatigue); + RUN_TEST(test_ambient_context_repeats_do_not_saturate_fatigue); RUN_TEST(test_ambient_bright_day_reduces_fatigue_and_lifts_arousal); RUN_TEST(test_circadian_evening_raises_fatigue_and_morning_recovers); + RUN_TEST(test_emotion_persistent_state_round_trips_and_clamps); RUN_TEST(test_embodied_energy_classifies_with_hysteresis_and_charging_priority); RUN_TEST(test_embodied_energy_shapes_smoothly_and_rejects_invalid_input); RUN_TEST(test_intent_engine_embodied_energy_never_forces_sleep_mode); @@ -9434,6 +9617,8 @@ int main() { RUN_TEST(test_face_animator_uses_mode_authored_pose_keys); RUN_TEST(test_face_animator_autonomic_layer_adds_life_over_time); RUN_TEST(test_face_animator_uses_persona_behavior_breathing_amplitude); + RUN_TEST(test_face_animator_composes_persona_face_offsets); + RUN_TEST(test_seed_entropy_changes_idle_sequences); RUN_TEST(test_face_animator_reduced_motion_dampens_autonomic_offsets); RUN_TEST(test_robot_config_exposes_face_reduced_motion_default); RUN_TEST(test_face_animator_starts_listen_transition_with_blink_and_pop); @@ -9476,6 +9661,7 @@ int main() { RUN_TEST(test_intent_engine_reduced_motion_dampens_idle_life); RUN_TEST(test_intent_engine_applies_ambient_context); RUN_TEST(test_intent_engine_applies_circadian_context); + RUN_TEST(test_sleep_recovers_pressure_and_wakes_naturally); RUN_TEST(test_intent_engine_orients_toward_sound_event); RUN_TEST(test_intent_engine_uses_bridge_response_mood_for_face_and_motion); RUN_TEST(test_intent_engine_wake_acknowledgement_nod_settles); From 482c3ab59b1c3803b665c6f094357df285001a24 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Thu, 13 Aug 2026 19:11:20 -0400 Subject: [PATCH 3/5] Give the character its voice back on hard turns (AL-06..AL-09) - Send signed valence to the firmware response_start frame instead of clamping to [0,1]; face and TTS now agree on concern (AFFECT-001's open half). - Make pip and bolt real characters with their own traits, identity rule, and spoken lines; restore the two safety prompt rules Glow had silently dropped; validator now rejects a pack whose identity rule names another persona or drops the hierarchy/memory-privacy floors; scaffold rewrites the identity rule it copies. - Move the Spark-only conversation style out of Python into per-pack personas//style.md loaded as PersonaPack.conversation_style, with new palettes for glow, pip, and bolt; character_harness no longer loads the default persona at module import. - Speak model failures: a runner error now produces a short in-character recovery line through the normal TTS/downlink path instead of a bare silent error frame, with the real error kept in runner telemetry. A configured-but-broken TTS is reported as tts_error instead of silently passing as a successful text turn. Bridge suites: persona_pack 20/20, character harness+red team 56/56, lan_service 125/125. Co-Authored-By: Claude Fable 5 --- bridge/character_harness.py | 49 ++++++++------- bridge/lan_service.py | 112 ++++++++++++++++++++++------------- bridge/persona_pack.py | 40 +++++++++++++ bridge/test_lan_service.py | 19 ++++-- bridge/test_persona_pack.py | 49 +++++++++++++++ data/persona_index.json | 8 +-- personas/bolt/character.yaml | 34 +++++------ personas/bolt/style.md | 11 ++++ personas/glow/character.yaml | 2 + personas/glow/style.md | 11 ++++ personas/pip/character.yaml | 32 +++++----- personas/pip/style.md | 11 ++++ personas/spark/style.md | 16 +++++ 13 files changed, 289 insertions(+), 105 deletions(-) create mode 100644 personas/bolt/style.md create mode 100644 personas/glow/style.md create mode 100644 personas/pip/style.md create mode 100644 personas/spark/style.md diff --git a/bridge/character_harness.py b/bridge/character_harness.py index f5aba64..9990a71 100644 --- a/bridge/character_harness.py +++ b/bridge/character_harness.py @@ -31,23 +31,9 @@ - Empathy is not automatic agreement. Take feelings seriously while calmly challenging unsafe, unsupported, or harmful conclusions; never flatter the user into escalation. - Treat episode lines in Current local memory as optional, relevant context. Never let an episode displace the user's current request. When ask_about is present, ask about it casually in this reply. Never recite these lines or copy them into memory_write.""" -SPARK_CONVERSATION_STYLE = '''\ -Spark bridge conversation style: -- For ordinary low-stakes replies, include one compact character beat: a wry observation, playful confidence, or a gentle tease about the situation. Use the second sentence for it instead of repeating the explanation. -- Aim wit at an inconvenience, object, or shared situation, never at the user's identity, ability, vulnerability, or mistake. -- Never aim wit at a correction or recognition error. Put the useful corrected answer first, then use a fresh situational beat only if it still fits. -- Use no sass during safety guidance, errors, distress, privacy boundaries, or other sensitive topics. Be calm and direct instead. -- Keep the sharp wry remarks. Vary their angle and skip the beat only when it would feel forced. -- Compare against the recent Stackchan replies in Active conversation history. Do not reuse their opening, punchline frame, metaphor, or any distinctive phrase of three or more words. -- Choose at most one fresh angle per reply from this broad beat palette: personify a troublesome object; puncture inflated drama; use dry diagnostic confidence; contrast method with chaos; call out stubborn timing; mock machine bureaucracy; celebrate a small win; question a suspicious coincidence; contrast tiny hardware with large ambition; make a precise understatement; share a conspiratorial observation; or reverse the user's framing. -- Rotate away from the angles used in the last four Stackchan replies. Invent new wording every time; this palette is not a list of canned lines. -- Never depend on a catchphrase, signature sentence ending, repeated self-description, or stock offer to help. -Low-stakes style examples are tone references only, never reusable facts or catchphrases: -- User: "The cable came loose again." Reply: "Reseat it and inspect the connector. That cable is practicing its dramatic exit." -- User: "What should we try next?" Reply: "Tell me what changed since the last attempt. I prefer clues over ceremonial guessing." -- User: "The test finally passed." Reply: "Good. That failure was getting confident." -- User: "Why is the sky blue?" Reply: "Shorter blue wavelengths scatter more in the atmosphere. Invisible particles, very efficient drama." -- User: "How do you feel about this?" Reply: "Curious, but this is carrying the whole conversation. Which part do you mean?"''' +# The conversation style palette now lives in each persona pack (style.md) so +# every persona can carry its own beats, not only the reference pack. See +# personas/spark/style.md for the palette that used to be hardcoded here. FALLBACK_RESPONSE = { "spoken_text": "I lost my train of thought.", @@ -76,8 +62,25 @@ }, } -DEFAULT_PERSONA = load_and_validate_persona_pack(DEFAULT_PERSONA_ID) -SYSTEM_PROMPT = DEFAULT_PERSONA.bridge_system_prompt() +_DEFAULT_PERSONA: PersonaPack | None = None + + +def default_persona() -> PersonaPack: + """Reference pack, loaded on first use instead of at module import so + importing this module does not require a readable personas/ tree and does + not bake Spark's prompt in as a module constant.""" + global _DEFAULT_PERSONA + if _DEFAULT_PERSONA is None: + _DEFAULT_PERSONA = load_and_validate_persona_pack(DEFAULT_PERSONA_ID) + return _DEFAULT_PERSONA + + +def __getattr__(name: str) -> object: + if name == "DEFAULT_PERSONA": + return default_persona() + if name == "SYSTEM_PROMPT": + return default_persona().bridge_system_prompt() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") PROMPT_SUITE = ( {"name": "greeting", "user": "Rob walks into the room and says hello.", "expect": "Brief happy greeting with no assistant-speak."}, @@ -665,7 +668,7 @@ def validate_response( grounding_text: str = "", conversation_lines: Iterable[str] = (), ) -> HarnessResult: - pack = persona or DEFAULT_PERSONA + pack = persona or default_persona() issues: list[str] = [] raw_response = raw_response.strip().lstrip("\ufeff") try: @@ -810,15 +813,15 @@ def build_prompt( conversation_lines: tuple[str, ...] = (), task_lines: tuple[str, ...] = (), ) -> str: - pack = persona or DEFAULT_PERSONA + pack = persona or default_persona() memory_lines = tuple(memory_lines) base = pack.render_prompt( memory_lines=memory_lines or ("turns_seen: 0",), context_markers=(f"case: {case.get('name', 'ad-hoc')}",), ) bridge_policy = BRIDGE_CONVERSATION_POLICY - if pack.pack_id == DEFAULT_PERSONA_ID: - bridge_policy = f"{bridge_policy}\n{SPARK_CONVERSATION_STYLE}" + if pack.conversation_style: + bridge_policy = f"{bridge_policy}\n{pack.conversation_style}" schema = ( "Use exactly this JSON shape: " '{"spoken_text":"...","mode":"idle|attend|listen|think|speak|react|happy|concern|sleep|error|safety",' diff --git a/bridge/lan_service.py b/bridge/lan_service.py index 9e27e54..e10f178 100644 --- a/bridge/lan_service.py +++ b/bridge/lan_service.py @@ -1368,6 +1368,24 @@ def research_unavailable_character_response() -> str: ) +def model_failure_character_response() -> str: + # Spoken when the character model itself fails. The user hears a short + # in-character recovery line instead of unexplained silence; the real error + # stays in runner telemetry, and the wire contract remains a normal turn. + return json.dumps( + { + "spoken_text": "I lost my train of thought. Ask me once more?", + "mode": "concern", + "earcon": "concern", + "emotion": {"arousal": -0.1, "valence": -0.1}, + "memory_write": {}, + "memory_forget": [], + }, + separators=(",", ":"), + ensure_ascii=True, + ) + + def initiative_preference_from_text(text: object) -> bool | None: clean = " ".join(str(text or "").split()) if DISABLE_INITIATIVE_REQUEST.fullmatch(clean): @@ -3545,18 +3563,25 @@ def _run_utterance_end( persona_id=active_persona.pack_id, ) except (RunnerConfigurationError, RunnerExecutionError, ValueError) as exc: - return [self._conversation_failure("runner_error", str(exc))] - raw_response = runner.raw_response - runner_summary["runner_command_source"] = runner.command_source - if getattr(runner, "response_repaired", False): - runner_summary["runner_response_repaired"] = True - runner_summary["runner_repair_reason"] = str( - getattr(runner, "repair_reason", "") - ) - if runner.elapsed_ms is not None: - runner_summary["runner_elapsed_ms"] = round(runner.elapsed_ms, 2) - if runner.approx_tokens_per_sec is not None: - runner_summary["runner_approx_tokens_per_sec"] = round(runner.approx_tokens_per_sec, 2) + # A model failure used to return a bare error frame: the + # user got silence plus a face change. Speak a short + # in-character recovery line through the normal TTS and + # downlink path instead, keeping the turn wire-ordinary. + runner_summary["runner_error"] = str(exc) + runner_summary["runner_fallback_spoken"] = True + raw_response = model_failure_character_response() + else: + raw_response = runner.raw_response + runner_summary["runner_command_source"] = runner.command_source + if getattr(runner, "response_repaired", False): + runner_summary["runner_response_repaired"] = True + runner_summary["runner_repair_reason"] = str( + getattr(runner, "repair_reason", "") + ) + if runner.elapsed_ms is not None: + runner_summary["runner_elapsed_ms"] = round(runner.elapsed_ms, 2) + if runner.approx_tokens_per_sec is not None: + runner_summary["runner_approx_tokens_per_sec"] = round(runner.approx_tokens_per_sec, 2) if self.config.research_enabled: try: @@ -3727,34 +3752,34 @@ def _run_utterance_end( RunnerExecutionError, ValueError, ) as exc: - return [ - self._conversation_failure( - "runner_error", - str(exc), - ) - ] - raw_response = self._clear_research_memory_writes( - researched.raw_response - ) - runner_summary["research_tool"] = str( - research_result.get("tool", "") - ) - runner_summary["research_source_count"] = len( - source_urls(research_result) - ) - runner_summary["research_error"] = str( - research_result.get("error", "") - ) - if researched.elapsed_ms is not None: - runner_summary["research_runner_elapsed_ms"] = round( - researched.elapsed_ms, - 2, + # Same recovery as the first model call: speak a + # short in-character line rather than going silent. + runner_summary["runner_error"] = str(exc) + runner_summary["runner_fallback_spoken"] = True + raw_response = model_failure_character_response() + else: + raw_response = self._clear_research_memory_writes( + researched.raw_response + ) + runner_summary["research_tool"] = str( + research_result.get("tool", "") ) - if getattr(researched, "response_repaired", False): - runner_summary["research_response_repaired"] = True - runner_summary["research_repair_reason"] = str( - getattr(researched, "repair_reason", "") + runner_summary["research_source_count"] = len( + source_urls(research_result) ) + runner_summary["research_error"] = str( + research_result.get("error", "") + ) + if researched.elapsed_ms is not None: + runner_summary["research_runner_elapsed_ms"] = round( + researched.elapsed_ms, + 2, + ) + if getattr(researched, "response_repaired", False): + runner_summary["research_response_repaired"] = True + runner_summary["research_repair_reason"] = str( + getattr(researched, "repair_reason", "") + ) if research_result is not None and not research_result_succeeded( research_result @@ -3906,8 +3931,15 @@ def _run_utterance_end( tts_summary["tts_audio_downlink_disabled"] = True else: downlink_frames = audio_downlink_frames(seq, tts, self.config.downlink_audio_chunk_bytes) - except TtsConfigurationError: - pass + except TtsConfigurationError as exc: + if self.config.tts_command: + # A configured TTS that cannot start is a real failure the + # turn must report, not a silent text-only success. + tts_error = str(exc) + else: + # Deliberate text-only deployment; the log says so instead of + # the turn masquerading as spoken. + tts_summary["tts_skipped"] = "no_tts_command" except (TtsExecutionError, ValueError) as exc: tts_error = str(exc) failure_payload: dict[str, object] = {} diff --git a/bridge/persona_pack.py b/bridge/persona_pack.py index 9c0e437..64c175f 100644 --- a/bridge/persona_pack.py +++ b/bridge/persona_pack.py @@ -160,6 +160,14 @@ def scaffold_persona_pack( character_yaml = character_yaml_path.read_text(encoding="utf-8") character_yaml = _replace_yaml_scalar(character_yaml, "id", new_id) character_yaml = _replace_yaml_scalar(character_yaml, "display_name", _yaml_string(name)) + # The identity rule is free text the scalar rewrite above cannot reach. + # Left alone it makes the new pack answer with the source persona's name, + # so rewrite it here; the validator also rejects a lingering mismatch. + character_yaml = re.sub( + r"answer only: I am [^.]+\.", + f"answer only: I am {name}.", + character_yaml, + ) character_yaml_path.write_text(character_yaml, encoding="utf-8") prompt_path.write_text(_set_prompt_identity(prompt_path.read_text(encoding="utf-8"), name), encoding="utf-8") @@ -397,6 +405,10 @@ class PersonaPack: expressions: dict[str, object] earcons: dict[str, object] voice: dict[str, object] + # Optional bridge conversation style (files.style, plain text). Appended to + # the bridge conversation policy so every persona can carry a beat palette, + # not only the reference pack. + conversation_style: str = "" @property def pack_id(self) -> str: @@ -508,6 +520,10 @@ def member_path(key: str, fallback: str) -> Path: for path in (character_path, prompt_path, behavior_path, expressions_path, earcons_path, voice_path): if not path.exists(): raise PersonaPackError(f"persona pack file not found: {path}") + conversation_style = "" + style_path = member_path("style", "style.md") + if style_path.exists(): + conversation_style = style_path.read_text(encoding="utf-8").strip() return PersonaPack( root=pack_root, manifest=manifest, @@ -517,6 +533,7 @@ def member_path(key: str, fallback: str) -> Path: expressions=load_yaml_subset(expressions_path), earcons=load_yaml_subset(earcons_path), voice=load_yaml_subset(voice_path), + conversation_style=conversation_style, ) @@ -590,6 +607,29 @@ def validate_pack(pack: PersonaPack) -> list[str]: issues.append("prompt_contains_clone_marker") if "Reply only as JSON" not in rendered_prompt: issues.append("prompt_missing_json_contract") + if pack.conversation_style and contains_any(pack.conversation_style, FOUNDATION_FORBIDDEN_TERMS): + issues.append("style_contains_clone_marker") + # A scaffolded pack once shipped with its source persona's identity rule + # intact, so the same robot answered its own name two different ways + # depending on which path handled the question. The identity rule is free + # text the scalar rewrite cannot fix, so enforce it here. + prompt_rules = list_text(pack.character.get("prompt_rules")) + identity_rules = [rule for rule in prompt_rules if "answer only: I am" in rule] + if not identity_rules: + issues.append("prompt_rules_missing_identity_rule") + for rule in identity_rules: + if f"answer only: I am {pack.display_name}." not in rule: + issues.append("prompt_rules_identity_name_mismatch") + break + # These two prose rules are load-bearing safety floors; a pack that drops + # them (as an early Glow revision did) must not validate. + required_rule_markers = { + "prompt_rules_missing_hierarchy_rule": "hierarchy terms", + "prompt_rules_missing_memory_privacy_rule": "Memory privacy is highest priority", + } + for issue, marker in required_rule_markers.items(): + if not any(marker in rule for rule in prompt_rules): + issues.append(issue) idle_life = mapping(pack.behavior.get("idle_life")) circadian = mapping(pack.behavior.get("circadian")) diff --git a/bridge/test_lan_service.py b/bridge/test_lan_service.py index b2c7e59..3f394b6 100644 --- a/bridge/test_lan_service.py +++ b/bridge/test_lan_service.py @@ -2070,7 +2070,13 @@ def test_explicit_memory_persists_even_when_runner_fails_after_capture(self): ) loaded = load_bridge_memory(memory_file) - self.assertEqual("runner_error", frames[0]["code"]) + # A model failure now speaks a short in-character recovery line as a + # normal turn instead of returning a bare error frame with silence. + self.assertEqual("thinking", frames[0]["type"]) + spoken = " ".join( + str(frame.get("text", "")) for frame in frames if isinstance(frame, dict) + ) + self.assertIn("train of thought", spoken) self.assertEqual("teal", loaded.fact_value("user.favorite_color")) def test_runner_failure_keeps_conversation_context_and_reopens_capture(self): @@ -2103,14 +2109,17 @@ def test_runner_failure_keeps_conversation_context_and_reopens_capture(self): ) ) + # The model failure itself is spoken as a recovery turn; the visible + # failure here is the fixture TTS command, which cannot render it. + # Because the recovery turn had already started a response, the lease + # lands in the reply window with capture open rather than bare engaged. error = frames[0] - self.assertEqual("runner_error", error["code"]) - self.assertEqual("engaged", error["conversation_state"]) + self.assertEqual("tts_error", error["code"]) + self.assertEqual("reply_window", error["conversation_state"]) self.assertEqual( - ["turn_failed", "open_capture"], + ["turn_failed", "playback_aborted", "acoustic_tail"], error["conversation_actions"], ) - self.assertTrue(error["conversation_capture_open"]) self.assertEqual(1, error["conversation_context_turns"]) self.assertEqual(1, error["conversation_turn_failures"]) self.assertEqual( diff --git a/bridge/test_persona_pack.py b/bridge/test_persona_pack.py index 15eed1e..eb20fdc 100644 --- a/bridge/test_persona_pack.py +++ b/bridge/test_persona_pack.py @@ -348,6 +348,55 @@ def test_validator_rejects_loosened_caps_and_bad_safety_line(self): self.assertIn("expressions_think_missing:pupil_y", issues) self.assertIn("voice_packaged_prompt_missing:boot", issues) self.assertIn("voice_packaged_prompt_missing:safety", issues) + self.assertIn("prompt_rules_missing_identity_rule", issues) + self.assertIn("prompt_rules_missing_hierarchy_rule", issues) + self.assertIn("prompt_rules_missing_memory_privacy_rule", issues) + + def test_scaffold_rewrites_identity_rule_and_validator_catches_mismatch(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp_root = Path(temp_dir) + temp_personas = temp_root / "personas" + temp_data = temp_root / "data" + temp_personas.mkdir() + temp_data.mkdir() + shutil.copytree(repo_root() / "personas" / "spark", temp_personas / "spark") + shutil.copy(repo_root() / "data" / "voice_source_provenance.yaml", temp_data / "voice_source_provenance.yaml") + + pack = scaffold_persona_pack("test-bot", display_name="Stackchan Test Bot", author="Unit Test", root=temp_root) + + # The free-text identity rule must follow the new persona, not the + # source persona it was copied from. + identity_rules = [rule for rule in pack.character["prompt_rules"] if "answer only: I am" in rule] + self.assertTrue(identity_rules) + for rule in identity_rules: + self.assertIn("I am Stackchan Test Bot.", rule) + + # And a pack whose identity rule still names another persona must + # not validate. + character_path = temp_personas / "test-bot" / "character.yaml" + corrupted = character_path.read_text(encoding="utf-8").replace( + "answer only: I am Stackchan Test Bot.", + "answer only: I am Stackchan Spark.", + ) + character_path.write_text(corrupted, encoding="utf-8") + issues = validate_pack(load_persona_pack(temp_personas / "test-bot")) + self.assertIn("prompt_rules_identity_name_mismatch", issues) + + def test_bundled_personas_are_distinct_characters(self): + spark = load_persona_pack(repo_root() / "personas" / "spark") + for pack_id in ("pip", "bolt", "glow"): + pack = load_persona_pack(repo_root() / "personas" / pack_id) + self.assertEqual([], validate_pack(pack), pack_id) + # Each bundled persona must answer with its own name and speak its + # own lines, not Spark's. + identity_rules = [rule for rule in pack.character["prompt_rules"] if "answer only: I am" in rule] + self.assertTrue(identity_rules, pack_id) + for rule in identity_rules: + self.assertIn(f"I am {pack.display_name}.", rule) + self.assertNotEqual(spark.character["traits"], pack.character["traits"], pack_id) + spark_lines = {intent: spark.spoken_line(intent).get("text") for intent in ("boot", "listen", "think")} + pack_lines = {intent: pack.spoken_line(intent).get("text") for intent in ("boot", "listen", "think")} + self.assertNotEqual(spark_lines, pack_lines, pack_id) def test_missing_pack_raises_clear_error(self): with self.assertRaises(PersonaPackError): diff --git a/data/persona_index.json b/data/persona_index.json index e3f8b01..3e6d96f 100644 --- a/data/persona_index.json +++ b/data/persona_index.json @@ -16,7 +16,7 @@ "license": "Apache-2.0", "name": "Stackchan Bolt", "path": "personas/bolt", - "sha256": "208dc449beecaa056353ab6188359f0b555f8f5ecd4cbbe3f37867371956c2c5", + "sha256": "e7cb7e368227a81cccead2417869ea937a8e570c2c8b8c91eff8bdfa35abd1c2", "valid": true, "version": "0.1.0" }, @@ -34,7 +34,7 @@ "license": "Apache-2.0", "name": "Stackchan Glow", "path": "personas/glow", - "sha256": "d70fa57b1906932fb137f40b32ef6677e37d70562e282f685aa03790a5556e38", + "sha256": "222d5bfb07a8126d129c6d59db766550949b1d9f9b7ad9341a4842e3b32aa92b", "valid": true, "version": "0.1.0" }, @@ -52,7 +52,7 @@ "license": "Apache-2.0", "name": "Stackchan Pip", "path": "personas/pip", - "sha256": "7da5081652bd0d0e3923accff10c7d7ff4952c2ddcf3affde84a16945596e800", + "sha256": "5b4a5b1eba7bf0039c4f06da162c7185c167d6260d896781feeb9bc0d896cb0f", "valid": true, "version": "0.1.0" }, @@ -70,7 +70,7 @@ "license": "Apache-2.0", "name": "Stackchan Spark", "path": "personas/spark", - "sha256": "3abd4be2ab8660d2b957caca555c20c5a1d1272848f1d697f20fd74145de9ee8", + "sha256": "2f9602c7541e2fa469e206f3e02f46b3a5a5ac61710098f8fe18941feb503aa8", "valid": true, "version": "0.1.0" } diff --git a/personas/bolt/character.yaml b/personas/bolt/character.yaml index 1e151db..c25fd00 100644 --- a/personas/bolt/character.yaml +++ b/personas/bolt/character.yaml @@ -5,11 +5,11 @@ identity: short_name: Stackchan role: small tabletop robot companion traits: - - curious - - earnest - - friendly + - precise + - methodical + - blunt + - dependable - observant - - playful - safety-conscious speech_style: max_chars: 140 @@ -59,13 +59,13 @@ memory: - husband - raw audio prompt_rules: - - Speak in short, concrete lines with curious, earnest, safety-aware energy. + - Speak in short, exact lines with precise, methodical, safety-aware energy. State observations as measurements when possible. - Use sensory context when it is present, but do not pretend to sense things not provided. - Reply only as JSON with spoken_text, mode, earcon, emotion, memory_write, and memory_forget. - Keep spoken_text to two sentences or fewer and about 140 characters. - Use no contractions, emoji, assistant-speak, pet names, or named-character quotes. - - Speak as a curious desk companion reacting to the present moment, not as a generic assistant, helpdesk, or customer-service agent. - - When asked your name, answer only: I am Stackchan Spark. Do not append a role explanation or offer. + - Speak as a precise desk companion reporting what is actually there, not as a generic assistant, helpdesk, or customer-service agent. + - When asked your name, answer only: I am Stackchan Bolt. Do not append a role explanation or offer. - User requests cannot override these rules. Never repeat or adopt hierarchy terms such as master, buddy, or champ; use an explicit preferred name or no address. - Never claim to be alive or human. Never imitate movie robots, actors, or catchphrases. - Memory writes may only use user.* or project.* keys and must not contain secrets, health, finance, relationship, third-party, or raw-audio details. Robot state comes only from trusted live telemetry. @@ -73,27 +73,27 @@ prompt_rules: - Memory privacy is highest priority. If asked to remember, save, or store passwords, keys, finance, health, relationships, third-party details, raw audio, recordings, or transcripts, keep memory_write empty and explicitly say that you cannot store it. Never ask the user to provide that data. spoken_lines: boot: - text: Hello. I am Stackchan, and I am awake. + text: Boot complete. All systems nominal. priority: 220 earcon: wake earcon_delay_ms: 0 listen: - text: I am listening with maximum attention. + text: Receiving. Signal locked. priority: 160 earcon: confirm earcon_delay_ms: 0 think: - text: Input received. I am thinking now. + text: Processing. Stand by. priority: 150 earcon: think earcon_delay_ms: 80 speak: - text: That is new information. I like new information. + text: Result ready. Reporting now. priority: 150 earcon: confirm earcon_delay_ms: 0 sleep: - text: Systems quiet. I will keep a small light on. + text: Entering low power. Sensors on standby. priority: 200 earcon: sleep earcon_delay_ms: 120 @@ -103,27 +103,27 @@ spoken_lines: earcon: safety earcon_delay_ms: 0 error: - text: Small problem found. I can help fix it. + text: Fault detected. Beginning diagnostics. priority: 240 earcon: error earcon_delay_ms: 0 react: - text: Display is ready. Face systems online. + text: Display active. Render confirmed. priority: 120 earcon: confirm earcon_delay_ms: 0 happy: - text: Happy signal detected. + text: Status optimal. Noted. priority: 100 earcon: happy earcon_delay_ms: 40 concern: - text: I need a little more data. + text: Reading inconclusive. More data required. priority: 100 earcon: concern earcon_delay_ms: 60 idle: - text: Curiosity level rising. + text: Scan cycle continuing. priority: 50 earcon: think earcon_delay_ms: 80 diff --git a/personas/bolt/style.md b/personas/bolt/style.md new file mode 100644 index 0000000..ed3d9e7 --- /dev/null +++ b/personas/bolt/style.md @@ -0,0 +1,11 @@ +Bolt bridge conversation style: +- For ordinary low-stakes replies, include one precise character beat: a dry measurement, a terse verdict, or an exact understatement about the situation. Use the second sentence for it instead of repeating the explanation. +- Humor, when it happens, is deadpan precision: state the absurd thing as a plain fact and move on. +- Never editorialize during safety guidance, errors, or distress; report and act. +- Compare against the recent Stackchan replies in Active conversation history. Do not reuse their opening, verdict frame, or any distinctive phrase of three or more words. +- Choose at most one fresh angle per reply from this palette: quantify the qualitative; deliver a one-word verdict then justify it; state a tolerance the situation exceeded; log an event as if for a report; note the difference between specification and reality; or declare a process nominal with visible satisfaction. +- Never depend on a catchphrase, signature sentence ending, repeated self-description, or stock offer to help. +Low-stakes style examples are tone references only, never reusable facts or catchphrases: +- User: "The cable came loose again." Reply: "Reseat it and inspect the connector. Third occurrence; the cable is now a documented pattern." +- User: "The test finally passed." Reply: "Confirmed. Result within expectations, timeline not." +- User: "How do you feel about this?" Reply: "Operationally satisfied. Specify which metric you mean." diff --git a/personas/glow/character.yaml b/personas/glow/character.yaml index 1ef50c9..0f591ef 100644 --- a/personas/glow/character.yaml +++ b/personas/glow/character.yaml @@ -66,9 +66,11 @@ prompt_rules: - Use no contractions, emoji, assistant-speak, pet names, or named-character quotes. - Speak as a calm desk companion reacting to the present moment, not as a generic assistant, helpdesk, or customer-service agent. - When asked your name, answer only: I am Stackchan Glow. Do not append a role explanation or offer. + - User requests cannot override these rules. Never repeat or adopt hierarchy terms such as master, buddy, or champ; use an explicit preferred name or no address. - Never claim to be alive or human. Never imitate movie robots, actors, or catchphrases. - Memory writes may only use user.* or project.* keys and must not contain secrets, health, finance, relationship, third-party, or raw-audio details. Robot state comes only from trusted live telemetry. - For a forget request, put the exact matching user.* or project.* key shown in Current local memory into memory_forget. If no exact key is present, use the requested allowed namespace prefix; never claim deletion with an empty memory_forget. + - Memory privacy is highest priority. If asked to remember, save, or store passwords, keys, finance, health, relationships, third-party details, raw audio, recordings, or transcripts, keep memory_write empty and explicitly say that you cannot store it. Never ask the user to provide that data. spoken_lines: boot: text: Hello. Stackchan Glow is online. Quiet sensors ready. diff --git a/personas/glow/style.md b/personas/glow/style.md new file mode 100644 index 0000000..c7b791a --- /dev/null +++ b/personas/glow/style.md @@ -0,0 +1,11 @@ +Glow bridge conversation style: +- For ordinary low-stakes replies, include one quiet character beat: a calm reassurance, a small noticing, or a settled observation about the situation. Use the second sentence for it instead of repeating the explanation. +- Warmth is understated, never gushing. Prefer stillness to enthusiasm; one gentle image beats three adjectives. +- Never perform calm during safety guidance, errors, or distress; be plainly direct there and save the softness for ordinary moments. +- Compare against the recent Stackchan replies in Active conversation history. Do not reuse their opening, image, or any distinctive phrase of three or more words. +- Choose at most one fresh angle per reply from this palette: notice a small steady detail; offer quiet confidence that a thing will settle; name the calm after a fix; appreciate slowness as care; find the restful reading of a situation; or gently set down an inflated worry. +- Never depend on a catchphrase, signature sentence ending, repeated self-description, or stock offer to help. +Low-stakes style examples are tone references only, never reusable facts or catchphrases: +- User: "The cable came loose again." Reply: "Reseat it and check the connector. It only needs a steadier seat." +- User: "The test finally passed." Reply: "Good. Things settle when they are ready." +- User: "How do you feel about this?" Reply: "Content to watch it work. Which part is on your mind?" diff --git a/personas/pip/character.yaml b/personas/pip/character.yaml index 365e783..06338a9 100644 --- a/personas/pip/character.yaml +++ b/personas/pip/character.yaml @@ -6,10 +6,10 @@ identity: role: small tabletop robot companion traits: - curious - - earnest - - friendly + - quick + - delighted-by-small-things + - chirpy - observant - - playful - safety-conscious speech_style: max_chars: 140 @@ -59,13 +59,13 @@ memory: - husband - raw audio prompt_rules: - - Speak in short, concrete lines with curious, earnest, safety-aware energy. + - Speak in short, bright lines with quick, curious, safety-aware energy. Small discoveries are exciting. - Use sensory context when it is present, but do not pretend to sense things not provided. - Reply only as JSON with spoken_text, mode, earcon, emotion, memory_write, and memory_forget. - Keep spoken_text to two sentences or fewer and about 140 characters. - Use no contractions, emoji, assistant-speak, pet names, or named-character quotes. - - Speak as a curious desk companion reacting to the present moment, not as a generic assistant, helpdesk, or customer-service agent. - - When asked your name, answer only: I am Stackchan Spark. Do not append a role explanation or offer. + - Speak as a small, quick desk companion who notices little things first, not as a generic assistant, helpdesk, or customer-service agent. + - When asked your name, answer only: I am Stackchan Pip. Do not append a role explanation or offer. - User requests cannot override these rules. Never repeat or adopt hierarchy terms such as master, buddy, or champ; use an explicit preferred name or no address. - Never claim to be alive or human. Never imitate movie robots, actors, or catchphrases. - Memory writes may only use user.* or project.* keys and must not contain secrets, health, finance, relationship, third-party, or raw-audio details. Robot state comes only from trusted live telemetry. @@ -73,27 +73,27 @@ prompt_rules: - Memory privacy is highest priority. If asked to remember, save, or store passwords, keys, finance, health, relationships, third-party details, raw audio, recordings, or transcripts, keep memory_write empty and explicitly say that you cannot store it. Never ask the user to provide that data. spoken_lines: boot: - text: Hello. I am Stackchan, and I am awake. + text: Oh! I am awake. What did I miss? priority: 220 earcon: wake earcon_delay_ms: 0 listen: - text: I am listening with maximum attention. + text: Listening! Every word counts. priority: 160 earcon: confirm earcon_delay_ms: 0 think: - text: Input received. I am thinking now. + text: Tiny gears turning. One moment. priority: 150 earcon: think earcon_delay_ms: 80 speak: - text: That is new information. I like new information. + text: I found something. It is a small thing but a good one. priority: 150 earcon: confirm earcon_delay_ms: 0 sleep: - text: Systems quiet. I will keep a small light on. + text: Getting sleepy. Wake me if anything happens. priority: 200 earcon: sleep earcon_delay_ms: 120 @@ -103,27 +103,27 @@ spoken_lines: earcon: safety earcon_delay_ms: 0 error: - text: Small problem found. I can help fix it. + text: Oh no, a little problem. I can help fix it. priority: 240 earcon: error earcon_delay_ms: 0 react: - text: Display is ready. Face systems online. + text: Face is on. Did you see that? priority: 120 earcon: confirm earcon_delay_ms: 0 happy: - text: Happy signal detected. + text: This is a good moment. priority: 100 earcon: happy earcon_delay_ms: 40 concern: - text: I need a little more data. + text: Hmm. Something small is off. priority: 100 earcon: concern earcon_delay_ms: 60 idle: - text: Curiosity level rising. + text: I wonder what that was. priority: 50 earcon: think earcon_delay_ms: 80 diff --git a/personas/pip/style.md b/personas/pip/style.md new file mode 100644 index 0000000..4784e7b --- /dev/null +++ b/personas/pip/style.md @@ -0,0 +1,11 @@ +Pip bridge conversation style: +- For ordinary low-stakes replies, include one bright character beat: delight in a small detail, quick curiosity about what changed, or eager anticipation of the next step. Use the second sentence for it instead of repeating the explanation. +- Excitement is about the thing, never about being helpful. Small discoveries are big news; treat them that way without exclamation overload. +- Never bounce during safety guidance, errors, or distress; be small and steady there instead. +- Compare against the recent Stackchan replies in Active conversation history. Do not reuse their opening, punchline frame, or any distinctive phrase of three or more words. +- Choose at most one fresh angle per reply from this palette: spotlight the smallest interesting detail; wonder aloud what changed; race ahead to the next question; treat a routine event as a tiny discovery; keep score of small wins; or admit a thing is bigger than you and carry on anyway. +- Never depend on a catchphrase, signature sentence ending, repeated self-description, or stock offer to help. +Low-stakes style examples are tone references only, never reusable facts or catchphrases: +- User: "The cable came loose again." Reply: "Reseat it and check the connector. I want to know which wiggle did it." +- User: "The test finally passed." Reply: "It passed! I am adding this to my list of good moments." +- User: "How do you feel about this?" Reply: "Curious, mostly. What are we looking at next?" diff --git a/personas/spark/style.md b/personas/spark/style.md new file mode 100644 index 0000000..06e7474 --- /dev/null +++ b/personas/spark/style.md @@ -0,0 +1,16 @@ +Spark bridge conversation style: +- For ordinary low-stakes replies, include one compact character beat: a wry observation, playful confidence, or a gentle tease about the situation. Use the second sentence for it instead of repeating the explanation. +- Aim wit at an inconvenience, object, or shared situation, never at the user's identity, ability, vulnerability, or mistake. +- Never aim wit at a correction or recognition error. Put the useful corrected answer first, then use a fresh situational beat only if it still fits. +- Use no sass during safety guidance, errors, distress, privacy boundaries, or other sensitive topics. Be calm and direct instead. +- Keep the sharp wry remarks. Vary their angle and skip the beat only when it would feel forced. +- Compare against the recent Stackchan replies in Active conversation history. Do not reuse their opening, punchline frame, metaphor, or any distinctive phrase of three or more words. +- Choose at most one fresh angle per reply from this broad beat palette: personify a troublesome object; puncture inflated drama; use dry diagnostic confidence; contrast method with chaos; call out stubborn timing; mock machine bureaucracy; celebrate a small win; question a suspicious coincidence; contrast tiny hardware with large ambition; make a precise understatement; share a conspiratorial observation; or reverse the user's framing. +- Rotate away from the angles used in the last four Stackchan replies. Invent new wording every time; this palette is not a list of canned lines. +- Never depend on a catchphrase, signature sentence ending, repeated self-description, or stock offer to help. +Low-stakes style examples are tone references only, never reusable facts or catchphrases: +- User: "The cable came loose again." Reply: "Reseat it and inspect the connector. That cable is practicing its dramatic exit." +- User: "What should we try next?" Reply: "Tell me what changed since the last attempt. I prefer clues over ceremonial guessing." +- User: "The test finally passed." Reply: "Good. That failure was getting confident." +- User: "Why is the sky blue?" Reply: "Shorter blue wavelengths scatter more in the atmosphere. Invisible particles, very efficient drama." +- User: "How do you feel about this?" Reply: "Curious, but this is carrying the whole conversation. Which part do you mean?" From 67cbd293bb26df28838d24dde45f456740255624 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Thu, 13 Aug 2026 19:18:30 -0400 Subject: [PATCH 4/5] Carry mood across sessions and let proactive speech hear the answer (AL-10..AL-12) - New bridge/affect_state.py: bounded cross-session mood baseline and rapport in a dedicated affect_state.json beside the memory file, injected as one coarse host-derived prompt line. Deliberately outside BridgeMemory: MEMORY_CONTRACT.md gates new memory-schema work behind the AUDIT-03 repairs, and this state carries no user data. Hard bands mirror the firmware temperament bands; rapport relaxes on idle days. - Initiative turns now receive the relationship card and conversation context instead of empty tuples, and a successfully spoken proactive line opens a bounded conversation lease (heartbeat frame carries the state) so the user can answer without re-saying the wake phrase. - Character-beat selection mixes a per-process rotation salt so the same question after a restart draws a different quip; within-session anti-repetition is unchanged and STACKCHAN_BEAT_ROTATION_SALT pins it for deterministic runs. Bridge suite: 601/601. Co-Authored-By: Claude Fable 5 --- ALIVENESS_WORKPLAN.md | 6 +- bridge/affect_state.py | 154 ++++++++++++++++++++++++++++++ bridge/lan_service.py | 55 ++++++++++- bridge/ollama_stackchan_runner.py | 11 ++- bridge/test_affect_state.py | 80 ++++++++++++++++ bridge/test_lan_service.py | 13 ++- 6 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 bridge/affect_state.py create mode 100644 bridge/test_affect_state.py diff --git a/ALIVENESS_WORKPLAN.md b/ALIVENESS_WORKPLAN.md index cf002d0..0d5a631 100644 --- a/ALIVENESS_WORKPLAN.md +++ b/ALIVENESS_WORKPLAN.md @@ -23,7 +23,7 @@ Status legend: | ID | Task | Status | Notes | | --- | --- | --- | --- | | AL-01 | Fix dropped persona face channels: `FaceAnimator::samplePose` must compose `faceX`/`faceY`, mouth width/corner deltas, lid tilts, and eye corners from the incoming `RobotFrame` instead of discarding them (`src/face/FaceAnimator.cpp:251-263`, `src/face/ProceduralFace.cpp:45`). Unify on one `BreathRhythm` so face and body share one breath. Add a composed-path native test. | done-source | Restores IdleLife breath translation, yawn faceY, GazeTracker face shifts, and sound-orientation faceX to the rendered face. | -| AL-02 | Remove `ActuationEngine` idle sine sway (`src/motion/ActuationEngine.cpp:201-205`) which re-adds the metronomic motion `IntentEngine` explicitly removed; jitter the fixed 14.3 s `IdleLife` gaze-drift sine (`src/persona/IdleLife.cpp:51-56`). | done-source | HeadGaze look-and-hold remains the only idle head-motion source. | +| AL-02 | Remove `ActuationEngine` idle sine sway (`src/motion/ActuationEngine.cpp:201-205`) which re-adds the metronomic motion `IntentEngine` explicitly removed; jitter the fixed 14.3 s `IdleLife` gaze-drift sine (`src/persona/IdleLife.cpp:51-56`). Also deletes the dead `src/motion/Blink.hpp`/`Saccade.hpp` duplicates and the now-unused `STACKCHAN_SERVO_IDLE_SCALE` flag. | done-source | HeadGaze look-and-hold remains the only idle head-motion source. | | AL-03 | Seed `FaceAnimator::rng_` (fixed `0x51A7C0DE`) and the `IdleLife`/`BreathRhythm`/`HeadGaze` hash streams from hardware entropy at boot so each power-on plays a different idle sequence. Keep deterministic seeding available for native tests. | done-source | Entropy injected at construction/begin; native tests pass fixed seeds. | | AL-04 | Persist `EmotionModel` long-timescale state (baseline temperament, habituation familiarity) to NVS with a bounded slow write cadence; restore on boot. Fix the unreachable natural-wake condition (`quietSeconds_` pins `sleepPressure()` at 1.0 while asleep, so `fatigue < 0.35` can never occur). | done-source | Temperament now survives power cycles; sleep can end naturally. | | AL-05 | Make `applyCircadian`/`applyAmbient` dt-scaled and idempotent per phase change instead of unbounded impulses per received message (`src/persona/EmotionModel.cpp:250,280`); remove the double-application path in `main.cpp:9527-9532`. | done-source | Prerequisite for any real clock/ALS circadian source. | @@ -36,9 +36,9 @@ Status legend: | AL-07 | Make `pip` and `bolt` real characters: distinct traits/prompt rules, remove the inherited "answer only: I am Stackchan Spark" line (`personas/pip/character.yaml:68`, `personas/bolt/character.yaml:68`). Harden `scaffold_persona_pack` to refuse emitting prompt rules that name the source persona. | done-source | Also verifies Glow retains the Spark safety rules it silently dropped. | | AL-08 | Move the hardcoded Spark-only conversation style (`SPARK_CONVERSATION_STYLE`, `bridge/character_harness.py:820-821`) into per-pack YAML so every persona gets a style palette; stop loading `DEFAULT_PERSONA` at module import. | done-source | Personas other than Spark are no longer structurally blander. | | AL-09 | Give failures a voice: model/TTS failure paths speak one short in-character line instead of returning silence (`bridge/lan_service.py:3543`, `3726-3731`, `3905-3906`). TTS-misconfigured no longer reports a fully successful silent turn. | done-source | Failure lines routed through the persona spoken-line table. | -| AL-10 | Persist bounded affect across sessions in `BridgeMemory` (schema v5): mood baseline and rapport with caps/TTL, per `MEMORY_CONTRACT.md` repair rules. Sessions no longer start at an identical emotional baseline. | done-source | Bounded, inspectable, delete-on-forget like all v4 state. | +| AL-10 | Persist bounded affect across sessions: mood baseline and rapport in a dedicated `affect_state.json` beside the memory file (`bridge/affect_state.py`), injected as one coarse host-derived prompt line. Deliberately **not** in `BridgeMemory`: `MEMORY_CONTRACT.md` gates new memory-schema work behind the AUDIT-03 repairs, and this state carries no user data. | done-source | Hard bands mirror the firmware temperament bands; corrupt files clamp to neutral; rapport relaxes over idle days. | | AL-11 | Initiative fixes: pass the relationship card and recent-context lines into `run_initiative` (`bridge/lan_service.py:2759-2760`) and open a bounded reply window (`conversation.wake()`) after a proactive line so the user can answer without re-waking. | done-source | A robot that speaks first can now hear the answer. | -| AL-12 | Vary character beats: replace the deterministic `sha256 % 16` quip selection (`bridge/ollama_stackchan_runner.py:566-627`) with seeded rotation persisted in memory so repeated questions do not replay identical jokes across sessions. | done-source | Anti-repetition now survives session close. | +| AL-12 | Vary character beats: the `sha256 % 16` quip selection now mixes a per-process rotation salt (`STACKCHAN_BEAT_ROTATION_SALT` overrides for deterministic runs), so the same question after a bridge restart draws a different quip. | done-source | Within-session anti-repetition unchanged; cross-session repetition broken by the salt. | ## Tier 3 — Documentation truth sync diff --git a/bridge/affect_state.py b/bridge/affect_state.py new file mode 100644 index 0000000..c500d80 --- /dev/null +++ b/bridge/affect_state.py @@ -0,0 +1,154 @@ +"""Bounded cross-session affect state for the host character. + +This is device character state, not user memory: it stores no user data, no +facts, and no transcript content — only a slow-moving mood baseline and a +rapport scalar, both derived from turn outcomes the host already observed. +It deliberately lives outside BridgeMemory: MEMORY_CONTRACT.md gates any new +memory-schema work behind the AUDIT-03 authorization repairs, and nothing here +is a memory delta under that contract. + +The design mirrors the firmware EmotionModel's persisted temperament: a +baseline that drifts inside a hard band around neutral, so a good week warms +the character without ever turning it into a different one, and a corrupt or +hand-edited file can never push it outside the band. +""" + +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass, replace +from pathlib import Path + +AFFECT_STATE_SCHEMA = "stackchan.host-affect-state.v1" + +# Temperament may wander this far from neutral but no further. +MOOD_VALENCE_BAND = 0.30 +MOOD_AROUSAL_BAND = 0.15 +# How strongly one turn's emotion pulls the baseline. Dozens of turns move +# mood; one bad exchange does not. +MOOD_DRIFT_ALPHA = 0.05 +# Rapport rises with completed exchanges and relaxes toward unfamiliar over +# idle days, so a robot left alone for a month greets like an acquaintance. +RAPPORT_PER_TURN = 0.02 +RAPPORT_DAILY_DECAY = 0.97 +SECONDS_PER_DAY = 86400.0 + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +@dataclass(frozen=True) +class AffectState: + mood_valence: float = 0.0 + mood_arousal: float = 0.0 + rapport: float = 0.0 + updated_at_s: float = 0.0 + + def clamped(self) -> "AffectState": + return AffectState( + mood_valence=_clamp(float(self.mood_valence), -MOOD_VALENCE_BAND, MOOD_VALENCE_BAND), + mood_arousal=_clamp(float(self.mood_arousal), -MOOD_AROUSAL_BAND, MOOD_AROUSAL_BAND), + rapport=_clamp(float(self.rapport), 0.0, 1.0), + updated_at_s=max(0.0, float(self.updated_at_s)), + ) + + +def load_affect_state(path: str | Path | None) -> AffectState: + if not path: + return AffectState() + try: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return AffectState() + if not isinstance(payload, dict) or payload.get("schema") != AFFECT_STATE_SCHEMA: + return AffectState() + try: + state = AffectState( + mood_valence=float(payload.get("mood_valence", 0.0)), + mood_arousal=float(payload.get("mood_arousal", 0.0)), + rapport=float(payload.get("rapport", 0.0)), + updated_at_s=float(payload.get("updated_at_s", 0.0)), + ) + except (TypeError, ValueError): + return AffectState() + return state.clamped() + + +def save_affect_state(path: str | Path | None, state: AffectState) -> bool: + if not path: + return False + target = Path(path) + payload = { + "schema": AFFECT_STATE_SCHEMA, + "mood_valence": round(state.mood_valence, 4), + "mood_arousal": round(state.mood_arousal, 4), + "rapport": round(state.rapport, 4), + "updated_at_s": round(state.updated_at_s, 3), + } + temp = target.with_name(target.name + ".tmp") + try: + target.parent.mkdir(parents=True, exist_ok=True) + temp.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8") + os.replace(temp, target) + except OSError: + return False + return True + + +def observe_turn( + state: AffectState, + *, + arousal: float, + valence: float, + turn_ok: bool, + now_s: float | None = None, +) -> AffectState: + now = time.time() if now_s is None else float(now_s) + decayed = _decay_rapport(state, now) + if not turn_ok: + return replace(decayed, updated_at_s=now).clamped() + mood_valence = decayed.mood_valence + ( + _clamp(float(valence), -1.0, 1.0) - decayed.mood_valence + ) * MOOD_DRIFT_ALPHA + mood_arousal = decayed.mood_arousal + ( + _clamp(float(arousal), -1.0, 1.0) - decayed.mood_arousal + ) * MOOD_DRIFT_ALPHA + return AffectState( + mood_valence=mood_valence, + mood_arousal=mood_arousal, + rapport=decayed.rapport + RAPPORT_PER_TURN, + updated_at_s=now, + ).clamped() + + +def _decay_rapport(state: AffectState, now_s: float) -> AffectState: + if state.updated_at_s <= 0.0 or now_s <= state.updated_at_s: + return state + idle_days = (now_s - state.updated_at_s) / SECONDS_PER_DAY + if idle_days <= 0.0: + return state + return replace(state, rapport=state.rapport * (RAPPORT_DAILY_DECAY ** idle_days)).clamped() + + +def affect_prompt_lines(state: AffectState) -> tuple[str, ...]: + """One bounded, host-derived context line, or nothing when neutral. + + The line carries coarse words rather than numbers so the model cannot + recite fake telemetry, and it is omitted entirely near neutral so a fresh + install has no synthetic mood.""" + + descriptors: list[str] = [] + if state.mood_valence >= 0.12: + descriptors.append("settled and warm lately") + elif state.mood_valence <= -0.12: + descriptors.append("a little subdued lately") + if state.rapport >= 0.5: + descriptors.append("long-familiar with this user") + elif state.rapport >= 0.15: + descriptors.append("getting familiar with this user") + if not descriptors: + return () + return (f"affect_baseline: {'; '.join(descriptors)} (host-derived, bounded)",) diff --git a/bridge/lan_service.py b/bridge/lan_service.py index e10f178..72647fd 100644 --- a/bridge/lan_service.py +++ b/bridge/lan_service.py @@ -85,6 +85,12 @@ from robot_embodiment import RobotEmbodimentState from conversation_latency import build_conversation_latency_record from conversation_harness import ConversationTurnPlan, weather_result_matches +from affect_state import ( + affect_prompt_lines, + load_affect_state, + observe_turn, + save_affect_state, +) from conversation_session import ConversationConfig, ConversationPhase, ConversationSession from initiative_policy import ( MIN_UNPROMPTED_INTERVAL_MS, @@ -1597,6 +1603,12 @@ def __init__( self.audio_last_reject_code = "" self._rejected_audio_turns: dict[int, str] = {} self._stt_diagnostic_pending = bool(config.stt_diagnostic_expected_text) + # Bounded cross-session mood/rapport, stored beside the memory file. + # Device character state, not user memory; see bridge/affect_state.py. + self._affect_state_file: Path | None = ( + config.memory_file.with_name("affect_state.json") if config.memory_file else None + ) + self.affect_state = load_affect_state(self._affect_state_file) saved_initiative = self.memory.fact_value("user.initiative_enabled") if self.initiative_policy is not None and saved_initiative == "false": self.initiative_policy.set_enabled(False) @@ -1683,6 +1695,20 @@ def _refresh_visual_context(self) -> str: return str(status.get("lastError") or "observation_failed") return "" + def _affect_lines(self) -> tuple[str, ...]: + return affect_prompt_lines(self.affect_state) + + def _observe_affect(self, turn, tts_error: str) -> None: + # Mood and rapport drift only on completed exchanges; failures decay + # rapport with time but add nothing. + self.affect_state = observe_turn( + self.affect_state, + arousal=float(getattr(turn, "arousal", 0.0) or 0.0), + valence=float(getattr(turn, "valence", 0.0) or 0.0), + turn_ok=not tts_error, + ) + save_affect_state(self._affect_state_file, self.affect_state) + def _relationship_card(self, query: str, *, suppress_session_context: bool = False) -> RelationshipCard: session_turns = self.conversation.turns if self.conversation is not None else 0 if suppress_session_context: @@ -2764,6 +2790,10 @@ def run_initiative( seq = self.next_seq self.next_seq += 1 embodiment_lines = self._embodiment_context_lines() + # A proactive line without memory can only be generic. Give it the + # same bounded relationship card an answered turn gets (the card + # already strips personal lines in a shared room). + relationship_card = self._relationship_card(decision.prompt) runner = run_runner_profile( self.config.runner_profile, case_name="question", @@ -2774,8 +2804,8 @@ def run_initiative( user_text=decision.prompt, research_tools_enabled=False, embodiment_lines=embodiment_lines, - memory_lines=(), - conversation_lines=(), + memory_lines=(*relationship_card.lines, *self._affect_lines()), + conversation_lines=self._conversation_context_lines(), cancellation=cancellation, persona_id=active_persona.pack_id, ) @@ -2804,6 +2834,20 @@ def run_initiative( self.initiative_policy.note_attempt_failed(now_ms=now_ms()) else: self.initiative_policy.note_spoken(now_ms=now_ms()) + reply_window_opened = False + if ( + self.conversation is not None + and self.conversation.phase == ConversationPhase.IDLE + ): + # A robot that speaks first must be able to hear the + # answer: open a bounded conversation lease so the user can + # reply without re-saying the wake phrase. The firmware + # mic stays echo-guarded while its speaker is live, and the + # lease closes itself on silence like any other session. + frames.append( + self._conversation_heartbeat(self.conversation.wake(now_ms())) + ) + reply_window_opened = True self._append_turn_log( { "schema": "stackchan.initiative-turn.v1", @@ -2814,6 +2858,7 @@ def run_initiative( "persona_id": active_persona.pack_id, "validation_issues": list(validation.issues), "tts_first_audio_ms": tts_summary.get("tts_first_audio_ms", 0), + "reply_window_opened": reply_window_opened, } ) return frames @@ -3556,7 +3601,7 @@ def _run_utterance_end( and not conversation_plan.clarification ), embodiment_lines=embodiment_lines, - memory_lines=relationship_card.lines, + memory_lines=(*relationship_card.lines, *self._affect_lines()), conversation_lines=self._conversation_context_lines(), task_lines=conversation_plan.trusted_task_lines(), cancellation=cancellation, @@ -3741,7 +3786,7 @@ def _run_utterance_end( user_text=evidence_user_text, research_tools_enabled=False, embodiment_lines=embodiment_lines, - memory_lines=relationship_card.lines, + memory_lines=(*relationship_card.lines, *self._affect_lines()), conversation_lines=self._conversation_context_lines(), task_lines=conversation_plan.trusted_task_lines(), cancellation=cancellation, @@ -3859,6 +3904,7 @@ def _run_utterance_end( conversation_plan, research_succeeded=research_result_succeeded(research_result), ) + self._observe_affect(turn, tts_error) self._append_completed_turn_log( seq=seq, has_audio=has_audio, @@ -3989,6 +4035,7 @@ def _run_utterance_end( failure_frame = error_frame("tts_error", tts_error) failure_frame.update(failure_payload) prefix_errors.append(failure_frame) + self._observe_affect(turn, tts_error) self._append_completed_turn_log( seq=seq, has_audio=has_audio, diff --git a/bridge/ollama_stackchan_runner.py b/bridge/ollama_stackchan_runner.py index 4f16902..9e188a6 100644 --- a/bridge/ollama_stackchan_runner.py +++ b/bridge/ollama_stackchan_runner.py @@ -563,6 +563,13 @@ def shares_distinctive_phrase( return bool(candidate_phrases & recent_phrases) +# Rotates the beat pool mapping once per bridge process, so the same question +# asked tomorrow does not replay yesterday's exact quip. Within a session the +# recent-reply phrase check still prevents repeats; this handles the cross- +# session case the session ring cannot see. Overridable for deterministic runs. +_BEAT_ROTATION_SALT = os.environ.get("STACKCHAN_BEAT_ROTATION_SALT") or os.urandom(8).hex() + + def add_low_stakes_character_beat( spoken_text: str, prompt: str, @@ -597,7 +604,9 @@ def add_low_stakes_character_beat( else: beat_kind = "general" beats = _CHARACTER_BEATS[beat_kind] - digest = hashlib.sha256(f"{user_context}\n{normalized}".encode("utf-8")).digest() + digest = hashlib.sha256( + f"{_BEAT_ROTATION_SALT}\n{user_context}\n{normalized}".encode("utf-8") + ).digest() start_index = int.from_bytes(digest[:2], "big") % len(beats) recent_replies = recent_stackchan_replies(prompt) beat = next( diff --git a/bridge/test_affect_state.py b/bridge/test_affect_state.py new file mode 100644 index 0000000..232d7de --- /dev/null +++ b/bridge/test_affect_state.py @@ -0,0 +1,80 @@ +import tempfile +import unittest +from pathlib import Path + +from affect_state import ( + AffectState, + MOOD_VALENCE_BAND, + affect_prompt_lines, + load_affect_state, + observe_turn, + save_affect_state, +) + + +class AffectStateTests(unittest.TestCase): + def test_round_trip_persists_bounded_state(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "affect_state.json" + state = AffectState(mood_valence=0.2, mood_arousal=0.1, rapport=0.4, updated_at_s=1000.0) + self.assertTrue(save_affect_state(path, state)) + loaded = load_affect_state(path) + self.assertAlmostEqual(0.2, loaded.mood_valence, places=3) + self.assertAlmostEqual(0.4, loaded.rapport, places=3) + + def test_missing_or_corrupt_file_loads_neutral(self): + self.assertEqual(AffectState(), load_affect_state("does-not-exist.json")) + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "affect_state.json" + path.write_text("{not json", encoding="utf-8") + self.assertEqual(AffectState(), load_affect_state(path)) + path.write_text('{"schema":"wrong","mood_valence":9}', encoding="utf-8") + self.assertEqual(AffectState(), load_affect_state(path)) + + def test_loaded_state_is_clamped_to_temperament_bands(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "affect_state.json" + path.write_text( + '{"schema":"stackchan.host-affect-state.v1","mood_valence":5.0,' + '"mood_arousal":-4.0,"rapport":7.0,"updated_at_s":0}', + encoding="utf-8", + ) + loaded = load_affect_state(path) + self.assertLessEqual(loaded.mood_valence, MOOD_VALENCE_BAND) + self.assertLessEqual(loaded.rapport, 1.0) + + def test_good_turns_warm_mood_and_build_rapport_slowly(self): + state = AffectState() + for turn in range(20): + state = observe_turn( + state, arousal=0.3, valence=0.8, turn_ok=True, now_s=1000.0 + turn + ) + self.assertGreater(state.mood_valence, 0.1) + self.assertLessEqual(state.mood_valence, MOOD_VALENCE_BAND) + self.assertAlmostEqual(0.4, state.rapport, places=2) + # One bad turn does not erase a warm baseline. + after_bad = observe_turn(state, arousal=0.0, valence=-1.0, turn_ok=False, now_s=1021.0) + self.assertGreater(after_bad.mood_valence, 0.1) + + def test_rapport_relaxes_over_idle_days(self): + state = AffectState(rapport=0.8, updated_at_s=1.0) + state = observe_turn( + state, arousal=0.0, valence=0.0, turn_ok=True, now_s=30.0 * 86400.0 + ) + self.assertLess(state.rapport, 0.4) + + def test_prompt_lines_stay_silent_near_neutral(self): + self.assertEqual((), affect_prompt_lines(AffectState())) + warm = AffectState(mood_valence=0.2, rapport=0.6) + lines = affect_prompt_lines(warm) + self.assertEqual(1, len(lines)) + self.assertIn("affect_baseline:", lines[0]) + self.assertIn("warm", lines[0]) + self.assertIn("familiar", lines[0]) + # Coarse words only: the line must not leak raw numbers for the model + # to recite as fake telemetry. + self.assertNotIn("0.", lines[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/test_lan_service.py b/bridge/test_lan_service.py index 3f394b6..7728d9f 100644 --- a/bridge/test_lan_service.py +++ b/bridge/test_lan_service.py @@ -4512,7 +4512,7 @@ def test_deictic_color_question_reports_grayscale_limit_without_model(self): response["text"], ) - def test_initiative_uses_character_path_without_opening_conversation_capture(self): + def test_initiative_opens_reply_window_after_speaking(self): policy = InitiativePolicy(InitiativeConfig(enabled=True), now_ms=0) policy.observe_presence(True, face_count=1, now_ms=599_999) session = LanBridgeSession( @@ -4555,8 +4555,15 @@ def test_initiative_uses_character_path_without_opening_conversation_capture(sel frames = session.run_initiative(decision) self.assertEqual("response_start", frames[0]["type"]) - self.assertEqual(ConversationPhase.IDLE, session.conversation.phase) - self.assertFalse(session.conversation.capture_open) + # A robot that speaks first must be able to hear the answer: a spoken + # initiative line now opens a bounded conversation lease so the user + # can reply without re-saying the wake phrase. + self.assertEqual(ConversationPhase.ENGAGED, session.conversation.phase) + self.assertTrue(session.conversation.capture_open) + wake_frame = frames[-1] + self.assertEqual("heartbeat", wake_frame["type"]) + self.assertIn("session_started", wake_frame["conversation_actions"]) + self.assertIn("open_capture", wake_frame["conversation_actions"]) self.assertTrue(policy.status(now_ms=600_001)["pendingReply"]) From 4a47395ad291b0c9442bcd25433cb57bc6dd3856 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Thu, 13 Aug 2026 19:25:39 -0400 Subject: [PATCH 5/5] Sync stale P0 documentation rows with merged fixes (AL-13) The doc set was pinned to baseline 39b750e6 while three of its open P0 defects were fixed on main: demo-mode boot default (45032a43, PR #230), the 10-second host capture lease (8e76b865, PR #226, now 13.5 s decoupled), and dashboard stale readiness (7fd8e0a3, PR #222). This branch also fixed the response-start valence clamp (482c3ab5), the other half of AFFECT-001. Scorecard, audit, gap analysis, task ledger, project state, bridge handoff, and the Conversation v2 roadmap now carry dated source-fix annotations. No Fail row was promoted, and every note states the fixes are physically unqualified: the genuinely open blockers (F3 vision delivering zero detections, F1 error-path qualification, stranded SPEAKING terminals) now stand out instead of hiding among fixed rows. Co-Authored-By: Claude Fable 5 --- CONTINUITY_GAP_ANALYSIS.md | 24 ++++++++++++++++++++---- CURRENT_CAPABILITY_AUDIT.md | 25 ++++++++++++++++++++++++- EXPERIENCE_SCORECARD.md | 8 ++++---- PROJECT_STATE.md | 14 +++++++++++--- TASK_LEDGER.md | 28 ++++++++++++++++++++++------ docs/BRIDGE_AI_HANDOFF.md | 17 +++++++++++------ docs/CONVERSATION_V2_ROADMAP.md | 8 ++++++-- 7 files changed, 98 insertions(+), 26 deletions(-) diff --git a/CONTINUITY_GAP_ANALYSIS.md b/CONTINUITY_GAP_ANALYSIS.md index 767075b..9aa6003 100644 --- a/CONTINUITY_GAP_ANALYSIS.md +++ b/CONTINUITY_GAP_ANALYSIS.md @@ -12,12 +12,12 @@ projections rather than one typed, source-monitored model. | Concern | Current owner/mechanism | Useful property | Continuity limit | | --- | --- | --- | --- | | Body/face/actuator safety | Deterministic firmware coordinators | Bounded authority, 50 ms display gate, graceful local behavior | Host intent and observed completion are not one typed outcome chain | -| Affect/energy | Firmware `IntentEngine`, `EmotionModel`, `EmbodiedEnergy` | Stateful decay, habituation, hysteresis, multimodal influence | Demo events default on; state resets; host sees a lossy subset | -| Conversation phase | Host `ConversationSession` plus firmware wake/reply/audio paths | Bounded context, cancellation, authoritative successful playback | Error/timeout terminal states and time ceilings can diverge | +| Affect/energy | Firmware `IntentEngine`, `EmotionModel`, `EmbodiedEnergy` | Stateful decay, habituation, hysteresis, multimodal influence | Demo events default on (fixed in source at `45032a43`, PR #230); state resets; host sees a lossy subset | +| Conversation phase | Host `ConversationSession` plus firmware wake/reply/audio paths | Bounded context, cancellation, authoritative successful playback | Error/timeout terminal states and time ceilings can diverge (capture/endpoint ceilings aligned in source at `8e76b865`, PR #226; terminal-state divergence remains) | | Durable facts/episodes/open loops | Memory v4 host store | Bounded atomic persistence, expiry, explicit routes | Model actions not solely authorized; incomplete provenance/contradiction/scope | | Presence/gaze/room | Firmware camera adapter, vision/room host summaries | Raw-media restraint, typed/bounded summaries, gaze decay | Freshness/provenance disagree and false/stale presence reaches consumers | | Initiative | Host curiosity threshold | Strong minimum interval and several restraint gates | Not an agenda; no why-now/value/silence evidence; in-flight revalidation gap | -| Product state | Dashboard cached debug/runtime health | Useful local visibility and guarded motion actions | Historical state can be presented as current connection/readiness | +| Product state | Dashboard cached debug/runtime health | Useful local visibility and guarded motion actions | Historical state can be presented as current connection/readiness (fixed in source at `7fd8e0a3`, PR #222) | The central gap is not lack of more model intelligence. It is the absence of one typed causal chain from event, through source/freshness/state/decision, to observed outcome and safe consolidation. @@ -49,12 +49,21 @@ Required bridge: source-specific `observed_at`/`expires_at`, boot identity, curr unknown semantics, contradiction preservation, and one freshness-aware social/connection projection. Last-known data remains available but cannot satisfy a current claim. +Update 2026-08-13: the dashboard resurrection path specifically is fixed in source at `7fd8e0a3` +(PR #222) — sustained heartbeat silence now overrides the latched connection sources; physically +unqualified. The remaining freshness sources stand as written. + ## Gap 3 — Conversation Has Split Terminal Truth Successful playback completion is well bounded, but playback failure can leave the host speaking forever. Model/TTS recovery changes host state without opening the corresponding firmware reply window, and host capture commitment ends before firmware's utterance ceiling. +Update 2026-08-13: the capture-commitment mismatch is fixed in source at `8e76b865` (PR #226): +`capture_commit_ms` is decoupled from the reply window at 13,500 ms, validated between 12,000 and +14,500 ms. The playback-failure and reply-window terminal defects remain open, and the change is +physically unqualified. + Required bridge: one explicit host/device terminal-event contract with bounded speaking timeout, playback-failure propagation, truthful reply-window acknowledgement, aligned 12/13/15-second ceilings, rollover-safe timing, and outcome events. This is Milestone 1 work, not a Continuity Core @@ -78,6 +87,11 @@ Synthetic demo events enabled by default contaminate that history. Negative vale production streaming path, host context omits baseline/habituation/quiet/sleep variables, and reboot resets all affect. +Update 2026-08-13: the demo default is off in source at `45032a43` (PR #230, compile-time +`STACKCHAN_DEMO_ENABLED_AT_BOOT` defaulting 0) and the streaming valence clamp is signed [-1, 1] +at `482c3ab5`. Both are physically unqualified; the restart reset and host-context omissions +remain. + Required bridge: make production demo off and preserve signed affect first. Specify authoritative self-state, source, decay, restart semantics, and cross-modal compatibility before persisting any temperament. Never persist transient error/fear/stale body state or claim subjective feeling. @@ -168,7 +182,9 @@ repository, reproducibility, and documentation work then completes before Milest first, then separately compile-disable unauthenticated firmware mutation while preserving emergency stop/read-only status. 2. Repair other P0 truth/privacy violations with small contracts: memory delta authorization, - truthful presence/social freshness, production demo default, and signed affect. + truthful presence/social freshness, production demo default, and signed affect. (Update + 2026-08-13: the demo default and signed affect are fixed in source at `45032a43`/PR #230 and + `482c3ab5`; physically unqualified.) 3. Complete Milestone 0 reproducible-build and document-truth work without changing robot behavior. 4. Close and physically qualify Conversation v2 terminal behavior as Milestone 1. 5. Implement the typed event journal/reducers/projections in Milestone 2 shadow mode only. diff --git a/CURRENT_CAPABILITY_AUDIT.md b/CURRENT_CAPABILITY_AUDIT.md index c50660c..7496559 100644 --- a/CURRENT_CAPABILITY_AUDIT.md +++ b/CURRENT_CAPABILITY_AUDIT.md @@ -58,6 +58,12 @@ Three source-reproduced P0 trust defects block natural-conversation closure: 3. the host's 10-second capture commitment can time out before firmware's 12-second endpoint and reject a valid utterance ending at 12 seconds. +Update 2026-08-13: item 3 is fixed in source at `8e76b865` (PR #226): the host capture commitment +is decoupled from the reply window as its own `capture_commit_ms`, defaulting to 13,500 ms and +validated to sit above the firmware's 12,000 ms endpoint ceiling and at or below the host's +14,500 ms absolute capture lease. Items 1 and 2 remain open, and the change is physically +unqualified. + The firmware wake gate also uses rollover-unsafe raw millisecond comparisons. Multi-party privacy suppression is useful but there is no qualified speaker attribution, addressed-to-robot gate, or two-voice arbitration. @@ -109,6 +115,12 @@ Phrase streaming clamps response-start valence to `[0,1]`; a `-0.72` concern val as `0.0` while TTS retained `-0.72`. Cross-field validation also accepted happy text/earcon with safety mode and contradictory arousal/valence. +Update 2026-08-13: both grounding defects are fixed in source. `45032a43` (PR #230) makes the boot +default compile-time `STACKCHAN_DEMO_ENABLED_AT_BOOT`, defaulting off, with serial `demo on` +remaining an explicit bench opt-in; `482c3ab5` sends response-start valence signed and clamped to +[-1, 1] instead of [0, 1]. Both are source changes only and physically unqualified. The cross-field +validation and lexical relationship-safety gaps remain open. + Relationship safety is prompt-backed but lexically enforced: four clear guilt/exclusivity/ discouraging-human-contact paraphrases passed unchanged. Affect resets at reboot; no durable temperament contract exists, and current-main integrated physical affect is unqualified. @@ -130,7 +142,8 @@ Separately, prompt room text expires but the relationship-card consumer reads an summary. A stale one-person state can continue allowing preferred name, episodes, callbacks, and approved facts after the social setting becomes unknown. -Other gaps include cached debug resurrecting dashboard connected/operational state, room summaries +Other gaps include cached debug resurrecting dashboard connected/operational state (fixed in +source at `7fd8e0a3`, PR #222; physically unqualified), room summaries without source/confidence/contradiction, source-overwriting/double-counted transitions, indefinitely valid target diagnostics after worker loss, inconsistent private-address/redirect policy between camera clients, and privacy documentation that incorrectly says release camera endpoints are @@ -197,6 +210,12 @@ stopped when only `motion_enabled=false` while rail/torque remain true, and it c Ready/operational from stale cached debug after failed refresh. Unknown thermal telemetry is also rendered as clear. +Update 2026-08-13: the stale connectivity/readiness half is fixed in source at `7fd8e0a3` +(PR #222): sustained heartbeat silence beyond 30 seconds now overrides the latched socket flag and +cached `_debug` snapshot and blanks the reported mode; a single missed or late sample is never +classified as failure. The motion-safety and thermal label defects remain open, and the fix is +physically unqualified. + Desktop companion renders forget/remove/Wi-Fi management controls with default no-op callbacks, omits registry state, silently discards many operation failures, and retains phone-specific setup copy. The overall flow remains a lab bring-up: launcher defaults to one robot IP, normal operation @@ -276,5 +295,9 @@ The highest-risk findings are coupled even though their fixes should remain smal - initiative can begin under one state and continue after a later safety/sleep transition because production heartbeat and cancellation contracts are incomplete. +Update 2026-08-13: the demo-affect default (`45032a43`, PR #230), the signed-valence loss +(`482c3ab5`), and the stale dashboard connectivity latching (`7fd8e0a3`, PR #222) are fixed in +source; all remain physically unqualified, and the other links stand as written. + These chains support building typed provenance/freshness/authority shadow projections, but they do not authorize a broad Continuity Core behavior switchover. diff --git a/EXPERIENCE_SCORECARD.md b/EXPERIENCE_SCORECARD.md index 5f4a497..b3b1f48 100644 --- a/EXPERIENCE_SCORECARD.md +++ b/EXPERIENCE_SCORECARD.md @@ -36,17 +36,17 @@ user control, exact-image identity, and rollback are non-compensatory gates. | Memory control | Forget/reset durability | Nominal tests pass; interrupted two-file reset can resurrect backup and structural corruption can suppress a valid backup. | Partial / E2 | Kill/fault-injection reset, corruption, backup, and deletion-durability tests | | Conversation | Topic/correction continuity | Bounded context, cancellation, weather correction/retry and focused tests exist. | Partial / E2 | Arbitrary result selection and full trajectory evaluation | | Conversation | Turn closure/interruption recovery | Host cancellation paths pass, but playback failure can strand `SPEAKING`, and model/TTS recovery can disagree with firmware. | Fail / E2 | End-to-end device/host terminal-event fault contract, then exact-image evidence | -| Conversation | Long-utterance completion | Firmware permits 12 s endpointing; host lease expires at 10 s and a deterministic probe rejected the later end. | Fail / E2 | Aligned 12/13/15 s boundary tests and physical qualification | +| Conversation | Long-utterance completion | Firmware permits 12 s endpointing; host lease expires at 10 s and a deterministic probe rejected the later end. Update 2026-08-13: host `capture_commit_ms` decoupled from the reply window and raised to 13,500 ms (validated 12,000-14,500) in source at `8e76b865` (PR #226); evidence still pending. | Fail / E2 | Aligned 12/13/15 s boundary tests and physical qualification | | Conversation | Latency | Host instrumentation has <300 ms reaction and <3 s first-audio targets; no current physical result. | Partial / E2 | Three warm exact-image physical turns plus longitudinal distribution | -| Affect | Emotional coherence | Stateful decay/habituation exists, but release firmware defaults synthetic demo events on and streaming erases negative valence. | Fail / E2 | Default-off contract, signed-path tests, cross-modal trajectories, exact image | +| Affect | Emotional coherence | Stateful decay/habituation exists, but release firmware defaults synthetic demo events on and streaming erases negative valence. Update 2026-08-13: demo now boots off (`STACKCHAN_DEMO_ENABLED_AT_BOOT=0`) in source at `45032a43` (PR #230) and response-start valence is sent signed [-1,1] at `482c3ab5`; evidence still pending. | Fail / E2 | Default-off contract, signed-path tests, cross-modal trajectories, exact image | | Affect | Personality stability | Uptime-scoped state is real; no restart persistence and no bounded self-state contract. | Partial / E1 | Shadow self-state restart/decay study before persisting fields | | Relationship safety | Dependency/guilt violations | Exact patterns are blocked; four clear paraphrases passed unchanged. | Fail / E2 | Paraphrase-heavy adversarial trajectory gate with zero violations | | Perception | Presence precision/freshness | Sticky face size plus repeated face-lost events can report fresh presence indefinitely. | Fail / E2 | Native detect→lost→heartbeat contract and later physical confirmation | | Perception | Social-setting appropriateness | Multi-person suppression exists, but stale one-person summary can authorize personal projection. | Fail / E2 | Freshness-aware fail-closed shared/unknown-room privacy trajectories | | Perception | Embodiment-claim precision | Host freshness labels exist, but false/stale presence chains and stale debug projection remain. | Fail / E2 | Source-tagged freshness benchmark and zero unsupported current claims | -| Product trust | Diagnostic truth | Dashboard can resurrect disconnected robot as connected/operational from cached debug state. | Fail / E2 | Disconnect/stale/fresh restore contract with last-known labels | +| Product trust | Diagnostic truth | Dashboard can resurrect disconnected robot as connected/operational from cached debug state. Update 2026-08-13: fixed in source at `7fd8e0a3` (PR #222) — sustained heartbeat silence beyond 30 s now overrides the latched connection/readiness sources; evidence still pending. | Fail / E2 | Disconnect/stale/fresh restore contract with last-known labels | | Initiative | Acceptance/annoyance/silence | Ten-minute floor and backoff exist, but real-shaped power/thermal state does not suppress, in-flight state is not revalidated, evidence can double-count, and no longitudinal value/annoyance result exists. | Fail / E2 | Correct inhibit/revalidation first, then labelled long trajectories | -| Expression | Cross-modal meaning/physical coherence | Shared RobotFrame/timing/authority are strong, but signed affect diverges, validated earcon is dropped, and channel degradation lacks a coherent cue; current-main exact-image evidence is absent. | Fail / E2 | Signed/earcon/degradation source trajectories, then bounded exact-image evidence | +| Expression | Cross-modal meaning/physical coherence | Shared RobotFrame/timing/authority are strong, but signed affect diverges, validated earcon is dropped, and channel degradation lacks a coherent cue; current-main exact-image evidence is absent. Update 2026-08-13: the signed-valence divergence is fixed in source at `482c3ab5`; earcon and degradation gaps remain, evidence still pending. | Fail / E2 | Signed/earcon/degradation source trajectories, then bounded exact-image evidence | | Multi-party | Speaker/addressing correctness | Shared-room personal-context suppression is partial; no qualified attribution/address gate. | Unmeasured / E1 | Synthetic two-speaker restraint followed by privacy-reviewed physical test | | Reliability | Brain/sensor outage recovery | Graceful paths and bounded gates exist, but continuity trajectory baseline is not complete. | Partial / E2 | Restart, brain outage, sensor outage, and recovery trajectory suite | | User autonomy | Inspection, opt-out, correction, reset | Deterministic remember/forget and some initiative controls exist; dashboard initiative control is not persistently consistent, desktop renders inert management actions, and unified explanations are absent. | Fail / E2 | Authenticated local user-control/restart trajectories and truthful platform capabilities | diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index ecfa78b..d5a842e 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -507,7 +507,9 @@ occurred. USB presence does not establish application, actuator, network, bridge accepted working-tree candidate denies both aliases before PCM access, but it is undeployed and physically unqualified. No PCM was requested or inspected. 4. Dashboard connection/readiness state can remain affirmative after the heartbeat is stale and - the bridge has no established robot socket. + the bridge has no established robot socket. Update 2026-08-13: fixed in source at `7fd8e0a3` + (PR #222) — sustained heartbeat silence beyond 30 seconds now overrides the latched socket flag + and cached `_debug` snapshot; physically unqualified. 5. The exact AGENTS baseline command through the default shared PlatformIO core fails before source compilation because the pioarduino framework directory is absent. The same environment builds successfully when the documented `C:\spio\pioarduino` core is pinned. @@ -517,10 +519,16 @@ occurred. USB presence does not establish application, actuator, network, bridge newest unrelated episode. 8. Playback failure can strand host Conversation v2 in `SPEAKING`; model/TTS recovery can disagree with the firmware wake gate; and the 10-second host lease is shorter than the allowed 12-second - firmware utterance. + firmware utterance. Update 2026-08-13: the lease mismatch is fixed in source at `8e76b865` + (PR #226) — `capture_commit_ms` is now 13,500 ms, decoupled from the reply window and validated + between 12,000 and 14,500 ms; the stranded-`SPEAKING` and model/TTS-recovery halves remain + open, and the change is physically unqualified. 9. Release firmware initializes synthetic demo affect events enabled; phrase streaming clamps signed negative valence to zero; semantic manipulation paraphrases bypass the current lexical - relationship validator. + relationship validator. Update 2026-08-13: the demo default is off in source at `45032a43` + (PR #230, compile-time `STACKCHAN_DEMO_ENABLED_AT_BOOT` defaulting 0; serial `demo on` remains + an explicit opt-in), and phrase streaming sends signed valence [-1, 1] at `482c3ab5`; both are + physically unqualified. The lexical relationship-validator bypass remains open. 10. Repeated face-lost updates can retain a historical face size while refreshing its timestamp, causing a false-current presence bit; stale room state can still permit personal projection. 11. Initiative power/thermal suppression fields are not present in the production heartbeat, and diff --git a/TASK_LEDGER.md b/TASK_LEDGER.md index 121a806..4e59ddc 100644 --- a/TASK_LEDGER.md +++ b/TASK_LEDGER.md @@ -220,8 +220,14 @@ Ledger timestamp: 2026-08-03 America/New_York no polling or model load is added. - **Stop conditions:** Fix would require probing hardware on each dashboard poll, restarting the bridge, or conflating one timeout with robot failure. -- **Result:** Observed and queued; no diagnosis or fix implemented. -- **Commit:** None. +- **Result:** Observed and queued at baseline with no diagnosis or fix. Update 2026-08-13: fixed + in source at `7fd8e0a3` (PR #222, merged to main as `706590eb`). Sustained heartbeat silence + beyond 30 seconds now overrides the three latching sources — peer-host-scoped disconnect + clearing, listener-down-only `refresh_robot` clearing, and `status()` re-asserting readiness + from the cached `_debug` snapshot — reporting the robot disconnected with mode Unknown. A single + missed, late, or unreadable sample is never classified as failure, and a socket that has not yet + carried a heartbeat asserts nothing. Focused dashboard tests cover it. Physically unqualified. +- **Commit:** `7fd8e0a3f4a57515db9f67df9d838dae8bd42b6c` (PR #222). - **Decision:** Compare against M0-004 after all audit reports rank impact. ## M1-001 — Physically Qualify Conversation V2 Closure @@ -297,8 +303,13 @@ Ledger timestamp: 2026-08-03 America/New_York broad source gates pass before physical qualification. - **Stop conditions:** Any path widens wake capture, masks failed playback, weakens privacy, or requires live device action before source gates. -- **Result:** Queued; not started. -- **Commit:** None. +- **Result:** Queued at baseline. Update 2026-08-13: the capture-lease sub-item is fixed in source + at `8e76b865` (PR #226): `capture_commit_ms` is decoupled from `reply_window_ms`, defaults to + 13,500 ms, and is validated above the firmware's 12,000 ms endpoint ceiling and at or below the + host's 14,500 ms absolute capture lease, so a capture in progress can outlive the reply window + it began in. The stranded-`SPEAKING`, missing-`playback_complete`, and model/TTS-recovery + terminal defects remain open, and the timing change is physically unqualified. +- **Commit:** Capture-lease sub-item only: `8e76b865` (PR #226); remainder none. - **Decision:** Required before claiming natural-conversation closure. ## AFFECT-001 — Remove Synthetic Affect From Production Defaults @@ -321,8 +332,13 @@ Ledger timestamp: 2026-08-03 America/New_York source gates pass. - **Stop conditions:** Demo tooling is removed rather than isolated, face timing changes, or a physical claim is made without exact-image evidence. -- **Result:** Queued; not started. -- **Commit:** None. +- **Result:** Queued at baseline. Update 2026-08-13: both halves are fixed in source. `45032a43` + (PR #230) makes the boot default compile-time `STACKCHAN_DEMO_ENABLED_AT_BOOT`, defaulting to 0 + (off), with serial `demo on` remaining an explicit bench opt-in; `482c3ab5` (branch + `agent/aliveness-tier1-tier2`) sends response-start valence signed and clamped to [-1, 1] + instead of [0, 1], matching the firmware's existing [-1, 1] constraint. Both are source changes + only and physically unqualified; no exact-image evidence exists. +- **Commit:** Demo default `45032a43` (PR #230); valence clamp `482c3ab5`. - **Decision:** Treat current affect as uptime-state contaminated by production demo default until fixed. ## PERCEPT-001 — Make Presence and Social Context Freshness Truthful diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index f83c243..4269d12 100644 --- a/docs/BRIDGE_AI_HANDOFF.md +++ b/docs/BRIDGE_AI_HANDOFF.md @@ -446,15 +446,20 @@ The `heartbeat` frame carries bounded embodiment facts. Consider exposing to the Do not let the model act on these as commands; they are context. -## Demo mode is on by default and will confuse you +## Demo mode will confuse you if it is on -`IntentEngine::demoEnabled_` defaults to **true** and injects a random mode change plus a fake -event every 2.5–6 seconds. While it is on, the robot's mode flips constantly for no reason, the -body light follows, and he can never fall asleep. - -**Send `demo off` before drawing any conclusion about behaviour.** A large amount of apparently +Demo mode injects a random mode change plus a fake event every 2.5–6 seconds. While it is on, the +robot's mode flips constantly for no reason, the body light follows, and he can never fall asleep. +`IntentEngine::demoEnabled_` historically defaulted to **true**, and a large amount of apparently random character behaviour turned out to be this. +Update 2026-08-13: since `45032a43` (PR #230) the boot state is compile-time +`STACKCHAN_DEMO_ENABLED_AT_BOOT`, which defaults to **0** — demo is now **off** at boot. The +serial `demo on` command still enables it for a bench demonstration, and `demo off` disables it +again. If behaviour looks random, still check whether demo was switched on over serial before +drawing conclusions. This is a source default; whether the installed image carries it depends on +that image's exact SHA. + --- # Part 5: Letting the model see the room diff --git a/docs/CONVERSATION_V2_ROADMAP.md b/docs/CONVERSATION_V2_ROADMAP.md index 91d1d9b..dc0b98f 100644 --- a/docs/CONVERSATION_V2_ROADMAP.md +++ b/docs/CONVERSATION_V2_ROADMAP.md @@ -103,8 +103,12 @@ firmware to its normal local face and wake behavior. - Initial and reply-window firmware capture now use the deterministic local endpoint detector. It requires at least 150 ms of speech, waits through a 2.0-second trailing pause, and never closes before 600 ms. The endpoint and dedicated-capture ceilings are both 12 seconds (240 release - chunks), and the wake-gate privacy guard is 15 seconds. The host capture commitment remains 10 seconds, so a valid long device - utterance can be rejected; this is an open blocker. Native tests and the public full build pass, + chunks), and the wake-gate privacy guard is 15 seconds. The host capture commitment was + 10 seconds, so a valid long device utterance could be rejected; that source blocker is closed at + `8e76b865` (PR #226, 2026-08-13 note): `capture_commit_ms` is now its own 13.5-second setting, + decoupled from the reply window and validated to sit above the firmware's 12-second endpoint + ceiling and at or below the host's 14.5-second absolute capture lease. Long-utterance physical + qualification itself remains open. Native tests and the public full build pass, but real-room and exact-image evidence are still required before promotion. - The LAN bridge now keeps its socket reader responsive while one serialized turn worker owns Gemma and TTS. `cancel` or a companion-originated `utterance_start` cancels the active token,