Skip to content

Commit 48db8c7

Browse files
hanamorixHana
andauthored
feat(user-preferences): split GUI cadence from internal calibration (PR-C) (#15)
Per principle audit (PR-C): introduces `user_preferences.json` as the single GUI-surfaceable cadence file. heartbeat_config.json remains, but is now formally documented as developer-only internal calibration — decay rates, GC thresholds, gating thresholds, reflex/research enable flags. The GUI must never read or write that file. Currently `dream_every_hours` is the only field in user_preferences.json, matching the principle: the user picks how often the brain dreams; the brain owns everything else. Resolution order in HeartbeatConfig.load(): 1. heartbeat_config.json's value (or framework default 24.0) 2. user_preferences.json's value WHEN the field is explicitly present The "explicitly present" check via `read_raw_keys()` matters for back-compat: a user_preferences.json that adds a new future field (e.g., growth_every_hours once Phase 2a lands) must not silently shadow a custom dream_every_hours sitting in heartbeat_config.json from before this split. Adds: - `brain/user_preferences.py` — `UserPreferences` dataclass with atomic load/save and a module-level `read_raw_keys()` helper. - 5 new precedence tests on HeartbeatConfig.load (override / missing / present-but-omits-field / only-user-prefs / corrupt-prefs). - 11 new unit tests on UserPreferences and read_raw_keys. Net: 492 tests passing (was 476, +16 new). Co-authored-by: Hana <hana@nanoclaw.local>
1 parent b97509c commit 48db8c7

4 files changed

Lines changed: 253 additions & 2 deletions

File tree

brain/engines/heartbeat.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import logging
1313
import os
14-
from dataclasses import dataclass, field
14+
from dataclasses import dataclass, field, replace
1515
from datetime import UTC, datetime
1616
from pathlib import Path
1717
from typing import Literal, get_args
@@ -32,7 +32,22 @@
3232

3333
@dataclass
3434
class HeartbeatConfig:
35-
"""Per-persona heartbeat configuration. Loaded from heartbeat_config.json."""
35+
"""Per-persona heartbeat configuration.
36+
37+
Two-file resolution per principle audit 2026-04-25 (PR-C):
38+
39+
1. `heartbeat_config.json` — developer-only internal calibration. The
40+
GUI never reads or writes this file. Holds decay/GC/threshold knobs
41+
that calibrate the brain's physiology.
42+
2. `user_preferences.json` — the GUI-surfaceable cadence file. When
43+
a field is present here (currently only `dream_every_hours`), it
44+
takes precedence over heartbeat_config.json. Missing or absent-key
45+
→ fall back to heartbeat_config.json's value (back-compat).
46+
47+
`dream_every_hours` is the one field that legitimately belongs to the
48+
user. Everything else on this dataclass is internal — exposing it in
49+
a GUI would let the user disable parts of the brain's autonomy.
50+
"""
3651

3752
dream_every_hours: float = 24.0
3853
decay_rate_per_tick: float = 0.01
@@ -48,6 +63,29 @@ class HeartbeatConfig:
4863

4964
@classmethod
5065
def load(cls, path: Path) -> HeartbeatConfig:
66+
"""Load heartbeat_config.json, then merge user_preferences.json if present.
67+
68+
`path` points at heartbeat_config.json. user_preferences.json is
69+
looked up next to it (`path.parent / "user_preferences.json"`).
70+
"""
71+
cfg = cls._load_internal(path)
72+
73+
# Merge user_preferences.json — only override fields explicitly
74+
# present in the file, so a user_preferences.json that omits
75+
# dream_every_hours doesn't shadow a custom value set in
76+
# heartbeat_config.json (back-compat for pre-PR-C personas).
77+
from brain.user_preferences import UserPreferences, read_raw_keys
78+
79+
user_prefs_path = path.parent / "user_preferences.json"
80+
explicit_keys = read_raw_keys(user_prefs_path)
81+
if "dream_every_hours" in explicit_keys:
82+
prefs = UserPreferences.load(user_prefs_path)
83+
cfg = replace(cfg, dream_every_hours=prefs.dream_every_hours)
84+
return cfg
85+
86+
@classmethod
87+
def _load_internal(cls, path: Path) -> HeartbeatConfig:
88+
"""Load heartbeat_config.json only — the developer-calibration layer."""
5189
if not path.exists():
5290
return cls()
5391
try:

brain/user_preferences.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Per-persona user preferences — the GUI-surfaceable cadence knobs.
2+
3+
Lives at `{persona_dir}/user_preferences.json`. This is the *only* file the
4+
end-user GUI reads or writes. Everything else (heartbeat_config.json,
5+
persona_config.json, the SQLite stores) is brain-internal.
6+
7+
Per principle audit 2026-04-25 (PR-C): the user surfaces are name, cadence
8+
(this file), face/body, and reading generated documents. heartbeat_config.json
9+
holds developer-only internal calibration (decay rates, GC thresholds, gating
10+
thresholds) that the GUI must never expose.
11+
12+
Currently ships only `dream_every_hours`. Future GUI cadence knobs land here
13+
(e.g., the Phase 2a `growth_every_hours` once that lands).
14+
15+
When both heartbeat_config.json and user_preferences.json define the same
16+
cadence field, user_preferences.json wins — the GUI is authoritative.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import os
23+
from dataclasses import dataclass
24+
from pathlib import Path
25+
26+
DEFAULT_DREAM_EVERY_HOURS = 24.0
27+
28+
29+
@dataclass
30+
class UserPreferences:
31+
"""GUI-surfaceable cadence preferences.
32+
33+
Hand-edited corruption degrades to defaults rather than crashing —
34+
same UX policy as HeartbeatConfig and PersonaConfig.
35+
"""
36+
37+
dream_every_hours: float = DEFAULT_DREAM_EVERY_HOURS
38+
39+
@classmethod
40+
def load(cls, path: Path) -> UserPreferences:
41+
if not path.exists():
42+
return cls()
43+
try:
44+
data = json.loads(path.read_text(encoding="utf-8"))
45+
except json.JSONDecodeError:
46+
return cls()
47+
if not isinstance(data, dict):
48+
return cls()
49+
try:
50+
return cls(
51+
dream_every_hours=float(
52+
data.get("dream_every_hours", DEFAULT_DREAM_EVERY_HOURS)
53+
),
54+
)
55+
except (TypeError, ValueError):
56+
return cls()
57+
58+
def save(self, path: Path) -> None:
59+
"""Atomic save via .new + os.replace."""
60+
payload = {"dream_every_hours": self.dream_every_hours}
61+
tmp = path.with_suffix(path.suffix + ".new")
62+
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
63+
os.replace(tmp, path)
64+
65+
66+
def read_raw_keys(path: Path) -> set[str]:
67+
"""Return the set of keys explicitly present in user_preferences.json.
68+
69+
Used by HeartbeatConfig.load to distinguish "file omits this field, fall
70+
back to heartbeat_config.json" from "file sets this field to its default
71+
value, override heartbeat_config.json". Returns empty set on any error
72+
(missing file, corrupt JSON, non-object payload).
73+
"""
74+
if not path.exists():
75+
return set()
76+
try:
77+
data = json.loads(path.read_text(encoding="utf-8"))
78+
except json.JSONDecodeError:
79+
return set()
80+
if not isinstance(data, dict):
81+
return set()
82+
return set(data.keys())

tests/unit/brain/engines/test_heartbeat.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,55 @@ def test_heartbeat_config_invalid_emit_memory_falls_back_to_default(tmp_path: Pa
8484
assert c.emit_memory == "conditional"
8585

8686

87+
# ---- PR-C: user_preferences.json merges over heartbeat_config.json ----
88+
89+
90+
def test_user_preferences_overrides_dream_every_hours(tmp_path: Path) -> None:
91+
"""When user_preferences.json sets dream_every_hours, it wins over heartbeat_config.json."""
92+
cfg_path = tmp_path / "heartbeat_config.json"
93+
cfg_path.write_text(json.dumps({"dream_every_hours": 24.0}))
94+
(tmp_path / "user_preferences.json").write_text(json.dumps({"dream_every_hours": 6.0}))
95+
c = HeartbeatConfig.load(cfg_path)
96+
assert c.dream_every_hours == 6.0
97+
98+
99+
def test_user_preferences_missing_falls_back_to_heartbeat_config(tmp_path: Path) -> None:
100+
"""No user_preferences.json → heartbeat_config.json's value stands (back-compat)."""
101+
cfg_path = tmp_path / "heartbeat_config.json"
102+
cfg_path.write_text(json.dumps({"dream_every_hours": 8.0}))
103+
c = HeartbeatConfig.load(cfg_path)
104+
assert c.dream_every_hours == 8.0
105+
106+
107+
def test_user_preferences_present_but_omits_field(tmp_path: Path) -> None:
108+
"""user_preferences.json without dream_every_hours doesn't shadow heartbeat_config.json.
109+
110+
Critical for back-compat: a future user_preferences.json with new fields
111+
must not silently reset dream_every_hours to the default.
112+
"""
113+
cfg_path = tmp_path / "heartbeat_config.json"
114+
cfg_path.write_text(json.dumps({"dream_every_hours": 8.0}))
115+
(tmp_path / "user_preferences.json").write_text(json.dumps({"some_future_field": "x"}))
116+
c = HeartbeatConfig.load(cfg_path)
117+
assert c.dream_every_hours == 8.0
118+
119+
120+
def test_user_preferences_only_no_heartbeat_config(tmp_path: Path) -> None:
121+
"""user_preferences.json drives dream_every_hours when heartbeat_config.json is absent."""
122+
(tmp_path / "user_preferences.json").write_text(json.dumps({"dream_every_hours": 12.0}))
123+
c = HeartbeatConfig.load(tmp_path / "heartbeat_config.json") # path doesn't exist
124+
assert c.dream_every_hours == 12.0
125+
126+
127+
def test_user_preferences_corrupt_does_not_break_load(tmp_path: Path) -> None:
128+
"""Corrupt user_preferences.json doesn't crash HeartbeatConfig.load()."""
129+
cfg_path = tmp_path / "heartbeat_config.json"
130+
cfg_path.write_text(json.dumps({"dream_every_hours": 8.0}))
131+
(tmp_path / "user_preferences.json").write_text("not json")
132+
c = HeartbeatConfig.load(cfg_path)
133+
assert c.dream_every_hours == 8.0 # falls back to heartbeat_config.json
134+
135+
87136
def test_heartbeat_state_load_missing_file_returns_none(tmp_path: Path) -> None:
88137
"""HeartbeatState.load() returns None for first-ever tick detection."""
89138
assert HeartbeatState.load(tmp_path / "state.json") is None
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Tests for brain.user_preferences — GUI-surfaceable cadence file."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from brain.user_preferences import (
8+
DEFAULT_DREAM_EVERY_HOURS,
9+
UserPreferences,
10+
read_raw_keys,
11+
)
12+
13+
14+
def test_load_missing_file_returns_defaults(tmp_path: Path) -> None:
15+
prefs = UserPreferences.load(tmp_path / "nope.json")
16+
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS
17+
18+
19+
def test_load_well_formed_file(tmp_path: Path) -> None:
20+
path = tmp_path / "user_preferences.json"
21+
path.write_text('{"dream_every_hours": 12.0}\n', encoding="utf-8")
22+
prefs = UserPreferences.load(path)
23+
assert prefs.dream_every_hours == 12.0
24+
25+
26+
def test_load_corrupt_json_returns_defaults(tmp_path: Path) -> None:
27+
path = tmp_path / "user_preferences.json"
28+
path.write_text("{not json", encoding="utf-8")
29+
prefs = UserPreferences.load(path)
30+
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS
31+
32+
33+
def test_load_non_object_payload_returns_defaults(tmp_path: Path) -> None:
34+
path = tmp_path / "user_preferences.json"
35+
path.write_text("[1, 2, 3]", encoding="utf-8")
36+
prefs = UserPreferences.load(path)
37+
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS
38+
39+
40+
def test_load_wrong_field_type_returns_defaults(tmp_path: Path) -> None:
41+
path = tmp_path / "user_preferences.json"
42+
path.write_text('{"dream_every_hours": "not-a-number"}', encoding="utf-8")
43+
prefs = UserPreferences.load(path)
44+
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS
45+
46+
47+
def test_save_round_trip(tmp_path: Path) -> None:
48+
path = tmp_path / "user_preferences.json"
49+
UserPreferences(dream_every_hours=8.0).save(path)
50+
assert UserPreferences.load(path).dream_every_hours == 8.0
51+
52+
53+
def test_save_is_atomic(tmp_path: Path) -> None:
54+
path = tmp_path / "user_preferences.json"
55+
UserPreferences(dream_every_hours=12.0).save(path)
56+
assert path.exists()
57+
assert not path.with_suffix(path.suffix + ".new").exists()
58+
59+
60+
# ---- read_raw_keys ----
61+
62+
63+
def test_read_raw_keys_missing_file(tmp_path: Path) -> None:
64+
assert read_raw_keys(tmp_path / "missing.json") == set()
65+
66+
67+
def test_read_raw_keys_returns_present_keys(tmp_path: Path) -> None:
68+
path = tmp_path / "user_preferences.json"
69+
path.write_text('{"dream_every_hours": 8.0, "future_field": 1}', encoding="utf-8")
70+
assert read_raw_keys(path) == {"dream_every_hours", "future_field"}
71+
72+
73+
def test_read_raw_keys_corrupt_json_returns_empty(tmp_path: Path) -> None:
74+
path = tmp_path / "user_preferences.json"
75+
path.write_text("not json", encoding="utf-8")
76+
assert read_raw_keys(path) == set()
77+
78+
79+
def test_read_raw_keys_non_object_returns_empty(tmp_path: Path) -> None:
80+
path = tmp_path / "user_preferences.json"
81+
path.write_text("[1, 2, 3]", encoding="utf-8")
82+
assert read_raw_keys(path) == set()

0 commit comments

Comments
 (0)