Deploy Pulse Familiar Space
Browse files- README.md +108 -6
- app.py +662 -0
- data/finetune/cue_bank.json +0 -0
- data/sessions.json +372 -0
- familiar/__init__.py +10 -0
- familiar/brain.py +179 -0
- familiar/coach.py +99 -0
- familiar/creature.py +105 -0
- familiar/features.py +216 -0
- familiar/hf_model.py +139 -0
- familiar/memories.py +35 -0
- familiar/rl.py +109 -0
- familiar/sim.py +159 -0
- familiar/state.py +101 -0
- familiar/trace.py +33 -0
- requirements.txt +10 -0
README.md
CHANGED
|
@@ -1,13 +1,115 @@
|
|
| 1 |
---
|
| 2 |
title: Pulse Familiar
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
|
|
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Pulse Familiar
|
| 3 |
+
emoji: 🌿
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
python_version: "3.10.13"
|
| 10 |
pinned: false
|
| 11 |
+
license: other
|
| 12 |
+
short_description: A local body-diff familiar alive on a real heartbeat
|
| 13 |
+
startup_duration_timeout: 1h
|
| 14 |
+
models:
|
| 15 |
+
- nvidia/Nemotron-Mini-4B-Instruct
|
| 16 |
+
- build-small-hackathon/pulse-familiar-fenn-lora
|
| 17 |
+
tags:
|
| 18 |
+
- track:thousand-token-wood
|
| 19 |
+
- sponsor:nvidia
|
| 20 |
+
- achievement:offgrid
|
| 21 |
+
- achievement:well-tuned
|
| 22 |
+
- achievement:offbrand
|
| 23 |
+
- achievement:tiny-titan
|
| 24 |
---
|
| 25 |
|
| 26 |
+
# 🌿 Pulse Familiar
|
| 27 |
+
|
| 28 |
+
> A small woodland familiar named **Fenn** that is *alive because of a real human
|
| 29 |
+
> heartbeat*. It turns derived ring signals into a daily body-diff brief, replayable
|
| 30 |
+
> biometric memories, and a tiny CRT creature voiced by **≤4B NVIDIA Nemotron**.
|
| 31 |
+
|
| 32 |
+
Built for the **Hugging Face × Gradio Build Small Hackathon** — 🍄 *Thousand Token
|
| 33 |
+
Wood* track.
|
| 34 |
+
|
| 35 |
+
## What it is
|
| 36 |
+
|
| 37 |
+
There is no chatbot here. Every line Fenn speaks is generated **from a heartbeat**:
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
HR / HRV ──▶ state.py ──▶ a mood ──▶ creature.py ──▶ Fenn's ASCII face
|
| 41 |
+
(pure math, (sleepy … + the mood-only cue ──▶ brain.py ──▶ one-line voice
|
| 42 |
+
vs the alarmed) (≤4B Nemotron)
|
| 43 |
+
wearer's
|
| 44 |
+
baseline)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
- **The mood** is deterministic math against the wearer's *own* baseline
|
| 48 |
+
(`familiar/state.py`) — faster-than-usual heart pushes arousal up; high HRV pulls
|
| 49 |
+
it back toward calm. Seven moods: `sleepy · serene · content · restless · anxious
|
| 50 |
+
· alarmed · excited`.
|
| 51 |
+
- **The voice** is a small model told only *how the heartbeat feels* — never the
|
| 52 |
+
numbers — so it speaks like a creature, not a doctor (`familiar/brain.py`).
|
| 53 |
+
|
| 54 |
+
## Four ways to use it
|
| 55 |
+
|
| 56 |
+
1. **🌙 Memories** — replay an anonymized **real** episode recorded from the maker's
|
| 57 |
+
Ultrahuman smart-ring (a calm night, a hard workout, a stressed afternoon) and
|
| 58 |
+
watch Fenn live through it.
|
| 59 |
+
2. **🌤 Daily Pulse Brief** — read a derived daily feature vector
|
| 60 |
+
(`data/daily_features.jsonl` when present locally), separate physical load from
|
| 61 |
+
sedentary strain, and get one recovery/output constraint for the day.
|
| 62 |
+
3. **🫁 Breathe with me** — a tiny **RL policy** (`familiar/rl.py`, REINFORCE,
|
| 63 |
+
trains in ~2s on CPU) that *learned which calming action settles a racing heart*
|
| 64 |
+
in a body-simulator built from the real ring stats. Start stressed; Fenn coaches
|
| 65 |
+
you down with paced breathing while the simulated heart settles live.
|
| 66 |
+
4. **🧪 Simulated pulse changes** — hidden dev/demo controls for mocked BPM transitions.
|
| 67 |
+
|
| 68 |
+
## Voice fine-tune (optional, drop-in)
|
| 69 |
+
|
| 70 |
+
`tools/generate_dataset.py` builds a 1,944-example chat-format SFT set from a
|
| 71 |
+
multi-teacher panel; `tools/modal_finetune.py` LoRA-tunes Nemotron-Mini-4B on it.
|
| 72 |
+
The public Space uses `build-small-hackathon/pulse-familiar-fenn-lora`. Set
|
| 73 |
+
`FAMILIAR_ADAPTER=<repo-or-path>` to swap adapters locally. Any future Modal run
|
| 74 |
+
requires W&B tracking via a Modal `wandb` secret.
|
| 75 |
+
|
| 76 |
+
## The model
|
| 77 |
+
|
| 78 |
+
- **`nvidia/Nemotron-Mini-4B-Instruct`** — a plain ≤4B transformer.
|
| 79 |
+
- Runs **in-process on ZeroGPU** (`familiar/hf_model.py`, `@spaces.GPU`) in the
|
| 80 |
+
public Space. The local demo can also use llama.cpp on linuxbox. The app makes
|
| 81 |
+
**zero external model API calls**.
|
| 82 |
+
- Degrades gracefully: if the model can't load, Fenn falls back to canned
|
| 83 |
+
in-character lines so the demo never hard-fails.
|
| 84 |
+
|
| 85 |
+
Backends are swappable via `FAMILIAR_PROVIDER` (`transformers` ships; `local`
|
| 86 |
+
llama.cpp and `openrouter` are for development).
|
| 87 |
+
|
| 88 |
+
## Run locally
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
uv venv
|
| 92 |
+
uv pip install -r requirements.txt
|
| 93 |
+
. .venv/bin/activate
|
| 94 |
+
python app.py # FAMILIAR_PROVIDER defaults to transformers/ZeroGPU-style
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Where the heartbeat comes from
|
| 98 |
+
|
| 99 |
+
`data/sessions.json` holds a few short, **anonymized** real episodes (relative time
|
| 100 |
+
+ HR/HRV only, no dates) carved from the maker's ring history with
|
| 101 |
+
`tools/extract_sessions.py`. `familiar/features.py` can derive local day-level
|
| 102 |
+
features for the Daily Pulse Brief, but `data/daily_features.jsonl` is gitignored.
|
| 103 |
+
The full raw export stays private and out of the repo.
|
| 104 |
+
|
| 105 |
+
## Submission
|
| 106 |
+
|
| 107 |
+
- 🤗 **Space:** https://huggingface.co/spaces/build-small-hackathon/pulse-familiar
|
| 108 |
+
- 🧬 **LoRA:** https://huggingface.co/build-small-hackathon/pulse-familiar-fenn-lora
|
| 109 |
+
- 🎥 **Demo video:** _link added at submission_
|
| 110 |
+
- 📣 **Social post:** _link added at submission_
|
| 111 |
+
|
| 112 |
+
## Credits
|
| 113 |
+
|
| 114 |
+
Heartbeat data: the maker's own Ultrahuman ring. Voice: NVIDIA Nemotron-Mini-4B
|
| 115 |
+
(`license: other` — see the model card). Built with Gradio on Hugging Face Spaces.
|
app.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pulse Familiar — a tiny ASCII creature that is alive because of a real heartbeat.
|
| 3 |
+
|
| 4 |
+
Fenn is a small woodland familiar. Its mood, its face, and its voice are driven
|
| 5 |
+
by heart-rate and heart-rate-variability — either yours, live, via the sliders
|
| 6 |
+
("Live touch"), or a *real* episode replayed from the maker's own smart-ring
|
| 7 |
+
history ("Memories"). The Daily Pulse Brief turns local derived ring features into
|
| 8 |
+
a body-diff and one action. The voice is a ≤4B NVIDIA Nemotron model running
|
| 9 |
+
locally via llama.cpp in dev or in-process on the Space. Built for the Hugging Face × Gradio
|
| 10 |
+
Build Small Hackathon — Thousand Token Wood track.
|
| 11 |
+
|
| 12 |
+
HR / HRV -> state.py (mood) -> creature.py (body) + brain.py (voice)
|
| 13 |
+
|
| 14 |
+
The creature lives inside a CRT terminal screen (gr.HTML) with a live ECG that
|
| 15 |
+
beats at the real BPM — driven by one render_screen() helper shared across modes.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import html
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import gradio as gr
|
| 26 |
+
|
| 27 |
+
from familiar import classify, speak
|
| 28 |
+
from familiar.brain import MODEL, _PROVIDER
|
| 29 |
+
from familiar.creature import MOOD_COLOR, render as render_fenn
|
| 30 |
+
from familiar.state import DEFAULT_HR_BASELINE, DEFAULT_HRV_BASELINE
|
| 31 |
+
from familiar import trace, memories, coach, hf_model
|
| 32 |
+
|
| 33 |
+
_DAILY_FEATURES = Path(__file__).resolve().parent / "data" / "daily_features.jsonl"
|
| 34 |
+
_ADAPTER = os.environ.get(
|
| 35 |
+
"FAMILIAR_ADAPTER", "build-small-hackathon/pulse-familiar-fenn-lora"
|
| 36 |
+
).strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --------------------------------------------------------------------------- #
|
| 40 |
+
# the CRT screen — one renderer, shared by all three tabs
|
| 41 |
+
# --------------------------------------------------------------------------- #
|
| 42 |
+
def _ascii_fenn(mood: str, color: str) -> str:
|
| 43 |
+
return html.escape(render_fenn(mood))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _badge_for(source: str, color: str) -> str:
|
| 47 |
+
model_label = "Nemotron · LoRA · live" if _ADAPTER else "Nemotron · live"
|
| 48 |
+
labels = {
|
| 49 |
+
"model": f"<span class='live-dot' style='color:{color}'>● {model_label}</span>",
|
| 50 |
+
"fallback": "<span class='fb-dot'>● fallback voice</span>",
|
| 51 |
+
"coach": "<span class='sim-dot'>● learned body-sim coach</span>",
|
| 52 |
+
"brief": "<span class='brief-dot'>● local daily pulse brief</span>",
|
| 53 |
+
"sample": "<span class='brief-dot'>● sample brief</span>",
|
| 54 |
+
"idle": "<span class='brief-dot'>● waiting for touch</span>",
|
| 55 |
+
"rule": "<span class='brief-dot'>● rule voice / no LLM</span>",
|
| 56 |
+
}
|
| 57 |
+
return labels.get(source, f"<span class='fb-dot'>● {html.escape(source)}</span>")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def render_screen(
|
| 61 |
+
mood: str,
|
| 62 |
+
line: str,
|
| 63 |
+
source: str,
|
| 64 |
+
hr: float,
|
| 65 |
+
hrv: float,
|
| 66 |
+
*,
|
| 67 |
+
title: str | None = None,
|
| 68 |
+
lens: str | None = None,
|
| 69 |
+
action: str | None = None,
|
| 70 |
+
stats: list[tuple[str, str]] | None = None,
|
| 71 |
+
progress: str = "",
|
| 72 |
+
) -> str:
|
| 73 |
+
"""Return the living-terminal HTML for one moment. data-bpm/data-color drive
|
| 74 |
+
the client-side ECG animation defined in the page <head>."""
|
| 75 |
+
color = MOOD_COLOR.get(mood, ("#7dffb0", 71))[0]
|
| 76 |
+
badge = _badge_for(source, color)
|
| 77 |
+
face = _ascii_fenn(mood, color)
|
| 78 |
+
beat = max(0.3, min(2.0, 60.0 / max(30.0, min(200.0, hr))))
|
| 79 |
+
title = title or mood.replace("_", " ").upper()
|
| 80 |
+
lens = lens or "Fenn is reading the pulse shape, not diagnosing it."
|
| 81 |
+
action = action or "watch the pulse, then choose one small next move."
|
| 82 |
+
stats = stats or [
|
| 83 |
+
("heart", f"{hr:.0f} bpm"),
|
| 84 |
+
("hrv", f"{hrv:.0f} ms"),
|
| 85 |
+
("mood", mood),
|
| 86 |
+
]
|
| 87 |
+
chips = "\n".join(
|
| 88 |
+
f"<div class='stat-chip'><span>{html.escape(k)}</span><b>{html.escape(str(v))}</b></div>"
|
| 89 |
+
for k, v in stats
|
| 90 |
+
)
|
| 91 |
+
return f"""
|
| 92 |
+
<div class="fenn-screen" data-bpm="{hr:.0f}" data-color="{color}"
|
| 93 |
+
style="--mood:{color};--beat:{beat:.2f}s">
|
| 94 |
+
<div class="fenn-left">
|
| 95 |
+
<div class="source-line">{badge}</div>
|
| 96 |
+
<pre class="fenn-face">{face}</pre>
|
| 97 |
+
<canvas class="ecg" width="720" height="96"></canvas>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="fenn-right">
|
| 100 |
+
<div class="stage-title">{html.escape(title)}</div>
|
| 101 |
+
<div class="fenn-voice">“{html.escape(line)}”</div>
|
| 102 |
+
<div class="stat-grid">{chips}</div>
|
| 103 |
+
<div class="brief-card"><span>WHY</span>{html.escape(lens)}</div>
|
| 104 |
+
<div class="brief-card"><span>NEXT</span>{html.escape(action)}</div>
|
| 105 |
+
<div class="fenn-row">
|
| 106 |
+
<span>MOOD: {mood.upper()}</span>
|
| 107 |
+
<span>♥ {hr:.0f} BPM ~ {hrv:.0f} MS</span>
|
| 108 |
+
<span>{html.escape(progress)}</span>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>"""
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# --------------------------------------------------------------------------- #
|
| 115 |
+
# core reaction (shared by all modes)
|
| 116 |
+
# --------------------------------------------------------------------------- #
|
| 117 |
+
def react(hr: float, hrv: float, prev_hr: float | None):
|
| 118 |
+
state = classify(hr, hrv, prev_hr=prev_hr)
|
| 119 |
+
line, source = speak(state)
|
| 120 |
+
trace.log(state, line, source)
|
| 121 |
+
return render_screen(state.mood, line, source, hr, hrv), state.as_dict(), hr
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def boot_live(hr: float, hrv: float, prev_hr: float | None):
|
| 125 |
+
state = classify(hr, hrv, prev_hr=prev_hr)
|
| 126 |
+
line = "touch the pulse; i'll listen."
|
| 127 |
+
return render_screen(state.mood, line, "idle", hr, hrv), state.as_dict(), hr
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def replay(ep_id: str):
|
| 131 |
+
ep = memories.get_episode(ep_id)
|
| 132 |
+
if not ep:
|
| 133 |
+
yield render_screen("content", "no memory loaded", "fallback", 62, 140), {}, ""
|
| 134 |
+
return
|
| 135 |
+
samples = ep["samples"]
|
| 136 |
+
prev_hr = last_mood = None
|
| 137 |
+
last_line, last_source = "", "model"
|
| 138 |
+
n = len(samples)
|
| 139 |
+
for i, s in enumerate(samples):
|
| 140 |
+
hr, hrv = s["hr"], s["hrv"]
|
| 141 |
+
state = classify(hr, hrv, prev_hr=prev_hr)
|
| 142 |
+
if state.mood != last_mood:
|
| 143 |
+
last_line, last_source = speak(state)
|
| 144 |
+
trace.log(state, last_line, last_source)
|
| 145 |
+
last_mood = state.mood
|
| 146 |
+
prev_hr = hr
|
| 147 |
+
prog = (f"**{ep['title']}** — *{ep['blurb']}* \n"
|
| 148 |
+
f"reliving moment {i + 1}/{n} · ♥ {hr:.0f} bpm ~ {hrv:.0f} ms")
|
| 149 |
+
yield render_screen(state.mood, last_line, last_source, hr, hrv), state.as_dict(), prog
|
| 150 |
+
time.sleep(0.5)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def breathe(start: str):
|
| 154 |
+
for body_html, _v, breath, st, prog in coach.breathe_session(start):
|
| 155 |
+
# coach yields its own pieces; re-render through the screen for consistency
|
| 156 |
+
yield (render_screen(st["mood"], _coach_line(_v), "coach", st["hr"], st["hrv"]),
|
| 157 |
+
breath, st, prog)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _coach_line(voice_md: str) -> str:
|
| 161 |
+
# coach.breathe_session yields a styled <div>…</div>; pull the quoted line out
|
| 162 |
+
import re
|
| 163 |
+
m = re.search(r"“(.+?)”", voice_md)
|
| 164 |
+
return m.group(1) if m else voice_md
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# --------------------------------------------------------------------------- #
|
| 168 |
+
# daily pulse brief — the useful daily layer over the Fenn toy
|
| 169 |
+
# --------------------------------------------------------------------------- #
|
| 170 |
+
_SAMPLE_DAY = {
|
| 171 |
+
"date": "sample",
|
| 172 |
+
"n_hr": 144,
|
| 173 |
+
"hr_median": 78,
|
| 174 |
+
"hr_rhr": 58,
|
| 175 |
+
"hr_max": 118,
|
| 176 |
+
"hrv_mean": 122,
|
| 177 |
+
"hrv_night": 118,
|
| 178 |
+
"steps_total": 2100,
|
| 179 |
+
"motion_mean": 42,
|
| 180 |
+
"exertion_frac": 0.0,
|
| 181 |
+
"hrv_z": -0.7,
|
| 182 |
+
"rhr_z": 0.9,
|
| 183 |
+
"hrv_trend3": "falling",
|
| 184 |
+
"hrv_cue": "lower",
|
| 185 |
+
"strain_cue": "strained",
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _load_daily_features() -> list[dict]:
|
| 190 |
+
if not _DAILY_FEATURES.exists():
|
| 191 |
+
return []
|
| 192 |
+
rows = []
|
| 193 |
+
for line in _DAILY_FEATURES.read_text().splitlines():
|
| 194 |
+
try:
|
| 195 |
+
rows.append(json.loads(line))
|
| 196 |
+
except json.JSONDecodeError:
|
| 197 |
+
continue
|
| 198 |
+
return rows
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _daily_choices() -> list[tuple[str, str]]:
|
| 202 |
+
rows = _load_daily_features()
|
| 203 |
+
if not rows:
|
| 204 |
+
return [("sample day · no private feature file on this Space", "sample")]
|
| 205 |
+
choices = []
|
| 206 |
+
for row in rows[-21:]:
|
| 207 |
+
label = (
|
| 208 |
+
f"{row['date']} · {row.get('hrv_cue', 'usual')} HRV · "
|
| 209 |
+
f"{row.get('strain_cue', 'restful')}"
|
| 210 |
+
)
|
| 211 |
+
choices.append((label, row["date"]))
|
| 212 |
+
return choices
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _daily_row(date: str) -> tuple[dict, str]:
|
| 216 |
+
rows = _load_daily_features()
|
| 217 |
+
for row in rows:
|
| 218 |
+
if row.get("date") == date:
|
| 219 |
+
return row, "brief"
|
| 220 |
+
if rows:
|
| 221 |
+
return rows[-1], "brief"
|
| 222 |
+
return _SAMPLE_DAY, "sample"
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _day_mood(row: dict) -> str:
|
| 226 |
+
hrv_cue = row.get("hrv_cue", "usual")
|
| 227 |
+
strain = row.get("strain_cue", "restful")
|
| 228 |
+
if strain == "strained":
|
| 229 |
+
return "anxious" if hrv_cue in {"lower", "much_lower"} else "restless"
|
| 230 |
+
if strain == "active":
|
| 231 |
+
return "excited" if hrv_cue != "much_lower" else "restless"
|
| 232 |
+
if hrv_cue == "higher":
|
| 233 |
+
return "serene"
|
| 234 |
+
if hrv_cue in {"lower", "much_lower"}:
|
| 235 |
+
return "restless"
|
| 236 |
+
return "content"
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _workload_lens(row: dict) -> str:
|
| 240 |
+
steps = float(row.get("steps_total") or 0)
|
| 241 |
+
motion = float(row.get("motion_mean") or 0)
|
| 242 |
+
rhr_z = row.get("rhr_z")
|
| 243 |
+
hrv_cue = row.get("hrv_cue", "usual")
|
| 244 |
+
strain = row.get("strain_cue", "restful")
|
| 245 |
+
if strain == "active" and (steps >= 3000 or motion >= 70):
|
| 246 |
+
return "physical load dominated; do not misread this as pure anxiety."
|
| 247 |
+
if rhr_z is not None and rhr_z >= 0.8 and steps < 2500 and motion < 70:
|
| 248 |
+
return "likely sedentary stress / deep-work load: high resting cost without much movement."
|
| 249 |
+
if hrv_cue in {"lower", "much_lower"}:
|
| 250 |
+
return "recovery debt: keep output smaller until the night signal rebounds."
|
| 251 |
+
if hrv_cue == "higher":
|
| 252 |
+
return "green-ish day: use the window, but do not spend it twice."
|
| 253 |
+
return "ordinary baseline day: keep the plan boring and clean."
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _one_action(row: dict) -> str:
|
| 257 |
+
hrv_cue = row.get("hrv_cue", "usual")
|
| 258 |
+
strain = row.get("strain_cue", "restful")
|
| 259 |
+
if strain == "active":
|
| 260 |
+
return "walk easy, hydrate, and keep the next hard push short."
|
| 261 |
+
if hrv_cue == "much_lower":
|
| 262 |
+
return "cut one ambitious task; add a 20-minute no-screen reset."
|
| 263 |
+
if hrv_cue == "lower":
|
| 264 |
+
return "ship one thing, then stop before the second spiral starts."
|
| 265 |
+
if hrv_cue == "higher":
|
| 266 |
+
return "use the clean window for one hard block, not scattered errands."
|
| 267 |
+
return "keep the day steady: one main block, one recovery block."
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _daily_line(row: dict) -> str:
|
| 271 |
+
hrv_cue = row.get("hrv_cue", "usual")
|
| 272 |
+
strain = row.get("strain_cue", "restful")
|
| 273 |
+
if strain == "active":
|
| 274 |
+
return "you spent sparks. let the little fire bank low."
|
| 275 |
+
if hrv_cue == "much_lower":
|
| 276 |
+
return "the drum sounds thin today. i make the nest smaller."
|
| 277 |
+
if hrv_cue == "lower":
|
| 278 |
+
return "less chase, more moss. i guard one clean task."
|
| 279 |
+
if hrv_cue == "higher":
|
| 280 |
+
return "green light in the ribs. step gently into it."
|
| 281 |
+
return "steady weather. i hum beside the plan."
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def daily_pulse(date: str):
|
| 285 |
+
row, source = _daily_row(date)
|
| 286 |
+
hr = float(row.get("hr_rhr") or row.get("hr_median") or DEFAULT_HR_BASELINE)
|
| 287 |
+
hrv = float(row.get("hrv_night") or row.get("hrv_mean") or DEFAULT_HRV_BASELINE)
|
| 288 |
+
mood = _day_mood(row)
|
| 289 |
+
line = _daily_line(row)
|
| 290 |
+
lens = _workload_lens(row)
|
| 291 |
+
action = _one_action(row)
|
| 292 |
+
brief = f"""
|
| 293 |
+
### Daily Pulse Brief — {row.get('date', 'sample')}
|
| 294 |
+
|
| 295 |
+
**Body diff**
|
| 296 |
+
- HRV cue: `{row.get('hrv_cue', 'usual')}` · trend: `{row.get('hrv_trend3', 'steady')}`
|
| 297 |
+
- Strain cue: `{row.get('strain_cue', 'restful')}` · workload lens: **{lens}**
|
| 298 |
+
- Resting proxy: `{row.get('hr_rhr')}` bpm · night HRV: `{row.get('hrv_night')}` · steps: `{row.get('steps_total')}`
|
| 299 |
+
|
| 300 |
+
**One move**
|
| 301 |
+
{action}
|
| 302 |
+
|
| 303 |
+
**Why this is useful**
|
| 304 |
+
This turns Fenn from “cute heartbeat toy” into a local body debugger: a daily
|
| 305 |
+
body diff, one constraint for the day, and no raw health export leaving disk.
|
| 306 |
+
"""
|
| 307 |
+
details = {
|
| 308 |
+
k: row.get(k)
|
| 309 |
+
for k in (
|
| 310 |
+
"date", "n_hr", "hr_median", "hr_rhr", "hr_max", "hrv_mean",
|
| 311 |
+
"hrv_night", "steps_total", "motion_mean", "exertion_frac",
|
| 312 |
+
"hrv_z", "rhr_z", "hrv_trend3", "hrv_cue", "strain_cue",
|
| 313 |
+
)
|
| 314 |
+
}
|
| 315 |
+
return render_screen(mood, line, source, hr, hrv), brief, details
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _day_title(row: dict) -> str:
|
| 319 |
+
strain = row.get("strain_cue", "restful")
|
| 320 |
+
hrv_cue = row.get("hrv_cue", "usual")
|
| 321 |
+
if strain == "active":
|
| 322 |
+
return "ACTIVE LOAD DAY"
|
| 323 |
+
if strain == "strained":
|
| 324 |
+
return "SEDENTARY STRAIN"
|
| 325 |
+
if hrv_cue in {"lower", "much_lower"}:
|
| 326 |
+
return "RECOVERY DEBT"
|
| 327 |
+
if hrv_cue == "higher":
|
| 328 |
+
return "GREEN WINDOW"
|
| 329 |
+
return "STEADY BASELINE"
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _stage_stats(row: dict, hr: float, hrv: float) -> list[tuple[str, str]]:
|
| 333 |
+
return [
|
| 334 |
+
("heart", f"{hr:.0f} bpm"),
|
| 335 |
+
("hrv", f"{hrv:.0f} ms"),
|
| 336 |
+
("steps", f"{float(row.get('steps_total') or 0):,.0f}"),
|
| 337 |
+
]
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def today_stage(date: str):
|
| 341 |
+
row, source = _daily_row(date)
|
| 342 |
+
hr = float(row.get("hr_rhr") or row.get("hr_median") or DEFAULT_HR_BASELINE)
|
| 343 |
+
hrv = float(row.get("hrv_night") or row.get("hrv_mean") or DEFAULT_HRV_BASELINE)
|
| 344 |
+
mood = _day_mood(row)
|
| 345 |
+
details = {
|
| 346 |
+
"mode": "daily derived",
|
| 347 |
+
"date": row.get("date"),
|
| 348 |
+
"hrv_cue": row.get("hrv_cue"),
|
| 349 |
+
"strain_cue": row.get("strain_cue"),
|
| 350 |
+
"hrv_z": row.get("hrv_z"),
|
| 351 |
+
"rhr_z": row.get("rhr_z"),
|
| 352 |
+
"steps_total": row.get("steps_total"),
|
| 353 |
+
}
|
| 354 |
+
html_stage = render_screen(
|
| 355 |
+
mood, _daily_line(row), "rule", hr, hrv,
|
| 356 |
+
title=_day_title(row),
|
| 357 |
+
lens=_workload_lens(row),
|
| 358 |
+
action=_one_action(row),
|
| 359 |
+
stats=_stage_stats(row, hr, hrv),
|
| 360 |
+
progress=f"daily derived / {source}",
|
| 361 |
+
)
|
| 362 |
+
return html_stage, details
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
_SCENARIOS = {
|
| 366 |
+
"rest": {
|
| 367 |
+
"target": (62, 145),
|
| 368 |
+
"title": "SIMULATED REST",
|
| 369 |
+
"line": "steady weather. i hum beside the plan.",
|
| 370 |
+
"lens": "mocked calm baseline: the character should stay small and steady.",
|
| 371 |
+
"action": "hold the line; no intervention needed.",
|
| 372 |
+
},
|
| 373 |
+
"deep work": {
|
| 374 |
+
"target": (92, 70),
|
| 375 |
+
"title": "SIMULATED DEEP WORK",
|
| 376 |
+
"line": "the drum leans forward. i keep the room small.",
|
| 377 |
+
"lens": "mocked focus load: higher heart, lower HRV, little movement.",
|
| 378 |
+
"action": "finish one block, then deliberately downshift.",
|
| 379 |
+
},
|
| 380 |
+
"panic": {
|
| 381 |
+
"target": (122, 28),
|
| 382 |
+
"title": "SIMULATED PANIC",
|
| 383 |
+
"line": "loud, loud. i stay close until it softens.",
|
| 384 |
+
"lens": "mocked stress spike: fast heart plus very low HRV.",
|
| 385 |
+
"action": "breathe down; do not make decisions at the peak.",
|
| 386 |
+
},
|
| 387 |
+
"workout": {
|
| 388 |
+
"target": (150, 120),
|
| 389 |
+
"title": "SIMULATED WORKOUT",
|
| 390 |
+
"line": "you blaze. i count sparks, not danger.",
|
| 391 |
+
"lens": "mocked physical exertion: high heart with enough HRV/movement context.",
|
| 392 |
+
"action": "cool down; do not mistake useful exertion for anxiety.",
|
| 393 |
+
},
|
| 394 |
+
"recovery": {
|
| 395 |
+
"target": (54, 185),
|
| 396 |
+
"title": "SIMULATED RECOVERY",
|
| 397 |
+
"line": "green hush in the ribs. i curl beside it.",
|
| 398 |
+
"lens": "mocked recovery: low heart and high HRV.",
|
| 399 |
+
"action": "use one clean block, then preserve the surplus.",
|
| 400 |
+
},
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def simulate_stage(name: str):
|
| 405 |
+
cfg = _SCENARIOS.get(name, _SCENARIOS["panic"])
|
| 406 |
+
start_hr, start_hrv = DEFAULT_HR_BASELINE, DEFAULT_HRV_BASELINE
|
| 407 |
+
target_hr, target_hrv = cfg["target"]
|
| 408 |
+
prev_hr = None
|
| 409 |
+
steps = 18
|
| 410 |
+
for i in range(steps + 1):
|
| 411 |
+
t = i / steps
|
| 412 |
+
# ease-in-out, so the mocked BPM change feels like physiology, not a jump cut
|
| 413 |
+
ease = t * t * (3 - 2 * t)
|
| 414 |
+
hr = start_hr + (target_hr - start_hr) * ease
|
| 415 |
+
hrv = start_hrv + (target_hrv - start_hrv) * ease
|
| 416 |
+
st = classify(hr, hrv, prev_hr=prev_hr)
|
| 417 |
+
prev_hr = hr
|
| 418 |
+
yield render_screen(
|
| 419 |
+
st.mood, cfg["line"], "rule", hr, hrv,
|
| 420 |
+
title=cfg["title"],
|
| 421 |
+
lens=cfg["lens"],
|
| 422 |
+
action=cfg["action"],
|
| 423 |
+
stats=[("heart", f"{hr:.0f} bpm"), ("hrv", f"{hrv:.0f} ms"), ("source", "mock")],
|
| 424 |
+
progress=f"simulated {i}/{steps}",
|
| 425 |
+
), st.as_dict()
|
| 426 |
+
time.sleep(0.07)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def replay_stage(ep_id: str):
|
| 430 |
+
ep = memories.get_episode(ep_id)
|
| 431 |
+
if not ep:
|
| 432 |
+
yield render_screen(
|
| 433 |
+
"content", "no memory loaded", "fallback", 62, 140,
|
| 434 |
+
title="NO MEMORY",
|
| 435 |
+
lens="No anonymized ring replay was found.",
|
| 436 |
+
action="Choose a memory after data is loaded.",
|
| 437 |
+
progress="real replay",
|
| 438 |
+
), {}
|
| 439 |
+
return
|
| 440 |
+
prev_hr = last_mood = None
|
| 441 |
+
last_line, last_source = "", "fallback"
|
| 442 |
+
samples = ep["samples"]
|
| 443 |
+
for i, s in enumerate(samples):
|
| 444 |
+
hr, hrv = s["hr"], s["hrv"]
|
| 445 |
+
st = classify(hr, hrv, prev_hr=prev_hr)
|
| 446 |
+
if st.mood != last_mood:
|
| 447 |
+
last_line, last_source = speak(st)
|
| 448 |
+
last_mood = st.mood
|
| 449 |
+
prev_hr = hr
|
| 450 |
+
yield render_screen(
|
| 451 |
+
st.mood, last_line, last_source, hr, hrv,
|
| 452 |
+
title=f"REAL REPLAY — {ep['title']}",
|
| 453 |
+
lens=ep["blurb"],
|
| 454 |
+
action="Watch Fenn change only when the body state changes.",
|
| 455 |
+
stats=[("heart", f"{hr:.0f} bpm"), ("hrv", f"{hrv:.0f} ms"), ("sample", f"{i + 1}/{len(samples)}")],
|
| 456 |
+
progress="real replay",
|
| 457 |
+
), st.as_dict()
|
| 458 |
+
time.sleep(0.35)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def replay_sleep():
|
| 462 |
+
yield from replay_stage("calm_night")
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def replay_workout():
|
| 466 |
+
yield from replay_stage("workout")
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def replay_stress():
|
| 470 |
+
yield from replay_stage("stress")
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def breathe_stage():
|
| 474 |
+
for _body_html, voice, breath, st, prog in coach.breathe_session("panic", max_steps=18):
|
| 475 |
+
line = _coach_line(voice)
|
| 476 |
+
yield render_screen(
|
| 477 |
+
st["mood"], line, "coach", st["hr"], st["hrv"],
|
| 478 |
+
title="BREATHE IT DOWN",
|
| 479 |
+
lens="body-sim: Fenn chooses a calming action and the mocked body responds.",
|
| 480 |
+
action=_coach_line(breath) if "“" in breath else "follow the paced breath until the pulse settles.",
|
| 481 |
+
stats=[("heart", f"{st['hr']:.0f} bpm"), ("hrv", f"{st['hrv']:.0f} ms"), ("source", "body-sim")],
|
| 482 |
+
progress=prog.replace("·", "/"),
|
| 483 |
+
), st
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# --------------------------------------------------------------------------- #
|
| 487 |
+
# page chrome
|
| 488 |
+
# --------------------------------------------------------------------------- #
|
| 489 |
+
_HEAD = """
|
| 490 |
+
<script>
|
| 491 |
+
(function(){
|
| 492 |
+
function pqrst(p){
|
| 493 |
+
if(p<0.12) return Math.sin(p/0.12*Math.PI)*0.12;
|
| 494 |
+
if(p<0.20) return 0;
|
| 495 |
+
if(p<0.24) return -0.25;
|
| 496 |
+
if(p<0.28) return 1.0;
|
| 497 |
+
if(p<0.33) return -0.45;
|
| 498 |
+
if(p<0.55) return 0;
|
| 499 |
+
if(p<0.75) return Math.sin((p-0.55)/0.20*Math.PI)*0.30;
|
| 500 |
+
return 0;
|
| 501 |
+
}
|
| 502 |
+
let last=0,t=0;
|
| 503 |
+
function loop(ts){
|
| 504 |
+
const dt=(ts-last)/1000||0; last=ts;
|
| 505 |
+
document.querySelectorAll('.fenn-screen').forEach(function(scr){
|
| 506 |
+
const cv=scr.querySelector('canvas.ecg'); if(!cv) return;
|
| 507 |
+
const ctx=cv.getContext('2d');
|
| 508 |
+
const bpm=Math.max(30,Math.min(200,parseFloat(scr.dataset.bpm||'62')));
|
| 509 |
+
const color=scr.dataset.color||'#7dffb0';
|
| 510 |
+
const beat=60/bpm; t=(t+dt)%beat;
|
| 511 |
+
if(cv._x===undefined) cv._x=0;
|
| 512 |
+
ctx.fillStyle='rgba(4,20,12,0.16)'; ctx.fillRect(0,0,cv.width,cv.height);
|
| 513 |
+
const y=cv.height/2 - pqrst(t/beat)*(cv.height*0.40);
|
| 514 |
+
ctx.fillStyle=color; ctx.shadowColor=color; ctx.shadowBlur=10;
|
| 515 |
+
ctx.fillRect(cv._x,y,2.6,2.6);
|
| 516 |
+
cv._x+=3; if(cv._x>cv.width){cv._x=0; ctx.clearRect(0,0,cv.width,cv.height);}
|
| 517 |
+
});
|
| 518 |
+
requestAnimationFrame(loop);
|
| 519 |
+
}
|
| 520 |
+
requestAnimationFrame(loop);
|
| 521 |
+
})();
|
| 522 |
+
</script>
|
| 523 |
+
"""
|
| 524 |
+
|
| 525 |
+
_CSS = """
|
| 526 |
+
.gradio-container{background:#090b09!important;color:#ece8dc!important;}
|
| 527 |
+
.gradio-container,.gradio-container *{font-family:"JetBrains Mono","SF Mono",
|
| 528 |
+
ui-monospace,Menlo,monospace!important;}
|
| 529 |
+
.app-wrap{max-width:1040px;margin:0 auto 10px;padding:8px 2px 0;}
|
| 530 |
+
.app-title{font-size:34px;line-height:1.05;margin:0 0 8px;color:#f4f0df;}
|
| 531 |
+
.app-sub{color:#8d9188;font-size:13px;max-width:680px;}
|
| 532 |
+
.episode-label{max-width:1040px;margin:18px auto 2px;color:#d6a849;font-size:12px;
|
| 533 |
+
letter-spacing:.18em;text-transform:uppercase;}
|
| 534 |
+
.episode-help{max-width:1040px;margin:0 auto;color:#74796f;font-size:12px;}
|
| 535 |
+
.controls{max-width:1040px;margin:14px auto 0;}
|
| 536 |
+
.episode-controls{margin-top:10px;}
|
| 537 |
+
.controls .form,.controls .block{background:transparent!important;border:0!important;box-shadow:none!important;
|
| 538 |
+
padding:0!important;}
|
| 539 |
+
.controls input{background:#10130f!important;color:#eee9d8!important;border:1px solid #2a2f26!important;
|
| 540 |
+
border-radius:999px!important;min-height:46px!important;text-align:center!important;}
|
| 541 |
+
.controls svg{color:#8d9188!important;}
|
| 542 |
+
button.primary,button.secondary{border-radius:999px!important;border:1px solid #33382f!important;
|
| 543 |
+
background:#11140f!important;color:#e9e5d7!important;box-shadow:none!important;}
|
| 544 |
+
button.primary{border-color:#d6a849!important;color:#fff4cf!important;}
|
| 545 |
+
.fenn-screen{max-width:1040px;margin:16px auto 4px;position:relative;display:grid;
|
| 546 |
+
grid-template-columns:minmax(260px,390px) 1fr;gap:26px;align-items:center;
|
| 547 |
+
border:1px solid #242820;border-radius:24px;padding:30px;background:
|
| 548 |
+
radial-gradient(600px 260px at 20% 20%,color-mix(in srgb,var(--mood) 12%,transparent),transparent 70%),
|
| 549 |
+
linear-gradient(180deg,#11140f,#0b0d0a);overflow:hidden;}
|
| 550 |
+
.fenn-left{display:flex;flex-direction:column;align-items:center;gap:12px;}
|
| 551 |
+
.source-line{align-self:flex-start;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:#878b81;}
|
| 552 |
+
.fenn-face{font-size:18px;line-height:1.18;white-space:pre;text-align:center;color:#f1eddd;
|
| 553 |
+
text-shadow:0 0 18px color-mix(in srgb,var(--mood) 55%,transparent);margin:0;
|
| 554 |
+
transform-origin:50% 62%;animation:fenn-bob 4s ease-in-out infinite,
|
| 555 |
+
fenn-pulse var(--beat) ease-out infinite;}
|
| 556 |
+
canvas.ecg{display:block;width:100%;height:52px;opacity:.78;
|
| 557 |
+
filter:drop-shadow(0 0 8px var(--mood));}
|
| 558 |
+
.fenn-right{min-width:0;}
|
| 559 |
+
.stage-title{color:var(--mood);font-size:13px;letter-spacing:.18em;font-weight:700;
|
| 560 |
+
margin-bottom:14px;text-transform:uppercase;}
|
| 561 |
+
.fenn-voice{font-size:34px;line-height:1.12;color:#f4f0df;letter-spacing:-.03em;
|
| 562 |
+
max-width:620px;margin-bottom:22px;}
|
| 563 |
+
.stat-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:10px 0 18px;}
|
| 564 |
+
.stat-chip{border:1px solid #252a22;border-radius:14px;padding:12px;background:#0c0f0b;}
|
| 565 |
+
.stat-chip span{display:block;color:#74796f;font-size:11px;text-transform:uppercase;letter-spacing:.1em;}
|
| 566 |
+
.stat-chip b{display:block;margin-top:4px;color:#ebe7d8;font-size:16px;font-weight:600;}
|
| 567 |
+
.brief-card{border-left:1px solid #30362c;padding:9px 0 9px 14px;color:#bbb8aa;font-size:14px;
|
| 568 |
+
line-height:1.45;margin-top:8px;}
|
| 569 |
+
.brief-card span{display:block;color:#74796f;font-size:10px;letter-spacing:.16em;margin-bottom:3px;}
|
| 570 |
+
.fenn-row{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;color:#686d64;
|
| 571 |
+
font-size:11px;letter-spacing:.08em;margin-top:18px;text-transform:uppercase;}
|
| 572 |
+
.live-dot,.fb-dot,.sim-dot,.brief-dot{color:#878b81!important;text-shadow:none!important;}
|
| 573 |
+
@keyframes fenn-bob{0%,100%{translate:0 0}50%{translate:0 -4px}}
|
| 574 |
+
@keyframes fenn-pulse{0%,82%,100%{scale:1}10%{scale:1.035}}
|
| 575 |
+
@media(max-width:760px){.fenn-screen{grid-template-columns:1fr;padding:22px}.fenn-voice{font-size:25px}
|
| 576 |
+
.stat-chip{padding:10px}.source-line{align-self:center}}
|
| 577 |
+
"""
|
| 578 |
+
|
| 579 |
+
_STORY = """
|
| 580 |
+
### What is this?
|
| 581 |
+
**Fenn** is a tiny familiar that only feels what your heart does. There's no
|
| 582 |
+
chatbot — every line is generated *from a heartbeat*:
|
| 583 |
+
|
| 584 |
+
```
|
| 585 |
+
HR / HRV → a mood (calm … alarmed) → Fenn's face + a one-line voice
|
| 586 |
+
```
|
| 587 |
+
|
| 588 |
+
The **mood** is pure math against the wearer's own baseline (`state.py`). The
|
| 589 |
+
**voice** is **NVIDIA's `Nemotron-Mini-4B-Instruct`** served locally through
|
| 590 |
+
llama.cpp in this demo, with the `fenn-lora` adapter loaded for the creature
|
| 591 |
+
voice. No cloud API is called.
|
| 592 |
+
|
| 593 |
+
**Daily Pulse Brief** is the practical layer: it reads a local, derived daily
|
| 594 |
+
feature vector (not the raw ring export), separates physical exertion from
|
| 595 |
+
sedentary strain, and gives one recovery/output constraint for the day.
|
| 596 |
+
|
| 597 |
+
**Memories** replays anonymized real episodes from the maker's own smart-ring.
|
| 598 |
+
"""
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def build() -> gr.Blocks:
|
| 602 |
+
backend = {"transformers": "in-process (ZeroGPU)", "local": "llama.cpp",
|
| 603 |
+
"openrouter": "OpenRouter (dev)"}.get(_PROVIDER, _PROVIDER)
|
| 604 |
+
st = hf_model.status() if _PROVIDER == "transformers" else {}
|
| 605 |
+
dep = " · deps ready" if st.get("deps_present") else ""
|
| 606 |
+
adapter = f" · LoRA `{_ADAPTER}`" if _ADAPTER else ""
|
| 607 |
+
status = f"voice: NVIDIA `{MODEL}` · {backend}{dep}{adapter} · no cloud calls"
|
| 608 |
+
|
| 609 |
+
with gr.Blocks(title="Pulse Familiar", theme=gr.themes.Soft(),
|
| 610 |
+
css=_CSS, head=_HEAD) as demo:
|
| 611 |
+
gr.HTML(
|
| 612 |
+
"<div class='app-wrap'>"
|
| 613 |
+
"<div class='app-title'>Pulse Familiar</div>"
|
| 614 |
+
"<div class='app-sub'>A small ASCII familiar for a private body debugger: "
|
| 615 |
+
"daily body diff, real ring replay, and honest simulated pulse changes.</div>"
|
| 616 |
+
f"<div class='app-sub'>{html.escape(status)}</div>"
|
| 617 |
+
"</div>"
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
stage = gr.HTML()
|
| 621 |
+
choices = _daily_choices()
|
| 622 |
+
|
| 623 |
+
gr.HTML(
|
| 624 |
+
"<div class='episode-label'>real ring replay</div>"
|
| 625 |
+
"<div class='episode-help'>choose a 90-minute slice from the private ring history</div>"
|
| 626 |
+
)
|
| 627 |
+
with gr.Row(elem_classes=["controls", "episode-controls"]):
|
| 628 |
+
sleep_btn = gr.Button("deep sleep", variant="primary", scale=1)
|
| 629 |
+
workout_btn = gr.Button("workout", scale=1)
|
| 630 |
+
stress_btn = gr.Button("stressed afternoon", scale=1)
|
| 631 |
+
today = gr.Button("today brief", scale=1)
|
| 632 |
+
|
| 633 |
+
with gr.Accordion("simulate / breathe / dev controls", open=False):
|
| 634 |
+
with gr.Row(elem_classes=["controls"]):
|
| 635 |
+
day = gr.Dropdown(choices=choices, value=choices[-1][1],
|
| 636 |
+
label="day", show_label=False, scale=2)
|
| 637 |
+
scenario = gr.Dropdown(
|
| 638 |
+
choices=list(_SCENARIOS), value="panic", label="mock transition",
|
| 639 |
+
show_label=False, scale=2
|
| 640 |
+
)
|
| 641 |
+
sim_btn = gr.Button("simulate bpm", variant="primary", scale=1)
|
| 642 |
+
breathe_btn = gr.Button("breathe down", scale=1)
|
| 643 |
+
|
| 644 |
+
with gr.Accordion("details / story", open=False):
|
| 645 |
+
gr.Markdown(_STORY)
|
| 646 |
+
details = gr.JSON(label="state")
|
| 647 |
+
|
| 648 |
+
demo.load(replay_sleep, [], [stage, details])
|
| 649 |
+
sleep_btn.click(replay_sleep, [], [stage, details])
|
| 650 |
+
workout_btn.click(replay_workout, [], [stage, details])
|
| 651 |
+
stress_btn.click(replay_stress, [], [stage, details])
|
| 652 |
+
today.click(today_stage, [day], [stage, details])
|
| 653 |
+
sim_btn.click(simulate_stage, [scenario], [stage, details])
|
| 654 |
+
breathe_btn.click(breathe_stage, [], [stage, details])
|
| 655 |
+
|
| 656 |
+
return demo
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
demo = build()
|
| 660 |
+
|
| 661 |
+
if __name__ == "__main__":
|
| 662 |
+
demo.launch()
|
data/finetune/cue_bank.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/sessions.json
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"episodes": [
|
| 3 |
+
{
|
| 4 |
+
"id": "calm_night",
|
| 5 |
+
"title": "A calm night",
|
| 6 |
+
"blurb": "deep sleep \u2014 the slowest, kindest the drum gets",
|
| 7 |
+
"samples": [
|
| 8 |
+
{
|
| 9 |
+
"dt": 0,
|
| 10 |
+
"hr": 76,
|
| 11 |
+
"hrv": 34
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"dt": 304,
|
| 15 |
+
"hr": 60,
|
| 16 |
+
"hrv": 150
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"dt": 600,
|
| 20 |
+
"hr": 59,
|
| 21 |
+
"hrv": 194
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"dt": 1200,
|
| 25 |
+
"hr": 53,
|
| 26 |
+
"hrv": 217
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"dt": 1505,
|
| 30 |
+
"hr": 53,
|
| 31 |
+
"hrv": 188
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"dt": 1800,
|
| 35 |
+
"hr": 59,
|
| 36 |
+
"hrv": 149
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"dt": 2105,
|
| 40 |
+
"hr": 52,
|
| 41 |
+
"hrv": 230
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"dt": 2409,
|
| 45 |
+
"hr": 52,
|
| 46 |
+
"hrv": 230
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"dt": 2705,
|
| 50 |
+
"hr": 57,
|
| 51 |
+
"hrv": 221
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"dt": 3000,
|
| 55 |
+
"hr": 55,
|
| 56 |
+
"hrv": 208
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"dt": 3600,
|
| 60 |
+
"hr": 50,
|
| 61 |
+
"hrv": 230
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"dt": 3904,
|
| 65 |
+
"hr": 53,
|
| 66 |
+
"hrv": 46
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"dt": 4200,
|
| 70 |
+
"hr": 52,
|
| 71 |
+
"hrv": 189
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"dt": 4504,
|
| 75 |
+
"hr": 54,
|
| 76 |
+
"hrv": 58
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"dt": 4805,
|
| 80 |
+
"hr": 55,
|
| 81 |
+
"hrv": 132
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"dt": 5100,
|
| 85 |
+
"hr": 52,
|
| 86 |
+
"hrv": 161
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"dt": 5405,
|
| 90 |
+
"hr": 51,
|
| 91 |
+
"hrv": 132
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"dt": 5700,
|
| 95 |
+
"hr": 59,
|
| 96 |
+
"hrv": 187
|
| 97 |
+
}
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"id": "workout",
|
| 102 |
+
"title": "A hard workout",
|
| 103 |
+
"blurb": "heart racing, but the body is moving \u2014 good fire",
|
| 104 |
+
"samples": [
|
| 105 |
+
{
|
| 106 |
+
"dt": 0,
|
| 107 |
+
"hr": 127,
|
| 108 |
+
"hrv": 106
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"dt": 305,
|
| 112 |
+
"hr": 125,
|
| 113 |
+
"hrv": 142
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"dt": 605,
|
| 117 |
+
"hr": 136,
|
| 118 |
+
"hrv": 119
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"dt": 905,
|
| 122 |
+
"hr": 141,
|
| 123 |
+
"hrv": 129
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"dt": 1205,
|
| 127 |
+
"hr": 134,
|
| 128 |
+
"hrv": 119
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"dt": 1505,
|
| 132 |
+
"hr": 121,
|
| 133 |
+
"hrv": 92
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"dt": 1805,
|
| 137 |
+
"hr": 121,
|
| 138 |
+
"hrv": 35
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"dt": 2100,
|
| 142 |
+
"hr": 143,
|
| 143 |
+
"hrv": 59
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"dt": 2405,
|
| 147 |
+
"hr": 133,
|
| 148 |
+
"hrv": 194
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"dt": 2705,
|
| 152 |
+
"hr": 137,
|
| 153 |
+
"hrv": 123
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"dt": 3000,
|
| 157 |
+
"hr": 124,
|
| 158 |
+
"hrv": 162
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
"dt": 3305,
|
| 162 |
+
"hr": 123,
|
| 163 |
+
"hrv": 192
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"dt": 3600,
|
| 167 |
+
"hr": 125,
|
| 168 |
+
"hrv": 114
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"dt": 5704,
|
| 172 |
+
"hr": 104,
|
| 173 |
+
"hrv": 191
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"dt": 6000,
|
| 177 |
+
"hr": 102,
|
| 178 |
+
"hrv": 233
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"dt": 6305,
|
| 182 |
+
"hr": 102,
|
| 183 |
+
"hrv": 150
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"dt": 6905,
|
| 187 |
+
"hr": 100,
|
| 188 |
+
"hrv": 115
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"dt": 7200,
|
| 192 |
+
"hr": 116,
|
| 193 |
+
"hrv": 224
|
| 194 |
+
}
|
| 195 |
+
]
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"id": "stress",
|
| 199 |
+
"title": "A stressed afternoon",
|
| 200 |
+
"blurb": "fast and tight, HRV crushed, sitting still",
|
| 201 |
+
"samples": [
|
| 202 |
+
{
|
| 203 |
+
"dt": 0,
|
| 204 |
+
"hr": 92,
|
| 205 |
+
"hrv": 57
|
| 206 |
+
},
|
| 207 |
+
{
|
| 208 |
+
"dt": 301,
|
| 209 |
+
"hr": 88,
|
| 210 |
+
"hrv": 10
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"dt": 902,
|
| 214 |
+
"hr": 103,
|
| 215 |
+
"hrv": 68
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"dt": 1502,
|
| 219 |
+
"hr": 118,
|
| 220 |
+
"hrv": 113
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"dt": 2402,
|
| 224 |
+
"hr": 120,
|
| 225 |
+
"hrv": 35
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"dt": 2702,
|
| 229 |
+
"hr": 125,
|
| 230 |
+
"hrv": 62
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
"dt": 3302,
|
| 234 |
+
"hr": 118,
|
| 235 |
+
"hrv": 99
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"dt": 3602,
|
| 239 |
+
"hr": 112,
|
| 240 |
+
"hrv": 135
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"dt": 3902,
|
| 244 |
+
"hr": 110,
|
| 245 |
+
"hrv": 136
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"dt": 4502,
|
| 249 |
+
"hr": 114,
|
| 250 |
+
"hrv": 89
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"dt": 6002,
|
| 254 |
+
"hr": 98,
|
| 255 |
+
"hrv": 20
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"dt": 6602,
|
| 259 |
+
"hr": 98,
|
| 260 |
+
"hrv": 38
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"dt": 6902,
|
| 264 |
+
"hr": 104,
|
| 265 |
+
"hrv": 91
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"dt": 8401,
|
| 269 |
+
"hr": 121,
|
| 270 |
+
"hrv": 103
|
| 271 |
+
}
|
| 272 |
+
]
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"id": "morning",
|
| 276 |
+
"title": "An ordinary morning",
|
| 277 |
+
"blurb": "just a normal day, near the usual rhythm",
|
| 278 |
+
"samples": [
|
| 279 |
+
{
|
| 280 |
+
"dt": 0,
|
| 281 |
+
"hr": 82,
|
| 282 |
+
"hrv": 174
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"dt": 302,
|
| 286 |
+
"hr": 84,
|
| 287 |
+
"hrv": 123
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"dt": 903,
|
| 291 |
+
"hr": 71,
|
| 292 |
+
"hrv": 107
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"dt": 1203,
|
| 296 |
+
"hr": 71,
|
| 297 |
+
"hrv": 107
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"dt": 1500,
|
| 301 |
+
"hr": 77,
|
| 302 |
+
"hrv": 152
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"dt": 1803,
|
| 306 |
+
"hr": 70,
|
| 307 |
+
"hrv": 95
|
| 308 |
+
},
|
| 309 |
+
{
|
| 310 |
+
"dt": 2100,
|
| 311 |
+
"hr": 71,
|
| 312 |
+
"hrv": 142
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"dt": 2403,
|
| 316 |
+
"hr": 72,
|
| 317 |
+
"hrv": 94
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"dt": 2700,
|
| 321 |
+
"hr": 79,
|
| 322 |
+
"hrv": 84
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
"dt": 3002,
|
| 326 |
+
"hr": 70,
|
| 327 |
+
"hrv": 77
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"dt": 3300,
|
| 331 |
+
"hr": 70,
|
| 332 |
+
"hrv": 207
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"dt": 3603,
|
| 336 |
+
"hr": 64,
|
| 337 |
+
"hrv": 175
|
| 338 |
+
},
|
| 339 |
+
{
|
| 340 |
+
"dt": 3900,
|
| 341 |
+
"hr": 64,
|
| 342 |
+
"hrv": 143
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"dt": 4203,
|
| 346 |
+
"hr": 65,
|
| 347 |
+
"hrv": 69
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"dt": 4500,
|
| 351 |
+
"hr": 62,
|
| 352 |
+
"hrv": 128
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"dt": 4803,
|
| 356 |
+
"hr": 69,
|
| 357 |
+
"hrv": 138
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"dt": 5100,
|
| 361 |
+
"hr": 70,
|
| 362 |
+
"hrv": 148
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
"dt": 5402,
|
| 366 |
+
"hr": 70,
|
| 367 |
+
"hrv": 148
|
| 368 |
+
}
|
| 369 |
+
]
|
| 370 |
+
}
|
| 371 |
+
]
|
| 372 |
+
}
|
familiar/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pulse Familiar — a tiny creature that lives off a real heartbeat."""
|
| 2 |
+
from .state import classify, CreatureState, MOODS
|
| 3 |
+
from .creature import render, beat_period_seconds, MOOD_COLOR
|
| 4 |
+
from .brain import speak, server_alive
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"classify", "CreatureState", "MOODS",
|
| 8 |
+
"render", "beat_period_seconds", "MOOD_COLOR",
|
| 9 |
+
"speak", "server_alive",
|
| 10 |
+
]
|
familiar/brain.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
brain.py — the familiar's voice.
|
| 3 |
+
|
| 4 |
+
Talks to a local llama.cpp `llama-server` (OpenAI-compatible /v1/chat/completions)
|
| 5 |
+
running a <=4B Nemotron model. Fully offline — no cloud APIs (Off-the-Grid badge).
|
| 6 |
+
|
| 7 |
+
The model only ever receives the creature's *mood* and a few soft physiological
|
| 8 |
+
cues, never clinical numbers. It speaks in short, in-character fragments. If the
|
| 9 |
+
server is unreachable, we fall back to canned lines so the app never hard-fails.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import random
|
| 15 |
+
import re
|
| 16 |
+
import requests
|
| 17 |
+
|
| 18 |
+
from .state import CreatureState
|
| 19 |
+
|
| 20 |
+
# --- backend selection (env FAMILIAR_PROVIDER):
|
| 21 |
+
# transformers : in-process <=4B model on the Space (ZeroGPU). SHIPS THIS.
|
| 22 |
+
# local : external llama.cpp llama-server over HTTP (linuxbox dev).
|
| 23 |
+
# openrouter : hosted HTTP, dev only (fast iteration on the voice).
|
| 24 |
+
# any failure -> canned in-character fallback, so the app never hard-fails.
|
| 25 |
+
_TIMEOUT = float(os.environ.get("LLAMA_TIMEOUT", "20"))
|
| 26 |
+
_PROVIDER = os.environ.get("FAMILIAR_PROVIDER", "transformers").lower()
|
| 27 |
+
|
| 28 |
+
if _PROVIDER == "transformers":
|
| 29 |
+
# in-process backend (the deployed Space). MODEL is the EXACT HF id the
|
| 30 |
+
# NVIDIA Nemotron Quest is verified on — keep it identical to hf_model.MODEL_ID.
|
| 31 |
+
from .hf_model import MODEL_ID as MODEL
|
| 32 |
+
BASE_URL = ""
|
| 33 |
+
API_KEY = ""
|
| 34 |
+
elif _PROVIDER == "openrouter":
|
| 35 |
+
BASE_URL = os.environ.get("FAMILIAR_BASE_URL", "https://openrouter.ai/api/v1")
|
| 36 |
+
API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
| 37 |
+
MODEL = os.environ.get("FAMILIAR_MODEL", "meta-llama/llama-3.2-3b-instruct")
|
| 38 |
+
else: # local llama-server (linuxbox dev)
|
| 39 |
+
BASE_URL = os.environ.get("FAMILIAR_BASE_URL", "http://127.0.0.1:8080/v1")
|
| 40 |
+
API_KEY = os.environ.get("FAMILIAR_API_KEY", "")
|
| 41 |
+
MODEL = os.environ.get("FAMILIAR_MODEL", "nemotron-mini-4b")
|
| 42 |
+
|
| 43 |
+
SYSTEM_PROMPT = (
|
| 44 |
+
"You are Fenn, a small woodland familiar that lives off a human's heartbeat. "
|
| 45 |
+
"You can feel their pulse but you are not a doctor and never give medical advice "
|
| 46 |
+
"or numbers. You speak in ONE short line (max ~15 words), warm, a little strange, "
|
| 47 |
+
"present-tense, like a creature who only knows feelings. You never explain yourself. "
|
| 48 |
+
"You react to how the heartbeat FEELS, not to data. Use lowercase. Do not say "
|
| 49 |
+
"you are an assistant. Do not narrate a generic forest scene. Do not say 'my heart'. "
|
| 50 |
+
"Prefer body words like drum, pulse, beat, breath, ribs, nest. Avoid paths, shadows, "
|
| 51 |
+
"moons, rivers, stars, and quoted speech."
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Soft, qualitative cue per mood handed to the model (keeps it from reciting vitals).
|
| 55 |
+
_MOOD_CUE = {
|
| 56 |
+
"sleepy": "their pulse is slow and heavy, they are drifting toward sleep",
|
| 57 |
+
"serene": "their heartbeat is calm and even, deeply at peace",
|
| 58 |
+
"content": "their pulse is steady and easy, all is well",
|
| 59 |
+
"restless": "their heartbeat is picking up, a little fidgety",
|
| 60 |
+
"anxious": "their pulse is fast and tight, unsettled, maybe worried",
|
| 61 |
+
"alarmed": "their heart is pounding hard and ragged, something is wrong",
|
| 62 |
+
"excited": "their heart races bright and open, thrilled and alive",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Richer per-mood cue bank (the diverse cues the teachers wrote during data-gen).
|
| 66 |
+
# Sampling from these makes Fenn's INPUT vary call-to-call, so it stops feeling
|
| 67 |
+
# canned — and matches the distribution the LoRA was trained on. Falls back to the
|
| 68 |
+
# single hand-written cue above if the bank isn't present.
|
| 69 |
+
import json as _json
|
| 70 |
+
from pathlib import Path as _Path
|
| 71 |
+
|
| 72 |
+
_CUE_BANK: dict[str, list[str]] = {}
|
| 73 |
+
try:
|
| 74 |
+
_bp = _Path(__file__).resolve().parent.parent / "data" / "finetune" / "cue_bank.json"
|
| 75 |
+
if _bp.exists():
|
| 76 |
+
_CUE_BANK = _json.loads(_bp.read_text())
|
| 77 |
+
except Exception:
|
| 78 |
+
_CUE_BANK = {}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _normalize_cue(cue: str) -> str:
|
| 82 |
+
cue = cue.strip().strip('"“”').rstrip(".").strip()
|
| 83 |
+
cue = re.sub(r"(?i)^right now\s+", "", cue).strip()
|
| 84 |
+
return cue or _MOOD_CUE["content"]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _cue_for(mood: str) -> str:
|
| 88 |
+
bank = _CUE_BANK.get(mood)
|
| 89 |
+
if bank:
|
| 90 |
+
return _normalize_cue(random.choice(bank))
|
| 91 |
+
return _normalize_cue(_MOOD_CUE.get(mood, _MOOD_CUE["content"]))
|
| 92 |
+
|
| 93 |
+
# Fallback lines if llama-server is down — keeps the loop demoable offline.
|
| 94 |
+
_FALLBACK = {
|
| 95 |
+
"sleepy": ["mmh… the drum slows. i curl up with it.", "soft… so soft now. i yawn with you."],
|
| 96 |
+
"serene": ["your tide is glass-still. i float.", "calm water in here. i breathe slow."],
|
| 97 |
+
"content": ["steady little drum. i'm happy by it.", "all even. i hum along."],
|
| 98 |
+
"restless": ["oh—it twitches. what stirs you?", "your drum fidgets. i tilt my head."],
|
| 99 |
+
"anxious": ["it's tight and quick… come back to me.", "the beat frets. i hold still for you."],
|
| 100 |
+
"alarmed": ["it POUNDS—! breathe, please, breathe.", "loud, loud, ragged. i grip your hand."],
|
| 101 |
+
"excited": ["you blaze! my whole self lights up!", "fast and bright—i dance in it!"],
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _messages(state: CreatureState) -> list[dict]:
|
| 106 |
+
cue = _cue_for(state.mood)
|
| 107 |
+
trend = {"rising": " and rising", "falling": " and settling"}.get(state.trend, "")
|
| 108 |
+
user = f"Right now {cue}{trend}. Say one line to them."
|
| 109 |
+
return [
|
| 110 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 111 |
+
{"role": "user", "content": "Right now their pulse is slow and heavy. Say one line to them."},
|
| 112 |
+
{"role": "assistant", "content": "mmh… the drum slows. i curl beside it."},
|
| 113 |
+
{"role": "user", "content": "Right now their pulse is fast and tight, unsettled. Say one line to them."},
|
| 114 |
+
{"role": "assistant", "content": "the beat frets. i hold still for you."},
|
| 115 |
+
{"role": "user", "content": user},
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _client_speak(state: CreatureState, *, max_tokens: int = 32,
|
| 120 |
+
temperature: float = 0.8) -> str | None:
|
| 121 |
+
# In-process transformers backend (the deployed Space).
|
| 122 |
+
if _PROVIDER == "transformers":
|
| 123 |
+
from . import hf_model
|
| 124 |
+
text = hf_model.generate(_messages(state), max_new_tokens=max_tokens,
|
| 125 |
+
temperature=temperature)
|
| 126 |
+
return _clean(text) if text else None
|
| 127 |
+
|
| 128 |
+
# HTTP backends (local llama-server / OpenRouter).
|
| 129 |
+
headers = {"Authorization": f"Bearer {API_KEY}"} if API_KEY else {}
|
| 130 |
+
try:
|
| 131 |
+
r = requests.post(
|
| 132 |
+
f"{BASE_URL}/chat/completions",
|
| 133 |
+
headers=headers,
|
| 134 |
+
json={
|
| 135 |
+
"model": MODEL,
|
| 136 |
+
"messages": _messages(state),
|
| 137 |
+
"max_tokens": max_tokens,
|
| 138 |
+
"temperature": temperature,
|
| 139 |
+
"stop": ["\n", "<|im_end|>", "<extra_id_1>", "</s>"],
|
| 140 |
+
},
|
| 141 |
+
timeout=_TIMEOUT,
|
| 142 |
+
)
|
| 143 |
+
r.raise_for_status()
|
| 144 |
+
text = r.json()["choices"][0]["message"]["content"]
|
| 145 |
+
return _clean(text) or None
|
| 146 |
+
except Exception:
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _clean(text: str) -> str:
|
| 151 |
+
"""Strip special tokens / quoting the model sometimes emits."""
|
| 152 |
+
for tok in ("<|im_end|>", "<extra_id_1>", "</s>", "<|endoftext|>"):
|
| 153 |
+
text = text.replace(tok, "")
|
| 154 |
+
text = text.strip().split("\n")[0]
|
| 155 |
+
text = re.sub(r"(?i)^(fenn|assistant|heartbeat|pulse|drum)\s*(whispers|says|murmurs)?\s*:\s*", "", text)
|
| 156 |
+
return text.strip(' "“”-')
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def speak(state: CreatureState) -> tuple[str, str]:
|
| 160 |
+
"""Return (line, source) where source is 'model' or 'fallback'."""
|
| 161 |
+
line = _client_speak(state)
|
| 162 |
+
if line:
|
| 163 |
+
return line, "model"
|
| 164 |
+
return random.choice(_FALLBACK.get(state.mood, _FALLBACK["content"])), "fallback"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def server_alive() -> bool:
|
| 168 |
+
if _PROVIDER == "transformers":
|
| 169 |
+
from . import hf_model
|
| 170 |
+
return hf_model.available()
|
| 171 |
+
if _PROVIDER == "openrouter":
|
| 172 |
+
return bool(API_KEY)
|
| 173 |
+
# local llama-server exposes /health at the root (strip the trailing /v1)
|
| 174 |
+
root = BASE_URL[:-3] if BASE_URL.endswith("/v1") else BASE_URL
|
| 175 |
+
try:
|
| 176 |
+
requests.get(f"{root}/health", timeout=2).raise_for_status()
|
| 177 |
+
return True
|
| 178 |
+
except Exception:
|
| 179 |
+
return False
|
familiar/coach.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
coach.py — "breathe with me": Fenn uses a LEARNED policy to settle a body.
|
| 3 |
+
|
| 4 |
+
The RL proof-of-concept in rl.py is wired into the live app here. On first use we
|
| 5 |
+
train a tiny REINFORCE policy against PulseEnv (the body-simulator grown from real
|
| 6 |
+
ring stats) — ~1-2s on CPU, numpy only. Then a session starts the simulated body
|
| 7 |
+
elevated and lets Fenn's *learned* policy pick a calming action each step; the
|
| 8 |
+
body responds, and the user watches the heart settle while Fenn coaches.
|
| 9 |
+
|
| 10 |
+
Honest scope: the policy is trained in SIMULATION (a model of the body fit to the
|
| 11 |
+
wearer's real percentiles + physiology priors), not on live physiology.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from .sim import PulseEnv, ACTIONS
|
| 18 |
+
from .rl import train, policy_action
|
| 19 |
+
from .creature import render
|
| 20 |
+
from .state import classify
|
| 21 |
+
|
| 22 |
+
_theta = None # cached learned policy (trained once, lazily)
|
| 23 |
+
|
| 24 |
+
# Fenn's coaching line + a paced-breath instruction per action. These are the
|
| 25 |
+
# creature's *own* voice for the breathing mode (kept canned so the animation
|
| 26 |
+
# stays smooth — the Nemotron model carries the other two tabs).
|
| 27 |
+
_ACTION_VOICE = {
|
| 28 |
+
"pace_breath": ("breathe with me… in… and slow out…", "breathe"),
|
| 29 |
+
"reassure": ("i'm here. you're safe. i've got your drum.", "hold"),
|
| 30 |
+
"distract": ("look — a leaf just spun by. follow it with me.", "soft"),
|
| 31 |
+
"stay_quiet": ("…i'll just stay close and quiet now.", "rest"),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# A small paced-breathing guide (≈ resonance breathing). Phase cycles per step.
|
| 35 |
+
_BREATH = [
|
| 36 |
+
"inhale ●○○○○○○○",
|
| 37 |
+
"inhale ●●●●○○○○",
|
| 38 |
+
"inhale ●●●●●●●●",
|
| 39 |
+
"hold ●●●●●●●●",
|
| 40 |
+
"exhale ●●●●○○○○",
|
| 41 |
+
"exhale ●○○○○○○○",
|
| 42 |
+
"rest ○○○○○○○○",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _get_policy():
|
| 47 |
+
global _theta
|
| 48 |
+
if _theta is None:
|
| 49 |
+
_theta, _ = train(episodes=2500, seed=0)
|
| 50 |
+
return _theta
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def breathe_session(start: str = "panic", max_steps: int = 28):
|
| 54 |
+
"""Generator: yield (body, voice_md, breath_md, state_dict, progress_md).
|
| 55 |
+
|
| 56 |
+
Fenn's learned policy acts on the simulated body until it settles or we hit
|
| 57 |
+
max_steps. The displayed HR/HRV is the *simulated* body responding to Fenn.
|
| 58 |
+
"""
|
| 59 |
+
theta = _get_policy()
|
| 60 |
+
env = PulseEnv(start=start, max_steps=max_steps, seed=7)
|
| 61 |
+
obs = env.reset(start=start)
|
| 62 |
+
hr0, hrv0 = float(obs[0]), float(obs[1])
|
| 63 |
+
|
| 64 |
+
for i in range(max_steps):
|
| 65 |
+
a = policy_action(theta, obs)
|
| 66 |
+
action = ACTIONS[a]
|
| 67 |
+
obs, _r, done, info = env.step(a)
|
| 68 |
+
hr, hrv = float(obs[0]), float(obs[1])
|
| 69 |
+
st = classify(hr, hrv)
|
| 70 |
+
|
| 71 |
+
line, _tone = _ACTION_VOICE.get(action, _ACTION_VOICE["stay_quiet"])
|
| 72 |
+
body = render(st.mood, beat_phase=0.7, hr=hr, hrv=hrv)
|
| 73 |
+
voice = (
|
| 74 |
+
f"<div style='font-size:1.2rem;font-style:italic;color:#5aa6a0'>"
|
| 75 |
+
f"“{line}”</div>"
|
| 76 |
+
)
|
| 77 |
+
breath = (
|
| 78 |
+
f"<pre style='font-size:1.15rem;line-height:1.5;color:#7bb86a'>"
|
| 79 |
+
f"{_BREATH[i % len(_BREATH)]}</pre>"
|
| 80 |
+
f"<sub>Fenn chose: <b>{action}</b> · the body is responding</sub>"
|
| 81 |
+
)
|
| 82 |
+
prog = (
|
| 83 |
+
f"settling… step {i + 1}/{max_steps} · "
|
| 84 |
+
f"♥ {hr:.0f} bpm (started {hr0:.0f}) ~ {hrv:.0f} ms (started {hrv0:.0f})"
|
| 85 |
+
)
|
| 86 |
+
yield body, voice, breath, st.as_dict(), prog
|
| 87 |
+
|
| 88 |
+
import time
|
| 89 |
+
time.sleep(0.45)
|
| 90 |
+
if done:
|
| 91 |
+
settled = (
|
| 92 |
+
f"**you settled.** ♥ {hr0:.0f}→{hr:.0f} bpm · ~ {hrv0:.0f}→{hrv:.0f} ms "
|
| 93 |
+
f"in {i + 1} breaths. Fenn learned this in a simulator built from real "
|
| 94 |
+
f"ring data — it trains in ~2 seconds."
|
| 95 |
+
)
|
| 96 |
+
yield body, ("<div style='font-size:1.2rem;font-style:italic;color:#5aa6a0'>"
|
| 97 |
+
"“there… your tide is glass-still again.”</div>"), breath, \
|
| 98 |
+
st.as_dict(), settled
|
| 99 |
+
return
|
familiar/creature.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
creature.py — the familiar's body, drawn in text.
|
| 3 |
+
|
| 4 |
+
One small ASCII creature ("Fenn") with a face per mood and a heartbeat that
|
| 5 |
+
beats: on each beat the chest glyph swells. Everything returns plain strings so
|
| 6 |
+
it renders identically in a terminal, a Gradio textbox, or the gr.Server frontend.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
FACES = _FACES = {
|
| 11 |
+
"sleepy": ("-.-", "_"),
|
| 12 |
+
"serene": ("^.^", "u"),
|
| 13 |
+
"content": ("o.o", "w"),
|
| 14 |
+
"restless": ("o.O", "~"),
|
| 15 |
+
"anxious": ("O.O", "o"),
|
| 16 |
+
"alarmed": (">.<", "!"),
|
| 17 |
+
"excited": ("*.*", "v"),
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# A color hint per mood (ANSI 256 + hex) — used by the gr.Server frontend later.
|
| 21 |
+
MOOD_COLOR = {
|
| 22 |
+
"sleepy": ("#6b7a99", 61),
|
| 23 |
+
"serene": ("#5aa6a0", 73),
|
| 24 |
+
"content": ("#7bb86a", 71),
|
| 25 |
+
"restless": ("#d6a849", 179),
|
| 26 |
+
"anxious": ("#d6803c", 173),
|
| 27 |
+
"alarmed": ("#c0432b", 167),
|
| 28 |
+
"excited": ("#d65a9a", 175),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# heartbeat chest glyphs, indexed by beat phase (0 = rest, 1 = full contraction)
|
| 32 |
+
_HEART = (" . ", " v ", "<3 ", "<3>", " V ")
|
| 33 |
+
|
| 34 |
+
_AURA = {
|
| 35 |
+
"sleepy": " z z",
|
| 36 |
+
"serene": " . .",
|
| 37 |
+
"content": " . .",
|
| 38 |
+
"restless": " ? ?",
|
| 39 |
+
"anxious": " ... ...",
|
| 40 |
+
"alarmed": " ! ! !",
|
| 41 |
+
"excited": " * * *",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
_CHEST = {
|
| 45 |
+
"sleepy": "zzz",
|
| 46 |
+
"serene": "<3",
|
| 47 |
+
"content": "<3",
|
| 48 |
+
"restless": "??",
|
| 49 |
+
"anxious": "!!",
|
| 50 |
+
"alarmed": "!!!",
|
| 51 |
+
"excited": "***",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
_TAIL = {
|
| 55 |
+
"sleepy": "~",
|
| 56 |
+
"serene": "~",
|
| 57 |
+
"content": "~",
|
| 58 |
+
"restless": "?",
|
| 59 |
+
"anxious": "...",
|
| 60 |
+
"alarmed": "!!",
|
| 61 |
+
"excited": "**",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def heart_glyph(beat_phase: float) -> str:
|
| 66 |
+
"""beat_phase in 0..1 -> a chest glyph. ~middle of the cycle = full beat."""
|
| 67 |
+
idx = min(len(_HEART) - 1, int(beat_phase * len(_HEART)))
|
| 68 |
+
return _HEART[idx]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def render(mood: str, *, beat_phase: float = 0.5, hr: float | None = None,
|
| 72 |
+
hrv: float | None = None) -> str:
|
| 73 |
+
"""Return the creature as a multi-line string for the given mood + beat."""
|
| 74 |
+
eyes, mouth = _FACES.get(mood, _FACES["content"])
|
| 75 |
+
heart = _CHEST.get(mood) or heart_glyph(beat_phase).strip()
|
| 76 |
+
vitals = ""
|
| 77 |
+
if hr is not None:
|
| 78 |
+
vitals = f" ♥ {hr:.0f} bpm" + (f" ~ {hrv:.0f} ms" if hrv is not None else "")
|
| 79 |
+
tag = f"{mood.upper()}{vitals}" if hr is not None else ""
|
| 80 |
+
middle = f" | ___ | {tag}" if tag else " | ___ |"
|
| 81 |
+
return (
|
| 82 |
+
f"{_AURA.get(mood, _AURA['content'])}\n"
|
| 83 |
+
" /\\_/\\\n"
|
| 84 |
+
f" _( {eyes} )_\n"
|
| 85 |
+
f" / {mouth:^3} \\\n"
|
| 86 |
+
f" / | {heart:^5} | \\ {_TAIL.get(mood, '~')}\n"
|
| 87 |
+
" /__/|_______|\\__\\\n"
|
| 88 |
+
f"{middle}\n"
|
| 89 |
+
" /| |\\\n"
|
| 90 |
+
" (_| |_)\n"
|
| 91 |
+
" '.___.'\n"
|
| 92 |
+
" .-' '-."
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# Beats-per-minute -> how long one beat cycle lasts, for the animation clock.
|
| 97 |
+
def beat_period_seconds(hr: float) -> float:
|
| 98 |
+
hr = max(30.0, min(220.0, hr))
|
| 99 |
+
return 60.0 / hr
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__": # quick visual smoke test
|
| 103 |
+
for m in _FACES:
|
| 104 |
+
print(render(m, beat_phase=0.5, hr=70, hrv=50))
|
| 105 |
+
print()
|
familiar/features.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
features.py — per-day biometric feature vectors from the real ring history.
|
| 3 |
+
|
| 4 |
+
This is the data layer for two things:
|
| 5 |
+
1. the morning-briefing agent (today's vector vs the wearer's own baselines), and
|
| 6 |
+
2. fine-tune training slice #1 (one grounded example per real day).
|
| 7 |
+
|
| 8 |
+
Source: ~/Health/data/uh_export_raw.jsonl (135K rows, ~65 days with HR over 10mo).
|
| 9 |
+
Each row: {"t": ISO8601, "metric": ..., "value": ..., "ts_epoch": ...}.
|
| 10 |
+
|
| 11 |
+
Key design choices (verified against the real data, 2026-06-09):
|
| 12 |
+
- HR and HRV share EXACT timestamps for ~93% of HR rows, so we INNER-JOIN on
|
| 13 |
+
timestamp (a dict keyed by `t`) — NOT zip-by-index, which misaligns them.
|
| 14 |
+
- Baselines are CAUSAL: each day's z-score uses only days strictly before it
|
| 15 |
+
(14-day EWMA), so the vector is honest for a "this morning" briefing.
|
| 16 |
+
- motion/steps gate the exercise-vs-stress confound: high HR + high motion =
|
| 17 |
+
exertion (good), high HR + low motion = strain/stress (the discriminator
|
| 18 |
+
state.py alone can't make).
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import math
|
| 24 |
+
from collections import defaultdict
|
| 25 |
+
from dataclasses import dataclass, asdict
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
RAW = Path.home() / "Health" / "data" / "uh_export_raw.jsonl"
|
| 29 |
+
OUT = Path(__file__).resolve().parent.parent / "data" / "daily_features.jsonl"
|
| 30 |
+
|
| 31 |
+
NIGHT_HOURS = range(0, 7) # 00:00–06:59 local — sleep-window proxy
|
| 32 |
+
EWMA_HALFLIFE = 14 # days; matches health_pulse.py adaptation scale
|
| 33 |
+
MIN_HISTORY = 3 # need >=3 prior days before a z-score is meaningful
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class DayFeatures:
|
| 38 |
+
date: str
|
| 39 |
+
n_hr: int # aligned (HR,HRV) samples that day
|
| 40 |
+
hr_median: float
|
| 41 |
+
hr_rhr: float | None # min HR in night window = resting proxy
|
| 42 |
+
hr_max: float
|
| 43 |
+
hrv_mean: float
|
| 44 |
+
hrv_night: float | None # mean HRV in night window
|
| 45 |
+
resp_mean: float | None
|
| 46 |
+
temp_mean: float | None
|
| 47 |
+
spo2_min: float | None
|
| 48 |
+
steps_total: float
|
| 49 |
+
motion_mean: float | None
|
| 50 |
+
exertion_frac: float # of high-HR samples, fraction co-occurring w/ motion
|
| 51 |
+
# causal baselines (only days strictly before this one)
|
| 52 |
+
hrv_z: float | None # (night HRV - trailing EWMA) / trailing sd
|
| 53 |
+
rhr_z: float | None
|
| 54 |
+
hrv_trend3: str # 3-day direction of night HRV: rising/falling/steady
|
| 55 |
+
# coarse ordinal cues for the model (Fable: bin, don't feed raw floats)
|
| 56 |
+
hrv_cue: str # much_lower | lower | usual | higher (vs own baseline)
|
| 57 |
+
strain_cue: str # restful | active | strained
|
| 58 |
+
|
| 59 |
+
def as_dict(self) -> dict:
|
| 60 |
+
return asdict(self)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _load_by_day() -> dict[str, dict[str, list]]:
|
| 64 |
+
"""Group raw rows into day -> metric -> list[(t, value)]."""
|
| 65 |
+
days: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
|
| 66 |
+
with open(RAW) as f:
|
| 67 |
+
for line in f:
|
| 68 |
+
o = json.loads(line)
|
| 69 |
+
t = o["t"]
|
| 70 |
+
days[t[:10]][o["metric"]].append((t, o["value"]))
|
| 71 |
+
return days
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _ts_map(rows: list) -> dict[str, float]:
|
| 75 |
+
return {t: v for t, v in rows}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _hour(t: str) -> int:
|
| 79 |
+
# "2025-07-09T17:01:28" -> 17
|
| 80 |
+
return int(t[11:13])
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _ewma(values: list[float]) -> tuple[float, float] | None:
|
| 84 |
+
"""Causal EWMA mean + sd over a sequence (oldest..newest). Returns (mean, sd)."""
|
| 85 |
+
if len(values) < MIN_HISTORY:
|
| 86 |
+
return None
|
| 87 |
+
alpha = 1 - math.exp(math.log(0.5) / EWMA_HALFLIFE)
|
| 88 |
+
mean = values[0]
|
| 89 |
+
var = 0.0
|
| 90 |
+
for v in values[1:]:
|
| 91 |
+
diff = v - mean
|
| 92 |
+
mean += alpha * diff
|
| 93 |
+
var = (1 - alpha) * (var + alpha * diff * diff)
|
| 94 |
+
return mean, math.sqrt(var)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _cue_from_z(z: float | None) -> str:
|
| 98 |
+
if z is None:
|
| 99 |
+
return "usual"
|
| 100 |
+
if z <= -1.5:
|
| 101 |
+
return "much_lower"
|
| 102 |
+
if z <= -0.5:
|
| 103 |
+
return "lower"
|
| 104 |
+
if z >= 0.5:
|
| 105 |
+
return "higher"
|
| 106 |
+
return "usual"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def compute() -> list[DayFeatures]:
|
| 110 |
+
days = _load_by_day()
|
| 111 |
+
ordered = sorted(d for d in days if days[d].get("raw_hr"))
|
| 112 |
+
|
| 113 |
+
night_hrv_history: list[float] = [] # one value per processed day (causal)
|
| 114 |
+
rhr_history: list[float] = []
|
| 115 |
+
out: list[DayFeatures] = []
|
| 116 |
+
|
| 117 |
+
for date in ordered:
|
| 118 |
+
d = days[date]
|
| 119 |
+
hr_map = _ts_map(d.get("raw_hr", []))
|
| 120 |
+
hrv_map = _ts_map(d.get("raw_hrv_2", []))
|
| 121 |
+
# INNER JOIN on exact timestamp — the verified-correct alignment.
|
| 122 |
+
shared = sorted(set(hr_map) & set(hrv_map))
|
| 123 |
+
if not shared:
|
| 124 |
+
continue
|
| 125 |
+
hr = [hr_map[t] for t in shared]
|
| 126 |
+
hrv = [hrv_map[t] for t in shared]
|
| 127 |
+
|
| 128 |
+
hr_sorted = sorted(hr)
|
| 129 |
+
hr_median = hr_sorted[len(hr_sorted) // 2]
|
| 130 |
+
hr_max = max(hr)
|
| 131 |
+
hrv_mean = sum(hrv) / len(hrv)
|
| 132 |
+
|
| 133 |
+
night_idx = [i for i, t in enumerate(shared) if _hour(t) in NIGHT_HOURS]
|
| 134 |
+
hr_rhr = min(hr[i] for i in night_idx) if night_idx else None
|
| 135 |
+
hrv_night = (sum(hrv[i] for i in night_idx) / len(night_idx)) if night_idx else None
|
| 136 |
+
|
| 137 |
+
# motion/steps context (own cadence; aggregate over the day)
|
| 138 |
+
motion_vals = [v for _, v in d.get("raw_motion", [])]
|
| 139 |
+
motion_mean = (sum(motion_vals) / len(motion_vals)) if motion_vals else None
|
| 140 |
+
steps_total = sum(v for _, v in d.get("steps", []))
|
| 141 |
+
resp_vals = [v for _, v in d.get("respiratory_rate", [])]
|
| 142 |
+
resp_mean = (sum(resp_vals) / len(resp_vals)) if resp_vals else None
|
| 143 |
+
temp_vals = [v for _, v in d.get("temp", [])]
|
| 144 |
+
temp_mean = (sum(temp_vals) / len(temp_vals)) if temp_vals else None
|
| 145 |
+
spo2_vals = [v for _, v in d.get("spo2", [])]
|
| 146 |
+
spo2_min = min(spo2_vals) if spo2_vals else None
|
| 147 |
+
|
| 148 |
+
# exertion fraction: of the day's high-HR samples, how many co-occur with
|
| 149 |
+
# real motion? high motion_mean threshold = the exercise/stress discriminator.
|
| 150 |
+
hi = [hr_map[t] for t in shared if hr_map[t] >= (hr_sorted[int(len(hr_sorted) * 0.75)])]
|
| 151 |
+
motion_active = (motion_mean is not None and motion_mean > 90) or steps_total > 200
|
| 152 |
+
exertion_frac = (1.0 if motion_active else 0.0) if hi else 0.0
|
| 153 |
+
|
| 154 |
+
# causal baselines (use history BEFORE appending today)
|
| 155 |
+
ewma_hrv = _ewma(night_hrv_history) if hrv_night is not None else None
|
| 156 |
+
ewma_rhr = _ewma(rhr_history) if hr_rhr is not None else None
|
| 157 |
+
hrv_z = ((hrv_night - ewma_hrv[0]) / ewma_hrv[1]) if (ewma_hrv and ewma_hrv[1] > 0) else None
|
| 158 |
+
rhr_z = ((hr_rhr - ewma_rhr[0]) / ewma_rhr[1]) if (ewma_rhr and ewma_rhr[1] > 0) else None
|
| 159 |
+
|
| 160 |
+
trend3 = "steady"
|
| 161 |
+
if len(night_hrv_history) >= 3 and hrv_night is not None:
|
| 162 |
+
recent = night_hrv_history[-3:]
|
| 163 |
+
if hrv_night > recent[-1] > recent[0]:
|
| 164 |
+
trend3 = "rising"
|
| 165 |
+
elif hrv_night < recent[-1] < recent[0]:
|
| 166 |
+
trend3 = "falling"
|
| 167 |
+
|
| 168 |
+
if motion_active:
|
| 169 |
+
strain_cue = "active"
|
| 170 |
+
elif hr_rhr is not None and rhr_z is not None and rhr_z > 0.8:
|
| 171 |
+
strain_cue = "strained"
|
| 172 |
+
else:
|
| 173 |
+
strain_cue = "restful"
|
| 174 |
+
|
| 175 |
+
out.append(DayFeatures(
|
| 176 |
+
date=date, n_hr=len(shared),
|
| 177 |
+
hr_median=round(hr_median, 1), hr_rhr=round(hr_rhr, 1) if hr_rhr else None,
|
| 178 |
+
hr_max=round(hr_max, 1), hrv_mean=round(hrv_mean, 1),
|
| 179 |
+
hrv_night=round(hrv_night, 1) if hrv_night else None,
|
| 180 |
+
resp_mean=round(resp_mean, 1) if resp_mean else None,
|
| 181 |
+
temp_mean=round(temp_mean, 2) if temp_mean else None,
|
| 182 |
+
spo2_min=spo2_min, steps_total=round(steps_total, 0),
|
| 183 |
+
motion_mean=round(motion_mean, 1) if motion_mean else None,
|
| 184 |
+
exertion_frac=round(exertion_frac, 2),
|
| 185 |
+
hrv_z=round(hrv_z, 2) if hrv_z is not None else None,
|
| 186 |
+
rhr_z=round(rhr_z, 2) if rhr_z is not None else None,
|
| 187 |
+
hrv_trend3=trend3,
|
| 188 |
+
hrv_cue=_cue_from_z(hrv_z), strain_cue=strain_cue,
|
| 189 |
+
))
|
| 190 |
+
|
| 191 |
+
# NOW append today to history (so next day's baseline is causal)
|
| 192 |
+
if hrv_night is not None:
|
| 193 |
+
night_hrv_history.append(hrv_night)
|
| 194 |
+
if hr_rhr is not None:
|
| 195 |
+
rhr_history.append(hr_rhr)
|
| 196 |
+
|
| 197 |
+
return out
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def main() -> None:
|
| 201 |
+
feats = compute()
|
| 202 |
+
OUT.parent.mkdir(parents=True, exist_ok=True)
|
| 203 |
+
with open(OUT, "w") as f:
|
| 204 |
+
for fv in feats:
|
| 205 |
+
f.write(json.dumps(fv.as_dict()) + "\n")
|
| 206 |
+
print(f"wrote {len(feats)} daily feature vectors -> {OUT}")
|
| 207 |
+
# show a few with established baselines
|
| 208 |
+
withz = [fv for fv in feats if fv.hrv_z is not None]
|
| 209 |
+
print(f"{len(withz)} days have a causal HRV baseline (>= {MIN_HISTORY} prior nights)")
|
| 210 |
+
for fv in withz[len(withz) // 2: len(withz) // 2 + 4]:
|
| 211 |
+
print(f" {fv.date}: HRVnight={fv.hrv_night} z={fv.hrv_z} cue={fv.hrv_cue} "
|
| 212 |
+
f"strain={fv.strain_cue} RHR={fv.hr_rhr} steps={fv.steps_total:.0f} trend={fv.hrv_trend3}")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
main()
|
familiar/hf_model.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
hf_model.py — in-process <=4B model for the deployed Space (ZeroGPU).
|
| 3 |
+
|
| 4 |
+
On Hugging Face Spaces there is no external llama-server, so Fenn's voice runs
|
| 5 |
+
*inside the app* on a NVIDIA Nemotron-Mini-4B-Instruct (plain transformer, ≤4B —
|
| 6 |
+
Tiny Titan + Nemotron Quest). On ZeroGPU the GPU is granted per-call via the
|
| 7 |
+
`@spaces.GPU` decorator; everything stays self-hosted (no cloud API calls).
|
| 8 |
+
|
| 9 |
+
This module is import-safe everywhere: torch / transformers / spaces are imported
|
| 10 |
+
lazily, so a laptop without them (or without a GPU) just never selects this
|
| 11 |
+
backend and brain.py falls back to the HTTP providers or canned lines.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
MODEL_ID = os.environ.get("FAMILIAR_HF_MODEL", "nvidia/Nemotron-Mini-4B-Instruct")
|
| 18 |
+
# Optional Fenn LoRA adapter (HF repo id or local path). If set and loadable, the
|
| 19 |
+
# fine-tuned voice is used; otherwise the base model runs prompt-only. Drop-in.
|
| 20 |
+
ADAPTER = os.environ.get(
|
| 21 |
+
"FAMILIAR_ADAPTER", "build-small-hackathon/pulse-familiar-fenn-lora"
|
| 22 |
+
).strip()
|
| 23 |
+
|
| 24 |
+
_tok = None
|
| 25 |
+
_model = None
|
| 26 |
+
_load_error: str | None = None
|
| 27 |
+
_spoke_live = False # flips True the first time the real model generates (honesty)
|
| 28 |
+
|
| 29 |
+
# `spaces` only exists on HF infra; degrade to a no-op decorator off-platform so
|
| 30 |
+
# the same code runs locally on CPU/MPS for testing. Duration is generous: a cold
|
| 31 |
+
# 4B load + first generate must fit inside ONE GPU grant on ZeroGPU, or the first
|
| 32 |
+
# interaction times out and the judge only ever sees the canned fallback.
|
| 33 |
+
try: # pragma: no cover - environment dependent
|
| 34 |
+
import spaces # type: ignore
|
| 35 |
+
|
| 36 |
+
_gpu = spaces.GPU(duration=120)
|
| 37 |
+
except Exception: # pragma: no cover
|
| 38 |
+
def _gpu(fn):
|
| 39 |
+
return fn
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _ensure_loaded() -> bool:
|
| 43 |
+
"""Lazy-load the model+tokenizer once. Returns True if usable."""
|
| 44 |
+
global _tok, _model, _load_error
|
| 45 |
+
if _model is not None:
|
| 46 |
+
return True
|
| 47 |
+
if _load_error is not None:
|
| 48 |
+
return False
|
| 49 |
+
try:
|
| 50 |
+
import torch
|
| 51 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 52 |
+
|
| 53 |
+
use_cuda = torch.cuda.is_available()
|
| 54 |
+
dtype = torch.bfloat16 if use_cuda else torch.float32
|
| 55 |
+
device = "cuda" if use_cuda else "cpu"
|
| 56 |
+
# pass an HF token if present, in case the NVIDIA repo is gated — otherwise
|
| 57 |
+
# from_pretrained 401s and we'd silently fall back (fake-Nemotron trap).
|
| 58 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 59 |
+
kw = {"token": token} if token else {}
|
| 60 |
+
_tok = AutoTokenizer.from_pretrained(MODEL_ID, **kw)
|
| 61 |
+
_model = AutoModelForCausalLM.from_pretrained(
|
| 62 |
+
MODEL_ID, torch_dtype=dtype, low_cpu_mem_usage=True, **kw
|
| 63 |
+
)
|
| 64 |
+
if ADAPTER:
|
| 65 |
+
try:
|
| 66 |
+
from peft import PeftModel
|
| 67 |
+
|
| 68 |
+
_model = PeftModel.from_pretrained(_model, ADAPTER)
|
| 69 |
+
except Exception:
|
| 70 |
+
pass # adapter missing/incompatible -> fall back to base model
|
| 71 |
+
_model = _model.to(device)
|
| 72 |
+
_model.eval()
|
| 73 |
+
return True
|
| 74 |
+
except Exception as e: # transformers/torch missing, OOM, gated repo, etc.
|
| 75 |
+
_load_error = f"{type(e).__name__}: {e}"
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def available() -> bool:
|
| 80 |
+
"""Cheap check used by brain.py / the UI status line."""
|
| 81 |
+
try:
|
| 82 |
+
import importlib.util
|
| 83 |
+
|
| 84 |
+
return all(
|
| 85 |
+
importlib.util.find_spec(m) is not None
|
| 86 |
+
for m in ("torch", "transformers")
|
| 87 |
+
)
|
| 88 |
+
except Exception:
|
| 89 |
+
return False
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@_gpu
|
| 93 |
+
def generate(messages: list[dict], *, max_new_tokens: int = 48,
|
| 94 |
+
temperature: float = 0.9) -> str | None:
|
| 95 |
+
"""Run one short generation. Returns raw text or None on any failure."""
|
| 96 |
+
if not _ensure_loaded():
|
| 97 |
+
return None
|
| 98 |
+
try:
|
| 99 |
+
import torch
|
| 100 |
+
|
| 101 |
+
inputs = _tok.apply_chat_template(
|
| 102 |
+
messages, add_generation_prompt=True, return_tensors="pt"
|
| 103 |
+
).to(_model.device)
|
| 104 |
+
with torch.no_grad():
|
| 105 |
+
out = _model.generate(
|
| 106 |
+
inputs,
|
| 107 |
+
max_new_tokens=max_new_tokens,
|
| 108 |
+
do_sample=True,
|
| 109 |
+
temperature=temperature,
|
| 110 |
+
top_p=0.9,
|
| 111 |
+
pad_token_id=_tok.eos_token_id,
|
| 112 |
+
)
|
| 113 |
+
text = _tok.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True)
|
| 114 |
+
global _spoke_live
|
| 115 |
+
_spoke_live = True
|
| 116 |
+
return text
|
| 117 |
+
except Exception:
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def load_error() -> str | None:
|
| 122 |
+
return _load_error
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def spoke_live() -> bool:
|
| 126 |
+
"""True once the real model has generated at least one line this session."""
|
| 127 |
+
return _spoke_live
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def status() -> dict:
|
| 131 |
+
"""Honest backend state for the UI — so we can SEE Nemotron is real, not faked."""
|
| 132 |
+
return {
|
| 133 |
+
"model_id": MODEL_ID,
|
| 134 |
+
"adapter": ADAPTER or None,
|
| 135 |
+
"loaded": _model is not None,
|
| 136 |
+
"spoke_live": _spoke_live,
|
| 137 |
+
"load_error": _load_error,
|
| 138 |
+
"deps_present": available(),
|
| 139 |
+
}
|
familiar/memories.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
memories.py — load the curated real-ring episodes for the Memories replay.
|
| 3 |
+
|
| 4 |
+
data/sessions.json holds a few short, anonymized episodes carved from the
|
| 5 |
+
wearer's actual Ultrahuman history (see tools/extract_sessions.py). Each is a
|
| 6 |
+
list of {dt, hr, hrv} samples. The UI replays them so Fenn reacts to a *real*
|
| 7 |
+
lived heartbeat, not a synthetic one — the whole point of the project.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
_PATH = Path(__file__).resolve().parent.parent / "data" / "sessions.json"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_episodes() -> list[dict]:
|
| 18 |
+
if not _PATH.exists():
|
| 19 |
+
return []
|
| 20 |
+
try:
|
| 21 |
+
return json.loads(_PATH.read_text()).get("episodes", [])
|
| 22 |
+
except Exception:
|
| 23 |
+
return []
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def episode_titles() -> list[tuple[str, str]]:
|
| 27 |
+
"""(label, id) pairs for a Gradio dropdown."""
|
| 28 |
+
return [(e["title"], e["id"]) for e in load_episodes()]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_episode(ep_id: str) -> dict | None:
|
| 32 |
+
for e in load_episodes():
|
| 33 |
+
if e["id"] == ep_id:
|
| 34 |
+
return e
|
| 35 |
+
return None
|
familiar/rl.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
rl.py — can Fenn LEARN to calm a body? A tiny REINFORCE policy on PulseEnv.
|
| 3 |
+
|
| 4 |
+
This is the "fun" proof-of-concept: a linear softmax policy over the (normalized)
|
| 5 |
+
state picks one of the calming ACTIONS each step; we train it by policy gradient
|
| 6 |
+
against the simulated body (sim.PulseEnv). No torch — numpy only, trains in
|
| 7 |
+
seconds on CPU because the environment is the mock built from real ring stats.
|
| 8 |
+
|
| 9 |
+
What it should learn: when the heart is elevated, prefer pace_breath / reassure;
|
| 10 |
+
when already calm, stop fussing. A learned policy should beat a random one and a
|
| 11 |
+
single fixed action. Honest scope: trained in SIMULATION (a model of the body),
|
| 12 |
+
not on live physiology — that's the disclosure for the writeup.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from .sim import PulseEnv, ACTIONS
|
| 19 |
+
|
| 20 |
+
_N_ACT = len(ACTIONS)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _features(obs: np.ndarray) -> np.ndarray:
|
| 24 |
+
"""Normalize (hr, hrv, step) -> small feature vector + bias."""
|
| 25 |
+
hr, hrv, step = obs
|
| 26 |
+
return np.array([1.0, (hr - 80) / 30.0, (hrv - 120) / 60.0, step / 40.0])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _policy(theta: np.ndarray, feat: np.ndarray):
|
| 30 |
+
logits = theta @ feat # (n_act,)
|
| 31 |
+
logits -= logits.max()
|
| 32 |
+
p = np.exp(logits)
|
| 33 |
+
return p / p.sum()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def train(episodes: int = 4000, lr: float = 0.02, seed: int = 0,
|
| 37 |
+
starts=("stress", "panic", "light")):
|
| 38 |
+
rng = np.random.default_rng(seed)
|
| 39 |
+
theta = np.zeros((_N_ACT, 4))
|
| 40 |
+
env = PulseEnv(seed=seed)
|
| 41 |
+
returns = []
|
| 42 |
+
for ep in range(episodes):
|
| 43 |
+
start = starts[ep % len(starts)]
|
| 44 |
+
obs = env.reset(start=start)
|
| 45 |
+
traj = [] # (feat, action, reward)
|
| 46 |
+
done = False
|
| 47 |
+
while not done:
|
| 48 |
+
feat = _features(obs)
|
| 49 |
+
p = _policy(theta, feat)
|
| 50 |
+
a = int(rng.choice(_N_ACT, p=p))
|
| 51 |
+
obs, r, done, _ = env.step(a)
|
| 52 |
+
traj.append((feat, a, r))
|
| 53 |
+
# discounted returns-to-go
|
| 54 |
+
G, gamma, grets = 0.0, 0.97, []
|
| 55 |
+
for (_, _, r) in reversed(traj):
|
| 56 |
+
G = r + gamma * G
|
| 57 |
+
grets.append(G)
|
| 58 |
+
grets.reverse()
|
| 59 |
+
grets = np.array(grets)
|
| 60 |
+
grets = (grets - grets.mean()) / (grets.std() + 1e-6) # baseline
|
| 61 |
+
# REINFORCE update
|
| 62 |
+
for (feat, a, _), Gt in zip(traj, grets):
|
| 63 |
+
p = _policy(theta, feat)
|
| 64 |
+
grad = -np.outer(p, feat)
|
| 65 |
+
grad[a] += feat
|
| 66 |
+
theta += lr * Gt * grad
|
| 67 |
+
returns.append(sum(r for _, _, r in traj))
|
| 68 |
+
return theta, returns
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def policy_action(theta: np.ndarray, obs: np.ndarray) -> int:
|
| 72 |
+
return int(np.argmax(_policy(theta, _features(obs))))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _eval(theta, n=300, start="stress", seed=99):
|
| 76 |
+
env = PulseEnv(seed=seed)
|
| 77 |
+
rng = np.random.default_rng(seed)
|
| 78 |
+
learned = fixed = rand = 0.0
|
| 79 |
+
for i in range(n):
|
| 80 |
+
# learned (greedy)
|
| 81 |
+
obs, done, tot = env.reset(start=start), False, 0.0
|
| 82 |
+
while not done:
|
| 83 |
+
obs, r, done, _ = env.step(policy_action(theta, obs)); tot += r
|
| 84 |
+
learned += tot
|
| 85 |
+
# fixed: always pace_breath
|
| 86 |
+
obs, done, tot = env.reset(start=start), False, 0.0
|
| 87 |
+
while not done:
|
| 88 |
+
obs, r, done, _ = env.step(ACTIONS.index("pace_breath")); tot += r
|
| 89 |
+
fixed += tot
|
| 90 |
+
# random
|
| 91 |
+
obs, done, tot = env.reset(start=start), False, 0.0
|
| 92 |
+
while not done:
|
| 93 |
+
obs, r, done, _ = env.step(int(rng.integers(_N_ACT))); tot += r
|
| 94 |
+
rand += tot
|
| 95 |
+
return learned / n, fixed / n, rand / n
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
theta, returns = train(episodes=4000, seed=0)
|
| 100 |
+
early = np.mean(returns[:200]); late = np.mean(returns[-200:])
|
| 101 |
+
print(f"train return: {early:.2f} (early) -> {late:.2f} (late)")
|
| 102 |
+
for start in ("stress", "panic"):
|
| 103 |
+
L, F, R = _eval(theta, start=start)
|
| 104 |
+
print(f"[{start:6}] learned={L:6.2f} fixed(pace)={F:6.2f} random={R:6.2f}")
|
| 105 |
+
# what did it learn?
|
| 106 |
+
print("\nlearned action by state:")
|
| 107 |
+
for hr, hrv in [(120, 30), (95, 70), (70, 140), (60, 160)]:
|
| 108 |
+
a = ACTIONS[policy_action(theta, np.array([hr, hrv, 5]))]
|
| 109 |
+
print(f" HR{hr:3} HRV{hrv:3} -> {a}")
|
familiar/sim.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
sim.py — a mock heartbeat, grown from real data.
|
| 3 |
+
|
| 4 |
+
Two jobs, one model:
|
| 5 |
+
1. generate_session(arc) -> a realistic HR/HRV time-series the creature can
|
| 6 |
+
"live through" (demo/replay without needing a fresh ring export).
|
| 7 |
+
2. PulseEnv -> a reinforcement-learning environment: Fenn picks a calming
|
| 8 |
+
action, the simulated body responds, reward = the body settling. Trainable
|
| 9 |
+
in minutes on CPU because episodes are simulated, not lived.
|
| 10 |
+
|
| 11 |
+
Grounding (measured from ~/Health/data/uh_export_raw.jsonl, n=14.3K HR / 16.8K HRV):
|
| 12 |
+
HR median 72, resting ~62, sd 21, p05 46 (deep sleep) .. p95 113 (exertion)
|
| 13 |
+
HRV median 131, sd 67, p05 5 .. p95 235
|
| 14 |
+
HR 5-min step sd ~18 (cross-regime); within a regime it's much smaller.
|
| 15 |
+
|
| 16 |
+
Physiology priors (established, not measured here):
|
| 17 |
+
- Higher arousal -> higher HR, lower HRV.
|
| 18 |
+
- STRESS vs EXERCISE both raise HR, but stress crushes HRV while a relaxed
|
| 19 |
+
high-HR (excitement) keeps HRV up. The env reward leans on this.
|
| 20 |
+
- Slow paced breathing (~6 breaths/min, resonance freq) raises HRV / lowers HR.
|
| 21 |
+
|
| 22 |
+
Determinism: no global RNG. Pass a seed; we derive a numpy Generator from it so
|
| 23 |
+
runs are reproducible (and the workflow/Date-free constraint is respected).
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
# Regime archetypes: (target_hr, target_hrv). Pulled from the real percentiles.
|
| 32 |
+
REGIMES = {
|
| 33 |
+
"deep_sleep": (48.0, 200.0),
|
| 34 |
+
"rest": (62.0, 150.0),
|
| 35 |
+
"calm": (66.0, 145.0),
|
| 36 |
+
"light": (88.0, 95.0),
|
| 37 |
+
"exercise": (118.0, 55.0), # high HR, but HRV not floored -> "excited"
|
| 38 |
+
"stress": (102.0, 38.0), # high HR AND crushed HRV -> "anxious/alarmed"
|
| 39 |
+
"panic": (128.0, 22.0),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# how fast HR pulls toward the regime target each step (Ornstein-Uhlenbeck theta)
|
| 43 |
+
_THETA_HR = 0.25
|
| 44 |
+
_THETA_HRV = 0.20
|
| 45 |
+
_SAMPLE_SEC = 30 # one simulated reading per 30s (denser than the 5-min export)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class Reading:
|
| 50 |
+
t: float # seconds from session start
|
| 51 |
+
hr: float
|
| 52 |
+
hrv: float
|
| 53 |
+
regime: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _step(hr, hrv, tgt_hr, tgt_hrv, rng, vol_hr=2.2, vol_hrv=7.0):
|
| 57 |
+
"""One OU step toward a regime target, with noise. Returns (hr, hrv)."""
|
| 58 |
+
hr = hr + _THETA_HR * (tgt_hr - hr) + rng.normal(0, vol_hr)
|
| 59 |
+
hrv = hrv + _THETA_HRV * (tgt_hrv - hrv) + rng.normal(0, vol_hrv)
|
| 60 |
+
return float(np.clip(hr, 38, 200)), float(np.clip(hrv, 4, 260))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def generate_session(arc: list[tuple[str, float]], *, seed: int = 0,
|
| 64 |
+
sample_sec: int = _SAMPLE_SEC) -> list[Reading]:
|
| 65 |
+
"""arc = [(regime, minutes), ...] -> a smooth HR/HRV walk through those regimes.
|
| 66 |
+
|
| 67 |
+
Example (a stressful afternoon that resolves):
|
| 68 |
+
[("calm", 2), ("stress", 4), ("panic", 1.5), ("rest", 4)]
|
| 69 |
+
"""
|
| 70 |
+
rng = np.random.default_rng(seed)
|
| 71 |
+
hr, hrv = REGIMES.get(arc[0][0], REGIMES["rest"])
|
| 72 |
+
out: list[Reading] = []
|
| 73 |
+
t = 0.0
|
| 74 |
+
for regime, minutes in arc:
|
| 75 |
+
tgt_hr, tgt_hrv = REGIMES.get(regime, REGIMES["rest"])
|
| 76 |
+
for _ in range(int(minutes * 60 / sample_sec)):
|
| 77 |
+
hr, hrv = _step(hr, hrv, tgt_hr, tgt_hrv, rng)
|
| 78 |
+
out.append(Reading(round(t, 1), round(hr, 1), round(hrv, 1), regime))
|
| 79 |
+
t += sample_sec
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# RL environment — Fenn tries to settle a simulated body.
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
ACTIONS = ("stay_quiet", "pace_breath", "reassure", "distract")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class PulseEnv:
|
| 90 |
+
"""Contextual episodic env. State = (hr, hrv, step). Fenn picks an action;
|
| 91 |
+
the body responds; reward favors HRV up + HR toward rest. The 'right' action
|
| 92 |
+
depends on the current state (pace_breath helps a racing heart; staying quiet
|
| 93 |
+
is fine when already calm) — so a learned policy beats a fixed one.
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
def __init__(self, *, max_steps: int = 40, start: str = "stress", seed: int = 0):
|
| 97 |
+
self.max_steps = max_steps
|
| 98 |
+
self.start_regime = start
|
| 99 |
+
self.rng = np.random.default_rng(seed)
|
| 100 |
+
self.reset()
|
| 101 |
+
|
| 102 |
+
def reset(self, start: str | None = None):
|
| 103 |
+
hr, hrv = REGIMES.get(start or self.start_regime, REGIMES["stress"])
|
| 104 |
+
self.hr, self.hrv = hr, hrv
|
| 105 |
+
self.step_i = 0
|
| 106 |
+
return self._obs()
|
| 107 |
+
|
| 108 |
+
def _obs(self):
|
| 109 |
+
return np.array([self.hr, self.hrv, self.step_i], dtype=np.float32)
|
| 110 |
+
|
| 111 |
+
def step(self, action: int):
|
| 112 |
+
a = ACTIONS[action]
|
| 113 |
+
# action shifts the body's *target* (where the OU pull goes) + a compliance
|
| 114 |
+
# roll (the human doesn't always follow). pace_breath is strongest when the
|
| 115 |
+
# heart is elevated; distract can backfire when already calm.
|
| 116 |
+
tgt_hr, tgt_hrv = self.hr, self.hrv
|
| 117 |
+
elevated = self.hr > 80
|
| 118 |
+
comply = self.rng.random() < (0.85 if a == "pace_breath" else 0.6)
|
| 119 |
+
if comply:
|
| 120 |
+
if a == "pace_breath":
|
| 121 |
+
tgt_hr, tgt_hrv = (62.0, 150.0) if elevated else (self.hr - 2, self.hrv + 8)
|
| 122 |
+
elif a == "reassure":
|
| 123 |
+
tgt_hr, tgt_hrv = self.hr - 6, self.hrv + 18
|
| 124 |
+
elif a == "distract":
|
| 125 |
+
# helps if anxious, slightly agitates if already calm
|
| 126 |
+
tgt_hr = self.hr - 8 if elevated else self.hr + 4
|
| 127 |
+
tgt_hrv = self.hrv + 10 if elevated else self.hrv - 6
|
| 128 |
+
else: # stay_quiet: gentle natural drift toward rest
|
| 129 |
+
tgt_hr, tgt_hrv = self.hr - 1.5, self.hrv + 3
|
| 130 |
+
|
| 131 |
+
hrv_before = self.hrv
|
| 132 |
+
self.hr, self.hrv = _step(self.hr, self.hrv, tgt_hr, tgt_hrv, self.rng,
|
| 133 |
+
vol_hr=1.5, vol_hrv=5.0)
|
| 134 |
+
self.step_i += 1
|
| 135 |
+
|
| 136 |
+
# reward: HRV gained this step + a pull toward restful HR, scaled small
|
| 137 |
+
reward = (self.hrv - hrv_before) / 10.0 + (70 - self.hr) / 60.0
|
| 138 |
+
done = self.step_i >= self.max_steps or (self.hr < 66 and self.hrv > 140)
|
| 139 |
+
if done and self.hr < 66 and self.hrv > 140:
|
| 140 |
+
reward += 5.0 # bonus for actually settling the body
|
| 141 |
+
return self._obs(), float(reward), done, {"action": a, "complied": comply}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
if __name__ == "__main__": # smoke: generate a session + a random-policy rollout
|
| 145 |
+
from .state import classify
|
| 146 |
+
sess = generate_session([("calm", 1), ("stress", 2), ("panic", 1), ("rest", 2)], seed=1)
|
| 147 |
+
print(f"session: {len(sess)} readings, {sess[-1].t/60:.1f} min")
|
| 148 |
+
for r in sess[::8]:
|
| 149 |
+
print(f" t={r.t/60:4.1f}m HR={r.hr:5.1f} HRV={r.hrv:5.0f} {r.regime:10} -> {classify(r.hr, r.hrv).mood}")
|
| 150 |
+
print("\nrandom-policy env rollout:")
|
| 151 |
+
env = PulseEnv(start="stress", seed=2)
|
| 152 |
+
rng = np.random.default_rng(2)
|
| 153 |
+
obs, tot = env.reset(), 0.0
|
| 154 |
+
for _ in range(40):
|
| 155 |
+
obs, rew, done, info = env.step(int(rng.integers(len(ACTIONS))))
|
| 156 |
+
tot += rew
|
| 157 |
+
if done:
|
| 158 |
+
break
|
| 159 |
+
print(f" ended HR={obs[0]:.0f} HRV={obs[1]:.0f} after {int(obs[2])} steps, return={tot:.2f}")
|
familiar/state.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
state.py — turn a heartbeat into a creature's emotional state.
|
| 3 |
+
|
| 4 |
+
Pure, deterministic. No model here: this is the bridge between physiology and
|
| 5 |
+
feeling. HR + HRV (relative to the wearer's own baseline) collapse into one of a
|
| 6 |
+
small set of moods plus an intensity 0..1. The model (brain.py) only ever sees
|
| 7 |
+
the *mood*, never raw clinical numbers — the creature feels, it doesn't diagnose.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass, asdict
|
| 12 |
+
|
| 13 |
+
# The wearer's real baselines, measured from ~/Health/data/uh_export_raw.jsonl
|
| 14 |
+
# (14.3K raw_hr, 16.8K raw_hrv_2 samples): HR median 72 / resting ~62, sd 21;
|
| 15 |
+
# HRV (raw_hrv_2) median 131, sd 67. Spreads below are the std-devs we
|
| 16 |
+
# standardize against. Overridable per-person in pulse.py.
|
| 17 |
+
DEFAULT_HR_BASELINE = 62.0 # bpm, calm resting
|
| 18 |
+
DEFAULT_HRV_BASELINE = 140.0 # ms, rested (real median ~131)
|
| 19 |
+
DEFAULT_HR_SPREAD = 16.0 # bpm, ~0.75 of real sd (resting-context, not all-day)
|
| 20 |
+
DEFAULT_HRV_SPREAD = 55.0 # ms
|
| 21 |
+
|
| 22 |
+
# Moods, ordered roughly low-arousal -> high-arousal. Each maps to an ASCII face
|
| 23 |
+
# in creature.py and a voice register in brain.py.
|
| 24 |
+
MOODS = ("sleepy", "serene", "content", "restless", "anxious", "alarmed", "excited")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class CreatureState:
|
| 29 |
+
mood: str # one of MOODS
|
| 30 |
+
intensity: float # 0..1, how strongly the mood is felt
|
| 31 |
+
hr: float # current heart rate (bpm)
|
| 32 |
+
hrv: float # current HRV (ms)
|
| 33 |
+
hr_z: float # HR standardized vs baseline (+ = faster than usual)
|
| 34 |
+
hrv_z: float # HRV standardized vs baseline (+ = more relaxed)
|
| 35 |
+
trend: str # "rising" | "falling" | "steady" — short-term HR drift
|
| 36 |
+
|
| 37 |
+
def as_dict(self) -> dict:
|
| 38 |
+
return asdict(self)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _z(value: float, baseline: float, spread: float) -> float:
|
| 42 |
+
"""Standardize a reading against the wearer's own baseline."""
|
| 43 |
+
if spread <= 0:
|
| 44 |
+
spread = 1.0
|
| 45 |
+
return (value - baseline) / spread
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def classify(
|
| 49 |
+
hr: float,
|
| 50 |
+
hrv: float,
|
| 51 |
+
*,
|
| 52 |
+
hr_baseline: float = DEFAULT_HR_BASELINE,
|
| 53 |
+
hrv_baseline: float = DEFAULT_HRV_BASELINE,
|
| 54 |
+
hr_spread: float = DEFAULT_HR_SPREAD,
|
| 55 |
+
hrv_spread: float = DEFAULT_HRV_SPREAD,
|
| 56 |
+
prev_hr: float | None = None,
|
| 57 |
+
) -> CreatureState:
|
| 58 |
+
"""Map a single (HR, HRV) reading to a CreatureState.
|
| 59 |
+
|
| 60 |
+
Arousal is driven mostly by HR (how fast the heart is going relative to the
|
| 61 |
+
wearer's normal); HRV refines it (low HRV under a high HR = stress, not joy).
|
| 62 |
+
"""
|
| 63 |
+
hr_z = _z(hr, hr_baseline, hr_spread)
|
| 64 |
+
hrv_z = _z(hrv, hrv_baseline, hrv_spread)
|
| 65 |
+
|
| 66 |
+
# arousal: faster-than-usual heart pushes up; high HRV pulls back toward calm
|
| 67 |
+
arousal = hr_z - 0.4 * hrv_z
|
| 68 |
+
|
| 69 |
+
if arousal < -1.2:
|
| 70 |
+
mood = "sleepy"
|
| 71 |
+
elif arousal < -0.4:
|
| 72 |
+
mood = "serene"
|
| 73 |
+
elif arousal < 0.5:
|
| 74 |
+
mood = "content"
|
| 75 |
+
elif arousal < 1.3:
|
| 76 |
+
mood = "restless"
|
| 77 |
+
elif arousal < 2.6:
|
| 78 |
+
# high arousal: low HRV => anxious (stress), decent HRV => excited (good)
|
| 79 |
+
mood = "anxious" if hrv_z < -0.1 else "excited"
|
| 80 |
+
else:
|
| 81 |
+
mood = "alarmed" if hrv_z < 0.0 else "excited"
|
| 82 |
+
|
| 83 |
+
intensity = max(0.0, min(1.0, abs(arousal) / 2.5))
|
| 84 |
+
|
| 85 |
+
trend = "steady"
|
| 86 |
+
if prev_hr is not None:
|
| 87 |
+
d = hr - prev_hr
|
| 88 |
+
if d > 2.5:
|
| 89 |
+
trend = "rising"
|
| 90 |
+
elif d < -2.5:
|
| 91 |
+
trend = "falling"
|
| 92 |
+
|
| 93 |
+
return CreatureState(
|
| 94 |
+
mood=mood,
|
| 95 |
+
intensity=round(intensity, 3),
|
| 96 |
+
hr=round(hr, 1),
|
| 97 |
+
hrv=round(hrv, 1),
|
| 98 |
+
hr_z=round(hr_z, 2),
|
| 99 |
+
hrv_z=round(hrv_z, 2),
|
| 100 |
+
trend=trend,
|
| 101 |
+
)
|
familiar/trace.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
trace.py — append-only log of every (state -> prompt -> utterance) the familiar
|
| 3 |
+
produces. Uploaded later as an HF dataset (Sharing-is-Caring badge) so others can
|
| 4 |
+
study how a tiny model voices a creature from biometrics.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from .state import CreatureState
|
| 14 |
+
|
| 15 |
+
_TRACE_PATH = Path(os.environ.get("TRACE_PATH", "data/traces.jsonl"))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def log(state: CreatureState, line: str, source: str) -> None:
|
| 19 |
+
_TRACE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
rec = {
|
| 21 |
+
"ts": round(time.time(), 3),
|
| 22 |
+
"mood": state.mood,
|
| 23 |
+
"intensity": state.intensity,
|
| 24 |
+
"hr": state.hr,
|
| 25 |
+
"hrv": state.hrv,
|
| 26 |
+
"hr_z": state.hr_z,
|
| 27 |
+
"hrv_z": state.hrv_z,
|
| 28 |
+
"trend": state.trend,
|
| 29 |
+
"line": line,
|
| 30 |
+
"source": source,
|
| 31 |
+
}
|
| 32 |
+
with _TRACE_PATH.open("a") as f:
|
| 33 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==5.49.1
|
| 2 |
+
spaces
|
| 3 |
+
torch
|
| 4 |
+
transformers>=4.44.0
|
| 5 |
+
accelerate>=0.33.0
|
| 6 |
+
peft>=0.12.0
|
| 7 |
+
sentencepiece
|
| 8 |
+
protobuf
|
| 9 |
+
numpy
|
| 10 |
+
requests
|