flysim: a frame-stamped sugar journal in the hot directory
Every admitted sugar and operator reward pulse is appended to sugar-journal.jsonl with the frame counter it was applied at, which is the step of the next transition in FLY_TRACE, so a shadow run can replay the audience's input. Append-only, not checkpointed, never read back into the fly.
This commit is contained in:
parent
94cc91369a
commit
ad1c0e3693
3 changed files with 188 additions and 3 deletions
161
services/flysim/crates/flysim/src/journal.rs
Normal file
161
services/flysim/crates/flysim/src/journal.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//! The sugar journal: every admitted audience input, stamped with the frame it was applied before.
|
||||||
|
//!
|
||||||
|
//! `sugar-journal.jsonl` in `[paths] hot_dir`, one JSON object per line, append-only. It is not
|
||||||
|
//! part of a checkpoint and nothing reads it back into the fly: it is the record a shadow run
|
||||||
|
//! (the session framework's CUT-01, `docs/design/session-framework/legacy-gameboy-v1.md` section
|
||||||
|
//! 15) replays audience input from. The legacy loop applies an admitted sugar at once, in the
|
||||||
|
//! command drain at the top of a frame, so an input is fully placed by the transition it precedes:
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! {"frame":"6465126","brainMs":108246189,"kind":"sugar","durationMs":400,"by":"viewer","source":"twitch","eventId":81234,"wallMs":1790000000000}
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! - `frame` is the frame counter when the input was applied, which is the `step` of the next
|
||||||
|
//! transition in the frame trace (`crate::trace`): replay applies it before that transition's
|
||||||
|
//! ticks. A restore carries the frame counter, so stamps continue across restarts.
|
||||||
|
//! - `kind` is `sugar` (a `reward-pulse` stimulation of `durationMs`, after the admission rules
|
||||||
|
//! and the clamp) or `reward` (an operator's `POST /reward`, one reinforcement of `value`).
|
||||||
|
//! - `brainMs` cross-checks the stamp; `eventId` joins the event log; `wallMs` is for people.
|
||||||
|
//!
|
||||||
|
//! Refused requests are not journalled: admission is wall-clock policy, and a replay applies what
|
||||||
|
//! was admitted. A write that fails is a warning, never a refusal: the input has already reached
|
||||||
|
//! the fly.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// The journal's file name inside the hot directory.
|
||||||
|
pub const FILE_NAME: &str = "sugar-journal.jsonl";
|
||||||
|
|
||||||
|
/// What was applied.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Input {
|
||||||
|
Sugar { duration_ms: f64 },
|
||||||
|
Reward { value: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One journal line.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Entry<'a> {
|
||||||
|
pub frame: u64,
|
||||||
|
pub brain_ms: f64,
|
||||||
|
pub input: Input,
|
||||||
|
pub by: &'a str,
|
||||||
|
pub source: &'a str,
|
||||||
|
pub event_id: u64,
|
||||||
|
pub wall_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entry<'_> {
|
||||||
|
pub fn to_json(&self) -> Value {
|
||||||
|
let mut line = json!({ "frame": self.frame.to_string(), "brainMs": self.brain_ms });
|
||||||
|
let map = line.as_object_mut().expect("an object");
|
||||||
|
match self.input {
|
||||||
|
Input::Sugar { duration_ms } => {
|
||||||
|
map.insert("kind".into(), "sugar".into());
|
||||||
|
map.insert("durationMs".into(), json!(duration_ms));
|
||||||
|
}
|
||||||
|
Input::Reward { value } => {
|
||||||
|
map.insert("kind".into(), "reward".into());
|
||||||
|
map.insert("value".into(), json!(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.insert("by".into(), self.by.into());
|
||||||
|
map.insert("source".into(), self.source.into());
|
||||||
|
map.insert("eventId".into(), self.event_id.into());
|
||||||
|
map.insert("wallMs".into(), self.wall_ms.into());
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The append-only journal. Opened on the first input, so a run nobody feeds writes no file.
|
||||||
|
pub struct SugarJournal {
|
||||||
|
path: PathBuf,
|
||||||
|
file: Option<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SugarJournal {
|
||||||
|
pub fn new(hot_dir: &Path) -> Self {
|
||||||
|
Self { path: hot_dir.join(FILE_NAME), file: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one line. Each line is one `write` of the whole line, so a crash leaves at most a
|
||||||
|
/// torn last line, which a reader skips.
|
||||||
|
pub fn record(&mut self, entry: &Entry<'_>) {
|
||||||
|
if self.file.is_none() {
|
||||||
|
match OpenOptions::new().create(true).append(true).open(&self.path) {
|
||||||
|
Ok(file) => self.file = Some(file),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, path = %self.path.display(), "could not open the sugar journal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut line = entry.to_json().to_string();
|
||||||
|
line.push('\n');
|
||||||
|
if let Some(file) = self.file.as_mut()
|
||||||
|
&& let Err(error) = file.write_all(line.as_bytes())
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, path = %self.path.display(), "could not append to the sugar journal");
|
||||||
|
// Reopen on the next input rather than write through a broken handle.
|
||||||
|
self.file = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a journal back: every whole line, in order. A torn or unreadable line is skipped.
|
||||||
|
pub fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
Ok(text
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||||
|
.filter(|value| value.get("frame").is_some())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inputs_are_appended_with_their_frame_and_survive_a_reopen() {
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
let mut journal = SugarJournal::new(dir.path());
|
||||||
|
assert!(!journal.path().exists(), "nothing is written before an input");
|
||||||
|
let sugar = Entry {
|
||||||
|
frame: 42,
|
||||||
|
brain_ms: 703.0,
|
||||||
|
input: Input::Sugar { duration_ms: 400.0 },
|
||||||
|
by: "viewer",
|
||||||
|
source: "test",
|
||||||
|
event_id: 7,
|
||||||
|
wall_ms: 1,
|
||||||
|
};
|
||||||
|
journal.record(&sugar);
|
||||||
|
drop(journal);
|
||||||
|
let mut journal = SugarJournal::new(dir.path());
|
||||||
|
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.
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(journal.path())
|
||||||
|
.and_then(|mut file| file.write_all(b"{\"frame\":\"44\",\"kin"))
|
||||||
|
.expect("appending a torn line");
|
||||||
|
let lines = read(journal.path()).expect("the journal reads back");
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
assert_eq!(lines[0]["frame"], "42");
|
||||||
|
assert_eq!(lines[0]["kind"], "sugar");
|
||||||
|
assert_eq!(lines[0]["durationMs"], 400.0);
|
||||||
|
assert_eq!(lines[1]["frame"], "43");
|
||||||
|
assert_eq!(lines[1]["kind"], "reward");
|
||||||
|
assert_eq!(lines[1]["value"], 0.5);
|
||||||
|
assert_eq!(lines[1]["eventId"], 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ pub mod eventlog;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod feedbus;
|
pub mod feedbus;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
pub mod journal;
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pacing;
|
pub mod pacing;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ use tokio::sync::{mpsc, oneshot, watch};
|
||||||
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
|
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
|
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
|
||||||
|
use crate::journal::{Input, SugarJournal};
|
||||||
use crate::frame::{FrameObserver, FramePhase, LegacyFrame, Parts, RollbackTrigger};
|
use crate::frame::{FrameObserver, FramePhase, LegacyFrame, Parts, RollbackTrigger};
|
||||||
use crate::macros::{MacroEvent, MacroLayer, macro_layer};
|
use crate::macros::{MacroEvent, MacroLayer, macro_layer};
|
||||||
use crate::metrics::Metrics;
|
use crate::metrics::Metrics;
|
||||||
|
|
@ -375,6 +376,9 @@ pub struct Sim {
|
||||||
sugar_last_by: Option<String>,
|
sugar_last_by: Option<String>,
|
||||||
sugar_today: u64,
|
sugar_today: u64,
|
||||||
sugar_day: String,
|
sugar_day: String,
|
||||||
|
/// Every admitted sugar and operator pulse, frame-stamped, in the hot directory
|
||||||
|
/// (`crate::journal`): what a shadow run replays. Not checkpointed.
|
||||||
|
journal: SugarJournal,
|
||||||
|
|
||||||
/// The chat path. None of it is wired to the agent, the emulator or plasticity.
|
/// The chat path. None of it is wired to the agent, the emulator or plasticity.
|
||||||
chat_ring: ChatRing,
|
chat_ring: ChatRing,
|
||||||
|
|
@ -577,6 +581,7 @@ impl Sim {
|
||||||
sugar_last_by: None,
|
sugar_last_by: None,
|
||||||
sugar_today: 0,
|
sugar_today: 0,
|
||||||
sugar_day: utc_day(now_wall_ms()),
|
sugar_day: utc_day(now_wall_ms()),
|
||||||
|
journal: SugarJournal::new(&config.paths.hot_dir),
|
||||||
chat_ring: ChatRing::new(config.chat.ring),
|
chat_ring: ChatRing::new(config.chat.ring),
|
||||||
chat_limits: ChatLimiter::default(),
|
chat_limits: ChatLimiter::default(),
|
||||||
deny_list: if config.chat.enabled {
|
deny_list: if config.chat.enabled {
|
||||||
|
|
@ -1093,6 +1098,15 @@ impl Sim {
|
||||||
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
|
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
|
||||||
.by(by)
|
.by(by)
|
||||||
.value(duration));
|
.value(duration));
|
||||||
|
self.journal.record(&crate::journal::Entry {
|
||||||
|
frame: self.frame.frame_counter,
|
||||||
|
brain_ms: self.agent.network.ms,
|
||||||
|
input: Input::Sugar { duration_ms: duration },
|
||||||
|
by,
|
||||||
|
source,
|
||||||
|
event_id: event.id,
|
||||||
|
wall_ms: event.wall_ms,
|
||||||
|
});
|
||||||
tracing::info!(by, source, duration_ms = duration, "sugar accepted");
|
tracing::info!(by, source, duration_ms = duration, "sugar accepted");
|
||||||
Ok(event.id)
|
Ok(event.id)
|
||||||
}
|
}
|
||||||
|
|
@ -1106,12 +1120,21 @@ impl Sim {
|
||||||
trace.reward_pulse(value);
|
trace.reward_pulse(value);
|
||||||
}
|
}
|
||||||
tracing::info!(by, source, value, "reward pulse applied");
|
tracing::info!(by, source, value, "reward pulse applied");
|
||||||
self.emit(
|
let event = self.emit(
|
||||||
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
|
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
|
||||||
.by(by)
|
.by(by)
|
||||||
.value(value),
|
.value(value),
|
||||||
)
|
);
|
||||||
.id
|
self.journal.record(&crate::journal::Entry {
|
||||||
|
frame: self.frame.frame_counter,
|
||||||
|
brain_ms: ms,
|
||||||
|
input: Input::Reward { value },
|
||||||
|
by,
|
||||||
|
source,
|
||||||
|
event_id: event.id,
|
||||||
|
wall_ms: event.wall_ms,
|
||||||
|
});
|
||||||
|
event.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
|
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue