flysim: FLY_TRACE's boundary half read back with fly-session-types, the journal held to the service test, rustfmt on the new files
This commit is contained in:
parent
60f09b79a3
commit
9e2f90749f
5 changed files with 254 additions and 38 deletions
|
|
@ -34,12 +34,12 @@
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use flybrain_core::agent::NeuralAgent;
|
use flybrain_core::agent::NeuralAgent;
|
||||||
use flybrain_core::decoder::gameboy::to_button_mask;
|
use flybrain_core::decoder::gameboy::to_button_mask;
|
||||||
|
use flybrain_gb::RewardEvent;
|
||||||
use flybrain_gb::adapter::{GameAdapter, ProgressSnapshot};
|
use flybrain_gb::adapter::{GameAdapter, ProgressSnapshot};
|
||||||
use flybrain_gb::emulator::{Emulator, FRAMEBUFFER_LEN};
|
use flybrain_gb::emulator::{Emulator, FRAMEBUFFER_LEN};
|
||||||
use flybrain_gb::macros::AdapterLedger;
|
use flybrain_gb::macros::AdapterLedger;
|
||||||
use flybrain_gb::ratchet::{Ratchet, Snapshot};
|
use flybrain_gb::ratchet::{Ratchet, Snapshot};
|
||||||
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
|
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
|
||||||
use flybrain_gb::RewardEvent;
|
|
||||||
|
|
||||||
use crate::macros::{MacroEvent, MacroLayer, Silence};
|
use crate::macros::{MacroEvent, MacroLayer, Silence};
|
||||||
use crate::trace::FrameTrace;
|
use crate::trace::FrameTrace;
|
||||||
|
|
@ -238,14 +238,20 @@ impl LegacyFrame {
|
||||||
|
|
||||||
/// A fresh start: one frame with no button down, then the brain's warm-up on it
|
/// A fresh start: one frame with no button down, then the brain's warm-up on it
|
||||||
/// (`Environment.Initialize`, legacy-gameboy-v1 section 9). Returns that frame's audio.
|
/// (`Environment.Initialize`, legacy-gameboy-v1 section 9). Returns that frame's audio.
|
||||||
pub fn initialize(&mut self, emulator: &mut Emulator, agent: &mut NeuralAgent) -> Result<Vec<u8>> {
|
pub fn initialize(
|
||||||
|
&mut self,
|
||||||
|
emulator: &mut Emulator,
|
||||||
|
agent: &mut NeuralAgent,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
emulator
|
emulator
|
||||||
.run_frame()
|
.run_frame()
|
||||||
.map_err(|error| anyhow!("running the first frame: {error}"))?;
|
.map_err(|error| anyhow!("running the first frame: {error}"))?;
|
||||||
self.frame_buffer.copy_from_slice(emulator.framebuffer());
|
self.frame_buffer.copy_from_slice(emulator.framebuffer());
|
||||||
self.frame_counter = 1;
|
self.frame_counter = 1;
|
||||||
let audio = emulator.take_audio_u8();
|
let audio = emulator.take_audio_u8();
|
||||||
agent.warmup(Some(&self.frame_buffer)).map_err(|error| anyhow!("{error}"))?;
|
agent
|
||||||
|
.warmup(Some(&self.frame_buffer))
|
||||||
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
Ok(audio)
|
Ok(audio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,24 +261,47 @@ impl LegacyFrame {
|
||||||
/// The readout transient is left as a fresh process has it (`legacy-transient-reset`), which
|
/// The readout transient is left as a fresh process has it (`legacy-transient-reset`), which
|
||||||
/// is what a restart of the service gives the fly. `import_state` of the agent is
|
/// is what a restart of the service gives the fly. `import_state` of the agent is
|
||||||
/// self-validating, so a refused checkpoint leaves the agent as it was.
|
/// self-validating, so a refused checkpoint leaves the agent as it was.
|
||||||
pub fn restore(&mut self, parts: &mut Parts<'_>, checkpoint: &crate::store::Checkpoint) -> Result<()> {
|
pub fn restore(
|
||||||
|
&mut self,
|
||||||
|
parts: &mut Parts<'_>,
|
||||||
|
checkpoint: &crate::store::Checkpoint,
|
||||||
|
) -> Result<()> {
|
||||||
let runtime = &checkpoint.runtime;
|
let runtime = &checkpoint.runtime;
|
||||||
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
|
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
|
||||||
anyhow::bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());
|
anyhow::bail!(
|
||||||
|
"checkpoint framebuffer is {} bytes",
|
||||||
|
runtime.framebuffer.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
parts.agent.import_state(&checkpoint.agent).map_err(|error| anyhow!("{error}"))?;
|
parts
|
||||||
parts.emulator.import_state(&runtime.emulator).map_err(|error| anyhow!("{error}"))?;
|
.agent
|
||||||
|
.import_state(&checkpoint.agent)
|
||||||
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
|
parts
|
||||||
|
.emulator
|
||||||
|
.import_state(&runtime.emulator)
|
||||||
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
if !runtime.reward.is_null() {
|
if !runtime.reward.is_null() {
|
||||||
parts.adapter.import_state(&runtime.reward).map_err(|error| anyhow!("{error}"))?;
|
parts
|
||||||
|
.adapter
|
||||||
|
.import_state(&runtime.reward)
|
||||||
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
}
|
}
|
||||||
let snapshot = if runtime.ratchet_game.is_empty() {
|
let snapshot = if runtime.ratchet_game.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(Snapshot { game: runtime.ratchet_game.clone(), frame: runtime.ratchet_frame.clone() })
|
Some(Snapshot {
|
||||||
|
game: runtime.ratchet_game.clone(),
|
||||||
|
frame: runtime.ratchet_frame.clone(),
|
||||||
|
})
|
||||||
};
|
};
|
||||||
parts
|
parts
|
||||||
.ratchet
|
.ratchet
|
||||||
.import(Some(runtime.ratchet), snapshot, parts.adapter.rank_ladder().len())
|
.import(
|
||||||
|
Some(runtime.ratchet),
|
||||||
|
snapshot,
|
||||||
|
parts.adapter.rank_ladder().len(),
|
||||||
|
)
|
||||||
.map_err(|error| anyhow!("{error}"))?;
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
|
|
||||||
self.remainder = checkpoint.agent.remainder;
|
self.remainder = checkpoint.agent.remainder;
|
||||||
|
|
@ -280,7 +309,10 @@ impl LegacyFrame {
|
||||||
self.buttons = runtime.buttons;
|
self.buttons = runtime.buttons;
|
||||||
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
|
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
|
||||||
let (width, height) = (parts.agent.frame.width, parts.agent.frame.height);
|
let (width, height) = (parts.agent.frame.width, parts.agent.frame.height);
|
||||||
parts.agent.network.set_visual_frame(&self.frame_buffer, width, height);
|
parts
|
||||||
|
.agent
|
||||||
|
.network
|
||||||
|
.set_visual_frame(&self.frame_buffer, width, height);
|
||||||
parts.emulator.set_buttons(self.buttons as u8);
|
parts.emulator.set_buttons(self.buttons as u8);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -325,11 +357,24 @@ impl LegacyFrame {
|
||||||
let audio = self.advance(parts.emulator, parts.agent, observer)?;
|
let audio = self.advance(parts.emulator, parts.agent, observer)?;
|
||||||
observer.after(FramePhase::Advanced, parts.agent);
|
observer.after(FramePhase::Advanced, parts.agent);
|
||||||
|
|
||||||
let evaluated = self.evaluate(parts.emulator, parts.adapter, parts.macros.as_deref_mut(), ms);
|
let evaluated = self.evaluate(
|
||||||
|
parts.emulator,
|
||||||
|
parts.adapter,
|
||||||
|
parts.macros.as_deref_mut(),
|
||||||
|
ms,
|
||||||
|
);
|
||||||
self.commit(parts.agent, &evaluated.rewards, ms);
|
self.commit(parts.agent, &evaluated.rewards, ms);
|
||||||
observer.after(FramePhase::Committed, parts.agent);
|
observer.after(FramePhase::Committed, parts.agent);
|
||||||
|
|
||||||
Ok(Transition { ticks, ms, bound, active, executed, audio, evaluated })
|
Ok(Transition {
|
||||||
|
ticks,
|
||||||
|
ms,
|
||||||
|
bound,
|
||||||
|
active,
|
||||||
|
executed,
|
||||||
|
audio,
|
||||||
|
evaluated,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rest of phase B and phase C behind a stub readout, for the drivers that measure the
|
/// The rest of phase B and phase C behind a stub readout, for the drivers that measure the
|
||||||
|
|
@ -379,7 +424,12 @@ impl LegacyFrame {
|
||||||
|
|
||||||
/// Phase A, the readout: decode the rates with the blocked direction and the bound channels,
|
/// Phase A, the readout: decode the rates with the blocked direction and the bound channels,
|
||||||
/// and restart the blocked window when the held channel changes.
|
/// and restart the blocked window when the held channel changes.
|
||||||
pub fn decode(&mut self, agent: &mut NeuralAgent, boot: bool, bound: Option<&[String]>) -> Vec<String> {
|
pub fn decode(
|
||||||
|
&mut self,
|
||||||
|
agent: &mut NeuralAgent,
|
||||||
|
boot: bool,
|
||||||
|
bound: Option<&[String]>,
|
||||||
|
) -> Vec<String> {
|
||||||
let ms = agent.network.ms;
|
let ms = agent.network.ms;
|
||||||
let rates = agent.network.rates.clone();
|
let rates = agent.network.rates.clone();
|
||||||
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group
|
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group
|
||||||
|
|
@ -392,7 +442,9 @@ impl LegacyFrame {
|
||||||
.then(|| agent.decoder.current())
|
.then(|| agent.decoder.current())
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
let active = agent.decoder.decode_bound(&rates, ms, boot, blocked.as_deref(), bound);
|
let active = agent
|
||||||
|
.decoder
|
||||||
|
.decode_bound(&rates, ms, boot, blocked.as_deref(), bound);
|
||||||
// A new winner starts its own window: it has not had a hold to move in yet.
|
// A new winner starts its own window: it has not had a hold to move in yet.
|
||||||
let held = agent.decoder.current().map(str::to_string);
|
let held = agent.decoder.current().map(str::to_string);
|
||||||
if held != self.held_channel {
|
if held != self.held_channel {
|
||||||
|
|
@ -423,9 +475,16 @@ impl LegacyFrame {
|
||||||
let ledger = AdapterLedger(adapter);
|
let ledger = AdapterLedger(adapter);
|
||||||
let decision = layer.decide(active, self.buttons, ms, emulator, &ledger);
|
let decision = layer.decide(active, self.buttons, ms, emulator, &ledger);
|
||||||
self.buttons = decision.mask;
|
self.buttons = decision.mask;
|
||||||
Executed { mask: decision.mask, events: decision.events, silence: decision.silence }
|
Executed {
|
||||||
|
mask: decision.mask,
|
||||||
|
events: decision.events,
|
||||||
|
silence: decision.silence,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => Executed { mask: self.buttons, ..Executed::default() },
|
None => Executed {
|
||||||
|
mask: self.buttons,
|
||||||
|
..Executed::default()
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if let Some(trace) = self.trace.as_mut() {
|
if let Some(trace) = self.trace.as_mut() {
|
||||||
trace.decided(active);
|
trace.decided(active);
|
||||||
|
|
@ -496,14 +555,20 @@ impl LegacyFrame {
|
||||||
if let Some(trace) = self.trace.as_mut() {
|
if let Some(trace) = self.trace.as_mut() {
|
||||||
trace.evaluated(&rewards, &abandoned, progress.rank);
|
trace.evaluated(&rewards, &abandoned, progress.rank);
|
||||||
}
|
}
|
||||||
Evaluated { rewards, abandoned, progress }
|
Evaluated {
|
||||||
|
rewards,
|
||||||
|
abandoned,
|
||||||
|
progress,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase D: the frame just produced becomes the next ticks' visual drive, each reward event
|
/// Phase D: the frame just produced becomes the next ticks' visual drive, each reward event
|
||||||
/// stimulates once, and the summed value reinforces once.
|
/// stimulates once, and the summed value reinforces once.
|
||||||
pub fn commit(&mut self, agent: &mut NeuralAgent, rewards: &[RewardEvent], ms: f64) {
|
pub fn commit(&mut self, agent: &mut NeuralAgent, rewards: &[RewardEvent], ms: f64) {
|
||||||
let (width, height) = (agent.frame.width, agent.frame.height);
|
let (width, height) = (agent.frame.width, agent.frame.height);
|
||||||
agent.network.set_visual_frame(&self.frame_buffer, width, height);
|
agent
|
||||||
|
.network
|
||||||
|
.set_visual_frame(&self.frame_buffer, width, height);
|
||||||
let mut total = 0.0;
|
let mut total = 0.0;
|
||||||
for event in rewards {
|
for event in rewards {
|
||||||
agent.network.stimulate(f64::from(event.stimulation_ms));
|
agent.network.stimulate(f64::from(event.stimulation_ms));
|
||||||
|
|
@ -541,7 +606,10 @@ impl LegacyFrame {
|
||||||
// rule as amended 2026-09-22): the macro layer answers "nearer the objective" with the map
|
// rule as amended 2026-09-22): the macro layer answers "nearer the objective" with the map
|
||||||
// graph it already walks (`docs/design/macros.md` section 12.15); in raw mode there is no
|
// graph it already walks (`docs/design/macros.md` section 12.15); in raw mode there is no
|
||||||
// layer and no objective, and the answer is false.
|
// layer and no objective, and the answer is false.
|
||||||
let nearer = parts.macros.as_deref().is_some_and(MacroLayer::nearer_the_objective);
|
let nearer = parts
|
||||||
|
.macros
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(MacroLayer::nearer_the_objective);
|
||||||
let trace = &mut self.trace;
|
let trace = &mut self.trace;
|
||||||
let mut saved = false;
|
let mut saved = false;
|
||||||
let recover = parts.ratchet.observe_with_progress(
|
let recover = parts.ratchet.observe_with_progress(
|
||||||
|
|
@ -563,14 +631,20 @@ impl LegacyFrame {
|
||||||
);
|
);
|
||||||
let rollback = if recover {
|
let rollback = if recover {
|
||||||
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
|
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
|
||||||
let trigger =
|
let trigger = if parts.adapter.game_over() {
|
||||||
if parts.adapter.game_over() { RollbackTrigger::GameOver } else { RollbackTrigger::Stall };
|
RollbackTrigger::GameOver
|
||||||
|
} else {
|
||||||
|
RollbackTrigger::Stall
|
||||||
|
};
|
||||||
let events = self.rollback(parts)?;
|
let events = self.rollback(parts)?;
|
||||||
Some(Rollback { trigger, events })
|
Some(Rollback { trigger, events })
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Ok(Boundary { captured: saved, rollback })
|
Ok(Boundary {
|
||||||
|
captured: saved,
|
||||||
|
rollback,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ratchet's game-only rollback (`legacy-ratchet-rollback-v1`): the slot is restored, the
|
/// The ratchet's game-only rollback (`legacy-ratchet-rollback-v1`): the slot is restored, the
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,10 @@ pub struct SugarJournal {
|
||||||
|
|
||||||
impl SugarJournal {
|
impl SugarJournal {
|
||||||
pub fn new(hot_dir: &Path) -> Self {
|
pub fn new(hot_dir: &Path) -> Self {
|
||||||
Self { path: hot_dir.join(FILE_NAME), file: None }
|
Self {
|
||||||
|
path: hot_dir.join(FILE_NAME),
|
||||||
|
file: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn path(&self) -> &Path {
|
pub fn path(&self) -> &Path {
|
||||||
|
|
@ -90,7 +93,11 @@ impl SugarJournal {
|
||||||
/// torn last line, which a reader skips.
|
/// torn last line, which a reader skips.
|
||||||
pub fn record(&mut self, entry: &Entry<'_>) {
|
pub fn record(&mut self, entry: &Entry<'_>) {
|
||||||
if self.file.is_none() {
|
if self.file.is_none() {
|
||||||
match OpenOptions::new().create(true).append(true).open(&self.path) {
|
match OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&self.path)
|
||||||
|
{
|
||||||
Ok(file) => self.file = Some(file),
|
Ok(file) => self.file = Some(file),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, path = %self.path.display(), "could not open the sugar journal");
|
tracing::warn!(%error, path = %self.path.display(), "could not open the sugar journal");
|
||||||
|
|
@ -128,7 +135,10 @@ mod tests {
|
||||||
fn inputs_are_appended_with_their_frame_and_survive_a_reopen() {
|
fn inputs_are_appended_with_their_frame_and_survive_a_reopen() {
|
||||||
let dir = tempfile::tempdir().expect("a temp dir");
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
let mut journal = SugarJournal::new(dir.path());
|
let mut journal = SugarJournal::new(dir.path());
|
||||||
assert!(!journal.path().exists(), "nothing is written before an input");
|
assert!(
|
||||||
|
!journal.path().exists(),
|
||||||
|
"nothing is written before an input"
|
||||||
|
);
|
||||||
let sugar = Entry {
|
let sugar = Entry {
|
||||||
frame: 42,
|
frame: 42,
|
||||||
brain_ms: 703.0,
|
brain_ms: 703.0,
|
||||||
|
|
@ -141,7 +151,12 @@ mod tests {
|
||||||
journal.record(&sugar);
|
journal.record(&sugar);
|
||||||
drop(journal);
|
drop(journal);
|
||||||
let mut journal = SugarJournal::new(dir.path());
|
let mut journal = SugarJournal::new(dir.path());
|
||||||
journal.record(&Entry { frame: 43, input: Input::Reward { value: 0.5 }, event_id: 8, ..sugar });
|
journal.record(&Entry {
|
||||||
|
frame: 43,
|
||||||
|
input: Input::Reward { value: 0.5 },
|
||||||
|
event_id: 8,
|
||||||
|
..sugar
|
||||||
|
});
|
||||||
// A torn tail from a crash is skipped, not fatal.
|
// A torn tail from a crash is skipped, not fatal.
|
||||||
std::fs::OpenOptions::new()
|
std::fs::OpenOptions::new()
|
||||||
.append(true)
|
.append(true)
|
||||||
|
|
|
||||||
|
|
@ -170,12 +170,14 @@ impl FrameTrace {
|
||||||
|
|
||||||
/// Sugar admitted at the top of the frame, before the brain ticks.
|
/// Sugar admitted at the top of the frame, before the brain ticks.
|
||||||
pub fn sugar(&mut self, duration_ms: f64) {
|
pub fn sugar(&mut self, duration_ms: f64) {
|
||||||
self.admissions.push(json!({ "kind": "sugar", "durationMs": duration_ms }));
|
self.admissions
|
||||||
|
.push(json!({ "kind": "sugar", "durationMs": duration_ms }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An operator reward pulse, applied at the top of the frame.
|
/// An operator reward pulse, applied at the top of the frame.
|
||||||
pub fn reward_pulse(&mut self, value: f64) {
|
pub fn reward_pulse(&mut self, value: f64) {
|
||||||
self.admissions.push(json!({ "kind": "reward", "value": value }));
|
self.admissions
|
||||||
|
.push(json!({ "kind": "reward", "value": value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A checkpoint capture at the current boundary: the open transition's, or the start's.
|
/// A checkpoint capture at the current boundary: the open transition's, or the start's.
|
||||||
|
|
@ -213,7 +215,9 @@ impl FrameTrace {
|
||||||
|
|
||||||
/// Phase A's ticks, and the network they left behind.
|
/// Phase A's ticks, and the network they left behind.
|
||||||
pub fn ticked(&mut self, ticks: u64, remainder_ms: f64, network: &LifNetwork) {
|
pub fn ticked(&mut self, ticks: u64, remainder_ms: f64, network: &LifNetwork) {
|
||||||
let Some(record) = self.open.as_mut() else { return };
|
let Some(record) = self.open.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
record.ticks = ticks;
|
record.ticks = ticks;
|
||||||
record.brain_ticks = network.ms as u64;
|
record.brain_ticks = network.ms as u64;
|
||||||
record.remainder = remainder_ns(remainder_ms);
|
record.remainder = remainder_ns(remainder_ms);
|
||||||
|
|
@ -241,7 +245,9 @@ impl FrameTrace {
|
||||||
pub fn executed(&mut self, mask: u32, events: &[MacroEvent]) {
|
pub fn executed(&mut self, mask: u32, events: &[MacroEvent]) {
|
||||||
if let Some(record) = self.open.as_mut() {
|
if let Some(record) = self.open.as_mut() {
|
||||||
record.mask = mask;
|
record.mask = mask;
|
||||||
record.macros.extend(events.iter().map(|event| macro_json("execute", event)));
|
record
|
||||||
|
.macros
|
||||||
|
.extend(events.iter().map(|event| macro_json("execute", event)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +256,9 @@ impl FrameTrace {
|
||||||
pub fn advanced(&mut self, framebuffer: &[u8], emulator: &Emulator) {
|
pub fn advanced(&mut self, framebuffer: &[u8], emulator: &Emulator) {
|
||||||
if let Some(record) = self.open.as_mut() {
|
if let Some(record) = self.open.as_mut() {
|
||||||
record.framebuffer = sha256_hex(framebuffer);
|
record.framebuffer = sha256_hex(framebuffer);
|
||||||
let wram: Vec<u8> = WRAM.map(|address| emulator.read_uncached(address)).collect();
|
let wram: Vec<u8> = WRAM
|
||||||
|
.map(|address| emulator.read_uncached(address))
|
||||||
|
.collect();
|
||||||
record.wram = sha256_hex(&wram);
|
record.wram = sha256_hex(&wram);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +273,9 @@ impl FrameTrace {
|
||||||
"stimulationMs": event.stimulation_ms,
|
"stimulationMs": event.stimulation_ms,
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
record.macros.extend(abandoned.iter().map(|event| macro_json("evaluate", event)));
|
record
|
||||||
|
.macros
|
||||||
|
.extend(abandoned.iter().map(|event| macro_json("evaluate", event)));
|
||||||
record.rank = rank;
|
record.rank = rank;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -289,12 +299,16 @@ impl FrameTrace {
|
||||||
"slotId": SLOT,
|
"slotId": SLOT,
|
||||||
"stateDigest": null,
|
"stateDigest": null,
|
||||||
}));
|
}));
|
||||||
record.macros.extend(events.iter().map(|event| macro_json("rollback", event)));
|
record
|
||||||
|
.macros
|
||||||
|
.extend(events.iter().map(|event| macro_json("rollback", event)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush_open(&mut self) {
|
fn flush_open(&mut self) {
|
||||||
let Some(record) = self.open.take() else { return };
|
let Some(record) = self.open.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let line = json!({
|
let line = json!({
|
||||||
"behaviour": {
|
"behaviour": {
|
||||||
"step": record.step.to_string(),
|
"step": record.step.to_string(),
|
||||||
|
|
@ -348,11 +362,20 @@ mod tests {
|
||||||
remainder += per_frame;
|
remainder += per_frame;
|
||||||
remainder -= remainder.floor();
|
remainder -= remainder.floor();
|
||||||
let value = remainder_ns(remainder);
|
let value = remainder_ns(remainder);
|
||||||
assert!(value.get("numerator").is_some(), "{remainder} was not exact: {value}");
|
assert!(
|
||||||
|
value.get("numerator").is_some(),
|
||||||
|
"{remainder} was not exact: {value}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(remainder_ns(0.0), json!({ "numerator": "0", "denominator": "1" }));
|
assert_eq!(
|
||||||
|
remainder_ns(0.0),
|
||||||
|
json!({ "numerator": "0", "denominator": "1" })
|
||||||
|
);
|
||||||
// 0.5 ms is 500000 ns.
|
// 0.5 ms is 500000 ns.
|
||||||
assert_eq!(remainder_ns(0.5), json!({ "numerator": "500000", "denominator": "1" }));
|
assert_eq!(
|
||||||
|
remainder_ns(0.5),
|
||||||
|
json!({ "numerator": "500000", "denominator": "1" })
|
||||||
|
);
|
||||||
// One 2^-15 ms step is 15625/512 ns.
|
// One 2^-15 ms step is 15625/512 ns.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
remainder_ns(1.0 / 32_768.0),
|
remainder_ns(1.0 / 32_768.0),
|
||||||
|
|
|
||||||
93
services/flysim/crates/flysim/tests/frame_trace.rs
Normal file
93
services/flysim/crates/flysim/tests/frame_trace.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
//! `FLY_TRACE`'s boundary half is the session framework's, field for field.
|
||||||
|
//!
|
||||||
|
//! The recorder writes `behaviour.boundaryActions` and `operational.captures` in the shapes of
|
||||||
|
//! `TraceBehaviour.boundaryActions` and `TraceOperational.captures` (step-v1 section 8, amended
|
||||||
|
//! 2026-09-23). This test records the two boundaries the legacy loop produces and reads them with
|
||||||
|
//! `fly-session-types` itself, by grafting them onto the shared baseline trace:
|
||||||
|
//!
|
||||||
|
//! - a rollback followed by the post-recovery checkpoint is a valid transition trace;
|
||||||
|
//! - a rung climb -- the milestone archive, *then* the ratchet's slot save -- is refused with the
|
||||||
|
//! capture-before-save error. That is the legacy order legacy-gameboy-v1 section 4 declares, and
|
||||||
|
//! the trace has to show it as it happens rather than tidy it away.
|
||||||
|
|
||||||
|
use fly_session_types::fixtures;
|
||||||
|
use fly_session_types::scalar::DomainType;
|
||||||
|
use fly_session_types::trace::TransitionTrace;
|
||||||
|
use flysim::trace::FrameTrace;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn lines(path: &std::path::Path) -> Vec<Value> {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.expect("the trace")
|
||||||
|
.lines()
|
||||||
|
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared baseline transition with this boundary's actions and captures in place of its own.
|
||||||
|
fn grafted(record: &Value) -> Result<TransitionTrace, String> {
|
||||||
|
let file = fixtures::load("traces.json").expect("traces.json");
|
||||||
|
let mut baseline = file.get("baseline").expect("baseline").clone();
|
||||||
|
baseline["behaviour"]["boundaryActions"] = record["behaviour"]["boundaryActions"].clone();
|
||||||
|
baseline["operational"]["captures"] = record["operational"]["captures"].clone();
|
||||||
|
TransitionTrace::from_json(&baseline).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_boundary_half_of_the_trace_is_the_session_frameworks() {
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
let path = dir.path().join("trace.jsonl");
|
||||||
|
let mut trace = FrameTrace::create(&path).expect("the trace file");
|
||||||
|
|
||||||
|
// The start: the boot checkpoint, before any transition.
|
||||||
|
trace.capture(1, 100);
|
||||||
|
// Transition 100 -> 101 ends in a stall rollback, then the post-recovery checkpoint.
|
||||||
|
trace.sugar(400.0);
|
||||||
|
trace.begin(100, 1_000.0);
|
||||||
|
trace.rolled_back(&[]);
|
||||||
|
trace.capture(2, 101);
|
||||||
|
// Transition 101 -> 102 climbs a rung: the archive first, then the ratchet captures.
|
||||||
|
trace.begin(101, 1_017.0);
|
||||||
|
trace.capture(3, 102);
|
||||||
|
trace.slot_saved(b"emulator state");
|
||||||
|
trace.finish();
|
||||||
|
drop(trace);
|
||||||
|
|
||||||
|
let lines = lines(&path);
|
||||||
|
assert_eq!(lines.len(), 4, "format, start, two transitions: {lines:?}");
|
||||||
|
assert_eq!(lines[0]["format"], flysim::trace::FORMAT);
|
||||||
|
assert_eq!(lines[1]["boundary"], "100");
|
||||||
|
assert_eq!(lines[1]["operational"]["captures"][0]["checkpointId"], "g1");
|
||||||
|
|
||||||
|
let rollback = &lines[2];
|
||||||
|
assert_eq!(rollback["behaviour"]["step"], "100");
|
||||||
|
assert_eq!(rollback["behaviour"]["admissions"][0]["kind"], "sugar");
|
||||||
|
assert_eq!(
|
||||||
|
rollback["behaviour"]["boundaryActions"][0]["kind"],
|
||||||
|
"rollback"
|
||||||
|
);
|
||||||
|
assert_eq!(rollback["operational"]["captures"][0]["afterActions"], 1);
|
||||||
|
let parsed = grafted(rollback).expect("a rollback and then its checkpoint is a valid trace");
|
||||||
|
assert_eq!(parsed.behaviour.boundary_actions.len(), 1);
|
||||||
|
assert_eq!(parsed.operational.captures.len(), 1);
|
||||||
|
|
||||||
|
let climb = &lines[3];
|
||||||
|
assert_eq!(
|
||||||
|
climb["behaviour"]["boundaryActions"][0]["kind"],
|
||||||
|
"save-slot"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
climb["behaviour"]["boundaryActions"][0]["slotId"],
|
||||||
|
flysim::trace::SLOT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
climb["behaviour"]["boundaryActions"][0]["stateDigest"],
|
||||||
|
flysim::trace::sha256_hex(b"emulator state")
|
||||||
|
);
|
||||||
|
assert_eq!(climb["operational"]["captures"][0]["afterActions"], 0);
|
||||||
|
let refused = grafted(climb).expect_err("the legacy archive precedes the slot save");
|
||||||
|
assert!(
|
||||||
|
refused.contains("before this boundary's slot saves"),
|
||||||
|
"refused for the declared reason: {refused}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -437,6 +437,17 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
|
||||||
assert_eq!(logged[0]["by"], json!("integration-test"));
|
assert_eq!(logged[0]["by"], json!("integration-test"));
|
||||||
let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap();
|
let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap();
|
||||||
assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}");
|
assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}");
|
||||||
|
// And in the hot directory's journal, stamped with the frame it was applied before, which
|
||||||
|
// is what a shadow run replays (`flysim::journal`).
|
||||||
|
let journal_path = dir.path().join("hot").join(flysim::journal::FILE_NAME);
|
||||||
|
let journal = flysim::journal::read(&journal_path).expect("the sugar journal");
|
||||||
|
assert_eq!(journal.len(), 1, "{journal:?}");
|
||||||
|
assert_eq!(journal[0]["kind"], json!("sugar"));
|
||||||
|
assert_eq!(journal[0]["eventId"], json!(event_id));
|
||||||
|
assert_eq!(journal[0]["durationMs"], json!(400.0));
|
||||||
|
let stamped: u64 =
|
||||||
|
journal[0]["frame"].as_str().and_then(|frame| frame.parse().ok()).expect("a frame");
|
||||||
|
assert!(stamped >= 1, "stamped with a frame the emulator has run: {stamped}");
|
||||||
|
|
||||||
// A second pulse while the first is still being applied is refused, not stacked.
|
// A second pulse while the first is still being applied is refused, not stacked.
|
||||||
let (status, body) = service.post(
|
let (status, body) = service.post(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue