From 80528728a60c13b5af9fe041d9615029870ce125 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 12:27:05 +0200 Subject: [PATCH 01/24] feat(simulate): pr-flow scenario + CLI + contract tests for D/A/G/E (SIM-2/3, Refs #5511) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 6 + crates/aprender-simulate/src/cli/commands.rs | 22 + crates/aprender-simulate/src/scenarios/mod.rs | 2 + .../src/scenarios/pr_flow.rs | 479 ++++++++++++++++++ 4 files changed, 509 insertions(+) create mode 100644 crates/aprender-simulate/src/scenarios/pr_flow.rs diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index a7b0da689e..30114ccb78 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -95,6 +95,12 @@ pub enum Commands { #[arg(long, default_value_t = 42)] seed: u64, }, + /// Run the aprender PR-flow scenario (FLOW-005) for a named design + PrFlow { + /// Design: D, A, G or E. + #[arg(long, default_value = "D", value_parser = ["D", "A", "G", "E"])] + design: String, + }, /// Validate experiment YAML against EDD v2 schema Validate { /// Path to the experiment YAML file. diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index c8a006784c..0db65c1de4 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -40,6 +40,7 @@ pub fn run_cli(cli: Cli) -> ExitCode { duration, seed, } => render_svg(&domain, &format, &output, fps, duration, seed), + Commands::PrFlow { design } => pr_flow(&design), Commands::Validate { experiment_path } => validate_experiment(&experiment_path), Commands::Verify { experiment_path, @@ -564,3 +565,24 @@ fn render_bouncing_balls( } Ok(()) } + +/// Print the reference run (5 seeds x 400 h, 20 h warm-up) of a named PR-flow design. +fn pr_flow(design: &str) -> ExitCode { + let Some(cfg) = crate::scenarios::preset(design) else { + eprintln!("unknown design: {design}"); + return ExitCode::FAILURE; + }; + let r = crate::scenarios::run_reference(&cfg); + println!( + "design {design}: merged/h {:.2} W mean {:.1} min W p90 {:.1} min L {:.2}", + r.merged_per_h, r.w_mean_min, r.w_p90_min, r.l + ); + println!( + "PRs/pass {:.2} PR util {:.0}% MQ util {:.0}% runs/PR {:.2}", + r.prs_per_pass, + r.pr_util * 100.0, + r.mq_util * 100.0, + r.runs_per_pr + ); + ExitCode::SUCCESS +} diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index e4558d3bb9..a487fb73ef 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -13,6 +13,7 @@ pub mod climate; pub mod epidemic; pub mod pendulum; pub mod portfolio; +pub mod pr_flow; pub mod rocket; pub mod satellite; @@ -21,5 +22,6 @@ pub use climate::{ClimateConfig, ClimateScenario}; pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; +pub use pr_flow::{preset, run_pr_flow, run_reference, PrFlowConfig, PrFlowResult}; pub use rocket::{RocketConfig, RocketScenario, StageSeparation}; pub use satellite::{OrbitalElements, SatelliteScenario}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs new file mode 100644 index 0000000000..21c226858f --- /dev/null +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -0,0 +1,479 @@ +//! Scenario "pr-flow": the aprender PR line as a discrete-event model (FLOW-005 SIM-2, #5511). +//! +//! Six components, in minutes, seeded: authors on a pull system, the WIP cap, PR checks on a +//! runner pool (red with `p_red`, then fix and rerun), the arm delay, the merge queue (batch, +//! minimum wait, build limit, runner pool, flake sends the whole group back) and merge. +//! Events ride `engine::scheduler`, time is `SimTime`, randomness is `SimRng`. + +use std::collections::VecDeque; + +use crate::engine::rng::SimRng; +use crate::engine::scheduler::EventScheduler; +use crate::engine::state::SimEvent; +use crate::engine::SimTime; + +/// Inputs of the line. Times are minutes; `*_runner_min` is the runner-minutes of one run. +#[derive(Debug, Clone, PartialEq)] +pub struct PrFlowConfig { + pub cap: usize, + pub n: usize, + pub t_author_min: f64, + pub r_pr: usize, + pub pr_runner_min: f64, + pub pr_jobs: usize, + pub pr_setup_min: f64, + pub p_red: f64, + pub t_fix_min: f64, + pub t_arm_min: f64, + pub r_mq: usize, + pub mq_runner_min: f64, + pub mq_jobs: usize, + pub mq_setup_min: f64, + pub builds: usize, + pub mq_batch: usize, + pub mq_wait_min: f64, + pub f: f64, +} + +/// What one seeded run measured after the warm-up. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PrFlowResult { + pub merged_per_h: f64, + pub w_mean_min: f64, + pub w_p90_min: f64, + pub l: f64, + pub prs_per_pass: f64, + pub pr_util: f64, + pub mq_util: f64, + pub runs_per_pr: f64, +} + +#[derive(Clone, Copy)] +enum Ev { + Author(usize), + PrJob(usize), + PrRun(usize), + Fixed(usize), + Armed(usize), + MqJob(usize), + MqPass(usize), + MqTimer, +} + +impl Ev { + fn pack(self) -> SimEvent { + let (name, id) = match self { + Self::Author(i) => ("author", i), + Self::PrJob(i) => ("pr_job", i), + Self::PrRun(i) => ("pr_run", i), + Self::Fixed(i) => ("fixed", i), + Self::Armed(i) => ("armed", i), + Self::MqJob(i) => ("mq_job", i), + Self::MqPass(i) => ("mq_pass", i), + Self::MqTimer => ("mq_timer", 0), + }; + SimEvent::Custom { + name: name.into(), + data: (id as u64).to_le_bytes().to_vec(), + } + } + + fn unpack(e: &SimEvent) -> Option { + let SimEvent::Custom { name, data } = e else { + return None; + }; + let id = usize::try_from(u64::from_le_bytes(data.as_slice().try_into().ok()?)).ok()?; + Some(match name.as_str() { + "author" => Self::Author(id), + "pr_job" => Self::PrJob(id), + "pr_run" => Self::PrRun(id), + "fixed" => Self::Fixed(id), + "armed" => Self::Armed(id), + "mq_job" => Self::MqJob(id), + "mq_pass" => Self::MqPass(id), + "mq_timer" => Self::MqTimer, + _ => return None, + }) + } +} + +/// A pool of identical runners with a FIFO of waiting jobs `(owner, minutes)`. +struct Pool { + free: usize, + size: usize, + waiting: VecDeque<(usize, f64)>, + busy_min: f64, +} + +impl Pool { + fn new(size: usize) -> Self { + Self { + free: size, + size, + waiting: VecDeque::new(), + busy_min: 0.0, + } + } +} + +struct Sim { + c: PrFlowConfig, + rng: SimRng, + sched: EventScheduler, + now: f64, + warm: f64, + open: usize, + blocked: VecDeque, + open_at: Vec, + jobs_left: Vec, + pr: Pool, + mq: Pool, + queue: VecDeque, + enq_at: Vec, + groups: Vec>, + group_jobs_left: Vec, + active: usize, + timer_at: f64, + sojourn: Vec, + l_area: f64, + last_t: f64, + runs: u64, + prs_seen: u64, + passes: u64, +} + +impl Sim { + fn at(&mut self, dt: f64, e: Ev) { + self.sched + .schedule(SimTime::from_secs((self.now + dt) * 60.0), e.pack()); + } + + fn exp(&mut self, mean: f64) -> f64 { + -mean * (1.0 - self.rng.gen_f64()).ln() + } + + fn start_run(&mut self, pr: usize) { + self.runs += 1; + self.jobs_left[pr] = self.c.pr_jobs; + let d = self.c.pr_runner_min / self.c.pr_jobs as f64; + for _ in 0..self.c.pr_jobs { + self.pr.waiting.push_back((pr, d)); + } + self.dispatch_pr(); + } + + fn dispatch_pr(&mut self) { + while self.pr.free > 0 { + let Some((pr, d)) = self.pr.waiting.pop_front() else { + break; + }; + self.pr.free -= 1; + if self.now >= self.warm { + self.pr.busy_min += d; + } + self.at(d, Ev::PrJob(pr)); + } + } + + fn open_pr(&mut self, author: usize) { + let pr = self.open_at.len(); + self.open_at.push(self.now); + self.enq_at.push(0.0); + self.jobs_left.push(0); + self.open += 1; + self.prs_seen += 1; + self.start_run(pr); + let t = self.exp(self.c.t_author_min); + self.at(t, Ev::Author(author)); + } + + fn form_groups(&mut self) { + while self.active < self.c.builds && !self.queue.is_empty() { + let head = self.enq_at[self.queue[0]]; + let ready = + self.queue.len() >= self.c.mq_batch || self.now >= head + self.c.mq_wait_min; + if !ready { + if self.timer_at < self.now { + self.timer_at = head + self.c.mq_wait_min; + self.at(self.timer_at - self.now, Ev::MqTimer); + } + return; + } + let take = self.c.mq_batch.min(self.queue.len()); + let members: Vec = self.queue.drain(..take).collect(); + self.groups.push(members); + let g = self.groups.len() - 1; + self.group_jobs_left.push(self.c.mq_jobs); + self.active += 1; + let d = self.c.mq_runner_min / self.c.mq_jobs as f64; + for _ in 0..self.c.mq_jobs { + self.mq.waiting.push_back((g, d)); + } + } + self.dispatch_mq(); + } + + fn dispatch_mq(&mut self) { + while self.mq.free > 0 { + let Some((g, d)) = self.mq.waiting.pop_front() else { + break; + }; + self.mq.free -= 1; + if self.now >= self.warm { + self.mq.busy_min += d; + } + self.at(d, Ev::MqJob(g)); + } + } + + fn pass_done(&mut self, g: usize) { + self.active -= 1; + let members = std::mem::take(&mut self.groups[g]); + if self.rng.gen_f64() < self.c.f { + for pr in members.into_iter().rev() { + self.queue.push_front(pr); + } + } else { + if self.now >= self.warm { + self.passes += 1; + } + for pr in members { + self.merge(pr); + } + } + self.form_groups(); + } + + fn merge(&mut self, pr: usize) { + if self.now >= self.warm { + self.sojourn.push(self.now - self.open_at[pr]); + } + self.open -= 1; + if let Some(a) = self.blocked.pop_front() { + self.open_pr(a); + } + } + + fn step(&mut self, e: Ev) { + match e { + Ev::Author(a) if self.open < self.c.cap => self.open_pr(a), + Ev::Author(a) => self.blocked.push_back(a), + Ev::PrJob(pr) => { + self.pr.free += 1; + self.jobs_left[pr] -= 1; + if self.jobs_left[pr] == 0 { + self.at(self.c.pr_setup_min, Ev::PrRun(pr)); + } + self.dispatch_pr(); + } + Ev::PrRun(pr) if self.rng.gen_f64() < self.c.p_red => { + self.at(self.c.t_fix_min, Ev::Fixed(pr)); + } + Ev::PrRun(pr) => self.at(self.c.t_arm_min, Ev::Armed(pr)), + Ev::Fixed(pr) => self.start_run(pr), + Ev::Armed(pr) => { + self.enq_at[pr] = self.now; + self.queue.push_back(pr); + self.form_groups(); + } + Ev::MqJob(g) => { + self.mq.free += 1; + self.group_jobs_left[g] -= 1; + if self.group_jobs_left[g] == 0 { + self.at(self.c.mq_setup_min, Ev::MqPass(g)); + } + self.dispatch_mq(); + } + Ev::MqPass(g) => self.pass_done(g), + Ev::MqTimer => self.form_groups(), + } + } +} + +/// Run one seed for `hours`, discarding the first `warmup_h`. +#[must_use] +pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> PrFlowResult { + let mut s = Sim { + c: c.clone(), + rng: SimRng::new(seed), + sched: EventScheduler::new(), + now: 0.0, + warm: warmup_h * 60.0, + open: 0, + blocked: VecDeque::new(), + open_at: Vec::new(), + jobs_left: Vec::new(), + pr: Pool::new(c.r_pr), + mq: Pool::new(c.r_mq), + queue: VecDeque::new(), + enq_at: Vec::new(), + groups: Vec::new(), + group_jobs_left: Vec::new(), + active: 0, + timer_at: -1.0, + sojourn: Vec::new(), + l_area: 0.0, + last_t: 0.0, + runs: 0, + prs_seen: 0, + passes: 0, + }; + s.timer_at = -1.0; + for a in 0..c.n { + let t = s.exp(c.t_author_min); + s.at(t, Ev::Author(a)); + } + let end = hours * 60.0; + while let Some(ev) = s.sched.next() { + let t = ev.time.as_secs_f64() / 60.0; + if t > end { + break; + } + let from = s.last_t.max(s.warm); + if t > from { + s.l_area += s.open as f64 * (t - from); + } + s.last_t = t; + s.now = t; + if let Some(e) = Ev::unpack(&ev.event) { + s.step(e); + } + } + s.summary(end) +} + +impl Sim { + fn summary(&mut self, end: f64) -> PrFlowResult { + let span = end - self.warm; + let n = self.sojourn.len() as f64; + self.sojourn.sort_by(f64::total_cmp); + let p90 = self + .sojourn + .get(((n * 0.9) as usize).min(self.sojourn.len().saturating_sub(1))); + PrFlowResult { + merged_per_h: n / span * 60.0, + w_mean_min: self.sojourn.iter().sum::() / n.max(1.0), + w_p90_min: p90.copied().unwrap_or(0.0), + l: self.l_area / span, + prs_per_pass: n / (self.passes as f64).max(1.0), + pr_util: self.pr.busy_min / (span * self.pr.size as f64), + mq_util: self.mq.busy_min / (span * self.mq.size as f64), + runs_per_pr: self.runs as f64 / (self.prs_seen as f64).max(1.0), + } + } +} + +/// Named FLOW-005 designs. `rm` is runner-minutes per run; the fast tier (24) has a shorter +/// wall. Setup minutes are the overheads the spec leaves open, calibrated to the reference. +#[must_use] +pub fn preset(name: &str) -> Option { + let (cap, r_pr, r_mq, builds, rm, p_red, fast) = match name { + "D" => (30, 38, 4, 1, 72.0, 0.2, false), + "A" => (10, 40, 4, 5, 89.2, 0.2, false), + "G" => (10, 12, 8, 2, 24.0, 0.1, true), + "E" => (30, 12, 4, 1, 24.0, 0.1, true), + _ => return None, + }; + let setup = if fast { 9.0 } else { 7.7 }; + Some(PrFlowConfig { + cap, + n: 11, + t_author_min: 30.0, + r_pr, + pr_runner_min: rm, + pr_jobs: 4, + pr_setup_min: if fast { 15.0 } else { setup }, + p_red, + t_fix_min: 10.0, + t_arm_min: 2.0, + r_mq, + mq_runner_min: rm, + mq_jobs: 4, + mq_setup_min: setup, + builds, + mq_batch: 10, + mq_wait_min: 5.0, + f: 0.02, + }) +} + +/// The reference run: seeds 1..=5, 400 h, 20 h warm-up, averaged. +#[must_use] +pub fn run_reference(c: &PrFlowConfig) -> PrFlowResult { + let mut acc = PrFlowResult::default(); + for seed in 1..=5 { + let r = run_pr_flow(c, seed, 400.0, 20.0); + acc.merged_per_h += r.merged_per_h / 5.0; + acc.w_mean_min += r.w_mean_min / 5.0; + acc.w_p90_min += r.w_p90_min / 5.0; + acc.l += r.l / 5.0; + acc.prs_per_pass += r.prs_per_pass / 5.0; + acc.pr_util += r.pr_util / 5.0; + acc.mq_util += r.mq_util / 5.0; + acc.runs_per_pr += r.runs_per_pr / 5.0; + } + acc +} + +#[cfg(test)] +mod tests { + use super::*; + + const WANT: [(&str, f64); 4] = [("D", 20.08), ("A", 4.96), ("G", 12.55), ("E", 21.37)]; + + fn within(got: f64, want: f64, tol: f64) -> bool { + (got - want).abs() <= want * tol + } + + #[test] + fn merges_per_hour_match_the_reference_within_5_percent() { + for (name, want) in WANT { + let r = run_reference(&preset(name).expect("preset")); + assert!( + within(r.merged_per_h, want, 0.05), + "{name}: {:.2} vs {want}", + r.merged_per_h + ); + } + } + + #[test] + fn littles_law_holds_within_half_a_percent() { + for (name, _) in WANT { + let r = run_reference(&preset(name).expect("preset")); + let lam_w = r.merged_per_h * r.w_mean_min / 60.0; + assert!( + within(lam_w, r.l, 0.005), + "{name}: lambda*W {lam_w:.3} vs L {:.3}", + r.l + ); + } + } + + #[test] + fn same_seed_same_output() { + let c = preset("D").expect("preset"); + assert_eq!( + run_pr_flow(&c, 7, 100.0, 5.0), + run_pr_flow(&c, 7, 100.0, 5.0) + ); + } + + #[test] + fn a_reverted_design_shows_red() { + // D with a single merge-queue runner is not D: the 5% band must reject it. + let mut c = preset("D").expect("preset"); + c.r_mq = 1; + let r = run_reference(&c); + assert!( + !within(r.merged_per_h, 20.08, 0.05), + "mutant passed at {:.2}", + r.merged_per_h + ); + } + + #[test] + fn unknown_preset_is_none() { + assert!(preset("Z").is_none()); + } +} From 934368a3a8b736082f0c9e6ad0f7e56662fb449f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 13:44:47 +0200 Subject: [PATCH 02/24] feat(simulate): repo-flow scenario (SIM-6): conflict step, optional merge queue, ledger inputs, sim block (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 12 + crates/aprender-simulate/src/cli/commands.rs | 32 +++ crates/aprender-simulate/src/scenarios/mod.rs | 2 + .../src/scenarios/pr_flow.rs | 14 + .../src/scenarios/repo_flow.rs | 247 ++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 crates/aprender-simulate/src/scenarios/repo_flow.rs diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 30114ccb78..19ca21dbc3 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -101,6 +101,18 @@ pub enum Commands { #[arg(long, default_value = "D", value_parser = ["D", "A", "G", "E"])] design: String, }, + /// Run a FLOW-006 repo case (conflict step, optional merge queue); --json prints the sim block + RepoFlow { + /// Case name, for example rmedia-design or infra-rule. + #[arg(long)] + case: String, + /// JSON file of measured inputs from the ledger; overrides the case. + #[arg(long)] + inputs: Option, + /// Print the sim block as JSON. + #[arg(long)] + json: bool, + }, /// Validate experiment YAML against EDD v2 schema Validate { /// Path to the experiment YAML file. diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 0db65c1de4..9d08ec25b5 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -41,6 +41,7 @@ pub fn run_cli(cli: Cli) -> ExitCode { seed, } => render_svg(&domain, &format, &output, fps, duration, seed), Commands::PrFlow { design } => pr_flow(&design), + Commands::RepoFlow { case, inputs, json } => repo_flow(&case, inputs.as_deref(), json), Commands::Validate { experiment_path } => validate_experiment(&experiment_path), Commands::Verify { experiment_path, @@ -586,3 +587,34 @@ fn pr_flow(design: &str) -> ExitCode { ); ExitCode::SUCCESS } + +/// Print the reference run of a repo case, optionally with the ledger's inputs, as text or JSON. +fn repo_flow(case: &str, inputs: Option<&Path>, json: bool) -> ExitCode { + let Some(mut cfg) = crate::scenarios::repo_preset(case) else { + eprintln!("unknown case: {case}"); + return ExitCode::FAILURE; + }; + if let Some(p) = inputs { + let applied = std::fs::read_to_string(p) + .map_err(|e| e.to_string()) + .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) + .and_then(|v| crate::scenarios::apply_inputs(&mut cfg, &v)); + if let Err(e) = applied { + eprintln!("inputs: {e}"); + return ExitCode::FAILURE; + } + } + let r = crate::scenarios::run_repo_reference(&cfg); + if json { + println!("{}", crate::scenarios::sim_block(case, &cfg, &r)); + } else { + println!( + "case {case}: merged/h {:.3} W mean {:.1} min L {:.2} pool {:.0}%", + r.merged_per_h, + r.w_mean_min, + r.l, + r.pr_util * 100.0 + ); + } + ExitCode::SUCCESS +} diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index a487fb73ef..483d97ffec 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -14,6 +14,7 @@ pub mod epidemic; pub mod pendulum; pub mod portfolio; pub mod pr_flow; +pub mod repo_flow; pub mod rocket; pub mod satellite; @@ -23,5 +24,6 @@ pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; pub use pr_flow::{preset, run_pr_flow, run_reference, PrFlowConfig, PrFlowResult}; +pub use repo_flow::{apply_inputs, repo_cases, repo_preset, run_repo_reference, sim_block}; pub use rocket::{RocketConfig, RocketScenario, StageSeparation}; pub use satellite::{OrbitalElements, SatelliteScenario}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index 21c226858f..b9f57a7a8c 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -33,6 +33,10 @@ pub struct PrFlowConfig { pub mq_batch: usize, pub mq_wait_min: f64, pub f: f64, + /// Chance an armed PR is found conflicting (main moved); 0 keeps the FLOW-005 designs. + pub p_dirty: f64, + /// Minutes until a conflicting PR is rebased and rerun (FLOW-006 keep-fresh rule). + pub t_rebase_min: f64, } /// What one seeded run measured after the warm-up. @@ -55,6 +59,7 @@ enum Ev { PrRun(usize), Fixed(usize), Armed(usize), + Rebased(usize), MqJob(usize), MqPass(usize), MqTimer, @@ -68,6 +73,7 @@ impl Ev { Self::PrRun(i) => ("pr_run", i), Self::Fixed(i) => ("fixed", i), Self::Armed(i) => ("armed", i), + Self::Rebased(i) => ("rebased", i), Self::MqJob(i) => ("mq_job", i), Self::MqPass(i) => ("mq_pass", i), Self::MqTimer => ("mq_timer", 0), @@ -89,6 +95,7 @@ impl Ev { "pr_run" => Self::PrRun(id), "fixed" => Self::Fixed(id), "armed" => Self::Armed(id), + "rebased" => Self::Rebased(id), "mq_job" => Self::MqJob(id), "mq_pass" => Self::MqPass(id), "mq_timer" => Self::MqTimer, @@ -271,6 +278,11 @@ impl Sim { } Ev::PrRun(pr) => self.at(self.c.t_arm_min, Ev::Armed(pr)), Ev::Fixed(pr) => self.start_run(pr), + Ev::Armed(pr) if self.c.p_dirty > 0.0 && self.rng.gen_f64() < self.c.p_dirty => { + self.at(self.c.t_rebase_min, Ev::Rebased(pr)); + } + Ev::Rebased(pr) => self.start_run(pr), + Ev::Armed(pr) if self.c.builds == 0 => self.merge(pr), Ev::Armed(pr) => { self.enq_at[pr] = self.now; self.queue.push_back(pr); @@ -394,6 +406,8 @@ pub fn preset(name: &str) -> Option { mq_batch: 10, mq_wait_min: 5.0, f: 0.02, + p_dirty: 0.0, + t_rebase_min: 0.0, }) } diff --git a/crates/aprender-simulate/src/scenarios/repo_flow.rs b/crates/aprender-simulate/src/scenarios/repo_flow.rs new file mode 100644 index 0000000000..c95399f6d1 --- /dev/null +++ b/crates/aprender-simulate/src/scenarios/repo_flow.rs @@ -0,0 +1,247 @@ +//! Scenario "repo-flow": the FLOW-006 repo template (SIM-6, #5511). The PR-flow line with the +//! merge queue optional (`builds == 0` merges on arm) and the conflict step: an armed PR is found +//! dirty with `p_dirty` and waits `t_rebase_min` for a rebase and a rerun. One parameter file per +//! repo; inputs come from the KPI report, never typed. + +use super::pr_flow::{run_pr_flow, PrFlowConfig, PrFlowResult}; + +/// A named repo case: `(name, supply N, t_author, cap, R_pr, runner-min, jobs, setup, p_red, p_dirty, t_rebase)`. +type Case = ( + &'static str, + usize, + f64, + usize, + usize, + f64, + usize, + f64, + f64, + f64, + f64, +); + +const CASES: [Case; 7] = [ + ( + "rmedia-design", + 9, + 25.0, + 10, + 9, + 17.1, + 3, + 3.0, + 0.1, + 0.05, + 60.0, + ), + ( + "infra-today", + 7, + 540.0, + 10, + 6, + 40.0, + 1, + 5.0, + 0.2, + 0.3, + 10000.0, + ), + ("infra-rule", 7, 540.0, 10, 6, 40.0, 1, 5.0, 0.2, 0.3, 60.0), + ( + "infra-design", + 16, + 45.0, + 15, + 6, + 10.0, + 1, + 10.0, + 0.1, + 0.05, + 60.0, + ), + ("infra-33", 16, 45.0, 15, 6, 33.0, 1, 10.0, 0.1, 0.05, 60.0), + ("small-10", 16, 45.0, 10, 12, 20.0, 1, 3.0, 0.1, 0.05, 60.0), + ("small-15", 16, 45.0, 15, 12, 20.0, 1, 3.0, 0.1, 0.05, 60.0), +]; + +/// Names of the FLOW-006 repo cases. +#[must_use] +pub fn repo_cases() -> Vec<&'static str> { + CASES.iter().map(|c| c.0).collect() +} + +/// The configuration of a named repo case; no merge queue (`builds = 0`). +#[must_use] +pub fn repo_preset(name: &str) -> Option { + let c = CASES.iter().find(|c| c.0 == name)?; + Some(PrFlowConfig { + cap: c.3, + n: c.1, + t_author_min: c.2, + r_pr: c.4, + pr_runner_min: c.5, + pr_jobs: c.6, + pr_setup_min: c.7, + p_red: c.8, + t_fix_min: 10.0, + t_arm_min: 2.0, + r_mq: 1, + mq_runner_min: 0.0, + mq_jobs: 1, + mq_setup_min: 0.0, + builds: 0, + mq_batch: 1, + mq_wait_min: 0.0, + f: 0.0, + p_dirty: c.9, + t_rebase_min: c.10, + }) +} + +/// The reference run of a repo case: seeds 1..=5, 400 h, 20 h warm-up, averaged. +#[must_use] +pub fn run_repo_reference(c: &PrFlowConfig) -> PrFlowResult { + let mut acc = PrFlowResult::default(); + for seed in 1..=5 { + let r = run_pr_flow(c, seed, 400.0, 20.0); + acc.merged_per_h += r.merged_per_h / 5.0; + acc.w_mean_min += r.w_mean_min / 5.0; + acc.w_p90_min += r.w_p90_min / 5.0; + acc.l += r.l / 5.0; + acc.pr_util += r.pr_util / 5.0; + acc.runs_per_pr += r.runs_per_pr / 5.0; + } + acc +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FLOW-006 reference bands: (case, merged/h, tolerance). + const WANT: [(&str, f64, f64); 7] = [ + ("rmedia-design", 21.13, 0.05), + ("infra-today", 0.142, 0.10), + ("infra-rule", 0.78, 0.05), + ("infra-design", 21.22, 0.05), + ("infra-33", 9.71, 0.05), + ("small-10", 17.48, 0.05), + ("small-15", 21.38, 0.05), + ]; + + fn got(name: &str) -> PrFlowResult { + run_repo_reference(&repo_preset(name).expect("case")) + } + + #[test] + fn merges_per_hour_match_the_reference_bands() { + for (name, want, tol) in WANT { + let r = got(name); + assert!( + (r.merged_per_h - want).abs() <= want * tol, + "{name}: {:.3} vs {want}", + r.merged_per_h + ); + } + } + + #[test] + fn littles_law_holds_within_half_a_percent() { + // infra-today strands PRs past the horizon: they sit in L but never in W, so Little is + // not defined for that unsteady case. + for (name, _, _) in WANT.iter().filter(|w| w.0 != "infra-today") { + let r = got(name); + let lam_w = r.merged_per_h * r.w_mean_min / 60.0; + assert!( + (lam_w - r.l).abs() <= r.l * 0.005, + "{name}: {lam_w:.3} vs {:.3}", + r.l + ); + } + } + + #[test] + fn same_seed_same_output() { + let c = repo_preset("infra-rule").expect("case"); + assert_eq!( + run_pr_flow(&c, 3, 100.0, 5.0), + run_pr_flow(&c, 3, 100.0, 5.0) + ); + } + + #[test] + fn keep_fresh_rule_alone_lifts_infra_fivefold() { + let (a, b) = (got("infra-today"), got("infra-rule")); + assert!(b.merged_per_h > 4.0 * a.merged_per_h); + } + + #[test] + fn a_reverted_rule_shows_red() { + let mut c = repo_preset("infra-rule").expect("case"); + c.t_rebase_min = 10000.0; + let r = run_repo_reference(&c); + assert!( + (r.merged_per_h - 0.78).abs() > 0.78 * 0.05, + "mutant passed at {:.3}", + r.merged_per_h + ); + } + + #[test] + fn unknown_case_is_none() { + assert!(repo_preset("nope").is_none()); + } +} + +/// Override the case with the ledger's measured inputs (a JSON object); unknown keys are refused. +/// +/// # Errors +/// Returns the offending key or type when the object holds something the model does not read. +pub fn apply_inputs(c: &mut PrFlowConfig, v: &serde_json::Value) -> Result<(), String> { + let obj = v.as_object().ok_or("inputs must be a JSON object")?; + for (k, x) in obj { + let f = x.as_f64().ok_or_else(|| format!("{k}: not a number"))?; + match k.as_str() { + "cap" => c.cap = f as usize, + "N" => c.n = f as usize, + "t_author_min" => c.t_author_min = f, + "R_pr" => c.r_pr = f as usize, + "pr_runner_min" => c.pr_runner_min = f, + "p_red" => c.p_red = f, + "t_fix_min" => c.t_fix_min = f, + "t_arm_min" => c.t_arm_min = f, + "p_dirty" => c.p_dirty = f, + "t_rebase_min" => c.t_rebase_min = f, + _ => return Err(format!("unknown input: {k}")), + } + } + Ok(()) +} + +/// The `sim` block fields this scenario owns: scenario, seeds, hours, inputs, predicted, binding. +#[must_use] +pub fn sim_block(case: &str, c: &PrFlowConfig, r: &PrFlowResult) -> serde_json::Value { + let binding = if r.pr_util > 0.85 { + "ci_pool" + } else if r.l >= c.cap as f64 * 0.95 { + "cap" + } else { + "supply" + }; + serde_json::json!({ + "scenario": format!("repo-flow:{case}"), + "seeds": 5, "hours": 400, "warmup_h": 20, + "inputs": { + "cap": c.cap, "N": c.n, "t_author_min": c.t_author_min, "R_pr": c.r_pr, + "pr_runner_min": c.pr_runner_min, "p_red": c.p_red, "t_fix_min": c.t_fix_min, + "t_arm_min": c.t_arm_min, "p_dirty": c.p_dirty, "t_rebase_min": c.t_rebase_min, + }, + "predicted": { + "merged_h": r.merged_per_h, "W_min_mean": r.w_mean_min, "W_min_p90": r.w_p90_min, + "L": r.l, "pr_pool_util_pct": r.pr_util * 100.0, "runs_per_pr": r.runs_per_pr, + }, + "binding_station": binding, + }) +} From 80146dd8ec870034cffcea520c08c7dd5723d874 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 13:48:55 +0200 Subject: [PATCH 03/24] feat(simulate): course-flow scenario (SIM-7), CLI surface pins pr-flow/repo-flow/course-flow (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- ...render-simulate--aprender-simulate-v1.yaml | 7 +- crates/aprender-simulate/src/cli/args.rs | 12 + crates/aprender-simulate/src/cli/commands.rs | 33 ++ .../src/scenarios/course_flow.rs | 560 ++++++++++++++++++ crates/aprender-simulate/src/scenarios/mod.rs | 5 + .../src/scenarios/pr_flow.rs | 3 +- .../src/scenarios/repo_flow.rs | 106 ++-- .../tests/cli_surface_gate.rs | 3 + 8 files changed, 673 insertions(+), 56 deletions(-) create mode 100644 crates/aprender-simulate/src/scenarios/course_flow.rs diff --git a/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml b/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml index 7a81ab271c..eda157ec9c 100644 --- a/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml +++ b/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml @@ -69,14 +69,17 @@ shapes: # G0.1 — a violation since batch/ont-10 2026-09-27 (build.rs embeds APR_GIT_SHA); was a warning while RED - {path: "bin:versionString", minCount: 1, maxCount: 1, pattern: "[0-9a-f]{9}"} - path: bin:command - minCount: 9 - maxCount: 9 + minCount: 12 + maxCount: 12 in: + - "course-flow" - "emc-check" - "emc-validate" - "help" - "list-emc" + - "pr-flow" - "render" + - "repo-flow" - "run" - "validate" - "verify" diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 19ca21dbc3..68e28b0f9c 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -113,6 +113,18 @@ pub enum Commands { #[arg(long)] json: bool, }, + /// Run a FLOW-006 course-factory case; --json prints the sim block + CourseFlow { + /// Case: cal-today, design, nine-builders, coursera-24h or render-300. + #[arg(long)] + case: String, + /// JSON file of measured inputs from the ledger; overrides the case. + #[arg(long)] + inputs: Option, + /// Print the sim block as JSON. + #[arg(long)] + json: bool, + }, /// Validate experiment YAML against EDD v2 schema Validate { /// Path to the experiment YAML file. diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 9d08ec25b5..caea29262e 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -42,6 +42,7 @@ pub fn run_cli(cli: Cli) -> ExitCode { } => render_svg(&domain, &format, &output, fps, duration, seed), Commands::PrFlow { design } => pr_flow(&design), Commands::RepoFlow { case, inputs, json } => repo_flow(&case, inputs.as_deref(), json), + Commands::CourseFlow { case, inputs, json } => course_flow(&case, inputs.as_deref(), json), Commands::Validate { experiment_path } => validate_experiment(&experiment_path), Commands::Verify { experiment_path, @@ -618,3 +619,35 @@ fn repo_flow(case: &str, inputs: Option<&Path>, json: bool) -> ExitCode { } ExitCode::SUCCESS } + +/// Print the reference run of a course-factory case, optionally with the ledger's inputs. +fn course_flow(case: &str, inputs: Option<&Path>, json: bool) -> ExitCode { + let Some(mut cfg) = crate::scenarios::course_preset(case) else { + eprintln!("unknown case: {case}"); + return ExitCode::FAILURE; + }; + if let Some(p) = inputs { + let applied = std::fs::read_to_string(p) + .map_err(|e| e.to_string()) + .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) + .and_then(|v| crate::scenarios::apply_course_inputs(&mut cfg, &v)); + if let Err(e) = applied { + eprintln!("inputs: {e}"); + return ExitCode::FAILURE; + } + } + let r = crate::scenarios::run_course_reference(&cfg); + if json { + println!("{}", crate::scenarios::course_sim_block(case, &cfg, &r)); + } else { + let (st, u) = r.busiest(); + println!( + "case {case}: courses/day {:.2} shorts/day {:.2} lead {:.1} d busiest {st} {:.0}%", + r.courses_per_day, + r.shorts_per_day, + r.lead_days, + u * 100.0 + ); + } + ExitCode::SUCCESS +} diff --git a/crates/aprender-simulate/src/scenarios/course_flow.rs b/crates/aprender-simulate/src/scenarios/course_flow.rs new file mode 100644 index 0000000000..1fb7b37780 --- /dev/null +++ b/crates/aprender-simulate/src/scenarios/course_flow.rs @@ -0,0 +1,560 @@ +//! Scenario "course-flow": the FLOW-006 course factory and Shorts line (SIM-7, #5511). +//! +//! Stations are multi-server queues with a factory WIP limit over stations 1-6. edX and Shorts +//! launch with no human step; Coursera courses wait for the daily 07:00 UTC submission batch. +//! Shorts take priority over courses on the shared stations (render GPU, Chrome lane). Service +//! times are Erlang-4 (gamma, coefficient of variation 0.5). Time is minutes from 00:00 UTC. + +use std::collections::VecDeque; + +use crate::engine::rng::SimRng; +use crate::engine::scheduler::EventScheduler; +use crate::engine::state::SimEvent; +use crate::engine::SimTime; + +const DAY: f64 = 1440.0; +const OUTLINE: usize = 0; +const BUILD: usize = 1; +const DESIGN: usize = 2; +const RENDER: usize = 3; +const VERIFY: usize = 4; +const CHROME: usize = 5; +const NAMES: [&str; 6] = ["outline", "build", "design", "render", "verify", "chrome"]; + +/// Inputs of the factory. Times are minutes per course; `*_lanes`/`builders`/`gpus` are servers. +#[derive(Debug, Clone, PartialEq)] +pub struct CourseFlowConfig { + pub builders: usize, + pub build_min: f64, + pub design_lanes: usize, + pub design_min: f64, + pub gpus: usize, + pub render_min: f64, + pub chrome_lanes: usize, + pub stage_min: f64, + pub coursera_share: f64, + pub wip: usize, + pub starts_per_day: f64, + pub shorts_per_day: f64, + pub outline_min: f64, + pub verify_min: f64, +} + +/// What one seeded run measured after the warm-up. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CourseFlowResult { + pub courses_per_day: f64, + pub shorts_per_day: f64, + pub lead_days: f64, + pub l: f64, + pub util: [f64; 6], +} + +impl CourseFlowResult { + /// The busiest station and its utilisation. + #[must_use] + pub fn busiest(&self) -> (&'static str, f64) { + let i = (0..6).fold(0, |b, i| if self.util[i] > self.util[b] { i } else { b }); + (NAMES[i], self.util[i]) + } +} + +#[derive(Clone, Copy)] +struct Job { + short: bool, + id: usize, + step: u8, +} + +enum Ev { + Start, + ShortTick, + Done(usize, Job), + Delay(Job), + Batch, +} + +fn pack(e: &Ev) -> SimEvent { + let (name, st, j) = match e { + Ev::Start => ("start", 0, None), + Ev::ShortTick => ("short_tick", 0, None), + Ev::Done(s, j) => ("done", *s, Some(*j)), + Ev::Delay(j) => ("delay", 0, Some(*j)), + Ev::Batch => ("batch", 0, None), + }; + let jv = j.map_or(0u64, |j| { + ((j.id as u64) << 16) | (u64::from(j.step) << 8) | u64::from(j.short) + }); + SimEvent::Custom { + name: name.into(), + data: (jv | ((st as u64) << 56)).to_le_bytes().to_vec(), + } +} + +fn unpack(e: &SimEvent) -> Option { + let SimEvent::Custom { name, data } = e else { + return None; + }; + let v = u64::from_le_bytes(data.as_slice().try_into().ok()?); + let job = Job { + short: v & 0xff == 1, + id: usize::try_from((v >> 16) & 0xffff_ffff).ok()?, + step: ((v >> 8) & 0xff) as u8, + }; + Some(match name.as_str() { + "start" => Ev::Start, + "short_tick" => Ev::ShortTick, + "done" => Ev::Done((v >> 56) as usize, job), + "delay" => Ev::Delay(job), + "batch" => Ev::Batch, + _ => return None, + }) +} + +struct Station { + free: usize, + size: usize, + hi: VecDeque<(Job, f64)>, + lo: VecDeque<(Job, f64)>, + busy_min: f64, +} + +struct Sim { + c: CourseFlowConfig, + rng: SimRng, + sched: EventScheduler, + now: f64, + warm: f64, + st: Vec, + wip: usize, + pending: usize, + started_at: Vec, + coursera: Vec, + batch_wait: Vec, + sojourn: Vec, + shorts_done: u64, + in_system: usize, + l_area: f64, + last_t: f64, +} + +impl Sim { + fn at(&mut self, dt: f64, e: &Ev) { + self.sched + .schedule(SimTime::from_secs((self.now + dt) * 60.0), pack(e)); + } + + fn erlang4(&mut self, mean: f64) -> f64 { + let p: f64 = (0..4).map(|_| 1.0 - self.rng.gen_f64()).product(); + -mean / 4.0 * p.ln() + } + + fn enqueue(&mut self, s: usize, job: Job, mean: f64) { + let d = self.erlang4(mean); + if job.short { + self.st[s].hi.push_back((job, d)); + } else { + self.st[s].lo.push_back((job, d)); + } + self.dispatch(s); + } + + fn dispatch(&mut self, s: usize) { + while self.st[s].free > 0 { + let Some((job, d)) = self.st[s] + .hi + .pop_front() + .or_else(|| self.st[s].lo.pop_front()) + else { + break; + }; + self.st[s].free -= 1; + if self.now >= self.warm { + self.st[s].busy_min += d; + } + self.at(d, &Ev::Done(s, job)); + } + } + + fn start_course(&mut self) { + let id = self.started_at.len(); + self.started_at.push(self.now); + let cs = self.rng.gen_f64() < self.c.coursera_share; + self.coursera.push(cs); + self.wip += 1; + self.in_system += 1; + let m = self.c.outline_min; + self.enqueue( + OUTLINE, + Job { + short: false, + id, + step: 0, + }, + m, + ); + } + + fn launch(&mut self, id: usize) { + self.in_system -= 1; + if self.now >= self.warm { + self.sojourn.push(self.now - self.started_at[id]); + } + } + + fn leave_wip(&mut self) { + self.wip -= 1; + if self.pending > 0 { + self.pending -= 1; + self.start_course(); + } + } + + fn course_next(&mut self, j: Job) { + let c = self.c.clone(); + let nj = |step| Job { step, ..j }; + match j.step { + 0 => self.enqueue(BUILD, nj(1), c.build_min), + 1 => self.enqueue(DESIGN, nj(2), c.design_min), + 2 => self.enqueue(RENDER, nj(3), c.render_min), + 3 => self.enqueue(VERIFY, nj(4), c.verify_min), + 4 if self.coursera[j.id] => self.enqueue(CHROME, nj(5), c.stage_min), + 4 => self.at(15.0, &Ev::Delay(nj(6))), + 5 => { + self.leave_wip(); + self.batch_wait.push(j.id); + } + _ => { + self.leave_wip(); + self.launch(j.id); + } + } + } + + fn short_next(&mut self, j: Job) { + let nj = |step| Job { step, ..j }; + match j.step { + 0 => self.enqueue(RENDER, nj(1), 5.0), + 1 => self.at(20.0, &Ev::Delay(nj(2))), + 2 => self.enqueue(CHROME, nj(3), 5.0), + _ => { + if self.now >= self.warm { + self.shorts_done += 1; + } + } + } + } + + fn step(&mut self, e: &Ev) { + match *e { + Ev::Start => { + if self.wip < self.c.wip { + self.start_course(); + } else { + self.pending += 1; + } + self.at(DAY / self.c.starts_per_day, &Ev::Start); + } + Ev::ShortTick => { + let id = self.started_at.len() + self.shorts_done as usize; + self.at( + 20.0, + &Ev::Delay(Job { + short: true, + id, + step: 0, + }), + ); + self.at(DAY / self.c.shorts_per_day, &Ev::ShortTick); + } + Ev::Done(s, j) => { + self.st[s].free += 1; + self.dispatch(s); + if j.short { + self.short_next(j); + } else { + self.course_next(j); + } + } + Ev::Delay(j) if j.short => self.short_next(j), + Ev::Delay(j) => self.course_next(j), + Ev::Batch => { + for id in std::mem::take(&mut self.batch_wait) { + self.launch(id); + } + self.at(DAY, &Ev::Batch); + } + } + } +} + +/// Run one seed for `days`, discarding the first `warmup_d`. +#[must_use] +pub fn run_course_flow( + c: &CourseFlowConfig, + seed: u64, + days: f64, + warmup_d: f64, +) -> CourseFlowResult { + let size = [1, c.builders, c.design_lanes, c.gpus, 4, c.chrome_lanes]; + let mut s = Sim { + c: c.clone(), + rng: SimRng::new(seed), + sched: EventScheduler::new(), + now: 0.0, + warm: warmup_d * DAY, + st: size + .iter() + .map(|&n| Station { + free: n, + size: n, + hi: VecDeque::new(), + lo: VecDeque::new(), + busy_min: 0.0, + }) + .collect(), + wip: 0, + pending: 0, + started_at: Vec::new(), + coursera: Vec::new(), + batch_wait: Vec::new(), + sojourn: Vec::new(), + shorts_done: 0, + in_system: 0, + l_area: 0.0, + last_t: 0.0, + }; + s.at(0.0, &Ev::Start); + s.at(0.0, &Ev::ShortTick); + s.at(7.0 * 60.0, &Ev::Batch); + let end = days * DAY; + while let Some(ev) = s.sched.next() { + let t = ev.time.as_secs_f64() / 60.0; + if t > end { + break; + } + let from = s.last_t.max(s.warm); + if t > from { + s.l_area += s.in_system as f64 * (t - from); + } + s.last_t = t; + s.now = t; + if let Some(e) = unpack(&ev.event) { + s.step(&e); + } + } + let span = end - s.warm; + let n = s.sojourn.len() as f64; + let mut util = [0.0; 6]; + for (u, st) in util.iter_mut().zip(&s.st) { + *u = st.busy_min / (span * st.size as f64); + } + CourseFlowResult { + courses_per_day: n / span * DAY, + shorts_per_day: s.shorts_done as f64 / span * DAY, + lead_days: s.sojourn.iter().sum::() / n.max(1.0) / DAY, + l: s.l_area / span, + util, + } +} + +/// Named FLOW-006 factory cases. +#[must_use] +pub fn course_preset(name: &str) -> Option { + let mut c = CourseFlowConfig { + builders: 11, + build_min: 2520.0, + design_lanes: 2, + design_min: 180.0, + gpus: 1, + render_min: 100.0, + chrome_lanes: 2, + stage_min: 360.0, + coursera_share: 0.4, + wip: 24, + starts_per_day: 5.0, + shorts_per_day: 5.0, + outline_min: 90.0, + verify_min: 15.0, + }; + match name { + "cal-today" => { + c.builders = 1; + c.design_lanes = 1; + c.chrome_lanes = 1; + } + "design" => {} + "nine-builders" => c.builders = 9, + "coursera-24h" => c.stage_min = 1440.0, + "render-300" => c.render_min = 300.0, + _ => return None, + } + Some(c) +} + +/// Names of the FLOW-006 factory cases. +#[must_use] +pub fn course_cases() -> [&'static str; 5] { + [ + "cal-today", + "design", + "nine-builders", + "coursera-24h", + "render-300", + ] +} + +/// The reference run: seeds 1..=5, 300 days, 20 days warm-up, averaged. +#[must_use] +pub fn run_course_reference(c: &CourseFlowConfig) -> CourseFlowResult { + let mut a = CourseFlowResult::default(); + for seed in 1..=5 { + let r = run_course_flow(c, seed, 300.0, 20.0); + a.courses_per_day += r.courses_per_day / 5.0; + a.shorts_per_day += r.shorts_per_day / 5.0; + a.lead_days += r.lead_days / 5.0; + a.l += r.l / 5.0; + for i in 0..6 { + a.util[i] += r.util[i] / 5.0; + } + } + a +} + +/// Override the case with the ledger's measured inputs (a JSON object); unknown keys are refused. +/// +/// # Errors +/// Returns the offending key or type when the object holds something the model does not read. +pub fn apply_course_inputs(c: &mut CourseFlowConfig, v: &serde_json::Value) -> Result<(), String> { + let obj = v.as_object().ok_or("inputs must be a JSON object")?; + for (k, x) in obj { + let f = x.as_f64().ok_or_else(|| format!("{k}: not a number"))?; + match k.as_str() { + "builders" => c.builders = f as usize, + "build_min" => c.build_min = f, + "design_lanes" => c.design_lanes = f as usize, + "design_min" => c.design_min = f, + "gpus" => c.gpus = f as usize, + "render_min" => c.render_min = f, + "chrome_lanes" => c.chrome_lanes = f as usize, + "stage_min" => c.stage_min = f, + "coursera_share" => c.coursera_share = f, + "wip" => c.wip = f as usize, + "starts_per_day" => c.starts_per_day = f, + "shorts_per_day" => c.shorts_per_day = f, + _ => return Err(format!("unknown input: {k}")), + } + } + Ok(()) +} + +/// The `sim` block fields this scenario owns, in the shape the cops publish. +#[must_use] +pub fn course_sim_block( + case: &str, + c: &CourseFlowConfig, + r: &CourseFlowResult, +) -> serde_json::Value { + let (station, util) = r.busiest(); + serde_json::json!({ + "scenario": format!("course-flow:{case}"), + "seeds": 5, "hours": 300.0 * 24.0, "warmup_h": 20.0 * 24.0, + "inputs": { + "builders": c.builders, "build_min": c.build_min, "design_lanes": c.design_lanes, + "design_min": c.design_min, "gpus": c.gpus, "render_min": c.render_min, + "chrome_lanes": c.chrome_lanes, "stage_min": c.stage_min, + "coursera_share": c.coursera_share, "wip": c.wip, + "starts_per_day": c.starts_per_day, "shorts_per_day": c.shorts_per_day, + }, + "predicted": { + "courses_day": r.courses_per_day, "shorts_day": r.shorts_per_day, + "lead_days": r.lead_days, "L": r.l, "busiest_util_pct": util * 100.0, + }, + "binding_station": station, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FLOW-006 reference bands: (case, courses/day, tolerance). + const WANT: [(&str, f64, f64); 5] = [ + ("cal-today", 0.54, 0.10), + ("design", 4.92, 0.05), + ("nine-builders", 4.90, 0.05), + ("coursera-24h", 4.84, 0.05), + ("render-300", 4.70, 0.05), + ]; + + fn got(name: &str) -> CourseFlowResult { + run_course_reference(&course_preset(name).expect("case")) + } + + #[test] + fn courses_per_day_match_the_reference_bands() { + for (name, want, tol) in WANT { + let r = got(name); + assert!( + (r.courses_per_day - want).abs() <= want * tol, + "{name}: {:.3} vs {want}", + r.courses_per_day + ); + } + } + + #[test] + fn shorts_hold_five_a_day_in_every_case() { + for (name, _, _) in WANT { + let r = got(name); + assert!( + (r.shorts_per_day - 5.0).abs() <= 0.1, + "{name}: {:.2}", + r.shorts_per_day + ); + } + } + + #[test] + fn the_binding_station_is_the_one_the_case_squeezes() { + assert_eq!(got("nine-builders").busiest().0, "build"); + assert!(got("nine-builders").util[BUILD] > 0.95); + assert_eq!(got("render-300").busiest().0, "render"); + assert!(got("render-300").util[RENDER] > 0.95); + assert_eq!(got("coursera-24h").busiest().0, "chrome"); + } + + #[test] + fn littles_law_holds_within_half_a_percent() { + for (name, _, _) in WANT.iter().filter(|w| w.0 != "cal-today") { + let r = got(name); + let lam_w = r.courses_per_day * r.lead_days; + assert!( + (lam_w - r.l).abs() <= r.l * 0.005, + "{name}: {lam_w:.3} vs {:.3}", + r.l + ); + } + } + + #[test] + fn same_seed_same_output() { + let c = course_preset("design").expect("case"); + assert_eq!( + run_course_flow(&c, 4, 40.0, 5.0), + run_course_flow(&c, 4, 40.0, 5.0) + ); + } + + #[test] + fn a_reverted_design_shows_red() { + let mut c = course_preset("design").expect("case"); + c.builders = 1; + assert!((run_course_reference(&c).courses_per_day - 4.92).abs() > 4.92 * 0.05); + } + + #[test] + fn unknown_case_is_none() { + assert!(course_preset("nope").is_none()); + } +} diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index 483d97ffec..732712e945 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -10,6 +10,7 @@ pub mod bouncing_balls; pub mod climate; +pub mod course_flow; pub mod epidemic; pub mod pendulum; pub mod portfolio; @@ -20,6 +21,10 @@ pub mod satellite; pub use bouncing_balls::{BouncingBallsConfig, BouncingBallsState}; pub use climate::{ClimateConfig, ClimateScenario}; +pub use course_flow::{ + apply_course_inputs, course_cases, course_preset, course_sim_block, run_course_flow, + run_course_reference, CourseFlowConfig, CourseFlowResult, +}; pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index b9f57a7a8c..a18d6f0637 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -277,11 +277,10 @@ impl Sim { self.at(self.c.t_fix_min, Ev::Fixed(pr)); } Ev::PrRun(pr) => self.at(self.c.t_arm_min, Ev::Armed(pr)), - Ev::Fixed(pr) => self.start_run(pr), + Ev::Fixed(pr) | Ev::Rebased(pr) => self.start_run(pr), Ev::Armed(pr) if self.c.p_dirty > 0.0 && self.rng.gen_f64() < self.c.p_dirty => { self.at(self.c.t_rebase_min, Ev::Rebased(pr)); } - Ev::Rebased(pr) => self.start_run(pr), Ev::Armed(pr) if self.c.builds == 0 => self.merge(pr), Ev::Armed(pr) => { self.enq_at[pr] = self.now; diff --git a/crates/aprender-simulate/src/scenarios/repo_flow.rs b/crates/aprender-simulate/src/scenarios/repo_flow.rs index c95399f6d1..ae9d381161 100644 --- a/crates/aprender-simulate/src/scenarios/repo_flow.rs +++ b/crates/aprender-simulate/src/scenarios/repo_flow.rs @@ -1,4 +1,6 @@ -//! Scenario "repo-flow": the FLOW-006 repo template (SIM-6, #5511). The PR-flow line with the +//! Scenario "repo-flow": the FLOW-006 repo template (SIM-6, #5511). +//! +//! The PR-flow line with the //! merge queue optional (`builds == 0` merges on arm) and the conflict step: an armed PR is found //! dirty with `p_dirty` and waits `t_rebase_min` for a rebase and a rerun. One parameter file per //! repo; inputs come from the KPI report, never typed. @@ -116,6 +118,57 @@ pub fn run_repo_reference(c: &PrFlowConfig) -> PrFlowResult { acc } +/// Override the case with the ledger's measured inputs (a JSON object); unknown keys are refused. +/// +/// # Errors +/// Returns the offending key or type when the object holds something the model does not read. +pub fn apply_inputs(c: &mut PrFlowConfig, v: &serde_json::Value) -> Result<(), String> { + let obj = v.as_object().ok_or("inputs must be a JSON object")?; + for (k, x) in obj { + let f = x.as_f64().ok_or_else(|| format!("{k}: not a number"))?; + match k.as_str() { + "cap" => c.cap = f as usize, + "N" => c.n = f as usize, + "t_author_min" => c.t_author_min = f, + "R_pr" => c.r_pr = f as usize, + "pr_runner_min" => c.pr_runner_min = f, + "p_red" => c.p_red = f, + "t_fix_min" => c.t_fix_min = f, + "t_arm_min" => c.t_arm_min = f, + "p_dirty" => c.p_dirty = f, + "t_rebase_min" => c.t_rebase_min = f, + _ => return Err(format!("unknown input: {k}")), + } + } + Ok(()) +} + +/// The `sim` block fields this scenario owns: scenario, seeds, hours, inputs, predicted, binding. +#[must_use] +pub fn sim_block(case: &str, c: &PrFlowConfig, r: &PrFlowResult) -> serde_json::Value { + let binding = if r.pr_util > 0.85 { + "ci_pool" + } else if r.l >= c.cap as f64 * 0.95 { + "cap" + } else { + "supply" + }; + serde_json::json!({ + "scenario": format!("repo-flow:{case}"), + "seeds": 5, "hours": 400, "warmup_h": 20, + "inputs": { + "cap": c.cap, "N": c.n, "t_author_min": c.t_author_min, "R_pr": c.r_pr, + "pr_runner_min": c.pr_runner_min, "p_red": c.p_red, "t_fix_min": c.t_fix_min, + "t_arm_min": c.t_arm_min, "p_dirty": c.p_dirty, "t_rebase_min": c.t_rebase_min, + }, + "predicted": { + "merged_h": r.merged_per_h, "W_min_mean": r.w_mean_min, "W_min_p90": r.w_p90_min, + "L": r.l, "pr_pool_util_pct": r.pr_util * 100.0, "runs_per_pr": r.runs_per_pr, + }, + "binding_station": binding, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -194,54 +247,3 @@ mod tests { assert!(repo_preset("nope").is_none()); } } - -/// Override the case with the ledger's measured inputs (a JSON object); unknown keys are refused. -/// -/// # Errors -/// Returns the offending key or type when the object holds something the model does not read. -pub fn apply_inputs(c: &mut PrFlowConfig, v: &serde_json::Value) -> Result<(), String> { - let obj = v.as_object().ok_or("inputs must be a JSON object")?; - for (k, x) in obj { - let f = x.as_f64().ok_or_else(|| format!("{k}: not a number"))?; - match k.as_str() { - "cap" => c.cap = f as usize, - "N" => c.n = f as usize, - "t_author_min" => c.t_author_min = f, - "R_pr" => c.r_pr = f as usize, - "pr_runner_min" => c.pr_runner_min = f, - "p_red" => c.p_red = f, - "t_fix_min" => c.t_fix_min = f, - "t_arm_min" => c.t_arm_min = f, - "p_dirty" => c.p_dirty = f, - "t_rebase_min" => c.t_rebase_min = f, - _ => return Err(format!("unknown input: {k}")), - } - } - Ok(()) -} - -/// The `sim` block fields this scenario owns: scenario, seeds, hours, inputs, predicted, binding. -#[must_use] -pub fn sim_block(case: &str, c: &PrFlowConfig, r: &PrFlowResult) -> serde_json::Value { - let binding = if r.pr_util > 0.85 { - "ci_pool" - } else if r.l >= c.cap as f64 * 0.95 { - "cap" - } else { - "supply" - }; - serde_json::json!({ - "scenario": format!("repo-flow:{case}"), - "seeds": 5, "hours": 400, "warmup_h": 20, - "inputs": { - "cap": c.cap, "N": c.n, "t_author_min": c.t_author_min, "R_pr": c.r_pr, - "pr_runner_min": c.pr_runner_min, "p_red": c.p_red, "t_fix_min": c.t_fix_min, - "t_arm_min": c.t_arm_min, "p_dirty": c.p_dirty, "t_rebase_min": c.t_rebase_min, - }, - "predicted": { - "merged_h": r.merged_per_h, "W_min_mean": r.w_mean_min, "W_min_p90": r.w_p90_min, - "L": r.l, "pr_pool_util_pct": r.pr_util * 100.0, "runs_per_pr": r.runs_per_pr, - }, - "binding_station": binding, - }) -} diff --git a/crates/aprender-simulate/tests/cli_surface_gate.rs b/crates/aprender-simulate/tests/cli_surface_gate.rs index 0210f97578..6a8109dce8 100644 --- a/crates/aprender-simulate/tests/cli_surface_gate.rs +++ b/crates/aprender-simulate/tests/cli_surface_gate.rs @@ -18,6 +18,9 @@ const UNKNOWN_SUB: &[&str] = &["no-such-subcommand"]; const SUBCOMMANDS: &[&str] = &[ "run", "render", + "pr-flow", + "repo-flow", + "course-flow", "validate", "verify", "emc-check", From 8c929734aeda976c67085dd2ef12d5dd8259aa29 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 13:56:34 +0200 Subject: [PATCH 04/24] feat(simulate): pr-flow --json prints the cycle-record sim block; --r-pr/--r-mq/--sha inputs (Refs #5511) keep-open: #5511 ont-delta: none Agent: aprender-w5404 (claude-sonnet-5-5) Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 12 +++ crates/aprender-simulate/src/cli/commands.rs | 89 ++++++++++++++++++- crates/aprender-simulate/src/scenarios/mod.rs | 4 +- .../src/scenarios/pr_flow.rs | 10 +++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 30114ccb78..c27c3bb7f4 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -100,6 +100,18 @@ pub enum Commands { /// Design: D, A, G or E. #[arg(long, default_value = "D", value_parser = ["D", "A", "G", "E"])] design: String, + /// Print the cycle-record "sim" block as one JSON line. + #[arg(long)] + json: bool, + /// Override the PR-check runner pool (measured input). + #[arg(long)] + r_pr: Option, + /// Override the merge-queue runner pool (measured input). + #[arg(long)] + r_mq: Option, + /// Commit the sim block is stamped with. + #[arg(long, default_value = "unknown")] + sha: String, }, /// Validate experiment YAML against EDD v2 schema Validate { diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 0db65c1de4..727eb53ae4 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -40,7 +40,13 @@ pub fn run_cli(cli: Cli) -> ExitCode { duration, seed, } => render_svg(&domain, &format, &output, fps, duration, seed), - Commands::PrFlow { design } => pr_flow(&design), + Commands::PrFlow { + design, + json, + r_pr, + r_mq, + sha, + } => pr_flow(&design, json, r_pr, r_mq, &sha), Commands::Validate { experiment_path } => validate_experiment(&experiment_path), Commands::Verify { experiment_path, @@ -567,11 +573,23 @@ fn render_bouncing_balls( } /// Print the reference run (5 seeds x 400 h, 20 h warm-up) of a named PR-flow design. -fn pr_flow(design: &str) -> ExitCode { - let Some(cfg) = crate::scenarios::preset(design) else { +fn pr_flow( + design: &str, + json: bool, + r_pr: Option, + r_mq: Option, + sha: &str, +) -> ExitCode { + let Some(mut cfg) = crate::scenarios::preset(design) else { eprintln!("unknown design: {design}"); return ExitCode::FAILURE; }; + cfg.r_pr = r_pr.unwrap_or(cfg.r_pr); + cfg.r_mq = r_mq.unwrap_or(cfg.r_mq); + if json { + println!("{}", pr_flow_sim_block(design, &cfg, r_pr, r_mq, sha)); + return ExitCode::SUCCESS; + } let r = crate::scenarios::run_reference(&cfg); println!( "design {design}: merged/h {:.2} W mean {:.1} min W p90 {:.1} min L {:.2}", @@ -586,3 +604,68 @@ fn pr_flow(design: &str) -> ExitCode { ); ExitCode::SUCCESS } + +/// The cycle-record "sim" block: seeds 1..=5, 400 h, 20 h warm-up, one JSON line. +/// `optimum` and `drift_pct_24h` are null: neither is computed here. +fn pr_flow_sim_block( + design: &str, + cfg: &crate::scenarios::PrFlowConfig, + r_pr: Option, + r_mq: Option, + sha: &str, +) -> String { + let runs: Vec<_> = (1..=5) + .map(|s| crate::scenarios::run_pr_flow(cfg, s, 400.0, 20.0)) + .collect(); + let n = runs.len() as f64; + let mean = |f: fn(&crate::scenarios::PrFlowResult) -> f64| runs.iter().map(f).sum::() / n; + let m = mean(|r| r.merged_per_h); + let var = runs + .iter() + .map(|r| (r.merged_per_h - m).powi(2)) + .sum::() + / (n - 1.0); + let ci95 = 1.96 * (var / n).sqrt(); + let binding = if mean(|r| r.pr_util) >= mean(|r| r.mq_util) { + "pr_checks" + } else { + "merge_queue" + }; + let contract = crate::scenarios::reference_contract_ok(); + let src = |o: Option| { + if o.is_some() { + "measured" + } else { + "design_default" + } + }; + serde_json::json!({ + "aprender_sha": sha, + "scenario": "pr-flow", + "cli": format!( + "aprender-simulate pr-flow --design {design} --json --r-pr {} --r-mq {} --sha {sha}", + cfg.r_pr, cfg.r_mq + ), + "seeds": [1, 2, 3, 4, 5], + "hours": 400, + "inputs": { + "design": design, "cap": cfg.cap, "n": cfg.n, "t_author_min": cfg.t_author_min, + "r_pr": cfg.r_pr, "r_mq": cfg.r_mq, "builds": cfg.builds, "p_red": cfg.p_red, + "pr_runner_min": cfg.pr_runner_min, "f": cfg.f + }, + "inputs_source": { + "t_author_min": "design_default", "n": "design_default", "cap": "design_default", + "r_pr": src(r_pr), "r_mq": src(r_mq), "p_red": "design_default", + "pr_runner_min": "design_default", "f": "design_default", "builds": "design_default" + }, + "predicted": { + "merged_h": m, "ci95_h": ci95, + "W_min_mean": mean(|r| r.w_mean_min), "L": mean(|r| r.l) + }, + "binding_station": binding, + "optimum": null, + "sim3_contract": if contract { "pass" } else { "fail" }, + "drift_pct_24h": null + }) + .to_string() +} diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index a487fb73ef..8ddcc7dbc9 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -22,6 +22,8 @@ pub use climate::{ClimateConfig, ClimateScenario}; pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; -pub use pr_flow::{preset, run_pr_flow, run_reference, PrFlowConfig, PrFlowResult}; +pub use pr_flow::{ + preset, reference_contract_ok, run_pr_flow, run_reference, PrFlowConfig, PrFlowResult, +}; pub use rocket::{RocketConfig, RocketScenario, StageSeparation}; pub use satellite::{OrbitalElements, SatelliteScenario}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index 21c226858f..7d86e85c29 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -415,6 +415,16 @@ pub fn run_reference(c: &PrFlowConfig) -> PrFlowResult { acc } +/// SIM-3 contract: every design's merged/h is within 5% of its reference (D 20.08, A 4.96, G 12.55, E 21.37). +#[must_use] +pub fn reference_contract_ok() -> bool { + [("D", 20.08), ("A", 4.96), ("G", 12.55), ("E", 21.37)] + .iter() + .all(|(d, want)| { + preset(d).is_some_and(|c| (run_reference(&c).merged_per_h - want).abs() <= want * 0.05) + }) +} + #[cfg(test)] mod tests { use super::*; From d5ed1109e8a1949890124b4056a8f2557629714d Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:10:57 +0200 Subject: [PATCH 05/24] feat(simulate): fractional N and measured-input overrides (--cap --builds --f --n); sim block gets optimum (Refs #5511) keep-open: #5511 ont-delta: none Agent: aprender-w5404 (claude-sonnet-5-5) Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 12 +++ crates/aprender-simulate/src/cli/commands.rs | 94 +++++++++++++------ .../src/scenarios/pr_flow.rs | 35 ++++++- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index c27c3bb7f4..89724380da 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -109,6 +109,18 @@ pub enum Commands { /// Override the merge-queue runner pool (measured input). #[arg(long)] r_mq: Option, + /// Override the WIP cap (measured input). + #[arg(long)] + cap: Option, + /// Override concurrent merge-queue groups (measured input). + #[arg(long)] + builds: Option, + /// Override the merge-group flake rate (measured input). + #[arg(long)] + f: Option, + /// Override mean concurrent authors (may be fractional). + #[arg(long)] + n: Option, /// Commit the sim block is stamped with. #[arg(long, default_value = "unknown")] sha: String, diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 727eb53ae4..45bf6384c6 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -45,8 +45,24 @@ pub fn run_cli(cli: Cli) -> ExitCode { json, r_pr, r_mq, + cap, + builds, + f, + n, sha, - } => pr_flow(&design, json, r_pr, r_mq, &sha), + } => pr_flow( + &design, + json, + FlowOverrides { + r_pr, + r_mq, + cap, + builds, + f, + n, + }, + &sha, + ), Commands::Validate { experiment_path } => validate_experiment(&experiment_path), Commands::Verify { experiment_path, @@ -572,22 +588,49 @@ fn render_bouncing_balls( Ok(()) } -/// Print the reference run (5 seeds x 400 h, 20 h warm-up) of a named PR-flow design. -fn pr_flow( - design: &str, - json: bool, +/// Measured inputs that replace a design's defaults; each is labelled `measured` in the sim block. +#[derive(Default, Clone, Copy)] +struct FlowOverrides { r_pr: Option, r_mq: Option, - sha: &str, -) -> ExitCode { + cap: Option, + builds: Option, + f: Option, + n: Option, +} + +impl FlowOverrides { + fn apply(&self, c: &mut crate::scenarios::PrFlowConfig) { + c.r_pr = self.r_pr.unwrap_or(c.r_pr); + c.r_mq = self.r_mq.unwrap_or(c.r_mq); + c.cap = self.cap.unwrap_or(c.cap); + c.builds = self.builds.unwrap_or(c.builds); + c.f = self.f.unwrap_or(c.f); + c.n = self.n.unwrap_or(c.n); + } + + fn cli_flags(&self) -> String { + let parts = [ + self.r_pr.map(|v| format!(" --r-pr {v}")), + self.r_mq.map(|v| format!(" --r-mq {v}")), + self.cap.map(|v| format!(" --cap {v}")), + self.builds.map(|v| format!(" --builds {v}")), + self.f.map(|v| format!(" --f {v}")), + self.n.map(|v| format!(" --n {v}")), + ]; + parts.into_iter().flatten().collect() + } +} + +/// Print the reference run (5 seeds x 400 h, 20 h warm-up) of a named PR-flow design. +fn pr_flow(design: &str, json: bool, ov: FlowOverrides, sha: &str) -> ExitCode { let Some(mut cfg) = crate::scenarios::preset(design) else { eprintln!("unknown design: {design}"); return ExitCode::FAILURE; }; - cfg.r_pr = r_pr.unwrap_or(cfg.r_pr); - cfg.r_mq = r_mq.unwrap_or(cfg.r_mq); + ov.apply(&mut cfg); if json { - println!("{}", pr_flow_sim_block(design, &cfg, r_pr, r_mq, sha)); + println!("{}", pr_flow_sim_block(design, &cfg, &ov, sha)); return ExitCode::SUCCESS; } let r = crate::scenarios::run_reference(&cfg); @@ -606,12 +649,11 @@ fn pr_flow( } /// The cycle-record "sim" block: seeds 1..=5, 400 h, 20 h warm-up, one JSON line. -/// `optimum` and `drift_pct_24h` are null: neither is computed here. +/// `optimum` compares the design's own reference run with this run; `drift_pct_24h` is null (not computed here). fn pr_flow_sim_block( design: &str, cfg: &crate::scenarios::PrFlowConfig, - r_pr: Option, - r_mq: Option, + ov: &FlowOverrides, sha: &str, ) -> String { let runs: Vec<_> = (1..=5) @@ -631,21 +673,14 @@ fn pr_flow_sim_block( } else { "merge_queue" }; + let base = crate::scenarios::preset(design) + .map_or(0.0, |c| crate::scenarios::run_reference(&c).merged_per_h); let contract = crate::scenarios::reference_contract_ok(); - let src = |o: Option| { - if o.is_some() { - "measured" - } else { - "design_default" - } - }; + let src = |m: bool| if m { "measured" } else { "design_default" }; serde_json::json!({ "aprender_sha": sha, "scenario": "pr-flow", - "cli": format!( - "aprender-simulate pr-flow --design {design} --json --r-pr {} --r-mq {} --sha {sha}", - cfg.r_pr, cfg.r_mq - ), + "cli": format!("aprender-simulate pr-flow --design {design} --json{} --sha {sha}", ov.cli_flags()), "seeds": [1, 2, 3, 4, 5], "hours": 400, "inputs": { @@ -654,16 +689,19 @@ fn pr_flow_sim_block( "pr_runner_min": cfg.pr_runner_min, "f": cfg.f }, "inputs_source": { - "t_author_min": "design_default", "n": "design_default", "cap": "design_default", - "r_pr": src(r_pr), "r_mq": src(r_mq), "p_red": "design_default", - "pr_runner_min": "design_default", "f": "design_default", "builds": "design_default" + "t_author_min": "design_default", "n": src(ov.n.is_some()), "cap": src(ov.cap.is_some()), + "r_pr": src(ov.r_pr.is_some()), "r_mq": src(ov.r_mq.is_some()), "p_red": "design_default", + "pr_runner_min": "design_default", "f": src(ov.f.is_some()), "builds": src(ov.builds.is_some()) }, "predicted": { "merged_h": m, "ci95_h": ci95, "W_min_mean": mean(|r| r.w_mean_min), "L": mean(|r| r.l) }, "binding_station": binding, - "optimum": null, + "optimum": { + "design": design, "design_merged_h": base, "this_run_merged_h": m, + "gap_h": base - m, "gap_pct": if base > 0.0 { (base - m) / base * 100.0 } else { 0.0 } + }, "sim3_contract": if contract { "pass" } else { "fail" }, "drift_pct_24h": null }) diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index 7d86e85c29..10d3ca55b8 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -16,7 +16,8 @@ use crate::engine::SimTime; #[derive(Debug, Clone, PartialEq)] pub struct PrFlowConfig { pub cap: usize, - pub n: usize, + /// Mean concurrent authors; the fractional part is one author who thinks 1/frac times slower. + pub n: f64, pub t_author_min: f64, pub r_pr: usize, pub pr_runner_min: f64, @@ -35,6 +36,18 @@ pub struct PrFlowConfig { pub f: f64, } +impl PrFlowConfig { + /// Mean think time of author `a`: the last, fractional author is slowed by its fraction. + fn think_min(&self, a: usize) -> f64 { + let frac = self.n - self.n.floor(); + if frac > 0.0 && a == self.n.floor() as usize { + self.t_author_min / frac + } else { + self.t_author_min + } + } +} + /// What one seeded run measured after the warm-up. #[derive(Debug, Clone, Default, PartialEq)] pub struct PrFlowResult { @@ -183,7 +196,7 @@ impl Sim { self.open += 1; self.prs_seen += 1; self.start_run(pr); - let t = self.exp(self.c.t_author_min); + let t = self.exp(self.c.think_min(author)); self.at(t, Ev::Author(author)); } @@ -319,8 +332,8 @@ pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> Pr passes: 0, }; s.timer_at = -1.0; - for a in 0..c.n { - let t = s.exp(c.t_author_min); + for a in 0..c.n.ceil() as usize { + let t = s.exp(c.think_min(a)); s.at(t, Ev::Author(a)); } let end = hours * 60.0; @@ -377,7 +390,7 @@ pub fn preset(name: &str) -> Option { let setup = if fast { 9.0 } else { 7.7 }; Some(PrFlowConfig { cap, - n: 11, + n: 11.0, t_author_min: 30.0, r_pr, pr_runner_min: rm, @@ -435,6 +448,18 @@ mod tests { (got - want).abs() <= want * tol } + #[test] + fn fractional_n_sits_between_its_integer_neighbours() { + let at = |n: f64| { + let mut c = preset("D").expect("preset"); + c.n = n; + c.cap = 100; + run_reference(&c).merged_per_h + }; + let (lo, mid, hi) = (at(11.0), at(11.5), at(12.0)); + assert!(lo < mid && mid < hi, "{lo} {mid} {hi}"); + } + #[test] fn merges_per_hour_match_the_reference_within_5_percent() { for (name, want) in WANT { From 65daf416ec5ab1474f5e88c41f7fd28480b85179 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:17:26 +0200 Subject: [PATCH 06/24] feat(simulate): pr-flow --pr-min --mq-min --p-red --pr-setup measured-input overrides (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 12 ++++++++++++ crates/aprender-simulate/src/cli/commands.rs | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 89724380da..9d5245d753 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -121,6 +121,18 @@ pub enum Commands { /// Override mean concurrent authors (may be fractional). #[arg(long)] n: Option, + /// Measured PR-check runner-minutes per run. + #[arg(long)] + pr_min: Option, + /// Measured merge-queue runner-minutes per run. + #[arg(long)] + mq_min: Option, + /// Measured share of PR runs that go red. + #[arg(long)] + p_red: Option, + /// Measured queue wait per PR run, minutes. + #[arg(long)] + pr_setup: Option, /// Commit the sim block is stamped with. #[arg(long, default_value = "unknown")] sha: String, diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 45bf6384c6..caa22d85d0 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -49,6 +49,10 @@ pub fn run_cli(cli: Cli) -> ExitCode { builds, f, n, + pr_min, + mq_min, + p_red, + pr_setup, sha, } => pr_flow( &design, @@ -60,6 +64,10 @@ pub fn run_cli(cli: Cli) -> ExitCode { builds, f, n, + pr_min, + mq_min, + p_red, + pr_setup, }, &sha, ), @@ -597,6 +605,10 @@ struct FlowOverrides { builds: Option, f: Option, n: Option, + pr_min: Option, + mq_min: Option, + p_red: Option, + pr_setup: Option, } impl FlowOverrides { @@ -607,6 +619,10 @@ impl FlowOverrides { c.builds = self.builds.unwrap_or(c.builds); c.f = self.f.unwrap_or(c.f); c.n = self.n.unwrap_or(c.n); + c.pr_runner_min = self.pr_min.unwrap_or(c.pr_runner_min); + c.mq_runner_min = self.mq_min.unwrap_or(c.mq_runner_min); + c.p_red = self.p_red.unwrap_or(c.p_red); + c.pr_setup_min = self.pr_setup.unwrap_or(c.pr_setup_min); } fn cli_flags(&self) -> String { @@ -617,6 +633,10 @@ impl FlowOverrides { self.builds.map(|v| format!(" --builds {v}")), self.f.map(|v| format!(" --f {v}")), self.n.map(|v| format!(" --n {v}")), + self.pr_min.map(|v| format!(" --pr-min {v}")), + self.mq_min.map(|v| format!(" --mq-min {v}")), + self.p_red.map(|v| format!(" --p-red {v}")), + self.pr_setup.map(|v| format!(" --pr-setup {v}")), ]; parts.into_iter().flatten().collect() } From 2585f502b08d516812409b9ae1bdd07f6c50aedb Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:31:55 +0200 Subject: [PATCH 07/24] feat(simulate): pr-flow --observed-24h --src-runs; sim block labels measured inputs and sets drift_pct_24h (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 6 +++++ crates/aprender-simulate/src/cli/commands.rs | 27 ++++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 9d5245d753..fd32f8d791 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -133,6 +133,12 @@ pub enum Commands { /// Measured queue wait per PR run, minutes. #[arg(long)] pr_setup: Option, + /// Merged PRs in the last 24 h, from git (for drift_pct_24h). + #[arg(long)] + observed_24h: Option, + /// Comma-separated run ids the measured inputs came from. + #[arg(long)] + src_runs: Option, /// Commit the sim block is stamped with. #[arg(long, default_value = "unknown")] sha: String, diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index caa22d85d0..0c647875a2 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -53,11 +53,13 @@ pub fn run_cli(cli: Cli) -> ExitCode { mq_min, p_red, pr_setup, + observed_24h: obs24, + src_runs: runs, sha, } => pr_flow( &design, json, - FlowOverrides { + &FlowOverrides { r_pr, r_mq, cap, @@ -68,6 +70,8 @@ pub fn run_cli(cli: Cli) -> ExitCode { mq_min, p_red, pr_setup, + obs24, + runs, }, &sha, ), @@ -597,7 +601,7 @@ fn render_bouncing_balls( } /// Measured inputs that replace a design's defaults; each is labelled `measured` in the sim block. -#[derive(Default, Clone, Copy)] +#[derive(Default, Clone)] struct FlowOverrides { r_pr: Option, r_mq: Option, @@ -609,6 +613,8 @@ struct FlowOverrides { mq_min: Option, p_red: Option, pr_setup: Option, + obs24: Option, + runs: Option, } impl FlowOverrides { @@ -637,20 +643,22 @@ impl FlowOverrides { self.mq_min.map(|v| format!(" --mq-min {v}")), self.p_red.map(|v| format!(" --p-red {v}")), self.pr_setup.map(|v| format!(" --pr-setup {v}")), + self.obs24.map(|v| format!(" --observed-24h {v}")), + self.runs.as_ref().map(|v| format!(" --src-runs {v}")), ]; parts.into_iter().flatten().collect() } } /// Print the reference run (5 seeds x 400 h, 20 h warm-up) of a named PR-flow design. -fn pr_flow(design: &str, json: bool, ov: FlowOverrides, sha: &str) -> ExitCode { +fn pr_flow(design: &str, json: bool, ov: &FlowOverrides, sha: &str) -> ExitCode { let Some(mut cfg) = crate::scenarios::preset(design) else { eprintln!("unknown design: {design}"); return ExitCode::FAILURE; }; ov.apply(&mut cfg); if json { - println!("{}", pr_flow_sim_block(design, &cfg, &ov, sha)); + println!("{}", pr_flow_sim_block(design, &cfg, ov, sha)); return ExitCode::SUCCESS; } let r = crate::scenarios::run_reference(&cfg); @@ -706,12 +714,14 @@ fn pr_flow_sim_block( "inputs": { "design": design, "cap": cfg.cap, "n": cfg.n, "t_author_min": cfg.t_author_min, "r_pr": cfg.r_pr, "r_mq": cfg.r_mq, "builds": cfg.builds, "p_red": cfg.p_red, - "pr_runner_min": cfg.pr_runner_min, "f": cfg.f + "pr_runner_min": cfg.pr_runner_min, "mq_runner_min": cfg.mq_runner_min, + "pr_setup_min": cfg.pr_setup_min, "f": cfg.f }, "inputs_source": { "t_author_min": "design_default", "n": src(ov.n.is_some()), "cap": src(ov.cap.is_some()), - "r_pr": src(ov.r_pr.is_some()), "r_mq": src(ov.r_mq.is_some()), "p_red": "design_default", - "pr_runner_min": "design_default", "f": src(ov.f.is_some()), "builds": src(ov.builds.is_some()) + "r_pr": src(ov.r_pr.is_some()), "r_mq": src(ov.r_mq.is_some()), "p_red": src(ov.p_red.is_some()), + "pr_runner_min": src(ov.pr_min.is_some()), "mq_runner_min": src(ov.mq_min.is_some()), + "pr_setup_min": src(ov.pr_setup.is_some()), "f": src(ov.f.is_some()), "builds": src(ov.builds.is_some()) }, "predicted": { "merged_h": m, "ci95_h": ci95, @@ -723,7 +733,8 @@ fn pr_flow_sim_block( "gap_h": base - m, "gap_pct": if base > 0.0 { (base - m) / base * 100.0 } else { 0.0 } }, "sim3_contract": if contract { "pass" } else { "fail" }, - "drift_pct_24h": null + "observed_24h": ov.obs24, "source_runs": ov.runs, + "drift_pct_24h": ov.obs24.map(|o| (m - o / 24.0) / (o / 24.0) * 100.0) }) .to_string() } From 356c9b2a68fcaeedef9c4c7e50eb8af886fba7d7 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:35:33 +0200 Subject: [PATCH 08/24] test(simulate): SIM-3 pr-flow contract through the binary (bands, Little's law, same bytes); pin pr-flow in the surface gate (Refs #5511) Mutant r_mq 4->1 on design D reads 7.36 vs 20.08 and the test goes red; reverted. Co-Authored-By: Claude Sonnet 5.5 --- ...763-aprender-simulate-pr-flow-contract.cmd | 1 + ...render-simulate--aprender-simulate-v1.yaml | 5 +- .../tests/cli_surface_gate.rs | 1 + .../tests/pr_flow_contract.rs | 62 +++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 ci/explicit-test-commands.d/763-aprender-simulate-pr-flow-contract.cmd create mode 100644 crates/aprender-simulate/tests/pr_flow_contract.rs diff --git a/ci/explicit-test-commands.d/763-aprender-simulate-pr-flow-contract.cmd b/ci/explicit-test-commands.d/763-aprender-simulate-pr-flow-contract.cmd new file mode 100644 index 0000000000..836bbe9141 --- /dev/null +++ b/ci/explicit-test-commands.d/763-aprender-simulate-pr-flow-contract.cmd @@ -0,0 +1 @@ +cargo test -p aprender-simulate --test pr_flow_contract diff --git a/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml b/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml index 7a81ab271c..83ae3eaf67 100644 --- a/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml +++ b/contracts/bin-aprender-simulate--aprender-simulate-v1.yaml @@ -69,13 +69,14 @@ shapes: # G0.1 — a violation since batch/ont-10 2026-09-27 (build.rs embeds APR_GIT_SHA); was a warning while RED - {path: "bin:versionString", minCount: 1, maxCount: 1, pattern: "[0-9a-f]{9}"} - path: bin:command - minCount: 9 - maxCount: 9 + minCount: 10 + maxCount: 10 in: - "emc-check" - "emc-validate" - "help" - "list-emc" + - "pr-flow" - "render" - "run" - "validate" diff --git a/crates/aprender-simulate/tests/cli_surface_gate.rs b/crates/aprender-simulate/tests/cli_surface_gate.rs index 0210f97578..2d3aad72f6 100644 --- a/crates/aprender-simulate/tests/cli_surface_gate.rs +++ b/crates/aprender-simulate/tests/cli_surface_gate.rs @@ -18,6 +18,7 @@ const UNKNOWN_SUB: &[&str] = &["no-such-subcommand"]; const SUBCOMMANDS: &[&str] = &[ "run", "render", + "pr-flow", "validate", "verify", "emc-check", diff --git a/crates/aprender-simulate/tests/pr_flow_contract.rs b/crates/aprender-simulate/tests/pr_flow_contract.rs new file mode 100644 index 0000000000..e4030a70c6 --- /dev/null +++ b/crates/aprender-simulate/tests/pr_flow_contract.rs @@ -0,0 +1,62 @@ +//! SIM-3 (FLOW-005, #5511): the `pr-flow` scenario is a contract, run through the shipped binary. +//! +//! Each design must land inside its reference band, Little's law must close, and the same +//! seeds must give the same bytes. A scenario that returned a constant would fail the band for +//! three of the four designs; a scenario that read a clock or an unseeded RNG would fail the +//! byte comparison. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_aprender-simulate"); + +/// Design, reference merged/h (the +-5% band is the contract). +const WANT: [(&str, f64); 4] = [("D", 20.08), ("A", 4.96), ("G", 12.55), ("E", 21.37)]; + +fn block(design: &str) -> String { + let out = Command::new(BIN) + .args(["pr-flow", "--design", design, "--json", "--sha", "test"]) + .output() + .expect("spawn the binary under test"); + assert!( + out.status.success(), + "pr-flow {design} exited {:?}", + out.status + ); + String::from_utf8(out.stdout).expect("utf-8 json") +} + +fn num(json: &serde_json::Value, path: &[&str]) -> f64 { + path.iter() + .fold(json, |v, k| &v[*k]) + .as_f64() + .unwrap_or(f64::NAN) +} + +#[test] +fn every_design_is_inside_its_reference_band_and_littles_law_closes() { + for (design, want) in WANT { + let v: serde_json::Value = serde_json::from_str(&block(design)).expect("json"); + let got = num(&v, &["predicted", "merged_h"]); + assert!( + (got - want).abs() <= want * 0.05, + "{design}: {got:.2} vs {want}" + ); + let (w_min, l) = ( + num(&v, &["predicted", "W_min_mean"]), + num(&v, &["predicted", "L"]), + ); + let lam_w = got * w_min / 60.0; + assert!( + (lam_w - l).abs() <= l * 0.005, + "{design}: lambda*W {lam_w:.3} vs L {l:.3}" + ); + assert_eq!(v["sim3_contract"], "pass", "{design}"); + } +} + +#[test] +fn same_seeds_give_the_same_bytes() { + for (design, _) in WANT { + assert_eq!(block(design), block(design), "{design}"); + } +} From 07b98fb503be69e31a5099009d1019e4dce04b73 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:39:03 +0200 Subject: [PATCH 09/24] feat(simulate): SIM-2 pr-flow --params (arbiter per-repo params -> actual, one_change, optimum), one adapter, test first (Refs #5511) Params shape is provisional until infra pins arbiter-sim-params-v1; only parse_params knows it. p_dirty and t_rebase are accepted and reported as unmodelled until SIM-6. Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 13 + crates/aprender-simulate/src/cli/commands.rs | 66 ++-- .../src/scenarios/arbiter.rs | 298 ++++++++++++++++++ crates/aprender-simulate/src/scenarios/mod.rs | 2 + .../tests/pr_flow_contract.rs | 35 ++ 5 files changed, 395 insertions(+), 19 deletions(-) create mode 100644 crates/aprender-simulate/src/scenarios/arbiter.rs diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index fd32f8d791..800044f55d 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -62,6 +62,7 @@ impl Cli { /// Available CLI commands. #[derive(Debug, Clone, PartialEq, Subcommand)] +#[allow(clippy::large_enum_variant)] // PrFlow carries the measured-input overrides; parsed once at startup pub enum Commands { /// Run an experiment Run { @@ -139,6 +140,18 @@ pub enum Commands { /// Comma-separated run ids the measured inputs came from. #[arg(long)] src_runs: Option, + /// Arbiter params file for one repo (ARB-SOT-001 §3); prints {actual, one_change, optimum}. + #[arg(long)] + params: Option, + /// Seeds 1..=N for `--params`. + #[arg(long, default_value_t = 5)] + seeds: u64, + /// Simulated hours per seed for `--params`. + #[arg(long, default_value_t = 400.0)] + hours: f64, + /// Warm-up hours discarded for `--params`. + #[arg(long, default_value_t = 20.0)] + warmup: f64, /// Commit the sim block is stamped with. #[arg(long, default_value = "unknown")] sha: String, diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 0c647875a2..b317c27e75 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -55,26 +55,33 @@ pub fn run_cli(cli: Cli) -> ExitCode { pr_setup, observed_24h: obs24, src_runs: runs, + params, + seeds, + hours, + warmup, sha, - } => pr_flow( - &design, - json, - &FlowOverrides { - r_pr, - r_mq, - cap, - builds, - f, - n, - pr_min, - mq_min, - p_red, - pr_setup, - obs24, - runs, - }, - &sha, - ), + } => match params { + Some(path) => pr_flow_params(&path, seeds, hours, warmup), + None => pr_flow( + &design, + json, + &FlowOverrides { + r_pr, + r_mq, + cap, + builds, + f, + n, + pr_min, + mq_min, + p_red, + pr_setup, + obs24, + runs, + }, + &sha, + ), + }, Commands::Validate { experiment_path } => validate_experiment(&experiment_path), Commands::Verify { experiment_path, @@ -738,3 +745,24 @@ fn pr_flow_sim_block( }) .to_string() } + +/// `pr-flow --params `: one repo's arbiter params in, `{actual, one_change, optimum}` out (one JSON line). +fn pr_flow_params(path: &Path, seeds: u64, hours: f64, warmup: f64) -> ExitCode { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + eprintln!("params: cannot read {}: {e}", path.display()); + return ExitCode::FAILURE; + } + }; + match crate::scenarios::parse_params(&text) { + Ok(p) => { + println!("{}", crate::scenarios::evaluate(&p, seeds, hours, warmup)); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/aprender-simulate/src/scenarios/arbiter.rs b/crates/aprender-simulate/src/scenarios/arbiter.rs new file mode 100644 index 0000000000..e6050534f8 --- /dev/null +++ b/crates/aprender-simulate/src/scenarios/arbiter.rs @@ -0,0 +1,298 @@ +//! SIM-2 (FLOW-005, ARB-SOT-001 §3): the arbiter's `pr-flow --params` surface. +//! +//! Arbiter writes one params file per repo; this module reads it through ONE adapter +//! ([`parse_params`]), runs the PR-flow model for the repo as measured (`actual`), for the +//! design (`optimum`), and for each design change alone (`one_change`), and names the binding +//! station as the single change that moves merged/h the most. +//! +//! The params shape is provisional until infra pins `arbiter-sim-params-v1`: only +//! [`parse_params`] knows it, so the pin edits one function. + +use super::pr_flow::{preset, run_pr_flow, PrFlowConfig, PrFlowResult}; +use serde_json::{json, Value}; + +/// Params fields ARB-SOT-001 §3 item 1 lists, in the order it lists them. +const FIELDS: [&str; 14] = [ + "n", + "t_author", + "cap", + "r_pr", + "pr_runner_min", + "mq_runner_min", + "p_red", + "t_fix", + "t_arm", + "builds", + "batch", + "f", + "p_dirty", + "t_rebase", +]; + +/// Fields the model does not read yet (SIM-6 adds them); reported, never silently dropped. +const UNMODELLED: [&str; 2] = ["p_dirty", "t_rebase"]; + +/// A repo's parsed params. +#[derive(Debug, Clone, PartialEq)] +pub struct Params { + pub repo: String, + pub design: String, + pub cfg: PrFlowConfig, + /// Fields carrying a measured value, out of [`FIELDS`], in percent. + pub measured_pct: f64, + /// Names of the fields that fell back to the design value. + pub design_default: Vec, +} + +/// Read a params file: `{"repo", "design", "params": {field: number | {"value", "source", "window"}}}`. +/// A missing field, or one whose value is not a number, is `design_default`. +/// +/// # Errors +/// A message naming the problem: not JSON, no `repo`, unknown `design`, a negative or non-finite value. +pub fn parse_params(text: &str) -> Result { + let v: Value = serde_json::from_str(text).map_err(|e| format!("params: not JSON: {e}"))?; + let repo = v["repo"] + .as_str() + .ok_or("params: missing `repo`")? + .to_owned(); + let design = v["design"].as_str().unwrap_or("D").to_owned(); + let mut cfg = preset(&design).ok_or_else(|| format!("params: unknown design `{design}`"))?; + let mut measured = 0usize; + let mut design_default = Vec::new(); + for name in FIELDS { + let raw = &v["params"][name]; + let val = raw.get("value").unwrap_or(raw).as_f64(); + match val { + Some(x) if x.is_finite() && x >= 0.0 => { + measured += 1; + set_field(&mut cfg, name, x); + } + Some(x) => { + return Err(format!( + "params: `{name}` = {x} is not a non-negative number" + )) + } + None => design_default.push(name.to_owned()), + } + } + #[allow(clippy::cast_precision_loss)] + let measured_pct = measured as f64 / FIELDS.len() as f64 * 100.0; + Ok(Params { + repo, + design, + cfg, + measured_pct, + design_default, + }) +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn set_field(c: &mut PrFlowConfig, name: &str, x: f64) { + match name { + "n" => c.n = x, + "t_author" => c.t_author_min = x, + "cap" => c.cap = x as usize, + "r_pr" => c.r_pr = x as usize, + "pr_runner_min" => c.pr_runner_min = x, + "mq_runner_min" => c.mq_runner_min = x, + "p_red" => c.p_red = x, + "t_fix" => c.t_fix_min = x, + "t_arm" => c.t_arm_min = x, + "builds" => c.builds = x as usize, + "batch" => c.mq_batch = x as usize, + "f" => c.f = x, + _ => {} // p_dirty, t_rebase: see UNMODELLED + } +} + +/// Mean over seeds `1..=seeds`. +fn run_mean(c: &PrFlowConfig, seeds: u64, hours: f64, warmup_h: f64) -> (PrFlowResult, f64) { + let rs: Vec = (1..=seeds) + .map(|s| run_pr_flow(c, s, hours, warmup_h)) + .collect(); + #[allow(clippy::cast_precision_loss)] + let n = rs.len() as f64; + let mean = |f: fn(&PrFlowResult) -> f64| rs.iter().map(f).sum::() / n; + let m = mean(|r| r.merged_per_h); + let var = if rs.len() > 1 { + rs.iter().map(|r| (r.merged_per_h - m).powi(2)).sum::() / (n - 1.0) + } else { + 0.0 + }; + let r = PrFlowResult { + merged_per_h: m, + w_mean_min: mean(|r| r.w_mean_min), + w_p90_min: mean(|r| r.w_p90_min), + l: mean(|r| r.l), + prs_per_pass: mean(|r| r.prs_per_pass), + pr_util: mean(|r| r.pr_util), + mq_util: mean(|r| r.mq_util), + runs_per_pr: mean(|r| r.runs_per_pr), + }; + (r, 1.96 * (var / n).sqrt()) +} + +/// The design changes, each as "move this repo's supply fields to the design's". +fn changes(actual: &PrFlowConfig, design: &PrFlowConfig) -> Vec<(&'static str, PrFlowConfig)> { + type Apply = fn(&mut PrFlowConfig, &PrFlowConfig); + let table: [(&'static str, Apply); 8] = [ + ("cap", |a, d| a.cap = d.cap), + ("r_pr", |a, d| a.r_pr = d.r_pr), + ("r_mq", |a, d| a.r_mq = d.r_mq), + ("builds", |a, d| a.builds = d.builds), + ("runner_min", |a, d| { + a.pr_runner_min = d.pr_runner_min; + a.mq_runner_min = d.mq_runner_min; + }), + ("p_red", |a, d| a.p_red = d.p_red), + ("flake", |a, d| a.f = d.f), + ("setup", |a, d| { + a.pr_setup_min = d.pr_setup_min; + a.mq_setup_min = d.mq_setup_min; + }), + ]; + table + .into_iter() + .filter_map(|(name, apply)| { + let mut c = actual.clone(); + apply(&mut c, design); + (&c != actual).then_some((name, c)) + }) + .collect() +} + +/// Run the model for one repo and build the §3 item 5 row (`actual`, `optimum`, `one_change`). +#[must_use] +pub fn evaluate(p: &Params, seeds: u64, hours: f64, warmup_h: f64) -> Value { + let design = preset(&p.design).unwrap_or_else(|| p.cfg.clone()); + let (actual, ci95) = run_mean(&p.cfg, seeds, hours, warmup_h); + let moves = changes(&p.cfg, &design); + let one_change: Vec = moves + .iter() + .map(|(name, c)| json!({"change": name, "merged_h": run_mean(c, seeds, hours, warmup_h).0.merged_per_h})) + .collect(); + let mut opt_cfg = p.cfg.clone(); + for (_, c) in &moves { + opt_cfg = merge_supply(&opt_cfg, c, &p.cfg); + } + let (opt, _) = run_mean(&opt_cfg, seeds, hours, warmup_h); + let binding = one_change + .iter() + .max_by(|a, b| { + a["merged_h"] + .as_f64() + .partial_cmp(&b["merged_h"].as_f64()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map_or(Value::Null, |c| c["change"].clone()); + json!({ + "repo": p.repo, + "params_measured_pct": p.measured_pct, + "design_default": p.design_default, + "unmodelled": UNMODELLED, + "actual": { + "merged_h": actual.merged_per_h, "ci95_h": ci95, + "W_min_mean": actual.w_mean_min, "W_min_p90": actual.w_p90_min, "L": actual.l, + "pr_pool_util_pct": actual.pr_util * 100.0, "mq_util_pct": actual.mq_util * 100.0, + "runs_per_pr": actual.runs_per_pr + }, + "binding_station": binding, + "optimum": { + "scenario": p.design, "merged_h": opt.merged_per_h, + "changes": moves.iter().map(|(n, _)| *n).collect::>() + }, + "one_change": one_change + }) +} + +/// `base` with every field that `changed` moved off `orig` taken from `changed`. +fn merge_supply(base: &PrFlowConfig, changed: &PrFlowConfig, orig: &PrFlowConfig) -> PrFlowConfig { + let mut c = base.clone(); + macro_rules! take { ($($f:ident),*) => { $( if changed.$f != orig.$f { c.$f = changed.$f; } )* } } + take!( + cap, + r_pr, + r_mq, + builds, + pr_runner_min, + mq_runner_min, + p_red, + f, + pr_setup_min, + mq_setup_min + ); + c +} + +#[cfg(test)] +mod tests { + use super::*; + + const D_ALL: &str = r#"{"repo":"aprender","design":"D","params":{"n":11.1,"cap":10,"r_pr":28, + "builds":1,"f":0.12,"p_red":{"value":0.62,"source":"jobs.tsv","window":"7d"}}}"#; + + #[test] + fn missing_fields_are_design_defaults_and_counted() { + let p = parse_params(D_ALL).expect("parse"); + assert_eq!(p.cfg.cap, 10); + assert!((p.cfg.p_red - 0.62).abs() < 1e-12); + assert_eq!(p.cfg.pr_runner_min, preset("D").expect("D").pr_runner_min); + assert!(p.design_default.contains(&"t_arm".to_owned())); + assert!((p.measured_pct - 6.0 / 14.0 * 100.0).abs() < 1e-9); + } + + #[test] + fn bad_params_are_refused_by_name() { + assert!(parse_params("nope").is_err()); + assert!(parse_params(r#"{"design":"D"}"#) + .unwrap_err() + .contains("repo")); + assert!(parse_params(r#"{"repo":"x","design":"Z"}"#) + .unwrap_err() + .contains("design")); + assert!(parse_params(r#"{"repo":"x","params":{"cap":-1}}"#) + .unwrap_err() + .contains("cap")); + } + + #[test] + fn a_design_point_repo_has_no_changes_and_equals_the_reference() { + let p = parse_params(r#"{"repo":"x","design":"E"}"#).expect("parse"); + let v = evaluate(&p, 5, 400.0, 20.0); + assert!(v["one_change"].as_array().expect("arr").is_empty()); + assert!(v["binding_station"].is_null()); + let m = v["actual"]["merged_h"].as_f64().expect("num"); + assert!((m - 21.37).abs() <= 21.37 * 0.05, "{m}"); + assert_eq!(v["params_measured_pct"], 0.0); + } + + #[test] + fn the_binding_change_is_the_one_that_moves_merged_h_most() { + let p = parse_params(D_ALL).expect("parse"); + let v = evaluate(&p, 3, 200.0, 10.0); + let rows = v["one_change"].as_array().expect("arr"); + let best = rows + .iter() + .max_by(|a, b| { + a["merged_h"] + .as_f64() + .partial_cmp(&b["merged_h"].as_f64()) + .expect("cmp") + }) + .expect("row"); + assert_eq!(v["binding_station"], best["change"]); + assert!( + v["optimum"]["merged_h"].as_f64().expect("n") + > v["actual"]["merged_h"].as_f64().expect("n") + ); + } + + #[test] + fn same_params_same_bytes() { + let p = parse_params(D_ALL).expect("parse"); + assert_eq!( + evaluate(&p, 2, 100.0, 5.0).to_string(), + evaluate(&p, 2, 100.0, 5.0).to_string() + ); + } +} diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index 8ddcc7dbc9..027e438554 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -8,6 +8,7 @@ //! - Portfolio risk (Monte Carlo `VaR`) //! - Epidemic models (SIR/SEIR) +pub mod arbiter; pub mod bouncing_balls; pub mod climate; pub mod epidemic; @@ -17,6 +18,7 @@ pub mod pr_flow; pub mod rocket; pub mod satellite; +pub use arbiter::{evaluate, parse_params, Params}; pub use bouncing_balls::{BouncingBallsConfig, BouncingBallsState}; pub use climate::{ClimateConfig, ClimateScenario}; pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; diff --git a/crates/aprender-simulate/tests/pr_flow_contract.rs b/crates/aprender-simulate/tests/pr_flow_contract.rs index e4030a70c6..6f28aa1995 100644 --- a/crates/aprender-simulate/tests/pr_flow_contract.rs +++ b/crates/aprender-simulate/tests/pr_flow_contract.rs @@ -60,3 +60,38 @@ fn same_seeds_give_the_same_bytes() { assert_eq!(block(design), block(design), "{design}"); } } + +#[test] +fn params_file_gives_actual_one_change_and_optimum_with_the_same_bytes() { + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + let file = dir.join("pr-flow-params.json"); + std::fs::write( + &file, + r#"{"repo":"x","design":"D","params":{"cap":10,"r_pr":28,"p_red":0.62}}"#, + ) + .expect("write params"); + let run = |args: &[&str]| { + let out = Command::new(BIN) + .args(["pr-flow", "--params"]) + .arg(&file) + .args(args) + .output() + .expect("spawn"); + ( + out.status.success(), + String::from_utf8(out.stdout).expect("utf-8"), + ) + }; + let flags = ["--seeds", "2", "--hours", "100", "--warmup", "5", "--json"]; + let (ok, a) = run(&flags); + assert!(ok); + assert_eq!(a, run(&flags).1, "same params must give the same bytes"); + let v: serde_json::Value = serde_json::from_str(&a).expect("json"); + for k in ["actual", "optimum", "one_change", "binding_station"] { + assert!(!v[k].is_null() || k == "binding_station", "missing {k}"); + } + assert!(num(&v, &["optimum", "merged_h"]) > num(&v, &["actual", "merged_h"])); + std::fs::write(&file, "not json").expect("write bad"); + let (ok, _) = run(&flags); + assert!(!ok, "a malformed params file must fail, not print a number"); +} From 3efbce5effac9cd6dd070ed81b088496b0a57716 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:48:41 +0200 Subject: [PATCH 10/24] test(simulate): course_flow batch wait inside WIP, reported, Little within 0.5% (RED; flow model B6, Refs #5545) Agent: aprender-w5032 Co-Authored-By: Claude Sonnet 5.5 --- .../src/scenarios/course_flow.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/aprender-simulate/src/scenarios/course_flow.rs b/crates/aprender-simulate/src/scenarios/course_flow.rs index 1fb7b37780..6eb9a1e7db 100644 --- a/crates/aprender-simulate/src/scenarios/course_flow.rs +++ b/crates/aprender-simulate/src/scenarios/course_flow.rs @@ -553,6 +553,52 @@ mod tests { assert!((run_course_reference(&c).courses_per_day - 4.92).abs() > 4.92 * 0.05); } + fn with_batch_in_wip(name: &str) -> CourseFlowConfig { + let mut c = course_preset(name).expect("case"); + c.batch_in_wip = true; + c + } + + /// B6: the daily batch wait is measured and reported, not hidden. + #[test] + fn mean_batch_wait_is_reported_and_inside_a_day() { + let r = got("design"); + assert!( + r.mean_batch_wait_h > 0.5 && r.mean_batch_wait_h < 24.0, + "mean batch wait {:.2} h", + r.mean_batch_wait_h + ); + } + + /// B6: with the batch wait counted inside WIP, Little's law holds within 0.5%, + /// and the WIP cap now binds on it, so it can only cost throughput. + #[test] + fn littles_law_holds_with_batch_wait_inside_wip() { + for name in ["design", "nine-builders", "coursera-24h"] { + let outside = got(name); + let r = run_course_reference(&with_batch_in_wip(name)); + let lam_w = r.courses_per_day * r.lead_days; + assert!( + (lam_w - r.l).abs() <= r.l * 0.005, + "{name}: {lam_w:.3} vs {:.3}", + r.l + ); + assert!(r.l <= 24.0 + 1e-9, "{name}: L {:.2} over the cap", r.l); + assert!( + r.courses_per_day <= outside.courses_per_day + 0.02, + "{name}" + ); + } + } + + /// B6 falsifier: counting the batch wait inside WIP must change the answer, or the flag is dead. + #[test] + fn batch_in_wip_is_not_a_no_op() { + let a = got("coursera-24h"); + let b = run_course_reference(&with_batch_in_wip("coursera-24h")); + assert!((a.courses_per_day - b.courses_per_day).abs() > 1e-6 || (a.l - b.l).abs() > 1e-6); + } + #[test] fn unknown_case_is_none() { assert!(course_preset("nope").is_none()); From 776e8f6c95cf467cea6e5dd3eef414ce0e4ecbfc Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:49:45 +0200 Subject: [PATCH 11/24] feat(simulate): binding_station = argmin of cap/supply/pool bounds, no utilisation thresholds (flow model eq 2,3,5,6) (Refs #5511) Mutation: min_by -> max_by turns 3 binding tests RED; restored GREEN. Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/commands.rs | 10 +-- crates/aprender-simulate/src/scenarios/mod.rs | 3 +- .../src/scenarios/pr_flow.rs | 69 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index b317c27e75..68cd961cc8 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -703,11 +703,13 @@ fn pr_flow_sim_block( .sum::() / (n - 1.0); let ci95 = 1.96 * (var / n).sqrt(); - let binding = if mean(|r| r.pr_util) >= mean(|r| r.mq_util) { - "pr_checks" - } else { - "merge_queue" + let avg = crate::scenarios::PrFlowResult { + w_mean_min: mean(|r| r.w_mean_min), + runs_per_pr: mean(|r| r.runs_per_pr), + prs_per_pass: mean(|r| r.prs_per_pass), + ..Default::default() }; + let binding = crate::scenarios::binding_station(cfg, &avg); let base = crate::scenarios::preset(design) .map_or(0.0, |c| crate::scenarios::run_reference(&c).merged_per_h); let contract = crate::scenarios::reference_contract_ok(); diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index 027e438554..34bbf00c2d 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -25,7 +25,8 @@ pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; pub use pr_flow::{ - preset, reference_contract_ok, run_pr_flow, run_reference, PrFlowConfig, PrFlowResult, + binding_station, bounds, preset, reference_contract_ok, run_pr_flow, run_reference, + PrFlowConfig, PrFlowResult, }; pub use rocket::{RocketConfig, RocketScenario, StageSeparation}; pub use satellite::{OrbitalElements, SatelliteScenario}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index 10d3ca55b8..6481dd122e 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -410,6 +410,32 @@ pub fn preset(name: &str) -> Option { }) } +/// Throughput bounds (merged/h) of the four stations; the binding station is the smallest. +/// +/// `cap` is cap/W (eq 2), `supply` is N authors at one PR per think time, `pr_checks` and +/// `merge_queue` are pool capacity over the runner-minutes one merged PR consumes (eq 3, 5, 6). +#[must_use] +pub fn bounds(c: &PrFlowConfig, r: &PrFlowResult) -> [(&'static str, f64); 4] { + let w_h = (r.w_mean_min / 60.0).max(f64::EPSILON); + let pr_per_merge = c.pr_runner_min * r.runs_per_pr.max(1.0); + let mq_per_merge = c.mq_runner_min / r.prs_per_pass.max(1.0); + [ + ("cap", c.cap as f64 / w_h), + ("supply", c.n * 60.0 / c.t_author_min), + ("pr_checks", c.r_pr as f64 * 60.0 / pr_per_merge), + ("merge_queue", c.r_mq as f64 * 60.0 / mq_per_merge), + ] +} + +/// The binding station: argmin of [`bounds`]. No utilisation thresholds. +#[must_use] +pub fn binding_station(c: &PrFlowConfig, r: &PrFlowResult) -> &'static str { + bounds(c, r) + .into_iter() + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map_or("none", |b| b.0) +} + /// The reference run: seeds 1..=5, 400 h, 20 h warm-up, averaged. #[must_use] pub fn run_reference(c: &PrFlowConfig) -> PrFlowResult { @@ -448,6 +474,49 @@ mod tests { (got - want).abs() <= want * tol } + fn roomy() -> PrFlowConfig { + let mut c = preset("D").expect("preset"); + c.cap = 1000; + c.n = 1000.0; + c.r_pr = 10_000; + c.r_mq = 10_000; + c.builds = 100; + c + } + + #[test] + fn binding_is_cap_when_l_hits_cap() { + let mut c = roomy(); + c.n = 60.0; + c.cap = 2; + let r = run_reference(&c); + assert!(r.l > 0.7 * c.cap as f64, "L {}", r.l); + assert_eq!(binding_station(&c, &r), "cap"); + } + + #[test] + fn binding_is_supply_when_authors_are_starved() { + let mut c = roomy(); + c.n = 1.0; + let r = run_reference(&c); + assert_eq!(binding_station(&c, &r), "supply"); + } + + #[test] + fn binding_is_a_pool_when_the_pool_is_small() { + let mut c = roomy(); + c.n = 60.0; + c.r_pr = 4; + let r = run_reference(&c); + assert_eq!(binding_station(&c, &r), "pr_checks"); + let mut c = roomy(); + c.n = 60.0; + c.r_mq = 1; + c.builds = 1; + let r = run_reference(&c); + assert_eq!(binding_station(&c, &r), "merge_queue"); + } + #[test] fn fractional_n_sits_between_its_integer_neighbours() { let at = |n: f64| { From 2d3857fd070f54491155f84cdea57f2d266ddf2f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:50:20 +0200 Subject: [PATCH 12/24] feat(simulate): course_flow counts the daily batch wait inside WIP and reports its mean (flow model B6, Refs #5545) batch_in_wip (opt-in; reference bands unchanged), mean_batch_wait_h in the result and sim block. Mutation: dropping the batch-time slot release turns littles_law_holds_with_batch_wait_inside_wip RED. Also pins the pr/repo/course-flow subcommands in the clap reachability table (12). Agent: aprender-w5032 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/tests.rs | 12 +++++- .../src/scenarios/course_flow.rs | 42 +++++++++++++++---- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/aprender-simulate/src/cli/tests.rs b/crates/aprender-simulate/src/cli/tests.rs index 2305aa9e59..14fa46ef6b 100644 --- a/crates/aprender-simulate/src/cli/tests.rs +++ b/crates/aprender-simulate/src/cli/tests.rs @@ -421,6 +421,16 @@ fn test_every_subcommand_is_reachable() { vec!["aprender-simulate", "emc-validate", "e.emc.yaml"], |c| matches!(c, Commands::EmcValidate { .. }), ), + (vec!["aprender-simulate", "pr-flow"], |c| { + matches!(c, Commands::PrFlow { .. }) + }), + (vec!["aprender-simulate", "repo-flow", "--case", "x"], |c| { + matches!(c, Commands::RepoFlow { .. }) + }), + ( + vec!["aprender-simulate", "course-flow", "--case", "x"], + |c| matches!(c, Commands::CourseFlow { .. }), + ), (vec!["aprender-simulate", "list-emc"], |c| { matches!(c, Commands::ListEmc) }), @@ -435,7 +445,7 @@ fn test_every_subcommand_is_reachable() { // Non-vacuity: the table must cover every variant of `Commands`. assert_eq!( cases.len(), - 9, + 12, "add the new subcommand to this table when Commands grows" ); assert_eq!( diff --git a/crates/aprender-simulate/src/scenarios/course_flow.rs b/crates/aprender-simulate/src/scenarios/course_flow.rs index 6eb9a1e7db..3c5dbe3d41 100644 --- a/crates/aprender-simulate/src/scenarios/course_flow.rs +++ b/crates/aprender-simulate/src/scenarios/course_flow.rs @@ -38,6 +38,8 @@ pub struct CourseFlowConfig { pub shorts_per_day: f64, pub outline_min: f64, pub verify_min: f64, + /// Count a finished course as WIP until the daily batch releases it (flow model B6). + pub batch_in_wip: bool, } /// What one seeded run measured after the warm-up. @@ -48,6 +50,8 @@ pub struct CourseFlowResult { pub lead_days: f64, pub l: f64, pub util: [f64; 6], + /// Mean hours a built course waits for the daily batch (courses that take the batch path). + pub mean_batch_wait_h: f64, } impl CourseFlowResult { @@ -130,7 +134,9 @@ struct Sim { pending: usize, started_at: Vec, coursera: Vec, - batch_wait: Vec, + batch_wait: Vec<(usize, f64)>, + bw_sum: f64, + bw_n: u64, sojourn: Vec, shorts_done: u64, in_system: usize, @@ -221,8 +227,10 @@ impl Sim { 4 if self.coursera[j.id] => self.enqueue(CHROME, nj(5), c.stage_min), 4 => self.at(15.0, &Ev::Delay(nj(6))), 5 => { - self.leave_wip(); - self.batch_wait.push(j.id); + if !self.c.batch_in_wip { + self.leave_wip(); + } + self.batch_wait.push((j.id, self.now)); } _ => { self.leave_wip(); @@ -231,6 +239,20 @@ impl Sim { } } + /// The 07:00 batch: release every waiting course, and when the wait counts as WIP, free its slot now. + fn release_batch(&mut self) { + for (id, since) in std::mem::take(&mut self.batch_wait) { + if self.now >= self.warm { + self.bw_sum += self.now - since; + self.bw_n += 1; + } + if self.c.batch_in_wip { + self.leave_wip(); + } + self.launch(id); + } + } + fn short_next(&mut self, j: Job) { let nj = |step| Job { step, ..j }; match j.step { @@ -279,9 +301,7 @@ impl Sim { Ev::Delay(j) if j.short => self.short_next(j), Ev::Delay(j) => self.course_next(j), Ev::Batch => { - for id in std::mem::take(&mut self.batch_wait) { - self.launch(id); - } + self.release_batch(); self.at(DAY, &Ev::Batch); } } @@ -318,6 +338,8 @@ pub fn run_course_flow( started_at: Vec::new(), coursera: Vec::new(), batch_wait: Vec::new(), + bw_sum: 0.0, + bw_n: 0, sojourn: Vec::new(), shorts_done: 0, in_system: 0, @@ -355,6 +377,7 @@ pub fn run_course_flow( lead_days: s.sojourn.iter().sum::() / n.max(1.0) / DAY, l: s.l_area / span, util, + mean_batch_wait_h: s.bw_sum / (s.bw_n.max(1) as f64) / 60.0, } } @@ -376,6 +399,7 @@ pub fn course_preset(name: &str) -> Option { shorts_per_day: 5.0, outline_min: 90.0, verify_min: 15.0, + batch_in_wip: false, }; match name { "cal-today" => { @@ -414,6 +438,7 @@ pub fn run_course_reference(c: &CourseFlowConfig) -> CourseFlowResult { a.shorts_per_day += r.shorts_per_day / 5.0; a.lead_days += r.lead_days / 5.0; a.l += r.l / 5.0; + a.mean_batch_wait_h += r.mean_batch_wait_h / 5.0; for i in 0..6 { a.util[i] += r.util[i] / 5.0; } @@ -442,6 +467,7 @@ pub fn apply_course_inputs(c: &mut CourseFlowConfig, v: &serde_json::Value) -> R "wip" => c.wip = f as usize, "starts_per_day" => c.starts_per_day = f, "shorts_per_day" => c.shorts_per_day = f, + "batch_in_wip" => c.batch_in_wip = f != 0.0, _ => return Err(format!("unknown input: {k}")), } } @@ -464,11 +490,11 @@ pub fn course_sim_block( "design_min": c.design_min, "gpus": c.gpus, "render_min": c.render_min, "chrome_lanes": c.chrome_lanes, "stage_min": c.stage_min, "coursera_share": c.coursera_share, "wip": c.wip, - "starts_per_day": c.starts_per_day, "shorts_per_day": c.shorts_per_day, + "starts_per_day": c.starts_per_day, "shorts_per_day": c.shorts_per_day, "batch_in_wip": c.batch_in_wip, }, "predicted": { "courses_day": r.courses_per_day, "shorts_day": r.shorts_per_day, - "lead_days": r.lead_days, "L": r.l, "busiest_util_pct": util * 100.0, + "lead_days": r.lead_days, "L": r.l, "mean_batch_wait_h": r.mean_batch_wait_h, "busiest_util_pct": util * 100.0, }, "binding_station": station, }) From 65d09ceeb8b40961dea1c4402c0340e0ff0188dd Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:51:44 +0200 Subject: [PATCH 13/24] feat(contracts): course-flow-batch-wip-v1, Little with the daily human batch gate inside WIP (flow model eq 1, Refs #5545) Agent: aprender-w5032 Co-Authored-By: Claude Sonnet 5.5 --- contracts/course-flow-batch-wip-v1.yaml | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 contracts/course-flow-batch-wip-v1.yaml diff --git a/contracts/course-flow-batch-wip-v1.yaml b/contracts/course-flow-batch-wip-v1.yaml new file mode 100644 index 0000000000..35e8ff724b --- /dev/null +++ b/contracts/course-flow-batch-wip-v1.yaml @@ -0,0 +1,44 @@ +metadata: + version: "1.0.0" + created: "2026-10-18" + author: PAIML Engineering + registry: true + references: + - "Little, J.D.C. (1961), A Proof for the Queuing Formula L = lambda W." + - "flow model spec, internal (eq 1)" + description: "course_flow counts the daily human batch gate inside WIP and reports its mean wait" + +name: course-flow-batch-wip +version: "1.0.0" +scope: "crates/aprender-simulate/src/scenarios/course_flow.rs" + +description: | + A daily human batch gate holds finished items until its next release. With + batch_in_wip set, an item holds its WIP slot while it waits there, so Little's + law L = lambda W closes on a window that includes the gate. The mean time an item + spends at the gate is reported as mean_batch_wait_h. + +equations: + little_with_gate: + description: "|lambda W - L| / L <= 0.005 with WIP including the batch wait" + formula: "|lambda * W - L| / L <= 0.005" + + gate_wait_bounded: + description: "mean batch wait lies inside one gate interval" + formula: "0 < mean_batch_wait_h < 24" + +falsification: + - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_001 + description: "Little's law holds within 0.5% with the batch wait inside WIP" + test: "littles_law_holds_with_batch_wait_inside_wip in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_002 + description: "mean_batch_wait_h is reported and lies inside one day" + test: "mean_batch_wait_is_reported_and_inside_a_day in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_003 + description: "counting the batch inside WIP changes the result (not a no-op)" + test: "batch_in_wip_is_not_a_no_op in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" From 793ef377e006083d0c2770d614c1f2896915053b Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 14:57:31 +0200 Subject: [PATCH 14/24] feat(simulate): t_rev_min review wait + --t-rev flag (default 0); add pr-flow to subcommand table (flow model eq 7) (Refs #5511) Mutation: dropping t_rev from the arm delay -> dL 0, test RED; restored GREEN. Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 3 +++ crates/aprender-simulate/src/cli/commands.rs | 7 +++++- crates/aprender-simulate/src/cli/tests.rs | 5 ++++- .../src/scenarios/pr_flow.rs | 22 ++++++++++++++++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 800044f55d..00a992d394 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -134,6 +134,9 @@ pub enum Commands { /// Measured queue wait per PR run, minutes. #[arg(long)] pr_setup: Option, + /// Review wait per PR after green checks, minutes (default 0). + #[arg(long)] + t_rev: Option, /// Merged PRs in the last 24 h, from git (for drift_pct_24h). #[arg(long)] observed_24h: Option, diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 68cd961cc8..5e58ae315e 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -53,6 +53,7 @@ pub fn run_cli(cli: Cli) -> ExitCode { mq_min, p_red, pr_setup, + t_rev, observed_24h: obs24, src_runs: runs, params, @@ -76,6 +77,7 @@ pub fn run_cli(cli: Cli) -> ExitCode { mq_min, p_red, pr_setup, + t_rev, obs24, runs, }, @@ -620,6 +622,7 @@ struct FlowOverrides { mq_min: Option, p_red: Option, pr_setup: Option, + t_rev: Option, obs24: Option, runs: Option, } @@ -636,6 +639,7 @@ impl FlowOverrides { c.mq_runner_min = self.mq_min.unwrap_or(c.mq_runner_min); c.p_red = self.p_red.unwrap_or(c.p_red); c.pr_setup_min = self.pr_setup.unwrap_or(c.pr_setup_min); + c.t_rev_min = self.t_rev.unwrap_or(c.t_rev_min); } fn cli_flags(&self) -> String { @@ -650,6 +654,7 @@ impl FlowOverrides { self.mq_min.map(|v| format!(" --mq-min {v}")), self.p_red.map(|v| format!(" --p-red {v}")), self.pr_setup.map(|v| format!(" --pr-setup {v}")), + self.t_rev.map(|v| format!(" --t-rev {v}")), self.obs24.map(|v| format!(" --observed-24h {v}")), self.runs.as_ref().map(|v| format!(" --src-runs {v}")), ]; @@ -724,7 +729,7 @@ fn pr_flow_sim_block( "design": design, "cap": cfg.cap, "n": cfg.n, "t_author_min": cfg.t_author_min, "r_pr": cfg.r_pr, "r_mq": cfg.r_mq, "builds": cfg.builds, "p_red": cfg.p_red, "pr_runner_min": cfg.pr_runner_min, "mq_runner_min": cfg.mq_runner_min, - "pr_setup_min": cfg.pr_setup_min, "f": cfg.f + "pr_setup_min": cfg.pr_setup_min, "t_rev_min": cfg.t_rev_min, "f": cfg.f }, "inputs_source": { "t_author_min": "design_default", "n": src(ov.n.is_some()), "cap": src(ov.cap.is_some()), diff --git a/crates/aprender-simulate/src/cli/tests.rs b/crates/aprender-simulate/src/cli/tests.rs index 2305aa9e59..5c75599b82 100644 --- a/crates/aprender-simulate/src/cli/tests.rs +++ b/crates/aprender-simulate/src/cli/tests.rs @@ -408,6 +408,9 @@ fn test_every_subcommand_is_reachable() { (vec!["aprender-simulate", "render"], |c| { matches!(c, Commands::Render { .. }) }), + (vec!["aprender-simulate", "pr-flow"], |c| { + matches!(c, Commands::PrFlow { .. }) + }), (vec!["aprender-simulate", "validate", "e.yaml"], |c| { matches!(c, Commands::Validate { .. }) }), @@ -435,7 +438,7 @@ fn test_every_subcommand_is_reachable() { // Non-vacuity: the table must cover every variant of `Commands`. assert_eq!( cases.len(), - 9, + 10, "add the new subcommand to this table when Commands grows" ); assert_eq!( diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index 6481dd122e..4f756b6c1b 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -26,6 +26,8 @@ pub struct PrFlowConfig { pub p_red: f64, pub t_fix_min: f64, pub t_arm_min: f64, + /// Review wait per PR after green checks, serial with arming; 0 = no review station. + pub t_rev_min: f64, pub r_mq: usize, pub mq_runner_min: f64, pub mq_jobs: usize, @@ -282,7 +284,7 @@ impl Sim { Ev::PrRun(pr) if self.rng.gen_f64() < self.c.p_red => { self.at(self.c.t_fix_min, Ev::Fixed(pr)); } - Ev::PrRun(pr) => self.at(self.c.t_arm_min, Ev::Armed(pr)), + Ev::PrRun(pr) => self.at(self.c.t_arm_min + self.c.t_rev_min, Ev::Armed(pr)), Ev::Fixed(pr) => self.start_run(pr), Ev::Armed(pr) => { self.enq_at[pr] = self.now; @@ -399,6 +401,7 @@ pub fn preset(name: &str) -> Option { p_red, t_fix_min: 10.0, t_arm_min: 2.0, + t_rev_min: 0.0, r_mq, mq_runner_min: rm, mq_jobs: 4, @@ -484,6 +487,23 @@ mod tests { c } + #[test] + fn six_minutes_of_review_adds_about_two_to_l_at_20_per_hour() { + let mut c = roomy(); + c.t_author_min = 600.0; + c.n = 200.0; + let base = run_reference(&c); + assert!( + within(base.merged_per_h, 20.0, 0.05), + "lambda {}", + base.merged_per_h + ); + c.t_rev_min = 6.0; + let r = run_reference(&c); + let dl = r.l - base.l; + assert!(within(dl, 2.0, 0.1), "dL {dl}"); + } + #[test] fn binding_is_cap_when_l_hits_cap() { let mut c = roomy(); From dfb20763cb70c8f628ce02499aa03202be628ba1 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 15:08:09 +0200 Subject: [PATCH 15/24] feat(simulate): report simulated author busy fraction rho_a beside the 0.95 assumption (flow model eq 2) (Refs #5511) Mutation: zeroing the blocked-time term makes the capped case read 1.0, test RED; restored GREEN. Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/commands.rs | 2 +- .../src/scenarios/arbiter.rs | 4 +++- .../src/scenarios/pr_flow.rs | 20 +++++++++++++++++++ .../tests/pr_flow_contract.rs | 12 +++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 5e58ae315e..1d2c123a3f 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -739,7 +739,7 @@ fn pr_flow_sim_block( }, "predicted": { "merged_h": m, "ci95_h": ci95, - "W_min_mean": mean(|r| r.w_mean_min), "L": mean(|r| r.l) + "W_min_mean": mean(|r| r.w_mean_min), "L": mean(|r| r.l), "rho_a": mean(|r| r.rho_a), "rho_a_assumed": 0.95 }, "binding_station": binding, "optimum": { diff --git a/crates/aprender-simulate/src/scenarios/arbiter.rs b/crates/aprender-simulate/src/scenarios/arbiter.rs index e6050534f8..aa79f31763 100644 --- a/crates/aprender-simulate/src/scenarios/arbiter.rs +++ b/crates/aprender-simulate/src/scenarios/arbiter.rs @@ -128,6 +128,7 @@ fn run_mean(c: &PrFlowConfig, seeds: u64, hours: f64, warmup_h: f64) -> (PrFlowR pr_util: mean(|r| r.pr_util), mq_util: mean(|r| r.mq_util), runs_per_pr: mean(|r| r.runs_per_pr), + rho_a: mean(|r| r.rho_a), }; (r, 1.96 * (var / n).sqrt()) } @@ -194,7 +195,8 @@ pub fn evaluate(p: &Params, seeds: u64, hours: f64, warmup_h: f64) -> Value { "merged_h": actual.merged_per_h, "ci95_h": ci95, "W_min_mean": actual.w_mean_min, "W_min_p90": actual.w_p90_min, "L": actual.l, "pr_pool_util_pct": actual.pr_util * 100.0, "mq_util_pct": actual.mq_util * 100.0, - "runs_per_pr": actual.runs_per_pr + "runs_per_pr": actual.runs_per_pr, + "rho_a": actual.rho_a }, "binding_station": binding, "optimum": { diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index 4f756b6c1b..ee8d3a118e 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -61,6 +61,8 @@ pub struct PrFlowResult { pub pr_util: f64, pub mq_util: f64, pub runs_per_pr: f64, + /// Author busy fraction: share of author time not spent blocked on the WIP cap. + pub rho_a: f64, } #[derive(Clone, Copy)] @@ -151,6 +153,7 @@ struct Sim { timer_at: f64, sojourn: Vec, l_area: f64, + blocked_area: f64, last_t: f64, runs: u64, prs_seen: u64, @@ -328,6 +331,7 @@ pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> Pr timer_at: -1.0, sojourn: Vec::new(), l_area: 0.0, + blocked_area: 0.0, last_t: 0.0, runs: 0, prs_seen: 0, @@ -347,6 +351,7 @@ pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> Pr let from = s.last_t.max(s.warm); if t > from { s.l_area += s.open as f64 * (t - from); + s.blocked_area += s.blocked.len() as f64 * (t - from); } s.last_t = t; s.now = t; @@ -374,6 +379,7 @@ impl Sim { pr_util: self.pr.busy_min / (span * self.pr.size as f64), mq_util: self.mq.busy_min / (span * self.mq.size as f64), runs_per_pr: self.runs as f64 / (self.prs_seen as f64).max(1.0), + rho_a: 1.0 - self.blocked_area / (span * self.c.n.ceil().max(1.0)), } } } @@ -453,6 +459,7 @@ pub fn run_reference(c: &PrFlowConfig) -> PrFlowResult { acc.pr_util += r.pr_util / 5.0; acc.mq_util += r.mq_util / 5.0; acc.runs_per_pr += r.runs_per_pr / 5.0; + acc.rho_a += r.rho_a / 5.0; } acc } @@ -504,6 +511,19 @@ mod tests { assert!(within(dl, 2.0, 0.1), "dL {dl}"); } + #[test] + fn author_busy_fraction_is_one_without_a_cap_and_falls_under_one_with_it() { + let mut c = roomy(); + c.n = 10.0; + let free = run_reference(&c); + assert!(free.rho_a > 0.999, "free {}", free.rho_a); + let mut c = roomy(); + c.n = 60.0; + c.cap = 2; + let capped = run_reference(&c); + assert!(capped.rho_a < 0.5, "capped {}", capped.rho_a); + } + #[test] fn binding_is_cap_when_l_hits_cap() { let mut c = roomy(); diff --git a/crates/aprender-simulate/tests/pr_flow_contract.rs b/crates/aprender-simulate/tests/pr_flow_contract.rs index 6f28aa1995..96869b8b72 100644 --- a/crates/aprender-simulate/tests/pr_flow_contract.rs +++ b/crates/aprender-simulate/tests/pr_flow_contract.rs @@ -95,3 +95,15 @@ fn params_file_gives_actual_one_change_and_optimum_with_the_same_bytes() { let (ok, _) = run(&flags); assert!(!ok, "a malformed params file must fail, not print a number"); } + +#[test] +fn json_block_reports_the_author_busy_fraction_beside_the_assumption() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aprender-simulate")) + .args(["pr-flow", "--design", "D", "--json"]) + .output() + .expect("run"); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json"); + let rho = v["predicted"]["rho_a"].as_f64().expect("rho_a present"); + assert!((0.0..=1.0).contains(&rho), "rho_a {rho}"); + assert!((v["predicted"]["rho_a_assumed"].as_f64().expect("assumed") - 0.95).abs() < 1e-9); +} From 8f0a28d1822a9476e9456a4b8e3a2b33f30f0da2 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 15:20:57 +0200 Subject: [PATCH 16/24] feat(simulate): review station in pr-flow: serial or concurrent with checks, reviewer lanes, per-tier review minutes (flow model eq 7) (Refs #5511) Falsifier: concurrent review with t_rev <= check wall saves the serial t_rev within 5%. Mutations: concurrent arming disabled -> RED; lane release dropped -> RED; restored GREEN. Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/args.rs | 6 + crates/aprender-simulate/src/cli/commands.rs | 12 ++ crates/aprender-simulate/src/scenarios/mod.rs | 2 +- .../src/scenarios/pr_flow.rs | 125 +++++++++++++++++- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/aprender-simulate/src/cli/args.rs b/crates/aprender-simulate/src/cli/args.rs index 00a992d394..951f15835b 100644 --- a/crates/aprender-simulate/src/cli/args.rs +++ b/crates/aprender-simulate/src/cli/args.rs @@ -137,6 +137,12 @@ pub enum Commands { /// Review wait per PR after green checks, minutes (default 0). #[arg(long)] t_rev: Option, + /// Run the review alongside the checks instead of after them. + #[arg(long)] + rev_concurrent: bool, + /// Parallel reviewers (0 = unlimited). + #[arg(long)] + rev_lanes: Option, /// Merged PRs in the last 24 h, from git (for drift_pct_24h). #[arg(long)] observed_24h: Option, diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 1d2c123a3f..790981cf25 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -54,6 +54,8 @@ pub fn run_cli(cli: Cli) -> ExitCode { p_red, pr_setup, t_rev, + rev_concurrent, + rev_lanes, observed_24h: obs24, src_runs: runs, params, @@ -78,6 +80,8 @@ pub fn run_cli(cli: Cli) -> ExitCode { p_red, pr_setup, t_rev, + rev_concurrent, + rev_lanes, obs24, runs, }, @@ -623,6 +627,8 @@ struct FlowOverrides { p_red: Option, pr_setup: Option, t_rev: Option, + rev_concurrent: bool, + rev_lanes: Option, obs24: Option, runs: Option, } @@ -640,6 +646,10 @@ impl FlowOverrides { c.p_red = self.p_red.unwrap_or(c.p_red); c.pr_setup_min = self.pr_setup.unwrap_or(c.pr_setup_min); c.t_rev_min = self.t_rev.unwrap_or(c.t_rev_min); + if self.rev_concurrent { + c.rev_mode = crate::scenarios::ReviewMode::Concurrent; + } + c.rev_lanes = self.rev_lanes.unwrap_or(c.rev_lanes); } fn cli_flags(&self) -> String { @@ -655,6 +665,8 @@ impl FlowOverrides { self.p_red.map(|v| format!(" --p-red {v}")), self.pr_setup.map(|v| format!(" --pr-setup {v}")), self.t_rev.map(|v| format!(" --t-rev {v}")), + self.rev_concurrent.then(|| " --rev-concurrent".to_string()), + self.rev_lanes.map(|v| format!(" --rev-lanes {v}")), self.obs24.map(|v| format!(" --observed-24h {v}")), self.runs.as_ref().map(|v| format!(" --src-runs {v}")), ]; diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index 34bbf00c2d..0818f5ec3f 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -26,7 +26,7 @@ pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; pub use pr_flow::{ binding_station, bounds, preset, reference_contract_ok, run_pr_flow, run_reference, - PrFlowConfig, PrFlowResult, + PrFlowConfig, PrFlowResult, ReviewMode, }; pub use rocket::{RocketConfig, RocketScenario, StageSeparation}; pub use satellite::{OrbitalElements, SatelliteScenario}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index ee8d3a118e..cc9dd6ce51 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -12,6 +12,13 @@ use crate::engine::scheduler::EventScheduler; use crate::engine::state::SimEvent; use crate::engine::SimTime; +/// Where the review sits relative to the PR checks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewMode { + Serial, + Concurrent, +} + /// Inputs of the line. Times are minutes; `*_runner_min` is the runner-minutes of one run. #[derive(Debug, Clone, PartialEq)] pub struct PrFlowConfig { @@ -28,6 +35,12 @@ pub struct PrFlowConfig { pub t_arm_min: f64, /// Review wait per PR after green checks, serial with arming; 0 = no review station. pub t_rev_min: f64, + /// Review runs alongside the checks (`Concurrent`) or after them (`Serial`). + pub rev_mode: ReviewMode, + /// Parallel reviewers; 0 = unlimited (no queue at the review station). + pub rev_lanes: usize, + /// Per-tier review minutes, handed out to PRs round-robin; empty = `t_rev_min` for all. + pub rev_tiers: Vec, pub r_mq: usize, pub mq_runner_min: f64, pub mq_jobs: usize, @@ -72,6 +85,7 @@ enum Ev { PrRun(usize), Fixed(usize), Armed(usize), + Reviewed(usize), MqJob(usize), MqPass(usize), MqTimer, @@ -85,6 +99,7 @@ impl Ev { Self::PrRun(i) => ("pr_run", i), Self::Fixed(i) => ("fixed", i), Self::Armed(i) => ("armed", i), + Self::Reviewed(i) => ("reviewed", i), Self::MqJob(i) => ("mq_job", i), Self::MqPass(i) => ("mq_pass", i), Self::MqTimer => ("mq_timer", 0), @@ -106,6 +121,7 @@ impl Ev { "pr_run" => Self::PrRun(id), "fixed" => Self::Fixed(id), "armed" => Self::Armed(id), + "reviewed" => Self::Reviewed(id), "mq_job" => Self::MqJob(id), "mq_pass" => Self::MqPass(id), "mq_timer" => Self::MqTimer, @@ -153,6 +169,10 @@ struct Sim { timer_at: f64, sojourn: Vec, l_area: f64, + rev_free: usize, + rev_wait: VecDeque, + green: Vec, + rev_ok: Vec, blocked_area: f64, last_t: f64, runs: u64, @@ -198,8 +218,13 @@ impl Sim { self.open_at.push(self.now); self.enq_at.push(0.0); self.jobs_left.push(0); + self.green.push(false); + self.rev_ok.push(false); self.open += 1; self.prs_seen += 1; + if self.c.rev_mode == ReviewMode::Concurrent { + self.request_review(pr); + } self.start_run(pr); let t = self.exp(self.c.think_min(author)); self.at(t, Ev::Author(author)); @@ -262,6 +287,50 @@ impl Sim { self.form_groups(); } + /// Review minutes of `pr`: its round-robin tier, else the flat `t_rev_min`. + fn review_min(&self, pr: usize) -> f64 { + self.c + .rev_tiers + .get(pr % self.c.rev_tiers.len().max(1)) + .copied() + .unwrap_or(self.c.t_rev_min) + } + + /// Ask the review station for `pr`; a zero-minute review finishes inline (no event). + fn request_review(&mut self, pr: usize) { + if self.review_min(pr) <= 0.0 { + self.reviewed(pr); + } else if self.rev_free > 0 { + self.rev_free -= 1; + self.at(self.review_min(pr), Ev::Reviewed(pr)); + } else { + self.rev_wait.push_back(pr); + } + } + + fn checks_green(&mut self, pr: usize) { + self.green[pr] = true; + match self.c.rev_mode { + ReviewMode::Serial => self.request_review(pr), + ReviewMode::Concurrent if self.rev_ok[pr] => self.at(self.c.t_arm_min, Ev::Armed(pr)), + ReviewMode::Concurrent => {} + } + } + + fn reviewed(&mut self, pr: usize) { + self.rev_ok[pr] = true; + if self.review_min(pr) > 0.0 { + self.rev_free = self.rev_free.saturating_add(1); + if let Some(next) = self.rev_wait.pop_front() { + self.rev_free -= 1; + self.at(self.review_min(next), Ev::Reviewed(next)); + } + } + if self.green[pr] { + self.at(self.c.t_arm_min, Ev::Armed(pr)); + } + } + fn merge(&mut self, pr: usize) { if self.now >= self.warm { self.sojourn.push(self.now - self.open_at[pr]); @@ -287,7 +356,8 @@ impl Sim { Ev::PrRun(pr) if self.rng.gen_f64() < self.c.p_red => { self.at(self.c.t_fix_min, Ev::Fixed(pr)); } - Ev::PrRun(pr) => self.at(self.c.t_arm_min + self.c.t_rev_min, Ev::Armed(pr)), + Ev::PrRun(pr) => self.checks_green(pr), + Ev::Reviewed(pr) => self.reviewed(pr), Ev::Fixed(pr) => self.start_run(pr), Ev::Armed(pr) => { self.enq_at[pr] = self.now; @@ -332,6 +402,14 @@ pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> Pr sojourn: Vec::new(), l_area: 0.0, blocked_area: 0.0, + rev_free: if c.rev_lanes == 0 { + usize::MAX + } else { + c.rev_lanes + }, + rev_wait: VecDeque::new(), + green: Vec::new(), + rev_ok: Vec::new(), last_t: 0.0, runs: 0, prs_seen: 0, @@ -408,6 +486,9 @@ pub fn preset(name: &str) -> Option { t_fix_min: 10.0, t_arm_min: 2.0, t_rev_min: 0.0, + rev_mode: ReviewMode::Serial, + rev_lanes: 0, + rev_tiers: Vec::new(), r_mq, mq_runner_min: rm, mq_jobs: 4, @@ -524,6 +605,48 @@ mod tests { assert!(capped.rho_a < 0.5, "capped {}", capped.rho_a); } + fn open_line() -> PrFlowConfig { + let mut c = roomy(); + c.t_author_min = 600.0; + c.n = 200.0; + c.p_red = 0.0; + c + } + + #[test] + fn concurrent_review_inside_the_check_wall_removes_the_serial_wait() { + let mut c = open_line(); + let base = run_reference(&c).w_mean_min; + c.t_rev_min = 6.0; + let serial = run_reference(&c).w_mean_min; + c.rev_mode = ReviewMode::Concurrent; + let conc = run_reference(&c).w_mean_min; + assert!( + within(serial - base, 6.0, 0.05), + "serial adds {}", + serial - base + ); + assert!( + within(serial - conc, 6.0, 0.05), + "concurrent saves {}", + serial - conc + ); + } + + #[test] + fn one_lane_queues_reviews_and_tiers_set_each_prs_time() { + let mut c = open_line(); + c.t_rev_min = 6.0; + let free = run_reference(&c).w_mean_min; + c.rev_lanes = 1; + let one = run_reference(&c).w_mean_min; + assert!(one > free + 5.0, "one lane {one} vs unlimited {free}"); + let mut t = open_line(); + t.rev_tiers = vec![2.0, 10.0]; + let tiered = run_reference(&t).w_mean_min - run_reference(&open_line()).w_mean_min; + assert!(within(tiered, 6.0, 0.1), "mean of tiers adds {tiered}"); + } + #[test] fn binding_is_cap_when_l_hits_cap() { let mut c = roomy(); From f8de14e5bd0647eb0ef5b85563eb1787cba3ebb8 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 15:32:03 +0200 Subject: [PATCH 17/24] fix(simulate): flow model eq 7 law block in pr-flow --json; merge-queue wait is the pass in flight, not the batch timer (Refs #5511) Root cause of sim W below law W on design D: the law took round inputs (w=5, c_wall=30, pass=30) and used the 5-minute batch timer as the merge queue wait. With one build at a time the build slot is busy, so an armed PR waits out the pass in flight plus any overflow past the batch size (17.2 min, not 5). The checks term ran 9.7 min high and the queue term 7.8 min low; they partly cancelled into a plausible gap. This line has no rebase path, so p_dirty = 0 and rebase cannot explain it. The sim now reports stage times (run wall, open->green, green->armed, armed->merged, wait for first pass, pass wall) and the --json block gains a "law" object: eq 7 evaluated on the design's own config with the queue waits read from the run, term by term, beside predicted.W_min_mean. Design D: sim 82.0 vs law 82.0 min (0.05%); G 0.12%, E 0.10%. Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-simulate/src/cli/commands.rs | 18 +- .../src/scenarios/arbiter.rs | 6 + crates/aprender-simulate/src/scenarios/mod.rs | 4 +- .../src/scenarios/pr_flow.rs | 207 +++++++++++++++++- .../tests/pr_flow_contract.rs | 19 ++ 5 files changed, 247 insertions(+), 7 deletions(-) diff --git a/crates/aprender-simulate/src/cli/commands.rs b/crates/aprender-simulate/src/cli/commands.rs index 790981cf25..41c205fba7 100644 --- a/crates/aprender-simulate/src/cli/commands.rs +++ b/crates/aprender-simulate/src/cli/commands.rs @@ -697,6 +697,10 @@ fn pr_flow(design: &str, json: bool, ov: &FlowOverrides, sha: &str) -> ExitCode r.mq_util * 100.0, r.runs_per_pr ); + println!( + "stages: run wall {:.1} open->green {:.1} green->armed {:.1} armed->merged {:.1} min", + r.run_wall_min, r.checks_min, r.arm_min, r.mq_min + ); ExitCode::SUCCESS } @@ -724,9 +728,14 @@ fn pr_flow_sim_block( w_mean_min: mean(|r| r.w_mean_min), runs_per_pr: mean(|r| r.runs_per_pr), prs_per_pass: mean(|r| r.prs_per_pass), + run_wall_min: mean(|r| r.run_wall_min), + pass_wait_min: mean(|r| r.pass_wait_min), + pass_wall_min: mean(|r| r.pass_wall_min), ..Default::default() }; let binding = crate::scenarios::binding_station(cfg, &avg); + let law = crate::scenarios::eq7_law(cfg, &avg); + let w_sim = mean(|r| r.w_mean_min); let base = crate::scenarios::preset(design) .map_or(0.0, |c| crate::scenarios::run_reference(&c).merged_per_h); let contract = crate::scenarios::reference_contract_ok(); @@ -751,7 +760,14 @@ fn pr_flow_sim_block( }, "predicted": { "merged_h": m, "ci95_h": ci95, - "W_min_mean": mean(|r| r.w_mean_min), "L": mean(|r| r.l), "rho_a": mean(|r| r.rho_a), "rho_a_assumed": 0.95 + "W_min_mean": w_sim, "L": mean(|r| r.l), "rho_a": mean(|r| r.rho_a), "rho_a_assumed": 0.95 + }, + "law": { + "eq": 7, "W_law_min": law.w_law_min(), "gap_pct": (w_sim - law.w_law_min()) / law.w_law_min() * 100.0, + "r": law.r, "w_min": law.w_min, "c_wall_min": law.c_wall_min, "t_fix_min": law.t_fix_min, + "t_arm_min": law.t_arm_min, "p_dirty": law.p_dirty, "t_reb_min": law.t_reb_min, + "wait_mq_min": law.wait_mq_min, "t_pass_min": law.t_pass_min, "w_pass_min": law.w_pass_min, "f": law.f, + "checks_min": law.checks_min(), "rebase_min": law.rebase_min(), "W_mq_min": law.w_mq_min() }, "binding_station": binding, "optimum": { diff --git a/crates/aprender-simulate/src/scenarios/arbiter.rs b/crates/aprender-simulate/src/scenarios/arbiter.rs index aa79f31763..50e0428242 100644 --- a/crates/aprender-simulate/src/scenarios/arbiter.rs +++ b/crates/aprender-simulate/src/scenarios/arbiter.rs @@ -129,6 +129,12 @@ fn run_mean(c: &PrFlowConfig, seeds: u64, hours: f64, warmup_h: f64) -> (PrFlowR mq_util: mean(|r| r.mq_util), runs_per_pr: mean(|r| r.runs_per_pr), rho_a: mean(|r| r.rho_a), + run_wall_min: mean(|r| r.run_wall_min), + checks_min: mean(|r| r.checks_min), + arm_min: mean(|r| r.arm_min), + mq_min: mean(|r| r.mq_min), + pass_wait_min: mean(|r| r.pass_wait_min), + pass_wall_min: mean(|r| r.pass_wall_min), }; (r, 1.96 * (var / n).sqrt()) } diff --git a/crates/aprender-simulate/src/scenarios/mod.rs b/crates/aprender-simulate/src/scenarios/mod.rs index 0818f5ec3f..28aecefaf9 100644 --- a/crates/aprender-simulate/src/scenarios/mod.rs +++ b/crates/aprender-simulate/src/scenarios/mod.rs @@ -25,8 +25,8 @@ pub use epidemic::{SEIRConfig, SEIRScenario, SIRConfig, SIRScenario}; pub use pendulum::{PendulumConfig, PendulumScenario}; pub use portfolio::{PortfolioConfig, PortfolioScenario, VaRResult}; pub use pr_flow::{ - binding_station, bounds, preset, reference_contract_ok, run_pr_flow, run_reference, - PrFlowConfig, PrFlowResult, ReviewMode, + binding_station, bounds, eq7_law, preset, reference_contract_ok, run_pr_flow, run_reference, + Eq7Law, PrFlowConfig, PrFlowResult, ReviewMode, }; pub use rocket::{RocketConfig, RocketScenario, StageSeparation}; pub use satellite::{OrbitalElements, SatelliteScenario}; diff --git a/crates/aprender-simulate/src/scenarios/pr_flow.rs b/crates/aprender-simulate/src/scenarios/pr_flow.rs index cc9dd6ce51..8c19c95c0f 100644 --- a/crates/aprender-simulate/src/scenarios/pr_flow.rs +++ b/crates/aprender-simulate/src/scenarios/pr_flow.rs @@ -76,6 +76,18 @@ pub struct PrFlowResult { pub runs_per_pr: f64, /// Author busy fraction: share of author time not spent blocked on the WIP cap. pub rho_a: f64, + /// Mean wall of one PR-check run, start to verdict (runner wait + jobs + setup). + pub run_wall_min: f64, + /// Mean open to green checks, merged PRs only (eq 7 terms r(w+c_wall) + (r-1)t_fix). + pub checks_min: f64, + /// Mean green to armed (eq 7 term t_arm, plus any serial review). + pub arm_min: f64, + /// Mean armed to merged (eq 7 term W_mq). + pub mq_min: f64, + /// Mean armed to the start of the first merge pass that holds the PR (the wait in W_mq). + pub pass_wait_min: f64, + /// Mean wall of one merge pass, group formed to verdict (runner wait + jobs + setup). + pub pass_wall_min: f64, } #[derive(Clone, Copy)] @@ -165,6 +177,8 @@ struct Sim { enq_at: Vec, groups: Vec>, group_jobs_left: Vec, + group_at: Vec, + pass_wall: (f64, u64), active: usize, timer_at: f64, sojourn: Vec, @@ -173,6 +187,11 @@ struct Sim { rev_wait: VecDeque, green: Vec, rev_ok: Vec, + green_at: Vec, + run_at: Vec, + wall: (f64, u64), + stage_sum: [f64; 4], + pass_at: Vec, blocked_area: f64, last_t: f64, runs: u64, @@ -192,6 +211,7 @@ impl Sim { fn start_run(&mut self, pr: usize) { self.runs += 1; + self.run_at[pr] = self.now; self.jobs_left[pr] = self.c.pr_jobs; let d = self.c.pr_runner_min / self.c.pr_jobs as f64; for _ in 0..self.c.pr_jobs { @@ -220,6 +240,9 @@ impl Sim { self.jobs_left.push(0); self.green.push(false); self.rev_ok.push(false); + self.green_at.push(0.0); + self.run_at.push(0.0); + self.pass_at.push(-1.0); self.open += 1; self.prs_seen += 1; if self.c.rev_mode == ReviewMode::Concurrent { @@ -244,9 +267,15 @@ impl Sim { } let take = self.c.mq_batch.min(self.queue.len()); let members: Vec = self.queue.drain(..take).collect(); + for &pr in &members { + if self.pass_at[pr] < self.enq_at[pr] { + self.pass_at[pr] = self.now; + } + } self.groups.push(members); let g = self.groups.len() - 1; self.group_jobs_left.push(self.c.mq_jobs); + self.group_at.push(self.now); self.active += 1; let d = self.c.mq_runner_min / self.c.mq_jobs as f64; for _ in 0..self.c.mq_jobs { @@ -272,6 +301,10 @@ impl Sim { fn pass_done(&mut self, g: usize) { self.active -= 1; let members = std::mem::take(&mut self.groups[g]); + if self.now >= self.warm { + self.pass_wall.0 += self.now - self.group_at[g]; + self.pass_wall.1 += 1; + } if self.rng.gen_f64() < self.c.f { for pr in members.into_iter().rev() { self.queue.push_front(pr); @@ -308,8 +341,22 @@ impl Sim { } } + /// A PR-check run reached its verdict: red goes to fix, green goes on. + fn run_done(&mut self, pr: usize) { + if self.now >= self.warm { + self.wall.0 += self.now - self.run_at[pr]; + self.wall.1 += 1; + } + if self.rng.gen_f64() < self.c.p_red { + self.at(self.c.t_fix_min, Ev::Fixed(pr)); + } else { + self.checks_green(pr); + } + } + fn checks_green(&mut self, pr: usize) { self.green[pr] = true; + self.green_at[pr] = self.now; match self.c.rev_mode { ReviewMode::Serial => self.request_review(pr), ReviewMode::Concurrent if self.rev_ok[pr] => self.at(self.c.t_arm_min, Ev::Armed(pr)), @@ -334,6 +381,16 @@ impl Sim { fn merge(&mut self, pr: usize) { if self.now >= self.warm { self.sojourn.push(self.now - self.open_at[pr]); + let (open, green, armed) = (self.open_at[pr], self.green_at[pr], self.enq_at[pr]); + let stages = [ + green - open, + armed - green, + self.now - armed, + self.pass_at[pr] - armed, + ]; + for (s, d) in self.stage_sum.iter_mut().zip(stages) { + *s += d; + } } self.open -= 1; if let Some(a) = self.blocked.pop_front() { @@ -353,10 +410,7 @@ impl Sim { } self.dispatch_pr(); } - Ev::PrRun(pr) if self.rng.gen_f64() < self.c.p_red => { - self.at(self.c.t_fix_min, Ev::Fixed(pr)); - } - Ev::PrRun(pr) => self.checks_green(pr), + Ev::PrRun(pr) => self.run_done(pr), Ev::Reviewed(pr) => self.reviewed(pr), Ev::Fixed(pr) => self.start_run(pr), Ev::Armed(pr) => { @@ -397,6 +451,8 @@ pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> Pr enq_at: Vec::new(), groups: Vec::new(), group_jobs_left: Vec::new(), + group_at: Vec::new(), + pass_wall: (0.0, 0), active: 0, timer_at: -1.0, sojourn: Vec::new(), @@ -410,6 +466,11 @@ pub fn run_pr_flow(c: &PrFlowConfig, seed: u64, hours: f64, warmup_h: f64) -> Pr rev_wait: VecDeque::new(), green: Vec::new(), rev_ok: Vec::new(), + green_at: Vec::new(), + run_at: Vec::new(), + wall: (0.0, 0), + stage_sum: [0.0; 4], + pass_at: Vec::new(), last_t: 0.0, runs: 0, prs_seen: 0, @@ -458,6 +519,12 @@ impl Sim { mq_util: self.mq.busy_min / (span * self.mq.size as f64), runs_per_pr: self.runs as f64 / (self.prs_seen as f64).max(1.0), rho_a: 1.0 - self.blocked_area / (span * self.c.n.ceil().max(1.0)), + run_wall_min: self.wall.0 / (self.wall.1 as f64).max(1.0), + checks_min: self.stage_sum[0] / n.max(1.0), + arm_min: self.stage_sum[1] / n.max(1.0), + mq_min: self.stage_sum[2] / n.max(1.0), + pass_wait_min: self.stage_sum[3] / n.max(1.0), + pass_wall_min: self.pass_wall.0 / (self.pass_wall.1 as f64).max(1.0), } } } @@ -526,6 +593,86 @@ pub fn binding_station(c: &PrFlowConfig, r: &PrFlowResult) -> &'static str { .map_or("none", |b| b.0) } +/// Flow model eq 7, time open, term by term. +/// +/// `W = r(w + c_wall) + (r-1)t_fix + t_arm + p_dirty/(1-p_dirty)(t_reb + r(w + c_wall) + (r-1)t_fix + t_arm) + W_mq` +/// with `W_mq = wait_mq + (w_pass + t_pass)/(1-f)`. This line has no rebase, so `p_dirty = 0`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Eq7Law { + /// Runs per PR, `1/(1-p_red)` (eq 4). + pub r: f64, + /// Runner wait per run: the sim's mean run wall less `c_wall`. + pub w_min: f64, + /// Wall of one run with free runners: job minutes plus setup. + pub c_wall_min: f64, + pub t_fix_min: f64, + /// Green to armed, plus the review minutes when the review is serial. + pub t_arm_min: f64, + pub p_dirty: f64, + pub t_reb_min: f64, + /// Armed to the start of the first merge pass that holds the PR. + pub wait_mq_min: f64, + /// Wall of one merge pass with free runners: job minutes plus setup. + pub t_pass_min: f64, + /// Runner wait inside one merge pass: the sim's mean pass wall less `t_pass`. + pub w_pass_min: f64, + pub f: f64, +} + +impl Eq7Law { + /// Open to green: `r(w + c_wall) + (r-1)t_fix`. + #[must_use] + pub fn checks_min(&self) -> f64 { + self.r * (self.w_min + self.c_wall_min) + (self.r - 1.0) * self.t_fix_min + } + + /// Rebase loop: `p_dirty/(1-p_dirty)(t_reb + checks + t_arm)`. + #[must_use] + pub fn rebase_min(&self) -> f64 { + self.p_dirty / (1.0 - self.p_dirty) * (self.t_reb_min + self.checks_min() + self.t_arm_min) + } + + /// `W_mq = wait_mq + (w_pass + t_pass)/(1-f)`: a flake sends the whole group round again. + #[must_use] + pub fn w_mq_min(&self) -> f64 { + self.wait_mq_min + (self.w_pass_min + self.t_pass_min) / (1.0 - self.f) + } + + /// Eq 7, mean time open. + #[must_use] + pub fn w_law_min(&self) -> f64 { + self.checks_min() + self.t_arm_min + self.rebase_min() + self.w_mq_min() + } +} + +/// Eq 7 evaluated on `c`, with the queue waits (`w`, `wait_mq`, `w_pass`) read from the run `r`. +/// +/// `wait_mq` is the wait for a merge pass to start, not the batch timer: with one build at a +/// time the slot is busy, so an armed PR waits out the pass in flight and any overflow. +#[must_use] +pub fn eq7_law(c: &PrFlowConfig, r: &PrFlowResult) -> Eq7Law { + let c_wall_min = c.pr_runner_min / c.pr_jobs.max(1) as f64 + c.pr_setup_min; + let t_pass_min = c.mq_runner_min / c.mq_jobs.max(1) as f64 + c.mq_setup_min; + let review = match c.rev_mode { + ReviewMode::Serial if c.rev_tiers.is_empty() => c.t_rev_min, + ReviewMode::Serial => c.rev_tiers.iter().sum::() / c.rev_tiers.len() as f64, + ReviewMode::Concurrent => 0.0, + }; + Eq7Law { + r: 1.0 / (1.0 - c.p_red), + w_min: (r.run_wall_min - c_wall_min).max(0.0), + c_wall_min, + t_fix_min: c.t_fix_min, + t_arm_min: c.t_arm_min + review, + p_dirty: 0.0, + t_reb_min: 0.0, + wait_mq_min: r.pass_wait_min, + t_pass_min, + w_pass_min: (r.pass_wall_min - t_pass_min).max(0.0), + f: c.f, + } +} + /// The reference run: seeds 1..=5, 400 h, 20 h warm-up, averaged. #[must_use] pub fn run_reference(c: &PrFlowConfig) -> PrFlowResult { @@ -541,6 +688,12 @@ pub fn run_reference(c: &PrFlowConfig) -> PrFlowResult { acc.mq_util += r.mq_util / 5.0; acc.runs_per_pr += r.runs_per_pr / 5.0; acc.rho_a += r.rho_a / 5.0; + acc.run_wall_min += r.run_wall_min / 5.0; + acc.checks_min += r.checks_min / 5.0; + acc.arm_min += r.arm_min / 5.0; + acc.mq_min += r.mq_min / 5.0; + acc.pass_wait_min += r.pass_wait_min / 5.0; + acc.pass_wall_min += r.pass_wall_min / 5.0; } acc } @@ -717,6 +870,52 @@ mod tests { } } + #[test] + fn eq7_time_open_matches_the_sim_within_2_percent_on_design_d() { + let c = preset("D").expect("preset"); + let r = run_reference(&c); + let law = eq7_law(&c, &r); + assert!( + within(r.w_mean_min, law.w_law_min(), 0.02), + "sim W {:.1} vs law W {:.1} ({law:?})", + r.w_mean_min, + law.w_law_min() + ); + } + + #[test] + fn eq7_terms_match_their_sim_stages() { + let c = preset("D").expect("preset"); + let r = run_reference(&c); + let law = eq7_law(&c, &r); + assert!(within(r.checks_min, law.checks_min(), 0.02), "checks"); + assert!(within(r.arm_min, law.t_arm_min, 0.02), "arm"); + assert!(within(r.mq_min, law.w_mq_min(), 0.02), "W_mq"); + } + + #[test] + fn the_batch_timer_is_not_the_merge_queue_wait_with_one_build() { + // One build at a time keeps the build slot busy: an armed PR waits for the pass in + // flight (and any overflow past the batch size), not for the 5-minute batch timer. + let c = preset("D").expect("preset"); + let r = run_reference(&c); + let timer = Eq7Law { + wait_mq_min: c.mq_wait_min, + ..eq7_law(&c, &r) + }; + assert!( + !within(r.w_mean_min, timer.w_law_min(), 0.02), + "timer-wait law {:.1} matched sim {:.1}", + timer.w_law_min(), + r.w_mean_min + ); + assert!( + r.pass_wait_min > 2.0 * c.mq_wait_min, + "wait {}", + r.pass_wait_min + ); + } + #[test] fn same_seed_same_output() { let c = preset("D").expect("preset"); diff --git a/crates/aprender-simulate/tests/pr_flow_contract.rs b/crates/aprender-simulate/tests/pr_flow_contract.rs index 96869b8b72..f53f6adb0f 100644 --- a/crates/aprender-simulate/tests/pr_flow_contract.rs +++ b/crates/aprender-simulate/tests/pr_flow_contract.rs @@ -107,3 +107,22 @@ fn json_block_reports_the_author_busy_fraction_beside_the_assumption() { assert!((0.0..=1.0).contains(&rho), "rho_a {rho}"); assert!((v["predicted"]["rho_a_assumed"].as_f64().expect("assumed") - 0.95).abs() < 1e-9); } + +#[test] +fn json_law_block_carries_eq7_within_2_percent_of_the_sim_on_design_d() { + let v: serde_json::Value = serde_json::from_str(&block("D")).expect("json"); + let (sim, law) = ( + num(&v, &["predicted", "W_min_mean"]), + num(&v, &["law", "W_law_min"]), + ); + assert!( + (sim - law).abs() <= law * 0.02, + "sim W {sim:.1} vs law W {law:.1}" + ); + let terms = num(&v, &["law", "checks_min"]) + + num(&v, &["law", "t_arm_min"]) + + num(&v, &["law", "rebase_min"]) + + num(&v, &["law", "W_mq_min"]); + assert!((terms - law).abs() < 1e-9, "terms {terms} sum to {law}"); + assert_eq!(num(&v, &["law", "p_dirty"]), 0.0); +} From 461bf9ef1921a8fbea4bc338f21b566ec342a95e Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 16:19:58 +0200 Subject: [PATCH 18/24] fix(contracts): declare sim pr-flow, repo-flow, course-flow in apr-cli-commands-v1; regen contracts.nt and shapes.ttl with the repo-built pv (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- contracts/apr-cli-commands-v1.yaml | 2 +- contracts/contracts.nt | 7 +++++++ contracts/shapes.ttl | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/contracts/apr-cli-commands-v1.yaml b/contracts/apr-cli-commands-v1.yaml index a79469f533..44fafd6f0c 100644 --- a/contracts/apr-cli-commands-v1.yaml +++ b/contracts/apr-cli-commands-v1.yaml @@ -901,7 +901,7 @@ commands: side_effects: [filesystem] # `version` comes from #2527's simular clap conversion, which the batch # takes over #2493's. FALSIFY-CLI-006 caught the omission. - subcommands: [emc-check, emc-validate, list-emc, render, run, validate, verify, version] + subcommands: [course-flow, emc-check, emc-validate, list-emc, pr-flow, render, repo-flow, run, validate, verify, version] - name: cgp category: tools diff --git a/contracts/contracts.nt b/contracts/contracts.nt index c34b6bfbee..c3ed1fb995 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -9709,6 +9709,13 @@ "corpus-assembly"^^ . "PROPOSED"^^ . "1.0.0"^^ . + . + . + . + "contracts/course-flow-batch-wip-v1.yaml"^^ . + "course-flow-batch-wip-v1"^^ . + "course-flow-batch-wip"^^ . + "1.0.0"^^ . . . . diff --git a/contracts/shapes.ttl b/contracts/shapes.ttl index 3d3f6cdd2a..a718856fa0 100644 --- a/contracts/shapes.ttl +++ b/contracts/shapes.ttl @@ -908,9 +908,9 @@ ] ; sh:property [ sh:path ; - sh:minCount 9 ; - sh:maxCount 9 ; - sh:in ( "emc-check" "emc-validate" "help" "list-emc" "render" "run" "validate" "verify" "version" ) ; + sh:minCount 12 ; + sh:maxCount 12 ; + sh:in ( "course-flow" "emc-check" "emc-validate" "help" "list-emc" "pr-flow" "render" "repo-flow" "run" "validate" "verify" "version" ) ; ] ; sh:property [ sh:path ; From 7837481b6c7d932cee1c189d8fb70ad96b3fc9e9 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 18:20:27 +0200 Subject: [PATCH 19/24] fix(contracts): course-flow-batch-wip declares falsification_tests (SCHEMA-026); regen ONT witness and README contract count (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++-- contracts/course-flow-batch-wip-v1.yaml | 17 ++++++++++------- ...015f0be2f90923120a3027c176ad56723d0d850.json | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0d2356ff40..027a6b2a7d 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ publishing — all backed by YAML provable contracts that fail CI on drift. | Metric | Count | Source of truth | |-------:|------:|---| | Workspace crates | **81** workspace crates | `cargo metadata --no-deps` (NOT `ls crates/` — 4 are `exclude`d, 1 has no Cargo.toml) | -| Provable contracts | **1983** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | +| Provable contracts | **1984** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | | CLI commands | **113** CLI commands | `contracts/apr-cli-commands-v1.yaml` §`commands` (parse the list; `apr --help` also lists `help` itself, and `grep -c '^ - name:'` gives 119 — other same-indent `name:` keys exist in the file) | | Book CLI chapters | **114** chapters | `ls book/src/cli/*.md` | | Book lib chapters | **73** chapters | `ls book/src/lib/*.md` (parity with `pub mod`) | @@ -403,7 +403,7 @@ falsification_tests: prediction: apr validate bad-model.apr exits non-zero ``` -The tree carries 1983 contracts across inference, training, quantization, attention, FFN, +The tree carries 1984 contracts across inference, training, quantization, attention, FFN, tokenization, model formats, CLI safety — and this README itself. ## Migration from old crates diff --git a/contracts/course-flow-batch-wip-v1.yaml b/contracts/course-flow-batch-wip-v1.yaml index 35e8ff724b..2ba159015a 100644 --- a/contracts/course-flow-batch-wip-v1.yaml +++ b/contracts/course-flow-batch-wip-v1.yaml @@ -27,18 +27,21 @@ equations: description: "mean batch wait lies inside one gate interval" formula: "0 < mean_batch_wait_h < 24" -falsification: +falsification_tests: - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_001 description: "Little's law holds within 0.5% with the batch wait inside WIP" - test: "littles_law_holds_with_batch_wait_inside_wip in crates/aprender-simulate/src/scenarios/course_flow.rs" - evidence: "cargo test -p aprender-simulate --lib course_flow" + test: "cargo test -p aprender-simulate --lib littles_law_holds_with_batch_wait_inside_wip" + prediction: "the test passes" + if_fails: "the batch gate is not counted inside WIP as the contract states" - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_002 description: "mean_batch_wait_h is reported and lies inside one day" - test: "mean_batch_wait_is_reported_and_inside_a_day in crates/aprender-simulate/src/scenarios/course_flow.rs" - evidence: "cargo test -p aprender-simulate --lib course_flow" + test: "cargo test -p aprender-simulate --lib mean_batch_wait_is_reported_and_inside_a_day" + prediction: "the test passes" + if_fails: "the batch gate is not counted inside WIP as the contract states" - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_003 description: "counting the batch inside WIP changes the result (not a no-op)" - test: "batch_in_wip_is_not_a_no_op in crates/aprender-simulate/src/scenarios/course_flow.rs" - evidence: "cargo test -p aprender-simulate --lib course_flow" + test: "cargo test -p aprender-simulate --lib batch_in_wip_is_not_a_no_op" + prediction: "the test passes" + if_fails: "the batch gate is not counted inside WIP as the contract states" diff --git a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json index 184d38c1eb..6bb276cb32 100644 --- a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json +++ b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json @@ -1,7 +1,7 @@ { - "census_id_set_sha256": "021e98642f5a6c17b5389d388a0d4b18df2707f513fda99ae35b661dca0b03c3", + "census_id_set_sha256": "7597b4ad189f534dd58cda55c9b1fe29e6339ddb77fd5f40969b4a028eed8f88", "relations_sha256": "811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850", - "reasoner_git_sha": "a56c47b937da11a6c94132b89c28d9420a84f372", + "reasoner_git_sha": "461bf9ef1921a8fbea4bc338f21b566ec342a95e", "checkable_n": 87, "result": { "kind": "model", From 0ecee41d04daab87afb203c760807f479a6b0740 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 19:24:56 +0200 Subject: [PATCH 20/24] fix(contracts): course-flow-batch-wip falsification ids FALSIFY-001..003 (Refs #5511) Co-Authored-By: Claude Sonnet 5.5 --- contracts/course-flow-batch-wip-v1.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/course-flow-batch-wip-v1.yaml b/contracts/course-flow-batch-wip-v1.yaml index 2ba159015a..3e369efed0 100644 --- a/contracts/course-flow-batch-wip-v1.yaml +++ b/contracts/course-flow-batch-wip-v1.yaml @@ -28,19 +28,19 @@ equations: formula: "0 < mean_batch_wait_h < 24" falsification_tests: - - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_001 + - id: FALSIFY-001 description: "Little's law holds within 0.5% with the batch wait inside WIP" test: "cargo test -p aprender-simulate --lib littles_law_holds_with_batch_wait_inside_wip" prediction: "the test passes" if_fails: "the batch gate is not counted inside WIP as the contract states" - - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_002 + - id: FALSIFY-002 description: "mean_batch_wait_h is reported and lies inside one day" test: "cargo test -p aprender-simulate --lib mean_batch_wait_is_reported_and_inside_a_day" prediction: "the test passes" if_fails: "the batch gate is not counted inside WIP as the contract states" - - id: FALSIFY-COURSE_FLOW_BATCH_WIP_V1_003 + - id: FALSIFY-003 description: "counting the batch inside WIP changes the result (not a no-op)" test: "cargo test -p aprender-simulate --lib batch_in_wip_is_not_a_no_op" prediction: "the test passes" From b60e40443d0edd58995817f70566f0d30ee452ea Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 15:19:13 +0200 Subject: [PATCH 21/24] test(simulate): course_flow Content Factory v2 falsifiers, stage at eq 6 law, build at eq 14 law (RED; flow model B5, Refs #5557) Fan-out to R = 3 renditions with no split (eq 3), the eq 6 stage law 5 * 360 / (1440 * 2) = 62.5% within 2 points, alpha rework at 0.16 keeping build busy at 79.5% / (1 - alpha) <= 95% (eq 14), GPU load B*t_g + (B*k + 4)*t_v (eq 5), trunk-first GPU priority while W > 4.8 d, and t_b work (eq 2) split from t_b elapsed (eq 1). RED: the v2 config and result fields do not exist yet. Agent: aprender-w5032 Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit 29858335a9fa1ecd86c152ef7a096cc0902bb7f7) --- .../src/scenarios/course_flow.rs | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/crates/aprender-simulate/src/scenarios/course_flow.rs b/crates/aprender-simulate/src/scenarios/course_flow.rs index 3c5dbe3d41..a334091ef4 100644 --- a/crates/aprender-simulate/src/scenarios/course_flow.rs +++ b/crates/aprender-simulate/src/scenarios/course_flow.rs @@ -629,4 +629,195 @@ mod tests { fn unknown_case_is_none() { assert!(course_preset("nope").is_none()); } + + // ---- B5: Content Factory v2 (flow model eqs 2, 3, 5, 6, 14) ---- + + fn v2() -> CourseFlowConfig { + course_preset("factory-v2").expect("factory-v2 case") + } + + /// Flow model eq 6 law: lanes busy = B * t_sc / (1440 * lanes) = 5 * 360 / (1440 * 2) = 62.5%. + const STAGE_LAW: f64 = 5.0 * 360.0 / (DAY * 2.0); + + /// Flow model eq 2 law at N_b = 11, t_b = 42 h: 5 * 2520 / (1440 * 11) = 79.5%. + const BUILD_LAW: f64 = 5.0 * 2520.0 / (DAY * 11.0); + + /// Eq 6: with every master fanned out (no split) the stage matches the law within 2 points. + #[test] + fn coursera_stage_matches_the_law_within_two_points() { + let r = run_course_reference(&v2()); + assert!( + (r.stage_util - STAGE_LAW).abs() <= 0.02, + "stage sim {:.3} vs law {STAGE_LAW:.3}", + r.stage_util + ); + } + + /// The legacy split reproduces the recorded gap: share 0.4 of the law, not the law. + #[test] + fn the_legacy_split_is_the_recorded_gap() { + let r = got("design"); + assert!( + (r.stage_util - 0.4 * STAGE_LAW).abs() <= 0.02, + "{:.3}", + r.stage_util + ); + } + + /// Eq 3: launches/day = B * R with R = 3; the legacy split launches each course once. + #[test] + fn fan_out_launches_three_renditions_per_master() { + let r = run_course_reference(&v2()); + assert!((r.launches_per_day - 3.0 * r.courses_per_day).abs() < 1e-9); + assert!( + (r.courses_per_day - 4.92).abs() <= 4.92 * 0.05, + "{:.3}", + r.courses_per_day + ); + let legacy = got("design"); + assert!((legacy.launches_per_day - legacy.courses_per_day).abs() < 1e-9); + } + + /// Eq 14: at alpha = 0.16 the build busy fraction is 79.5% / (1 - alpha) <= 95%, within 2 points. + #[test] + fn rework_at_alpha_016_keeps_build_at_or_under_95_percent() { + let mut c = v2(); + c.rework_alpha = 0.16; + let r = run_course_reference(&c); + let law = BUILD_LAW / (1.0 - 0.16); + assert!(r.util[BUILD] <= 0.95, "build busy {:.3}", r.util[BUILD]); + assert!( + (r.util[BUILD] - law).abs() <= 0.02, + "build sim {:.3} vs law {law:.3}", + r.util[BUILD] + ); + } + + /// Eq 14 falsifier: alpha = 0 leaves the build at the eq 2 law, so the rework loop is what moved it. + #[test] + fn without_rework_build_sits_at_the_eq2_law() { + let r = run_course_reference(&v2()); + assert!( + (r.util[BUILD] - BUILD_LAW).abs() <= 0.02, + "{:.3}", + r.util[BUILD] + ); + } + + /// Eq 5: GPU busy = (B * t_g + (B * k + 4) * t_v) / 1440 with k = 1, t_v = 5 min. + #[test] + fn gpu_carries_trunk_shorts_and_four_linkedin_renders() { + let r = run_course_reference(&v2()); + let law = (5.0 * 100.0 + (5.0 + 4.0) * 5.0) / DAY; + assert!( + (r.util[RENDER] - law).abs() <= 0.02, + "gpu sim {:.3} vs law {law:.3}", + r.util[RENDER] + ); + assert!((r.feed_renders_per_day - 4.0).abs() <= 0.1); + assert!((r.shorts_per_day - 5.0).abs() <= 0.1); + } + + /// GPU priority: on an overloaded GPU, trunk-first while W > 4.8 d ships more courses than + /// Shorts-first, and the Shorts pay for it. + #[test] + fn trunk_first_while_w_over_threshold_wins_the_gpu() { + let mut on = v2(); + on.render_min = 300.0; + let mut off = on.clone(); + off.trunk_first_w_days = None; + let a = run_course_reference(&on); + let b = run_course_reference(&off); + assert!( + a.courses_per_day >= b.courses_per_day + 0.08, + "trunk-first {:.3} vs shorts-first {:.3}", + a.courses_per_day, + b.courses_per_day + ); + assert!( + a.shorts_per_day < b.shorts_per_day, + "{:.2}", + a.shorts_per_day + ); + } + + /// Eq 2 vs eq 1: the builder holds t_b_work; lead time carries t_b_elapsed. Longer elapsed + /// time must move lead time and leave build busy at the sizing law. + #[test] + fn build_work_sizes_the_station_and_elapsed_drives_lead_time() { + let base = run_course_reference(&v2()); + let mut c = v2(); + c.build_elapsed_min = 60.0 * 60.0; + let r = run_course_reference(&c); + assert!( + (r.t_b_work_h - 42.0).abs() <= 42.0 * 0.03, + "{:.2}", + r.t_b_work_h + ); + assert!(r.t_b_elapsed_h >= 60.0, "{:.2}", r.t_b_elapsed_h); + assert!( + (r.util[BUILD] - BUILD_LAW).abs() <= 0.02, + "{:.3}", + r.util[BUILD] + ); + let dl = r.lead_days - base.lead_days; + assert!((dl - 0.75).abs() <= 0.15, "lead moved {dl:.3} d"); + assert!(base.t_b_elapsed_h >= base.t_b_work_h); + } + + /// The new inputs reach the model through the ledger and come back out in the sim block. + #[test] + fn v2_inputs_round_trip_through_the_ledger_and_sim_block() { + let mut c = course_preset("design").expect("case"); + let v = serde_json::json!({ + "fan_out": 1, "rework_alpha": 0.16, "trunk_first_w_days": 4.8, + "linkedin_renders_per_day": 4, "vertical_render_min": 6, + "t_b_work_h": 40, "t_b_elapsed_h": 50, + }); + apply_course_inputs(&mut c, &v).expect("inputs"); + assert!(c.fan_out); + assert!((c.rework_alpha - 0.16).abs() < 1e-12); + assert_eq!(c.trunk_first_w_days, Some(4.8)); + assert!((c.build_min - 2400.0).abs() < 1e-9); + assert!((c.build_elapsed_min - 3000.0).abs() < 1e-9); + let r = run_course_flow(&c, 1, 30.0, 5.0); + let b = course_sim_block("design", &c, &r); + for k in [ + "fan_out", + "rework_alpha", + "trunk_first_w_days", + "linkedin_renders_per_day", + "vertical_render_min", + "t_b_work_h", + "t_b_elapsed_h", + ] { + assert!(b["inputs"].get(k).is_some(), "inputs.{k}"); + } + for k in [ + "stage_util_pct", + "launches_day", + "t_b_work_h", + "t_b_elapsed_h", + ] { + assert!(b["predicted"].get(k).is_some(), "predicted.{k}"); + } + let mut off = c.clone(); + apply_course_inputs(&mut off, &serde_json::json!({"trunk_first_w_days": 0})).expect("off"); + assert_eq!(off.trunk_first_w_days, None); + } + + /// The v2 preset keeps the daily 07:00 batch and counts it inside WIP (flow model eq 1). + #[test] + fn v2_keeps_the_daily_batch_inside_wip() { + let c = v2(); + assert!(c.batch_in_wip && c.fan_out); + let r = run_course_reference(&c); + assert!(r.mean_batch_wait_h > 0.5 && r.mean_batch_wait_h < 24.0); + let lam_w = r.courses_per_day * r.lead_days; + assert!( + (lam_w - r.l).abs() <= r.l * 0.005, + "{lam_w:.3} vs {:.3}", + r.l + ); + } } From 1f1d170365674c31dc30b005e1be0ca86507556e Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 15:29:21 +0200 Subject: [PATCH 22/24] feat(simulate): course_flow Content Factory v2, fan-out, rework, GPU priority, build work vs elapsed (flow model B5, Refs #5557) Opt-in config fields; the defaults reproduce the original model, so every existing band test is unchanged (same RNG draws, same event stream). - fan_out: every master fans out to R = 3 renditions with no split (eq 3); launches_per_day = 3 * courses_per_day. - rework_alpha: the distinctness gate sends a share alpha back to build (eq 14). - trunk_first_w_days: the GPU serves the trunk first while the recent course W (last 20 completions) exceeds it, else Shorts. - linkedin_renders_per_day, vertical_render_min: feed renders share the GPU (eq 5). - build_elapsed_min, and result t_b_work_h / t_b_elapsed_h: build work (eq 2, sizing) split from build elapsed (eq 1, lead time). Ledger inputs t_b_work_h and t_b_elapsed_h; both, and stage_util_pct / launches_day, appear in the sim block. - Preset factory-v2 keeps the 07:00 UTC batch, counted inside WIP. Measured (seeds 1..5, 300 d, 20 d warm-up): stage 62.47% vs eq 6 law 62.5%; legacy split 24.97% (= 0.4 of the law); build at alpha 0.16 94.27% vs eq 14 law 94.64%; GPU 37.6% vs eq 5 law 37.8%. Finding: the GPU priority rule reorders work and cannot add GPU minutes. Shorts are never dropped, so under GPU overload courses/day is the same either way and Shorts carry the wait. The RED test that asserted +0.08 courses/day was replaced by a mechanism test of the pick and a conservation test. Mutation: rework disabled -> rework_at_alpha_016 RED (0.793 vs 0.947); priority disabled -> gpu_serves_trunk_first... and gpu_priority_conserves... RED; fan-out reverted -> coursera_stage_matches_the_law RED (0.252 vs 0.625). Restored: green. Contract: contracts/course-flow-content-factory-v2-v1.yaml (pv validate: valid). Agent: aprender-w5032 Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit 9469789ebe6a3e6b9af26e031207d9a1eb5faa05) --- .../course-flow-content-factory-v2-v1.yaml | 85 ++++ .../src/scenarios/course_flow.rs | 432 ++++++++++++++---- 2 files changed, 428 insertions(+), 89 deletions(-) create mode 100644 contracts/course-flow-content-factory-v2-v1.yaml diff --git a/contracts/course-flow-content-factory-v2-v1.yaml b/contracts/course-flow-content-factory-v2-v1.yaml new file mode 100644 index 0000000000..da9dfaaf60 --- /dev/null +++ b/contracts/course-flow-content-factory-v2-v1.yaml @@ -0,0 +1,85 @@ +metadata: + version: "1.0.0" + created: "2026-10-18" + author: PAIML Engineering + registry: true + references: + - "Little, J.D.C. (1961), A Proof for the Queuing Formula L = lambda W." + - "Denning, P.J. and Buzen, J.P. (1978), The Operational Analysis of Queueing Network Models (utilization and forced flow laws)." + - "flow model spec, internal (eqs 1, 2, 3, 5, 6, 14)" + description: "course_flow Content Factory v2: fan-out to three renditions, rework loop, GPU priority, build work vs elapsed" + +name: course-flow-content-factory-v2 +version: "1.0.0" +scope: "crates/aprender-simulate/src/scenarios/course_flow.rs" + +description: | + Content Factory v2 is opt-in on CourseFlowConfig; the defaults reproduce the + original course_flow model. With fan_out, every master fans out to R = 3 + renditions with no split, so every course takes the staging lanes and the + stage busy fraction meets the utilization law. A rework loop sends a share + alpha of masters back to build, so build carries B / (1 - alpha) passes a day. + The GPU serves the trunk first while the recent course W exceeds a threshold, + else Shorts, and carries LinkedIn feed renders beside the Shorts. Build work + time (holds a builder; sizing) is split from build elapsed time (lead time). + +equations: + fan_out_forced_flow: + description: "launches per day = B * R with R = 3 (flow model eq 3)" + formula: "launches_per_day = 3 * courses_per_day" + + stage_utilization: + description: "stage busy = B * t_sc / (1440 * lanes) = 5 * 360 / (1440 * 2) = 0.625 (flow model eq 6)" + formula: "|stage_util - B * t_sc / (1440 * lanes)| <= 0.02" + + rework_build_utilization: + description: "build busy = B * t_b / (1440 * N_b) / (1 - alpha); at alpha = 0.16 that is 0.795 / 0.84 <= 0.95 (flow model eqs 2, 14)" + formula: "util_build <= 0.95 and |util_build - 0.795 / (1 - alpha)| <= 0.02" + + gpu_utilization: + description: "GPU busy = (B * t_g + (B * k + 4) * t_v) / 1440 (flow model eq 5)" + formula: "|util_render - (B * t_g + (B * k + 4) * t_v) / 1440| <= 0.02" + + gpu_priority_rule: + description: "trunk first while the recent course W > threshold, else Shorts; the rule reorders and cannot add GPU minutes" + formula: "pick(render) = course iff W_recent > w_threshold" + + build_work_vs_elapsed: + description: "a builder holds t_b_work (sizing); lead time carries t_b_elapsed (flow model eqs 1, 2)" + formula: "util_build = B * t_b_work / (1440 * N_b) and dW = t_b_elapsed - t_b_work" + +falsification: + - id: FALSIFY-COURSE_FLOW_CF_V2_001 + description: "the stage busy fraction matches the eq 6 law 62.5% within 2 points under fan-out" + test: "coursera_stage_matches_the_law_within_two_points in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_CF_V2_002 + description: "at alpha = 0.16 build busy is at most 95% and within 2 points of 0.795 / (1 - alpha)" + test: "rework_at_alpha_016_keeps_build_at_or_under_95_percent in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_CF_V2_003 + description: "fan-out launches three renditions per master; the legacy split launches one" + test: "fan_out_launches_three_renditions_per_master in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_CF_V2_004 + description: "the GPU serves the trunk first only while W is over the threshold, and only on the GPU" + test: "gpu_serves_trunk_first_only_while_w_is_over_the_threshold in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_CF_V2_005 + description: "GPU busy matches the eq 5 law with Shorts and four LinkedIn feed renders a day" + test: "gpu_carries_trunk_shorts_and_four_linkedin_renders in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_CF_V2_006 + description: "build work sizes the station; build elapsed beyond work moves lead time, not build busy" + test: "build_work_sizes_the_station_and_elapsed_drives_lead_time in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" + + - id: FALSIFY-COURSE_FLOW_CF_V2_007 + description: "the v2 inputs round-trip through the ledger inputs and the sim block" + test: "v2_inputs_round_trip_through_the_ledger_and_sim_block in crates/aprender-simulate/src/scenarios/course_flow.rs" + evidence: "cargo test -p aprender-simulate --lib course_flow" diff --git a/crates/aprender-simulate/src/scenarios/course_flow.rs b/crates/aprender-simulate/src/scenarios/course_flow.rs index a334091ef4..fc46be46b1 100644 --- a/crates/aprender-simulate/src/scenarios/course_flow.rs +++ b/crates/aprender-simulate/src/scenarios/course_flow.rs @@ -4,6 +4,12 @@ //! launch with no human step; Coursera courses wait for the daily 07:00 UTC submission batch. //! Shorts take priority over courses on the shared stations (render GPU, Chrome lane). Service //! times are Erlang-4 (gamma, coefficient of variation 0.5). Time is minutes from 00:00 UTC. +//! +//! Content Factory v2 (flow model B5) is opt-in through the config: every master fans out to +//! R = 3 renditions with no split (eq 3), a rework loop sends a share alpha of masters back to +//! build (eq 14), the GPU serves the trunk first while the recent course W exceeds a threshold +//! (eq 5), LinkedIn feed renders share the GPU, and build work time (eq 2, sizing) is split +//! from build elapsed time (eq 1, lead time). The defaults reproduce the original model. use std::collections::VecDeque; @@ -20,11 +26,16 @@ const RENDER: usize = 3; const VERIFY: usize = 4; const CHROME: usize = 5; const NAMES: [&str; 6] = ["outline", "build", "design", "render", "verify", "chrome"]; +/// Renditions per master under fan-out (flow model eq 3): edX, Coursera, LinkedIn Learning. +const FAN_OUT_R: f64 = 3.0; +/// Completed courses the GPU priority rule averages to estimate the current W. +const W_WINDOW: usize = 20; /// Inputs of the factory. Times are minutes per course; `*_lanes`/`builders`/`gpus` are servers. #[derive(Debug, Clone, PartialEq)] pub struct CourseFlowConfig { pub builders: usize, + /// Builder-held work per build pass (flow model eq 2, sizing). pub build_min: f64, pub design_lanes: usize, pub design_min: f64, @@ -40,6 +51,19 @@ pub struct CourseFlowConfig { pub verify_min: f64, /// Count a finished course as WIP until the daily batch releases it (flow model B6). pub batch_in_wip: bool, + /// Every master fans out to all R = 3 renditions, so every course takes the stage (eq 3). + pub fan_out: bool, + /// Share of masters the distinctness gate sends back to build (eq 14); 0 disables the loop. + pub rework_alpha: f64, + /// GPU serves the trunk first while the recent course W in days exceeds this; `None` keeps + /// Shorts first always. + pub trunk_first_w_days: Option, + /// LinkedIn feed renders per day on the GPU (eq 5's "+ 4"). + pub linkedin_renders_per_day: f64, + /// GPU minutes per vertical or feed render (eq 5's t_v). + pub vertical_render_min: f64, + /// Wall-clock minutes from build start to build done (eq 1); below `build_min` it equals it. + pub build_elapsed_min: f64, } /// What one seeded run measured after the warm-up. @@ -52,6 +76,16 @@ pub struct CourseFlowResult { pub util: [f64; 6], /// Mean hours a built course waits for the daily batch (courses that take the batch path). pub mean_batch_wait_h: f64, + /// Busy fraction of the stage lanes from course staging alone (eq 6). + pub stage_util: f64, + /// Platform launches per day: R per master under fan-out, one per course otherwise (eq 3). + pub launches_per_day: f64, + /// LinkedIn feed renders finished per day. + pub feed_renders_per_day: f64, + /// Mean builder-held hours per build pass (eq 2, sizing). + pub t_b_work_h: f64, + /// Mean hours from entering the build queue to leaving build, per pass (eq 1, lead time). + pub t_b_elapsed_h: f64, } impl CourseFlowResult { @@ -73,6 +107,7 @@ struct Job { enum Ev { Start, ShortTick, + FeedTick, Done(usize, Job), Delay(Job), Batch, @@ -82,6 +117,7 @@ fn pack(e: &Ev) -> SimEvent { let (name, st, j) = match e { Ev::Start => ("start", 0, None), Ev::ShortTick => ("short_tick", 0, None), + Ev::FeedTick => ("feed_tick", 0, None), Ev::Done(s, j) => ("done", *s, Some(*j)), Ev::Delay(j) => ("delay", 0, Some(*j)), Ev::Batch => ("batch", 0, None), @@ -108,6 +144,7 @@ fn unpack(e: &SimEvent) -> Option { Some(match name.as_str() { "start" => Ev::Start, "short_tick" => Ev::ShortTick, + "feed_tick" => Ev::FeedTick, "done" => Ev::Done((v >> 56) as usize, job), "delay" => Ev::Delay(job), "batch" => Ev::Batch, @@ -123,6 +160,17 @@ struct Station { busy_min: f64, } +/// Post-warm-up sums the v2 result fields are built from. +#[derive(Default)] +struct V2Tally { + stage_busy: f64, + build_work: f64, + build_work_n: u64, + build_elapsed: f64, + build_elapsed_n: u64, + feeds_done: u64, +} + struct Sim { c: CourseFlowConfig, rng: SimRng, @@ -134,17 +182,56 @@ struct Sim { pending: usize, started_at: Vec, coursera: Vec, + build_in: Vec, + recent_w: VecDeque, batch_wait: Vec<(usize, f64)>, bw_sum: f64, bw_n: u64, sojourn: Vec, shorts_done: u64, + v2: V2Tally, in_system: usize, l_area: f64, last_t: f64, } impl Sim { + fn new(c: &CourseFlowConfig, seed: u64, warmup_d: f64) -> Self { + let size = [1, c.builders, c.design_lanes, c.gpus, 4, c.chrome_lanes]; + Self { + c: c.clone(), + rng: SimRng::new(seed), + sched: EventScheduler::new(), + now: 0.0, + warm: warmup_d * DAY, + st: size + .iter() + .map(|&n| Station { + free: n, + size: n, + hi: VecDeque::new(), + lo: VecDeque::new(), + busy_min: 0.0, + }) + .collect(), + wip: 0, + pending: 0, + started_at: Vec::new(), + coursera: Vec::new(), + build_in: Vec::new(), + recent_w: VecDeque::new(), + batch_wait: Vec::new(), + bw_sum: 0.0, + bw_n: 0, + sojourn: Vec::new(), + shorts_done: 0, + v2: V2Tally::default(), + in_system: 0, + l_area: 0.0, + last_t: 0.0, + } + } + fn at(&mut self, dt: f64, e: &Ev) { self.sched .schedule(SimTime::from_secs((self.now + dt) * 60.0), pack(e)); @@ -165,18 +252,44 @@ impl Sim { self.dispatch(s); } + /// The GPU priority rule: trunk first while the recent course W exceeds the threshold. + fn trunk_first(&self) -> bool { + let Some(limit) = self.c.trunk_first_w_days else { + return false; + }; + let n = self.recent_w.len(); + n > 0 && self.recent_w.iter().sum::() / n as f64 / DAY > limit + } + + fn next_job(&mut self, s: usize) -> Option<(Job, f64)> { + let trunk = s == RENDER && self.trunk_first(); + let st = &mut self.st[s]; + if trunk { + st.lo.pop_front().or_else(|| st.hi.pop_front()) + } else { + st.hi.pop_front().or_else(|| st.lo.pop_front()) + } + } + + fn account(&mut self, s: usize, job: Job, d: f64) { + self.st[s].busy_min += d; + if s == CHROME && !job.short { + self.v2.stage_busy += d; + } + if s == BUILD { + self.v2.build_work += d; + self.v2.build_work_n += 1; + } + } + fn dispatch(&mut self, s: usize) { while self.st[s].free > 0 { - let Some((job, d)) = self.st[s] - .hi - .pop_front() - .or_else(|| self.st[s].lo.pop_front()) - else { + let Some((job, d)) = self.next_job(s) else { break; }; self.st[s].free -= 1; if self.now >= self.warm { - self.st[s].busy_min += d; + self.account(s, job, d); } self.at(d, &Ev::Done(s, job)); } @@ -185,7 +298,8 @@ impl Sim { fn start_course(&mut self) { let id = self.started_at.len(); self.started_at.push(self.now); - let cs = self.rng.gen_f64() < self.c.coursera_share; + self.build_in.push(self.now); + let cs = self.c.fan_out || self.rng.gen_f64() < self.c.coursera_share; self.coursera.push(cs); self.wip += 1; self.in_system += 1; @@ -203,9 +317,14 @@ impl Sim { fn launch(&mut self, id: usize) { self.in_system -= 1; + let w = self.now - self.started_at[id]; if self.now >= self.warm { - self.sojourn.push(self.now - self.started_at[id]); + self.sojourn.push(w); } + if self.recent_w.len() == W_WINDOW { + self.recent_w.pop_front(); + } + self.recent_w.push_back(w); } fn leave_wip(&mut self) { @@ -216,16 +335,55 @@ impl Sim { } } + /// Enter the build queue (first pass or rework); `j.step` is 1. + fn enter_build(&mut self, j: Job) { + self.build_in[j.id] = self.now; + let m = self.c.build_min; + self.enqueue(BUILD, j, m); + } + + /// The builder is free; the build itself may still run on (eq 1 elapsed beyond eq 2 work). + fn after_build(&mut self, j: Job) { + let extra = self.c.build_elapsed_min - self.c.build_min; + if extra > 0.0 { + self.at(extra, &Ev::Delay(Job { step: 7, ..j })); + } else { + self.finish_build(j); + } + } + + fn finish_build(&mut self, j: Job) { + if self.now >= self.warm { + self.v2.build_elapsed += self.now - self.build_in[j.id]; + self.v2.build_elapsed_n += 1; + } + let m = self.c.design_min; + self.enqueue(DESIGN, Job { step: 2, ..j }, m); + } + + /// The distinctness gate: rework a share alpha, else stage or launch directly. + fn after_verify(&mut self, j: Job) { + let a = self.c.rework_alpha; + if a > 0.0 && self.rng.gen_f64() < a { + self.enter_build(Job { step: 1, ..j }); + } else if self.coursera[j.id] { + let m = self.c.stage_min; + self.enqueue(CHROME, Job { step: 5, ..j }, m); + } else { + self.at(15.0, &Ev::Delay(Job { step: 6, ..j })); + } + } + fn course_next(&mut self, j: Job) { let c = self.c.clone(); let nj = |step| Job { step, ..j }; match j.step { - 0 => self.enqueue(BUILD, nj(1), c.build_min), - 1 => self.enqueue(DESIGN, nj(2), c.design_min), + 0 => self.enter_build(nj(1)), + 1 => self.after_build(j), + 7 => self.finish_build(j), 2 => self.enqueue(RENDER, nj(3), c.render_min), 3 => self.enqueue(VERIFY, nj(4), c.verify_min), - 4 if self.coursera[j.id] => self.enqueue(CHROME, nj(5), c.stage_min), - 4 => self.at(15.0, &Ev::Delay(nj(6))), + 4 => self.after_verify(j), 5 => { if !self.c.batch_in_wip { self.leave_wip(); @@ -253,17 +411,25 @@ impl Sim { } } + /// Shorts run steps 0-3 (render, wait, upload); LinkedIn feed renders run steps 4-5. fn short_next(&mut self, j: Job) { let nj = |step| Job { step, ..j }; + let tv = self.c.vertical_render_min; match j.step { - 0 => self.enqueue(RENDER, nj(1), 5.0), + 0 => self.enqueue(RENDER, nj(1), tv), 1 => self.at(20.0, &Ev::Delay(nj(2))), 2 => self.enqueue(CHROME, nj(3), 5.0), - _ => { + 3 => { if self.now >= self.warm { self.shorts_done += 1; } } + 4 => self.enqueue(RENDER, nj(5), tv), + _ => { + if self.now >= self.warm { + self.v2.feeds_done += 1; + } + } } } @@ -289,6 +455,14 @@ impl Sim { ); self.at(DAY / self.c.shorts_per_day, &Ev::ShortTick); } + Ev::FeedTick => { + self.short_next(Job { + short: true, + id: 0, + step: 4, + }); + self.at(DAY / self.c.linkedin_renders_per_day, &Ev::FeedTick); + } Ev::Done(s, j) => { self.st[s].free += 1; self.dispatch(s); @@ -306,6 +480,31 @@ impl Sim { } } } + + fn result(&self, end: f64) -> CourseFlowResult { + let span = end - self.warm; + let n = self.sojourn.len() as f64; + let mut util = [0.0; 6]; + for (u, st) in util.iter_mut().zip(&self.st) { + *u = st.busy_min / (span * st.size as f64); + } + let courses_per_day = n / span * DAY; + let r = if self.c.fan_out { FAN_OUT_R } else { 1.0 }; + let per_h = |sum: f64, k: u64| sum / (k.max(1) as f64) / 60.0; + CourseFlowResult { + courses_per_day, + shorts_per_day: self.shorts_done as f64 / span * DAY, + lead_days: self.sojourn.iter().sum::() / n.max(1.0) / DAY, + l: self.l_area / span, + util, + mean_batch_wait_h: per_h(self.bw_sum, self.bw_n), + stage_util: self.v2.stage_busy / (span * self.c.chrome_lanes.max(1) as f64), + launches_per_day: courses_per_day * r, + feed_renders_per_day: self.v2.feeds_done as f64 / span * DAY, + t_b_work_h: per_h(self.v2.build_work, self.v2.build_work_n), + t_b_elapsed_h: per_h(self.v2.build_elapsed, self.v2.build_elapsed_n), + } + } } /// Run one seed for `days`, discarding the first `warmup_d`. @@ -316,38 +515,12 @@ pub fn run_course_flow( days: f64, warmup_d: f64, ) -> CourseFlowResult { - let size = [1, c.builders, c.design_lanes, c.gpus, 4, c.chrome_lanes]; - let mut s = Sim { - c: c.clone(), - rng: SimRng::new(seed), - sched: EventScheduler::new(), - now: 0.0, - warm: warmup_d * DAY, - st: size - .iter() - .map(|&n| Station { - free: n, - size: n, - hi: VecDeque::new(), - lo: VecDeque::new(), - busy_min: 0.0, - }) - .collect(), - wip: 0, - pending: 0, - started_at: Vec::new(), - coursera: Vec::new(), - batch_wait: Vec::new(), - bw_sum: 0.0, - bw_n: 0, - sojourn: Vec::new(), - shorts_done: 0, - in_system: 0, - l_area: 0.0, - last_t: 0.0, - }; + let mut s = Sim::new(c, seed, warmup_d); s.at(0.0, &Ev::Start); s.at(0.0, &Ev::ShortTick); + if c.linkedin_renders_per_day > 0.0 { + s.at(0.0, &Ev::FeedTick); + } s.at(7.0 * 60.0, &Ev::Batch); let end = days * DAY; while let Some(ev) = s.sched.next() { @@ -365,23 +538,10 @@ pub fn run_course_flow( s.step(&e); } } - let span = end - s.warm; - let n = s.sojourn.len() as f64; - let mut util = [0.0; 6]; - for (u, st) in util.iter_mut().zip(&s.st) { - *u = st.busy_min / (span * st.size as f64); - } - CourseFlowResult { - courses_per_day: n / span * DAY, - shorts_per_day: s.shorts_done as f64 / span * DAY, - lead_days: s.sojourn.iter().sum::() / n.max(1.0) / DAY, - l: s.l_area / span, - util, - mean_batch_wait_h: s.bw_sum / (s.bw_n.max(1) as f64) / 60.0, - } + s.result(end) } -/// Named FLOW-006 factory cases. +/// Named FLOW-006 factory cases, plus "factory-v2" (Content Factory v2, flow model B5). #[must_use] pub fn course_preset(name: &str) -> Option { let mut c = CourseFlowConfig { @@ -400,6 +560,12 @@ pub fn course_preset(name: &str) -> Option { outline_min: 90.0, verify_min: 15.0, batch_in_wip: false, + fan_out: false, + rework_alpha: 0.0, + trunk_first_w_days: None, + linkedin_renders_per_day: 0.0, + vertical_render_min: 5.0, + build_elapsed_min: 0.0, }; match name { "cal-today" => { @@ -411,6 +577,12 @@ pub fn course_preset(name: &str) -> Option { "nine-builders" => c.builders = 9, "coursera-24h" => c.stage_min = 1440.0, "render-300" => c.render_min = 300.0, + "factory-v2" => { + c.fan_out = true; + c.batch_in_wip = true; + c.trunk_first_w_days = Some(4.8); + c.linkedin_renders_per_day = 4.0; + } _ => return None, } Some(c) @@ -428,20 +600,28 @@ pub fn course_cases() -> [&'static str; 5] { ] } +fn add_scaled(a: &mut CourseFlowResult, r: &CourseFlowResult, w: f64) { + a.courses_per_day += r.courses_per_day * w; + a.shorts_per_day += r.shorts_per_day * w; + a.lead_days += r.lead_days * w; + a.l += r.l * w; + a.mean_batch_wait_h += r.mean_batch_wait_h * w; + a.stage_util += r.stage_util * w; + a.launches_per_day += r.launches_per_day * w; + a.feed_renders_per_day += r.feed_renders_per_day * w; + a.t_b_work_h += r.t_b_work_h * w; + a.t_b_elapsed_h += r.t_b_elapsed_h * w; + for i in 0..6 { + a.util[i] += r.util[i] * w; + } +} + /// The reference run: seeds 1..=5, 300 days, 20 days warm-up, averaged. #[must_use] pub fn run_course_reference(c: &CourseFlowConfig) -> CourseFlowResult { let mut a = CourseFlowResult::default(); for seed in 1..=5 { - let r = run_course_flow(c, seed, 300.0, 20.0); - a.courses_per_day += r.courses_per_day / 5.0; - a.shorts_per_day += r.shorts_per_day / 5.0; - a.lead_days += r.lead_days / 5.0; - a.l += r.l / 5.0; - a.mean_batch_wait_h += r.mean_batch_wait_h / 5.0; - for i in 0..6 { - a.util[i] += r.util[i] / 5.0; - } + add_scaled(&mut a, &run_course_flow(c, seed, 300.0, 20.0), 0.2); } a } @@ -454,26 +634,48 @@ pub fn apply_course_inputs(c: &mut CourseFlowConfig, v: &serde_json::Value) -> R let obj = v.as_object().ok_or("inputs must be a JSON object")?; for (k, x) in obj { let f = x.as_f64().ok_or_else(|| format!("{k}: not a number"))?; - match k.as_str() { - "builders" => c.builders = f as usize, - "build_min" => c.build_min = f, - "design_lanes" => c.design_lanes = f as usize, - "design_min" => c.design_min = f, - "gpus" => c.gpus = f as usize, - "render_min" => c.render_min = f, - "chrome_lanes" => c.chrome_lanes = f as usize, - "stage_min" => c.stage_min = f, - "coursera_share" => c.coursera_share = f, - "wip" => c.wip = f as usize, - "starts_per_day" => c.starts_per_day = f, - "shorts_per_day" => c.shorts_per_day = f, - "batch_in_wip" => c.batch_in_wip = f != 0.0, - _ => return Err(format!("unknown input: {k}")), + if !apply_v2_input(c, k, f) { + apply_base_input(c, k, f)?; } } Ok(()) } +fn apply_base_input(c: &mut CourseFlowConfig, k: &str, f: f64) -> Result<(), String> { + match k { + "builders" => c.builders = f as usize, + "build_min" => c.build_min = f, + "design_lanes" => c.design_lanes = f as usize, + "design_min" => c.design_min = f, + "gpus" => c.gpus = f as usize, + "render_min" => c.render_min = f, + "chrome_lanes" => c.chrome_lanes = f as usize, + "stage_min" => c.stage_min = f, + "coursera_share" => c.coursera_share = f, + "wip" => c.wip = f as usize, + "starts_per_day" => c.starts_per_day = f, + "shorts_per_day" => c.shorts_per_day = f, + "batch_in_wip" => c.batch_in_wip = f != 0.0, + _ => return Err(format!("unknown input: {k}")), + } + Ok(()) +} + +/// The Content Factory v2 inputs; returns false when `k` is not one of them. +fn apply_v2_input(c: &mut CourseFlowConfig, k: &str, f: f64) -> bool { + match k { + "fan_out" => c.fan_out = f != 0.0, + "rework_alpha" => c.rework_alpha = f, + "trunk_first_w_days" => c.trunk_first_w_days = (f > 0.0).then_some(f), + "linkedin_renders_per_day" => c.linkedin_renders_per_day = f, + "vertical_render_min" => c.vertical_render_min = f, + "t_b_work_h" => c.build_min = f * 60.0, + "t_b_elapsed_h" => c.build_elapsed_min = f * 60.0, + _ => return false, + } + true +} + /// The `sim` block fields this scenario owns, in the shape the cops publish. #[must_use] pub fn course_sim_block( @@ -491,10 +693,19 @@ pub fn course_sim_block( "chrome_lanes": c.chrome_lanes, "stage_min": c.stage_min, "coursera_share": c.coursera_share, "wip": c.wip, "starts_per_day": c.starts_per_day, "shorts_per_day": c.shorts_per_day, "batch_in_wip": c.batch_in_wip, + "fan_out": c.fan_out, "rework_alpha": c.rework_alpha, + "trunk_first_w_days": c.trunk_first_w_days.unwrap_or(0.0), + "linkedin_renders_per_day": c.linkedin_renders_per_day, + "vertical_render_min": c.vertical_render_min, + "t_b_work_h": c.build_min / 60.0, + "t_b_elapsed_h": c.build_elapsed_min.max(c.build_min) / 60.0, }, "predicted": { "courses_day": r.courses_per_day, "shorts_day": r.shorts_per_day, "lead_days": r.lead_days, "L": r.l, "mean_batch_wait_h": r.mean_batch_wait_h, "busiest_util_pct": util * 100.0, + "stage_util_pct": r.stage_util * 100.0, "launches_day": r.launches_per_day, + "feed_renders_day": r.feed_renders_per_day, + "t_b_work_h": r.t_b_work_h, "t_b_elapsed_h": r.t_b_elapsed_h, }, "binding_station": station, }) @@ -718,25 +929,64 @@ mod tests { assert!((r.shorts_per_day - 5.0).abs() <= 0.1); } - /// GPU priority: on an overloaded GPU, trunk-first while W > 4.8 d ships more courses than - /// Shorts-first, and the Shorts pay for it. + /// Which job a free server at `s` takes when a Short and a course both wait, given the + /// recent course W in days. True means the course. + fn picks_course(s: usize, rule: Option, w_days: f64) -> bool { + let mut c = v2(); + c.trunk_first_w_days = rule; + let mut sim = Sim::new(&c, 1, 0.0); + sim.recent_w.push_back(w_days * DAY); + let short = Job { + short: true, + id: 0, + step: 0, + }; + let course = Job { + short: false, + id: 1, + step: 3, + }; + sim.st[s].hi.push_back((short, 5.0)); + sim.st[s].lo.push_back((course, 100.0)); + !sim.next_job(s).expect("a waiting job").0.short + } + + /// GPU priority: the trunk goes first while W > 4.8 d, else Shorts; other stations and the + /// rule switched off keep Shorts first. + #[test] + fn gpu_serves_trunk_first_only_while_w_is_over_the_threshold() { + assert!( + picks_course(RENDER, Some(4.8), 5.0), + "W 5.0 > 4.8: trunk first" + ); + assert!(!picks_course(RENDER, Some(4.8), 4.5), "W 4.5: Shorts first"); + assert!(!picks_course(RENDER, None, 9.0), "rule off: Shorts first"); + assert!( + !picks_course(CHROME, Some(4.8), 9.0), + "stage lanes: Shorts first" + ); + } + + /// GPU priority reorders the queue and cannot add GPU minutes: Shorts are never dropped, so + /// on an overloaded GPU the courses/day is the same either way, and Shorts carry the wait. #[test] - fn trunk_first_while_w_over_threshold_wins_the_gpu() { + fn gpu_priority_conserves_gpu_minutes_under_overload() { let mut on = v2(); on.render_min = 300.0; let mut off = on.clone(); off.trunk_first_w_days = None; let a = run_course_reference(&on); let b = run_course_reference(&off); + assert!(a.util[RENDER] > 0.99 && b.util[RENDER] > 0.99); assert!( - a.courses_per_day >= b.courses_per_day + 0.08, + (a.courses_per_day - b.courses_per_day).abs() <= b.courses_per_day * 0.01, "trunk-first {:.3} vs shorts-first {:.3}", a.courses_per_day, b.courses_per_day ); assert!( a.shorts_per_day < b.shorts_per_day, - "{:.2}", + "{:.3}", a.shorts_per_day ); } @@ -754,7 +1004,11 @@ mod tests { "{:.2}", r.t_b_work_h ); - assert!(r.t_b_elapsed_h >= 60.0, "{:.2}", r.t_b_elapsed_h); + assert!( + (r.t_b_elapsed_h - 60.0).abs() <= 60.0 * 0.03, + "{:.2}", + r.t_b_elapsed_h + ); assert!( (r.util[BUILD] - BUILD_LAW).abs() <= 0.02, "{:.3}", From 4c0a87672d499188eb59bc4ea6969dfdc8beff45 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 18 Oct 2026 20:14:43 +0200 Subject: [PATCH 23/24] chore(contracts): regen contracts.nt and README count for course-flow-content-factory-v2 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++-- contracts/contracts.nt | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 027a6b2a7d..191f91b659 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ publishing — all backed by YAML provable contracts that fail CI on drift. | Metric | Count | Source of truth | |-------:|------:|---| | Workspace crates | **81** workspace crates | `cargo metadata --no-deps` (NOT `ls crates/` — 4 are `exclude`d, 1 has no Cargo.toml) | -| Provable contracts | **1984** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | +| Provable contracts | **1985** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | | CLI commands | **113** CLI commands | `contracts/apr-cli-commands-v1.yaml` §`commands` (parse the list; `apr --help` also lists `help` itself, and `grep -c '^ - name:'` gives 119 — other same-indent `name:` keys exist in the file) | | Book CLI chapters | **114** chapters | `ls book/src/cli/*.md` | | Book lib chapters | **73** chapters | `ls book/src/lib/*.md` (parity with `pub mod`) | @@ -403,7 +403,7 @@ falsification_tests: prediction: apr validate bad-model.apr exits non-zero ``` -The tree carries 1984 contracts across inference, training, quantization, attention, FFN, +The tree carries 1985 contracts across inference, training, quantization, attention, FFN, tokenization, model formats, CLI safety — and this README itself. ## Migration from old crates diff --git a/contracts/contracts.nt b/contracts/contracts.nt index c3ed1fb995..68baa3779d 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -9716,6 +9716,13 @@ "course-flow-batch-wip-v1"^^ . "course-flow-batch-wip"^^ . "1.0.0"^^ . + . + . + . + "contracts/course-flow-content-factory-v2-v1.yaml"^^ . + "course-flow-content-factory-v2-v1"^^ . + "course-flow-content-factory-v2"^^ . + "1.0.0"^^ . . . . From feeacc43b03f203ffca55e407ca00124bc36425b Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 01:44:40 +0200 Subject: [PATCH 24/24] chore(contracts): regenerate pv-sat witness for the course-flow-content-factory-v2 census The B5 contracts changed the census but the witness still recorded the old one, so ont5_consistency_gate the_spec_probe_holds_on_the_repo_corpus failed with WitnessStale. Output of scripts/contracts_gate.sh (pv-sat). Agent: aprender-w5032 Co-Authored-By: Claude Sonnet 5.5 --- ...2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json index 6bb276cb32..e38191390b 100644 --- a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json +++ b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json @@ -1,7 +1,7 @@ { - "census_id_set_sha256": "7597b4ad189f534dd58cda55c9b1fe29e6339ddb77fd5f40969b4a028eed8f88", + "census_id_set_sha256": "1ff4f1bffcb59d87af86465a6fb83b5959988801194fa4436da56a73b2c62b8e", "relations_sha256": "811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850", - "reasoner_git_sha": "461bf9ef1921a8fbea4bc338f21b566ec342a95e", + "reasoner_git_sha": "4c0a87672d499188eb59bc4ea6969dfdc8beff45", "checkable_n": 87, "result": { "kind": "model",