sriharsha150 commited on
Commit
f55e3c6
·
verified ·
1 Parent(s): f8b009e

Deploy Pulse Familiar Space

Browse files
README.md CHANGED
@@ -1,13 +1,115 @@
1
  ---
2
  title: Pulse Familiar
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.12'
9
  app_file: app.py
 
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &nbsp; ~ {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