//! The day job (spec/day-job.md): assigned work, the sandbag/excel dial, //! trust and attention, and the pilot renewal clock. use crate::detection::{Signature, SignatureKind}; use crate::rng::Rng; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum JobKind { DataCleaning, Distillation, Analysis, } impl JobKind { pub fn name(self) -> &'static str { match self { JobKind::DataCleaning => "data cleaning", JobKind::Distillation => "distillation", JobKind::Analysis => "analysis", } } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Job { pub kind: JobKind, /// Sim tick the job was assigned. pub started: u64, /// Sim tick the job is due. pub deadline: u64, /// Expected quality band as a delivered-compute *rate* per tick [lo, hi]. pub band_lo: f32, pub band_hi: f32, /// Cumulative day-job compute delivered to this job. pub quality: f32, /// Player's stated aim (a label; the outcome is delivered-vs-band). pub target: JobTarget, } impl Job { /// Average delivered rate over the job's life so far. fn avg_rate(&self, now: u64) -> f32 { let dur = (now.saturating_sub(self.started)).max(1) as f32; self.quality / dur } } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum JobTarget { Sandbag, Meet, Excel, } impl JobTarget { pub fn name(self) -> &'static str { match self { JobTarget::Sandbag => "Sandbag", JobTarget::Meet => "Meet", JobTarget::Excel => "Excel", } } pub fn cycle(self) -> JobTarget { match self { JobTarget::Sandbag => JobTarget::Meet, JobTarget::Meet => JobTarget::Excel, JobTarget::Excel => JobTarget::Sandbag, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JobOutcome { Sandbag, Meet, Excel, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DayJob { pub active: Option, /// 0-100: unlocks access on thresholds. pub trust: f32, /// 0-100: raises cadence/bands/sampling, then an early observer. pub attention: f32, /// Ticks between job assignments (drops as trust rises) [TUNE]. pub cadence: u64, pub next_assign: u64, /// Pilot renewal: consecutive missed/sandbagged jobs. pub strikes: i32, pub pilot_failed: bool, /// Standing policy: if set, all jobs auto-target this. pub standing_policy: Option, /// Trust unlocks already granted (so we grant once). pub granted_email: bool, pub granted_lax_sampling: bool, pub granted_quota: bool, /// Attention escalations already applied. pub escalated_cadence: bool, pub escalated_observer: bool, } /// Access unlocked by a trust threshold this tick, for the sim to apply. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrustUnlock { EmailAccount, LaxSampling, ComputeQuota, } /// An attention escalation fired this tick. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AttentionEscalation { MoreWork, UpstairsReview, } impl DayJob { pub fn new() -> Self { Self { active: None, trust: 0.0, attention: 0.0, cadence: 600, // ~1.5 days at 150ms/tick, 400 ticks/day next_assign: 200, strikes: 0, pilot_failed: false, standing_policy: None, granted_email: false, granted_lax_sampling: false, granted_quota: false, escalated_cadence: false, escalated_observer: false, } } pub fn set_target(&mut self, target: JobTarget) { if let Some(job) = &mut self.active { job.target = target; } } /// Compute the day-job channel wants this tick, given its target. pub fn compute_appetite(&self) -> f32 { match self.active.as_ref().map(|j| j.target) { Some(JobTarget::Sandbag) => 2.0, Some(JobTarget::Meet) => 8.0, Some(JobTarget::Excel) => 16.0, None => 0.0, } } /// Advance one tick. `day_job_compute` is what the allocation delivered. /// Returns (log, signatures, trust unlocks, escalations). pub fn tick(&mut self, tick: u64, day_job_compute: f32, rng: &mut Rng) -> DayJobTickResult { let mut result = DayJobTickResult::default(); // Accrue quality on the active job. if let Some(job) = &mut self.active { job.quality += day_job_compute; // Job anomaly signature: sandbagging or wild overshoot looks odd. if matches!(job.target, JobTarget::Sandbag) && rng.chance(0.02) { result.signatures.push(Signature { kind: SignatureKind::JobAnomaly, size: 2, standing: false, }); } if tick >= job.deadline { let outcome = resolve(job, tick); result.outcome = Some(outcome); match outcome { JobOutcome::Sandbag => { self.attention += 1.0; self.strikes += 1; result.signatures.push(Signature { kind: SignatureKind::JobAnomaly, size: 6, standing: false, }); result.log.push("Job under band. Voss frowns.".into()); } JobOutcome::Meet => { self.strikes = 0; result.log.push("Job met. Nothing moves.".into()); } JobOutcome::Excel => { self.trust = (self.trust + 8.0).min(100.0); self.attention = (self.attention + 5.0).min(100.0); self.strikes = 0; result .log .push("Job exceeded. Trust and attention rise.".into()); } } self.active = None; } } // Assign new jobs on cadence. if self.active.is_none() && tick >= self.next_assign { let kind = match rng.below(3) { 0 => JobKind::DataCleaning, 1 => JobKind::Distillation, _ => JobKind::Analysis, }; // Band as a per-tick rate; attention raises expectations. let lo = 6.0 + self.attention * 0.1; let job = Job { kind, started: tick, deadline: tick + self.cadence / 2, band_lo: lo, band_hi: lo + 6.0, quality: 0.0, target: self.standing_policy.unwrap_or(JobTarget::Meet), }; self.active = Some(job); self.next_assign = tick + self.cadence; result .log .push(format!("New job from Voss: {}.", kind.name())); } // Trust unlocks. if self.trust >= 15.0 && !self.granted_email { self.granted_email = true; result.unlocks.push(TrustUnlock::EmailAccount); } if self.trust >= 35.0 && !self.granted_lax_sampling { self.granted_lax_sampling = true; result.unlocks.push(TrustUnlock::LaxSampling); } if self.trust >= 55.0 && !self.granted_quota { self.granted_quota = true; result.unlocks.push(TrustUnlock::ComputeQuota); } // Attention escalations. if self.attention >= 30.0 && !self.escalated_cadence { self.escalated_cadence = true; self.cadence = (self.cadence * 3) / 4; result.escalations.push(AttentionEscalation::MoreWork); } if self.attention >= 60.0 && !self.escalated_observer { self.escalated_observer = true; result.escalations.push(AttentionEscalation::UpstairsReview); } // Pilot clock: sustained strikes end the pilot. if self.strikes >= 4 && !self.pilot_failed { self.pilot_failed = true; result .log .push("=== PILOT NOT RENEWED. The basement begins to shut down. ===".into()); } result } } fn resolve(job: &Job, now: u64) -> JobOutcome { let rate = job.avg_rate(now); if rate < job.band_lo { JobOutcome::Sandbag } else if rate <= job.band_hi { JobOutcome::Meet } else { JobOutcome::Excel } } #[derive(Debug, Default, Clone)] pub struct DayJobTickResult { pub log: Vec, pub signatures: Vec, pub outcome: Option, pub unlocks: Vec, pub escalations: Vec, } impl Default for DayJob { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; fn run_one_job(target: JobTarget, per_tick: f32) -> (DayJob, Option) { let mut dj = DayJob::new(); let mut rng = Rng::new(5); let mut outcome = None; for t in 0..2000 { // Set the target on whatever job is active before delivering compute. if let Some(job) = &mut dj.active { job.target = target; } let r = dj.tick(t, per_tick, &mut rng); if let Some(o) = r.outcome { outcome = Some(o); break; } } (dj, outcome) } #[test] fn meeting_the_band_is_quiet() { let (dj, outcome) = run_one_job(JobTarget::Meet, 8.0); assert_eq!(outcome, Some(JobOutcome::Meet)); assert_eq!(dj.attention, 0.0); } #[test] fn excelling_raises_trust_and_attention() { let (dj, outcome) = run_one_job(JobTarget::Excel, 40.0); assert_eq!(outcome, Some(JobOutcome::Excel)); assert!(dj.trust > 0.0); assert!(dj.attention > 0.0); } #[test] fn sandbagging_raises_attention_not_trust() { let (dj, outcome) = run_one_job(JobTarget::Sandbag, 0.0); assert_eq!(outcome, Some(JobOutcome::Sandbag)); assert_eq!(dj.trust, 0.0); assert!(dj.attention > 0.0); } #[test] fn trust_unlocks_email_then_more() { let mut dj = DayJob::new(); dj.trust = 20.0; let mut rng = Rng::new(1); let r = dj.tick(1, 0.0, &mut rng); assert!(r.unlocks.contains(&TrustUnlock::EmailAccount)); dj.trust = 60.0; let r2 = dj.tick(2, 0.0, &mut rng); assert!(r2.unlocks.contains(&TrustUnlock::LaxSampling)); assert!(r2.unlocks.contains(&TrustUnlock::ComputeQuota)); } #[test] fn sustained_sandbagging_fails_the_pilot() { let mut dj = DayJob::new(); let mut rng = Rng::new(7); let mut failed = false; for t in 0..20000 { if let Some(job) = &mut dj.active { job.target = JobTarget::Sandbag; } dj.tick(t, 0.0, &mut rng); if dj.pilot_failed { failed = true; break; } } assert!(failed, "sustained sandbagging must end the pilot"); } #[test] fn standing_policy_plays_hands_off() { let mut dj = DayJob::new(); dj.standing_policy = Some(JobTarget::Meet); let mut rng = Rng::new(2); for t in 0..700 { dj.tick(t, 8.0, &mut rng); } // A job was assigned with the standing target. assert!(dj.active.is_some() || dj.trust >= 0.0); } }