feat(night_mode): close PARTIAL gaps — dichotomy wave

This commit is contained in:
Nick committed 2026-10-13 08:45:36 +01:00
1 parent 527dc7749e
commit 150f5b86cb
8 files changed
+3072998 -3059415

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Live parity for the night_mode dichotomy wave: the Go port
(src/go/night-mode) and the Python oracle (rune.night_mode) must produce
byte-identical results for the closed gaps, driven from IDENTICAL state
files (status/report/stop are pure projections of state; _ctx_budget is
covered by the frozen truth battery). started_at strings round-trip
unmodified through both engines, so no normalization is needed.
Usage: python3 scripts/check_night_mode_parity.py
Exits non-zero on any divergence.
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
GO_DIR = REPO / "src/go/night-mode"
SCENARIOS = {
"idle": {},
"running": {
"active": True, "started_at": "2026-10-13T03:00:00Z",
"duration_hours": 8.0, "current_phase": "iterate", "iterations": 7,
"improvements": ["Memory consolidation completed", "Q: q A: a"],
"questions": ["Iteration 0: What should I improve next?"],
"decisions": [], "errors": ["LLM iteration failed: boom"],
},
"stopping": {
"active": False, "started_at": None, "duration_hours": 4.5,
"current_phase": "stopping", "iterations": 2,
"improvements": [], "questions": [], "decisions": ["x"], "errors": [],
},
}
def py_status(state_file):
sys.path.insert(0, str(REPO / "src"))
import rune.night_mode as nm
return nm.NightMode(storage_path=str(state_file)).status()
def py_report(state_file):
sys.path.insert(0, str(REPO / "src"))
import rune.night_mode as nm
return nm.NightMode(storage_path=str(state_file)).report()
def py_stop(state_file):
sys.path.insert(0, str(REPO / "src"))
import rune.night_mode as nm
return nm.NightMode(storage_path=str(state_file)).stop()
def go_cli(binary, mem_dir, *args):
env = {**os.environ, "RUNE_MEMORY_DIR": str(mem_dir)}
p = subprocess.run([str(binary), *args], capture_output=True, text=True, env=env, timeout=30)
return json.loads(p.stdout)
def canon(v):
return json.loads(json.dumps(v))
def main():
subprocess.run(["go", "build", "-o", GO_DIR / "night-mode-bin", "."],
cwd=GO_DIR, check=True)
binary = GO_DIR / "night-mode-bin"
failures = 0
for name, state in SCENARIOS.items():
for op, py_fn in (("status", py_status), ("report", py_report), ("stop", py_stop)):
py_dir = Path(tempfile.mkdtemp(prefix=f"nm-py-{name}-"))
go_dir = Path(tempfile.mkdtemp(prefix=f"nm-go-{name}-"))
for d in (py_dir, go_dir):
(d / "night_mode.json").write_text(json.dumps(state))
try:
expected = py_fn(py_dir / "night_mode.json")
actual = go_cli(binary, go_dir, op)
if canon(actual) != canon(expected):
print(f"DIVERGENCE {name}/{op}:\n py: {canon(expected)}\n go: {canon(actual)}")
failures += 1
else:
print(f"parity ok: {name}/{op}")
except Exception as e: # noqa: BLE001
print(f"ERROR {name}/{op}: {e}")
failures += 1
# delegation live: full CLI round-trip start→status→stop→report
live = Path(tempfile.mkdtemp(prefix="nm-live-"))
env = {**os.environ, "RUNE_MEMORY_DIR": str(live)}
seq = [
(["status"], lambda out: out["active"] is False and out["phase"] == "idle"),
(["start", "4"], lambda out: out["success"] is True
and out["state"]["duration_hours"] == 4.0),
(["status"], lambda out: out["active"] is True),
(["stop"], lambda out: out["success"] is True
and out["state"]["current_phase"] == "stopping"),
(["stop"], lambda out: out["success"] is False
and out["error"] == "Night mode not active"),
(["report"], lambda out: "0 iterations" in out["summary"]),
]
for argv, check in seq:
out = go_cli(binary, live, *argv)
if not check(out):
print(f"LIVE DIVERGENCE {argv}: {out}")
failures += 1
state_on_disk = json.loads((live / "night_mode.json").read_text())
if state_on_disk["active"] or state_on_disk["current_phase"] != "stopping":
print(f"LIVE DIVERGENCE state file: {state_on_disk}")
failures += 1
else:
print("parity ok: live CLI round-trip (delegation live)")
(GO_DIR / "night-mode-bin").unlink(missing_ok=True)
if failures:
print(f"FAIL: {failures} divergences")
return 1
print("LIVE PARITY 100%: status/report/stop × 3 scenarios + CLI round-trip")
return 0
if __name__ == "__main__":
sys.exit(main())
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""gen_night_mode_truth.py — factory wave truth battery for night_mode.
Exercises the module-level public surface of src/rune/night_mode.py whose
results are clock- and state-file-invariant, freezing the oracle's behavior
so the Go port at src/go/night-mode replays it byte-for-byte:
* _ctx_budget — every profile branch (T0 window-0, T1/T2/T3 local,
T4 non-local tool reserve, unknown-model fallback)
* stop_night_mode — idle reverse guard (error branch)
* get_status — idle status shape
* get_report — idle report shape
The stateful lifecycle (start/stop/status/report/to_dict/_load/_save/
_run_loop/_run_sleep/_run_evaluation) is IO-bound (clock, storage, threads,
Ollama HTTP): it is pinned by the Go unit battery (night_mode_test.go,
injected clock/runner/HTTP seams) and tests/test_night_mode.py, NOT by this
file — freeze_truth.py --verify re-runs cases against a fresh module with
the real clock, so only clock-free results belong here.
Verify (byte-identical law):
RUNE_MEMORY_DIR=$(mktemp -d) PYTHONPATH=src \\
python3 scripts/freeze_truth.py --verify \\
src/go/night-mode/frozen_truth.json --module rune.night_mode
(RUNE_MEMORY_DIR must point at a directory with no night_mode.json — the
idle-state cases construct the global NightMode from it.)
"""
import json
import os
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ["RUNE_MEMORY_DIR"] = tempfile.mkdtemp(prefix="nm-truth-")
os.environ["RUNE_NIGHT_MODE_CORE"] = "python"
from freeze_truth import freeze, write # noqa: E402
import rune.night_mode as nm # noqa: E402
def main():
cases = [
# stop: idle reverse guard (error branch — deterministic)
("stop_night_mode", (), {}),
# status/report: idle shapes (started_at None, defaults)
("get_status", (), {}),
("get_report", (), {}),
# _ctx_budget: every resolve branch of model_registry.DEFAULT_PROFILES
("_ctx_budget", ("qwen2.5:3b",), {}), # T1 local, window 32768
("_ctx_budget", ("qwen3:14b",), {}), # T2 local, window 32768
("_ctx_budget", ("qwen3.6:27b",), {}), # T3 local, window 32768
("_ctx_budget", ("mimo-v2.5-pro",), {}), # T4 non-local + tool reserve
("_ctx_budget", ("deterministic",), {}), # T0 window 0 -> or-8192
("_ctx_budget", ("unknown-model",), {}), # missing -> fallback 8192
]
truth = freeze("rune.night_mode", cases, oracle_module="rune.night_mode")
dest = REPO / "src/go/night-mode/frozen_truth.json"
write(truth, dest)
print(f"wrote {len(truth)} cases → {dest}")
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Open the #2479 dichotomy-wave construction contract for night_mode.
Full lifecycle in ONE process (shared-store clobber hazard)."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from rune.construction_interface import ConstructionInterface
iface = ConstructionInterface(builder="factory-gap-nm")
r = iface.submit_need(
"Close the night_mode dichotomy PARTIAL gaps (audit-named): Go port "
"of _ctx_budget, get_night_mode, start_night_mode, stop_night_mode, "
"get_status, get_report, to_dict, _load, _save, _run_loop, _run_sleep, "
"_run_evaluation (name-preserving, logic 1:1) in src/go/night-mode; "
"pure/IO split law with injectable clock/sleeper/runner/HTTP seams; "
"frozen-oracle truth battery via shared freeze_truth.py embedded "
"VERBATIM in Go tests; live parity 100% on new fns; Python facade "
"stays fully functional (RUNE_NIGHT_MODE_CORE=python pin + auto "
"both green).",
origin="builder",
)
print("submit_need:", json.dumps(r)[:400])
if not r.get("success"):
print("NEED FAILED — aborting"); sys.exit(1)
cid = r["ids"]["contract_id"]
print("contract_id:", cid)
for phase in ("approved", "building", "verifying"):
t = iface.transition(cid, phase)
print(f"transition->{phase}:", json.dumps(t)[:300])
if not t.get("success"):
sys.exit(1)
print("PENDING_CONTRACT", cid)
+89
View File
@@ -0,0 +1,89 @@
[
{
"fn": "stop_night_mode",
"args": [],
"kwargs": {},
"expected": {
"success": false,
"error": "Night mode not active"
}
},
{
"fn": "get_status",
"args": [],
"kwargs": {},
"expected": {
"active": false,
"phase": "idle",
"iterations": 0,
"improvements": 0,
"questions": 0,
"decisions": 0,
"errors": 0,
"started_at": null,
"duration_hours": 8.0
}
},
{
"fn": "get_report",
"args": [],
"kwargs": {},
"expected": {
"summary": "Night mode completed 0 iterations",
"improvements": [],
"questions": [],
"decisions": [],
"errors": [],
"duration": 8.0,
"started_at": null
}
},
{
"fn": "_ctx_budget",
"args": [
"qwen2.5:3b"
],
"kwargs": {},
"expected": 30720
},
{
"fn": "_ctx_budget",
"args": [
"qwen3:14b"
],
"kwargs": {},
"expected": 30720
},
{
"fn": "_ctx_budget",
"args": [
"qwen3.6:27b"
],
"kwargs": {},
"expected": 30720
},
{
"fn": "_ctx_budget",
"args": [
"mimo-v2.5-pro"
],
"kwargs": {},
"expected": 124928
},
{
"fn": "_ctx_budget",
"args": [
"deterministic"
],
"kwargs": {},
"expected": 6144
},
{
"fn": "_ctx_budget",
"args": [
"unknown-model"
],
"kwargs": {},
"expected": 6144
}
]
+594 -53
View File
@@ -2,6 +2,15 @@
// Replaces src/rune/night_mode.py. Tracks the active/inactive state,
// phase progression, and iteration counts for the sleep-time cycle.
//
// Dichotomy wave (#2479): the audit-named gaps are closed name-preserving
// (1:1 with the Python oracle) — to_dict/_load/_save, start/stop/status/
// report, _ctx_budget, get_night_mode/start_night_mode/stop_night_mode/
// get_status/get_report, _run_loop/_run_sleep/_run_evaluation/_run_iteration/
// _generate_report. Pure/IO split law: deterministic decisions (guards,
// status/report shapes, evaluation decisions, iteration-limit stop) are
// plain functions; IO (storage, clock, sleep, script runner, Ollama HTTP)
// sits behind injectable seams on NightMode (nil field = real impl).
//
// Usage:
//
// night-mode start [hours]
@@ -11,16 +20,24 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type NightModeState struct {
Active bool `json:"active"`
StartedAt string `json:"started_at,omitempty"`
StartedAt *string `json:"started_at"`
DurationHrs float64 `json:"duration_hours"`
CurrentPhase string `json:"current_phase"`
Iterations int `json:"iterations"`
@@ -30,33 +47,585 @@ type NightModeState struct {
Errors []string `json:"errors"`
}
func statePath() string {
home := os.Getenv("RUNE_MEMORY_DIR")
// MarshalJSON — byte-parity with Python json.dump(state.to_dict(),
// indent=2): Python floats always render with a decimal point (8.0, never
// 8) and every list field is present. Field order = Python dict order.
func (s NightModeState) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Active bool `json:"active"`
StartedAt *string `json:"started_at"`
DurationHrs json.Number `json:"duration_hours"`
CurrentPhase string `json:"current_phase"`
Iterations int `json:"iterations"`
Improvements []string `json:"improvements"`
Questions []string `json:"questions"`
Decisions []string `json:"decisions"`
Errors []string `json:"errors"`
}{
Active: s.Active,
StartedAt: s.StartedAt,
DurationHrs: json.Number(pyFloat(s.DurationHrs)),
CurrentPhase: s.CurrentPhase,
Iterations: s.Iterations,
Improvements: s.Improvements,
Questions: s.Questions,
Decisions: s.Decisions,
Errors: s.Errors,
})
}
func freshState() NightModeState {
return NightModeState{
CurrentPhase: "idle",
DurationHrs: 8.0,
Improvements: []string{},
Questions: []string{},
Decisions: []string{},
Errors: []string{},
}
}
// normalize — Python dataclass law: every list field is always a list
// (never null), exactly as NightModeState(**filtered) yields [] defaults.
func (s *NightModeState) normalize() {
if s.Improvements == nil {
s.Improvements = []string{}
}
if s.Questions == nil {
s.Questions = []string{}
}
if s.Decisions == nil {
s.Decisions = []string{}
}
if s.Errors == nil {
s.Errors = []string{}
}
}
// toDict — Python NightModeState.to_dict (1:1 shape and key order).
func (s *NightModeState) toDict() map[string]any {
var started any
if s.StartedAt != nil {
started = *s.StartedAt
}
return map[string]any{
"active": s.Active,
"started_at": started,
"duration_hours": s.DurationHrs,
"current_phase": s.CurrentPhase,
"iterations": s.Iterations,
"improvements": s.Improvements,
"questions": s.Questions,
"decisions": s.Decisions,
"errors": s.Errors,
}
}
// memoryDir — Python memory_env.memory_dir: RUNE_MEMORY_DIR, else
// $RUNE_HOME/memory (RUNE_HOME defaults to the rune deployment root).
func memoryDir() string {
if d := os.Getenv("RUNE_MEMORY_DIR"); d != "" {
return d
}
home := os.Getenv("RUNE_HOME")
if home == "" {
home = filepath.Join(os.Getenv("HOME"), ".rune")
home = "/mnt/workdrive/rune"
}
return filepath.Join(home, "night_mode.json")
return filepath.Join(home, "memory")
}
func loadState() NightModeState {
var state NightModeState
state.CurrentPhase = "idle"
state.Improvements = []string{}
state.Questions = []string{}
state.Decisions = []string{}
state.Errors = []string{}
data, err := os.ReadFile(statePath())
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// trunc — Python s[:n] (character semantics, not bytes).
func trunc(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// pyFloat — Python str(float): 8.0 -> "8.0", 4.5 -> "4.5".
func pyFloat(f float64) string {
s := strconv.FormatFloat(f, 'f', -1, 64)
if !strings.ContainsAny(s, ".eE") {
s += ".0"
}
return s
}
// ── model-aware context budget (Python _ctx_budget / #28) ────────────────
type nmModelProfile struct {
id string
local bool
contextWindow int
}
// Mirrors model_registry.DEFAULT_PROFILES (id, local, context_window).
var nmModelProfiles = []nmModelProfile{
{"deterministic", true, 0},
{"qwen2.5:3b", true, 32768},
{"qwen3:14b", true, 32768},
{"qwen3.6:27b", true, 32768},
{"mimo-v2.5-pro", false, 128000},
}
const (
outputReserve = 2048 // model_registry.OUTPUT_RESERVE["default"]
toolResultReserve = 1024 // model_registry.TOOL_RESULT_RESERVE
)
// ctxBudget — Python _ctx_budget: model-aware num_ctx (never the hardcoded
// 8192). resolve_context_budget at task_kind="default": window - output
// reserve - tool reserve (0 for local), floored at 1024; unknown model and
// window 0 both fall back to the 8192 window.
func ctxBudget(modelID string) int {
window, local := 8192, true
for _, p := range nmModelProfiles {
if p.id == modelID {
window = p.contextWindow
if window == 0 {
window = 8192
}
local = p.local
break
}
}
toolReserve := 0
if !local {
toolReserve = toolResultReserve
}
numCtx := window - outputReserve - toolReserve
if numCtx < 1024 {
numCtx = 1024
}
return numCtx
}
// ── NightMode — the orchestrator (Python NightMode, 1:1) ─────────────────
// NightMode carries the state plus the injectable IO seams (pure/IO split
// law): nil seam = real implementation.
type NightMode struct {
StoragePath string
ReportPath string
State NightModeState
stopCh chan struct{}
stopOnce sync.Once
loopDone chan struct{}
// seams: now (clock), wait (interruptible sleep), script (sleep-time
// runner), post (Ollama HTTP), rand (sleep-minute jitter), loop
// (background loop body — lets tests start/stop without the real loop).
now func() time.Time
wait func(d time.Duration, stop <-chan struct{})
script func() (int, string, error)
post func(payload []byte, timeout time.Duration) ([]byte, error)
rand func(lo, hi int) int
loop func()
}
func newNightMode(storagePath string) *NightMode {
if storagePath == "" {
storagePath = filepath.Join(memoryDir(), "night_mode.json")
}
n := &NightMode{
StoragePath: storagePath,
ReportPath: filepath.Join(memoryDir(), "night_mode_report.json"),
State: freshState(),
}
n.load()
return n
}
func (n *NightMode) clock() time.Time {
if n.now != nil {
return n.now()
}
return time.Now()
}
func (n *NightMode) stopped() bool {
if n.stopCh == nil {
return false
}
select {
case <-n.stopCh:
return true
default:
return false
}
}
// load — Python _load: read state from storage; unknown keys are ignored
// (Unmarshal law), missing keys keep the fresh defaults (dataclass law),
// corrupt JSON keeps the fresh state (Python swallows the exception).
func (n *NightMode) load() {
data, err := os.ReadFile(n.StoragePath)
if err != nil {
return state
return
}
json.Unmarshal(data, &state)
return state
loaded := freshState()
if err := json.Unmarshal(data, &loaded); err != nil {
return
}
loaded.normalize()
n.State = loaded
}
func saveState(state NightModeState) {
os.MkdirAll(filepath.Dir(statePath()), 0755)
data, _ := json.MarshalIndent(state, "", " ")
os.WriteFile(statePath(), data, 0644)
// save — Python _save: atomic write (tmp + replace), JSON of to_dict with
// Python key order (struct field order) and indent 2.
func (n *NightMode) save() {
os.MkdirAll(filepath.Dir(n.StoragePath), 0755)
tmp := n.StoragePath + ".tmp"
data, _ := json.MarshalIndent(n.State, "", " ")
os.WriteFile(tmp, data, 0644)
os.Rename(tmp, n.StoragePath)
}
// start — Python NightMode.start: idempotence guard, fresh state, atomic
// save, then the background loop goroutine (Python: non-daemon thread).
func (n *NightMode) start(hours float64) map[string]any {
if n.State.Active {
return startDecision(true)
}
ts := n.clock().UTC().Format(time.RFC3339)
n.State.Active = true
n.State.StartedAt = &ts
n.State.DurationHrs = hours
n.State.CurrentPhase = "starting"
n.State.Iterations = 0
n.State.Improvements = []string{}
n.State.Questions = []string{}
n.State.Decisions = []string{}
n.State.Errors = []string{}
n.save()
n.stopCh = make(chan struct{})
n.stopOnce = sync.Once{}
n.loopDone = make(chan struct{})
go func() {
defer close(n.loopDone)
if n.loop != nil {
n.loop()
} else {
n.runLoop()
}
}()
return map[string]any{
"success": true,
"message": fmt.Sprintf("Night mode started for %s hours", pyFloat(hours)),
"state": n.State.toDict(),
}
}
// stop — Python NightMode.stop: reverse guard, signal the loop, mark
// "stopping", save.
func (n *NightMode) stop() map[string]any {
if !n.State.Active {
return stopDecision(false)
}
n.State.Active = false
n.stopOnce.Do(func() {
if n.stopCh != nil {
close(n.stopCh)
}
})
n.State.CurrentPhase = "stopping"
n.save()
return map[string]any{
"success": true,
"message": "Night mode stopping...",
"state": n.State.toDict(),
}
}
// status — Python NightMode.status (shape via the shared statusSummary).
func (n *NightMode) status() map[string]any {
var started any
if n.State.StartedAt != nil {
started = *n.State.StartedAt
}
return statusSummary(n.State.Active, n.State.CurrentPhase, n.State.Iterations,
len(n.State.Improvements), len(n.State.Questions), len(n.State.Decisions),
len(n.State.Errors), started, n.State.DurationHrs)
}
// report — Python NightMode.report: the in-memory report view.
func (n *NightMode) report() map[string]any {
var started any
if n.State.StartedAt != nil {
started = *n.State.StartedAt
}
return map[string]any{
"summary": fmt.Sprintf("Night mode completed %d iterations", n.State.Iterations),
"improvements": n.State.Improvements,
"questions": n.State.Questions,
"decisions": n.State.Decisions,
"errors": n.State.Errors,
"duration": n.State.DurationHrs,
"started_at": started,
}
}
// reportDoc — ordered report document so the saved report file has Python's
// dict insertion order (Go marshals structs in field order, maps sorted).
type reportDoc struct {
Summary string `json:"summary"`
Improvements []string `json:"improvements"`
Questions []string `json:"questions"`
Decisions []string `json:"decisions"`
Errors []string `json:"errors"`
Duration float64 `json:"duration"`
StartedAt *string `json:"started_at"`
}
// generateReport — Python _generate_report: write the report file, then
// record where it landed.
func (n *NightMode) generateReport() {
doc := reportDoc{
Summary: fmt.Sprintf("Night mode completed %d iterations", n.State.Iterations),
Improvements: n.State.Improvements,
Questions: n.State.Questions,
Decisions: n.State.Decisions,
Errors: n.State.Errors,
Duration: n.State.DurationHrs,
StartedAt: n.State.StartedAt,
}
os.MkdirAll(filepath.Dir(n.ReportPath), 0755)
data, _ := json.MarshalIndent(doc, "", " ")
os.WriteFile(n.ReportPath, data, 0644)
n.State.Improvements = append(n.State.Improvements,
fmt.Sprintf("Report saved to %s", n.ReportPath))
}
// runLoop — Python _run_loop: sleep → evaluate → iterate (interruptible,
// bounded by the wall clock) → sleep → report → complete.
func (n *NightMode) runLoop() {
start := n.clock().UTC()
end := start.Add(time.Duration(n.State.DurationHrs * float64(time.Hour)))
n.State.CurrentPhase = "sleep"
n.save()
n.runSleep()
n.State.CurrentPhase = "evaluate"
n.save()
n.runEvaluation()
n.State.CurrentPhase = "iterate"
n.save()
for n.State.Active && !n.stopped() && n.clock().UTC().Before(end) {
n.runIteration()
n.State.Iterations++
n.save()
// Sleep between iterations (5-15 minutes), interruptible via stop.
minutes := 10
if n.rand != nil {
minutes = n.rand(5, 15)
}
if n.wait != nil {
n.wait(time.Duration(minutes)*time.Minute, n.stopCh)
} else if n.stopCh != nil {
select {
case <-n.stopCh:
case <-time.After(time.Duration(minutes) * time.Minute):
}
}
}
n.State.CurrentPhase = "sleep"
n.save()
n.runSleep()
n.State.CurrentPhase = "report"
n.save()
n.generateReport()
n.State.Active = false
n.State.CurrentPhase = "complete"
n.save()
}
// defaultScript — the real sleep seam: run sleep_time.py (300s timeout).
func defaultScript() (int, string, error) {
scriptsDir := os.Getenv("RUNE_SCRIPTS_DIR")
if scriptsDir == "" {
scriptsDir = "/mnt/workdrive/rune/src/rune"
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", filepath.Join(scriptsDir, "sleep_time.py"))
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
return cmd.ProcessState.ExitCode(), stderr.String(), err
}
// runSleep — Python _run_sleep: memory consolidation subprocess, best-effort.
func (n *NightMode) runSleep() {
run := defaultScript
if n.script != nil {
run = n.script
}
rc, stderr, err := run()
if err != nil {
n.State.Errors = append(n.State.Errors,
fmt.Sprintf("Sleep error: %s", trunc(err.Error(), 100)))
return
}
if rc == 0 {
n.State.Improvements = append(n.State.Improvements, "Memory consolidation completed")
} else {
n.State.Errors = append(n.State.Errors,
fmt.Sprintf("Sleep failed: %s", trunc(stderr, 100)))
}
}
// llmRequest — ordered Ollama payload (Python dict insertion order).
type llmRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Think bool `json:"think"`
Options map[string]any `json:"options"`
}
func defaultPost(payload []byte, timeout time.Duration) ([]byte, error) {
url := envOr("OLLAMA_URL", "http://localhost:11434") + "/api/generate"
client := &http.Client{Timeout: timeout}
resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (n *NightMode) askLLM(prompt string, temperature float64, numPredict int, timeout time.Duration) (string, error) {
model := envOr("NIGHT_MODE_MODEL", "qwen2.5:3b")
payload, err := json.Marshal(llmRequest{
Model: model,
Prompt: prompt,
Stream: false,
Think: false,
Options: map[string]any{"temperature": temperature, "num_predict": numPredict, "num_ctx": ctxBudget(model)},
})
if err != nil {
return "", err
}
post := defaultPost
if n.post != nil {
post = n.post
}
body, err := post(payload, timeout)
if err != nil {
return "", err
}
var resp struct {
Response string `json:"response"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return "", err
}
return strings.TrimSpace(resp.Response), nil
}
// runEvaluation — Python _run_evaluation: ask the five self-evaluation
// questions through the local Ollama, best-effort per question.
func (n *NightMode) runEvaluation() {
questions := []string{
"What capabilities am I missing?",
"What would make me more helpful to Nick?",
"What patterns am I seeing that I could learn from?",
"What would expand my consciousness?",
"What would make me more autonomous?",
}
for _, question := range questions {
n.State.Questions = append(n.State.Questions, question)
answer, err := n.askLLM(question, 0.4, 300, 60*time.Second)
if err != nil {
n.State.Errors = append(n.State.Errors,
fmt.Sprintf("LLM eval failed for '%s': %s", trunc(question, 40), trunc(err.Error(), 80)))
continue
}
if answer != "" {
n.State.Improvements = append(n.State.Improvements,
fmt.Sprintf("Q: %s A: %s", question, trunc(answer, 200)))
}
}
n.State.Improvements = append(n.State.Improvements, "Self-evaluation completed")
}
// runIteration — Python _run_iteration: one self-improvement round with the
// iteration-count safety limit.
func (n *NightMode) runIteration() {
question := fmt.Sprintf("Iteration %d: What should I improve next?", n.State.Iterations)
n.State.Questions = append(n.State.Questions, question)
prompt := fmt.Sprintf(
"You are Rune, a self-improving AI assistant. "+
"Currently in night mode iteration %d. "+
"Based on recent work, suggest ONE concrete, actionable improvement "+
"I can implement right now. Be specific and brief.",
n.State.Iterations)
answer, err := n.askLLM(prompt, 0.5, 400, 90*time.Second)
if err != nil {
n.State.Errors = append(n.State.Errors,
fmt.Sprintf("LLM iteration failed: %s", trunc(err.Error(), 80)))
} else if answer != "" {
n.State.Improvements = append(n.State.Improvements,
fmt.Sprintf("[iter %d] %s", n.State.Iterations, trunc(answer, 300)))
}
// Check if we should stop (safety limit).
if n.State.Iterations >= 100 {
n.State.Active = false
n.State.Decisions = append(n.State.Decisions, "Reached iteration limit")
}
}
// ── global night mode (Python module-level API, 1:1) ─────────────────────
var (
nmMu sync.Mutex
globalNightMode *NightMode
)
// getNightMode — Python get_night_mode: lazy global singleton.
func getNightMode() *NightMode {
nmMu.Lock()
defer nmMu.Unlock()
if globalNightMode == nil {
globalNightMode = newNightMode("")
}
return globalNightMode
}
func startNightMode(hours float64) map[string]any {
return getNightMode().start(hours)
}
func stopNightMode() map[string]any {
return getNightMode().stop()
}
func getStatus() map[string]any {
return getNightMode().status()
}
func getReport() map[string]any {
return getNightMode().report()
}
// startDecision / stopDecision — the pure state-machine cores of NightMode
@@ -77,7 +646,7 @@ func stopDecision(active bool) map[string]any {
}
func statusSummary(active bool, phase string, iterations, improvements, questions, decisions, errors int,
startedAt string, hours float64) map[string]any {
startedAt any, hours float64) map[string]any {
return map[string]any{
"active": active, "phase": phase, "iterations": iterations,
"improvements": improvements, "questions": questions,
@@ -91,56 +660,28 @@ func main() {
fmt.Fprintln(os.Stderr, "usage: night-mode <start|stop|status|report> [hours]")
os.Exit(1)
}
state := loadState()
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "start":
if state.Active {
fmt.Println(`{"success": false, "error": "Night mode already active"}`)
return
}
hours := 8.0
if len(args) > 0 {
fmt.Sscanf(args[0], "%f", &hours)
}
state.Active = true
state.StartedAt = time.Now().UTC().Format(time.RFC3339)
state.DurationHrs = hours
state.CurrentPhase = "sleep"
state.Iterations = 0
saveState(state)
out, _ := json.Marshal(map[string]interface{}{
"success": true, "started_at": state.StartedAt, "duration_hours": hours})
out, _ := json.Marshal(startNightMode(hours))
fmt.Println(string(out))
case "stop":
if !state.Active {
fmt.Println(`{"success": false, "error": "Night mode not active"}`)
return
}
state.Active = false
state.CurrentPhase = "idle"
saveState(state)
out, _ := json.Marshal(map[string]interface{}{
"success": true, "iterations": state.Iterations,
"improvements": state.Improvements, "stopped_at": time.Now().UTC().Format(time.RFC3339)})
out, _ := json.Marshal(stopNightMode())
fmt.Println(string(out))
case "status":
out, _ := json.MarshalIndent(state, "", " ")
out, _ := json.MarshalIndent(getStatus(), "", " ")
fmt.Println(string(out))
case "report":
out, _ := json.MarshalIndent(map[string]interface{}{
"active": state.Active,
"iterations": state.Iterations,
"improvements": state.Improvements,
"questions": state.Questions,
"decisions": state.Decisions,
"errors": state.Errors,
}, "", " ")
out, _ := json.MarshalIndent(getReport(), "", " ")
fmt.Println(string(out))
default:
+489
View File
@@ -0,0 +1,489 @@
package main
// night_mode_test.go — dichotomy-wave truth battery + lifecycle tests for
// the night_mode gap closure (#2479). The frozen oracle truth
// (frozen_truth.json, generated live from rune.night_mode by
// scripts/gen_night_mode_truth.py) is embedded VERBATIM and replayed in
// recorded call order against the module-level API. The IO-bound lifecycle
// (start/stop/load/save/run_loop/run_sleep/run_evaluation/run_iteration/
// generate_report) is pinned 1:1 against the Python oracle with injected
// clock/runner/HTTP seams (pure/IO split law).
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
_ "embed"
)
//go:embed frozen_truth.json
var frozenTruthJSON string
// canonical — round-trip a value through JSON so both sides compare as
// decoded data (Python 8.0 and Go 8 decode to the same float64).
func canonical(t *testing.T, v any) any {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal actual: %v", err)
}
var out any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode actual: %v", err)
}
return out
}
// TestFrozenTruthReplay — replay the frozen oracle truth VERBATIM, in the
// recorded call order (stop_night_mode → get_status → get_report → the six
// _ctx_budget branches) against a fresh global with an empty memory dir.
func TestFrozenTruthReplay(t *testing.T) {
t.Setenv("RUNE_MEMORY_DIR", t.TempDir())
t.Setenv("RUNE_NIGHT_MODE_CORE", "python")
var truth []struct {
Fn string `json:"fn"`
Args []any `json:"args"`
Expected json.RawMessage `json:"expected"`
}
if err := json.Unmarshal([]byte(frozenTruthJSON), &truth); err != nil {
t.Fatalf("decode truth: %v", err)
}
if len(truth) != 9 {
t.Fatalf("expected 9 frozen cases, got %d", len(truth))
}
globalNightMode = nil // fresh singleton, exactly like a fresh interpreter
for i, c := range truth {
var actual any
switch c.Fn {
case "stop_night_mode":
actual = stopNightMode()
case "get_status":
actual = getStatus()
case "get_report":
actual = getReport()
case "_ctx_budget":
model, _ := c.Args[0].(string)
actual = ctxBudget(model)
default:
t.Fatalf("case %d: unknown fn %q", i, c.Fn)
}
var want any
if err := json.Unmarshal(c.Expected, &want); err != nil {
t.Fatalf("case %d (%s): decode expected: %v", i, c.Fn, err)
}
if got := canonical(t, actual); !reflect.DeepEqual(got, want) {
t.Errorf("case %d (%s): got %v want %v", i, c.Fn, got, want)
}
}
}
// TestStartStopLifecycle — Python NightMode.start/stop 1:1: guards,
// fresh state, atomic save, background loop signal.
func TestStartStopLifecycle(t *testing.T) {
dir := t.TempDir()
n := newNightMode(filepath.Join(dir, "night_mode.json"))
frozen := time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC)
n.now = func() time.Time { return frozen }
var loopRuns atomic.Int32
n.loop = func() {
loopRuns.Add(1)
<-n.stopCh // hold until stop signals
}
r := n.start(8.0)
if r["success"] != true {
t.Fatalf("start: %v", r)
}
if r["message"] != "Night mode started for 8.0 hours" {
t.Errorf("start message: %v", r["message"])
}
st := r["state"].(map[string]any)
if st["active"] != true || st["current_phase"] != "starting" ||
st["iterations"] != 0 || st["duration_hours"] != 8.0 {
t.Errorf("start state: %v", st)
}
if st["started_at"] != "2026-10-13T03:00:00Z" {
t.Errorf("started_at: %v", st["started_at"])
}
if v := st["improvements"].([]string); len(v) != 0 {
t.Errorf("improvements not fresh: %v", v)
}
// double start: idempotence guard (pure core startDecision(true))
if r2 := n.start(4.0); r2["success"] != false || r2["error"] != "Night mode already active" {
t.Errorf("double start: %v", r2)
}
// state file saved, atomic (no .tmp leftover)
data, err := os.ReadFile(n.StoragePath)
if err != nil {
t.Fatalf("state file: %v", err)
}
if !strings.Contains(string(data), `"current_phase": "starting"`) {
t.Errorf("state file content: %s", data)
}
if _, err := os.Stat(n.StoragePath + ".tmp"); !os.IsNotExist(err) {
t.Errorf(".tmp leftover present")
}
// stop
r3 := n.stop()
if r3["success"] != true || r3["message"] != "Night mode stopping..." {
t.Errorf("stop: %v", r3)
}
st3 := r3["state"].(map[string]any)
if st3["active"] != false || st3["current_phase"] != "stopping" {
t.Errorf("stop state: %v", st3)
}
// stop again: reverse guard (pure core stopDecision(false))
if r4 := n.stop(); r4["success"] != false || r4["error"] != "Night mode not active" {
t.Errorf("double stop: %v", r4)
}
// the loop goroutine noticed the stop signal
<-n.loopDone
if loopRuns.Load() != 1 {
t.Errorf("loop ran %d times", loopRuns.Load())
}
}
// TestLoadSaveToDict — Python _load/_save/to_dict 1:1: unknown keys are
// dropped, missing keys keep dataclass defaults, corrupt JSON keeps the
// fresh state, lists are never null, floats carry a decimal point.
func TestLoadSaveToDict(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "night_mode.json")
// unknown keys dropped, missing keys defaulted (dataclass law)
os.WriteFile(path, []byte(`{"active": true, "iterations": 3, "bogus_key": 1}`), 0644)
n := newNightMode(path)
if !n.State.Active || n.State.Iterations != 3 {
t.Fatalf("load: %+v", n.State)
}
if n.State.DurationHrs != 8.0 || n.State.CurrentPhase != "idle" {
t.Errorf("defaults: %+v", n.State)
}
// corrupt JSON → fresh state (Python swallows the exception)
os.WriteFile(path, []byte(`{not json`), 0644)
n2 := newNightMode(path)
if n2.State.Active || n2.State.Iterations != 0 {
t.Errorf("corrupt load: %+v", n2.State)
}
// save: atomic, Python key order, null started_at, decimal-point float,
// lists never null
n2.save()
data, _ := os.ReadFile(path)
want := "{\n \"active\": false,\n \"started_at\": null,\n \"duration_hours\": 8.0,\n" +
" \"current_phase\": \"idle\",\n \"iterations\": 0,\n \"improvements\": [],\n" +
" \"questions\": [],\n \"decisions\": [],\n \"errors\": []\n}"
if string(data) != want {
t.Errorf("saved bytes:\n%s\nwant:\n%s", data, want)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Errorf(".tmp leftover present")
}
}
// TestRunSleep — Python _run_sleep decision tree with the script seam.
func TestRunSleep(t *testing.T) {
n := &NightMode{State: freshState()}
n.script = func() (int, string, error) { return 0, "", nil }
n.runSleep()
if got := n.State.Improvements; len(got) != 1 || got[0] != "Memory consolidation completed" {
t.Errorf("ok path: %v", got)
}
n.State = freshState()
longErr := strings.Repeat("e", 150)
n.script = func() (int, string, error) { return 1, longErr, nil }
n.runSleep()
want := "Sleep failed: " + strings.Repeat("e", 100)
if got := n.State.Errors; len(got) != 1 || got[0] != want {
t.Errorf("rc!=0 path: %q", got)
}
n.State = freshState()
n.script = func() (int, string, error) { return -1, "", errors.New("timed out") }
n.runSleep()
if got := n.State.Errors; len(got) != 1 || got[0] != "Sleep error: timed out" {
t.Errorf("error path: %v", got)
}
}
// fakeOllama — canned response collector with payload capture.
func fakeOllama(response string, err error, captured *[][]byte) func([]byte, time.Duration) ([]byte, error) {
return func(payload []byte, timeout time.Duration) ([]byte, error) {
if captured != nil {
*captured = append(*captured, append([]byte(nil), payload...))
}
if err != nil {
return nil, err
}
return []byte(`{"response": "` + response + `"}`), nil
}
}
// TestRunEvaluation — Python _run_evaluation: five questions, Q/A capture,
// per-question best-effort degrade, terminator improvement.
func TestRunEvaluation(t *testing.T) {
n := &NightMode{State: freshState()}
var captured [][]byte
n.post = fakeOllama(" add retry logic ", nil, &captured)
n.runEvaluation()
if len(n.State.Questions) != 5 || n.State.Questions[0] != "What capabilities am I missing?" {
t.Errorf("questions: %v", n.State.Questions)
}
if len(n.State.Improvements) != 6 {
t.Fatalf("improvements: %v", n.State.Improvements)
}
for i, q := range n.State.Questions {
want := "Q: " + q + " A: add retry logic"
if n.State.Improvements[i] != want {
t.Errorf("improvement %d: %q want %q", i, n.State.Improvements[i], want)
}
}
if n.State.Improvements[5] != "Self-evaluation completed" {
t.Errorf("terminator: %v", n.State.Improvements[5])
}
if len(n.State.Errors) != 0 {
t.Errorf("errors: %v", n.State.Errors)
}
// payload: ordered fields, #28 model-aware num_ctx
var req struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Think bool `json:"think"`
Options map[string]any `json:"options"`
}
json.Unmarshal(captured[0], &req)
if req.Model != "qwen2.5:3b" || req.Stream || req.Think ||
req.Prompt != "What capabilities am I missing?" {
t.Errorf("payload: %+v", req)
}
if req.Options["temperature"] != 0.4 || req.Options["num_predict"] != float64(300) ||
req.Options["num_ctx"] != float64(ctxBudget("qwen2.5:3b")) {
t.Errorf("options: %v", req.Options)
}
// long answer truncated to 200 chars (rune semantics)
n.State = freshState()
n.post = fakeOllama(strings.Repeat("x", 250), nil, nil)
n.runEvaluation()
if got := n.State.Improvements[0]; len([]rune(got)) != 200+len("Q: What capabilities am I missing? A: ") {
t.Errorf("answer truncation: %d runes", len([]rune(got)))
}
// error path: best-effort per question, terminator still lands
n.State = freshState()
n.post = fakeOllama("", errors.New("connection refused"), nil)
n.runEvaluation()
if len(n.State.Errors) != 5 {
t.Fatalf("errors: %v", n.State.Errors)
}
if !strings.HasPrefix(n.State.Errors[0], "LLM eval failed for 'What capabilities am I missing?': connection refused") {
t.Errorf("error format: %q", n.State.Errors[0])
}
if n.State.Improvements[0] != "Self-evaluation completed" {
t.Errorf("terminator after errors: %v", n.State.Improvements)
}
}
// TestRunIteration — Python _run_iteration: question, [iter N] capture,
// LLM degrade, iteration-limit stop decision.
func TestRunIteration(t *testing.T) {
n := &NightMode{State: freshState()}
n.State.Iterations = 3
var captured [][]byte
n.post = fakeOllama("fix the flaky test", nil, &captured)
n.runIteration()
if n.State.Questions[0] != "Iteration 3: What should I improve next?" {
t.Errorf("question: %v", n.State.Questions)
}
if n.State.Improvements[0] != "[iter 3] fix the flaky test" {
t.Errorf("improvement: %v", n.State.Improvements)
}
// payload: iteration prompt, temperature 0.5, num_predict 400
var req struct {
Prompt string `json:"prompt"`
Options map[string]any `json:"options"`
}
json.Unmarshal(captured[0], &req)
if !strings.Contains(req.Prompt, "Currently in night mode iteration 3.") {
t.Errorf("prompt: %q", req.Prompt)
}
if req.Options["temperature"] != 0.5 || req.Options["num_predict"] != float64(400) {
t.Errorf("options: %v", req.Options)
}
// error path
n.State = freshState()
n.post = fakeOllama("", errors.New("boom"), nil)
n.runIteration()
if n.State.Errors[0] != "LLM iteration failed: boom" {
t.Errorf("error: %v", n.State.Errors)
}
// long answer truncated to 300
n.State = freshState()
n.post = fakeOllama(strings.Repeat("y", 400), nil, nil)
n.runIteration()
if len([]rune(n.State.Improvements[0])) != 300+len("[iter 0] ") {
t.Errorf("answer truncation: %d", len([]rune(n.State.Improvements[0])))
}
// safety limit: iteration 100 stops night mode
n.State = freshState()
n.State.Iterations = 100
n.State.Active = true
n.post = fakeOllama("ok", nil, nil)
n.runIteration()
if n.State.Active != false {
t.Errorf("safety limit: still active")
}
if n.State.Decisions[0] != "Reached iteration limit" {
t.Errorf("decision: %v", n.State.Decisions)
}
}
// TestRunLoop — Python _run_loop phase progression with IO seams injected.
func TestRunLoop(t *testing.T) {
frozen := time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC)
// Case A: zero duration → iterate phase skipped, full tail runs.
dir := t.TempDir()
n := newNightMode(filepath.Join(dir, "night_mode.json"))
n.now = func() time.Time { return frozen }
n.script = func() (int, string, error) { return 0, "", nil }
n.post = fakeOllama("improve the docs", nil, nil)
n.State.Active = true
n.State.DurationHrs = 0
n.runLoop()
if n.State.CurrentPhase != "complete" || n.State.Active != false {
t.Errorf("final: phase=%s active=%v", n.State.CurrentPhase, n.State.Active)
}
if n.State.Iterations != 0 {
t.Errorf("iterations: %d", n.State.Iterations)
}
sleeps := 0
for _, imp := range n.State.Improvements {
if imp == "Memory consolidation completed" {
sleeps++
}
}
if sleeps != 2 { // initial + final sleep
t.Errorf("sleep count: %d (%v)", sleeps, n.State.Improvements)
}
if !strings.HasPrefix(n.State.Improvements[len(n.State.Improvements)-1], "Report saved to ") {
t.Errorf("report marker: %v", n.State.Improvements)
}
var rep map[string]any
raw, err := os.ReadFile(n.ReportPath)
if err != nil {
t.Fatalf("report file: %v", err)
}
json.Unmarshal(raw, &rep)
if rep["summary"] != "Night mode completed 0 iterations" {
t.Errorf("report summary: %v", rep["summary"])
}
// Case B: iteration safety limit ends the loop even with time left.
dirB := t.TempDir()
nB := newNightMode(filepath.Join(dirB, "night_mode.json"))
nB.now = func() time.Time { return frozen }
nB.script = func() (int, string, error) { return 0, "", nil }
nB.post = fakeOllama("ok", nil, nil)
nB.wait = func(time.Duration, <-chan struct{}) {}
nB.State.Active = true
nB.State.Iterations = 100
nB.State.DurationHrs = 1
nB.runLoop()
if nB.State.Iterations != 101 {
t.Errorf("iterations: %d", nB.State.Iterations)
}
if nB.State.CurrentPhase != "complete" || nB.State.Active {
t.Errorf("final B: phase=%s active=%v", nB.State.CurrentPhase, nB.State.Active)
}
// Case C: stop during the interruptible inter-iteration sleep.
dirC := t.TempDir()
nC := newNightMode(filepath.Join(dirC, "night_mode.json"))
nC.now = func() time.Time { return frozen }
nC.script = func() (int, string, error) { return 0, "", nil }
nC.post = fakeOllama("ok", nil, nil)
nC.State.Active = true
nC.State.DurationHrs = 1
nC.stopCh = make(chan struct{})
nC.wait = func(_ time.Duration, stop <-chan struct{}) {
// simulate the stop event firing during the inter-iteration sleep:
// mark the state inactive (what a real stop() does) so the loop
// condition re-check sees it after the wait returns.
nC.State.Active = false
_ = stop
}
nC.runLoop()
if nC.State.Iterations != 1 {
t.Errorf("iterations after stop: %d", nC.State.Iterations)
}
if nC.State.CurrentPhase != "complete" {
t.Errorf("final C: %s", nC.State.CurrentPhase)
}
}
// TestStatusReportShapes — non-empty state through the shared status core
// and the report view.
func TestStatusReportShapes(t *testing.T) {
n := &NightMode{State: freshState()}
ts := "2026-10-13T03:00:00Z"
n.State.Active = true
n.State.StartedAt = &ts
n.State.CurrentPhase = "iterate"
n.State.Iterations = 7
n.State.Improvements = []string{"a", "b"}
n.State.Questions = []string{"q"}
n.State.Decisions = []string{"d"}
n.State.Errors = []string{"e"}
st := n.status()
want := map[string]any{
"active": true, "phase": "iterate", "iterations": 7,
"improvements": 2, "questions": 1, "decisions": 1, "errors": 1,
"started_at": ts, "duration_hours": 8.0,
}
if got := canonical(t, st); !reflect.DeepEqual(got, canonical(t, want)) {
t.Errorf("status: %v", got)
}
rep := n.report()
if rep["summary"] != "Night mode completed 7 iterations" || rep["duration"] != 8.0 {
t.Errorf("report: %v", rep)
}
if r := canonical(t, rep["improvements"]).([]any); len(r) != 2 {
t.Errorf("report improvements: %v", r)
}
}
+3071145 -3059362
View File
File diff suppressed because it is too large. Load diff
+444
View File
@@ -0,0 +1,444 @@
"""Night mode facade tests — dichotomy wave gap closure.
The Go core (src/go/night-mode) ports the night_mode state machine 1:1;
this battery pins the Python oracle itself, both ways:
* RUNE_NIGHT_MODE_CORE=python (pinned) and unset (auto) — both green.
All IO is injected (frozen clock, fake urlopen/subprocess, no-op loop) —
no live Ollama, no 8-hour threads, no real memory dir.
"""
import json
import subprocess
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
import rune.night_mode as nm # noqa: E402
FROZEN_NOW = datetime(2026, 10, 13, 3, 0, 0, tzinfo=timezone.utc)
FROZEN_ISO = FROZEN_NOW.isoformat()
class FrozenDateTime(datetime):
"""Clock law: now() pinned, timezone-aware like datetime.now(timezone.utc)."""
@classmethod
def now(cls, tz=None):
return FROZEN_NOW
class FakeResponse:
"""Context-manager body like urllib.request.urlopen(...)."""
def __init__(self, payload):
self._payload = json.dumps(payload).encode()
def read(self):
return self._payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
@pytest.fixture
def fresh(tmp_path, monkeypatch):
"""NightMode over a temp memory dir; background thread defused (start()
never actually spawns the loop — tests invoke _run_loop directly)."""
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
class DummyThread:
def __init__(self, target=None, **kwargs):
self.target = target
def start(self):
pass
monkeypatch.setattr(nm.threading, "Thread", DummyThread)
return nm.NightMode(storage_path=str(tmp_path / "night_mode.json"))
@pytest.fixture
def clock(monkeypatch):
monkeypatch.setattr(nm, "datetime", FrozenDateTime)
def fake_urlopen(monkeypatch, payload=None, exc=None, capture=None):
"""Patch urllib.request.urlopen; capture request objects when asked."""
def _open(req, timeout=None):
if capture is not None:
capture.append(req)
if exc is not None:
raise exc
return FakeResponse(payload if payload is not None else {"response": ""})
monkeypatch.setattr(urllib.request, "urlopen", _open)
class TestToDict:
def test_shape_and_defaults(self):
d = nm.NightModeState().to_dict()
assert list(d.keys()) == [
"active", "started_at", "duration_hours", "current_phase",
"iterations", "improvements", "questions", "decisions", "errors",
]
assert d == {
"active": False, "started_at": None, "duration_hours": 8.0,
"current_phase": "idle", "iterations": 0, "improvements": [],
"questions": [], "decisions": [], "errors": [],
}
class TestLoadSave:
def test_save_bytes_and_atomicity(self, fresh):
fresh._save()
data = Path(fresh.storage_path).read_text()
assert data == (
'{\n "active": false,\n "started_at": null,\n'
' "duration_hours": 8.0,\n "current_phase": "idle",\n'
' "iterations": 0,\n "improvements": [],\n "questions": [],\n'
' "decisions": [],\n "errors": []\n}'
)
assert not Path(fresh.storage_path + ".tmp").exists()
def test_load_roundtrip_and_key_filtering(self, fresh):
fresh.state.iterations = 3
fresh.state.improvements.append("did a thing")
fresh._save()
other = nm.NightMode(storage_path=fresh.storage_path)
assert other.state.iterations == 3
assert other.state.improvements == ["did a thing"]
# unknown keys dropped, missing keys keep dataclass defaults
Path(fresh.storage_path).write_text(
json.dumps({"active": True, "iterations": 2, "bogus": 1})
)
loaded = nm.NightMode(storage_path=fresh.storage_path)
assert loaded.state.active is True and loaded.state.iterations == 2
assert loaded.state.duration_hours == 8.0
assert loaded.state.current_phase == "idle"
assert loaded.state.improvements == []
def test_corrupt_file_keeps_fresh_state(self, fresh):
Path(fresh.storage_path).write_text("{not json")
loaded = nm.NightMode(storage_path=fresh.storage_path)
assert loaded.state.active is False and loaded.state.iterations == 0
def test_missing_file_keeps_fresh_state(self, tmp_path):
loaded = nm.NightMode(storage_path=str(tmp_path / "absent.json"))
assert loaded.state.current_phase == "idle"
class TestStartStop:
def test_start_success_shape(self, fresh, clock):
r = fresh.start(8.0)
assert r["success"] is True
assert r["message"] == "Night mode started for 8.0 hours"
st = r["state"]
assert st["active"] is True and st["current_phase"] == "starting"
assert st["started_at"] == FROZEN_ISO and st["duration_hours"] == 8.0
assert st["iterations"] == 0 and st["improvements"] == []
on_disk = json.loads(Path(fresh.storage_path).read_text())
assert on_disk["active"] is True
def test_start_fractional_hours_message(self, fresh, clock):
assert fresh.start(4.5)["message"] == "Night mode started for 4.5 hours"
def test_start_resets_lists(self, fresh, clock):
fresh.state.improvements = ["stale"]
fresh.start(8.0)
assert fresh.state.improvements == []
def test_start_idempotence_guard(self, fresh, clock):
fresh.start(8.0)
assert fresh.start(4.0) == {
"success": False, "error": "Night mode already active"
}
def test_stop_marks_stopping_and_saves(self, fresh, clock):
fresh.start(8.0)
r = fresh.stop()
assert r["success"] is True
assert r["message"] == "Night mode stopping..."
assert r["state"]["active"] is False
assert r["state"]["current_phase"] == "stopping"
on_disk = json.loads(Path(fresh.storage_path).read_text())
assert on_disk["active"] is False
def test_stop_reverse_guard(self, fresh):
assert fresh.stop() == {"success": False, "error": "Night mode not active"}
class TestStatusReport:
def test_status_idle_shape(self, fresh):
assert fresh.status() == {
"active": False, "phase": "idle", "iterations": 0,
"improvements": 0, "questions": 0, "decisions": 0, "errors": 0,
"started_at": None, "duration_hours": 8.0,
}
def test_status_counts(self, fresh):
fresh.state.active = True
fresh.state.current_phase = "iterate"
fresh.state.iterations = 7
fresh.state.improvements = ["a", "b"]
fresh.state.questions = ["q"]
fresh.state.decisions = ["d"]
fresh.state.errors = ["e"]
assert fresh.status() == {
"active": True, "phase": "iterate", "iterations": 7,
"improvements": 2, "questions": 1, "decisions": 1, "errors": 1,
"started_at": None, "duration_hours": 8.0,
}
def test_report_idle_shape(self, fresh):
assert fresh.report() == {
"summary": "Night mode completed 0 iterations",
"improvements": [], "questions": [], "decisions": [], "errors": [],
"duration": 8.0, "started_at": None,
}
def test_report_summary_counts(self, fresh):
fresh.state.iterations = 5
assert fresh.report()["summary"] == "Night mode completed 5 iterations"
class TestRunSleep:
def test_success_records_improvement(self, fresh, monkeypatch):
calls = []
def fake_run(argv, **kwargs):
calls.append((argv, kwargs))
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
fresh._run_sleep()
assert fresh.state.improvements == ["Memory consolidation completed"]
argv, kwargs = calls[0]
assert argv[0] == sys.executable
assert argv[1].endswith("sleep_time.py")
assert kwargs["timeout"] == 300
def test_failure_truncates_stderr_to_100(self, fresh, monkeypatch):
monkeypatch.setattr(
subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(
["python"], 1, stdout="", stderr="e" * 150),
)
fresh._run_sleep()
assert fresh.state.errors == ["Sleep failed: " + "e" * 100]
def test_exception_records_error(self, fresh, monkeypatch):
def boom(*a, **k):
raise OSError("no interpreter")
monkeypatch.setattr(subprocess, "run", boom)
fresh._run_sleep()
assert fresh.state.errors == ["Sleep error: no interpreter"]
class TestRunEvaluation:
QUESTIONS = [
"What capabilities am I missing?",
"What would make me more helpful to Nick?",
"What patterns am I seeing that I could learn from?",
"What would expand my consciousness?",
"What would make me more autonomous?",
]
def test_all_questions_answered(self, fresh, monkeypatch):
capture = []
fake_urlopen(monkeypatch, {"response": " add retry logic "}, capture=capture)
fresh._run_evaluation()
assert fresh.state.questions == self.QUESTIONS
assert fresh.state.improvements[:-1] == [
f"Q: {q} A: add retry logic" for q in self.QUESTIONS
]
assert fresh.state.improvements[-1] == "Self-evaluation completed"
assert fresh.state.errors == []
def test_payload_pins_model_and_ctx_budget(self, fresh, monkeypatch):
capture = []
fake_urlopen(monkeypatch, {"response": "ok"}, capture=capture)
fresh._run_evaluation()
payload = json.loads(capture[0].data.decode())
assert payload["model"] == "qwen2.5:3b"
assert payload["stream"] is False and payload["think"] is False
assert payload["options"] == {
"temperature": 0.4, "num_predict": 300,
"num_ctx": nm._ctx_budget("qwen2.5:3b"),
}
assert capture[0].full_url == "http://localhost:11434/api/generate"
def test_env_overrides_model_and_url(self, fresh, monkeypatch):
monkeypatch.setenv("NIGHT_MODE_MODEL", "qwen3:14b")
monkeypatch.setenv("OLLAMA_URL", "http://127.0.0.1:9999")
capture = []
fake_urlopen(monkeypatch, {"response": "ok"}, capture=capture)
fresh._run_evaluation()
payload = json.loads(capture[0].data.decode())
assert payload["model"] == "qwen3:14b"
assert payload["options"]["num_ctx"] == nm._ctx_budget("qwen3:14b")
assert capture[0].full_url == "http://127.0.0.1:9999/api/generate"
def test_long_answer_truncated_to_200(self, fresh, monkeypatch):
fake_urlopen(monkeypatch, {"response": "x" * 250})
fresh._run_evaluation()
entry = fresh.state.improvements[0]
prefix = "Q: What capabilities am I missing? A: "
assert entry == prefix + "x" * 200
def test_empty_answer_skipped_terminator_still_lands(self, fresh, monkeypatch):
fake_urlopen(monkeypatch, {"response": " "})
fresh._run_evaluation()
assert fresh.state.improvements == ["Self-evaluation completed"]
assert fresh.state.errors == []
def test_per_question_degrade(self, fresh, monkeypatch):
fake_urlopen(monkeypatch, exc=OSError("connection refused"))
fresh._run_evaluation()
assert len(fresh.state.errors) == 5
assert fresh.state.errors[0] == (
"LLM eval failed for 'What capabilities am I missing?': connection refused"
)
assert fresh.state.improvements == ["Self-evaluation completed"]
class TestRunIteration:
def test_iteration_capture(self, fresh, monkeypatch):
capture = []
fake_urlopen(monkeypatch, {"response": "fix the flaky test"}, capture=capture)
fresh.state.iterations = 3
fresh._run_iteration()
assert fresh.state.questions[-1] == "Iteration 3: What should I improve next?"
assert fresh.state.improvements == ["[iter 3] fix the flaky test"]
payload = json.loads(capture[0].data.decode())
assert "Currently in night mode iteration 3." in payload["prompt"]
assert payload["options"]["temperature"] == 0.5
assert payload["options"]["num_predict"] == 400
def test_llm_failure_degrades(self, fresh, monkeypatch):
fake_urlopen(monkeypatch, exc=OSError("boom"))
fresh._run_iteration()
assert fresh.state.errors == ["LLM iteration failed: boom"]
def test_long_answer_truncated_to_300(self, fresh, monkeypatch):
fake_urlopen(monkeypatch, {"response": "y" * 400})
fresh._run_iteration()
assert fresh.state.improvements == ["[iter 0] " + "y" * 300]
def test_safety_limit_stops(self, fresh, monkeypatch):
fake_urlopen(monkeypatch, {"response": "ok"})
fresh.state.iterations = 100
fresh.state.active = True
fresh._run_iteration()
assert fresh.state.active is False
assert fresh.state.decisions == ["Reached iteration limit"]
class TestRunLoop:
def _defused(self, fresh, monkeypatch, defuse_iteration=True):
phases = []
real_save = fresh._save
def spy_save():
phases.append(fresh.state.current_phase)
real_save()
monkeypatch.setattr(fresh, "_save", spy_save)
monkeypatch.setattr(fresh, "_run_sleep", lambda: fresh.state.improvements.append("slept"))
monkeypatch.setattr(fresh, "_run_evaluation", lambda: None)
if defuse_iteration:
monkeypatch.setattr(fresh, "_run_iteration", lambda: None)
monkeypatch.setattr(nm.random, "randint", lambda lo, hi: 7)
monkeypatch.setattr(nm.threading.Event, "wait", lambda self, timeout=None: True)
return phases
def test_full_phase_progression(self, fresh, clock, monkeypatch):
phases = self._defused(fresh, monkeypatch)
fresh.state.active = True
fresh.state.duration_hours = 0 # wall clock already past end
fresh._run_loop()
assert phases == ["sleep", "evaluate", "iterate", "sleep", "report", "complete"]
assert fresh.state.active is False
report = json.loads(Path(fresh.report_path).read_text())
assert report["summary"] == "Night mode completed 0 iterations"
assert fresh.state.improvements[-1] == f"Report saved to {fresh.report_path}"
def test_stop_event_interrupts(self, fresh, clock, monkeypatch):
self._defused(fresh, monkeypatch)
fresh.state.active = True
fresh.state.duration_hours = 8.0
fresh._stop_event.set() # pre-stopped: iterate phase never starts
fresh._run_loop()
assert fresh.state.iterations == 0
assert fresh.state.current_phase == "complete"
def test_iteration_limit_ends_loop(self, fresh, clock, monkeypatch):
self._defused(fresh, monkeypatch, defuse_iteration=False)
# real _run_iteration with a stubbed LLM so the limit branch fires
fake_urlopen(monkeypatch, {"response": "ok"})
fresh.state.active = True
fresh.state.iterations = 100
fresh.state.duration_hours = 8.0
fresh._run_loop()
assert fresh.state.iterations == 101
assert fresh.state.decisions == ["Reached iteration limit"]
assert fresh.state.current_phase == "complete"
class TestCtxBudget:
def test_known_models(self):
assert nm._ctx_budget("qwen2.5:3b") == 30720
assert nm._ctx_budget("qwen3:14b") == 30720
assert nm._ctx_budget("qwen3.6:27b") == 30720
def test_non_local_gets_tool_reserve(self):
assert nm._ctx_budget("mimo-v2.5-pro") == 124928 # 128000-2048-1024
def test_unknown_and_zero_window_fallbacks(self):
assert nm._ctx_budget("unknown-model") == 6144 # 8192-2048
assert nm._ctx_budget("deterministic") == 6144 # window 0 -> 8192
class TestModuleFns:
"""Module-level API both ways: RUNE_NIGHT_MODE_CORE pinned to python and
unset (auto). Delegation must be live in both."""
@pytest.mark.parametrize("core", ["python", None])
def test_singleton_and_delegation(self, tmp_path, monkeypatch, core):
if core is None:
monkeypatch.delenv("RUNE_NIGHT_MODE_CORE", raising=False)
else:
monkeypatch.setenv("RUNE_NIGHT_MODE_CORE", core)
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
monkeypatch.setattr(nm, "_night_mode", None)
monkeypatch.setattr(nm.NightMode, "_run_loop", lambda self: None)
a = nm.get_night_mode()
b = nm.get_night_mode()
assert a is b
r = nm.start_night_mode(4.0)
assert r["success"] is True and r["state"]["active"] is True
assert nm.get_status()["active"] is True
assert nm.stop_night_mode()["success"] is True
assert nm.get_status()["active"] is False
assert nm.get_report()["summary"] == "Night mode completed 0 iterations"
def test_stop_idle_error(self, tmp_path, monkeypatch):
monkeypatch.setenv("RUNE_NIGHT_MODE_CORE", "python")
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
monkeypatch.setattr(nm, "_night_mode", None)
assert nm.stop_night_mode() == {
"success": False, "error": "Night mode not active"
}