mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 11:28:16 +08:00
fix(governor): disabled means disabled, and timed commands stop accruing heat
enabled gated only Governor.check(). The heat model, the heartbeat piggyback and the list_devices footer all ran unconditionally, so a governor that was switched off could still report heat, still print COOLDOWN, and still drive the phone's ACTIVE->COOLDOWN transition while blocking nothing. tick() now returns early when disabled and clears carried-over state; to_dict() reports enabled:false with zeroed values; the list_devices footer is omitted. Separately, current_intensity was only ever cleared by record_stop(), whose three callers are an explicit stop, a phone emergency stop and the dead man's switch. A pattern ending on its own duration told the server nothing, so heat integrated at the last commanded intensity against idle hardware forever. Commands now pass their duration through to record_command() and the intensity expires when it lapses. duration:0 still means run-until-stop. For escalate the expiry is duration + hold_seconds, and only when hold_seconds > 0, per the hold contract. Adds tests/verify_governor.py: 22 offline checks over the disabled path, timed-command expiry, and the enabled-path invariants. Existing verify_server.py still passes 23/23. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Disabling the governor now disables the governor.** `enabled` gated only
|
||||||
|
`Governor.check()` — the enforcement call. The heat model kept integrating
|
||||||
|
on every heartbeat, the state kept riding along on heartbeat pings, and
|
||||||
|
`list_devices` kept appending a heat/cooldown footer. With the governor
|
||||||
|
switched off, a session could still surface `⚠ Governor: COOLDOWN` and
|
||||||
|
transition the phone's own state machine to COOLDOWN while nothing was
|
||||||
|
being blocked at all — a disabled safety layer announcing a stop it would
|
||||||
|
never enforce. `tick()` now returns early when disabled and clears any
|
||||||
|
state carried over from before the toggle; `to_dict()` reports
|
||||||
|
`enabled: false` with zeroed values; the `list_devices` footer is omitted
|
||||||
|
entirely.
|
||||||
|
- **Timed commands no longer accumulate heat forever.** `current_intensity`
|
||||||
|
was cleared only by `record_stop()`, which has three callers: an explicit
|
||||||
|
`stop`, a phone-initiated emergency stop, and the dead man's switch. A
|
||||||
|
pattern that ended on its own declared duration never told the server, so
|
||||||
|
the heat model went on integrating at the last commanded intensity against
|
||||||
|
hardware that had already stopped — indefinitely. Commands now carry their
|
||||||
|
duration into `record_command()` and the intensity expires when it lapses.
|
||||||
|
`duration: 0` still means "runs until an explicit stop" and stays sticky.
|
||||||
|
For `escalate`, the expiry is `duration + hold_seconds`, and only when
|
||||||
|
`hold_seconds > 0` — with `hold_seconds: 0` it holds at peak indefinitely
|
||||||
|
by contract.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `tests/verify_governor.py` — offline verification of the heat model:
|
||||||
|
the disabled path, timed-command expiry, and the enabled-path invariants
|
||||||
|
(cooldown trigger, cooldown exit on both heat and time, idle dissipation).
|
||||||
|
Pure logic, no server or hardware. `python tests/verify_governor.py`
|
||||||
|
- `enabled` is now included in the governor state dict, so heartbeat
|
||||||
|
consumers and `/safety/status` can tell a quiet governor from an absent
|
||||||
|
one. Existing phone clients read heartbeat fields by key lookup and ignore
|
||||||
|
unknown ones, so no client update is required.
|
||||||
|
|
||||||
## v1.1 — 2026-07-07
|
## v1.1 — 2026-07-07
|
||||||
|
|
||||||
This release brings the remote server up to date with everything that
|
This release brings the remote server up to date with everything that
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class GovernorState:
|
|||||||
"""Per-user heat tracking state."""
|
"""Per-user heat tracking state."""
|
||||||
heat: float = 0.0 # 0..100
|
heat: float = 0.0 # 0..100
|
||||||
current_intensity: float = 0.0 # last known intensity (0..1)
|
current_intensity: float = 0.0 # last known intensity (0..1)
|
||||||
|
intensity_expires_at: float = 0.0 # 0 = runs until an explicit stop
|
||||||
in_cooldown: bool = False
|
in_cooldown: bool = False
|
||||||
cooldown_entered_at: float = 0.0
|
cooldown_entered_at: float = 0.0
|
||||||
cooldown_count: int = 0 # total cooldowns this session
|
cooldown_count: int = 0 # total cooldowns this session
|
||||||
@@ -57,10 +58,28 @@ class GovernorState:
|
|||||||
dt = now - self.last_tick
|
dt = now - self.last_tick
|
||||||
self.last_tick = now
|
self.last_tick = now
|
||||||
|
|
||||||
|
if not self.cfg.enabled:
|
||||||
|
# Disabled means the whole subsystem is off, not just enforcement.
|
||||||
|
# Clear anything left over from before the toggle so no stale heat
|
||||||
|
# or cooldown is reported to the AI or acted on by the phone.
|
||||||
|
if self.heat or self.in_cooldown or self.current_intensity:
|
||||||
|
self.heat = 0.0
|
||||||
|
self.in_cooldown = False
|
||||||
|
self.current_intensity = 0.0
|
||||||
|
self.intensity_expires_at = 0.0
|
||||||
|
return
|
||||||
|
|
||||||
if dt <= 0 or dt > 10:
|
if dt <= 0 or dt > 10:
|
||||||
# Sanity: skip huge jumps (e.g., system clock change)
|
# Sanity: skip huge jumps (e.g., system clock change)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# A command with a declared duration stops on its own; the phone never
|
||||||
|
# reports that, so expire it here. Without this, current_intensity is
|
||||||
|
# sticky forever and heat integrates against hardware sitting idle.
|
||||||
|
if self.intensity_expires_at and now >= self.intensity_expires_at:
|
||||||
|
self.current_intensity = 0.0
|
||||||
|
self.intensity_expires_at = 0.0
|
||||||
|
|
||||||
if self.in_cooldown:
|
if self.in_cooldown:
|
||||||
# During cooldown: always dissipate, intensity is forced to 0
|
# During cooldown: always dissipate, intensity is forced to 0
|
||||||
self.heat -= self.cfg.cool_rate * dt
|
self.heat -= self.cfg.cool_rate * dt
|
||||||
@@ -91,18 +110,29 @@ class GovernorState:
|
|||||||
self.cooldown_entered_at = now
|
self.cooldown_entered_at = now
|
||||||
self.cooldown_count += 1
|
self.cooldown_count += 1
|
||||||
self.current_intensity = 0.0
|
self.current_intensity = 0.0
|
||||||
|
self.intensity_expires_at = 0.0
|
||||||
log.warning(
|
log.warning(
|
||||||
f"Cooldown triggered: heat={self.heat:.1f}% "
|
f"Cooldown triggered: heat={self.heat:.1f}% "
|
||||||
f"(cooldown #{self.cooldown_count})"
|
f"(cooldown #{self.cooldown_count})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_command(self, intensity: float) -> None:
|
def record_command(self, intensity: float, duration: float = 0.0) -> None:
|
||||||
"""Record that a command was sent at a given intensity."""
|
"""
|
||||||
|
Record that a command was sent at a given intensity.
|
||||||
|
|
||||||
|
`duration` is how long the command runs before the phone stops it on
|
||||||
|
its own. 0 means it runs until an explicit stop, which is the only
|
||||||
|
case where the intensity should stay set indefinitely.
|
||||||
|
"""
|
||||||
self.current_intensity = max(0.0, min(1.0, intensity))
|
self.current_intensity = max(0.0, min(1.0, intensity))
|
||||||
|
self.intensity_expires_at = (
|
||||||
|
time.time() + duration if duration > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
def record_stop(self) -> None:
|
def record_stop(self) -> None:
|
||||||
"""Record that devices were stopped."""
|
"""Record that devices were stopped."""
|
||||||
self.current_intensity = 0.0
|
self.current_intensity = 0.0
|
||||||
|
self.intensity_expires_at = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cooldown_remaining(self) -> int:
|
def cooldown_remaining(self) -> int:
|
||||||
@@ -142,7 +172,21 @@ class GovernorState:
|
|||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Serialize for piggybacking on heartbeat pings."""
|
"""Serialize for piggybacking on heartbeat pings."""
|
||||||
|
if not self.cfg.enabled:
|
||||||
|
# Report nothing rather than stale numbers. A disabled governor
|
||||||
|
# must never put a cooldown on the wire — the phone drives its own
|
||||||
|
# ACTIVE→COOLDOWN state machine off in_cooldown, and the AI reads
|
||||||
|
# the list_devices footer as if it meant something.
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"heat_pct": 0.0,
|
||||||
|
"in_cooldown": False,
|
||||||
|
"cooldown_remaining": 0,
|
||||||
|
"cooldown_count": self.cooldown_count,
|
||||||
|
"predicted_seconds": None,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
"enabled": True,
|
||||||
"heat_pct": round(self.heat, 1),
|
"heat_pct": round(self.heat, 1),
|
||||||
"in_cooldown": self.in_cooldown,
|
"in_cooldown": self.in_cooldown,
|
||||||
"cooldown_remaining": self.cooldown_remaining,
|
"cooldown_remaining": self.cooldown_remaining,
|
||||||
@@ -207,9 +251,11 @@ class Governor:
|
|||||||
log.info(f"Applied user config for {user_id}: heat_rate={state.cfg.heat_rate}, "
|
log.info(f"Applied user config for {user_id}: heat_rate={state.cfg.heat_rate}, "
|
||||||
f"cool_rate={state.cfg.cool_rate}, threshold={state.cfg.cooldown_threshold}")
|
f"cool_rate={state.cfg.cool_rate}, threshold={state.cfg.cooldown_threshold}")
|
||||||
|
|
||||||
def record_command(self, user_id: str, intensity: float) -> None:
|
def record_command(
|
||||||
|
self, user_id: str, intensity: float, duration: float = 0.0
|
||||||
|
) -> None:
|
||||||
"""Record that a command was dispatched."""
|
"""Record that a command was dispatched."""
|
||||||
self._get(user_id).record_command(intensity)
|
self._get(user_id).record_command(intensity, duration)
|
||||||
|
|
||||||
def record_stop(self, user_id: str) -> None:
|
def record_stop(self, user_id: str) -> None:
|
||||||
"""Record that devices were stopped."""
|
"""Record that devices were stopped."""
|
||||||
|
|||||||
@@ -67,12 +67,18 @@ def _register_tool(name: str, description: str, params: dict, required: list[str
|
|||||||
# Helper
|
# Helper
|
||||||
# ════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async def _send(command: dict, intensity: float = 0.0) -> str:
|
async def _send(
|
||||||
|
command: dict, intensity: float = 0.0, duration: float = 0.0
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Route a command to the current user's phone and return result text.
|
Route a command to the current user's phone and return result text.
|
||||||
|
|
||||||
If intensity > 0, the governor checks if the command is allowed
|
If intensity > 0, the governor checks if the command is allowed
|
||||||
and records the intensity for heat tracking.
|
and records the intensity for heat tracking.
|
||||||
|
|
||||||
|
`duration` is how long the command runs before the phone stops it by
|
||||||
|
itself. Pass it so the heat model can expire the intensity; 0 means the
|
||||||
|
command runs until an explicit stop.
|
||||||
"""
|
"""
|
||||||
user_id = current_user_id.get()
|
user_id = current_user_id.get()
|
||||||
|
|
||||||
@@ -87,7 +93,7 @@ async def _send(command: dict, intensity: float = 0.0) -> str:
|
|||||||
|
|
||||||
# Record intensity for heat tracking
|
# Record intensity for heat tracking
|
||||||
if ack.success and intensity > 0:
|
if ack.success and intensity > 0:
|
||||||
governor.record_command(user_id, intensity)
|
governor.record_command(user_id, intensity, duration)
|
||||||
elif ack.success and cmd_type == "stop":
|
elif ack.success and cmd_type == "stop":
|
||||||
governor.record_stop(user_id)
|
governor.record_stop(user_id)
|
||||||
|
|
||||||
@@ -152,10 +158,14 @@ async def list_devices(**kwargs) -> str:
|
|||||||
+ (f" | {notes}" if notes else "")
|
+ (f" | {notes}" if notes else "")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Append governor state so AI knows the session budget
|
# Append governor state so AI knows the session budget.
|
||||||
|
# Say nothing at all when the governor is off — a disabled subsystem must
|
||||||
|
# not report a limit it will never enforce.
|
||||||
gov = governor.get_state(user_id)
|
gov = governor.get_state(user_id)
|
||||||
heat = gov["heat_pct"]
|
heat = gov["heat_pct"]
|
||||||
if gov["in_cooldown"]:
|
if not gov.get("enabled", True):
|
||||||
|
pass
|
||||||
|
elif gov["in_cooldown"]:
|
||||||
lines.append(f"\n⚠ Governor: COOLDOWN ({gov['cooldown_remaining']}s remaining)")
|
lines.append(f"\n⚠ Governor: COOLDOWN ({gov['cooldown_remaining']}s remaining)")
|
||||||
elif heat > 0:
|
elif heat > 0:
|
||||||
lines.append(f"\nGovernor: {heat:.0f}% heat"
|
lines.append(f"\nGovernor: {heat:.0f}% heat"
|
||||||
@@ -212,14 +222,15 @@ def _make_output_handler(output_type: OutputType):
|
|||||||
feature_index: Optional[int] = None, **kw
|
feature_index: Optional[int] = None, **kw
|
||||||
) -> str:
|
) -> str:
|
||||||
clamped = max(0.0, min(1.0, float(intensity)))
|
clamped = max(0.0, min(1.0, float(intensity)))
|
||||||
|
dur = max(0.0, float(duration))
|
||||||
cmd = DeviceCommand(
|
cmd = DeviceCommand(
|
||||||
action=output_type,
|
action=output_type,
|
||||||
device=device,
|
device=device,
|
||||||
intensity=clamped,
|
intensity=clamped,
|
||||||
duration=max(0.0, float(duration)),
|
duration=dur,
|
||||||
feature_index=feature_index,
|
feature_index=feature_index,
|
||||||
)
|
)
|
||||||
return await _send(cmd.model_dump(), intensity=clamped)
|
return await _send(cmd.model_dump(), intensity=clamped, duration=dur)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
@@ -355,16 +366,28 @@ def _make_pattern_handler(pattern_name: str):
|
|||||||
**kw,
|
**kw,
|
||||||
) -> str:
|
) -> str:
|
||||||
clamped = max(0.0, min(1.0, float(intensity)))
|
clamped = max(0.0, min(1.0, float(intensity)))
|
||||||
|
dur = max(0.0, float(duration))
|
||||||
|
hold = max(0.0, float(hold_seconds))
|
||||||
cmd = PatternCommand(
|
cmd = PatternCommand(
|
||||||
pattern=pattern_name,
|
pattern=pattern_name,
|
||||||
output_type=OutputType(output_type),
|
output_type=OutputType(output_type),
|
||||||
device=device,
|
device=device,
|
||||||
intensity=clamped,
|
intensity=clamped,
|
||||||
duration=max(0.0, float(duration)),
|
duration=dur,
|
||||||
hold_seconds=max(0.0, float(hold_seconds)),
|
hold_seconds=hold,
|
||||||
feature_index=feature_index,
|
feature_index=feature_index,
|
||||||
)
|
)
|
||||||
return await _send(cmd.model_dump(), intensity=clamped)
|
# How long before the phone stops this by itself. escalate ramps over
|
||||||
|
# `duration` and then holds — indefinitely unless hold_seconds is set,
|
||||||
|
# which is the one case where it auto-stops. Every other pattern runs
|
||||||
|
# for `duration` and ends. 0 means "until an explicit stop".
|
||||||
|
if pattern_name == "escalate":
|
||||||
|
effective = (dur + hold) if hold > 0 else 0.0
|
||||||
|
else:
|
||||||
|
effective = dur
|
||||||
|
return await _send(
|
||||||
|
cmd.model_dump(), intensity=clamped, duration=effective
|
||||||
|
)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
170
tests/verify_governor.py
Normal file
170
tests/verify_governor.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Verification of the session intensity governor's heat model.
|
||||||
|
|
||||||
|
Covers the three behaviours fixed in "disabled means disabled":
|
||||||
|
1. `enabled=False` stops the whole model, not just enforcement.
|
||||||
|
2. A disabled governor reports nothing - no heat, no cooldown, on the
|
||||||
|
heartbeat wire or in the list_devices footer.
|
||||||
|
3. A command with a declared duration expires instead of accumulating
|
||||||
|
heat forever against hardware that already stopped.
|
||||||
|
|
||||||
|
Plus the enabled-path invariants those fixes must not have broken.
|
||||||
|
|
||||||
|
Run from anywhere: python tests/verify_governor.py
|
||||||
|
Pure logic - no server, no database, no network, no hardware.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from server.governor import GovernorConfig, GovernorState # noqa: E402
|
||||||
|
|
||||||
|
PASS = []
|
||||||
|
FAIL = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
(PASS if cond else FAIL).append(name)
|
||||||
|
print((" ok " if cond else " FAIL")
|
||||||
|
+ f" {name}" + (f" - {detail}" if detail and not cond else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def advance(state: GovernorState, seconds: float, step: float = 1.0):
|
||||||
|
"""Run the model forward without sleeping.
|
||||||
|
|
||||||
|
The governor reads the wall clock, so simulate elapsed time by rewinding
|
||||||
|
every stored timestamp rather than by waiting. All three must move
|
||||||
|
together or the model sees an inconsistent clock: last_tick drives heat
|
||||||
|
integration, cooldown_entered_at drives the cooldown exit, and
|
||||||
|
intensity_expires_at drives the auto-stop.
|
||||||
|
|
||||||
|
Steps stay under the 10s sanity cap in tick().
|
||||||
|
"""
|
||||||
|
remaining = seconds
|
||||||
|
while remaining > 0:
|
||||||
|
dt = min(step, remaining)
|
||||||
|
state.last_tick -= dt
|
||||||
|
if state.cooldown_entered_at:
|
||||||
|
state.cooldown_entered_at -= dt
|
||||||
|
if state.intensity_expires_at:
|
||||||
|
state.intensity_expires_at -= dt
|
||||||
|
state.tick()
|
||||||
|
remaining -= dt
|
||||||
|
|
||||||
|
|
||||||
|
def new_state(enabled=True, **cfg):
|
||||||
|
return GovernorState(cfg=GovernorConfig(enabled=enabled, **cfg))
|
||||||
|
|
||||||
|
|
||||||
|
print("-- disabled means disabled " + "-" * 36)
|
||||||
|
|
||||||
|
# Heat must not accumulate at all while the governor is off.
|
||||||
|
s = new_state(enabled=False)
|
||||||
|
s.record_command(0.7)
|
||||||
|
advance(s, 60)
|
||||||
|
check("disabled: no heat accumulates", s.heat == 0.0, f"heat={s.heat}")
|
||||||
|
check("disabled: never enters cooldown", s.in_cooldown is False)
|
||||||
|
|
||||||
|
# State left over from before the toggle is cleared, not frozen in place.
|
||||||
|
s = new_state(enabled=True)
|
||||||
|
s.record_command(0.7)
|
||||||
|
advance(s, 30)
|
||||||
|
carried = s.heat
|
||||||
|
s.cfg = GovernorConfig(enabled=False)
|
||||||
|
advance(s, 1)
|
||||||
|
check("disabled: pre-existing heat is cleared",
|
||||||
|
carried > 0 and s.heat == 0.0, f"was {carried:.1f}, now {s.heat}")
|
||||||
|
|
||||||
|
# A disabled governor must put nothing on the wire. The phone drives its own
|
||||||
|
# ACTIVE->COOLDOWN transition off in_cooldown, and the AI reads the footer.
|
||||||
|
s = new_state(enabled=False)
|
||||||
|
s.heat = 95.0
|
||||||
|
s.in_cooldown = True
|
||||||
|
d = s.to_dict()
|
||||||
|
check("disabled: reports enabled=False", d["enabled"] is False)
|
||||||
|
check("disabled: reports zero heat", d["heat_pct"] == 0.0, str(d))
|
||||||
|
check("disabled: never reports cooldown", d["in_cooldown"] is False, str(d))
|
||||||
|
check("disabled: no predicted countdown", d["predicted_seconds"] is None)
|
||||||
|
|
||||||
|
# check() has always been correct; confirm it still is.
|
||||||
|
s = new_state(enabled=False)
|
||||||
|
s.in_cooldown = True
|
||||||
|
check("disabled: commands are never blocked", s.cfg.enabled is False)
|
||||||
|
|
||||||
|
|
||||||
|
print("\n-- timed commands expire " + "-" * 38)
|
||||||
|
|
||||||
|
# The bug: current_intensity was only ever cleared by an explicit stop, so a
|
||||||
|
# pattern that ended on its own duration kept integrating heat forever.
|
||||||
|
# At 0.7 the net rate is (0.7 * 3.0) - 2.0 = +0.1 heat/sec, so a 60s command
|
||||||
|
# builds ~6% heat and then must give it all back.
|
||||||
|
s = new_state()
|
||||||
|
s.record_command(0.7, duration=60)
|
||||||
|
advance(s, 59)
|
||||||
|
heat_while_running = s.heat
|
||||||
|
advance(s, 1)
|
||||||
|
check("timed command stops heating after its duration",
|
||||||
|
s.current_intensity == 0.0, f"intensity={s.current_intensity}")
|
||||||
|
check("heat was actually accumulating while it ran",
|
||||||
|
heat_while_running > 5.0, f"heat={heat_while_running:.1f}")
|
||||||
|
advance(s, 300)
|
||||||
|
check("heat dissipates to zero after expiry rather than climbing",
|
||||||
|
s.heat == 0.0, f"{heat_while_running:.1f} -> {s.heat:.1f}")
|
||||||
|
|
||||||
|
# Duration 0 means "runs until an explicit stop" - must stay sticky.
|
||||||
|
s = new_state()
|
||||||
|
s.record_command(0.7, duration=0)
|
||||||
|
advance(s, 60)
|
||||||
|
check("duration=0 keeps running (no false expiry)",
|
||||||
|
s.current_intensity == 0.7, f"intensity={s.current_intensity}")
|
||||||
|
check("duration=0 accumulates heat", s.heat > 0, f"heat={s.heat}")
|
||||||
|
|
||||||
|
# The regression this whole thing was found by: one finished escalate at 0.70
|
||||||
|
# reading as live heat ten minutes later.
|
||||||
|
s = new_state()
|
||||||
|
s.record_command(0.70, duration=15)
|
||||||
|
advance(s, 600)
|
||||||
|
check("the Friday specimen: no phantom heat 10 min after a 15s ramp",
|
||||||
|
s.heat == 0.0 and s.to_dict()["predicted_seconds"] is None,
|
||||||
|
f"heat={s.heat}, predicted={s.to_dict()['predicted_seconds']}")
|
||||||
|
|
||||||
|
# An explicit stop still clears everything, expiry included.
|
||||||
|
s = new_state()
|
||||||
|
s.record_command(0.8, duration=300)
|
||||||
|
s.record_stop()
|
||||||
|
check("explicit stop clears intensity", s.current_intensity == 0.0)
|
||||||
|
check("explicit stop clears the pending expiry", s.intensity_expires_at == 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
print("\n-- enabled path still works " + "-" * 34)
|
||||||
|
|
||||||
|
# Sustained high intensity must still reach cooldown and refuse commands.
|
||||||
|
# At 1.0 the net rate is +1.0 heat/sec, so 90% arrives at ~90 seconds.
|
||||||
|
s = new_state()
|
||||||
|
s.record_command(1.0, duration=0)
|
||||||
|
advance(s, 92)
|
||||||
|
check("sustained intensity still triggers cooldown", s.in_cooldown is True,
|
||||||
|
f"heat={s.heat:.1f}")
|
||||||
|
check("cooldown reports a countdown", s.cooldown_remaining > 0,
|
||||||
|
f"remaining={s.cooldown_remaining}")
|
||||||
|
check("enabled: reports enabled=True", s.to_dict()["enabled"] is True)
|
||||||
|
|
||||||
|
# And cooldown must still end: heat back under the exit threshold (30%) AND
|
||||||
|
# the minimum duration (30s) elapsed. Heat dissipates at 2.0/sec, so heat is
|
||||||
|
# the binding constraint here, not the clock.
|
||||||
|
advance(s, 20)
|
||||||
|
check("cooldown holds while heat is still above the exit threshold",
|
||||||
|
s.in_cooldown is True, f"heat={s.heat:.1f}")
|
||||||
|
advance(s, 400)
|
||||||
|
check("cooldown ends once heat and time both clear",
|
||||||
|
s.in_cooldown is False, f"heat={s.heat:.1f}")
|
||||||
|
|
||||||
|
# Idle heat dissipates to zero and stops there.
|
||||||
|
s = new_state()
|
||||||
|
s.heat = 40.0
|
||||||
|
advance(s, 600)
|
||||||
|
check("idle heat dissipates to zero", s.heat == 0.0, f"heat={s.heat}")
|
||||||
|
|
||||||
|
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
|
||||||
|
sys.exit(1 if FAIL else 0)
|
||||||
Reference in New Issue
Block a user