thread_broker/controller.rs
1//! The control loop: measure each stage's true rate, solve for the split, jump.
2//!
3//! The shape of this loop is taken from DS2 (Kalavri et al., OSDI'18): estimate
4//! a rate per stage that is independent of the resources currently assigned to
5//! it, then compute the assignment directly rather than searching for it. The
6//! guards around the jump — ratify against throughput, remember what failed,
7//! blank out measurements taken while the system is still moving — come from
8//! Flink's autoscaler (FLIP-271) and from FDP (Suleman et al., PACT'10), which
9//! demote hill-climbing to a check on a model-based decision rather than the
10//! decision itself.
11//!
12//! See `notes/thread-broker/design.md` for the derivation and the citations.
13
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::thread::JoinHandle;
17use std::time::{Duration, Instant};
18
19use crate::{
20 BrokerError, BrokerErrorKind, Consumer, OpeningPolicy, Producer, ProducerMeasurementMode,
21 ProducerMeasurementStats, ProducerPressure, ResizeSide, SteadyStatePolicy, Stop, ThreadBroker,
22 Work, idle_fraction, now,
23};
24
25/// What the broker did, for logging and for tests.
26#[derive(Debug, Clone, Default, serde::Serialize)]
27pub struct BrokerReport {
28 /// Configured behavior after the first stable split.
29 pub steady_state_policy: SteadyStatePolicy,
30 /// True when the controller terminated itself at convergence instead of
31 /// continuing to sample until the application called `finish`.
32 pub monitoring_stopped_after_convergence: bool,
33 /// Number of controller wakeups, including startup warm-up windows.
34 pub controller_samples: u64,
35 /// Wall time for which the controller remained alive.
36 pub controller_elapsed: Duration,
37 /// Controller thread CPU time across its complete lifetime. This is
38 /// measured with only one thread-clock read at start and one at exit.
39 pub controller_cpu_nanos: Option<u64>,
40 /// Controller thread-clock reads that failed.
41 pub controller_cpu_accounting_failures: usize,
42 /// Effective cadence between responsive steady-state probes.
43 pub steady_probe_interval: Duration,
44 /// Cost and result of the optional startup-only opening bracket.
45 pub opening_bracket: OpeningBracketReport,
46 /// Consumer threads at each decision, in order.
47 pub consumer_trajectory: Vec<usize>,
48 /// Producer limit at each decision, in order.
49 pub producer_trajectory: Vec<usize>,
50 /// Actual live/active occupancy observed at every recorded sample.
51 pub actual_consumer_trajectory: Vec<usize>,
52 pub actual_producer_trajectory: Vec<usize>,
53 /// Controller elapsed time for each retained trajectory point.
54 pub trajectory_elapsed: Vec<Duration>,
55 /// Times at which the inferred producer ceiling or its evidence changed.
56 pub cap_trajectory: Vec<CapObservation>,
57 /// Real pipeline-throughput windows retained while a decision was open.
58 /// Steady-state probes are excluded, so this remains bounded startup or
59 /// resurvey evidence rather than recurring telemetry overhead.
60 pub throughput_trace: Vec<f64>,
61 pub throughput_trace_dropped: usize,
62 /// Oldest samples discarded after reaching `trace_capacity`.
63 pub trace_samples_dropped: usize,
64 /// Splits applied, including reverts.
65 pub moves: usize,
66 /// Moves undone because throughput got worse.
67 pub reverts: usize,
68 /// Guarded local candidates attempted after the opening rejected or could
69 /// not distinguish the model's differing answer.
70 pub nonlinear_probes: usize,
71 /// Probe targets retained because their throughput confidence interval was
72 /// strictly better than the previous split.
73 pub nonlinear_probe_improvements: usize,
74 /// Whether the final split is held by measured opening-bracket evidence
75 /// rather than the allocation-independent cost model.
76 pub nonlinear_override: bool,
77 /// Ratifications whose uncertainty interval could not establish a
78 /// regression or a clear non-regression.
79 pub inconclusive_ratifications: usize,
80 /// Times a settled split was re-opened by a detected regime change.
81 pub resurveys: usize,
82 /// Windows discarded as carrying too little work to estimate a rate from.
83 pub rejected_windows: usize,
84 /// Otherwise-usable windows rejected because producer buffers filled or
85 /// drained materially, so the two stages did not process equivalent work.
86 pub flow_transient_windows: usize,
87 /// Samples where the run was source-bound rather than decode-bound.
88 pub source_bound_samples: usize,
89 /// Samples where the producer could not use more concurrency at all.
90 pub inelastic_samples: usize,
91 /// Model-requested producer shrinks vetoed because runnable producer work
92 /// was still queued behind the current limit.
93 pub pressure_vetoed_shrinks: usize,
94 /// Time from the end of warm-up until the split stopped changing.
95 ///
96 /// Measured to the last actual **change of allocation**, not to the moment
97 /// the controller last stopped deliberating. Those differ: a settled split
98 /// can be re-opened by drift, re-solved, and left exactly where it was. That
99 /// is a decision being reconsidered, not an allocation moving, and reporting
100 /// it as a fresh convergence is misleading in the direction that matters —
101 /// it makes a stable run look unstable.
102 ///
103 /// Measured on a 2.3 billion read Flex run at `-t 64`: the split reached
104 /// 25 decode slots about two seconds in and never moved again for the
105 /// remaining 111 seconds, while a single harmless resurvey made this field
106 /// read 87 seconds.
107 pub time_to_converge: Option<Duration>,
108 /// Final split.
109 pub final_consumer_threads: usize,
110 pub final_producer_limit: usize,
111 /// Actual controlled occupancy observed when the controller stopped. Under
112 /// freeze-after-convergence this is earlier than application shutdown.
113 pub final_consumer_live: usize,
114 pub final_producer_active: usize,
115 /// Largest observed sum of live consumer workers and active producer slots.
116 pub peak_controlled_slots: usize,
117 /// Peak live threads explicitly outside the controlled slot budget: the
118 /// controller itself plus producer-reported coordinators/optional samplers.
119 pub peak_auxiliary_threads: usize,
120 /// Producer measurement diagnostics. Native cumulative counters report
121 /// zero samples and observation time; sampled adapters also report their
122 /// cadence and sampler CPU.
123 pub producer_measurement: Option<ProducerMeasurementStats>,
124 /// Rejected targets and the workload epoch in which they failed.
125 pub rejections: Vec<Rejection>,
126 pub final_epoch: u64,
127 pub final_phase: BrokerPhase,
128 pub current_drift: Duration,
129 pub terminal_error: Option<String>,
130 /// Last solved model, for diagnosing a split that looks wrong.
131 pub final_model: Option<Model>,
132}
133
134/// Auditable cost and result of [`OpeningPolicy::Bracket`].
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
136pub struct OpeningBracketReport {
137 /// Candidate allocations whose evidence horizon completed. The already
138 /// measured opening is not counted.
139 pub points_measured: usize,
140 /// Clean throughput samples consumed by candidate evidence horizons.
141 pub samples: usize,
142 /// Wall time from first stable disagreement until the bracket completed.
143 pub wall_nanos: u64,
144 pub outcome: OpeningBracketOutcome,
145}
146
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
148#[serde(rename_all = "snake_case")]
149pub enum OpeningBracketOutcome {
150 #[default]
151 NotConfigured,
152 /// The first stable model answer matched the opening, so no bracket work
153 /// was performed.
154 ModelAgreed,
155 /// The model's differing answer was measured and retained.
156 ModelSelected,
157 /// The model answer and local alternatives failed to beat the opening.
158 OpeningRetained,
159 /// A measured local alternative beat both the model answer and opening.
160 AlternativeSelected,
161 /// The configured wall budget ended the experiment.
162 BudgetExhausted,
163 /// Mapping ended before the experiment reached a decision.
164 Incomplete,
165 /// The model-only freeze policy deliberately skipped calibration.
166 SkippedBySteadyPolicy,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
170pub struct Rejection {
171 pub epoch: u64,
172 pub producer_target: usize,
173 pub baseline_rate: f64,
174 pub achieved_rate: f64,
175 pub baseline_uncertainty: f64,
176 pub achieved_uncertainty: f64,
177 pub producer_cost_share: f64,
178}
179
180impl BrokerReport {
181 /// Whether the broker settled and stayed settled.
182 pub fn converged(&self) -> bool {
183 self.time_to_converge.is_some()
184 && self.final_phase == BrokerPhase::Steady
185 && self.current_drift.is_zero()
186 && self.terminal_error.is_none()
187 }
188
189 fn record_sample(
190 &mut self,
191 requested: Split,
192 actual: Split,
193 elapsed: Duration,
194 capacity: usize,
195 ) {
196 let unchanged = self.consumer_trajectory.last() == Some(&requested.0)
197 && self.producer_trajectory.last() == Some(&requested.1)
198 && self.actual_consumer_trajectory.last() == Some(&actual.0)
199 && self.actual_producer_trajectory.last() == Some(&actual.1);
200 if unchanged {
201 return;
202 }
203 if self.consumer_trajectory.len() == capacity {
204 self.consumer_trajectory.remove(0);
205 self.producer_trajectory.remove(0);
206 self.actual_consumer_trajectory.remove(0);
207 self.actual_producer_trajectory.remove(0);
208 self.trajectory_elapsed.remove(0);
209 self.trace_samples_dropped += 1;
210 }
211 self.consumer_trajectory.push(requested.0);
212 self.producer_trajectory.push(requested.1);
213 self.actual_consumer_trajectory.push(actual.0);
214 self.actual_producer_trajectory.push(actual.1);
215 self.trajectory_elapsed.push(elapsed);
216 }
217
218 fn record_cap(
219 &mut self,
220 elapsed: Duration,
221 useful_cap: usize,
222 reason: ProducerCapReason,
223 capacity: usize,
224 ) {
225 let unchanged = self.cap_trajectory.last().is_some_and(|observation| {
226 observation.useful_cap == useful_cap && observation.reason == reason
227 });
228 if unchanged {
229 return;
230 }
231 if self.cap_trajectory.len() == capacity {
232 self.cap_trajectory.remove(0);
233 }
234 self.cap_trajectory.push(CapObservation {
235 elapsed,
236 useful_cap,
237 reason,
238 });
239 }
240
241 fn record_throughput(&mut self, throughput: f64, capacity: usize) {
242 if self.throughput_trace.len() == capacity {
243 self.throughput_trace.remove(0);
244 self.throughput_trace_dropped += 1;
245 }
246 self.throughput_trace.push(throughput);
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
251pub struct CapObservation {
252 pub elapsed: Duration,
253 pub useful_cap: usize,
254 pub reason: ProducerCapReason,
255}
256
257/// The solved model at one instant.
258#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
259pub struct Model {
260 /// Fraction of the pipeline's total per-item cost that falls on the
261 /// producer: `s_p / (s_p + s_c)`. The split is this times the budget.
262 pub producer_cost_share: f64,
263 /// Approximate 95% half-width of the cost-share estimate across retained
264 /// clean windows.
265 pub producer_cost_share_uncertainty: f64,
266 /// Producer slots the model asks for, before clamping.
267 pub ideal_producer_slots: usize,
268 /// Producer slots above which more concurrency has been observed not to
269 /// help. `usize::MAX` when nothing has been observed to bound it.
270 pub useful_cap: usize,
271 /// Evidence that established `useful_cap`.
272 pub useful_cap_reason: ProducerCapReason,
273 /// Movement thresholds after scaling the configured absolute floors by the
274 /// observed uncertainty at this budget.
275 pub effective_deadband_threads: usize,
276 pub effective_resurvey_distance: usize,
277}
278
279/// Evidence behind an empirical producer-concurrency ceiling.
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
281#[serde(rename_all = "snake_case")]
282pub enum ProducerCapReason {
283 #[default]
284 None,
285 /// The producer persistently left granted execution slots idle.
286 Slack,
287 /// The producer persistently reported source-bound or inelastic pressure.
288 Source,
289 /// Both signals independently support a ceiling.
290 SlackAndSource,
291}
292
293#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
294#[serde(rename_all = "snake_case")]
295pub enum BrokerPhase {
296 #[default]
297 Starting,
298 Survey,
299 Draining,
300 Blackout,
301 Ratifying,
302 Steady,
303}
304
305/// A broker sampling in the background. Dropping it stops the loop.
306pub struct RunningBroker {
307 stop: Arc<Stop>,
308 join: Option<JoinHandle<Result<BrokerReport, BrokerError>>>,
309}
310
311impl RunningBroker {
312 /// Stop sampling and collect the report.
313 pub fn finish(mut self) -> Result<BrokerReport, BrokerError> {
314 self.stop.set();
315 let Some(join) = self.join.take() else {
316 return Err(BrokerError::new(BrokerErrorKind::ThreadPanicked));
317 };
318 join.join()
319 .map_err(|_| BrokerError::new(BrokerErrorKind::ThreadPanicked))?
320 }
321}
322
323impl Drop for RunningBroker {
324 fn drop(&mut self) {
325 self.stop.set();
326 if let Some(h) = self.join.take() {
327 match h.join() {
328 Ok(Ok(_)) => {}
329 Ok(Err(error)) => tracing::error!(%error, "thread broker failed while dropping"),
330 Err(_) => tracing::error!("thread-broker sampler panicked while dropping"),
331 }
332 }
333 }
334}
335
336/// A split: consumer threads, producer slots.
337type Split = (usize, usize);
338
339/// Where the loop is.
340#[derive(Debug, Clone)]
341enum Phase {
342 /// Gathering cost evidence at the current split.
343 Survey,
344 /// The shrinking side has accepted its new target; wait until its actual
345 /// occupancy releases the slots before growing the other side.
346 Draining {
347 side: ResizeSide,
348 to: Split,
349 started: Instant,
350 next: Box<Phase>,
351 },
352 /// A move was just applied; measurements are contaminated until it settles.
353 Blackout { left: usize, next: Box<Phase> },
354 /// The opening bracket exhausted its wall budget. Restore the last proven
355 /// split before returning to ordinary steady-state control.
356 OpeningBracketRestore { restore: Split },
357 /// The move has settled. Is throughput actually better?
358 Ratify {
359 left: usize,
360 from: Split,
361 target: usize,
362 baseline: RateEstimate,
363 rates: Vec<f64>,
364 kind: RatificationKind,
365 },
366 /// Settled. Sleeping until the model's answer moves a long way, and stays
367 /// moved.
368 Steady { drifted_for: Duration },
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372enum RatificationKind {
373 Model,
374 OpeningModel,
375 NonlinearProbe,
376}
377
378impl Phase {
379 fn public(&self) -> BrokerPhase {
380 match self {
381 Self::Survey => BrokerPhase::Survey,
382 Self::Draining { .. } => BrokerPhase::Draining,
383 Self::Blackout { .. } | Self::OpeningBracketRestore { .. } => BrokerPhase::Blackout,
384 Self::Ratify { .. } => BrokerPhase::Ratifying,
385 Self::Steady { .. } => BrokerPhase::Steady,
386 }
387 }
388
389 fn drift_duration(&self) -> Duration {
390 match self {
391 Self::Steady { drifted_for } => *drifted_for,
392 _ => Duration::ZERO,
393 }
394 }
395
396 fn measurement_mode(&self) -> ProducerMeasurementMode {
397 match self {
398 Self::Survey | Self::Ratify { .. } | Self::OpeningBracketRestore { .. } => {
399 ProducerMeasurementMode::Calibration
400 }
401 Self::Draining { .. } | Self::Blackout { .. } | Self::Steady { .. } => {
402 ProducerMeasurementMode::Monitoring
403 }
404 }
405 }
406}
407
408impl<C: Consumer + 'static, P: Producer + 'static> ThreadBroker<C, P> {
409 /// Apply the starting split and begin sampling.
410 ///
411 /// The start is deliberately coarse. Under a solving controller it is only
412 /// the point at which the first rates are measured, not a position that has
413 /// to be walked away from one step at a time — which is what made the
414 /// starting point dominate the outcome under the previous control law. It
415 /// stays biased toward the consumer because a run that never needs decode
416 /// concurrency then never pays for any.
417 pub fn start(self) -> Result<RunningBroker, BrokerError> {
418 let floor = self.config.min_producer_slots.max(1);
419 let ceiling = self.budget - self.config.min_consumer_threads;
420 let coarse = (self.budget / 8).clamp(floor, ceiling);
421 let initial_producer = self
422 .initial_producer_slots
423 .unwrap_or(coarse)
424 .clamp(floor, ceiling);
425 let initial_consumer = self.budget - initial_producer;
426
427 self.consumer
428 .set_threads(initial_consumer)
429 .map_err(|source| {
430 BrokerError::new(BrokerErrorKind::ResizeRefused {
431 side: ResizeSide::Consumer,
432 requested: initial_consumer,
433 source,
434 })
435 })?;
436 self.producer
437 .set_limit(initial_producer)
438 .map_err(|source| {
439 BrokerError::new(BrokerErrorKind::ResizeRefused {
440 side: ResizeSide::Producer,
441 requested: initial_producer,
442 source,
443 })
444 })?;
445 let initial = (initial_consumer, initial_producer);
446
447 let stop = Stop::new();
448 let stop_thread = Arc::clone(&stop);
449 let join = std::thread::Builder::new()
450 .name("thread-broker".into())
451 .spawn(move || self.run(stop_thread, initial))
452 .map_err(|source| BrokerError::new(BrokerErrorKind::ThreadSpawn(source)))?;
453
454 Ok(RunningBroker {
455 stop,
456 join: Some(join),
457 })
458 }
459
460 fn run(self, stop: Arc<Stop>, initial: Split) -> Result<BrokerReport, BrokerError> {
461 let controller_cpu = crate::ThreadCpuTimer::start();
462 let cfg = self.config;
463 let steady_probe_interval = cfg.steady_probe_interval.unwrap_or(cfg.sample_interval);
464 let opening_bracket = match cfg.opening_policy {
465 OpeningPolicy::Fixed => None,
466 OpeningPolicy::Bracket(bracket) => Some(bracket),
467 };
468 let opening_bracket_enabled = opening_bracket.is_some()
469 && matches!(
470 self.steady_state_policy,
471 SteadyStatePolicy::Responsive | SteadyStatePolicy::FreezeAfterFullCalibration
472 );
473 let mut report = BrokerReport {
474 steady_state_policy: self.steady_state_policy,
475 steady_probe_interval,
476 opening_bracket: OpeningBracketReport {
477 outcome: match (opening_bracket, opening_bracket_enabled) {
478 (None, _) => OpeningBracketOutcome::NotConfigured,
479 (Some(_), false) => OpeningBracketOutcome::SkippedBySteadyPolicy,
480 (Some(_), true) => OpeningBracketOutcome::Incomplete,
481 },
482 ..OpeningBracketReport::default()
483 },
484 ..BrokerReport::default()
485 };
486
487 let mut measurement_mode = ProducerMeasurementMode::Calibration;
488 self.producer
489 .set_monitoring_interval((steady_probe_interval / 4).max(Duration::from_nanos(1)));
490 self.producer.set_measurement_mode(measurement_mode);
491 self.consumer.set_measurement_mode(measurement_mode);
492
493 let started = now();
494 let mut prev_consumer = self.consumer.work();
495 let mut prev_producer = self.producer.work();
496 let mut prev_buffer = self.producer.buffered_items();
497 let mut prev_at = started;
498
499 // The broker's own view of the split, rather than reading it back. Both
500 // pools converge toward what they are told over some interval, so
501 // reading the current value back conflates "what I asked for" with
502 // "what has taken effect yet". The budget must be enforced against the
503 // former or two consecutive decisions can double-spend the same threads.
504 let mut split = initial;
505 // When the split last *changed*, which is what convergence means. A
506 // decision that is re-opened and reaches the same answer has not
507 // un-converged: nothing moved, no work was redone, and the allocation
508 // the run has been using is still the one it started with.
509 let mut last_split_change = started;
510
511 let mut costs = Costs::new(cfg.smoothing_windows);
512 // Throughput over the same span the ratify stage will measure, so the
513 // before/after comparison is like for like. A single pre-move window as
514 // the baseline made the test a coin flip: one unlucky window sets a bar
515 // the move cannot clear, or an unusually good one hides a genuine
516 // regression.
517 let opening_bracket_samples = opening_bracket
518 .map(|bracket| duration_samples(bracket.horizon, cfg.sample_interval))
519 .unwrap_or(0);
520 let mut recent = Recent::new(cfg.ratify_samples.max(opening_bracket_samples));
521 let mut caps = Caps::new(cfg.cap_history, cfg.cap_persistence);
522 // Targets that were tried and made throughput worse. FDP calls this the
523 // tabu set; without it a model that keeps recomputing the same answer
524 // re-applies a move that has already been measured as a regression.
525 let mut rejected: HashMap<usize, f64> = HashMap::new();
526 let mut epoch = 0u64;
527 let mut phase = Phase::Survey;
528 let mut warm_ended: Option<Instant> = None;
529 let mut surveyed = 0usize;
530 let mut settled_rate: Option<f64> = None;
531 let mut pressure_vetoed_for = Duration::ZERO;
532 // The full responsive policy pays a bounded startup probe when the
533 // isolated cost model collapses to the producer floor. This catches
534 // negative consumer scaling and allocation-dependent stage costs that
535 // no one-point service-cost estimate can identify. The cheaper
536 // convergence freeze is explicitly model-only; full-calibration freeze
537 // runs the same bounded probe as responsive mode before stopping.
538 let mut nonlinear_targets = std::collections::VecDeque::new();
539 let mut nonlinear_probe_complete = !opening_bracket_enabled;
540 let mut opening_bracket_started: Option<Instant> = None;
541 let mut opening_bracket_points_started = 0usize;
542 let mut nonlinear_override = false;
543 // The achieved estimate at the last retained nonlinear point. Reusing
544 // it makes the next probe compare adjacent points on the response
545 // curve; `recent` still contains pre-probe windows and is only the
546 // baseline for the first point.
547 let mut nonlinear_rate: Option<RateEstimate> = None;
548 // Fallback point in the current bounded exploration. The model answer
549 // is primary while its first apparent regression is being confirmed;
550 // a proven regression replaces it with the opening. Optional local
551 // candidates must prove improvement before replacing that fallback.
552 let mut nonlinear_best_split: Option<Split> = None;
553 let mut nonlinear_tried = std::collections::HashSet::new();
554 // Model drift is meaningful only relative to the model that established
555 // the settled split. Comparing only the newly rounded target makes a
556 // stable share near a slot boundary alternate forever between adjacent
557 // integers. Keep the settled evidence so reopening requires either a
558 // material share change or a changed empirical cap.
559 let mut settled_model: Option<Model> = None;
560
561 while !stop.is_set() {
562 // Establish convergence with one ordinary-cadence clean steady
563 // window. Only an already-converged responsive broker adopts the
564 // sparse cadence; otherwise a five-minute probe interval would also
565 // delay convergence (and freeze) by five minutes.
566 let mut interval =
567 if matches!(phase, Phase::Steady { .. }) && report.time_to_converge.is_some() {
568 steady_probe_interval
569 } else {
570 cfg.sample_interval
571 };
572 // A wall-bounded startup bracket must not inherit a long steady
573 // sleep just as its deadline arrives. Wake on the deadline itself;
574 // an in-flight drain remains governed by resize_timeout because it
575 // is unsafe to grow the opposite pool before shrink acknowledgement.
576 if !matches!(phase, Phase::Draining { .. })
577 && !nonlinear_probe_complete
578 && let (Some(bracket), Some(bracket_started)) =
579 (opening_bracket, opening_bracket_started)
580 {
581 let remaining = bracket
582 .total_budget
583 .saturating_sub(now().saturating_duration_since(bracket_started));
584 interval = interval.min(remaining);
585 }
586 if stop.wait_timeout(interval) {
587 break;
588 }
589
590 let sampled_at = now();
591 report.controller_samples += 1;
592 let consumer = self.consumer.work();
593 let producer = self.producer.work();
594 for (side, previous, observed) in [
595 (ResizeSide::Consumer, prev_consumer, consumer),
596 (ResizeSide::Producer, prev_producer, producer),
597 ] {
598 if observed.busy_nanos < previous.busy_nanos || observed.items < previous.items {
599 let actual = (self.consumer.live_threads(), self.producer.active_slots());
600 return Err(runtime_failure(
601 BrokerErrorKind::WorkCountersRegressed {
602 side,
603 previous,
604 observed,
605 },
606 report,
607 split,
608 actual,
609 phase.public(),
610 epoch,
611 ));
612 }
613 }
614 let window = sampled_at.saturating_duration_since(prev_at);
615 let dc = consumer.delta(prev_consumer);
616 let dp = producer.delta(prev_producer);
617 let buffer = self.producer.buffered_items();
618 let flow_stable = match (prev_buffer, buffer) {
619 (Some(previous), Some(current)) => {
620 let drift = previous.abs_diff(current);
621 let relative = (dp.items as f64 * cfg.max_buffer_drift_fraction).round() as u64;
622 drift <= cfg.max_buffer_drift_items.max(relative)
623 }
624 _ => true,
625 };
626 prev_consumer = consumer;
627 prev_producer = producer;
628 prev_buffer = buffer;
629 prev_at = sampled_at;
630
631 // Discard the startup ramp entirely, re-basing as we go so the first
632 // recorded window contains none of it. Without this every workload
633 // reports ~99% consumer starvation in its first 75 ms, because the
634 // consumer's threads are still starting and nothing has been
635 // processed yet.
636 if sampled_at.saturating_duration_since(started) < cfg.warmup {
637 continue;
638 }
639 let warm_ended = *warm_ended.get_or_insert(sampled_at);
640
641 let pressure = self.producer.pressure();
642 match pressure {
643 ProducerPressure::SourceBound => report.source_bound_samples += 1,
644 ProducerPressure::Inelastic => report.inelastic_samples += 1,
645 _ => {}
646 }
647
648 // Throughput of the pipeline as a whole, which is what the
649 // accept/revert decision is actually about. Measured at the
650 // consumer, because the producer's output can run ahead into buffers
651 // and briefly show progress the pipeline did not make.
652 let throughput = rate_per_second(dc.items, window);
653
654 let usable = usable_window(dc, dp, window);
655 let resizing = matches!(phase, Phase::Draining { .. });
656 let clean = usable && flow_stable && !resizing;
657 // A zero-progress window is unusable for per-item service cost,
658 // but it is essential throughput evidence. Dropping it conditions
659 // the rate on batch completion and can make a bursty, slow split
660 // look as fast as a smooth one. Keep rate and cost eligibility
661 // deliberately separate.
662 let rate_clean = flow_stable && !resizing && !window.is_zero();
663 if rate_clean && !matches!(phase, Phase::Steady { .. }) {
664 report.record_throughput(throughput, cfg.trace_capacity);
665 }
666 if clean {
667 costs.push(dc.busy_nanos, dp.busy_nanos);
668 caps.observe_slack(dp, window, split.1);
669 } else if !resizing && !usable {
670 report.rejected_windows += 1;
671 } else if !resizing && !flow_stable {
672 report.flow_transient_windows += 1;
673 }
674 // Source/elasticity pressure describes producer admission directly;
675 // unlike busy-derived slack, it does not require equivalent work to
676 // have crossed the downstream buffer. Requiring stable flow here
677 // delayed a persistent source ceiling by 5--12 seconds on Gate G.
678 if !resizing {
679 caps.observe_pressure(window, split.1, pressure);
680 }
681 if rate_clean && matches!(phase, Phase::Survey | Phase::Steady { .. }) {
682 recent.push(dc.items, window);
683 }
684
685 let live = self.consumer.live_threads();
686 let active = self.producer.active_slots();
687 report.peak_controlled_slots = report
688 .peak_controlled_slots
689 .max(live.saturating_add(active));
690 report.peak_auxiliary_threads = report
691 .peak_auxiliary_threads
692 .max(self.producer.auxiliary_threads().saturating_add(1));
693 report.producer_measurement = self.producer.measurement_stats();
694 let idle = idle_fraction(dc.busy_nanos, live, window);
695
696 if !matches!(phase, Phase::Survey) {
697 pressure_vetoed_for = Duration::ZERO;
698 }
699 let opening_bracket_expired = !nonlinear_probe_complete
700 && opening_bracket.is_some_and(|bracket| {
701 opening_bracket_started.is_some_and(|started| {
702 sampled_at.saturating_duration_since(started) >= bracket.total_budget
703 })
704 });
705
706 phase = match phase {
707 Phase::OpeningBracketRestore { restore } => {
708 nonlinear_probe_complete = true;
709 nonlinear_targets.clear();
710 finish_opening_bracket(
711 &mut report,
712 &mut opening_bracket_started,
713 OpeningBracketOutcome::BudgetExhausted,
714 sampled_at,
715 );
716 settled_rate = nonlinear_rate.map(|rate| rate.mean);
717 recent.clear();
718 if split == restore {
719 Phase::Steady {
720 drifted_for: Duration::ZERO,
721 }
722 } else {
723 report.reverts += 1;
724 match self.begin_move(
725 split,
726 restore,
727 Phase::Blackout {
728 left: cfg.blackout_samples,
729 next: Box::new(Phase::Steady {
730 drifted_for: Duration::ZERO,
731 }),
732 },
733 ) {
734 Ok((interim, phase)) => {
735 split = interim;
736 last_split_change = now();
737 phase
738 }
739 Err(kind) => {
740 report.final_consumer_threads = split.0;
741 report.final_producer_limit = split.1;
742 report.final_consumer_live = live;
743 report.final_producer_active = active;
744 return Err(runtime_failure(
745 kind,
746 report,
747 split,
748 (live, active),
749 BrokerPhase::Blackout,
750 epoch,
751 ));
752 }
753 }
754 }
755 }
756 Phase::Draining {
757 side,
758 to,
759 started,
760 next,
761 } => {
762 let (observed, target) = match side {
763 ResizeSide::Consumer => (live, to.0),
764 ResizeSide::Producer => (active, to.1),
765 };
766 if observed <= target {
767 let growth = match side {
768 ResizeSide::Consumer => {
769 self.producer.set_limit(to.1).map_err(|source| {
770 BrokerErrorKind::ResizeRefused {
771 side: ResizeSide::Producer,
772 requested: to.1,
773 source,
774 }
775 })
776 }
777 ResizeSide::Producer => {
778 self.consumer.set_threads(to.0).map_err(|source| {
779 BrokerErrorKind::ResizeRefused {
780 side: ResizeSide::Consumer,
781 requested: to.0,
782 source,
783 }
784 })
785 }
786 };
787 if let Err(kind) = growth {
788 report.final_consumer_threads = split.0;
789 report.final_producer_limit = split.1;
790 report.final_consumer_live = live;
791 report.final_producer_active = active;
792 return Err(runtime_failure(
793 kind,
794 report,
795 split,
796 (live, active),
797 BrokerPhase::Draining,
798 epoch,
799 ));
800 }
801 split = to;
802 last_split_change = now();
803 report.moves += 1;
804 costs.clear();
805 *next
806 } else if sampled_at.saturating_duration_since(started) >= cfg.resize_timeout {
807 report.final_consumer_threads = split.0;
808 report.final_producer_limit = split.1;
809 report.final_consumer_live = live;
810 report.final_producer_active = active;
811 let kind = BrokerErrorKind::ResizeTimedOut {
812 side,
813 target,
814 observed,
815 timeout: cfg.resize_timeout,
816 };
817 return Err(runtime_failure(
818 kind,
819 report,
820 split,
821 (live, active),
822 BrokerPhase::Draining,
823 epoch,
824 ));
825 } else {
826 Phase::Draining {
827 side,
828 to,
829 started,
830 next,
831 }
832 }
833 }
834
835 Phase::Blackout { left, next } => {
836 if opening_bracket_expired {
837 Phase::OpeningBracketRestore {
838 restore: nonlinear_best_split.unwrap_or(initial),
839 }
840 } else if !rate_clean {
841 Phase::Blackout { left, next }
842 } else if left > 1 {
843 Phase::Blackout {
844 left: left - 1,
845 next,
846 }
847 } else {
848 // Everything measured while the pools were converging is
849 // about a split that no longer exists.
850 costs.clear();
851 *next
852 }
853 }
854
855 Phase::Ratify {
856 left,
857 from,
858 target,
859 baseline,
860 mut rates,
861 kind,
862 } => {
863 if opening_bracket_expired {
864 Phase::OpeningBracketRestore {
865 restore: nonlinear_best_split.unwrap_or(initial),
866 }
867 } else if !rate_clean {
868 Phase::Ratify {
869 left,
870 from,
871 target,
872 baseline,
873 rates,
874 kind,
875 }
876 } else {
877 rates.push(rate_per_second(dc.items, window));
878 if matches!(
879 kind,
880 RatificationKind::OpeningModel | RatificationKind::NonlinearProbe
881 ) {
882 report.opening_bracket.samples += 1;
883 }
884 if left > 1 {
885 Phase::Ratify {
886 left: left - 1,
887 from,
888 target,
889 baseline,
890 rates,
891 kind,
892 }
893 } else {
894 let achieved = RateEstimate::from_rates(rates.iter().copied());
895 let opening_model_max_samples =
896 cfg.ratify_samples.max(opening_bracket_samples);
897 let confirm_opening_model_regression = kind
898 == RatificationKind::OpeningModel
899 && rates.len() < opening_model_max_samples
900 && compare_opening_model_rates(
901 baseline,
902 achieved,
903 cfg.regression_tolerance,
904 ) == RatificationOutcome::Regressed;
905 if confirm_opening_model_regression {
906 tracing::debug!(
907 "thread-broker: extending apparent opening-model regression \
908 from {} to {} samples",
909 rates.len(),
910 opening_model_max_samples,
911 );
912 Phase::Ratify {
913 left: opening_model_max_samples - rates.len(),
914 from,
915 target,
916 baseline,
917 rates,
918 kind,
919 }
920 } else {
921 if matches!(
922 kind,
923 RatificationKind::OpeningModel
924 | RatificationKind::NonlinearProbe
925 ) {
926 report.opening_bracket.points_measured += 1;
927 }
928 if kind == RatificationKind::NonlinearProbe {
929 let improved = nonlinear_probe_improved(
930 baseline,
931 achieved,
932 cfg.regression_tolerance,
933 );
934 if improved {
935 tracing::debug!(
936 "thread-broker: nonlinear probe kept producer {} -> {} \
937 ({:.0} [{:.0}, {:.0}] vs {:.0} [{:.0}, {:.0}] items/s)",
938 from.1,
939 target,
940 achieved.mean,
941 achieved.lower(),
942 achieved.upper(),
943 baseline.mean,
944 baseline.lower(),
945 baseline.upper(),
946 );
947 report.nonlinear_probe_improvements += 1;
948 nonlinear_override = true;
949 nonlinear_rate = Some(achieved);
950 nonlinear_best_split = Some(split);
951 settled_rate = Some(achieved.mean);
952 costs.clear();
953 // The model has already lost to the
954 // opening. The first local point is on the
955 // opposite side of that rejected answer;
956 // once it proves an improvement, continuing
957 // back toward the rejected model cannot
958 // justify another startup move.
959 nonlinear_probe_complete = true;
960 nonlinear_targets.clear();
961 finish_opening_bracket(
962 &mut report,
963 &mut opening_bracket_started,
964 OpeningBracketOutcome::AlternativeSelected,
965 sampled_at,
966 );
967 Phase::Steady {
968 drifted_for: Duration::ZERO,
969 }
970 } else {
971 let comparison = compare_rates(
972 baseline,
973 achieved,
974 cfg.regression_tolerance,
975 );
976 tracing::debug!(
977 "thread-broker: nonlinear probe did not retain producer {} \
978 ({:.0} [{:.0}, {:.0}] vs {:.0} [{:.0}, {:.0}] items/s)",
979 target,
980 achieved.mean,
981 achieved.lower(),
982 achieved.upper(),
983 baseline.mean,
984 baseline.lower(),
985 baseline.upper(),
986 );
987 if comparison == RatificationOutcome::Inconclusive {
988 report.inconclusive_ratifications += 1;
989 }
990 // Every optional point has the higher
991 // burden of proving improvement. A failure
992 // does not discard a queued candidate on
993 // the other side of the opening: that point
994 // is what turns a one-sided guess into a
995 // bracket.
996 costs.clear();
997 if nonlinear_targets.is_empty() {
998 nonlinear_probe_complete = true;
999 let restore = nonlinear_best_split.unwrap_or(from);
1000 let outcome = retained_bracket_outcome(
1001 nonlinear_override,
1002 nonlinear_best_split,
1003 initial,
1004 );
1005 finish_opening_bracket(
1006 &mut report,
1007 &mut opening_bracket_started,
1008 outcome,
1009 sampled_at,
1010 );
1011 settled_rate = nonlinear_rate.map(|rate| rate.mean);
1012 nonlinear_best_split = None;
1013 recent.clear();
1014 if split == restore {
1015 Phase::Steady {
1016 drifted_for: Duration::ZERO,
1017 }
1018 } else {
1019 report.reverts += 1;
1020 match self.begin_move(
1021 split,
1022 restore,
1023 Phase::Blackout {
1024 left: cfg.blackout_samples,
1025 next: Box::new(Phase::Steady {
1026 drifted_for: Duration::ZERO,
1027 }),
1028 },
1029 ) {
1030 Ok((interim, phase)) => {
1031 split = interim;
1032 last_split_change = now();
1033 phase
1034 }
1035 Err(error_kind) => {
1036 report.final_consumer_threads = split.0;
1037 report.final_producer_limit = split.1;
1038 report.final_consumer_live = live;
1039 report.final_producer_active = active;
1040 return Err(runtime_failure(
1041 error_kind,
1042 report,
1043 split,
1044 (live, active),
1045 BrokerPhase::Ratifying,
1046 epoch,
1047 ));
1048 }
1049 }
1050 }
1051 } else {
1052 // Restore the last proven point before
1053 // measuring the queued candidate on the
1054 // other side of the opening. Otherwise
1055 // it inherits cold-start cost from a
1056 // failed endpoint while being compared
1057 // with a warm baseline.
1058 let restore = nonlinear_best_split.unwrap_or(from);
1059 if split != restore {
1060 match self.begin_move(
1061 split,
1062 restore,
1063 Phase::Blackout {
1064 left: cfg.blackout_samples,
1065 next: Box::new(Phase::Steady {
1066 drifted_for: Duration::ZERO,
1067 }),
1068 },
1069 ) {
1070 Ok((interim, phase)) => {
1071 split = interim;
1072 last_split_change = now();
1073 phase
1074 }
1075 Err(error_kind) => {
1076 report.final_consumer_threads = split.0;
1077 report.final_producer_limit = split.1;
1078 report.final_consumer_live = live;
1079 report.final_producer_active = active;
1080 return Err(runtime_failure(
1081 error_kind,
1082 report,
1083 split,
1084 (live, active),
1085 BrokerPhase::Ratifying,
1086 epoch,
1087 ));
1088 }
1089 }
1090 } else {
1091 Phase::Steady {
1092 drifted_for: Duration::ZERO,
1093 }
1094 }
1095 }
1096 }
1097 } else {
1098 // Only a regression reverts; absence of improvement does
1099 // not. Near the optimum the surface is locally flat --
1100 // measured, 22 and 23 producer slots of 64 differ by 3%,
1101 // which is inside this comparison's own noise -- so "no
1102 // better" is a perfectly ordinary reading for a *correct*
1103 // move. Treating it as failure would revert the broker to
1104 // wherever it happened to start, which is the one place
1105 // with no evidence for it at all.
1106 //
1107 // Flink reaches the same conclusion from the other end:
1108 // its `detectIneffectiveScaleUp` compares against a
1109 // model-*predicted* increase rather than any increase,
1110 // and still ships disabled by default.
1111 let opening_model_validation =
1112 kind == RatificationKind::OpeningModel;
1113 let comparison = if opening_model_validation {
1114 compare_opening_model_rates(
1115 baseline,
1116 achieved,
1117 cfg.regression_tolerance,
1118 )
1119 } else {
1120 compare_rates(baseline, achieved, cfg.regression_tolerance)
1121 };
1122 let cap_confirms_opening_model = opening_model_validation
1123 && empirical_cap_confirms_target(
1124 report.final_model,
1125 target,
1126 );
1127 if opening_model_validation
1128 && comparison != RatificationOutcome::Kept
1129 && !cap_confirms_opening_model
1130 {
1131 if comparison == RatificationOutcome::Inconclusive {
1132 report.inconclusive_ratifications += 1;
1133 }
1134 tracing::debug!(
1135 "thread-broker: exploring around opening {} after {} \
1136 model point {} ({:.0} vs {:.0} items/s)",
1137 from.1,
1138 if comparison == RatificationOutcome::Regressed {
1139 "regressed"
1140 } else {
1141 "inconclusive"
1142 },
1143 target,
1144 achieved.mean,
1145 baseline.mean,
1146 );
1147 // The opening is a pivot and a measured
1148 // high-water mark, not a fallback. An
1149 // optional point must beat the higher of
1150 // the opening/model rates, while failure
1151 // still returns to the primary model.
1152 // This lets an ambiguous t8 comparison
1153 // discover five without letting a
1154 // temporarily depressed t32 model make
1155 // another mediocre point look better.
1156 nonlinear_rate = Some(if achieved.mean >= baseline.mean {
1157 achieved
1158 } else {
1159 baseline
1160 });
1161 nonlinear_best_split = Some(split);
1162 nonlinear_tried.insert(from.1);
1163 nonlinear_tried.insert(target);
1164 let bracket = opening_bracket
1165 .expect("opening model validation requires config");
1166 nonlinear_targets = opening_bracket_targets(
1167 self.budget,
1168 cfg.min_consumer_threads,
1169 cfg.min_producer_slots,
1170 from.1,
1171 target,
1172 bracket
1173 .max_points
1174 .saturating_sub(opening_bracket_points_started),
1175 );
1176 settled_rate = Some(achieved.mean);
1177 costs.clear();
1178 Phase::Steady {
1179 drifted_for: Duration::ZERO,
1180 }
1181 } else if comparison == RatificationOutcome::Regressed
1182 && !opening_model_validation
1183 {
1184 tracing::debug!(
1185 "thread-broker: reverting producer {} -> {} after \
1186 measured regression ({:.0} vs {:.0} items/s)",
1187 target,
1188 from.1,
1189 achieved.mean,
1190 baseline.mean,
1191 );
1192 let rejected_share = report
1193 .final_model
1194 .map(|model| model.producer_cost_share)
1195 .unwrap_or_default();
1196 rejected.insert(target, rejected_share);
1197 report.rejections.push(Rejection {
1198 epoch,
1199 producer_target: target,
1200 baseline_rate: baseline.mean,
1201 achieved_rate: achieved.mean,
1202 baseline_uncertainty: baseline.half_width,
1203 achieved_uncertainty: achieved.half_width,
1204 producer_cost_share: rejected_share,
1205 });
1206 settled_rate = Some(baseline.mean);
1207 report.reverts += 1;
1208 costs.clear();
1209 match self.begin_move(
1210 split,
1211 from,
1212 Phase::Blackout {
1213 left: cfg.blackout_samples,
1214 next: Box::new(Phase::Steady {
1215 drifted_for: Duration::ZERO,
1216 }),
1217 },
1218 ) {
1219 Ok((interim, phase)) => {
1220 split = interim;
1221 last_split_change = now();
1222 phase
1223 }
1224 Err(kind) => {
1225 report.final_consumer_threads = split.0;
1226 report.final_producer_limit = split.1;
1227 report.final_consumer_live = live;
1228 report.final_producer_active = active;
1229 return Err(runtime_failure(
1230 kind,
1231 report,
1232 split,
1233 (live, active),
1234 BrokerPhase::Ratifying,
1235 epoch,
1236 ));
1237 }
1238 }
1239 } else {
1240 if comparison == RatificationOutcome::Inconclusive {
1241 report.inconclusive_ratifications += 1;
1242 }
1243 if cap_confirms_opening_model
1244 && comparison != RatificationOutcome::Kept
1245 {
1246 tracing::debug!(
1247 "thread-broker: retaining opening-model producer \
1248 {} despite {} throughput evidence; empirical {} \
1249 cap independently confirms the target",
1250 target,
1251 if comparison == RatificationOutcome::Regressed {
1252 "regressed"
1253 } else {
1254 "inconclusive"
1255 },
1256 match report
1257 .final_model
1258 .expect("confirmed model has a snapshot")
1259 .useful_cap_reason
1260 {
1261 ProducerCapReason::Slack => "slack",
1262 ProducerCapReason::Source => "source",
1263 ProducerCapReason::SlackAndSource => {
1264 "slack-and-source"
1265 }
1266 ProducerCapReason::None => "missing",
1267 },
1268 );
1269 }
1270 // The differing model answer was measured
1271 // and retained. The bracket has answered
1272 // its only question, so it must not add an
1273 // exploratory tax after agreement.
1274 if opening_model_validation {
1275 nonlinear_probe_complete = true;
1276 nonlinear_targets.clear();
1277 nonlinear_rate = None;
1278 nonlinear_best_split = None;
1279 finish_opening_bracket(
1280 &mut report,
1281 &mut opening_bracket_started,
1282 OpeningBracketOutcome::ModelSelected,
1283 sampled_at,
1284 );
1285 }
1286 // Kept. Re-survey once: the rates may have been
1287 // measured under a split that distorted them, and
1288 // DS2 reports convergence in at most a few such
1289 // rounds. The deadband and the tabu set are what
1290 // stop this from cycling.
1291 surveyed = 0;
1292 settled_rate = None;
1293 Phase::Survey
1294 }
1295 }
1296 }
1297 }
1298 }
1299 }
1300
1301 Phase::Steady { .. } if opening_bracket_expired => Phase::OpeningBracketRestore {
1302 restore: nonlinear_best_split.unwrap_or(initial),
1303 },
1304
1305 Phase::Steady { drifted_for } => {
1306 // Keep solving, but act only on a large drift. The model is
1307 // the change detector: it is computed from the cost ratio
1308 // rather than from the outcome, so it moves as soon as the
1309 // workload does -- including when the change is an
1310 // *opportunity* rather than a loss, which a throughput
1311 // monitor cannot see at all.
1312 let solved = costs.solve(self.budget, &cfg, &caps);
1313 // Refresh the diagnostic even when nothing is acted on: a
1314 // report read at the end of a long settled run should
1315 // describe the workload as it finished, not as it looked at
1316 // the one sample that happened to settle it.
1317 if let Some(s) = &solved {
1318 report.final_model = Some(s.snapshot);
1319 }
1320 let bracket = opening_bracket;
1321 let bracket_budget_expired = bracket.is_some_and(|bracket| {
1322 opening_bracket_started.is_some_and(|started| {
1323 sampled_at.saturating_duration_since(started)
1324 + bracket.horizon.saturating_mul(2)
1325 > bracket.total_budget
1326 })
1327 });
1328 let bracket_has_point_budget = bracket
1329 .is_some_and(|bracket| opening_bracket_points_started < bracket.max_points);
1330 let probe_target = if !nonlinear_probe_complete
1331 && !bracket_budget_expired
1332 && bracket_has_point_budget
1333 {
1334 let target = loop {
1335 match nonlinear_targets.pop_front() {
1336 Some(target)
1337 if target != split.1 && !nonlinear_tried.contains(&target) =>
1338 {
1339 break Some(target);
1340 }
1341 Some(_) => {}
1342 None => break None,
1343 }
1344 };
1345 if target.is_none() {
1346 nonlinear_probe_complete = true;
1347 let outcome = retained_bracket_outcome(
1348 nonlinear_override,
1349 nonlinear_best_split,
1350 initial,
1351 );
1352 finish_opening_bracket(
1353 &mut report,
1354 &mut opening_bracket_started,
1355 outcome,
1356 sampled_at,
1357 );
1358 }
1359 target
1360 } else {
1361 if !nonlinear_probe_complete {
1362 nonlinear_probe_complete = true;
1363 nonlinear_targets.clear();
1364 let outcome = if bracket_budget_expired {
1365 OpeningBracketOutcome::BudgetExhausted
1366 } else {
1367 retained_bracket_outcome(
1368 nonlinear_override,
1369 nonlinear_best_split,
1370 initial,
1371 )
1372 };
1373 finish_opening_bracket(
1374 &mut report,
1375 &mut opening_bracket_started,
1376 outcome,
1377 sampled_at,
1378 );
1379 }
1380 None
1381 };
1382
1383 if let Some(target) = probe_target {
1384 opening_bracket_points_started += 1;
1385 report.nonlinear_probes += 1;
1386 let from = split;
1387 let to = (self.budget - target, target);
1388 // A local bracket point is optional and must prove a real
1389 // improvement, unlike a model-directed move which only
1390 // has to avoid regression. Progress publishers must be
1391 // fine grained enough that the ordinary ratification
1392 // horizon is representative; stretching the horizon to
1393 // compensate for bursty publication only delays the
1394 // decision and hides the real measurement problem.
1395 let probe_samples = opening_bracket_samples;
1396 let probe_blackout_samples = cfg.blackout_samples;
1397 let baseline = nonlinear_rate.unwrap_or_else(|| recent.estimate());
1398 nonlinear_rate.get_or_insert(baseline);
1399 nonlinear_best_split.get_or_insert(from);
1400 nonlinear_tried.insert(from.1);
1401 nonlinear_tried.insert(target);
1402 tracing::debug!(
1403 "thread-broker: probing opening bracket producer {} -> {}",
1404 from.1,
1405 target,
1406 );
1407 match self.begin_move(
1408 from,
1409 to,
1410 Phase::Blackout {
1411 left: probe_blackout_samples,
1412 next: Box::new(Phase::Ratify {
1413 left: probe_samples,
1414 from,
1415 target,
1416 baseline,
1417 rates: Vec::with_capacity(probe_samples),
1418 kind: RatificationKind::NonlinearProbe,
1419 }),
1420 },
1421 ) {
1422 Ok((interim, phase)) => {
1423 split = interim;
1424 last_split_change = now();
1425 phase
1426 }
1427 Err(kind) => {
1428 report.final_consumer_threads = split.0;
1429 report.final_producer_limit = split.1;
1430 report.final_consumer_live = live;
1431 report.final_producer_active = active;
1432 return Err(runtime_failure(
1433 kind,
1434 report,
1435 split,
1436 (live, active),
1437 BrokerPhase::Steady,
1438 epoch,
1439 ));
1440 }
1441 }
1442 } else {
1443 let model_drifted = !nonlinear_override
1444 && solved.as_ref().is_some_and(|s| {
1445 let far_enough = s.target.abs_diff(split.1)
1446 >= s.snapshot.effective_resurvey_distance;
1447 let changed_since_settling = settled_model.is_none_or(|settled| {
1448 let combined_uncertainty = settled
1449 .producer_cost_share_uncertainty
1450 + s.snapshot.producer_cost_share_uncertainty;
1451 let share_changed = (settled.producer_cost_share
1452 - s.snapshot.producer_cost_share)
1453 .abs()
1454 > combined_uncertainty.max(0.05);
1455 let cap_changed = settled.useful_cap != s.snapshot.useful_cap
1456 && (settled.useful_cap_reason != ProducerCapReason::None
1457 || s.snapshot.useful_cap_reason
1458 != ProducerCapReason::None);
1459 share_changed || cap_changed
1460 });
1461 let pressure_allows =
1462 !(s.target < split.1 && pressure == ProducerPressure::Starved);
1463 let changed_since_rejection =
1464 rejected.get(&s.target).is_none_or(|rejected_share| {
1465 (s.snapshot.producer_cost_share - rejected_share).abs()
1466 > (2.0 * s.snapshot.producer_cost_share_uncertainty)
1467 .max(0.05)
1468 });
1469 far_enough
1470 && changed_since_settling
1471 && pressure_allows
1472 && changed_since_rejection
1473 });
1474 let current_rate = recent.estimate();
1475 // A common-mode rate change with an unchanged cost share
1476 // does not imply a different split. Reopening on throughput
1477 // alone made ordinary machine-load variation (and the tail
1478 // of a finite input) look like a workload epoch. The rate
1479 // detector remains useful only when it can expire rejection
1480 // evidence: a previously bad target may become valid after
1481 // the response surface changes even if the local cost share
1482 // rounds to the same split.
1483 let throughput_drifted = settled_rate.is_some_and(|baseline| {
1484 if baseline <= 0.0 {
1485 return false;
1486 }
1487 let regressed = current_rate.upper() < baseline * 0.9;
1488 let improved = current_rate.lower() > baseline * 1.1;
1489 // After measuring a nonlinear response surface, an
1490 // upside-only rate change at the retained split is
1491 // not evidence that another split is better. The
1492 // first scATAC probe otherwise repeatedly reopened
1493 // while its reduced consumer set warmed up. A real
1494 // loss still reopens it. Model rejections retain the
1495 // two-sided test so changed work can expire evidence
1496 // against a previously rejected target.
1497 (nonlinear_override && regressed)
1498 || (!nonlinear_override
1499 && !rejected.is_empty()
1500 && (regressed || improved))
1501 });
1502 let next_drift =
1503 consecutive_drift_duration(drifted_for, window, cfg.sample_interval);
1504 match model_drifted || throughput_drifted {
1505 // Distance alone is not enough: the noise in the solved
1506 // target grows with the budget while a fixed distance
1507 // does not, so at 64 threads ordinary jitter clears a
1508 // band that 32 threads never approaches. Persistence is
1509 // what tells the two apart -- noise does not survive a
1510 // second of consecutive windows, a regime change does.
1511 true if next_drift >= cfg.resurvey_persistence => {
1512 tracing::debug!(
1513 "thread-broker: persistent workload change at producer split {} \
1514 (model target {}, throughput {:.0}/s); re-opening",
1515 split.1,
1516 solved.as_ref().map_or(split.1, |model| model.target),
1517 current_rate.mean,
1518 );
1519 report.resurveys += 1;
1520 epoch += 1;
1521 rejected.clear();
1522 nonlinear_override = false;
1523 nonlinear_rate = None;
1524 nonlinear_best_split = None;
1525 nonlinear_tried.clear();
1526 // Opening calibration is deliberately
1527 // startup-only. A later regime change reuses
1528 // the ordinary model/ratification path and
1529 // cannot re-arm the bracket or its fine
1530 // measurement cadence.
1531 nonlinear_targets.clear();
1532 nonlinear_probe_complete = true;
1533 settled_model = None;
1534 // Persistent drift was itself measured from a
1535 // full clean cost window. Reuse that evidence
1536 // and require one fresh confirmation instead of
1537 // paying another complete smoothing horizon;
1538 // this keeps recovery under one second without
1539 // weakening the 800 ms false-resurvey guard.
1540 surveyed = cfg.smoothing_windows.saturating_sub(1);
1541 settled_rate = None;
1542 Phase::Survey
1543 }
1544 true => Phase::Steady {
1545 drifted_for: next_drift,
1546 },
1547 false => {
1548 if settled_model.is_none() {
1549 settled_model = solved.map(|model| model.snapshot);
1550 }
1551 // Measured to the last split change, not to
1552 // this sample: a run that opened at the right
1553 // allocation and never moved converged
1554 // immediately, however long it then ran.
1555 report.time_to_converge =
1556 Some(last_split_change.saturating_duration_since(warm_ended));
1557 settled_rate.get_or_insert(current_rate.mean);
1558 Phase::Steady {
1559 drifted_for: Duration::ZERO,
1560 }
1561 }
1562 }
1563 }
1564 }
1565
1566 Phase::Survey => {
1567 surveyed += clean as usize;
1568 match costs.solve(self.budget, &cfg, &caps) {
1569 // Not enough evidence yet, or one side has produced no
1570 // work at all. Keep looking rather than guessing.
1571 _ if surveyed < cfg.smoothing_windows => Phase::Survey,
1572 None => Phase::Survey,
1573 Some(model) => {
1574 // A target that regressed is tabu only in this
1575 // workload epoch. Keep the last ratified split
1576 // until a proven regime change opens a new epoch,
1577 // rather than walking through unsupported neighbors.
1578 let mut target = if rejected.contains_key(&model.target) {
1579 split.1
1580 } else {
1581 model.target
1582 };
1583 let opening_model_disagrees = opening_bracket_enabled
1584 && !nonlinear_probe_complete
1585 && split == initial
1586 && model.target != initial.1;
1587 // The cost model is intentionally primary, but it
1588 // assumes allocation-independent service cost. At a
1589 // low allocation, a decoder can under-report the
1590 // thread-time a second slot would unlock. A live
1591 // `Starved` signal may therefore veto *removing* a
1592 // slot that still has runnable work behind it. It
1593 // never chooses a larger target, preserving the
1594 // isolated cost-share solution instead of reviving
1595 // the old starvation hill climber.
1596 let current_capacity_used =
1597 producer_capacity_used(active, split.1, dp.busy_nanos, window);
1598 let source_growth_wait = target > split.1
1599 && matches!(
1600 pressure,
1601 ProducerPressure::SourceBound | ProducerPressure::Inelastic
1602 )
1603 && model.snapshot.useful_cap_reason == ProducerCapReason::None;
1604 let pressure_veto = target < split.1
1605 && pressure == ProducerPressure::Starved
1606 && current_capacity_used
1607 && !opening_model_disagrees;
1608 if pressure_veto {
1609 target = split.1;
1610 report.pressure_vetoed_shrinks += 1;
1611 pressure_vetoed_for += window;
1612 } else {
1613 pressure_vetoed_for = Duration::ZERO;
1614 }
1615 let distance = target.abs_diff(split.1);
1616 let opening_disagrees = opening_model_disagrees;
1617 if opening_bracket_enabled
1618 && !nonlinear_probe_complete
1619 && split == initial
1620 && target == initial.1
1621 {
1622 finish_opening_bracket(
1623 &mut report,
1624 &mut opening_bracket_started,
1625 OpeningBracketOutcome::ModelAgreed,
1626 sampled_at,
1627 );
1628 nonlinear_probe_complete = true;
1629 } else if opening_disagrees && opening_bracket_started.is_none() {
1630 opening_bracket_started = Some(sampled_at);
1631 }
1632 // A single queued snapshot can be a short decode
1633 // burst, especially for mapping-heavy modalities.
1634 // Keep surveying until starvation has persisted as
1635 // long as other cap evidence before accepting the
1636 // current split as the bounded bracket correction.
1637 if source_growth_wait {
1638 // Direct source pressure can arrive before its
1639 // duration-based cap is mature. Do not race a
1640 // large growth move against that evidence; keep
1641 // surveying until the signal either clears or
1642 // establishes the current grant as the cap.
1643 Phase::Survey
1644 } else if pressure_veto && pressure_vetoed_for < cfg.cap_persistence {
1645 Phase::Survey
1646 } else if opening_disagrees && recent.len() < opening_bracket_samples {
1647 // Compare like-sized blocks. Starting the model
1648 // candidate with only the smoothing horizon in
1649 // its baseline made short, bursty workloads
1650 // needlessly inconclusive.
1651 Phase::Survey
1652 } else if opening_disagrees
1653 && opening_bracket.is_some_and(|bracket| {
1654 opening_bracket_started.is_some_and(|started| {
1655 sampled_at.saturating_duration_since(started)
1656 + bracket.horizon.saturating_mul(2)
1657 > bracket.total_budget
1658 })
1659 })
1660 {
1661 nonlinear_probe_complete = true;
1662 finish_opening_bracket(
1663 &mut report,
1664 &mut opening_bracket_started,
1665 OpeningBracketOutcome::BudgetExhausted,
1666 sampled_at,
1667 );
1668 report.final_model = Some(model.snapshot);
1669 settled_rate = Some(recent.estimate().mean);
1670 Phase::Steady {
1671 drifted_for: Duration::ZERO,
1672 }
1673 } else if distance < model.snapshot.effective_deadband_threads
1674 && !opening_disagrees
1675 {
1676 report.final_model = Some(model.snapshot);
1677 settled_rate = Some(recent.estimate().mean);
1678 Phase::Steady {
1679 drifted_for: Duration::ZERO,
1680 }
1681 } else {
1682 let from = split;
1683 let ratification_kind = if opening_disagrees {
1684 opening_bracket_points_started += 1;
1685 RatificationKind::OpeningModel
1686 } else {
1687 RatificationKind::Model
1688 };
1689 let ratify_samples = if opening_disagrees {
1690 opening_bracket_samples
1691 } else {
1692 cfg.ratify_samples
1693 };
1694 let ratify_capacity = if opening_disagrees {
1695 ratify_samples.max(cfg.ratify_samples)
1696 } else {
1697 ratify_samples
1698 };
1699 let blackout_samples = cfg.blackout_samples;
1700 tracing::debug!(
1701 "thread-broker: solved producer {} -> {} \
1702 (cost share {:.2}, cap {})",
1703 from.1,
1704 target,
1705 model.snapshot.producer_cost_share,
1706 fmt_cap(model.snapshot.useful_cap),
1707 );
1708 report.final_model = Some(model.snapshot);
1709 let to = (self.budget - target, target);
1710 if opening_disagrees {
1711 // Until a material, separated regression is
1712 // confirmed, absence of evidence favors the
1713 // primary model over the opening hint. This
1714 // also makes a bracket deadline during the
1715 // confirmation extension retain the model.
1716 nonlinear_best_split = Some(to);
1717 }
1718 match self.begin_move(
1719 from,
1720 to,
1721 Phase::Blackout {
1722 left: blackout_samples,
1723 next: Box::new(Phase::Ratify {
1724 left: ratify_samples,
1725 from,
1726 target,
1727 baseline: recent.estimate(),
1728 rates: Vec::with_capacity(ratify_capacity),
1729 kind: ratification_kind,
1730 }),
1731 },
1732 ) {
1733 Ok((interim, phase)) => {
1734 split = interim;
1735 last_split_change = now();
1736 phase
1737 }
1738 Err(kind) => {
1739 report.final_consumer_threads = split.0;
1740 report.final_producer_limit = split.1;
1741 report.final_consumer_live = live;
1742 report.final_producer_active = active;
1743 return Err(runtime_failure(
1744 kind,
1745 report,
1746 split,
1747 (live, active),
1748 BrokerPhase::Survey,
1749 epoch,
1750 ));
1751 }
1752 }
1753 }
1754 }
1755 }
1756 }
1757 };
1758
1759 // Keep high-resolution progress publication through the bounded
1760 // opening bracket, including its resize/blackout stages. A
1761 // slow split is precisely where coarse item batches quantize the
1762 // baseline. Once probing is complete, both stages can use their
1763 // settled cadence.
1764 let next_measurement_mode = if nonlinear_probe_complete {
1765 phase.measurement_mode()
1766 } else {
1767 ProducerMeasurementMode::Calibration
1768 };
1769 if next_measurement_mode != measurement_mode {
1770 self.producer.set_measurement_mode(next_measurement_mode);
1771 self.consumer.set_measurement_mode(next_measurement_mode);
1772 measurement_mode = next_measurement_mode;
1773 }
1774
1775 report.record_sample(
1776 split,
1777 (live, active),
1778 sampled_at.saturating_duration_since(started),
1779 cfg.trace_capacity,
1780 );
1781 let (useful_cap, useful_cap_reason) = caps.useful();
1782 report.record_cap(
1783 sampled_at.saturating_duration_since(started),
1784 useful_cap,
1785 useful_cap_reason,
1786 cfg.trace_capacity,
1787 );
1788
1789 tracing::trace!(
1790 "thread-broker: {:?} idle {:.1}% pressure {:?} {:.0} items/s \
1791 consumer {} (live {live}) producer {} (active {active})",
1792 PhaseName(&phase),
1793 idle * 100.0,
1794 pressure,
1795 throughput,
1796 split.0,
1797 split.1,
1798 );
1799
1800 // The low-overhead policy pays the full, guarded convergence cost
1801 // and one clean steady-state validation window, then ends both the
1802 // controller thread and (when it is owned only by this broker) the
1803 // producer's sampling adapter. There is no long timeout or dormant
1804 // polling loop: recurring cost after this point is exactly zero.
1805 if matches!(
1806 self.steady_state_policy,
1807 SteadyStatePolicy::FreezeAfterConvergence
1808 | SteadyStatePolicy::FreezeAfterFullCalibration
1809 ) && report.time_to_converge.is_some()
1810 && matches!(
1811 phase,
1812 Phase::Steady {
1813 drifted_for: Duration::ZERO
1814 }
1815 )
1816 {
1817 report.monitoring_stopped_after_convergence = true;
1818 break;
1819 }
1820 }
1821
1822 if report.opening_bracket.outcome == OpeningBracketOutcome::Incomplete {
1823 finish_opening_bracket(
1824 &mut report,
1825 &mut opening_bracket_started,
1826 OpeningBracketOutcome::Incomplete,
1827 now(),
1828 );
1829 }
1830 report.final_consumer_threads = split.0;
1831 report.final_producer_limit = split.1;
1832 report.final_consumer_live = self.consumer.live_threads();
1833 report.final_producer_active = self.producer.active_slots();
1834 report.peak_controlled_slots = report.peak_controlled_slots.max(
1835 report
1836 .final_consumer_live
1837 .saturating_add(report.final_producer_active),
1838 );
1839 report.final_epoch = epoch;
1840 report.final_phase = phase.public();
1841 report.nonlinear_override = nonlinear_override;
1842 report.current_drift = phase.drift_duration();
1843 report.controller_elapsed = now().saturating_duration_since(started);
1844 report.producer_measurement = self.producer.finish_measurement();
1845 report.controller_cpu_nanos = controller_cpu
1846 .elapsed()
1847 .and_then(|elapsed| u64::try_from(elapsed.as_nanos()).ok());
1848 report.controller_cpu_accounting_failures =
1849 usize::from(report.controller_cpu_nanos.is_none());
1850 Ok(report)
1851 }
1852
1853 /// Begin a two-phase move by changing only the shrinking side.
1854 ///
1855 /// The order matters: growing first lets the two sides briefly sum above the
1856 /// budget, which on a full machine is exactly the oversubscription the budget
1857 /// exists to prevent.
1858 ///
1859 /// The direction comes from the caller rather than from
1860 /// [`Producer::limit`]. Reading it back would reintroduce the very confusion
1861 /// the broker avoids by tracking its own view of the split: a pool that has
1862 /// not yet converged to the last request reports the old value, and the
1863 /// ordering decision would then be made against a split that no longer
1864 /// exists.
1865 fn begin_move(
1866 &self,
1867 from: Split,
1868 to: Split,
1869 next: Phase,
1870 ) -> Result<(Split, Phase), BrokerErrorKind> {
1871 if to.1 > from.1 {
1872 self.consumer
1873 .set_threads(to.0)
1874 .map_err(|source| BrokerErrorKind::ResizeRefused {
1875 side: ResizeSide::Consumer,
1876 requested: to.0,
1877 source,
1878 })?;
1879 Ok((
1880 (to.0, from.1),
1881 Phase::Draining {
1882 side: ResizeSide::Consumer,
1883 to,
1884 started: now(),
1885 next: Box::new(next),
1886 },
1887 ))
1888 } else {
1889 self.producer
1890 .set_limit(to.1)
1891 .map_err(|source| BrokerErrorKind::ResizeRefused {
1892 side: ResizeSide::Producer,
1893 requested: to.1,
1894 source,
1895 })?;
1896 Ok((
1897 (from.0, to.1),
1898 Phase::Draining {
1899 side: ResizeSide::Producer,
1900 to,
1901 started: now(),
1902 next: Box::new(next),
1903 },
1904 ))
1905 }
1906 }
1907}
1908
1909/// Recent pipeline throughput, over a fixed number of windows.
1910///
1911/// Exists only so the ratify comparison averages the same span on both sides of
1912/// a move. Both halves are `items / elapsed` over `ratify_samples` windows, so
1913/// neither is more exposed to a single noisy window than the other.
1914struct Recent {
1915 ring: std::collections::VecDeque<(u64, Duration)>,
1916 capacity: usize,
1917}
1918
1919impl Recent {
1920 fn new(windows: usize) -> Self {
1921 let capacity = windows.max(1);
1922 Self {
1923 ring: std::collections::VecDeque::with_capacity(capacity),
1924 capacity,
1925 }
1926 }
1927
1928 fn push(&mut self, items: u64, window: Duration) {
1929 if self.ring.len() == self.capacity {
1930 self.ring.pop_front();
1931 }
1932 self.ring.push_back((items, window));
1933 }
1934
1935 fn estimate(&self) -> RateEstimate {
1936 RateEstimate::from_rates(
1937 self.ring
1938 .iter()
1939 .map(|&(items, elapsed)| rate_per_second(items, elapsed)),
1940 )
1941 }
1942
1943 fn len(&self) -> usize {
1944 self.ring.len()
1945 }
1946
1947 fn clear(&mut self) {
1948 self.ring.clear();
1949 }
1950}
1951
1952fn duration_samples(horizon: Duration, interval: Duration) -> usize {
1953 let samples = horizon.as_nanos().div_ceil(interval.as_nanos().max(1));
1954 usize::try_from(samples).unwrap_or(usize::MAX).max(1)
1955}
1956
1957fn finish_opening_bracket(
1958 report: &mut BrokerReport,
1959 started: &mut Option<Instant>,
1960 outcome: OpeningBracketOutcome,
1961 at: Instant,
1962) {
1963 if let Some(started) = started.take() {
1964 report.opening_bracket.wall_nanos =
1965 u64::try_from(at.saturating_duration_since(started).as_nanos()).unwrap_or(u64::MAX);
1966 }
1967 report.opening_bracket.outcome = outcome;
1968}
1969
1970fn retained_bracket_outcome(
1971 alternative_selected: bool,
1972 retained: Option<Split>,
1973 opening: Split,
1974) -> OpeningBracketOutcome {
1975 if alternative_selected {
1976 OpeningBracketOutcome::AlternativeSelected
1977 } else if retained.is_some_and(|split| split != opening) {
1978 OpeningBracketOutcome::ModelSelected
1979 } else {
1980 OpeningBracketOutcome::OpeningRetained
1981 }
1982}
1983
1984/// Candidate allocations adjacent to the opening, first away from the rejected
1985/// model and then toward it. The model point itself has already been measured.
1986/// Alternating around the opening distinguishes opposite response shapes with
1987/// the same model answer: at scATAC t32 the lower model point wins, whereas at
1988/// t8 the first point above the opening is the real peak.
1989fn opening_bracket_targets(
1990 budget: usize,
1991 min_consumer_threads: usize,
1992 min_producer_slots: usize,
1993 opening: usize,
1994 model: usize,
1995 maximum_points: usize,
1996) -> std::collections::VecDeque<usize> {
1997 let mut targets = std::collections::VecDeque::new();
1998 if maximum_points == 0 {
1999 return targets;
2000 }
2001 let floor = min_producer_slots;
2002 let ceiling = budget.saturating_sub(min_consumer_threads).max(floor);
2003 let toward_is_lower = model < opening;
2004 for distance in 1..budget {
2005 let lower = opening
2006 .checked_sub(distance)
2007 .filter(|target| *target >= floor);
2008 let upper = opening
2009 .checked_add(distance)
2010 .filter(|target| *target <= ceiling);
2011 let ordered = if toward_is_lower {
2012 [upper, lower]
2013 } else {
2014 [lower, upper]
2015 };
2016 for target in ordered.into_iter().flatten() {
2017 if target != model && !targets.contains(&target) {
2018 targets.push_back(target);
2019 if targets.len() == maximum_points {
2020 return targets;
2021 }
2022 }
2023 }
2024 }
2025 targets
2026}
2027
2028#[derive(Debug, Clone, Copy, Default)]
2029struct RateEstimate {
2030 mean: f64,
2031 half_width: f64,
2032 samples: usize,
2033 zero_samples: usize,
2034}
2035
2036#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2037enum RatificationOutcome {
2038 Kept,
2039 Inconclusive,
2040 Regressed,
2041}
2042
2043fn compare_rates(
2044 baseline: RateEstimate,
2045 achieved: RateEstimate,
2046 tolerance: f64,
2047) -> RatificationOutcome {
2048 if baseline.mean <= 0.0 {
2049 return RatificationOutcome::Kept;
2050 }
2051 // One output flush or I/O pause is evidence that this block is noisy, not
2052 // evidence that the target is bad. Sustained zero progress remains visible
2053 // (and can still regress); only the isolated case is forced inconclusive.
2054 if achieved.samples > 1 && achieved.zero_samples == 1 {
2055 return RatificationOutcome::Inconclusive;
2056 }
2057
2058 let boundary = baseline.mean * (1.0 - tolerance);
2059 // The blocks are independent, so uncertainty in their difference combines
2060 // in quadrature rather than by adding both 95% widths. Ratification asks a
2061 // directional question (is the new rate below the tolerated boundary?),
2062 // hence the one-sided 95% factor 1.645 rather than 1.96.
2063 let difference_half_width =
2064 (1.645 / 1.96) * (baseline.half_width * (1.0 - tolerance)).hypot(achieved.half_width);
2065 if achieved.mean + difference_half_width < boundary {
2066 RatificationOutcome::Regressed
2067 } else if achieved.mean - difference_half_width < boundary {
2068 RatificationOutcome::Inconclusive
2069 } else {
2070 RatificationOutcome::Kept
2071 }
2072}
2073
2074/// A differing model answer has higher evidential status than the opening.
2075///
2076/// Ordinary ratification's directional test is intentionally sensitive to a
2077/// tolerated loss. Rejecting the model point is more consequential: it restores
2078/// an unevidenced opening and starts the optional response search. Require the
2079/// same two independent facts used to retain an optional point, in reverse:
2080/// non-overlapping intervals and a material loss. A directional rejection that
2081/// does not clear both gates is inconclusive and therefore keeps the model.
2082fn compare_opening_model_rates(
2083 baseline: RateEstimate,
2084 achieved: RateEstimate,
2085 tolerance: f64,
2086) -> RatificationOutcome {
2087 match compare_rates(baseline, achieved, tolerance) {
2088 RatificationOutcome::Regressed
2089 if baseline.lower() > achieved.upper()
2090 && achieved.mean < baseline.mean * (1.0 - tolerance) =>
2091 {
2092 RatificationOutcome::Regressed
2093 }
2094 RatificationOutcome::Regressed => RatificationOutcome::Inconclusive,
2095 outcome => outcome,
2096 }
2097}
2098
2099fn empirical_cap_confirms_target(model: Option<Model>, target: usize) -> bool {
2100 model.is_some_and(|model| {
2101 model.useful_cap <= target && model.useful_cap_reason != ProducerCapReason::None
2102 })
2103}
2104
2105/// A nonlinear probe has to be both statistically and materially better.
2106///
2107/// Applying the materiality tolerance to the baseline confidence endpoint
2108/// double-counts uncertainty: two already-disjoint 95% intervals can fail only
2109/// because the noisier endpoint is inflated a second time. Keep the questions
2110/// separate instead: intervals must not overlap, and means must differ by the
2111/// configured practical threshold.
2112fn nonlinear_probe_improved(
2113 baseline: RateEstimate,
2114 achieved: RateEstimate,
2115 tolerance: f64,
2116) -> bool {
2117 achieved.lower() > baseline.upper() && achieved.mean > baseline.mean * (1.0 + tolerance)
2118}
2119
2120impl RateEstimate {
2121 fn from_rates(rates: impl Iterator<Item = f64>) -> Self {
2122 let rates: Vec<_> = rates.filter(|rate| rate.is_finite()).collect();
2123 if rates.is_empty() {
2124 return Self::default();
2125 }
2126 let mean = rates.iter().sum::<f64>() / rates.len() as f64;
2127 let half_width = if rates.len() > 1 {
2128 let variance = rates.iter().map(|rate| (rate - mean).powi(2)).sum::<f64>()
2129 / (rates.len() - 1) as f64;
2130 1.96 * variance.sqrt() / (rates.len() as f64).sqrt()
2131 } else {
2132 f64::INFINITY
2133 };
2134 Self {
2135 mean,
2136 half_width,
2137 samples: rates.len(),
2138 zero_samples: rates.iter().filter(|rate| **rate == 0.0).count(),
2139 }
2140 }
2141
2142 fn lower(self) -> f64 {
2143 (self.mean - self.half_width).max(0.0)
2144 }
2145
2146 fn upper(self) -> f64 {
2147 self.mean + self.half_width
2148 }
2149}
2150
2151/// Smoothed busy time for the two stages over the recent windows.
2152///
2153/// Sums rather than an average of per-window ratios, so a window in which the
2154/// pipeline barely moved contributes proportionally rather than equally.
2155struct Costs {
2156 ring: std::collections::VecDeque<(u64, u64)>,
2157 capacity: usize,
2158}
2159
2160struct Solved {
2161 target: usize,
2162 snapshot: Model,
2163}
2164
2165impl Costs {
2166 fn new(windows: usize) -> Self {
2167 let capacity = windows.max(1);
2168 Self {
2169 ring: std::collections::VecDeque::with_capacity(capacity),
2170 capacity,
2171 }
2172 }
2173
2174 fn push(&mut self, consumer_busy: u64, producer_busy: u64) {
2175 if self.ring.len() == self.capacity {
2176 self.ring.pop_front();
2177 }
2178 self.ring.push_back((consumer_busy, producer_busy));
2179 }
2180
2181 fn clear(&mut self) {
2182 self.ring.clear();
2183 }
2184
2185 /// The closed-form split.
2186 ///
2187 /// Writing `s_p` and `s_c` for the two stages' per-item costs, a budget `N`
2188 /// divided into `d` producer threads and `N - d` consumer threads finishes
2189 /// both sides together when `d / s_p = (N - d) / s_c`, whose solution is
2190 ///
2191 /// ```text
2192 /// d* = N * s_p / (s_p + s_c)
2193 /// ```
2194 ///
2195 /// Sanity check the direction: as the producer gets more expensive per item,
2196 /// `d*` tends to `N`, which is right.
2197 ///
2198 /// # Why the busy times can be substituted directly
2199 ///
2200 /// Over a window in which the pipeline moved `X` items, the producer spent
2201 /// `X * s_p` thread-nanoseconds and the consumer `X * s_c`. So
2202 ///
2203 /// ```text
2204 /// busy_p / (busy_p + busy_c) = s_p / (s_p + s_c)
2205 /// ```
2206 ///
2207 /// exactly — the `X` cancels, and with it any need for the two stages to
2208 /// count work in the same unit. In particular a run mixing compressed and
2209 /// uncompressed inputs is handled correctly for free: the producer simply
2210 /// does no work for the uncompressed ones, its busy time is proportionally
2211 /// smaller, and the split follows.
2212 ///
2213 /// # The assumption in that step
2214 ///
2215 /// "The same `X`" is exact only in steady state. Over a window in which the
2216 /// buffer between the stages is filling or draining, the two sides handled
2217 /// *different* numbers of items and the estimate is biased by the
2218 /// difference. Three things bound it — the estimate is summed over
2219 /// `smoothing_windows` rather than taken from one; a move is followed by a
2220 /// blackout long enough for the buffer to re-level; and a window in which
2221 /// the pipeline made no progress is discarded outright by
2222 /// [`usable_window`] — but none of them is a proof, and a workload whose
2223 /// compressibility varies in long runs is where to look first if the split
2224 /// comes out wrong.
2225 ///
2226 /// This is solvable at all only because busy time excludes blocking. Wall
2227 /// time would make both sides a function of `d`, and the equation would say
2228 /// nothing. DS2 (Kalavri et al., OSDI'18) calls this the *true* rate as
2229 /// against the *observed* rate, and section 2 of that paper is a catalogue
2230 /// of the controllers that go wrong by using the latter.
2231 fn solve(&self, budget: usize, cfg: &crate::BrokerConfig, caps: &Caps) -> Option<Solved> {
2232 let (consumer, producer) = self
2233 .ring
2234 .iter()
2235 .fold((0u64, 0u64), |(c, p), (dc, dp)| (c + dc, p + dp));
2236 let total = consumer.checked_add(producer)?;
2237 if total == 0 {
2238 return None;
2239 }
2240
2241 let share = producer as f64 / total as f64;
2242 let window_shares: Vec<_> = self
2243 .ring
2244 .iter()
2245 .filter_map(|(consumer, producer)| {
2246 let total = consumer.saturating_add(*producer);
2247 (total > 0).then_some(*producer as f64 / total as f64)
2248 })
2249 .collect();
2250 let uncertainty = if window_shares.len() > 1 {
2251 let variance = window_shares
2252 .iter()
2253 .map(|sample| (sample - share).powi(2))
2254 .sum::<f64>()
2255 / (window_shares.len() - 1) as f64;
2256 (1.96 * variance.sqrt() / (window_shares.len() as f64).sqrt()).clamp(0.0, 0.5)
2257 } else {
2258 0.5
2259 };
2260 let ideal = (budget as f64 * share).round().max(1.0) as usize;
2261
2262 let lo = cfg.min_producer_slots.max(1);
2263 let hi = budget.saturating_sub(cfg.min_consumer_threads).max(lo);
2264 let (useful, useful_cap_reason) = caps.useful();
2265 let target = ideal.clamp(lo, hi).min(useful.max(lo));
2266 let effective_deadband = cfg
2267 .deadband_threads
2268 .max((budget as f64 * uncertainty).ceil() as usize);
2269 let effective_resurvey = cfg
2270 .resurvey_distance
2271 .max((budget as f64 * uncertainty * 2.0).ceil() as usize)
2272 .max(effective_deadband);
2273
2274 Some(Solved {
2275 target,
2276 snapshot: Model {
2277 producer_cost_share: share,
2278 producer_cost_share_uncertainty: uncertainty,
2279 ideal_producer_slots: ideal,
2280 useful_cap: useful,
2281 useful_cap_reason,
2282 effective_deadband_threads: effective_deadband,
2283 effective_resurvey_distance: effective_resurvey,
2284 },
2285 })
2286 }
2287}
2288
2289/// The empirical ceiling on producer concurrency.
2290///
2291/// The model assumes a stage can use every thread it is given. A producer
2292/// reading from a saturated disk cannot, and the model has no way to know that:
2293/// the cost share says how much *work* decoding is, not whether there is
2294/// anything to decode it from. Left uncapped, the solver buys slots that sit
2295/// idle — which is FLINK-31215, where a non-scalable bottleneck upstream leads
2296/// the autoscaler to keep scaling a stage that cannot go any faster.
2297///
2298/// Two independent observations bound it, and both are one-sided: a cap is only
2299/// ever recorded when the producer *demonstrably* failed to use what it had.
2300///
2301/// # Why it is a recent window and not a running minimum
2302///
2303/// A running minimum never recovers. One quiet stretch — a between-files lull,
2304/// a stall downstream — would pin the ceiling for the rest of the run, and the
2305/// broker could not buy decode concurrency again however badly the next file
2306/// needed it. Bounding it to a recent window makes the cap self-healing, at the
2307/// cost of forgetting a constraint that has not been observed for a few seconds,
2308/// which is the right trade: a constraint that still holds will be observed
2309/// again almost immediately.
2310struct Caps {
2311 history: Duration,
2312 persistence: Duration,
2313 slack_for: Duration,
2314 slack_age: Duration,
2315 slack_peak: f64,
2316 slack_limit: Option<usize>,
2317 slack_candidate: Option<(usize, usize)>,
2318 slack_cap: Option<usize>,
2319 saturated_for: Duration,
2320 saturated_age: Duration,
2321 saturated_at: Option<usize>,
2322}
2323
2324impl Caps {
2325 fn new(history: Duration, persistence: Duration) -> Self {
2326 Self {
2327 history,
2328 persistence,
2329 slack_for: Duration::ZERO,
2330 slack_age: Duration::ZERO,
2331 slack_peak: 0.0,
2332 slack_limit: None,
2333 slack_candidate: None,
2334 slack_cap: None,
2335 saturated_for: Duration::ZERO,
2336 saturated_age: Duration::ZERO,
2337 saturated_at: None,
2338 }
2339 }
2340
2341 /// Observe busy-derived slack only when producer and consumer progress is
2342 /// comparable. Buffer fill/drain can otherwise make ordinary pipeline lag
2343 /// look like unused producer concurrency.
2344 fn observe_slack(&mut self, dp: Work, window: Duration, producer_limit: usize) {
2345 // Concurrency the producer actually achieved this window, in
2346 // thread-equivalents. Busy time excludes blocking, so this is work done
2347 // rather than slots held.
2348 let achieved = dp.busy_nanos as f64 / window.as_nanos().max(1) as f64;
2349 // One quiet sample is a lull, not scalability evidence. Require the
2350 // same condition continuously for a duration independent of sampling
2351 // cadence before turning it into a cap.
2352 if self.slack_limit != Some(producer_limit) {
2353 self.slack_for = Duration::ZERO;
2354 self.slack_peak = 0.0;
2355 self.slack_limit = Some(producer_limit);
2356 }
2357 if producer_limit as f64 > achieved + 1.0 {
2358 self.slack_for += window;
2359 self.slack_age = Duration::ZERO;
2360 self.slack_peak = self.slack_peak.max(achieved);
2361 if self.slack_for >= self.persistence {
2362 let observed_cap = self.slack_peak.ceil() as usize + 1;
2363 if producer_limit >= observed_cap.saturating_mul(2) {
2364 // A grant at least twice the sustained ceiling is already
2365 // a decisive exploration. This lets an initially invisible
2366 // source ceiling recover after one oversized model move.
2367 self.slack_cap = Some(observed_cap);
2368 self.slack_candidate = Some((producer_limit, observed_cap));
2369 } else {
2370 match self.slack_candidate {
2371 Some((prior_limit, prior_cap))
2372 if prior_limit != producer_limit
2373 && prior_limit > prior_cap
2374 && producer_limit > observed_cap
2375 && prior_cap.abs_diff(observed_cap) <= 1 =>
2376 {
2377 // One mildly under-filled grant can be a batching or
2378 // barrier artifact: real scRNA used more concurrency
2379 // and halved wall time when its grant grew. Comparable
2380 // ceilings at two distinct grants, each with real
2381 // headroom above its inferred ceiling, make it a
2382 // hard cap. A grant equal to the inferred cap
2383 // cannot confirm its own saturation.
2384 self.slack_cap = Some(prior_cap.max(observed_cap));
2385 }
2386 Some((prior_limit, _)) if prior_limit != producer_limit => {
2387 self.slack_cap = None;
2388 self.slack_candidate = Some((producer_limit, observed_cap));
2389 }
2390 None => {
2391 self.slack_candidate = Some((producer_limit, observed_cap));
2392 }
2393 _ => {}
2394 }
2395 }
2396 }
2397 } else {
2398 self.slack_for = Duration::ZERO;
2399 self.slack_peak = 0.0;
2400 self.slack_age += window;
2401 if self.slack_age >= self.history {
2402 self.slack_candidate = None;
2403 self.slack_cap = None;
2404 }
2405 }
2406 }
2407
2408 /// Observe the producer's direct source/elasticity classification.
2409 ///
2410 /// This evidence is independent of downstream buffer equivalence. It still
2411 /// requires duration-based persistence and expires after contradictory
2412 /// observations, so a lone source lull cannot become a lasting cap.
2413 fn observe_pressure(&mut self, window: Duration, producer_limit: usize, p: ProducerPressure) {
2414 // Cap history uses only the source/elasticity half of
2415 // `ProducerPressure`. It is a saturating signal -- it cannot tell "just
2416 // enough" from "far too much" -- so it may cap growth but must never
2417 // size the split. `Starved`'s separate bounded shrink veto lives at the
2418 // decision point, where it can retain but never increase the target.
2419 match p {
2420 ProducerPressure::SourceBound | ProducerPressure::Inelastic => {
2421 self.saturated_for += window;
2422 self.saturated_age = Duration::ZERO;
2423 if self.saturated_for >= self.persistence {
2424 self.saturated_at = Some(match self.saturated_at {
2425 Some(prev) => prev.min(producer_limit.max(1)),
2426 None => producer_limit.max(1),
2427 });
2428 }
2429 }
2430 _ => {
2431 self.saturated_for = Duration::ZERO;
2432 self.saturated_age += window;
2433 if self.saturated_age >= self.history {
2434 self.saturated_at = None;
2435 }
2436 }
2437 }
2438 }
2439
2440 #[cfg(test)]
2441 fn observe(&mut self, dp: Work, window: Duration, producer_limit: usize, p: ProducerPressure) {
2442 self.observe_slack(dp, window, producer_limit);
2443 self.observe_pressure(window, producer_limit, p);
2444 }
2445
2446 /// The largest producer limit worth asking for, or `usize::MAX` if nothing
2447 /// has been observed to bound it.
2448 fn useful(&self) -> (usize, ProducerCapReason) {
2449 let mut cap = usize::MAX;
2450
2451 // Slack: if the producer was handed more than a thread's worth of slack
2452 // and declined to use it, more slots are not what it is short of. The
2453 // ceiling is the best it managed while it had that slack, plus one so a
2454 // marginal case is not pinched.
2455 if let Some(slack) = self.slack_cap {
2456 cap = cap.min(slack);
2457 }
2458
2459 if let Some(limit) = self.saturated_at {
2460 cap = cap.min(limit);
2461 }
2462 let reason = match (self.slack_cap.is_some(), self.saturated_at.is_some()) {
2463 (false, false) => ProducerCapReason::None,
2464 (true, false) => ProducerCapReason::Slack,
2465 (false, true) => ProducerCapReason::Source,
2466 (true, true) => ProducerCapReason::SlackAndSource,
2467 };
2468 (cap, reason)
2469 }
2470}
2471
2472/// Whether a window carries enough work to draw a conclusion from.
2473///
2474/// The cost share is a ratio, and a window containing a handful of nanoseconds
2475/// of work on one side gives a ratio dominated by where the window boundaries
2476/// happened to fall relative to the counters' flush granularity. The CLR thread
2477/// pool's hill climber guards the same hazard from the other direction,
2478/// rejecting a sample window when in-flight work is large relative to completed
2479/// work.
2480///
2481/// The test is on the *total*, not on each side, so that a producer doing no
2482/// work at all is admitted rather than discarded. That case is real and needs
2483/// the right answer: a run whose inputs are already uncompressed has no decode
2484/// cost, and the honest reading of the window is a cost share of zero, which
2485/// solves to the producer floor and hands the whole budget to the consumer.
2486/// Requiring both sides to be busy would instead reject every window forever and
2487/// strand the initial guess.
2488///
2489/// Progress is required as well as busy time, because a window in which the
2490/// pipeline moved nothing says only that something is stalled, and which side
2491/// happened to be spinning during it is not evidence about their relative costs.
2492///
2493/// The floor is a twentieth of one thread-window, far below anything a working
2494/// stage produces and far above the residue of one that has stopped.
2495fn usable_window(dc: Work, dp: Work, window: Duration) -> bool {
2496 let floor = window.as_nanos() as u64 / 20;
2497 dc.items > 0 && dc.busy_nanos.saturating_add(dp.busy_nanos) > floor
2498}
2499
2500/// Whether a `Starved` producer demonstrably used the allocation a shrink would
2501/// remove. Queue state alone can remain stale while already-produced buffers
2502/// drain, so it is not enough to invoke the nonlinear shrink veto.
2503fn producer_capacity_used(
2504 active_slots: usize,
2505 requested_slots: usize,
2506 busy_nanos: u64,
2507 window: Duration,
2508) -> bool {
2509 let capacity_nanos = (window.as_nanos() as u64).saturating_mul(requested_slots as u64);
2510 active_slots >= requested_slots
2511 || busy_nanos.saturating_mul(4) >= capacity_nanos.saturating_mul(3)
2512}
2513
2514fn rate_per_second(items: u64, window: Duration) -> f64 {
2515 let secs = window.as_secs_f64();
2516 if secs <= 0.0 {
2517 return 0.0;
2518 }
2519 items as f64 / secs
2520}
2521
2522/// Duration represented by consecutive drift observations.
2523///
2524/// The first sparse steady-state probe says only that drift exists *now*; it
2525/// cannot prove that drift was present throughout the entire sleep since the
2526/// previous clean probe. Credit it with one active-cadence window. A second
2527/// consecutive observation spans real elapsed time and may then satisfy the
2528/// wall-clock persistence guard.
2529fn consecutive_drift_duration(
2530 previous: Duration,
2531 observation_window: Duration,
2532 active_interval: Duration,
2533) -> Duration {
2534 if previous.is_zero() {
2535 observation_window.min(active_interval)
2536 } else {
2537 previous.saturating_add(observation_window)
2538 }
2539}
2540
2541fn fmt_cap(cap: usize) -> String {
2542 if cap == usize::MAX {
2543 "none".into()
2544 } else {
2545 cap.to_string()
2546 }
2547}
2548
2549fn runtime_failure(
2550 kind: BrokerErrorKind,
2551 mut report: BrokerReport,
2552 requested: Split,
2553 actual: Split,
2554 phase: BrokerPhase,
2555 epoch: u64,
2556) -> BrokerError {
2557 let error = BrokerError::new(kind);
2558 report.final_consumer_threads = requested.0;
2559 report.final_producer_limit = requested.1;
2560 report.final_consumer_live = actual.0;
2561 report.final_producer_active = actual.1;
2562 report.final_phase = phase;
2563 report.final_epoch = epoch;
2564 report.terminal_error = Some(error.to_string());
2565 error.with_report(report)
2566}
2567
2568struct PhaseName<'a>(&'a Phase);
2569
2570impl std::fmt::Debug for PhaseName<'_> {
2571 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2572 f.write_str(match self.0 {
2573 Phase::Survey => "survey",
2574 Phase::Draining { .. } => "draining",
2575 Phase::Blackout { .. } => "blackout",
2576 Phase::OpeningBracketRestore { .. } => "opening-bracket-restore",
2577 Phase::Ratify { .. } => "ratify",
2578 Phase::Steady { .. } => "steady",
2579 })
2580 }
2581}
2582
2583#[cfg(test)]
2584mod tests {
2585 use super::{
2586 BrokerPhase, BrokerReport, Caps, Costs, Model, Phase, ProducerCapReason, RateEstimate,
2587 RatificationKind, RatificationOutcome, Recent, compare_opening_model_rates, compare_rates,
2588 consecutive_drift_duration, duration_samples, empirical_cap_confirms_target,
2589 nonlinear_probe_improved, opening_bracket_targets, producer_capacity_used,
2590 retained_bracket_outcome,
2591 };
2592 use crate::{
2593 BrokerConfig, OpeningBracketOutcome, ProducerMeasurementMode, ProducerPressure, ResizeSide,
2594 Work,
2595 };
2596 use std::time::{Duration, Instant};
2597
2598 struct Noise(u64);
2599
2600 impl Noise {
2601 fn new(seed: u64) -> Self {
2602 Self(seed.max(1))
2603 }
2604
2605 fn uniform(&mut self) -> f64 {
2606 self.0 = self
2607 .0
2608 .wrapping_mul(6_364_136_223_846_793_005)
2609 .wrapping_add(1);
2610 (self.0 >> 11) as f64 / (1u64 << 53) as f64
2611 }
2612
2613 /// Irwin-Hall approximation to a standard normal. It is deterministic,
2614 /// portable, and sufficient for controller acceptance simulations.
2615 fn normal(&mut self) -> f64 {
2616 (0..12).map(|_| self.uniform()).sum::<f64>() - 6.0
2617 }
2618 }
2619
2620 fn estimate(rate: f64, relative_sigma: f64, noise: &mut Noise) -> RateEstimate {
2621 RateEstimate::from_rates((0..4).map(|_| rate * (1.0 + relative_sigma * noise.normal())))
2622 }
2623
2624 fn push_noisy_share(costs: &mut Costs, share: f64, noise: &mut Noise) {
2625 let observed = (share + 0.005 * noise.normal()).clamp(0.001, 0.999);
2626 let total = 1_000_000.0;
2627 costs.push(((1.0 - observed) * total) as u64, (observed * total) as u64);
2628 }
2629
2630 fn work_at(concurrency: usize, window: Duration) -> Work {
2631 Work {
2632 busy_nanos: (window.as_nanos() as u64).saturating_mul(concurrency as u64),
2633 items: 1,
2634 }
2635 }
2636
2637 #[test]
2638 fn one_slack_lull_does_not_create_a_cap() {
2639 let mut caps = Caps::new(Duration::from_secs(2), Duration::from_millis(300));
2640 let window = Duration::from_millis(100);
2641 caps.observe(work_at(2, window), window, 8, ProducerPressure::Satisfied);
2642 assert_eq!(caps.useful(), (usize::MAX, ProducerCapReason::None));
2643 }
2644
2645 #[test]
2646 fn cap_confidence_uses_duration_and_expires_by_duration() {
2647 for window in [Duration::from_millis(25), Duration::from_millis(100)] {
2648 let mut caps = Caps::new(Duration::from_secs(1), Duration::from_millis(300));
2649 let samples = Duration::from_millis(300)
2650 .as_nanos()
2651 .div_ceil(window.as_nanos());
2652 for _ in 0..samples {
2653 caps.observe(work_at(6, window), window, 8, ProducerPressure::Satisfied);
2654 }
2655 assert_eq!(
2656 caps.useful(),
2657 (usize::MAX, ProducerCapReason::None),
2658 "one grant must remain provisional"
2659 );
2660
2661 for _ in 0..samples {
2662 caps.observe(work_at(6, window), window, 12, ProducerPressure::Satisfied);
2663 }
2664 assert_eq!(caps.useful(), (7, ProducerCapReason::Slack));
2665
2666 let expiry = Duration::from_secs(1)
2667 .as_nanos()
2668 .div_ceil(window.as_nanos());
2669 for _ in 0..expiry {
2670 caps.observe(work_at(12, window), window, 12, ProducerPressure::Starved);
2671 }
2672 assert_eq!(caps.useful(), (usize::MAX, ProducerCapReason::None));
2673 }
2674 }
2675
2676 #[test]
2677 fn adjacent_grant_cannot_confirm_its_own_inferred_cap() {
2678 let mut caps = Caps::new(Duration::from_secs(1), Duration::from_millis(300));
2679 let window = Duration::from_millis(100);
2680 for _ in 0..3 {
2681 caps.observe(work_at(6, window), window, 8, ProducerPressure::Satisfied);
2682 }
2683 assert_eq!(caps.useful(), (usize::MAX, ProducerCapReason::None));
2684 for _ in 0..3 {
2685 caps.observe(work_at(6, window), window, 7, ProducerPressure::Satisfied);
2686 }
2687 assert_eq!(
2688 caps.useful(),
2689 (usize::MAX, ProducerCapReason::None),
2690 "a grant equal to the inferred cap has no headroom to confirm saturation"
2691 );
2692 }
2693
2694 #[test]
2695 fn source_caps_arrive_and_lull_caps_recover_within_one_second() {
2696 for window in [Duration::from_millis(25), Duration::from_millis(100)] {
2697 let mut source = Caps::new(Duration::from_millis(800), Duration::from_millis(300));
2698 let evidence = Duration::from_millis(300)
2699 .as_nanos()
2700 .div_ceil(window.as_nanos());
2701 for _ in 0..evidence {
2702 source.observe(work_at(8, window), window, 8, ProducerPressure::SourceBound);
2703 }
2704 assert_eq!(source.useful(), (8, ProducerCapReason::Source));
2705
2706 let mut lull = Caps::new(Duration::from_millis(800), Duration::from_millis(300));
2707 let lull_samples = Duration::from_secs(1)
2708 .as_nanos()
2709 .div_ceil(window.as_nanos());
2710 for _ in 0..lull_samples {
2711 lull.observe(work_at(0, window), window, 8, ProducerPressure::Satisfied);
2712 }
2713 assert_eq!(lull.useful(), (1, ProducerCapReason::Slack));
2714
2715 let recovery_samples = Duration::from_millis(800)
2716 .as_nanos()
2717 .div_ceil(window.as_nanos());
2718 for _ in 0..recovery_samples {
2719 lull.observe(work_at(8, window), window, 8, ProducerPressure::Starved);
2720 }
2721 assert_eq!(
2722 lull.useful(),
2723 (usize::MAX, ProducerCapReason::None),
2724 "{window:?} sampling retained a lull cap after 800 ms of contrary evidence"
2725 );
2726 }
2727 }
2728
2729 #[test]
2730 fn direct_pressure_cap_does_not_wait_for_flow_stability() {
2731 let mut caps = Caps::new(Duration::from_millis(800), Duration::from_millis(300));
2732 for _ in 0..3 {
2733 caps.observe_pressure(Duration::from_millis(100), 2, ProducerPressure::Inelastic);
2734 }
2735 assert_eq!(caps.useful(), (2, ProducerCapReason::Source));
2736 }
2737
2738 #[test]
2739 fn interval_ratification_meets_seeded_error_gates() {
2740 let mut false_rejections = 0;
2741 let mut regressions_detected = 0;
2742 for seed in 1..=1_000 {
2743 let mut noise = Noise::new(seed);
2744 let baseline = estimate(100.0, 0.02, &mut noise);
2745 let equal = estimate(100.0, 0.02, &mut noise);
2746 false_rejections +=
2747 usize::from(compare_rates(baseline, equal, 0.05) == RatificationOutcome::Regressed);
2748
2749 let baseline = estimate(100.0, 0.02, &mut noise);
2750 let regressed = estimate(90.0, 0.02, &mut noise);
2751 regressions_detected += usize::from(
2752 compare_rates(baseline, regressed, 0.05) == RatificationOutcome::Regressed,
2753 );
2754 }
2755 assert!(
2756 false_rejections < 10,
2757 "{false_rejections}/1000 equal-rate traces were confidently rejected"
2758 );
2759 assert!(
2760 regressions_detected >= 950,
2761 "only {regressions_detected}/1000 ten-percent regressions were detected"
2762 );
2763 }
2764
2765 #[test]
2766 fn uncertainty_hysteresis_meets_seeded_budget_gates() {
2767 let cfg = BrokerConfig::default();
2768 let caps = Caps::new(cfg.cap_history, cfg.cap_persistence);
2769
2770 for budget in [2usize, 4, 8, 16, 32, 64, 96] {
2771 let base_target = (budget / 3).clamp(1, budget - 1);
2772 let base_share = base_target as f64 / budget as f64;
2773 let mut stable_runs_without_resurvey = 0;
2774 let mut shifts_detected = 0;
2775
2776 for seed in 1..=1_000 {
2777 let mut noise = Noise::new(seed ^ ((budget as u64) << 32));
2778 let mut costs = Costs::new(cfg.smoothing_windows);
2779 let mut drifted_for = Duration::ZERO;
2780 let mut false_resurvey = false;
2781 for _ in 0..300 {
2782 push_noisy_share(&mut costs, base_share, &mut noise);
2783 if let Some(solved) = costs.solve(budget, &cfg, &caps) {
2784 if solved.target.abs_diff(base_target)
2785 >= solved.snapshot.effective_resurvey_distance
2786 {
2787 drifted_for += Duration::from_millis(100);
2788 false_resurvey |= drifted_for >= cfg.resurvey_persistence;
2789 } else {
2790 drifted_for = Duration::ZERO;
2791 }
2792 }
2793 }
2794 stable_runs_without_resurvey += usize::from(!false_resurvey);
2795
2796 // A two-slot budget has only one legal split, so there is no
2797 // allocation change for any controller to detect.
2798 if budget > 2 {
2799 let shift = ((budget as f64 * 0.10).ceil() as usize).max(1);
2800 let changed_target = (base_target + shift).min(budget - 1);
2801 let changed_share = changed_target as f64 / budget as f64;
2802 drifted_for = Duration::ZERO;
2803 let mut detected = false;
2804 for _ in 0..20 {
2805 push_noisy_share(&mut costs, changed_share, &mut noise);
2806 if let Some(solved) = costs.solve(budget, &cfg, &caps) {
2807 if solved.target.abs_diff(base_target)
2808 >= solved.snapshot.effective_resurvey_distance
2809 {
2810 drifted_for += Duration::from_millis(100);
2811 detected |= drifted_for >= cfg.resurvey_persistence;
2812 } else {
2813 drifted_for = Duration::ZERO;
2814 }
2815 }
2816 }
2817 shifts_detected += usize::from(detected);
2818 }
2819 }
2820
2821 assert!(
2822 stable_runs_without_resurvey >= 990,
2823 "budget {budget}: only {stable_runs_without_resurvey}/1000 stable 30-second traces avoided resurvey"
2824 );
2825 if budget > 2 {
2826 assert!(
2827 shifts_detected >= 990,
2828 "budget {budget}: only {shifts_detected}/1000 ten-percent shifts reopened within two seconds"
2829 );
2830 }
2831 }
2832 }
2833
2834 #[test]
2835 fn report_trace_is_bounded_and_convergence_is_terminal_state_aware() {
2836 let mut report = BrokerReport::default();
2837 for producer in 1..=10 {
2838 report.record_sample(
2839 (16 - producer, producer),
2840 (15 - producer, producer),
2841 Duration::from_millis(producer as u64),
2842 3,
2843 );
2844 }
2845 assert_eq!(report.producer_trajectory, vec![8, 9, 10]);
2846 assert_eq!(report.actual_producer_trajectory, vec![8, 9, 10]);
2847 assert_eq!(
2848 report.trajectory_elapsed,
2849 vec![
2850 Duration::from_millis(8),
2851 Duration::from_millis(9),
2852 Duration::from_millis(10),
2853 ]
2854 );
2855 assert_eq!(report.trace_samples_dropped, 7);
2856
2857 report.time_to_converge = Some(Duration::from_secs(1));
2858 for phase in [
2859 BrokerPhase::Starting,
2860 BrokerPhase::Survey,
2861 BrokerPhase::Draining,
2862 BrokerPhase::Blackout,
2863 BrokerPhase::Ratifying,
2864 ] {
2865 report.final_phase = phase;
2866 assert!(!report.converged(), "{phase:?} was labeled converged");
2867 }
2868 report.final_phase = BrokerPhase::Steady;
2869 assert!(report.converged());
2870 report.current_drift = Duration::from_millis(100);
2871 assert!(!report.converged());
2872 report.current_drift = Duration::ZERO;
2873 report.terminal_error = Some("injected".into());
2874 assert!(!report.converged());
2875 }
2876
2877 #[test]
2878 fn producer_measurement_is_dense_only_while_a_decision_is_open() {
2879 assert_eq!(
2880 Phase::Survey.measurement_mode(),
2881 ProducerMeasurementMode::Calibration
2882 );
2883 assert_eq!(
2884 Phase::Ratify {
2885 left: 1,
2886 from: (7, 1),
2887 target: 2,
2888 baseline: RateEstimate::from_rates([1.0].into_iter()),
2889 rates: Vec::new(),
2890 kind: RatificationKind::Model,
2891 }
2892 .measurement_mode(),
2893 ProducerMeasurementMode::Calibration
2894 );
2895
2896 for phase in [
2897 Phase::Draining {
2898 side: ResizeSide::Producer,
2899 to: (6, 2),
2900 started: Instant::now(),
2901 next: Box::new(Phase::Survey),
2902 },
2903 Phase::Blackout {
2904 left: 1,
2905 next: Box::new(Phase::Survey),
2906 },
2907 Phase::Steady {
2908 drifted_for: Duration::ZERO,
2909 },
2910 ] {
2911 assert_eq!(
2912 phase.measurement_mode(),
2913 ProducerMeasurementMode::Monitoring
2914 );
2915 }
2916 }
2917
2918 #[test]
2919 fn recent_throughput_keeps_zero_progress_windows() {
2920 let window = Duration::from_millis(100);
2921 let mut recent = Recent::new(3);
2922 recent.push(100, window);
2923 recent.push(0, window);
2924 recent.push(100, window);
2925
2926 let estimate = recent.estimate();
2927 assert!((estimate.mean - (2_000.0 / 3.0)).abs() < 1e-9);
2928 assert!(estimate.lower() < estimate.mean);
2929 assert!(estimate.upper() > estimate.mean);
2930 }
2931
2932 #[test]
2933 fn nonlinear_probe_separates_significance_from_materiality() {
2934 let baseline = RateEstimate {
2935 mean: 6_386.0,
2936 half_width: 3_066.0,
2937 ..RateEstimate::default()
2938 };
2939 let achieved = RateEstimate {
2940 mean: 12_453.0,
2941 half_width: 2_688.0,
2942 ..RateEstimate::default()
2943 };
2944 assert!(nonlinear_probe_improved(baseline, achieved, 0.05));
2945
2946 // A large mean gain without separated intervals is not evidence.
2947 assert!(!nonlinear_probe_improved(
2948 baseline,
2949 RateEstimate {
2950 mean: 12_000.0,
2951 half_width: 4_000.0,
2952 ..RateEstimate::default()
2953 },
2954 0.05,
2955 ));
2956 // Separated intervals with a sub-threshold mean gain are not useful.
2957 assert!(!nonlinear_probe_improved(
2958 RateEstimate {
2959 mean: 100.0,
2960 half_width: 0.1,
2961 ..RateEstimate::default()
2962 },
2963 RateEstimate {
2964 mean: 104.0,
2965 half_width: 0.1,
2966 ..RateEstimate::default()
2967 },
2968 0.05,
2969 ));
2970 }
2971
2972 #[test]
2973 fn opening_model_requires_separation_and_materiality_to_revert() {
2974 let baseline = RateEstimate {
2975 mean: 100.0,
2976 half_width: 2.0,
2977 samples: 2,
2978 ..RateEstimate::default()
2979 };
2980 assert_eq!(
2981 compare_opening_model_rates(
2982 baseline,
2983 RateEstimate {
2984 mean: 90.0,
2985 half_width: 2.0,
2986 samples: 2,
2987 ..RateEstimate::default()
2988 },
2989 0.05,
2990 ),
2991 RatificationOutcome::Regressed,
2992 "a separated, material loss must reject the model point"
2993 );
2994
2995 assert_eq!(
2996 compare_opening_model_rates(
2997 RateEstimate {
2998 mean: 100.0,
2999 half_width: 10.0,
3000 samples: 2,
3001 ..RateEstimate::default()
3002 },
3003 RateEstimate {
3004 mean: 80.0,
3005 half_width: 10.0,
3006 samples: 2,
3007 ..RateEstimate::default()
3008 },
3009 0.05,
3010 ),
3011 RatificationOutcome::Inconclusive,
3012 "touching intervals must not restore an unevidenced opening"
3013 );
3014
3015 assert_ne!(
3016 compare_opening_model_rates(
3017 RateEstimate {
3018 mean: 100.0,
3019 half_width: 0.1,
3020 samples: 2,
3021 ..RateEstimate::default()
3022 },
3023 RateEstimate {
3024 mean: 96.0,
3025 half_width: 0.1,
3026 samples: 2,
3027 ..RateEstimate::default()
3028 },
3029 0.05,
3030 ),
3031 RatificationOutcome::Regressed,
3032 "a statistically separated but immaterial loss must keep the model point"
3033 );
3034 }
3035
3036 #[test]
3037 fn bracket_without_a_proven_alternative_retains_the_model_not_the_opening() {
3038 let opening = (28, 4);
3039 let model = (31, 1);
3040 assert_eq!(
3041 retained_bracket_outcome(false, Some(model), opening),
3042 OpeningBracketOutcome::ModelSelected
3043 );
3044 assert_eq!(
3045 retained_bracket_outcome(false, Some(opening), opening),
3046 OpeningBracketOutcome::OpeningRetained
3047 );
3048 assert_eq!(
3049 retained_bracket_outcome(true, Some(model), opening),
3050 OpeningBracketOutcome::AlternativeSelected
3051 );
3052 }
3053
3054 #[test]
3055 fn empirical_cap_can_independently_confirm_the_model_target() {
3056 let model = |useful_cap, useful_cap_reason| Model {
3057 producer_cost_share: 0.0,
3058 producer_cost_share_uncertainty: 0.0,
3059 ideal_producer_slots: 1,
3060 useful_cap,
3061 useful_cap_reason,
3062 effective_deadband_threads: 1,
3063 effective_resurvey_distance: 1,
3064 };
3065 assert!(empirical_cap_confirms_target(
3066 Some(model(1, ProducerCapReason::Slack)),
3067 1
3068 ));
3069 assert!(empirical_cap_confirms_target(
3070 Some(model(1, ProducerCapReason::Source)),
3071 2
3072 ));
3073 assert!(!empirical_cap_confirms_target(
3074 Some(model(2, ProducerCapReason::Slack)),
3075 1
3076 ));
3077 assert!(!empirical_cap_confirms_target(
3078 Some(model(1, ProducerCapReason::None)),
3079 1
3080 ));
3081 assert!(!empirical_cap_confirms_target(None, 1));
3082 }
3083
3084 #[test]
3085 fn opening_bracket_horizon_rounds_up_to_whole_samples() {
3086 assert_eq!(
3087 duration_samples(Duration::from_millis(300), Duration::from_millis(100)),
3088 3
3089 );
3090 assert_eq!(
3091 duration_samples(Duration::from_millis(301), Duration::from_millis(100)),
3092 4
3093 );
3094 assert_eq!(
3095 duration_samples(Duration::from_nanos(1), Duration::from_millis(100)),
3096 1
3097 );
3098 }
3099
3100 #[test]
3101 fn opening_bracket_alternates_around_the_opening_and_is_bounded() {
3102 assert_eq!(
3103 opening_bracket_targets(32, 1, 1, 4, 1, 2)
3104 .into_iter()
3105 .collect::<Vec<_>>(),
3106 vec![5, 3]
3107 );
3108 assert_eq!(
3109 opening_bracket_targets(8, 1, 1, 4, 7, 3)
3110 .into_iter()
3111 .collect::<Vec<_>>(),
3112 vec![3, 5, 2]
3113 );
3114 assert!(opening_bracket_targets(8, 1, 1, 4, 1, 0).is_empty());
3115 assert_eq!(
3116 opening_bracket_targets(8, 1, 3, 4, 6, 4)
3117 .into_iter()
3118 .collect::<Vec<_>>(),
3119 vec![3, 5, 7],
3120 "candidate generation must preserve the configured safety floor"
3121 );
3122 }
3123
3124 #[test]
3125 fn isolated_zero_progress_cannot_create_rejection_evidence() {
3126 let baseline = RateEstimate::from_rates([100.0; 10].into_iter());
3127 let mut achieved = [100.0; 10];
3128 achieved[4] = 0.0;
3129 let achieved = RateEstimate::from_rates(achieved.into_iter());
3130 assert_eq!(
3131 compare_rates(baseline, achieved, 0.05),
3132 RatificationOutcome::Inconclusive
3133 );
3134 }
3135
3136 #[test]
3137 fn one_sparse_probe_cannot_satisfy_resurvey_persistence() {
3138 let active = Duration::from_millis(100);
3139 let sparse = Duration::from_secs(5);
3140 let first = consecutive_drift_duration(Duration::ZERO, sparse, active);
3141 assert_eq!(first, active);
3142 assert!(first < Duration::from_millis(800));
3143
3144 // A second consecutive observation really does establish that the
3145 // drift persisted across the sparse interval.
3146 assert_eq!(
3147 consecutive_drift_duration(first, sparse, active),
3148 Duration::from_millis(5_100)
3149 );
3150 }
3151
3152 #[test]
3153 fn shrink_veto_requires_evidence_that_current_capacity_was_used() {
3154 let window = Duration::from_millis(100);
3155 assert!(!producer_capacity_used(0, 2, 20_000_000, window));
3156 assert!(producer_capacity_used(2, 2, 0, window));
3157 assert!(producer_capacity_used(0, 2, 150_000_000, window));
3158 }
3159}