Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions brain/engines/heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import json
import logging
import os
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal, get_args
Expand All @@ -32,7 +32,22 @@

@dataclass
class HeartbeatConfig:
"""Per-persona heartbeat configuration. Loaded from heartbeat_config.json."""
"""Per-persona heartbeat configuration.

Two-file resolution per principle audit 2026-04-25 (PR-C):

1. `heartbeat_config.json` — developer-only internal calibration. The
GUI never reads or writes this file. Holds decay/GC/threshold knobs
that calibrate the brain's physiology.
2. `user_preferences.json` — the GUI-surfaceable cadence file. When
a field is present here (currently only `dream_every_hours`), it
takes precedence over heartbeat_config.json. Missing or absent-key
→ fall back to heartbeat_config.json's value (back-compat).

`dream_every_hours` is the one field that legitimately belongs to the
user. Everything else on this dataclass is internal — exposing it in
a GUI would let the user disable parts of the brain's autonomy.
"""

dream_every_hours: float = 24.0
decay_rate_per_tick: float = 0.01
Expand All @@ -48,6 +63,29 @@ class HeartbeatConfig:

@classmethod
def load(cls, path: Path) -> HeartbeatConfig:
"""Load heartbeat_config.json, then merge user_preferences.json if present.

`path` points at heartbeat_config.json. user_preferences.json is
looked up next to it (`path.parent / "user_preferences.json"`).
"""
cfg = cls._load_internal(path)

# Merge user_preferences.json — only override fields explicitly
# present in the file, so a user_preferences.json that omits
# dream_every_hours doesn't shadow a custom value set in
# heartbeat_config.json (back-compat for pre-PR-C personas).
from brain.user_preferences import UserPreferences, read_raw_keys

user_prefs_path = path.parent / "user_preferences.json"
explicit_keys = read_raw_keys(user_prefs_path)
if "dream_every_hours" in explicit_keys:
prefs = UserPreferences.load(user_prefs_path)
cfg = replace(cfg, dream_every_hours=prefs.dream_every_hours)
return cfg

@classmethod
def _load_internal(cls, path: Path) -> HeartbeatConfig:
"""Load heartbeat_config.json only — the developer-calibration layer."""
if not path.exists():
return cls()
try:
Expand Down
82 changes: 82 additions & 0 deletions brain/user_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Per-persona user preferences — the GUI-surfaceable cadence knobs.

Lives at `{persona_dir}/user_preferences.json`. This is the *only* file the
end-user GUI reads or writes. Everything else (heartbeat_config.json,
persona_config.json, the SQLite stores) is brain-internal.

Per principle audit 2026-04-25 (PR-C): the user surfaces are name, cadence
(this file), face/body, and reading generated documents. heartbeat_config.json
holds developer-only internal calibration (decay rates, GC thresholds, gating
thresholds) that the GUI must never expose.

Currently ships only `dream_every_hours`. Future GUI cadence knobs land here
(e.g., the Phase 2a `growth_every_hours` once that lands).

When both heartbeat_config.json and user_preferences.json define the same
cadence field, user_preferences.json wins — the GUI is authoritative.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass
from pathlib import Path

DEFAULT_DREAM_EVERY_HOURS = 24.0


@dataclass
class UserPreferences:
"""GUI-surfaceable cadence preferences.

Hand-edited corruption degrades to defaults rather than crashing —
same UX policy as HeartbeatConfig and PersonaConfig.
"""

dream_every_hours: float = DEFAULT_DREAM_EVERY_HOURS

@classmethod
def load(cls, path: Path) -> UserPreferences:
if not path.exists():
return cls()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return cls()
if not isinstance(data, dict):
return cls()
try:
return cls(
dream_every_hours=float(
data.get("dream_every_hours", DEFAULT_DREAM_EVERY_HOURS)
),
)
except (TypeError, ValueError):
return cls()

def save(self, path: Path) -> None:
"""Atomic save via .new + os.replace."""
payload = {"dream_every_hours": self.dream_every_hours}
tmp = path.with_suffix(path.suffix + ".new")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)


def read_raw_keys(path: Path) -> set[str]:
"""Return the set of keys explicitly present in user_preferences.json.

Used by HeartbeatConfig.load to distinguish "file omits this field, fall
back to heartbeat_config.json" from "file sets this field to its default
value, override heartbeat_config.json". Returns empty set on any error
(missing file, corrupt JSON, non-object payload).
"""
if not path.exists():
return set()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return set()
if not isinstance(data, dict):
return set()
return set(data.keys())
49 changes: 49 additions & 0 deletions tests/unit/brain/engines/test_heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,55 @@ def test_heartbeat_config_invalid_emit_memory_falls_back_to_default(tmp_path: Pa
assert c.emit_memory == "conditional"


# ---- PR-C: user_preferences.json merges over heartbeat_config.json ----


def test_user_preferences_overrides_dream_every_hours(tmp_path: Path) -> None:
"""When user_preferences.json sets dream_every_hours, it wins over heartbeat_config.json."""
cfg_path = tmp_path / "heartbeat_config.json"
cfg_path.write_text(json.dumps({"dream_every_hours": 24.0}))
(tmp_path / "user_preferences.json").write_text(json.dumps({"dream_every_hours": 6.0}))
c = HeartbeatConfig.load(cfg_path)
assert c.dream_every_hours == 6.0


def test_user_preferences_missing_falls_back_to_heartbeat_config(tmp_path: Path) -> None:
"""No user_preferences.json → heartbeat_config.json's value stands (back-compat)."""
cfg_path = tmp_path / "heartbeat_config.json"
cfg_path.write_text(json.dumps({"dream_every_hours": 8.0}))
c = HeartbeatConfig.load(cfg_path)
assert c.dream_every_hours == 8.0


def test_user_preferences_present_but_omits_field(tmp_path: Path) -> None:
"""user_preferences.json without dream_every_hours doesn't shadow heartbeat_config.json.

Critical for back-compat: a future user_preferences.json with new fields
must not silently reset dream_every_hours to the default.
"""
cfg_path = tmp_path / "heartbeat_config.json"
cfg_path.write_text(json.dumps({"dream_every_hours": 8.0}))
(tmp_path / "user_preferences.json").write_text(json.dumps({"some_future_field": "x"}))
c = HeartbeatConfig.load(cfg_path)
assert c.dream_every_hours == 8.0


def test_user_preferences_only_no_heartbeat_config(tmp_path: Path) -> None:
"""user_preferences.json drives dream_every_hours when heartbeat_config.json is absent."""
(tmp_path / "user_preferences.json").write_text(json.dumps({"dream_every_hours": 12.0}))
c = HeartbeatConfig.load(tmp_path / "heartbeat_config.json") # path doesn't exist
assert c.dream_every_hours == 12.0


def test_user_preferences_corrupt_does_not_break_load(tmp_path: Path) -> None:
"""Corrupt user_preferences.json doesn't crash HeartbeatConfig.load()."""
cfg_path = tmp_path / "heartbeat_config.json"
cfg_path.write_text(json.dumps({"dream_every_hours": 8.0}))
(tmp_path / "user_preferences.json").write_text("not json")
c = HeartbeatConfig.load(cfg_path)
assert c.dream_every_hours == 8.0 # falls back to heartbeat_config.json


def test_heartbeat_state_load_missing_file_returns_none(tmp_path: Path) -> None:
"""HeartbeatState.load() returns None for first-ever tick detection."""
assert HeartbeatState.load(tmp_path / "state.json") is None
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/brain/test_user_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Tests for brain.user_preferences — GUI-surfaceable cadence file."""

from __future__ import annotations

from pathlib import Path

from brain.user_preferences import (
DEFAULT_DREAM_EVERY_HOURS,
UserPreferences,
read_raw_keys,
)


def test_load_missing_file_returns_defaults(tmp_path: Path) -> None:
prefs = UserPreferences.load(tmp_path / "nope.json")
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS


def test_load_well_formed_file(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text('{"dream_every_hours": 12.0}\n', encoding="utf-8")
prefs = UserPreferences.load(path)
assert prefs.dream_every_hours == 12.0


def test_load_corrupt_json_returns_defaults(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text("{not json", encoding="utf-8")
prefs = UserPreferences.load(path)
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS


def test_load_non_object_payload_returns_defaults(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text("[1, 2, 3]", encoding="utf-8")
prefs = UserPreferences.load(path)
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS


def test_load_wrong_field_type_returns_defaults(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text('{"dream_every_hours": "not-a-number"}', encoding="utf-8")
prefs = UserPreferences.load(path)
assert prefs.dream_every_hours == DEFAULT_DREAM_EVERY_HOURS


def test_save_round_trip(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
UserPreferences(dream_every_hours=8.0).save(path)
assert UserPreferences.load(path).dream_every_hours == 8.0


def test_save_is_atomic(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
UserPreferences(dream_every_hours=12.0).save(path)
assert path.exists()
assert not path.with_suffix(path.suffix + ".new").exists()


# ---- read_raw_keys ----


def test_read_raw_keys_missing_file(tmp_path: Path) -> None:
assert read_raw_keys(tmp_path / "missing.json") == set()


def test_read_raw_keys_returns_present_keys(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text('{"dream_every_hours": 8.0, "future_field": 1}', encoding="utf-8")
assert read_raw_keys(path) == {"dream_every_hours", "future_field"}


def test_read_raw_keys_corrupt_json_returns_empty(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text("not json", encoding="utf-8")
assert read_raw_keys(path) == set()


def test_read_raw_keys_non_object_returns_empty(tmp_path: Path) -> None:
path = tmp_path / "user_preferences.json"
path.write_text("[1, 2, 3]", encoding="utf-8")
assert read_raw_keys(path) == set()
Loading