The reproduction, the three surveys and the before/after for the rung-10 Pewter stall, with the ROM-gated run's table. Rows 51 (UNKNOWN's two states), 52 (a building with no row on the map graph) and 53 (a frontier excluded by a window that lapses) each with their tests.
546 lines
22 KiB
Rust
546 lines
22 KiB
Rust
//! The progress ratchet: a port of the prototype's `src/runtime/ratchet.ts`.
|
|
//!
|
|
//! It keeps the best safe game snapshot seen so far and decides when a stalled
|
|
//! run should be rolled back to it. Generic over the adapter's rank: nothing
|
|
//! here knows about Pokémon, so a platformer adapter's ladder works unchanged.
|
|
//!
|
|
//! Limits (`docs/ratchet-sprint.md`): 120 brain seconds of stall, 180-second
|
|
//! recovery cooldown, three attempts per best rank, 36 lifetime recoveries.
|
|
//! They are deliberately conservative and can leave an unproductive run
|
|
//! unrecovered once exhausted.
|
|
//!
|
|
//! The lifetime budget scales with the ladder: it was twelve for a 16-rung ladder
|
|
//! and is 36 for the 38-rung one (`docs/design/ladder.md`), so three attempts at
|
|
//! every rung is still within budget. The per-rung allowance is unchanged, and so
|
|
//! are both windows.
|
|
//!
|
|
//! Those numbers are only the *defaults*. A game can ask for others, and for one
|
|
//! extra trigger, through the [`RecoveryPolicy`] its adapter returns
|
|
//! ([`crate::adapter::GameAdapter::recovery_policy`]). The default policy is
|
|
//! exactly the Pokémon behaviour above, game-over trigger disabled, so an adapter
|
|
//! that does not override it is unaffected; `docs/design/platformer.md` §5 explains
|
|
//! why a platformer needs a game-over restore and a lifetime budget larger still.
|
|
//!
|
|
//! The rank bound is not one of those numbers: it is the length of the running
|
|
//! adapter's ladder, which [`Ratchet::import`] takes as an argument.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::emulator::FRAMEBUFFER_LEN;
|
|
|
|
/// Brain milliseconds of stall before a recovery may fire.
|
|
pub const STALL_MS: u64 = 120_000;
|
|
/// Brain milliseconds between recoveries.
|
|
pub const RECOVERY_COOLDOWN_MS: u64 = 180_000;
|
|
/// Brain milliseconds of sustained unsafe activity that restarts the stall
|
|
/// window. Short walking or warp transitions are below this.
|
|
pub const UNSAFE_RESET_MS: u64 = 1_000;
|
|
/// Recovery attempts allowed at one best rank.
|
|
pub const MAX_ATTEMPTS: u64 = 3;
|
|
/// Lifetime recovery budget.
|
|
pub const MAX_RECOVERIES: u64 = 36;
|
|
|
|
/// Per-game recovery limits and triggers.
|
|
///
|
|
/// [`Default`] is the Pokémon policy, unchanged: the constants above, and no
|
|
/// game-over trigger. An adapter overrides it by returning its own from
|
|
/// [`crate::adapter::GameAdapter::recovery_policy`], which is where the
|
|
/// platformer's larger budgets live.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct RecoveryPolicy {
|
|
/// Brain milliseconds without progress before a stall recovery may fire.
|
|
pub stall_ms: u64,
|
|
/// Brain milliseconds between stall recoveries.
|
|
pub cooldown_ms: u64,
|
|
/// Brain milliseconds between game-over recoveries, or `None` to disable the
|
|
/// game-over trigger entirely. `None` is the default: Pokémon Red has no
|
|
/// game-over state the ratchet acts on, and a blackout there is not a lost
|
|
/// run.
|
|
pub game_over_cooldown_ms: Option<u64>,
|
|
/// Brain milliseconds of sustained unsafe activity that restarts the stall
|
|
/// window.
|
|
pub unsafe_reset_ms: u64,
|
|
/// Recovery attempts allowed at one best rank.
|
|
pub max_attempts: u64,
|
|
/// Lifetime recovery budget.
|
|
pub max_recoveries: u64,
|
|
}
|
|
|
|
impl Default for RecoveryPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
stall_ms: STALL_MS,
|
|
cooldown_ms: RECOVERY_COOLDOWN_MS,
|
|
game_over_cooldown_ms: None,
|
|
unsafe_reset_ms: UNSAFE_RESET_MS,
|
|
max_attempts: MAX_ATTEMPTS,
|
|
max_recoveries: MAX_RECOVERIES,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RatchetState {
|
|
pub version: u32,
|
|
pub best: u64,
|
|
pub recoveries: u64,
|
|
pub attempts: u64,
|
|
#[serde(rename = "lastProgress")]
|
|
pub last_progress: u64,
|
|
#[serde(rename = "lastRecovery")]
|
|
pub last_recovery: u64,
|
|
pub coverage: u64,
|
|
}
|
|
|
|
impl Default for RatchetState {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: 1,
|
|
best: 0,
|
|
recoveries: 0,
|
|
attempts: 0,
|
|
last_progress: 0,
|
|
last_recovery: 0,
|
|
coverage: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RatchetError(pub &'static str);
|
|
|
|
impl std::fmt::Display for RatchetError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.0)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RatchetError {}
|
|
|
|
/// The best safe snapshot: emulator state plus the exact framebuffer that was
|
|
/// on screen when it was taken. The framebuffer is not part of binjgb's
|
|
/// `EmulatorState`, so it has to be carried alongside.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Snapshot {
|
|
pub game: Vec<u8>,
|
|
pub frame: Vec<u8>,
|
|
}
|
|
|
|
/// Only safe, stable overworld samples count toward the stall window.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Ratchet {
|
|
pub state: RatchetState,
|
|
pub snapshot: Option<Snapshot>,
|
|
policy: RecoveryPolicy,
|
|
unsafe_since: Option<u64>,
|
|
}
|
|
|
|
impl Ratchet {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// A ratchet with per-game limits, from the adapter's
|
|
/// [`RecoveryPolicy`].
|
|
pub fn with_policy(policy: RecoveryPolicy) -> Self {
|
|
Self { policy, ..Self::default() }
|
|
}
|
|
|
|
pub fn policy(&self) -> RecoveryPolicy {
|
|
self.policy
|
|
}
|
|
|
|
/// Record one sample and say whether the caller should now recover.
|
|
///
|
|
/// `capture` is invoked only when `rank` beats the best safe rank seen; it
|
|
/// must return the current emulator state and framebuffer.
|
|
pub fn observe<F>(
|
|
&mut self,
|
|
safe: bool,
|
|
rank: u64,
|
|
coverage: u64,
|
|
now: u64,
|
|
capture: F,
|
|
) -> bool
|
|
where
|
|
F: FnOnce() -> Snapshot,
|
|
{
|
|
self.observe_with_game_over(safe, rank, coverage, now, false, capture)
|
|
}
|
|
|
|
/// [`Ratchet::observe`] plus the game-over trigger.
|
|
///
|
|
/// `game_over` comes from [`crate::adapter::GameAdapter::game_over`], which
|
|
/// is false for every adapter that does not implement it. When it is true
|
|
/// *and* the policy enables the trigger, the stall window is skipped: a game
|
|
/// over discards the whole run and parks the fly on a static title screen,
|
|
/// so there is nothing to wait for (`docs/design/platformer.md` §5, restore
|
|
/// trigger A). Its own cooldown and the same attempt and lifetime budgets
|
|
/// still apply, so a run that cannot be saved is left alone.
|
|
pub fn observe_with_game_over<F>(
|
|
&mut self,
|
|
safe: bool,
|
|
rank: u64,
|
|
coverage: u64,
|
|
now: u64,
|
|
game_over: bool,
|
|
capture: F,
|
|
) -> bool
|
|
where
|
|
F: FnOnce() -> Snapshot,
|
|
{
|
|
self.observe_with_progress(safe, rank, coverage, now, game_over, false, capture)
|
|
}
|
|
|
|
/// [`Ratchet::observe_with_game_over`] plus a progress signal the coverage figure cannot
|
|
/// carry.
|
|
///
|
|
/// `progressed` is the caller saying "something happened on this sample that is plainly
|
|
/// progress, and it is not a number that only grows". The stall window is restarted by it
|
|
/// exactly as a rise in `coverage` restarts it, and nothing else changes: no budget is
|
|
/// spent, no snapshot is taken and no trigger is skipped.
|
|
///
|
|
/// It exists because coverage is the *only* progress the window could see, and coverage is
|
|
/// ground never stood on. A fly crossing a town it has already covered to reach the rung's
|
|
/// own door is getting somewhere and earns none. Measured on rung 10, 2026-09-22: two
|
|
/// "Stuck" rollbacks inside half an hour, both on a fly that was walking, each one landing
|
|
/// it back where it had started. What the Pokemon loop passes here is "nearer the objective,
|
|
/// in map hops, than this run has ever been" (`docs/design/macros.md` section 12.15), which
|
|
/// can only fire as many times as the road is long.
|
|
///
|
|
/// Nothing in this module knows what the signal means, which is the same bargain `coverage`
|
|
/// is: the ladder, the map graph and the objective all belong to the adapter.
|
|
// One argument past clippy's seven, and the argument is the point: this is the whole of one
|
|
// sample. A struct for it would be a type that exists only to be destructured at the one
|
|
// call site the sim loop has, which is the same idiom `decoder.rs` and `lif.rs` take here.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn observe_with_progress<F>(
|
|
&mut self,
|
|
safe: bool,
|
|
rank: u64,
|
|
coverage: u64,
|
|
now: u64,
|
|
game_over: bool,
|
|
progressed: bool,
|
|
capture: F,
|
|
) -> bool
|
|
where
|
|
F: FnOnce() -> Snapshot,
|
|
{
|
|
if !safe {
|
|
let since = *self.unsafe_since.get_or_insert(now);
|
|
// Allow brief walking/warp instability; sustained menus, scripts
|
|
// and battles restart the stall window rather than aging into a
|
|
// reset.
|
|
if now.saturating_sub(since) >= self.policy.unsafe_reset_ms {
|
|
self.state.last_progress = now;
|
|
}
|
|
} else {
|
|
self.unsafe_since = None;
|
|
}
|
|
if coverage > self.state.coverage || progressed {
|
|
self.state.last_progress = now;
|
|
}
|
|
self.state.coverage = self.state.coverage.max(coverage);
|
|
if safe && rank > self.state.best {
|
|
self.snapshot = Some(capture());
|
|
self.state.best = rank;
|
|
self.state.attempts = 0;
|
|
self.state.last_progress = now;
|
|
}
|
|
if self.budget_spent() {
|
|
return false;
|
|
}
|
|
// Trigger A, game over: no stall wait, its own cooldown. `lastRecovery`
|
|
// is 0 until the first recovery, so the cooldown only applies once one
|
|
// has happened; otherwise a game over in the first minute of a run would
|
|
// be measured against the clock's origin. The stall trigger keeps its
|
|
// original behaviour here, which is why this is not shared.
|
|
if let Some(cooldown) = self.policy.game_over_cooldown_ms.filter(|_| game_over) {
|
|
let cooling = self.state.recoveries > 0
|
|
&& now.saturating_sub(self.state.last_recovery) < cooldown;
|
|
return !cooling && self.spend(now);
|
|
}
|
|
// Trigger B, stall: an unsafe sample never recovers, because the restore
|
|
// would land on top of whatever made it unsafe.
|
|
if !safe
|
|
|| now.saturating_sub(self.state.last_progress) < self.policy.stall_ms
|
|
|| now.saturating_sub(self.state.last_recovery) < self.policy.cooldown_ms
|
|
{
|
|
return false;
|
|
}
|
|
self.spend(now)
|
|
}
|
|
|
|
/// Whether no recovery is possible at all: nothing archived, or a budget
|
|
/// exhausted. Deliberately checked before either trigger, so an exhausted run
|
|
/// stays unrecovered rather than looping.
|
|
fn budget_spent(&self) -> bool {
|
|
self.snapshot.is_none()
|
|
|| self.state.attempts >= self.policy.max_attempts
|
|
|| self.state.recoveries >= self.policy.max_recoveries
|
|
}
|
|
|
|
/// Charge one recovery against both budgets and restart the stall window.
|
|
fn spend(&mut self, now: u64) -> bool {
|
|
self.state.recoveries += 1;
|
|
self.state.attempts += 1;
|
|
self.state.last_recovery = now;
|
|
self.state.last_progress = now;
|
|
true
|
|
}
|
|
|
|
/// Restore from a checkpoint. `None` is a legacy checkpoint with no ratchet
|
|
/// extension: it seeds itself at the next safe observation.
|
|
///
|
|
/// `ladder_len` is the length of the running adapter's
|
|
/// [`crate::GameAdapter::rank_ladder`], which bounds `best`. It is a parameter
|
|
/// rather than a constant because the bound belongs to the adapter: this module
|
|
/// knows nothing about any game, the Pokémon ladder is 38 rungs and the
|
|
/// platformer's is 16, and a `best` past the end of the live ladder would index
|
|
/// a label that does not exist. The budget bounds, by contrast, come from this
|
|
/// ratchet's [`RecoveryPolicy`], because those are per-game numbers.
|
|
pub fn import(
|
|
&mut self,
|
|
state: Option<RatchetState>,
|
|
snapshot: Option<Snapshot>,
|
|
ladder_len: usize,
|
|
) -> Result<(), RatchetError> {
|
|
let Some(state) = state else { return Ok(()) };
|
|
const INVALID: RatchetError = RatchetError("Invalid ratchet checkpoint");
|
|
// The budget bounds come from this ratchet's own policy, so a checkpoint
|
|
// written under a larger per-game budget is not rejected by the default
|
|
// one and vice versa.
|
|
if state.version != 1
|
|
|| state.best >= ladder_len as u64
|
|
|| state.attempts > self.policy.max_attempts
|
|
|| state.recoveries > self.policy.max_recoveries
|
|
{
|
|
return Err(INVALID);
|
|
}
|
|
if state.best > 0 {
|
|
match &snapshot {
|
|
Some(snapshot)
|
|
if !snapshot.game.is_empty() && snapshot.frame.len() == FRAMEBUFFER_LEN => {}
|
|
_ => return Err(INVALID),
|
|
}
|
|
}
|
|
self.state = state;
|
|
self.snapshot = snapshot;
|
|
self.unsafe_since = None;
|
|
Ok(())
|
|
}
|
|
|
|
/// The snapshot's emulator state, if one has been captured.
|
|
pub fn game(&self) -> Option<&[u8]> {
|
|
self.snapshot.as_ref().map(|snapshot| snapshot.game.as_slice())
|
|
}
|
|
|
|
/// The snapshot's framebuffer, if one has been captured.
|
|
pub fn frame(&self) -> Option<&[u8]> {
|
|
self.snapshot.as_ref().map(|snapshot| snapshot.frame.as_slice())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::cell::Cell;
|
|
|
|
use super::*;
|
|
|
|
/// The ladder length these tests bound `best` against: the Pokemon ladder's
|
|
/// 38 rungs, so ranks 0..=37 are in range and 38 is not.
|
|
const LADDER: usize = 38;
|
|
|
|
fn snapshot(id: u8) -> Snapshot {
|
|
Snapshot { game: vec![id], frame: vec![0; FRAMEBUFFER_LEN] }
|
|
}
|
|
|
|
#[test]
|
|
fn captures_rising_safe_milestones_persists_best_bounds_recovery_and_excludes_dialogue() {
|
|
let mut ratchet = Ratchet::new();
|
|
let captures = Cell::new(0u8);
|
|
let capture = || {
|
|
captures.set(captures.get() + 1);
|
|
snapshot(captures.get())
|
|
};
|
|
|
|
ratchet.observe(false, 5, 0, 0, capture);
|
|
assert_eq!(captures.get(), 0, "an unsafe sample never captures");
|
|
|
|
ratchet.observe(true, 1, 1, 10, capture);
|
|
ratchet.observe(true, 1, 1, 20, capture);
|
|
assert_eq!(captures.get(), 1, "an equal rank does not recapture");
|
|
|
|
ratchet.observe(true, 3, 2, 30, capture);
|
|
ratchet.observe(true, 2, 2, 40, capture);
|
|
assert_eq!(captures.get(), 2, "a regressed rank does not recapture");
|
|
|
|
let mut restored = Ratchet::new();
|
|
restored.import(Some(ratchet.state), ratchet.snapshot.clone(), LADDER).unwrap();
|
|
|
|
assert!(!restored.observe(false, 2, 2, 179_000, capture));
|
|
assert!(!restored.observe(false, 2, 2, 180_000, capture));
|
|
assert!(!restored.observe(true, 2, 2, 180_001, capture));
|
|
assert!(restored.observe(true, 2, 2, 300_001, capture));
|
|
assert!(restored.observe(true, 2, 2, 480_001, capture));
|
|
assert!(restored.observe(true, 2, 2, 660_001, capture));
|
|
assert!(!restored.observe(true, 2, 2, 840_001, capture), "three attempts per rank");
|
|
|
|
assert_eq!(restored.state.best, 3);
|
|
assert_eq!(restored.game().unwrap()[0], 2);
|
|
|
|
let bad = RatchetState { version: 2, ..ratchet.state };
|
|
assert_eq!(
|
|
Ratchet::new().import(Some(bad), ratchet.snapshot.clone(), LADDER).unwrap_err(),
|
|
RatchetError("Invalid ratchet checkpoint")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_legacy_checkpoint_without_a_ratchet_extension_is_accepted() {
|
|
let mut ratchet = Ratchet::new();
|
|
ratchet.import(None, None, LADDER).unwrap();
|
|
assert_eq!(ratchet.state, RatchetState::default());
|
|
assert!(ratchet.snapshot.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_best_rank_without_a_usable_snapshot_is_rejected() {
|
|
let state = RatchetState { best: 3, ..RatchetState::default() };
|
|
assert!(Ratchet::new().import(Some(state), None, LADDER).is_err());
|
|
assert!(
|
|
Ratchet::new()
|
|
.import(Some(state), Some(Snapshot { game: vec![1], frame: vec![0; 16] }), LADDER)
|
|
.is_err(),
|
|
"a truncated framebuffer is not a snapshot"
|
|
);
|
|
assert!(
|
|
Ratchet::new()
|
|
.import(Some(state), Some(Snapshot { game: Vec::new(), frame: vec![0; FRAMEBUFFER_LEN] }), LADDER)
|
|
.is_err()
|
|
);
|
|
assert!(Ratchet::new().import(Some(state), Some(snapshot(1)), LADDER).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_range_counters_are_rejected() {
|
|
for state in [
|
|
RatchetState { best: LADDER as u64, ..RatchetState::default() },
|
|
RatchetState { attempts: MAX_ATTEMPTS + 1, ..RatchetState::default() },
|
|
RatchetState { recoveries: MAX_RECOVERIES + 1, ..RatchetState::default() },
|
|
] {
|
|
assert!(
|
|
Ratchet::new().import(Some(state), Some(snapshot(1)), LADDER).is_err(),
|
|
"{state:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_rank_bound_is_the_ladder_the_caller_passes_not_a_constant() {
|
|
// The same checkpoint is valid under one adapter's ladder and not another's.
|
|
let state = RatchetState { best: 20, ..RatchetState::default() };
|
|
assert!(Ratchet::new().import(Some(state), Some(snapshot(1)), 38).is_ok());
|
|
assert!(
|
|
Ratchet::new().import(Some(state), Some(snapshot(1)), 16).is_err(),
|
|
"rank 20 is off the end of a 16-rung ladder"
|
|
);
|
|
|
|
// The top rung is in range and one past it is not, for any length.
|
|
for ladder in [1usize, 8, 16, 38] {
|
|
let top = RatchetState { best: ladder as u64 - 1, ..RatchetState::default() };
|
|
let past = RatchetState { best: ladder as u64, ..RatchetState::default() };
|
|
assert!(
|
|
Ratchet::new().import(Some(top), Some(snapshot(1)), ladder).is_ok(),
|
|
"ladder {ladder}: rank {} should be in range",
|
|
ladder - 1
|
|
);
|
|
assert!(
|
|
Ratchet::new().import(Some(past), Some(snapshot(1)), ladder).is_err(),
|
|
"ladder {ladder}: rank {ladder} should be out of range"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_budgets_are_the_long_ladder_s() {
|
|
// 36 lifetime recoveries against the 38-rung ladder, three per rung as
|
|
// before: the lifetime budget is what stops a pathological run, and it is
|
|
// the only one the longer ladder changed.
|
|
assert_eq!(MAX_RECOVERIES, 36);
|
|
assert_eq!(MAX_ATTEMPTS, 3);
|
|
assert_eq!(STALL_MS, 120_000, "the stall window is unchanged");
|
|
assert_eq!(RECOVERY_COOLDOWN_MS, 180_000, "and so is the cooldown");
|
|
}
|
|
|
|
#[test]
|
|
fn sustained_unsafe_activity_restarts_the_stall_window() {
|
|
let mut ratchet = Ratchet::new();
|
|
let capture = || snapshot(1);
|
|
ratchet.observe(true, 1, 1, 0, capture);
|
|
// A stall long enough to recover, interrupted by a sustained battle.
|
|
assert!(!ratchet.observe(false, 1, 1, 100_000, || snapshot(1)));
|
|
assert!(!ratchet.observe(false, 1, 1, 130_000, || snapshot(1)));
|
|
// lastProgress moved to 130_000, so 200_000 is only 70 s of stall.
|
|
assert!(!ratchet.observe(true, 1, 1, 200_000, || snapshot(1)));
|
|
assert!(ratchet.observe(true, 1, 1, 260_000, || snapshot(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn new_coverage_resets_the_stall_window() {
|
|
let mut ratchet = Ratchet::new();
|
|
ratchet.observe(true, 1, 1, 0, || snapshot(1));
|
|
assert!(!ratchet.observe(true, 1, 2, 119_000, || snapshot(1)));
|
|
// The new coverage moved lastProgress to 119_000, so this is 111 s.
|
|
assert!(!ratchet.observe(true, 1, 2, 230_000, || snapshot(1)));
|
|
assert!(ratchet.observe(true, 1, 2, 239_001, || snapshot(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_map_the_run_has_never_stood_on_is_coverage_and_resets_the_window() {
|
|
// The half of the 2026-09-17 rule that already held, pinned because the other half is
|
|
// new: entering a map for the first time is ground never stood on, so it arrives here as
|
|
// a rise in `coverage` and needs nothing of its own. Re-entering a map the run has
|
|
// covered is not, which is exactly the rung-10 museum and why the signal below exists.
|
|
let mut ratchet = Ratchet::new();
|
|
ratchet.observe(true, 1, 100, 0, || snapshot(1));
|
|
// A new map: eighty tiles nobody had stood on.
|
|
assert!(!ratchet.observe(true, 1, 180, 100_000, || snapshot(1)));
|
|
// Walking it again for two minutes adds none, and the window ages from the arrival.
|
|
assert!(!ratchet.observe(true, 1, 180, 219_000, || snapshot(1)));
|
|
assert!(ratchet.observe(true, 1, 180, 220_001, || snapshot(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn getting_nearer_the_objective_resets_the_stall_window_without_spending_anything() {
|
|
// The rung-10 rollbacks of 2026-09-22: a fly walking a town it has already covered
|
|
// toward the rung's own door earns no coverage while it does it, and the window aged out
|
|
// twice in half an hour. A caller's progress signal restarts the window exactly as
|
|
// coverage does -- and does nothing else.
|
|
let mut ratchet = Ratchet::new();
|
|
ratchet.observe(true, 1, 100, 0, || snapshot(1));
|
|
// 119 s of walking ground the run has covered, and then one step nearer the gym.
|
|
assert!(!ratchet.observe_with_progress(true, 1, 100, 119_000, false, true, || snapshot(1)));
|
|
let state = ratchet.state;
|
|
assert_eq!(state.last_progress, 119_000);
|
|
assert_eq!((state.recoveries, state.attempts), (0, 0), "progress spends no budget");
|
|
// The window now runs from there rather than from the start.
|
|
assert!(!ratchet.observe(true, 1, 100, 238_000, || snapshot(1)));
|
|
assert!(ratchet.observe(true, 1, 100, 239_001, || snapshot(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_progress_signal_cannot_conjure_a_recovery_or_skip_a_trigger() {
|
|
// It restarts the window; it is not a trigger and it is not a snapshot. With nothing
|
|
// archived there is nothing to recover to, and an unsafe sample still never recovers.
|
|
let mut ratchet = Ratchet::new();
|
|
assert!(!ratchet.observe_with_progress(true, 0, 0, 0, false, true, || snapshot(1)));
|
|
assert!(ratchet.snapshot.is_none(), "progress captures nothing");
|
|
let mut armed = Ratchet::new();
|
|
armed.observe(true, 1, 1, 0, || snapshot(1));
|
|
// Unsafe, stalled, and told that something progressed: still no recovery, because an
|
|
// unsafe sample would restore on top of whatever made it unsafe.
|
|
assert!(!armed.observe_with_progress(false, 1, 1, 400_000, false, true, || snapshot(1)));
|
|
}
|
|
}
|