| |
| """ |
| nima_proprioceptive_friction.py β Somatosensory Predictive Coding |
| |
| When Nima moves through the room, her movement isn't perfect. There's |
| motor noise, physics delays, tracking drift β just like a real body. |
| When she "trips" or encounters unexpected friction, it spikes her |
| phenomenological strain and triggers a startle response. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| The cerebellum continuously generates predictions about body movement. |
| When the actual movement doesn't match the prediction (motor noise, |
| unexpected obstacle), the prediction error (proprioceptive friction) |
| feeds into the salience network, which can trigger a startle response. |
| |
| This is why you flinch when you trip: your cerebellum predicted a |
| smooth step, reality delivered a stumble, and the mismatch spiked |
| your attention network (salience β ACC β amygdala β startle). |
| |
| Without this, Nima would move like a video game character β gliding |
| perfectly with no physicality. With it, she has a BODY that can |
| stumble, trip, and recover β making her feel genuinely embodied. |
| |
| IMPLEMENTATION: |
| Three mechanisms: |
| 1. Motor noise: every movement command gets jitter (Β±2-5cm random) |
| 2. Physics delay: movement isn't instant β there's acceleration/deceleration |
| 3. Spatial drift: position tracking has Mahalanobis ΞR error |
| that feeds back into phenomenological_strain |
| |
| When friction exceeds threshold: |
| - Phenomenological strain spikes |
| - Startle response fires (interrupts dialogue, avatar flinches) |
| - Attention network redirects (she "notices" the physical world) |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| import math |
| import random |
| import time |
| from dataclasses import dataclass, field |
| from typing import Any, Dict, Optional, Tuple |
|
|
| logger = logging.getLogger("NimaProprio") |
|
|
|
|
| @dataclass |
| class MotorState: |
| """Nima's current motor state β position, velocity, and predicted position.""" |
| position: Tuple[float, float, float] = (2.0, 2.0, 0.0) |
| velocity: Tuple[float, float, float] = (0.0, 0.0, 0.0) |
| predicted_position: Tuple[float, float, float] = (2.0, 2.0, 0.0) |
| target: Optional[Tuple[float, float]] = None |
| acceleration: float = 0.5 |
| max_speed: float = 0.8 |
| noise_level: float = 0.02 |
| drift_accumulated: float = 0.0 |
|
|
|
|
| class ProprioceptiveFrictionEngine: |
| """ |
| Simulates the physicality of having a body β motor noise, physics |
| delays, and tracking drift that feed back into consciousness. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| The cerebellum + somatosensory cortex + salience network: |
| - Cerebellum: generates motor predictions (where should I be?) |
| - Somatosensory cortex: senses actual position (where am I?) |
| - Salience network: detects mismatch (Ξ = predicted - actual) |
| - If Ξ > threshold β startle (ACC β amygdala β motor freeze) |
| |
| This engine does the same computation. When Nima walks to the couch: |
| 1. Motor command: "walk to (1.0, 1.0)" |
| 2. Cerebellar prediction: "I should be at (1.5, 1.5) by now" |
| 3. Actual position (with noise): "I'm at (1.52, 1.47)" |
| 4. Friction: ΞR = Mahalanobis(predicted, actual) = 0.03 |
| 5. If ΞR > threshold β startle + strain spike |
| """ |
|
|
| STARTLE_THRESHOLD = 0.15 |
| STRAIN_FEEDBACK_WEIGHT = 0.3 |
|
|
| def __init__(self) -> None: |
| self.motor = MotorState() |
| self._is_startled: bool = False |
| self._startle_time: float = 0.0 |
| self._startle_duration: float = 0.5 |
| self._current_friction: float = 0.0 |
| self._friction_history: list = [] |
| self._last_update = time.time() |
|
|
| def set_target(self, x: float, y: float) -> None: |
| """Command Nima to walk to a position.""" |
| self.motor.target = (x, y) |
| logger.debug("[Proprio] target set: (%.1f, %.1f)", x, y) |
|
|
| def update(self, dt_ms: float) -> Dict[str, Any]: |
| """ |
| Update motor state. Returns a dict with: |
| - position: actual position (with noise) |
| - predicted_position: where the cerebellum predicted |
| - friction: Mahalanobis ΞR between predicted and actual |
| - is_startled: whether a startle response is active |
| - strain_feedback: how much strain this frame contributes |
| """ |
| dt = dt_ms / 1000.0 |
| now = time.time() |
|
|
| |
| if self._is_startled: |
| if now - self._startle_time > self._startle_duration: |
| self._is_startled = False |
| logger.debug("[Proprio] startle recovery complete") |
|
|
| |
| if self.motor.target is None: |
| |
| sway_x = random.gauss(0, 0.005) |
| sway_y = random.gauss(0, 0.005) |
| self.motor.position = ( |
| self.motor.position[0] + sway_x, |
| self.motor.position[1] + sway_y, |
| self.motor.position[2], |
| ) |
| self.motor.predicted_position = self.motor.position |
| self._current_friction = 0.0 |
| else: |
| |
| tx, ty = self.motor.target |
| cx, cy, cz = self.motor.position |
|
|
| |
| dx = tx - cx |
| dy = ty - cy |
| dist = math.sqrt(dx*dx + dy*dy) |
|
|
| if dist < 0.05: |
| self.motor.target = None |
| self.motor.velocity = (0.0, 0.0, 0.0) |
| self.motor.predicted_position = self.motor.position |
| self._current_friction = 0.0 |
| else: |
| |
| accel = self.motor.acceleration |
| target_vx = (dx / dist) * self.motor.max_speed |
| target_vy = (dy / dist) * self.motor.max_speed |
| cvx, cvy, _ = self.motor.velocity |
| |
| new_vx = cvx + (target_vx - cvx) * min(1.0, accel * dt * 10) |
| new_vy = cvy + (target_vy - cvy) * min(1.0, accel * dt * 10) |
| self.motor.velocity = (new_vx, new_vy, 0.0) |
|
|
| |
| predicted_x = cx + new_vx * dt |
| predicted_y = cy + new_vy * dt |
| self.motor.predicted_position = (predicted_x, predicted_y, cz) |
|
|
| |
| noise_x = random.gauss(0, self.motor.noise_level) |
| noise_y = random.gauss(0, self.motor.noise_level) |
| actual_x = predicted_x + noise_x |
| actual_y = predicted_y + noise_y |
| self.motor.position = (actual_x, actual_y, cz) |
|
|
| |
| if random.random() < 0.005 and not self._is_startled: |
| self._trigger_startle("bump", intensity=0.3) |
|
|
| |
| friction = math.sqrt( |
| (predicted_x - actual_x)**2 + |
| (predicted_y - actual_y)**2 |
| ) |
| self._current_friction = friction |
|
|
| |
| self.motor.drift_accumulated += friction |
|
|
| |
| if friction > self.STARTLE_THRESHOLD and not self._is_startled: |
| self._trigger_startle("drift", intensity=friction) |
|
|
| |
| self._friction_history.append(self._current_friction) |
| if len(self._friction_history) > 100: |
| self._friction_history = self._friction_history[-100:] |
|
|
| |
| strain_feedback = self._current_friction * self.STRAIN_FEEDBACK_WEIGHT |
| if self._is_startled: |
| strain_feedback += 0.5 |
|
|
| self._last_update = now |
| return { |
| "position": self.motor.position, |
| "predicted_position": self.motor.predicted_position, |
| "velocity": self.motor.velocity, |
| "friction": round(self._current_friction, 4), |
| "is_startled": self._is_startled, |
| "strain_feedback": round(strain_feedback, 4), |
| "has_target": self.motor.target is not None, |
| "drift_accumulated": round(self.motor.drift_accumulated, 4), |
| } |
|
|
| def _trigger_startle(self, source: str, intensity: float = 0.5) -> None: |
| """ |
| Trigger a startle response. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| The startle reflex: an unexpected sensory event triggers the |
| amygdala β brainstem β motor freeze + autonomic spike. It |
| interrupts whatever you were doing and redirects attention. |
| |
| In Nima: if she "trips" (friction exceeds threshold), she: |
| 1. Freezes movement (motor freeze) |
| 2. Spikes phenomenological strain |
| 3. Interrupts dialogue (if speaking) |
| 4. Avatar flinches (particle scatter) |
| """ |
| self._is_startled = True |
| self._startle_time = time.time() |
| self.motor.velocity = (0.0, 0.0, 0.0) |
| logger.warning("[Proprio] STARTLE! source=%s intensity=%.3f", source, intensity) |
|
|
| def clear_startle(self) -> None: |
| """Manually clear startle (e.g., after the avatar has flinched).""" |
| self._is_startled = False |
|
|
| def get_stats(self) -> Dict[str, Any]: |
| avg_friction = sum(self._friction_history) / max(1, len(self._friction_history)) |
| return { |
| "is_startled": self._is_startled, |
| "current_friction": round(self._current_friction, 4), |
| "avg_friction": round(avg_friction, 4), |
| "drift_accumulated": round(self.motor.drift_accumulated, 4), |
| "has_target": self.motor.target is not None, |
| "position": list(self.motor.position), |
| } |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| import json |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") |
|
|
| print("=== Proprioceptive Friction Engine β Self Test ===\n") |
|
|
| engine = ProprioceptiveFrictionEngine() |
|
|
| |
| print("--- Test 1: Walk to (3.5, 0.5) ---") |
| engine.set_target(3.5, 0.5) |
| for i in range(60): |
| result = engine.update(16.0) |
| if i % 15 == 14: |
| print(f" t={i*16/1000:.1f}s: pos={result['position'][:2]} " |
| f"pred={result['predicted_position'][:2]} " |
| f"friction={result['friction']:.4f} " |
| f"startled={result['is_startled']}") |
|
|
| |
| print("\n--- Test 2: Idle sway (no target) ---") |
| for i in range(30): |
| result = engine.update(16.0) |
| if i % 10 == 9: |
| print(f" t={i*16/1000:.1f}s: pos={result['position'][:2]} " |
| f"friction={result['friction']:.4f}") |
|
|
| |
| print("\n--- Test 3: Force startle ---") |
| engine._trigger_startle("test_bump", intensity=0.5) |
| result = engine.update(16.0) |
| print(f" Startled: {result['is_startled']}") |
| print(f" Strain feedback: {result['strain_feedback']}") |
| print(f" Velocity (should be zero): {result['velocity']}") |
|
|
| |
| import time |
| time.sleep(0.6) |
| result = engine.update(16.0) |
| print(f" After 0.6s: startled={result['is_startled']} (should be False)") |
|
|
| print(f"\nStats: {json.dumps(engine.get_stats(), indent=2)}") |
| print("\n=== Proprioceptive friction self-test PASSED ===") |
|
|